Browse Source

feat(sentry apps): Add comments webhooks (#32245)

* Add comment webhooks
Colleen O'Rourke 3 years ago
parent
commit
378167e9bc

+ 16 - 0
src/sentry/api/endpoints/group_notes.py

@@ -11,6 +11,7 @@ from sentry.api.serializers.rest_framework.group_notes import NoteSerializer
 from sentry.api.serializers.rest_framework.mentions import extract_user_ids_from_mentions
 from sentry.models import Activity, GroupSubscription
 from sentry.notifications.types import GroupSubscriptionReason
+from sentry.signals import comment_created
 from sentry.types.activity import ActivityType
 from sentry.utils.functional import extract_lazy_object
 
@@ -76,4 +77,19 @@ class GroupNotesEndpoint(GroupEndpoint):
         )
 
         self.create_external_comment(request, group, activity)
+
+        webhook_data = {
+            "comment_id": activity.id,
+            "timestamp": activity.datetime,
+            "comment": activity.data.get("text"),
+            "project_slug": activity.project.slug,
+        }
+
+        comment_created.send_robust(
+            project=group.project,
+            user=request.user,
+            group=group,
+            data=webhook_data,
+            sender="post",
+        )
         return Response(serialize(activity, request.user), status=201)

+ 31 - 0
src/sentry/api/endpoints/group_notes_details.py

@@ -8,6 +8,7 @@ from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.serializers import serialize
 from sentry.api.serializers.rest_framework.group_notes import NoteSerializer
 from sentry.models import Activity
+from sentry.signals import comment_deleted, comment_updated
 
 
 class GroupNotesDetailsEndpoint(GroupEndpoint):
@@ -26,8 +27,23 @@ class GroupNotesDetailsEndpoint(GroupEndpoint):
         except Activity.DoesNotExist:
             raise ResourceDoesNotExist
 
+        webhook_data = {
+            "comment_id": note.id,
+            "timestamp": note.datetime,
+            "comment": note.data.get("text"),
+            "project_slug": note.project.slug,
+        }
+
         note.delete()
 
+        comment_deleted.send_robust(
+            project=group.project,
+            user=request.user,
+            group=group,
+            data=webhook_data,
+            sender="delete",
+        )
+
         return Response(status=204)
 
     def put(self, request: Request, group, note_id) -> Response:
@@ -56,6 +72,21 @@ class GroupNotesDetailsEndpoint(GroupEndpoint):
 
             if note.data.get("external_id"):
                 self.update_external_comment(request, group, note)
+
+            webhook_data = {
+                "comment_id": note.id,
+                "timestamp": note.datetime,
+                "comment": note.data.get("text"),
+                "project_slug": note.project.slug,
+            }
+
+            comment_updated.send_robust(
+                project=group.project,
+                user=request.user,
+                group=group,
+                data=webhook_data,
+                sender="put",
+            )
             return Response(serialize(note, request.user), status=200)
 
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 9 - 2
src/sentry/models/integrations/sentry_app.py

@@ -29,14 +29,20 @@ from sentry.utils import metrics
 # listening to a list of specific events. This is a mapping of what those
 # specific events are for each resource.
 EVENT_EXPANSION = {
-    "issue": ["issue.created", "issue.resolved", "issue.ignored", "issue.assigned"],
+    "issue": [
+        "issue.created",
+        "issue.resolved",
+        "issue.ignored",
+        "issue.assigned",
+    ],
     "error": ["error.created"],
+    "comment": ["comment.created", "comment.updated", "comment.deleted"],
 }
 
 # We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
 # per-event-type (issue.created, project.deleted, etc.). These are valid
 # resources a Sentry App may subscribe to.
-VALID_EVENT_RESOURCES = ("issue", "error")
+VALID_EVENT_RESOURCES = ("issue", "error", "comment")
 
 REQUIRED_EVENT_PERMISSIONS = {
     "issue": "event:read",
@@ -45,6 +51,7 @@ REQUIRED_EVENT_PERMISSIONS = {
     "member": "member:read",
     "organization": "org:read",
     "team": "team:read",
+    "comment": "event:read",
 }
 
 # The only events valid for Sentry Apps are the ones listed in the values of

+ 37 - 2
src/sentry/receivers/sentry_apps.py

@@ -1,6 +1,13 @@
 from sentry.models import GroupAssignee, SentryAppInstallation
-from sentry.signals import issue_assigned, issue_ignored, issue_resolved
-from sentry.tasks.sentry_apps import workflow_notification
+from sentry.signals import (
+    comment_created,
+    comment_deleted,
+    comment_updated,
+    issue_assigned,
+    issue_ignored,
+    issue_resolved,
+)
+from sentry.tasks.sentry_apps import build_comment_webhook, workflow_notification
 
 
 @issue_assigned.connect(weak=False)
@@ -38,6 +45,34 @@ def send_issue_ignored_webhook(project, user, group_list, **kwargs):
         send_workflow_webhooks(project.organization, issue, user, "issue.ignored")
 
 
+@comment_created.connect(weak=False)
+def send_comment_created_webhook(project, user, group, data, **kwargs):
+    send_comment_webhooks(project.organization, group, user, "comment.created", data=data)
+
+
+@comment_updated.connect(weak=False)
+def send_comment_updated_webhook(project, user, group, data, **kwargs):
+    send_comment_webhooks(project.organization, group, user, "comment.updated", data=data)
+
+
+@comment_deleted.connect(weak=False)
+def send_comment_deleted_webhook(project, user, group, data, **kwargs):
+    send_comment_webhooks(project.organization, group, user, "comment.deleted", data=data)
+
+
+def send_comment_webhooks(organization, issue, user, event, data=None):
+    data = data or {}
+
+    for install in installations_to_notify(organization, event):
+        build_comment_webhook.delay(
+            installation_id=install.id,
+            issue_id=issue.id,
+            type=event,
+            user_id=(user.id if user else None),
+            data=data,
+        )
+
+
 def send_workflow_webhooks(organization, issue, user, event, data=None):
     data = data or {}
 

+ 9 - 6
src/sentry/signals.py

@@ -77,12 +77,6 @@ email_verified = BetterSignal(providing_args=["email"])
 mocks_loaded = BetterSignal(providing_args=["project"])
 
 user_feedback_received = BetterSignal(providing_args=["project"])
-issue_assigned = BetterSignal(providing_args=["project", "group", "user"])
-
-issue_resolved = BetterSignal(
-    providing_args=["organization_id", "project", "group", "user", "resolution_type"]
-)
-issue_unresolved = BetterSignal(providing_args=["project", "user", "group", "transition_type"])
 
 advanced_search = BetterSignal(providing_args=["project"])
 advanced_search_feature_gated = BetterSignal(providing_args=["organization", "user"])
@@ -97,9 +91,18 @@ repo_linked = BetterSignal(providing_args=["repo", "user"])
 release_created = BetterSignal(providing_args=["release"])
 deploy_created = BetterSignal(providing_args=["deploy"])
 ownership_rule_created = BetterSignal(providing_args=["project"])
+issue_assigned = BetterSignal(providing_args=["project", "group", "user"])
+
+issue_resolved = BetterSignal(
+    providing_args=["organization_id", "project", "group", "user", "resolution_type"]
+)
+issue_unresolved = BetterSignal(providing_args=["project", "user", "group", "transition_type"])
 issue_ignored = BetterSignal(providing_args=["project", "user", "group_list", "activity_data"])
 issue_unignored = BetterSignal(providing_args=["project", "user", "group", "transition_type"])
 issue_mark_reviewed = BetterSignal(providing_args=["project", "user", "group"])
+comment_created = BetterSignal(providing_args=["project", "user", "group", "activity_data"])
+comment_updated = BetterSignal(providing_args=["project", "user", "group", "activity_data"])
+comment_deleted = BetterSignal(providing_args=["project", "user", "group", "activity_data"])
 inbox_in = BetterSignal(providing_args=["project", "user", "group", "reason"])
 inbox_out = BetterSignal(
     providing_args=["project", "user", "group", "action", "inbox_date_added", "referrer"]

+ 26 - 7
src/sentry/tasks/sentry_apps.py

@@ -10,6 +10,7 @@ from sentry.constants import SentryAppInstallationStatus
 from sentry.eventstore.models import Event
 from sentry.http import safe_urlopen
 from sentry.models import (
+    Activity,
     Group,
     Organization,
     Project,
@@ -45,7 +46,7 @@ RETRY_OPTIONS = {
 # Hook events to match what we externally call these primitives.
 RESOURCE_RENAMES = {"Group": "issue"}
 
-TYPES = {"Group": Group, "Error": Event}
+TYPES = {"Group": Group, "Error": Event, "Comment": Activity}
 
 
 def _webhook_event_data(event, group_id, project_id):
@@ -151,7 +152,6 @@ def _process_resource_change(action, sender, instance_id, retryer=None, *args, *
             extra = {"sender": sender, "action": action, "event_id": instance_id}
             logger.info("process_resource_change.event_missing_event", extra=extra)
             return
-
         name = sender.lower()
     else:
         # Some resources are named differently than their model. eg. Group vs Issue.
@@ -239,8 +239,30 @@ def installation_webhook(installation_id, user_id, *args, **kwargs):
 @instrumented_task(name="sentry.tasks.sentry_apps.workflow_notification", **TASK_OPTIONS)
 @retry(**RETRY_OPTIONS)
 def workflow_notification(installation_id, issue_id, type, user_id, *args, **kwargs):
-    extra = {"installation_id": installation_id, "issue_id": issue_id}
+    install, issue, user = get_webhook_data(installation_id, issue_id, user_id)
 
+    data = kwargs.get("data", {})
+    data.update({"issue": serialize(issue)})
+    send_webhooks(installation=install, event=f"issue.{type}", data=data, actor=user)
+
+
+@instrumented_task(name="sentry.tasks.sentry_apps.build_comment_webhook", **TASK_OPTIONS)
+@retry(**RETRY_OPTIONS)
+def build_comment_webhook(installation_id, issue_id, type, user_id, *args, **kwargs):
+    install, _, user = get_webhook_data(installation_id, issue_id, user_id)
+    data = kwargs.get("data", {})
+    payload = {
+        "comment_id": data.get("comment_id"),
+        "group_id": issue_id,
+        "project_slug": data.get("project_slug"),
+        "timestamp": data.get("timestamp"),
+        "comment": data.get("comment"),
+    }
+    send_webhooks(installation=install, event=type, data=payload, actor=user)
+
+
+def get_webhook_data(installation_id, issue_id, user_id):
+    extra = {"installation_id": installation_id, "issue_id": issue_id}
     try:
         install = SentryAppInstallation.objects.get(
             id=installation_id, status=SentryAppInstallationStatus.INSTALLED
@@ -262,10 +284,7 @@ def workflow_notification(installation_id, issue_id, type, user_id, *args, **kwa
     except User.DoesNotExist:
         logger.info("workflow_notification.missing_user", extra=extra)
 
-    data = kwargs.get("data", {})
-    data.update({"issue": serialize(issue)})
-
-    send_webhooks(installation=install, event=f"issue.{type}", data=data, actor=user)
+    return (install, issue, user)
 
 
 @instrumented_task("sentry.tasks.send_process_resource_change_webhook", **TASK_OPTIONS)

+ 7 - 0
src/sentry/testutils/factories.py

@@ -1191,3 +1191,10 @@ class Factories:
             prev_history_date=prev_history_date,
             **kwargs,
         )
+
+    @staticmethod
+    def create_comment(issue, project, user, text="hello world"):
+        data = {"text": text}
+        return Activity.objects.create(
+            project=project, group=issue, type=Activity.NOTE, user=user, data=data
+        )

+ 3 - 0
src/sentry/testutils/fixtures.py

@@ -390,6 +390,9 @@ class Fixtures:
             kwargs["actor"] = self.user.actor
         return Factories.create_group_history(*args, **kwargs)
 
+    def create_comment(self, *args, **kwargs):
+        return Factories.create_comment(*args, **kwargs)
+
     @pytest.fixture(autouse=True)
     def _init_insta_snapshot(self, insta_snapshot):
         self.insta_snapshot = insta_snapshot

+ 1 - 1
static/app/types/integrations.tsx

@@ -448,7 +448,7 @@ export type AppOrProviderOrPlugin =
 /**
  * Webhooks and servicehooks
  */
-export type WebhookEvent = 'issue' | 'error';
+export type WebhookEvent = 'issue' | 'error' | 'comment';
 
 export type ServiceHook = {
   dateCreated: string;

+ 3 - 1
static/app/views/settings/organizationDeveloperSettings/constants.tsx

@@ -1,11 +1,13 @@
-export const EVENT_CHOICES = ['issue', 'error'] as const;
+export const EVENT_CHOICES = ['issue', 'error', 'comment'] as const;
 
 export const DESCRIPTIONS = {
   issue: 'created, resolved, assigned',
   error: 'created',
+  comment: 'created, edited, deleted',
 } as const;
 
 export const PERMISSIONS_MAP = {
   issue: 'Event',
   error: 'Event',
+  comment: 'Event',
 } as const;

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