Browse Source

feat(app-platform): Issue Link UI (#12345)

This change takes care of dynamically rendering the Link and Create
forms for Sentry Apps that support Issue Link components.
Matte Noble 6 years ago
parent
commit
27adc796ab

+ 8 - 1
src/sentry/api/bases/sentryapps.py

@@ -188,7 +188,6 @@ class SentryAppInstallationsBaseEndpoint(Endpoint):
             organization = organizations.get(slug=organization_slug)
         except Organization.DoesNotExist:
             raise Http404
-
         self.check_object_permissions(request, organization)
 
         kwargs['organization'] = organization
@@ -199,6 +198,14 @@ class SentryAppInstallationPermission(SentryPermission):
     scope_map = {
         'GET': ('org:read', 'org:integrations', 'org:write', 'org:admin'),
         'DELETE': ('org:integrations', 'org:write', 'org:admin'),
+        # NOTE(mn): The only POST endpoint right now is to create External
+        # Issues, which uses this baseclass since it's nested under an
+        # installation.
+        #
+        # The scopes below really only make sense for that endpoint. Any other
+        # nested endpoints will probably need different scopes - figure out how
+        # to deal with that when it happens.
+        'POST': ('org:integrations', 'event:write', 'event:admin'),
     }
 
     def has_object_permission(self, request, view, installation):

+ 32 - 6
src/sentry/api/endpoints/sentry_app_components.py

@@ -1,10 +1,13 @@
 from __future__ import absolute_import
 
+from rest_framework.response import Response
+
 from sentry.api.bases import OrganizationEndpoint, SentryAppBaseEndpoint
 from sentry.api.paginator import OffsetPaginator
 from sentry.api.serializers import serialize
 from sentry.features.helpers import requires_feature
-from sentry.models import SentryAppComponent, SentryApp
+from sentry.mediators import sentry_app_components
+from sentry.models import Project, SentryAppComponent
 
 
 class SentryAppComponentsEndpoint(SentryAppBaseEndpoint):
@@ -21,13 +24,36 @@ class SentryAppComponentsEndpoint(SentryAppBaseEndpoint):
 class OrganizationSentryAppComponentsEndpoint(OrganizationEndpoint):
     @requires_feature('organizations:sentry-apps')
     def get(self, request, organization):
+        try:
+            project = Project.objects.get(
+                id=request.GET['projectId'],
+                organization_id=organization.id,
+            )
+        except Project.DoesNotExist:
+            return Response([], status=404)
+
+        components = []
+
+        for install in organization.sentry_app_installations.all():
+            _components = SentryAppComponent.objects.filter(
+                sentry_app_id=install.sentry_app_id,
+            )
+
+            if 'filter' in request.GET:
+                _components = _components.filter(type=request.GET['filter'])
+
+            for component in _components:
+                sentry_app_components.Preparer.run(
+                    component=component,
+                    install=install,
+                    project=project,
+                )
+
+            components.extend(_components)
+
         return self.paginate(
             request=request,
-            queryset=SentryAppComponent.objects.filter(
-                sentry_app_id__in=SentryApp.objects.filter(
-                    installations__in=organization.sentry_app_installations.all(),
-                )
-            ),
+            queryset=components,
             paginator_cls=OffsetPaginator,
             on_results=lambda x: serialize(x, request.user),
         )

+ 16 - 6
src/sentry/api/endpoints/sentry_app_installation_external_issues.py

@@ -16,9 +16,13 @@ class SentryAppInstallationExternalIssuesEndpoint(SentryAppInstallationBaseEndpo
                             actor=request.user):
             return Response(status=404)
 
-        group_id = request.DATA.get('groupId')
-        if not group_id:
-            return Response({'detail': 'groupId is required'}, status=400)
+        data = request.DATA.copy()
+
+        if not set(['groupId', 'action', 'uri']).issubset(data.keys()):
+            return Response(status=400)
+
+        group_id = data.get('groupId')
+        del data['groupId']
 
         try:
             group = Group.objects.get(
@@ -30,13 +34,19 @@ class SentryAppInstallationExternalIssuesEndpoint(SentryAppInstallationBaseEndpo
         except Group.DoesNotExist:
             return Response(status=404)
 
+        action = data['action']
+        del data['action']
+
+        uri = data.get('uri')
+        del data['uri']
+
         try:
             external_issue = IssueLinkCreator.run(
                 install=installation,
                 group=group,
-                action=request.DATA.get('action'),
-                fields=request.DATA.get('fields'),
-                uri=request.DATA.get('uri'),
+                action=action,
+                fields=data,
+                uri=uri,
             )
         except Exception:
             return Response({'error': 'Error communicating with Sentry App service'}, status=400)

+ 14 - 3
src/sentry/api/serializers/models/sentry_app.py

@@ -1,5 +1,7 @@
 from __future__ import absolute_import
 
+from sentry.app import env
+from sentry.auth.superuser import is_active_superuser
 from sentry.api.serializers import Serializer, register
 from sentry.models import SentryApp
 
@@ -8,7 +10,8 @@ from sentry.models import SentryApp
 class SentryAppSerializer(Serializer):
     def serialize(self, obj, attrs, user):
         from sentry.mediators.service_hooks.creator import consolidate_events
-        return {
+
+        data = {
             'name': obj.name,
             'slug': obj.slug,
             'scopes': obj.get_scopes(),
@@ -19,7 +22,15 @@ class SentryAppSerializer(Serializer):
             'webhookUrl': obj.webhook_url,
             'redirectUrl': obj.redirect_url,
             'isAlertable': obj.is_alertable,
-            'clientId': obj.application.client_id,
-            'clientSecret': obj.application.client_secret,
             'overview': obj.overview,
         }
+
+        if is_active_superuser(env.request) or (
+            hasattr(user, 'get_orgs') and obj.owner in user.get_orgs()
+        ):
+            data.update({
+                'clientId': obj.application.client_id,
+                'clientSecret': obj.application.client_secret,
+            })
+
+        return data

+ 5 - 1
src/sentry/api/serializers/models/sentry_app_component.py

@@ -13,5 +13,9 @@ class SentryAppComponentSerializer(Serializer):
             'uuid': six.binary_type(obj.uuid),
             'type': obj.type,
             'schema': obj.schema,
-            'sentryAppId': obj.sentry_app_id,
+            'sentryApp': {
+                'uuid': obj.sentry_app.uuid,
+                'slug': obj.sentry_app.slug,
+                'name': obj.sentry_app.name,
+            }
         }

+ 1 - 0
src/sentry/mediators/__init__.py

@@ -11,3 +11,4 @@ from .token_exchange import (  # NOQA
     GrantExchanger,
     Refresher,
 )
+from .sentry_app_components import *  # NOQA

+ 2 - 0
src/sentry/mediators/external_issues/issue_link_creator.py

@@ -35,10 +35,12 @@ class IssueLinkCreator(Mediator):
 
     def _format_response_data(self):
         web_url = self.response['webUrl']
+
         display_name = u'{}#{}'.format(
             self.response['project'],
             self.response['identifier'],
         )
+
         return [web_url, display_name]
 
     def _create_external_issue(self):

+ 2 - 3
src/sentry/mediators/external_requests/issue_link_requester.py

@@ -54,9 +54,8 @@ class IssueLinkRequester(Mediator):
         return self._make_request()
 
     def _build_url(self):
-        domain = urlparse(self.sentry_app.webhook_url).netloc
-        url = u'https://{}{}'.format(domain, self.uri)
-        return url
+        urlparts = urlparse(self.sentry_app.webhook_url)
+        return u'{}://{}{}'.format(urlparts.scheme, urlparts.netloc, self.uri)
 
     def _make_request(self):
         req = safe_urlopen(

+ 19 - 15
src/sentry/mediators/external_requests/select_requester.py

@@ -4,7 +4,7 @@ import six
 import logging
 from uuid import uuid4
 
-from six.moves.urllib.parse import urlparse, urlencode
+from six.moves.urllib.parse import urlparse, urlencode, urlunparse
 from sentry.http import safe_urlopen, safe_urlread
 from sentry.coreapi import APIError
 from sentry.mediators import Mediator, Param
@@ -33,22 +33,26 @@ class SelectRequester(Mediator):
         return self._make_request()
 
     def _build_url(self):
-        domain = urlparse(self.sentry_app.webhook_url).netloc
-        url = u'https://{}{}'.format(domain, self.uri)
-        params = {'installationId': self.install.uuid}
+        urlparts = list(urlparse(self.sentry_app.webhook_url))
+        urlparts[2] = self.uri
+
+        query = {'installationId': self.install.uuid}
+
         if self.project:
-            params['projectSlug'] = self.project.slug
-        url += '?' + urlencode(params)
-        return url
+            query['projectSlug'] = self.project.slug
 
-    def _make_request(self):
-        req = safe_urlopen(
-            url=self._build_url(),
-            headers=self._build_headers(),
-        )
+        urlparts[4] = urlencode(query)
+        return urlunparse(urlparts)
 
+    def _make_request(self):
         try:
-            body = safe_urlread(req)
+            body = safe_urlread(
+                safe_urlopen(
+                    url=self._build_url(),
+                    headers=self._build_headers(),
+                )
+            )
+
             response = json.loads(body)
         except Exception:
             logger.info(
@@ -78,9 +82,9 @@ class SelectRequester(Mediator):
         choices = []
 
         for option in resp:
-            choices.append([option['label'], option['value']])
+            choices.append([option['value'], option['label']])
             if option.get('default'):
-                response['default'] = [option['label'], option['value']]
+                response['defaultValue'] = option['value']
 
         response['choices'] = choices
         return response

+ 3 - 0
src/sentry/mediators/sentry_app_components/__init__.py

@@ -0,0 +1,3 @@
+from __future__ import absolute_import
+
+from .preparer import Preparer  # NOQA

Some files were not shown because too many files changed in this diff