Skip to content

feat(sentry apps): add urls to issue.created webhook #93780

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/sentry/models/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,13 @@ def get_absolute_url(
query = urlencode(params)
return organization.absolute_url(path, query=query)

def get_absolute_api_url(self, params: Mapping[str, str] | None = None):
query = None
path = f"/issues/{self.id}/"
if params:
query = urlencode(params)
return self.organization.absolute_api_url(path=path, query=query)
Comment on lines +696 to +700
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to include query params? The published endpoint doesn't look like it takes any? https://docs.sentry.io/api/events/retrieve-an-issue/ :/


@property
def qualified_short_id(self):
if self.short_id is not None:
Expand Down
21 changes: 20 additions & 1 deletion src/sentry/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
from sentry.hybridcloud.services.organization_mapping import organization_mapping_service
from sentry.locks import locks
from sentry.notifications.services import notifications_service
from sentry.organizations.absolute_url import has_customer_domain, organization_absolute_url
from sentry.organizations.absolute_url import (
api_absolute_url,
has_customer_domain,
organization_absolute_url,
)
from sentry.roles.manager import Role
from sentry.users.services.user import RpcUser, RpcUserProfile
from sentry.users.services.user.service import user_service
Expand Down Expand Up @@ -482,6 +486,21 @@ def absolute_url(self, path: str, query: str | None = None, fragment: str | None
fragment=fragment,
)

def absolute_api_url(
self, path: str, query: str | None = None, fragment: str | None = None
) -> str:
"""
Get an absolute URL to `path` for this organization for APIs

e.g https://sentry.io/api/0/organizations/<org_slug><path>
"""
return api_absolute_url(
slug=self.slug,
path=path,
query=query,
fragment=fragment,
)

def get_scopes(self, role: Role) -> frozenset[str]:
"""
Note that scopes for team-roles are filtered through this method too.
Expand Down
17 changes: 17 additions & 0 deletions src/sentry/organizations/absolute_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,20 @@ def organization_absolute_url(
if fragment:
parts.append(fragment)
return "".join(parts)


def api_absolute_url(
*, slug: str, path: str, query: str | None = None, fragment: str | None = None
) -> str:
path = f"/api/0/organizations/{slug}{path}"
uri = absolute_uri(path)
parts = [uri]
if query and not query.startswith("?"):
query = f"?{query}"
if query:
parts.append(query)
if fragment and not fragment.startswith("#"):
fragment = f"#{fragment}"
if fragment:
parts.append(fragment)
return "".join(parts)
27 changes: 24 additions & 3 deletions src/sentry/sentry_apps/tasks/sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from sentry import analytics, features, nodestore
from sentry.api.serializers import serialize
from sentry.api.serializers.models.group import BaseGroupSerializerResponse
from sentry.constants import SentryAppInstallationStatus
from sentry.db.models.base import Model
from sentry.eventstore.models import BaseEvent, Event, GroupEvent
Expand Down Expand Up @@ -124,6 +125,25 @@ def _webhook_event_data(
return event_context


class WebhookGroupResponse(BaseGroupSerializerResponse):
web_url: str
project_url: str
url: str


def _webhook_issue_data(
group: Group, serialized_group: BaseGroupSerializerResponse
) -> WebhookGroupResponse:

webhook_payload = WebhookGroupResponse(
url=group.get_absolute_api_url(),
web_url=group.get_absolute_url(),
project_url=group.project.get_absolute_url(),
**serialized_group,
)
return webhook_payload


@instrumented_task(
name="sentry.sentry_apps.tasks.sentry_apps.send_alert_webhook_v2",
taskworker_config=TaskworkerConfig(
Expand Down Expand Up @@ -326,12 +346,13 @@ def _process_resource_change(
)
if event in installation.sentry_app.events
]
data = {}
data: dict[str, Any] = {}
if isinstance(instance, (Event, GroupEvent)):
assert instance.group_id, "group id is required to create webhook event data"
data[name] = _webhook_event_data(instance, instance.group_id, instance.project_id)
else:
data[name] = serialize(instance)
elif isinstance(instance, Group):
serialized_group = serialize(instance)
data[name] = _webhook_issue_data(group=instance, serialized_group=serialized_group)

# Datetimes need to be string cast for task payloads.
for date_key in ("datetime", "firstSeen", "lastSeen"):
Expand Down
8 changes: 8 additions & 0 deletions tests/sentry/models/test_organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,14 @@ def test_absolute_url_with_customer_domain(self):
url = org.absolute_url("/organizations/acme/issues/", query="?project=123", fragment="#ref")
assert url == "http://acme.testserver/issues/?project=123#ref"

def test_absolute_api_url(self):
org = self.create_organization(owner=self.user, slug="acme")
url = org.absolute_api_url("/restore/")
assert url == "http://testserver/api/0/organizations/acme/restore/"

url = org.absolute_api_url("/issues/", query="project=123", fragment="ref")
assert url == "http://testserver/api/0/organizations/acme/issues/?project=123#ref"

def test_get_bulk_owner_profiles(self):
u1, u2, u3 = (self.create_user() for _ in range(3))
o1, o2, o3 = (self.create_organization(owner=u) for u in (u1, u2, u3))
Expand Down
16 changes: 14 additions & 2 deletions tests/sentry/sentry_apps/tasks/test_sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,10 +755,22 @@ def test_project_filter_matches_project_sends_webhook(self, mock_record, safe_ur
assert safe_urlopen.called
((args, kwargs),) = safe_urlopen.call_args_list
data = json.loads(kwargs["data"])
issue_data = data["data"]["issue"]
assert data["action"] == "created"
assert data["installation"]["uuid"] == self.install.uuid
assert data["data"]["issue"]["id"] == str(event.group.id)

assert issue_data["id"] == str(event.group.id)
assert (
issue_data["url"]
== f"http://testserver/api/0/organizations/{self.organization.slug}/issues/{event.group.id}/"
)
assert (
issue_data["web_url"]
== f"http://testserver/organizations/{self.organization.slug}/issues/{event.group.id}/"
)
assert (
issue_data["project_url"]
== f"http://testserver/organizations/{self.organization.slug}/issues/?project={event.project_id}"
)
# SLO assertions
assert_success_metric(mock_record)
# PREPARE_WEBHOOK (success) -> SEND_WEBHOOK (success) -> SEND_WEBHOOK (success) -> SEND_WEBHOOK (success)
Expand Down
Loading