Browse Source

feat(slack): Implement issue actions via slack

 * Implements Assign, Ignore, and Resolve (via dialog).

 * Refactor attachment generation to remove buttons when an action is
   completed on the issue from within slack.
Evan Purkhiser 7 years ago
parent
commit
9e56b8ee7b

+ 1 - 0
CHANGES

@@ -1,5 +1,6 @@
 Version 8.23 (Unreleased)
 -------------------------
+- Experiemental implementation of slack actions via a new Integrations and Identity API.
 
 Schema Changes
 ~~~~~~~~~~~~~~

+ 253 - 0
src/sentry/integrations/slack/action_endpoint.py

@@ -0,0 +1,253 @@
+from __future__ import absolute_import
+
+from django.core.urlresolvers import reverse
+
+from sentry import http, options
+from sentry.api import client
+from sentry.api.base import Endpoint
+from sentry.models import Group, Integration, Project, IdentityProvider, Identity, ApiKey
+from sentry.utils import json
+from sentry.utils.http import absolute_uri
+
+from .utils import build_attachment, logger
+
+LINK_IDENTITY_MESSAGE = "Looks like you haven't linked your Sentry account with your Slack identity yet! <{associate_url}|Link your identity now> to perform actions in Sentry through Slack."
+
+RESOLVE_SELECTOR = {
+    'label': 'Resolve issue',
+    'type': 'select',
+    'name': 'resolve_type',
+    'placeholder': 'Select the resolution target',
+    'value': 'resolved',
+    'options': [
+        {
+            'label': 'Immediately',
+            'value': 'resolved'
+        },
+        {
+            'label': 'In the next release',
+            'value': 'resolved:inNextRelease'
+        },
+        {
+            'label': 'In the current release',
+            'value': 'resolved:inCurrentRelease'
+        },
+    ],
+}
+
+
+class SlackActionEndpoint(Endpoint):
+    authentication_classes = ()
+    permission_classes = ()
+
+    def on_assign(self, request, identity, group, action):
+        assignee = action['selected_options'][0]['value']
+
+        if assignee == 'none':
+            assignee = None
+
+        self.update_group(group, identity, {'assignedTo': assignee})
+
+    def on_status(self, request, identity, group, action, data, integration):
+        status = action['value']
+
+        status_data = status.split(':', 1)
+        status = {'status': status_data[0]}
+
+        resolve_type = status_data[-1]
+
+        if resolve_type == 'inNextRelease':
+            status.update({'statusDetails': {'inNextRelease': True}})
+        elif resolve_type == 'inCurrentRelease':
+            status.update({'statusDetails': {'inRelease': 'latest'}})
+
+        self.update_group(group, identity, status)
+
+    def update_group(self, group, identity, data):
+        event_write_key = ApiKey(
+            organization=group.project.organization,
+            scope_list=['event:write'],
+        )
+
+        return client.put(
+            path='/projects/{}/{}/issues/'.format(
+                group.project.organization.slug,
+                group.project.slug,
+            ),
+            params={'id': group.id},
+            data=data,
+            user=identity.user,
+            auth=event_write_key,
+        )
+
+    def open_resolve_dialog(self, data, group, integration):
+        # XXX(epurkhiser): In order to update the original message we have to
+        # keep track of the response_url in the callback_id. Definitely hacky,
+        # but seems like there's no other solutions [1]:
+        #
+        # [1]: https://stackoverflow.com/questions/46629852/update-a-bot-message-after-responding-to-a-slack-dialog#comment80795670_46629852
+        callback_id = json.dumps({
+            'issue': group.id,
+            'orig_response_url': data['response_url'],
+        })
+
+        dialog = {
+            'callback_id': callback_id,
+            'title': u'Resolve {}'.format(group.qualified_short_id),
+            'submit_label': 'Resolve',
+            'elements': [RESOLVE_SELECTOR],
+        }
+
+        payload = {
+            'dialog': json.dumps(dialog),
+            'trigger_id': data['trigger_id'],
+            'token': integration.metadata['bot_access_token'],
+        }
+
+        session = http.build_session()
+        req = session.post('https://slack.com/api/dialog.open', data=payload)
+        resp = req.json()
+        if not resp.get('ok'):
+            logger.error('slack.action.response-error', extra={
+                'error': resp.get('error'),
+            })
+
+    def post(self, request):
+        logging_data = {}
+
+        try:
+            data = request.DATA
+        except (ValueError, TypeError):
+            logger.error('slack.action.invalid-json', extra=logging_data, exc_info=True)
+            return self.respond(status=400)
+
+        try:
+            data = json.loads(data['payload'])
+        except (KeyError, IndexError, TypeError, ValueError):
+            logger.error('slack.action.invalid-payload', extra=logging_data, exc_info=True)
+            return self.respond(status=400)
+
+        event_id = data.get('event_id')
+        team_id = data.get('team', {}).get('id')
+        channel_id = data.get('channel', {}).get('id')
+        user_id = data.get('user', {}).get('id')
+        callback_id = data.get('callback_id')
+
+        logging_data.update({
+            'team_id': team_id,
+            'channel_id': channel_id,
+            'user_id': user_id,
+            'event_id': event_id,
+            'callback_id': callback_id,
+        })
+
+        token = data.get('token')
+        if token != options.get('slack.verification-token'):
+            logger.error('slack.action.invalid-token', extra=logging_data)
+            return self.respond(status=401)
+
+        logger.info('slack.action', extra=logging_data)
+
+        try:
+            integration = Integration.objects.get(
+                provider='slack',
+                external_id=team_id,
+            )
+        except Integration.DoesNotExist:
+            logger.error('slack.action.invalid-team-id', extra=logging_data)
+            return self.respond(status=403)
+
+        logging_data['integration_id'] = integration.id
+
+        callback_data = json.loads(callback_id)
+
+        # Determine the issue group action is being taken on
+        group_id = callback_data['issue']
+
+        # Actions list may be empty when receiving a dialog response
+        action_list = data.get('actions', [])
+
+        try:
+            group = Group.objects.get(
+                project__in=Project.objects.filter(
+                    organization__in=integration.organizations.all(),
+                ),
+                id=group_id,
+            )
+        except Group.DoesNotExist:
+            logger.error('slack.action.invalid-issue', extra=logging_data)
+            return self.respond(status=403)
+
+        # Determine the acting user by slack identity
+        try:
+            identity = Identity.objects.get(
+                external_id=user_id,
+                idp=IdentityProvider.objects.get(organization=group.organization),
+            )
+        except Identity.DoesNotExist:
+            associate_url = absolute_uri(reverse('sentry-account-associate-identity', kwargs={
+                'organization_slug': group.organization.slug,
+                'provider_key': 'slack',
+            }))
+
+            return self.respond({
+                'response_type': 'ephemeral',
+                'replace_original': False,
+                'text': LINK_IDENTITY_MESSAGE.format(associate_url=associate_url)
+            })
+
+        # Handle status dialog submission
+        if data['type'] == 'dialog_submission' and 'resolve_type' in data['submission']:
+            # Masquerade a status action
+            action = {
+                'name': 'status',
+                'value': data['submission']['resolve_type'],
+            }
+
+            self.on_status(request, identity, group, action, data, integration)
+            group = Group.objects.get(id=group.id)
+
+            attachment = build_attachment(group, identity=identity, actions=[action])
+
+            # use the original response_url to update the link attachment
+            session = http.build_session()
+            req = session.post(callback_data['orig_response_url'], json=attachment)
+            resp = req.json()
+            if not resp.get('ok'):
+                logger.error('slack.action.response-error', extra={
+                    'error': resp.get('error'),
+                })
+
+            return self.respond()
+
+        # Usually we'll want to respond with the updated attachment including
+        # the list of actions taken. However, when opening a dialog we do not
+        # have anything to update the message with and will use the
+        # response_url later to update it.
+        defer_attachment_update = False
+
+        # Handle interaction actions
+        try:
+            for action in action_list:
+                if action['name'] == 'status':
+                    self.on_status(request, identity, group, action, data, integration)
+                elif action['name'] == 'assign':
+                    self.on_assign(request, identity, group, action)
+                elif action['name'] == 'resolve_dialog':
+                    self.open_resolve_dialog(data, group, integration)
+                    defer_attachment_update = True
+        except client.ApiError as e:
+            return self.respond({
+                'response_type': 'ephemeral',
+                'replace_original': False,
+                'text': u'Action failed: {}'.format(e.body['detail']),
+            })
+
+        if defer_attachment_update:
+            return self.respond()
+
+        # Reload group as it may have been mutated by the action
+        group = Group.objects.get(id=group.id)
+
+        attachment = build_attachment(group, identity=identity, actions=action_list)
+        return self.respond(attachment)

+ 5 - 30
src/sentry/integrations/slack/event_endpoint.py

@@ -1,27 +1,18 @@
 from __future__ import absolute_import
 
-import logging
 import json
 import re
 import six
 
-from six.moves.urllib.parse import parse_qs, urlencode, urlparse, urlunparse
-
 from sentry import http, options
 from sentry.api.base import Endpoint
 from sentry.models import Group, Integration, Project
 
-logger = logging.getLogger('sentry.integrations.slack')
-
-_link_regexp = re.compile(r'^https?\://[^/]+/[^/]+/[^/]+/issues/(\d+)/')
+from .utils import build_attachment, logger
 
-LEVEL_TO_COLOR = {
-    'debug': 'cfd3da',
-    'info': '2788ce',
-    'warning': 'f18500',
-    'error': 'f43f20',
-    'fatal': 'd20f2a',
-}
+# XXX(dcramer): this could be more tightly bound to our configured domain,
+# but slack limits what we can unfurl anyways so its probably safe
+_link_regexp = re.compile(r'^https?\://[^/]+/[^/]+/[^/]+/issues/(\d+)')
 
 
 # XXX(dcramer): a lot of this is copied from sentry-plugins right now, and will
@@ -39,22 +30,6 @@ class SlackEventEndpoint(Endpoint):
         except (TypeError, ValueError):
             return
 
-    def _attachment_for(self, group):
-        return {
-            'fallback': u'[{}] {}'.format(group.project.slug, group.title),
-            'title': group.title,
-            'title_link': self._add_notification_referrer_param(group.get_absolute_url()),
-        }
-
-    def _add_notification_referrer_param(self, url):
-        parsed_url = urlparse(url)
-        query = parse_qs(parsed_url.query)
-        query['referrer'] = 'slack'
-
-        url_list = list(parsed_url)
-        url_list[4] = urlencode(query, doseq=True)
-        return urlunparse(url_list)
-
     def on_url_verification(self, request, data):
         return self.respond({
             'challenge': data['challenge'],
@@ -87,7 +62,7 @@ class SlackEventEndpoint(Endpoint):
             'channel': data['channel'],
             'ts': data['message_ts'],
             'unfurls': json.dumps({
-                v: self._attachment_for(results[k])
+                v: build_attachment(results[k])
                 for k, v in six.iteritems(issue_map)
                 if k in results
             }),

+ 2 - 0
src/sentry/integrations/slack/urls.py

@@ -2,10 +2,12 @@ from __future__ import absolute_import, print_function
 
 from django.conf.urls import patterns, url
 
+from .action_endpoint import SlackActionEndpoint
 from .event_endpoint import SlackEventEndpoint
 
 
 urlpatterns = patterns(
     '',
+    url(r'^action/$', SlackActionEndpoint.as_view()),
     url(r'^event/$', SlackEventEndpoint.as_view()),
 )

+ 204 - 0
src/sentry/integrations/slack/utils.py

@@ -0,0 +1,204 @@
+from __future__ import absolute_import
+
+import logging
+import time
+
+from django.db.models import Q
+from six.moves.urllib.parse import parse_qs, urlencode, urlparse, urlunparse
+
+from sentry.utils import json
+from sentry.utils.assets import get_asset_url
+from sentry.utils.http import absolute_uri
+from sentry.models import GroupStatus, GroupAssignee, OrganizationMember, User, Identity
+
+logger = logging.getLogger('sentry.integrations.slack')
+
+UNASSIGN_OPTION = {
+    'text': u':negative_squared_cross_mark: Unassign Issue',
+    'value': 'none',
+}
+
+# Attachment colors used for issues with no actions take
+NEW_ISSUE_COLOR = '#E03E2F'
+ACTIONED_ISSUE_COLOR = '#EDEEEF'
+
+
+def get_assignees(group):
+    queryset = OrganizationMember.objects.filter(
+        Q(user__is_active=True) | Q(user__isnull=True),
+        organization=group.organization,
+        teams__in=group.project.teams.all(),
+    ).select_related('user')
+
+    members = sorted(queryset, key=lambda x: x.user.get_display_name() if x.user_id else x.email)
+    members = filter(lambda m: m.user_id is not None, members)
+
+    return [{'text': x.user.get_display_name(), 'value': x.user.username} for x in members]
+
+
+def add_notification_referrer_param(url, provider):
+    parsed_url = urlparse(url)
+    query = parse_qs(parsed_url.query)
+    query['referrer'] = provider
+    url_list = list(parsed_url)
+    url_list[4] = urlencode(query, doseq=True)
+    return urlunparse(url_list)
+
+
+def build_attachment_title(group, event=None):
+    ev_metadata = group.get_event_metadata()
+    ev_type = group.get_event_type()
+    if ev_type == 'error':
+        if group.culprit:
+            return u'{} - {}'.format(ev_metadata['type'][:40], group.culprit)
+        return ev_metadata['type']
+    elif ev_type == 'csp':
+        return u'{} - {}'.format(ev_metadata['directive'], ev_metadata['uri'])
+    else:
+        if group.culprit:
+            return u'{} - {}'.format(group.title[:40], group.culprit)
+        return group.title
+
+
+def build_attachment_text(group, event=None):
+    ev_metadata = group.get_event_metadata()
+    ev_type = group.get_event_type()
+    if ev_type == 'error':
+        return ev_metadata['value']
+    else:
+        return None
+
+
+def build_assigned_text(identity, assignee):
+    if assignee == 'none':
+        return u'*Issue unassigned by <@{user_id}>*'.format(
+            user_id=identity.external_id,
+        )
+
+    try:
+        assignee_user = User.objects.get(email=assignee)
+    except User.DoesNotExist:
+        return
+
+    try:
+        assignee_ident = Identity.objects.get(user=assignee_user)
+        assignee_text = u'<@{}>'.format(assignee_ident.external_id)
+    except Identity.DoesNotExist:
+        assignee_text = assignee_user.get_display_name()
+
+    return u'*Issue assigned to {assignee_text} by <@{user_id}>*'.format(
+        assignee_text=assignee_text,
+        user_id=identity.external_id,
+    )
+
+
+def build_action_text(identity, action):
+    if action['name'] == 'assign':
+        return build_assigned_text(identity, action['selected_options'][0]['value'])
+
+    statuses = {
+        'resolved': 'resolved',
+        'ignored': 'ignored',
+        'unresolved': 're-opened',
+    }
+
+    # Resolve actions have additional 'parameters' after ':'
+    status = action['value'].split(':', 1)[0]
+
+    # Action has no valid action text, ignore
+    if status not in statuses:
+        return
+
+    return u'*Issue {status} by <@{user_id}>*'.format(
+        status=statuses[status],
+        user_id=identity.external_id,
+    )
+
+
+def build_attachment(group, event=None, identity=None, actions=None):
+    # XXX(dcramer): options are limited to 100 choices, even when nested
+    status = group.get_status()
+    assignees = get_assignees(group)
+
+    logo_url = absolute_uri(get_asset_url('sentry', 'images/sentry-email-avatar.png'))
+    color = NEW_ISSUE_COLOR
+
+    text = build_attachment_text(group, event) or ''
+
+    if actions is None:
+        actions = []
+
+    try:
+        assignee = GroupAssignee.objects.get(group=group).user
+        assignee = {
+            'text': assignee.get_display_name(),
+            'value': assignee.username,
+        }
+
+        # Add unassign option to the top of the list
+        assignees.insert(0, UNASSIGN_OPTION)
+    except GroupAssignee.DoesNotExist:
+        assignee = None
+
+    resolve_button = {
+        'name': 'resolve_dialog',
+        'value': 'resolve_dialog',
+        'type': 'button',
+        'text': 'Resolve',
+    }
+
+    ignore_button = {
+        'name': 'status',
+        'value': 'ignored',
+        'type': 'button',
+        'text': 'Ignore',
+    }
+
+    if status == GroupStatus.RESOLVED:
+        resolve_button.update({
+            'name': 'status',
+            'text': 'Unresolve Issue',
+            'value': 'unresolved',
+        })
+
+    if status == GroupStatus.IGNORED:
+        ignore_button.update({
+            'text': 'Stop Ignoring',
+            'value': 'unresolved',
+        })
+
+    payload_actions = [
+        resolve_button,
+        ignore_button,
+        {
+            'name': 'assign',
+            'text': 'Select Assignee ..',
+            'type': 'select',
+            'options': assignees,
+            'selected_options': [assignee],
+        },
+    ]
+
+    if actions:
+        action_texts = filter(None, [build_action_text(identity, a) for a in actions])
+        text += '\n' + '\n'.join(action_texts)
+
+        color = ACTIONED_ISSUE_COLOR
+        payload_actions = []
+
+    return {
+        'fallback': u'[{}] {}'.format(group.project.slug, group.title),
+        'title': build_attachment_title(group, event),
+        'title_link': add_notification_referrer_param(group.get_absolute_url(), 'slack'),
+        'text': text,
+        'mrkdwn_in': ['text'],
+        'callback_id': json.dumps({'issue': group.id}),
+        'footer_icon': logo_url,
+        'footer': u'{} / {}'.format(
+            group.organization.slug,
+            group.project.slug,
+        ),
+        'ts': int(time.mktime(group.last_seen.timetuple())),
+        'color': color,
+        'actions': payload_actions,
+    }

+ 325 - 0
tests/sentry/integrations/slack/test_action_endpoint.py

@@ -0,0 +1,325 @@
+from __future__ import absolute_import
+
+import responses
+
+from django.core.urlresolvers import reverse
+from six.moves.urllib.parse import parse_qs
+
+from sentry import options
+from sentry.models import (
+    Integration, OrganizationIntegration, Identity, IdentityProvider,
+    IdentityStatus, Group, GroupStatus, GroupAssignee, AuthProvider,
+    AuthIdentity
+)
+from sentry.testutils import APITestCase
+from sentry.utils import json
+from sentry.utils.http import absolute_uri
+from sentry.integrations.slack.action_endpoint import LINK_IDENTITY_MESSAGE
+
+
+class BaseEventTest(APITestCase):
+    def setUp(self):
+        super(BaseEventTest, self).setUp()
+        self.user = self.create_user(is_superuser=False)
+        self.org = self.create_organization(owner=None)
+        self.team = self.create_team(organization=self.org, members=[self.user])
+
+        self.integration = Integration.objects.create(
+            provider='slack',
+            external_id='TXXXXXXX1',
+            metadata={
+                'access_token': 'xoxp-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx',
+                'bot_access_token': 'xoxb-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx',
+            }
+        )
+        OrganizationIntegration.objects.create(
+            organization=self.org,
+            integration=self.integration,
+        )
+
+        self.idp = IdentityProvider.objects.create(
+            type='slack',
+            organization=self.org,
+            config={},
+        )
+        self.identity = Identity.objects.create(
+            external_id='slack_id',
+            idp=self.idp,
+            user=self.user,
+            status=IdentityStatus.VALID,
+            scopes=[],
+        )
+
+        self.project1 = self.create_project(organization=self.org)
+        self.group1 = self.create_group(project=self.project1)
+
+        self.trigger_id = '13345224609.738474920.8088930838d88f008e0'
+        self.response_url = 'https://hooks.slack.com/actions/T47563693/6204672533/x7ZLaiVMoECAW50Gw1ZYAXEM'
+
+    def post_webhook(self, action_data=None, type='event_callback', data=None,
+                     token=None, team_id='TXXXXXXX1', callback_id=None, slack_user=None):
+        if token is None:
+            token = options.get('slack.verification-token')
+
+        if slack_user is None:
+            slack_user = {'id': self.identity.external_id, 'domain': 'example'}
+
+        if callback_id is None:
+            callback_id = json.dumps({'issue': self.group1.id})
+
+        payload = {
+            'token': token,
+            'team': {
+                'id': team_id,
+                'domain': 'example.com',
+            },
+            'channel': {
+                'id': 'C065W1189',
+                'domain': 'forgotten-works',
+            },
+            'user': slack_user,
+            'callback_id': callback_id,
+            'action_ts': '1458170917.164398',
+            'message_ts': '1458170866.000004',
+            'original_message': {},  # unused
+            'trigger_id': self.trigger_id,
+            'response_url': self.response_url,
+            'attachment_id': '1',
+            'actions': action_data or [],
+            'type': type,
+        }
+        if data:
+            payload.update(data)
+
+        payload = {'payload': json.dumps(payload)}
+
+        return self.client.post('/extensions/slack/action/', data=payload)
+
+
+class StatusActionTest(BaseEventTest):
+    def test_ask_linking(self):
+        resp = self.post_webhook(slack_user={
+            'id': 'invalid-id',
+            'domain': 'example',
+        })
+
+        associate_url = absolute_uri(reverse('sentry-account-associate-identity', kwargs={
+            'organization_slug': self.org.slug,
+            'provider_key': 'slack',
+        }))
+
+        assert resp.status_code == 200, resp.content
+        assert resp.data['response_type'] == 'ephemeral'
+        assert resp.data['text'] == LINK_IDENTITY_MESSAGE.format(
+            associate_url=associate_url,
+        )
+
+    def test_ignore_issue(self):
+        status_action = {
+            'name': 'status',
+            'value': 'ignored',
+            'type': 'button'
+        }
+
+        resp = self.post_webhook(action_data=[status_action])
+        self.group1 = Group.objects.get(id=self.group1.id)
+
+        assert resp.status_code == 200, resp.content
+        assert self.group1.get_status() == GroupStatus.IGNORED
+
+        expect_status = u'*Issue ignored by <@{}>*'.format(self.identity.external_id)
+        assert resp.data['text'].endswith(expect_status), resp.data['text']
+
+    def test_ignore_issue_with_additional_user_auth(self):
+        """
+        Ensure that we can act as a user even when the organization has SSO enabled
+        """
+        auth_idp = AuthProvider.objects.create(
+            organization=self.org,
+            provider='dummy',
+        )
+        AuthIdentity.objects.create(
+            auth_provider=auth_idp,
+            user=self.user,
+        )
+
+        status_action = {
+            'name': 'status',
+            'value': 'ignored',
+            'type': 'button'
+        }
+
+        resp = self.post_webhook(action_data=[status_action])
+        self.group1 = Group.objects.get(id=self.group1.id)
+
+        assert resp.status_code == 200, resp.content
+        assert self.group1.get_status() == GroupStatus.IGNORED
+
+        expect_status = u'*Issue ignored by <@{}>*'.format(self.identity.external_id)
+        assert resp.data['text'].endswith(expect_status), resp.data['text']
+
+    def test_assign_issue(self):
+        user2 = self.create_user(is_superuser=False)
+        self.create_member(user=user2, organization=self.org, teams=[self.team])
+
+        # Assign to user
+        status_action = {
+            'name': 'assign',
+            'selected_options': [{'value': user2.username}],
+        }
+
+        resp = self.post_webhook(action_data=[status_action])
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group1, user=user2).exists()
+
+        expect_status = u'*Issue assigned to {assignee} by <@{assigner}>*'.format(
+            assignee=user2.get_display_name(),
+            assigner=self.identity.external_id,
+        )
+
+        # Unassign from user
+        status_action = {
+            'name': 'assign',
+            'selected_options': [{'value': 'none'}],
+        }
+
+        resp = self.post_webhook(action_data=[status_action])
+
+        assert resp.status_code == 200, resp.content
+        assert not GroupAssignee.objects.filter(group=self.group1).exists()
+
+        expect_status = u'*Issue unassigned by <@{assigner}>*'.format(
+            assignee=user2.get_display_name(),
+            assigner=self.identity.external_id,
+        )
+
+        assert resp.data['text'].endswith(expect_status), resp.data['text']
+
+    def test_assign_issue_user_has_identity(self):
+        user2 = self.create_user(is_superuser=False)
+        self.create_member(user=user2, organization=self.org, teams=[self.team])
+
+        user2_identity = Identity.objects.create(
+            external_id='slack_id2',
+            idp=self.idp,
+            user=user2,
+            status=IdentityStatus.VALID,
+            scopes=[],
+        )
+
+        status_action = {
+            'name': 'assign',
+            'selected_options': [{'value': user2.username}],
+        }
+
+        resp = self.post_webhook(action_data=[status_action])
+
+        assert resp.status_code == 200, resp.content
+        assert GroupAssignee.objects.filter(group=self.group1, user=user2).exists()
+
+        expect_status = u'*Issue assigned to <@{assignee}> by <@{assigner}>*'.format(
+            assignee=user2_identity.external_id,
+            assigner=self.identity.external_id,
+        )
+
+        assert resp.data['text'].endswith(expect_status), resp.data['text']
+
+    @responses.activate
+    def test_resolve_issue(self):
+        status_action = {
+            'name': 'resolve_dialog',
+            'value': 'resolve_dialog',
+        }
+
+        # Expect request to open dialog on slack
+        responses.add(
+            method=responses.POST,
+            url='https://slack.com/api/dialog.open',
+            body='{"ok": true}',
+            status=200,
+            content_type='application/json',
+        )
+
+        resp = self.post_webhook(action_data=[status_action])
+        assert resp.status_code == 200, resp.content
+
+        # Opening dialog should *not* cause the current message to be updated
+        assert resp.content == ''
+
+        data = parse_qs(responses.calls[0].request.body)
+        assert data['token'][0] == self.integration.metadata['bot_access_token']
+        assert data['trigger_id'][0] == self.trigger_id
+        assert 'dialog' in data
+
+        dialog = json.loads(data['dialog'][0])
+        callback_data = json.loads(dialog['callback_id'])
+        assert int(callback_data['issue']) == self.group1.id
+        assert callback_data['orig_response_url'] == self.response_url
+
+        # Completing the dialog will update the message
+        responses.add(
+            method=responses.POST,
+            url=self.response_url,
+            body='{"ok": true}',
+            status=200,
+            content_type='application/json',
+        )
+
+        resp = self.post_webhook(
+            type='dialog_submission',
+            callback_id=dialog['callback_id'],
+            data={'submission': {'resolve_type': 'resolved'}}
+        )
+        self.group1 = Group.objects.get(id=self.group1.id)
+
+        assert resp.status_code == 200, resp.content
+        assert self.group1.get_status() == GroupStatus.RESOLVED
+
+        update_data = json.loads(responses.calls[1].request.body)
+
+        expect_status = u'*Issue resolved by <@{}>*'.format(self.identity.external_id)
+        assert update_data['text'].endswith(expect_status)
+
+    def test_permission_denied(self):
+        user2 = self.create_user(is_superuser=False)
+
+        user2_identity = Identity.objects.create(
+            external_id='slack_id2',
+            idp=self.idp,
+            user=user2,
+            status=IdentityStatus.VALID,
+            scopes=[],
+        )
+
+        status_action = {
+            'name': 'status',
+            'value': 'ignored',
+            'type': 'button'
+        }
+
+        resp = self.post_webhook(
+            action_data=[status_action],
+            slack_user={'id': user2_identity.external_id},
+        )
+        self.group1 = Group.objects.get(id=self.group1.id)
+
+        assert resp.status_code == 200, resp.content
+        assert not self.group1.get_status() == GroupStatus.IGNORED
+
+        assert resp.data['response_type'] == 'ephemeral'
+        assert not resp.data['replace_original']
+        assert resp.data['text'] == 'Action failed: You do not have permission to perform this action.'
+
+    def test_invalid_token(self):
+        resp = self.post_webhook(token='invalid')
+        assert resp.status_code == 401
+
+    def test_no_integration(self):
+        self.integration.delete()
+        resp = self.post_webhook()
+        assert resp.status_code == 403
+
+    def test_slack_bad_payload(self):
+        resp = self.client.post('/extensions/slack/action/', data={'nopayload': 0})
+        assert resp.status_code == 400