diff --git a/rbintegrations/extension.py b/rbintegrations/extension.py
index e6315b4e4fe2846119fed959b3fb1fc691ccfafd..243aa095d2d713140c083ef544b64d6449db169d 100644
--- a/rbintegrations/extension.py
+++ b/rbintegrations/extension.py
@@ -6,6 +6,7 @@ from reviewboard.extensions.base import Extension
 from reviewboard.extensions.hooks import IntegrationHook, URLHook
 
 from rbintegrations.circleci.integration import CircleCIIntegration
+from rbintegrations.idonethis.integration import IDoneThisIntegration
 from rbintegrations.slack.integration import SlackIntegration
 from rbintegrations.travisci.integration import TravisCIIntegration
 
@@ -21,6 +22,7 @@ class RBIntegrationsExtension(Extension):
 
     integrations = [
         CircleCIIntegration,
+        IDoneThisIntegration,
         SlackIntegration,
         TravisCIIntegration,
     ]
diff --git a/rbintegrations/idonethis/__init__.py b/rbintegrations/idonethis/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rbintegrations/idonethis/entries.py b/rbintegrations/idonethis/entries.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3b6d90f2f104e14065da111f283f309b2845f4e
--- /dev/null
+++ b/rbintegrations/idonethis/entries.py
@@ -0,0 +1,130 @@
+"""Entries that can be posted to I Done This on behalf of the user."""
+
+import re
+import string
+
+from reviewboard.admin.server import build_server_url
+
+
+# Entry types.
+REPLY_PUBLISHED = 'reply_published'
+REVIEW_REQUEST_COMPLETED = 'review_request_completed'
+REVIEW_REQUEST_DISCARDED = 'review_request_discarded'
+REVIEW_REQUEST_PUBLISHED = 'review_request_published'
+REVIEW_REQUEST_REOPENED = 'review_request_reopened'
+REVIEW_REQUEST_UPDATED = 'review_request_updated'
+REVIEW_PUBLISHED = 'review_published'
+REVIEW_PUBLISHED_ISSUE = 'review_published_issue'
+REVIEW_PUBLISHED_ISSUES = 'review_published_issues'
+REVIEW_PUBLISHED_SHIPIT = 'review_published_shipit'
+REVIEW_PUBLISHED_SHIPIT_ISSUE = 'review_published_shipit_issue'
+REVIEW_PUBLISHED_SHIPIT_ISSUES = 'review_published_shipit_issues'
+
+
+# Default template strings for each entry type. Should not be translated.
+default_template_strings = {
+    REPLY_PUBLISHED:
+        'Replied to review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_REQUEST_COMPLETED:
+        'Completed review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_REQUEST_DISCARDED:
+        'Discarded review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_REQUEST_PUBLISHED:
+        'Published review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_REQUEST_REOPENED:
+        'Reopened review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_REQUEST_UPDATED:
+        'Updated review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED:
+        'Posted review on review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED_ISSUE:
+        'Posted review (1 issue) on review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED_ISSUES:
+        'Posted review (${num_issues} issues) on review request '
+        '${review_request_id}: ${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED_SHIPIT:
+        'Posted Ship it! on review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED_SHIPIT_ISSUE:
+        'Posted Ship it! (1 issue) on review request ${review_request_id}: '
+        '${summary} ${url} ${group_tags}',
+    REVIEW_PUBLISHED_SHIPIT_ISSUES:
+        'Posted Ship it! (${num_issues} issues) on review request '
+        '${review_request_id}: ${summary} ${url} ${group_tags}',
+}
+
+
+# Tags allow only alphanumeric characters and underscore.
+INVALID_TAG_CHARS_RE = re.compile(r'\W')
+MULTIPLE_WHITESPACE_RE = re.compile(r'\s+')
+
+
+def format_template_string(template_string, num_issues=0, review_request=None,
+                           review_request_id=None, summary=None,
+                           group_tags=None, url=None):
+    """Format a template string for an I Done This entry.
+
+    Args:
+        template_string (unicode):
+            The template string to substitute arguments into.
+
+        num_issues (int, optional):
+            Number of issues opened in a review, used for ``${num_issues}``.
+
+        review_request (reviewboard.reviews.models.review_request.
+                        ReviewRequest, optional):
+            Review request used for the remaining template string arguments.
+            Any arguments provided separately will override the review request.
+
+        review_request_id (int, optional):
+            Review request ID, used for ``${review_request_id}``.
+
+        summary (unicode, optional):
+            Review request summary, used for ``${summary}``.
+
+        group_tags (unicode, optional):
+            Reviewer group names as #tags, used for ``${group_tags}``.
+
+        url (unicode, optional):
+            URL for the review request, review, or reply, used for ``${url}``.
+
+    Returns:
+        unicode:
+        Template string with replaced arguments and cleaned whitespace.
+
+    Raises:
+        ValueError:
+            Raised if the template string is invalid.
+
+        KeyError:
+            Raised if the template string contains unrecognized arguments.
+    """
+    if review_request:
+        review_request_id = review_request_id or review_request.display_id
+        summary = summary or review_request.summary
+        url = url or build_server_url(review_request.get_absolute_url())
+
+        if not group_tags:
+            # Tags allow only alphanumeric characters and underscore.
+            group_names = review_request.target_groups.values_list('name',
+                                                                   flat=True)
+            group_tags = ' '.join(
+                '#%s' % INVALID_TAG_CHARS_RE.sub('_', group_name)
+                for group_name in group_names)
+
+    result = string.Template(template_string).substitute(
+        num_issues=num_issues,
+        review_request_id=review_request_id,
+        summary=summary,
+        group_tags=group_tags,
+        url=url)
+
+    return MULTIPLE_WHITESPACE_RE.sub(' ', result).strip()
diff --git a/rbintegrations/idonethis/forms.py b/rbintegrations/idonethis/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..bf31114e5749457922ede8b001ff5f4d0adc3ca1
--- /dev/null
+++ b/rbintegrations/idonethis/forms.py
@@ -0,0 +1,180 @@
+"""Forms for I Done This integration."""
+
+from __future__ import unicode_literals
+
+import logging
+
+from django import forms
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.six.moves.urllib.request import urlopen
+from django.utils.translation import ugettext, ugettext_lazy as _
+from djblets.forms.fields import ConditionsField
+from reviewboard.accounts.forms.pages import AccountPageForm
+from reviewboard.integrations.forms import IntegrationConfigForm
+from reviewboard.reviews.conditions import ReviewRequestConditionChoices
+from reviewboard.scmtools.crypto_utils import encrypt_password
+
+from rbintegrations.idonethis.utils import (create_idonethis_request,
+                                            delete_cached_user_team_ids,
+                                            get_user_api_token)
+
+
+class IDoneThisIntegrationConfigForm(IntegrationConfigForm):
+    """Admin configuration form for I Done This.
+
+    This allows an administrator to set up a configuration for posting 'done'
+    entries to a given I Done This team based on the specified conditions.
+    """
+
+    conditions = ConditionsField(ReviewRequestConditionChoices,
+                                 label=_('Conditions'))
+
+    team_id = forms.CharField(
+        label=_('Team ID'),
+        required=True,
+        help_text=_('The identifier of the team to receive posts. This can '
+                    'be found at the end of the team URL, e.g. '
+                    '<code>https://beta.idonethis.com/t/'
+                    '<strong>123456abcdef</strong></code>'),
+        widget=forms.TextInput(attrs={
+            'size': 15,
+        }))
+
+    def clean_team_id(self):
+        """Clean and validate the 'team_id' field.
+
+        Returns:
+            unicode:
+            Team ID with leading and trailing whitespace removed.
+
+        Raises:
+            django.core.exceptions.ValidationError:
+                Raised if the team ID contains any slashes.
+        """
+        team_id = self.cleaned_data['team_id'].strip()
+
+        if '/' in team_id:
+            raise forms.ValidationError(
+                ugettext('Team ID cannot contain slashes.'))
+
+        return team_id
+
+    class Meta:
+        fieldsets = (
+            (_('What to Post'), {
+                'description': _(
+                    'You can choose which review request activity would be '
+                    'posted by selecting the conditions to match.'
+                ),
+                'fields': ('conditions',),
+            }),
+            (_('Where to Post'), {
+                'description': _(
+                    'Posts are made to the specified I Done This team on '
+                    'behalf of individual users who belong to that team. '
+                    'A separate configuration is required for each team, '
+                    'and multiple configurations may use the same team to '
+                    'specify alternative sets of conditions.\n'
+                    'To enable posting, each user has to provide their '
+                    'personal I Done This API Token on their Review Board '
+                    'account page.'
+                ),
+                'fields': ('team_id',),
+                'classes': ('wide',)
+            }),
+        )
+
+
+class IDoneThisIntegrationAccountPageForm(AccountPageForm):
+    """User account page form for I Done This.
+
+    This allows a user to specify their I Done This API Token and choose
+    which actions result in posting of 'done' entries to I Done This.
+    """
+
+    form_id = 'idonethis_account_page_form'
+    form_title = _('I Done This')
+    template_name = 'rbintegrations/idonethis/account_page_form.html'
+
+    idonethis_api_token = forms.CharField(
+        label=_('API Token'),
+        required=False,
+        widget=forms.TextInput(attrs={
+            'size': 45,
+        }))
+
+    def clean_idonethis_api_token(self):
+        """Clean and validate the 'idonethis_api_token' field.
+
+        This performs a test against the I Done This authentication test
+        endpoint to ensure that the provided API token is valid. We only care
+        if the request is successful, so we ignore the returned user data.
+
+        Returns:
+            unicode:
+            Validated API token with leading and trailing whitespace removed,
+            or an empty string if the API token is empty.
+
+        Raises:
+            django.core.exceptions.ValidationError:
+                Raised if the API token validation fails.
+        """
+        api_token = self.cleaned_data['idonethis_api_token'].strip()
+
+        if not api_token:
+            return ''
+
+        request = create_idonethis_request('noop', api_token)
+        logging.debug('IDoneThis: Validating API token for user "%s", '
+                      'request "%s %s"',
+                      self.user.username,
+                      request.get_method(),
+                      request.get_full_url())
+
+        try:
+            urlopen(request)
+        except (HTTPError, URLError) as e:
+            if isinstance(e, HTTPError):
+                error_info = '%s, error data: %s' % (e, e.read())
+            else:
+                error_info = e.reason
+
+            logging.error('IDoneThis: Failed to validate API token for user '
+                          '"%s", request "%s %s": %s',
+                          self.user.username,
+                          request.get_method(),
+                          request.get_full_url(),
+                          error_info)
+
+            raise forms.ValidationError(
+                ugettext('Error validating the API Token. Make sure the token '
+                         'matches your I Done This Account Settings.'))
+
+        return api_token
+
+    def load(self):
+        """Load the account page form."""
+        self.set_initial({
+            'idonethis_api_token': get_user_api_token(self.user),
+        })
+
+    def save(self):
+        """Save the account page form.
+
+        Stores an encrypted version of the API token.
+        """
+        api_token = self.cleaned_data['idonethis_api_token']
+        settings = self.profile.settings.setdefault('idonethis', {})
+
+        if api_token:
+            logging.debug('IDoneThis: Saving API token for user "%s"',
+                          self.user.username)
+            settings['api_token'] = encrypt_password(api_token)
+        elif 'api_token' in settings:
+            logging.debug('IDoneThis: Deleting API token for user "%s"',
+                          self.user.username)
+            del settings['api_token']
+
+        self.profile.save()
+
+        delete_cached_user_team_ids(self.user)
diff --git a/rbintegrations/idonethis/integration.py b/rbintegrations/idonethis/integration.py
new file mode 100644
index 0000000000000000000000000000000000000000..17b0da45a4577909c1f2dc1d17996efe0ce792eb
--- /dev/null
+++ b/rbintegrations/idonethis/integration.py
@@ -0,0 +1,353 @@
+"""Integration with I Done This."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+
+from django.utils.functional import cached_property
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.six.moves.urllib.request import urlopen
+from django.utils.translation import ugettext_lazy as _
+from reviewboard.admin.server import build_server_url
+from reviewboard.extensions.hooks import AccountPagesHook, SignalHook
+from reviewboard.integrations import Integration
+from reviewboard.reviews.models import BaseComment, ReviewRequest
+from reviewboard.reviews.signals import (review_request_closed,
+                                         review_request_published,
+                                         review_request_reopened,
+                                         review_published,
+                                         reply_published)
+
+from rbintegrations.idonethis import entries
+from rbintegrations.idonethis.forms import IDoneThisIntegrationConfigForm
+from rbintegrations.idonethis.pages import IDoneThisIntegrationAccountPage
+from rbintegrations.idonethis.utils import (create_idonethis_request,
+                                            get_user_api_token,
+                                            get_user_team_ids)
+
+
+class IDoneThisIntegration(Integration):
+    """Integrates Review Board with I Done This.
+
+    This integration allows posting 'done' entries to I Done This teams on
+    behalf of users when they publish, change, or close a review request, and
+    when they publish reviews or replies.
+
+    I Done This entries are plain text, with #tags (used for group names)
+    and @mentions (unused; only I Done This admins can configure them).
+    URLs inside entries shown on the I Done This website are also
+    automatically linked with truncated URL text.
+    """
+
+    name = _('I Done This')
+    description = _(
+        'Posts on behalf of users to I Done This teams when review requests '
+        'are created, updated, and reviewed.'
+    )
+
+    default_settings = {
+        'team_id': '',
+    }
+
+    config_form_cls = IDoneThisIntegrationConfigForm
+
+    def initialize(self):
+        """Initialize the integration hooks."""
+        AccountPagesHook(self, [IDoneThisIntegrationAccountPage])
+
+        hooks = (
+            (review_request_closed, self._on_review_request_closed),
+            (review_request_published, self._on_review_request_published),
+            (review_request_reopened, self._on_review_request_reopened),
+            (review_published, self._on_review_published),
+            (reply_published, self._on_reply_published),
+        )
+
+        for signal, handler in hooks:
+            SignalHook(self, signal, handler)
+
+    @cached_property
+    def icon_static_urls(self):
+        """The icons used for the integration."""
+        from rbintegrations.extension import RBIntegrationsExtension
+
+        extension = RBIntegrationsExtension.instance
+
+        return {
+            '1x': extension.get_static_url('images/idonethis/icon.png'),
+            '2x': extension.get_static_url('images/idonethis/icon@2x.png'),
+        }
+
+    def post_entry(self, entry_type, user, review_request, signal_name,
+                   url=None, num_issues=0):
+        """Post a 'done' entry to I Done This teams.
+
+        Posts the specified entry to any configured teams that the user
+        belongs to.
+
+        Args:
+            entry_type (unicode):
+                Entry type from :py:mod:`rbintegrations.idonethis.entries` to
+                post.
+
+            user (django.contrib.auth.models.User):
+                The user who updated the review request. Entries are only
+                posted if the user has specified an API token for I Done This,
+                and is a member of the configured teams.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request the notification is bound to.
+
+            signal_name (unicode):
+                The name of the signal triggering this notification.
+
+            url (unicode, optional):
+                URL to show in the entry instead of the review request URL.
+
+            num_issues (int, optional):
+                Number of issues opened in a review.
+        """
+        if not (review_request.public and user and user.is_active):
+            return
+
+        api_token = get_user_api_token(user)
+
+        if not api_token:
+            return
+
+        user_team_ids = None
+
+        for config in self.get_configs(review_request.local_site):
+            if not config.match_conditions(form_cls=self.config_form_cls,
+                                           review_request=review_request):
+                continue
+
+            # Lazy load team IDs after the first matching configuration.
+            if user_team_ids is None:
+                user_team_ids = set(get_user_team_ids(user))
+
+            if not user_team_ids:
+                # We finished posting to all of the user's teams, the request
+                # to get the teams failed, or the user is not in any team.
+                return
+
+            team_id = config.get('team_id')
+
+            if not team_id or team_id not in user_team_ids:
+                continue
+
+            # Avoid posting duplicate entries to the same team from multiple
+            # matching configurations.
+            user_team_ids.remove(team_id)
+
+            template_string = entries.default_template_strings[entry_type]
+
+            entry_body = entries.format_template_string(
+                template_string=template_string,
+                num_issues=num_issues,
+                review_request=review_request,
+                url=url)
+
+            json_payload = json.dumps({
+                'body': entry_body,
+                'team_id': team_id,
+                'status': 'done',
+                # Optional 'occurred_on' is automatically set by I Done This.
+            })
+
+            request = create_idonethis_request(request_path='entries',
+                                               api_token=api_token,
+                                               json_payload=json_payload)
+            logging.debug('IDoneThis: Posting entry "%s" for signal "%s", '
+                          'review_request ID %d, user "%s" to team "%s", '
+                          'request "%s %s"',
+                          entry_type,
+                          signal_name,
+                          review_request.pk,
+                          user.username,
+                          team_id,
+                          request.get_method(),
+                          request.get_full_url())
+
+            try:
+                urlopen(request)
+            except (HTTPError, URLError) as e:
+                # TODO: record failure in user settings and possibly notify the
+                # user on the account page so that problems can be noticed.
+                if isinstance(e, HTTPError):
+                    error_info = '%s, error data: %s' % (e, e.read())
+                else:
+                    error_info = e.reason
+
+                logging.error('IDoneThis: Failed to post entry for user "%s" '
+                              'to team "%s", request "%s %s": %s',
+                              user.username,
+                              team_id,
+                              request.get_method(),
+                              request.get_full_url(),
+                              error_info)
+
+    def _on_review_request_closed(self, user, review_request, close_type,
+                                  **kwargs):
+        """Handler for when review requests are closed.
+
+        Posts an entry to any configured I Done This teams when a review
+        request is closed.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who closed the review request.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request that was closed.
+
+            close_type (unicode):
+                The close type. Must be either ReviewRequest.DISCARDED or
+                ReviewRequest.SUBMITTED.
+
+            **kwargs (dict):
+                Additional keyword arguments passed to the handler.
+        """
+        if close_type == ReviewRequest.DISCARDED:
+            entry_type = entries.REVIEW_REQUEST_DISCARDED
+        else:
+            entry_type = entries.REVIEW_REQUEST_COMPLETED
+
+        self.post_entry(entry_type=entry_type,
+                        user=user,
+                        review_request=review_request,
+                        signal_name='review_request_closed')
+
+    def _on_review_request_published(self, user, review_request, changedesc,
+                                     **kwargs):
+        """Handler for when review requests are published.
+
+        Posts an entry to any configured I Done This teams when a review
+        request is published.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who published the review request.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request that was published.
+
+            changedesc (reviewboard.changedescs.models.ChangeDescription):
+                The change description for the update, if any.
+
+            **kwargs (dict):
+                Additional keyword arguments passed to the handler.
+        """
+        if not changedesc:
+            entry_type = entries.REVIEW_REQUEST_PUBLISHED
+        elif ('status' in changedesc.fields_changed and
+              changedesc.fields_changed['status']['new'][0] ==
+                ReviewRequest.PENDING_REVIEW):
+            entry_type = entries.REVIEW_REQUEST_REOPENED
+        else:
+            entry_type = entries.REVIEW_REQUEST_UPDATED
+
+        self.post_entry(entry_type=entry_type,
+                        user=user,
+                        review_request=review_request,
+                        signal_name='review_request_published')
+
+    def _on_review_request_reopened(self, user, review_request, **kwargs):
+        """Handler for when review requests are reopened.
+
+        Posts an entry to any configured I Done This teams when a review
+        request is reopened.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who reopened the review request.
+
+            review_request (reviewboard.reviews.models.review_request.
+                            ReviewRequest):
+                The review request that was reopened.
+
+            **kwargs (dict):
+                Additional keyword arguments passed to the handler.
+        """
+        self.post_entry(entry_type=entries.REVIEW_REQUEST_REOPENED,
+                        user=user,
+                        review_request=review_request,
+                        signal_name='review_request_reopened')
+
+    def _on_review_published(self, user, review, to_submitter_only, **kwargs):
+        """Handler for when a review is published.
+
+        Posts an entry to any configured I Done This teams when a review is
+        published.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who published the review.
+
+            review (reviewboard.reviews.models.review.Review):
+                The review that was published.
+
+            to_submitter_only (boolean):
+                Whether the review should be sent only to the review request
+                submitter.
+
+            **kwargs (dict):
+                Additional keyword arguments passed to the handler.
+        """
+        if to_submitter_only:
+            return
+
+        num_issues = 0
+
+        for comment in review.get_all_comments():
+            if (comment.issue_opened and
+                comment.issue_status == BaseComment.OPEN):
+                num_issues += 1
+
+        if review.ship_it:
+            if num_issues == 0:
+                entry_type = entries.REVIEW_PUBLISHED_SHIPIT
+            elif num_issues == 1:
+                entry_type = entries.REVIEW_PUBLISHED_SHIPIT_ISSUE
+            else:
+                entry_type = entries.REVIEW_PUBLISHED_SHIPIT_ISSUES
+        else:
+            if num_issues == 0:
+                entry_type = entries.REVIEW_PUBLISHED
+            elif num_issues == 1:
+                entry_type = entries.REVIEW_PUBLISHED_ISSUE
+            else:
+                entry_type = entries.REVIEW_PUBLISHED_ISSUES
+
+        self.post_entry(entry_type=entry_type,
+                        user=user,
+                        review_request=review.review_request,
+                        signal_name='review_published',
+                        url=build_server_url(review.get_absolute_url()),
+                        num_issues=num_issues)
+
+    def _on_reply_published(self, user, reply, **kwargs):
+        """Handler for when a reply to a review is published.
+
+        Posts an entry to any configured I Done This teams when a reply to a
+        review is published.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who published the reply.
+
+            reply (reviewboard.reviews.models.review.Review):
+                The reply that was published.
+
+            **kwargs (dict):
+                Additional keyword arguments passed to the handler.
+        """
+        self.post_entry(entry_type=entries.REPLY_PUBLISHED,
+                        user=user,
+                        review_request=reply.review_request,
+                        signal_name='reply_published',
+                        url=build_server_url(reply.get_absolute_url()))
diff --git a/rbintegrations/idonethis/pages.py b/rbintegrations/idonethis/pages.py
new file mode 100644
index 0000000000000000000000000000000000000000..287f63f2d829cb9e7ec0302dfffd10ad03deb5d0
--- /dev/null
+++ b/rbintegrations/idonethis/pages.py
@@ -0,0 +1,16 @@
+"""Pages for I Done This integration."""
+
+from __future__ import unicode_literals
+
+from django.utils.translation import ugettext_lazy as _
+from reviewboard.accounts.pages import AccountPage
+
+from rbintegrations.idonethis.forms import IDoneThisIntegrationAccountPageForm
+
+
+class IDoneThisIntegrationAccountPage(AccountPage):
+    """User account page for I Done This."""
+
+    page_id = 'idonethis_account_page'
+    page_title = _('I Done This')
+    form_classes = [IDoneThisIntegrationAccountPageForm]
diff --git a/rbintegrations/idonethis/tests.py b/rbintegrations/idonethis/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d39b70e73c75203130512e82c1b48c4cdda65ba
--- /dev/null
+++ b/rbintegrations/idonethis/tests.py
@@ -0,0 +1,2023 @@
+"""Unit tests for I Done This integration."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+
+from django.contrib.auth.models import User
+from django.core.cache import cache
+from django.test import RequestFactory
+from django.utils.six.moves import cStringIO as StringIO
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.six.moves.urllib.request import urlopen
+from djblets.cache.backend import cache_memoize, make_cache_key
+from djblets.conditions import ConditionSet, Condition
+from djblets.testing.decorators import add_fixtures
+from reviewboard.reviews.conditions import ReviewRequestRepositoriesChoice
+from reviewboard.reviews.models import ReviewRequestDraft
+from reviewboard.scmtools.crypto_utils import (decrypt_password,
+                                               encrypt_password)
+
+from rbintegrations.idonethis.forms import (
+    IDoneThisIntegrationAccountPageForm,
+    IDoneThisIntegrationConfigForm)
+from rbintegrations.idonethis.integration import IDoneThisIntegration
+from rbintegrations.idonethis.utils import get_user_team_ids
+from rbintegrations.testing.testcases import IntegrationTestCase
+
+
+class IDoneThisIntegrationTests(IntegrationTestCase):
+    """Test posting of I Done This entries with review request activity."""
+
+    integration_cls = IDoneThisIntegration
+
+    fixtures = ['test_scmtools', 'test_users']
+
+    def setUp(self):
+        """Set up this test case."""
+        super(IDoneThisIntegrationTests, self).setUp()
+
+        self.user = User.objects.create_user(username='testuser')
+        self.team_ids_cache_key = make_cache_key('idonethis-team_ids-testuser')
+        self.profile = self.user.get_profile()
+        self.profile.settings['idonethis'] = {
+            'api_token': encrypt_password('tok123'),
+        }
+
+    def test_post_review_request_closed_completed(self):
+        """Testing IDoneThisIntegration posts on review request closed as
+        completed
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        request = urlopen.spy.calls[0].args[0]
+        self.assertEqual(request.get_full_url(),
+                         'https://beta.idonethis.com/api/v2/entries')
+        self.assertEqual(request.get_method(), 'POST')
+        self.assertEqual(request.get_header('Authorization'), 'Token tok123')
+        self.assertEqual(
+            json.loads(request.get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_closed_completed_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request closed as
+        completed with local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_request_closed_discarded(self):
+        """Testing IDoneThisIntegration posts on review request closed as
+        discarded
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.DISCARDED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Discarded review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_closed_discarded_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request closed as
+        discarded with local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.DISCARDED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Discarded review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_request_published_normal(self):
+        """Testing IDoneThisIntegration posts on review request published"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=False)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Published review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_published_normal_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request published with
+        local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=False)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Published review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_request_published_reopen(self):
+        """Testing IDoneThisIntegration posts on review request published after
+        discard and reopen
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review_request.close(review_request.DISCARDED)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.reopen(self.user)
+
+        # Reopened discarded review is not public until publish,
+        # so post_entry should not post.
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 2)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Reopened review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_published_reopen_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request published after
+        discard and reopen with local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        review_request.close(review_request.DISCARDED)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.reopen(self.user)
+
+        # Reopened discarded review is not public until publish,
+        # so post_entry should not post.
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 2)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Reopened review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_request_published_update(self):
+        """Testing IDoneThisIntegration posts on review request published after
+        update
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.summary = 'My new summary'
+        draft.description = 'My new description'
+        draft.save()
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Updated review request 1: My new summary '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_published_update_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request published after
+        update with local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.summary = 'My new summary'
+        draft.description = 'My new description'
+        draft.save()
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Updated review request 1: My new summary '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_request_reopened(self):
+        """Testing IDoneThisIntegration posts on review request reopened after
+        after submit
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review_request.close(review_request.SUBMITTED)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.reopen(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Reopened review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_request_reopened_with_local_site(self):
+        """Testing IDoneThisIntegration posts on review request reopened after
+        submit with local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+        review_request.local_site.users.add(self.user)
+
+        review_request.close(review_request.SUBMITTED)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.reopen(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Reopened review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/ #group',
+            })
+
+    def test_post_review_published_with_0_issues(self):
+        """Testing IDoneThisIntegration posts on review published with no open
+        issues
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_0_issues_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with no open
+        issues and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_review_published_with_1_issue(self):
+        """Testing IDoneThisIntegration posts on review published with 1 open
+        issue
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review (1 issue) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_1_issue_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with 1 open
+        issue and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review (1 issue) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_review_published_with_2_issues(self):
+        """Testing IDoneThisIntegration posts on review published with > 1 open
+        issues
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review, issue_opened=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review (2 issues) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_2_issues_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with > 1 open
+        issues and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review, issue_opened=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted review (2 issues) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_review_published_with_shipit_0_issues(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and no open issues
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_shipit_0_issues_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and no open issues and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_review_published_with_shipit_1_issue(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and 1 open issue
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! (1 issue) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_shipit_1_issue_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and 1 open issue and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! (1 issue) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_review_published_with_shipit_2_issues(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and > 1 open issues
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review, issue_opened=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! (2 issues) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/r/1/#review1 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_review_published_with_shipit_2_issues_local_site(self):
+        """Testing IDoneThisIntegration posts on review published with Ship it!
+        and > 1 open issues and local site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    ship_it=True)
+        self.create_general_comment(review, issue_opened=True)
+        self.create_general_comment(review, issue_opened=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Posted Ship it! (2 issues) on review request 1: '
+                        'Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review1 '
+                        '#group',
+            })
+
+    def test_post_reply_published(self):
+        """Testing IDoneThisIntegration posts on reply published"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    publish=True)
+        comment = self.create_general_comment(review, issue_opened=True)
+
+        reply = self.create_reply(review, user=self.user)
+        self.create_general_comment(reply, reply_to=comment)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        reply.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Replied to review request 1: Test Review Request '
+                        'http://example.com/r/1/#review2 #group',
+            })
+
+    @add_fixtures(['test_site'])
+    def test_post_reply_published_with_local_site(self):
+        """Testing IDoneThisIntegration posts on reply published with local
+        site
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            with_local_site=True,
+            local_id=1,
+            publish=True)
+        group = self.create_review_group(name='group', with_local_site=True)
+        review_request.target_groups.add(group)
+
+        review = self.create_review(review_request,
+                                    user=self.user,
+                                    publish=True)
+        comment = self.create_general_comment(review, issue_opened=True)
+
+        reply = self.create_reply(review, user=self.user)
+        self.create_general_comment(reply, reply_to=comment)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', with_local_site=True)
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        reply.publish()
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Replied to review request 1: Test Review Request '
+                        'http://example.com/s/local-site-1/r/1/#review2 '
+                        '#group',
+            })
+
+    def test_no_post_review_published_to_submitter_only(self):
+        """Testing IDoneThisIntegration doesn't post on review published to
+        submitter only
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            summary='Test Review Request',
+            publish=True)
+
+        review = self.create_review(review_request, user=self.user)
+        self.create_general_comment(review)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review.publish(to_submitter_only=True)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_no_post_without_user(self):
+        """Testing IDoneThisIntegration doesn't post without a user"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(self.integration.get_configs)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(self.integration.get_configs.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_no_post_with_inactive_user(self):
+        """Testing IDoneThisIntegration doesn't post with inactive user"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        self.user.is_active = False
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(self.integration.get_configs)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(self.integration.get_configs.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_no_post_without_api_token(self):
+        """Testing IDoneThisIntegration doesn't post without an API Token"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        self.profile.settings['idonethis'] = {}
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(self.integration.get_configs)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(self.integration.get_configs.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_no_post_without_matching_condition(self):
+        """Testing IDoneThisIntegration doesn't post without a matching
+        condition
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123', repository_operator='none')
+        self._create_config(team_id='teamABC', repository_operator='none')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(get_user_team_ids)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_no_post_without_matching_team_id(self):
+        """Testing IDoneThisIntegration doesn't post without a matching
+        team ID
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123x')
+        self._create_config(team_id='teamABCx')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(get_user_team_ids)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+    def test_try_post_with_httperror(self):
+        """Testing IDoneThisIntegration tries to post to multiple teams with
+        HTTPError
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self._create_config(team_id='teamABC')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_fake=_urlopen_raise_httperror)
+        self.spy_on(logging.error)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 2)
+        self.assertEqual(len(logging.error.spy.calls), 2)
+
+    def test_try_post_with_urlerror(self):
+        """Testing IDoneThisIntegration tries to post to multiple teams with
+        URLError
+        """
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self._create_config(team_id='teamABC')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_fake=_urlopen_raise_urlerror)
+        self.spy_on(logging.error)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 2)
+        self.assertEqual(len(logging.error.spy.calls), 2)
+
+    def test_post_to_multiple_teams(self):
+        """Testing IDoneThisIntegration posts to multiple matched teams"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self._create_config(team_id='teamABC')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(get_user_team_ids)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 2)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[1].args[0].get_data()),
+            {
+                'team_id': 'teamABC',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    def test_post_once_to_duplicate_teams(self):
+        """Testing IDoneThisIntegration posts once to duplicate teams"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='group')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self._create_config(team_id='teamABC')
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(get_user_team_ids)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 2)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[1].args[0].get_data()),
+            {
+                'team_id': 'teamABC',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #group',
+            })
+
+    def test_post_with_multiple_target_groups(self):
+        """Testing IDoneThisIntegration posts with multiple target groups"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+        group = self.create_review_group(name='groupA')
+        review_request.target_groups.add(group)
+        group = self.create_review_group(name='new group-B')
+        review_request.target_groups.add(group)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/ #groupA #new_group_B',
+            })
+
+    def test_post_without_target_groups(self):
+        """Testing IDoneThisIntegration posts without target groups"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/',
+            })
+
+    def test_post_with_extra_whitespace_removed(self):
+        """Testing IDoneThisIntegration posts with extra whitespace removed"""
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='    Test    Review     Request    ',
+            publish=True)
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(urlopen, call_original=False)
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[0].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/',
+            })
+
+    def test_post_multiple_events_with_single_team_ids_request(self):
+        """Testing IDoneThisIntegration posts multiple events with a single
+        request for the user team IDs
+        """
+        def _urlopen(request):
+            if request.get_full_url().endswith('/teams'):
+                return StringIO(json.dumps([
+                    {
+                        'hash_id': 'team123',
+                    },
+                ]))
+
+            return StringIO('')
+
+        review_request = self.create_review_request(
+            create_repository=True,
+            submitter=self.user,
+            summary='Test Review Request',
+            publish=False)
+
+        self._create_config(team_id='team123')
+        self.integration.enable_integration()
+
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+        self.spy_on(self.integration.post_entry)
+        self.spy_on(get_user_team_ids)
+        self.spy_on(urlopen, call_fake=_urlopen)
+
+        review_request.publish(self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 1)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 2)
+
+        self.assertEqual(urlopen.spy.calls[0].args[0].get_full_url(),
+                         'https://beta.idonethis.com/api/v2/teams')
+
+        self.assertIn(self.team_ids_cache_key, cache)
+        self.assertEqual(cache.get(self.team_ids_cache_key), {'team123'})
+
+        self.assertEqual(urlopen.spy.calls[1].args[0].get_full_url(),
+                         'https://beta.idonethis.com/api/v2/entries')
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[1].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Published review request 1: Test Review Request '
+                        'http://example.com/r/1/',
+            })
+
+        review_request.close(review_request.SUBMITTED, self.user)
+
+        self.assertEqual(len(self.integration.post_entry.spy.calls), 2)
+        self.assertEqual(len(get_user_team_ids.spy.calls), 2)
+        self.assertEqual(len(urlopen.spy.calls), 3)
+
+        self.assertEqual(urlopen.spy.calls[2].args[0].get_full_url(),
+                         'https://beta.idonethis.com/api/v2/entries')
+        self.assertEqual(
+            json.loads(urlopen.spy.calls[2].args[0].get_data()),
+            {
+                'team_id': 'team123',
+                'status': 'done',
+                'body': 'Completed review request 1: Test Review Request '
+                        'http://example.com/r/1/',
+            })
+
+    def _create_config(self,
+                       team_id,
+                       with_local_site=False,
+                       repository_operator='any'):
+        """Create an integration config with the given parameters.
+
+        Args:
+            team_id (unicode):
+                The team ID to post entries to.
+
+            with_local_site (boolean, optional):
+                Whether the configuration should be for a local site.
+
+            repository_operator (unicode, optional):
+                Operator for a repository choice to satisfy the condition.
+        """
+        choice = ReviewRequestRepositoriesChoice()
+
+        condition_set = ConditionSet(conditions=[
+            Condition(choice=choice,
+                      operator=choice.get_operator(repository_operator)),
+        ])
+
+        if with_local_site:
+            local_site = self.get_local_site(name=self.local_site_name)
+        else:
+            local_site = None
+
+        config = self.integration.create_config(name='Config %s' % team_id,
+                                                enabled=True,
+                                                local_site=local_site)
+        config.set('team_id', team_id)
+        config.set('conditions', condition_set.serialize())
+        config.save()
+
+
+class IDoneThisIntegrationFormTests(IntegrationTestCase):
+    """Test the admin configuration and user account page forms."""
+
+    integration_cls = IDoneThisIntegration
+
+    def setUp(self):
+        """Initialize this test case."""
+        super(IDoneThisIntegrationFormTests, self).setUp()
+
+        self.request = RequestFactory().get('test')
+        self.user = User.objects.create_user(username='testuser')
+        self.team_ids_cache_key = make_cache_key('idonethis-team_ids-testuser')
+        self.profile = self.user.get_profile()
+
+    def test_admin_clean_team_id_whitespace(self):
+        """Testing IDoneThisIntegration admin form, cleaning team ID strips
+        whitespace
+        """
+        form = IDoneThisIntegrationConfigForm(
+            integration=self.integration,
+            request=self.request,
+            data={
+                'team_id': '  team123  ',
+            })
+
+        self.spy_on(form.clean_team_id)
+
+        form.full_clean()
+
+        self.assertEqual(len(form.clean_team_id.spy.calls), 1)
+        self.assertEqual(form.cleaned_data['team_id'], 'team123')
+
+    def test_admin_clean_team_id_validation(self):
+        """Testing IDoneThisIntegration admin form, cleaning team ID containing
+        slash raises validation error
+        """
+        form = IDoneThisIntegrationConfigForm(self.integration, self.request)
+        form.cleaned_data = {
+            'team_id': 't/team123',
+        }
+
+        self.assertRaisesValidationError(
+            'Team ID cannot contain slashes.',
+            form.clean_team_id)
+
+    def test_user_clean_token_empty(self):
+        """Testing IDoneThisIntegration user form, cleaning empty API token
+        skips validation
+        """
+        form = IDoneThisIntegrationAccountPageForm(
+            page=None,
+            request=self.request,
+            user=self.user,
+            data={
+                'idonethis_api_token': '   ',
+            })
+
+        self.spy_on(urlopen)
+        self.spy_on(form.clean_idonethis_api_token)
+
+        form.full_clean()
+
+        self.assertEqual(len(urlopen.spy.calls), 0)
+        self.assertEqual(len(form.clean_idonethis_api_token.spy.calls), 1)
+
+        self.assertEqual(form.cleaned_data['idonethis_api_token'], '')
+
+    def test_user_clean_token_validation_request(self):
+        """Testing IDoneThisIntegration user form, cleaning API token strips
+        whitespace and performs validation API request
+        """
+        form = IDoneThisIntegrationAccountPageForm(
+            page=None,
+            request=self.request,
+            user=self.user,
+            data={
+                'idonethis_api_token': '  tok123  ',
+            })
+
+        self.spy_on(urlopen, call_original=False)
+        self.spy_on(logging.error)
+        self.spy_on(form.clean_idonethis_api_token)
+
+        form.full_clean()
+
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 0)
+        self.assertEqual(len(form.clean_idonethis_api_token.spy.calls), 1)
+
+        request = urlopen.spy.calls[0].args[0]
+        self.assertEqual(request.get_full_url(),
+                         'https://beta.idonethis.com/api/v2/noop')
+        self.assertEqual(request.get_method(), 'GET')
+        self.assertEqual(request.get_header('Authorization'), 'Token tok123')
+        self.assertEqual(request.get_data(), None)
+
+        self.assertEqual(form.cleaned_data['idonethis_api_token'], 'tok123')
+
+    def test_user_clean_token_validation_httperror(self):
+        """Testing IDoneThisIntegration user form, cleaning API token with
+        HTTPError raises validation error
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': 'tok123',
+        }
+
+        self.spy_on(urlopen, call_fake=_urlopen_raise_httperror)
+        self.spy_on(logging.error)
+
+        self.assertRaisesValidationError(
+            'Error validating the API Token. Make sure the token matches your '
+            'I Done This Account Settings.',
+            form.clean_idonethis_api_token)
+
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+    def test_user_clean_token_validation_urlerror(self):
+        """Testing IDoneThisIntegration user form, cleaning API token with
+        URLError raises validation error
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': 'tok123',
+        }
+
+        self.spy_on(urlopen, call_fake=_urlopen_raise_urlerror)
+        self.spy_on(logging.error)
+
+        self.assertRaisesValidationError(
+            'Error validating the API Token. Make sure the token matches your '
+            'I Done This Account Settings.',
+            form.clean_idonethis_api_token)
+
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+    def test_user_load_token_empty(self):
+        """Testing IDoneThisIntegration user form, loading empty API token
+        when not in settings
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+
+        self.spy_on(decrypt_password)
+
+        form.load()
+
+        self.assertEqual(len(decrypt_password.spy.calls), 0)
+
+        self.assertEqual(form.fields['idonethis_api_token'].initial, None)
+
+    def test_user_load_token_decrypted(self):
+        """Testing IDoneThisIntegration user form, loading decrypted API token
+        from settings
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        self.profile.settings['idonethis'] = {
+            'api_token': encrypt_password('tok123'),
+        }
+
+        self.spy_on(decrypt_password)
+
+        form.load()
+
+        self.assertEqual(len(decrypt_password.spy.calls), 1)
+
+        self.assertEqual(form.fields['idonethis_api_token'].initial, 'tok123')
+
+    def test_user_save_token_empty(self):
+        """Testing IDoneThisIntegration user form, saving empty API token
+        creates empty settings
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': '',
+        }
+
+        self.spy_on(encrypt_password)
+        self.spy_on(self.profile.save)
+
+        form.save()
+
+        self.assertEqual(len(encrypt_password.spy.calls), 0)
+        self.assertEqual(len(self.profile.save.spy.calls), 1)
+
+        self.assertEqual(self.profile.settings['idonethis'], {})
+
+    def test_user_save_token_empty_removes_old_token(self):
+        """Testing IDoneThisIntegration user form, saving empty API token
+        removes old token from settings and deletes cached team IDs
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': '',
+        }
+        self.profile.settings['idonethis'] = {
+            'api_token': encrypt_password('tok123'),
+            'other_key': 'other value',
+        }
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+
+        self.spy_on(encrypt_password)
+        self.spy_on(self.profile.save)
+
+        form.save()
+
+        self.assertEqual(len(encrypt_password.spy.calls), 0)
+        self.assertEqual(len(self.profile.save.spy.calls), 1)
+
+        self.assertEqual(
+            self.profile.settings['idonethis'],
+            {
+                'other_key': 'other value',
+            })
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+    def test_user_save_token_encrypted(self):
+        """Testing IDoneThisIntegration user form, saving encrypted API token
+        to settings
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': 'tok123',
+        }
+
+        self.spy_on(encrypt_password)
+        self.spy_on(self.profile.save)
+
+        form.save()
+
+        self.assertEqual(len(encrypt_password.spy.calls), 1)
+        self.assertEqual(len(self.profile.save.spy.calls), 1)
+
+        self.assertEqual(
+            decrypt_password(self.profile.settings['idonethis']['api_token']),
+            'tok123')
+
+    def test_user_save_token_encrypted_replaces_old_token(self):
+        """Testing IDoneThisIntegration user form, saving encrypted API token
+        replaces old token in settings and deletes cached team IDs
+        """
+        form = IDoneThisIntegrationAccountPageForm(page=None,
+                                                   request=self.request,
+                                                   user=self.user)
+        form.cleaned_data = {
+            'idonethis_api_token': 'tokABC',
+        }
+        self.profile.settings['idonethis'] = {
+            'api_token': encrypt_password('tok123'),
+            'other_key': 'other value',
+        }
+
+        cache.set(self.team_ids_cache_key, {'team123', 'teamABC'})
+
+        self.spy_on(encrypt_password)
+        self.spy_on(self.profile.save)
+
+        form.save()
+
+        self.assertEqual(len(encrypt_password.spy.calls), 1)
+        self.assertEqual(len(self.profile.save.spy.calls), 1)
+
+        self.assertEqual(
+            decrypt_password(self.profile.settings['idonethis']['api_token']),
+            'tokABC')
+        self.assertEqual(self.profile.settings['idonethis']['other_key'],
+                         'other value')
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+
+class IDoneThisIntegrationUtilTests(IntegrationTestCase):
+    """Test utility methods which are not fully covered by other tests."""
+
+    integration_cls = IDoneThisIntegration
+
+    def setUp(self):
+        """Initialize this test case."""
+        super(IDoneThisIntegrationUtilTests, self).setUp()
+
+        self.user = User.objects.create_user(username='testuser')
+        self.team_ids_cache_key = make_cache_key('idonethis-team_ids-testuser')
+        self.profile = self.user.get_profile()
+        self.profile.settings['idonethis'] = {
+            'api_token': encrypt_password('tok123'),
+        }
+
+    def test_get_user_team_ids_without_token(self):
+        """Testing IDoneThisIntegration team IDs get None without an API
+        token
+        """
+        self.profile.settings['idonethis'] = {}
+
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 0)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+        self.assertEqual(team_ids, None)
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+    def test_get_user_team_ids_from_cache(self):
+        """Testing IDoneThisIntegration team IDs get data from cache without
+        API request
+        """
+        cached_team_ids = {'team123', 'teamABC'}
+        cache.set(self.team_ids_cache_key, cached_team_ids)
+
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 0)
+
+        self.assertEqual(team_ids, cached_team_ids)
+
+    def test_get_user_team_ids_request(self):
+        """Testing IDoneThisIntegration team IDs get data from API request
+        if not in cache
+        """
+        response = json.dumps([
+            {
+                'name': 'Example Team 1',
+                'created_at': '2016-07-05T07:13:57.873Z',
+                'updated_at': '2016-07-05T07:13:57.873Z',
+                'hash_id': 'team123'
+            }, {
+                'name': 'Example Team 2',
+                'created_at': '2016-07-05T07:13:57.875Z',
+                'updated_at': '2016-07-05T07:13:57.875Z',
+                'hash_id': 'teamABC'
+            }, {
+                'name': 'Duplicate Team 1',
+                'created_at': '2016-07-05T07:13:57.877Z',
+                'updated_at': '2016-07-05T07:13:57.877Z',
+                'hash_id': 'team123'
+            }])
+
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=lambda request: StringIO(response))
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 0)
+
+        request = urlopen.spy.calls[0].args[0]
+        self.assertEqual(request.get_full_url(),
+                         'https://beta.idonethis.com/api/v2/teams')
+        self.assertEqual(request.get_method(), 'GET')
+        self.assertEqual(request.get_header('Authorization'), 'Token tok123')
+        self.assertEqual(request.get_data(), None)
+
+        self.assertEqual(team_ids, {'team123', 'teamABC'})
+        self.assertEqual(team_ids, cache.get(self.team_ids_cache_key))
+
+    def test_get_user_team_ids_request_empty_team_list(self):
+        """Testing IDoneThisIntegration team IDs get empty set if API request
+        gets empty list
+        """
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=lambda request: StringIO('[]'))
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 0)
+
+        self.assertEqual(team_ids, set())
+        self.assertEqual(team_ids, cache.get(self.team_ids_cache_key))
+
+    def test_get_user_team_ids_request_httperror(self):
+        """Testing IDoneThisIntegration team IDs get None if API request gets
+        HTTPError
+        """
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=_urlopen_raise_httperror)
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+        self.assertEqual(team_ids, None)
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+    def test_get_user_team_ids_request_urlerror(self):
+        """Testing IDoneThisIntegration team IDs get None if API request gets
+        URLError
+        """
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=_urlopen_raise_urlerror)
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+        self.assertEqual(team_ids, None)
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+    def test_get_user_team_ids_request_invalid_json(self):
+        """Testing IDoneThisIntegration team IDs get None if API request gets
+        invalid JSON
+        """
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=lambda request: StringIO('[invalid'))
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+        self.assertEqual(team_ids, None)
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+    def test_get_user_team_ids_request_invalid_team_data(self):
+        """Testing IDoneThisIntegration team IDs get None if API request gets
+        invalid team data
+        """
+        self.spy_on(cache_memoize)
+        self.spy_on(urlopen, call_fake=lambda request: StringIO('[{"a":"b"}]'))
+        self.spy_on(logging.error)
+
+        team_ids = get_user_team_ids(self.user)
+
+        self.assertEqual(len(cache_memoize.spy.calls), 1)
+        self.assertEqual(len(urlopen.spy.calls), 1)
+        self.assertEqual(len(logging.error.spy.calls), 1)
+
+        self.assertEqual(team_ids, None)
+        self.assertNotIn(self.team_ids_cache_key, cache)
+
+
+def _urlopen_raise_httperror(request):
+    """Fake urlopen that raises an HTTPError for testing.
+
+    Args:
+        request (urllib2.Request):
+            The request to open.
+
+    Raises:
+        urllib2.HTTPError:
+            The error for testing.
+    """
+    raise HTTPError(request.get_full_url(), 401, '', {}, StringIO(''))
+
+
+def _urlopen_raise_urlerror(request):
+    """Fake urlopen that raises a URLError for testing.
+
+    Args:
+        request (urllib2.Request):
+            The request to open.
+
+    Raises:
+        urllib2.URLError:
+            The error for testing.
+    """
+    raise URLError('url error')
diff --git a/rbintegrations/idonethis/utils.py b/rbintegrations/idonethis/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..658788d9f72eae6bb2cf15f99a8b2680a701794e
--- /dev/null
+++ b/rbintegrations/idonethis/utils.py
@@ -0,0 +1,152 @@
+"""Utility functions for I Done This integration."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+
+from django.core.cache import cache
+from django.utils.six.moves.urllib.error import HTTPError, URLError
+from django.utils.six.moves.urllib.request import Request, urlopen
+from djblets.cache.backend import cache_memoize, make_cache_key
+from reviewboard.scmtools.crypto_utils import decrypt_password
+
+
+IDONETHIS_API_BASE_URL = 'https://beta.idonethis.com/api/v2'
+TEAM_IDS_CACHE_EXPIRATION = 24 * 60 * 60  # 1 day
+
+
+def create_idonethis_request(request_path, api_token, json_payload=None):
+    """Create a urllib request for the I Done This API.
+
+    Args:
+        request_path (unicode):
+            The API request path, relative to the base API URL.
+
+        api_token (unicode):
+            The user's API token for authorization.
+
+        json_payload (unicode, optional):
+            JSON payload for a POST request. If this is omitted,
+            the request will be a GET.
+
+    Returns:
+        urllib2.Request:
+        The I Done This API request with the provided details.
+    """
+    url = '%s/%s' % (IDONETHIS_API_BASE_URL, request_path)
+    headers = {
+        'Authorization': 'Token %s' % api_token,
+    }
+
+    if json_payload:
+        headers['Content-Type'] = 'application/json'
+
+    return Request(url, json_payload, headers)
+
+
+def get_user_api_token(user):
+    """Return the user's API token for I Done This.
+
+    Args:
+        user (django.contrib.auth.models.User):
+            The user whose API token should be retrieved.
+
+    Returns:
+        unicode:
+        The user's API token, or ``None`` if the user has not set one.
+    """
+    try:
+        settings = user.get_profile().settings['idonethis']
+        return decrypt_password(settings['api_token'])
+    except KeyError:
+        return None
+
+
+def get_user_team_ids(user):
+    """Return a set of I Done This team IDs that the user belongs to.
+
+    Retrieves the set of teams from the I Done This API and caches it to
+    avoid excessive requests. Team membership is not expected to change
+    frequently, but the cache can be manually deleted if necessary.
+
+    Args:
+        user (django.contrib.auth.models.User):
+            The user whose cached team IDs should be retrieved.
+
+    Returns:
+        set:
+        The user's team IDs, or ``None`` if they could not be retrieved.
+    """
+    def _get_user_team_ids_uncached():
+        request = create_idonethis_request(request_path='teams',
+                                           api_token=api_token)
+        logging.debug('IDoneThis: Loading teams for user "%s", '
+                      'request "%s %s"',
+                      user.username,
+                      request.get_method(),
+                      request.get_full_url())
+
+        try:
+            teams_data = urlopen(request).read()
+        except (HTTPError, URLError) as e:
+            if isinstance(e, HTTPError):
+                error_info = '%s, error data: %s' % (e, e.read())
+            else:
+                error_info = e.reason
+
+            logging.error('IDoneThis: Failed to load teams for user "%s", '
+                          'request "%s %s": %s',
+                          user.username,
+                          request.get_method(),
+                          request.get_full_url(),
+                          error_info)
+            raise
+
+        try:
+            return set(t['hash_id'] for t in json.loads(teams_data))
+        except Exception as e:
+            logging.error('IDoneThis: Failed to parse teams for user "%s": '
+                          '%s, teams data: %s',
+                          user.username,
+                          e,
+                          teams_data)
+            raise
+
+    api_token = get_user_api_token(user)
+
+    if not api_token:
+        return None
+
+    try:
+        return cache_memoize(_make_user_team_ids_cache_key(user),
+                             _get_user_team_ids_uncached,
+                             expiration=TEAM_IDS_CACHE_EXPIRATION)
+    except Exception:
+        return None
+
+
+def delete_cached_user_team_ids(user):
+    """Delete the user's cached I Done This team IDs.
+
+    Args:
+        user (django.contrib.auth.models.User):
+            The user whose cached team IDs should be deleted.
+    """
+    logging.debug('IDoneThis: Deleting cached team IDs for user "%s"',
+                  user.username)
+    cache.delete(make_cache_key(_make_user_team_ids_cache_key(user)))
+
+
+def _make_user_team_ids_cache_key(user):
+    """Make a cache key for the user's I Done This team IDs.
+
+    Args:
+        user (django.contrib.auth.models.User):
+            The user to generate the cache key for.
+
+    Returns:
+        unicode:
+        The cache key for the user's team IDs.
+    """
+    return 'idonethis-team_ids-%s' % user.username
diff --git a/rbintegrations/static/images/idonethis/icon.png b/rbintegrations/static/images/idonethis/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..02529afcf2d2515769872361eee1fa625b542125
Binary files /dev/null and b/rbintegrations/static/images/idonethis/icon.png differ
diff --git a/rbintegrations/static/images/idonethis/icon@2x.png b/rbintegrations/static/images/idonethis/icon@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..02320d57abed1445f385bf08ca2a817a551b0bbf
Binary files /dev/null and b/rbintegrations/static/images/idonethis/icon@2x.png differ
diff --git a/rbintegrations/templates/rbintegrations/idonethis/account_page_form.html b/rbintegrations/templates/rbintegrations/idonethis/account_page_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..185d2ec673649763f2e73951834ed2653481b690
--- /dev/null
+++ b/rbintegrations/templates/rbintegrations/idonethis/account_page_form.html
@@ -0,0 +1,24 @@
+{% extends "configforms/config_page_form.html" %}
+{% load i18n %}
+
+{% block pre_fields %}
+<p>
+{%  blocktrans %}
+ Specify your personal API Token to allow Review Board to post your review
+ request activity to I Done This.
+{%  endblocktrans %}
+</p>
+<p>
+{%  blocktrans with url="https://beta.idonethis.com/u/settings" %}
+ You can find the API Token within your
+ <a target="_blank" href="{{url}}">I Done This Account Settings</a>.
+{%  endblocktrans %}
+</p>
+<p>
+{%  blocktrans %}
+ Your activity is only posted to I Done This teams you are a member of, if
+ they are configured by the Review Board administrator. You can remove the
+ token at any time to disable new posts from Review Board.
+{%  endblocktrans %}
+</p>
+{% endblock pre_fields %}
