From 36743f9f4f3a8b84f212d55e45850687163973d7 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Tue, 29 Jul 2025 14:06:29 -0700 Subject: [PATCH 1/5] Adds initial issue service method for linked summaries --- src/sentry/issues/services/issue/impl.py | 25 +- src/sentry/issues/services/issue/model.py | 24 ++ src/sentry/issues/services/issue/service.py | 26 +- .../issues/services/test_issue_service.py | 223 ++++++++++++++++++ 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 tests/sentry/issues/services/test_issue_service.py diff --git a/src/sentry/issues/services/issue/impl.py b/src/sentry/issues/services/issue/impl.py index 819613f07e4bb6..412928bd95c019 100644 --- a/src/sentry/issues/services/issue/impl.py +++ b/src/sentry/issues/services/issue/impl.py @@ -4,7 +4,8 @@ # defined, because we want to reflect on type annotations and avoid forward references. -from sentry.issues.services.issue.model import RpcGroupShareMetadata +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.issues.services.issue.model import RpcGroupShareMetadata, RpcLinkedIssueSummary from sentry.issues.services.issue.service import IssueService from sentry.models.group import Group from sentry.models.organization import Organization @@ -45,3 +46,25 @@ def upsert_issue_email_reply( # Call the task synchronously so that the outbox retry works # correctly should this fail. process_inbound_email(from_email, group_id, text) + + def get_linked_issues( + self, + *, + region_name: str, + integration_id: int, + organization_ids: list[int], + external_issue_key: str, + ) -> list[RpcLinkedIssueSummary]: + + # Filtering on organization_id and integration_id is analogous to + # querying by integration installations. + external_issues = ExternalIssue.objects.filter( + key=external_issue_key, + organization_id__in=organization_ids, + integration_id=integration_id, + ).order_by("date_added") + + return [ + RpcLinkedIssueSummary.from_external_issue(external_issue) + for external_issue in external_issues + ] diff --git a/src/sentry/issues/services/issue/model.py b/src/sentry/issues/services/issue/model.py index b90c628f8966bf..2ca3aa3af7a7cc 100644 --- a/src/sentry/issues/services/issue/model.py +++ b/src/sentry/issues/services/issue/model.py @@ -1,6 +1,30 @@ +from __future__ import annotations + from sentry.hybridcloud.rpc import RpcModel +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.models.grouplink import GroupLink class RpcGroupShareMetadata(RpcModel): title: str message: str + + +class RpcLinkedIssueSummary(RpcModel): + title: str + issue_link: str + + @classmethod + def from_external_issue(cls, external_issue: ExternalIssue) -> RpcLinkedIssueSummary: + group_link = GroupLink.objects.get( + linked_id=external_issue.id, + linked_type=GroupLink.LinkedType.issue, + relationship=GroupLink.Relationship.references, + ) + + group_url = group_link.group.get_absolute_url() + + return RpcLinkedIssueSummary( + title=external_issue.title or "", + issue_link=group_url, + ) diff --git a/src/sentry/issues/services/issue/service.py b/src/sentry/issues/services/issue/service.py index 3da4d316d670a9..2ecb1739f42070 100644 --- a/src/sentry/issues/services/issue/service.py +++ b/src/sentry/issues/services/issue/service.py @@ -8,7 +8,7 @@ from sentry.hybridcloud.rpc.resolvers import ByOrganizationId, ByOrganizationSlug, ByRegionName from sentry.hybridcloud.rpc.service import RpcService, regional_rpc_method -from sentry.issues.services.issue.model import RpcGroupShareMetadata +from sentry.issues.services.issue.model import RpcGroupShareMetadata, RpcLinkedIssueSummary from sentry.silo.base import SiloMode @@ -54,5 +54,29 @@ def upsert_issue_email_reply( ) -> None: pass + @regional_rpc_method(resolve=ByRegionName(), return_none_if_mapping_not_found=True) + @abstractmethod + def get_linked_issues( + self, + *, + region_name: str, + integration_id: int, + organization_ids: list[int], + external_issue_key: str, + ) -> list[RpcLinkedIssueSummary]: + """ + Returns a list of linked issue summaries for a given integration ID + + org ID combination. + + This is intended to be used for control to fan out to individual regions + in order to surface linked issue data related to a given set of + integration installations. + + `organization_ids` may be a little superfluous here, but allows us to + filter, if necessary, and validate that the issue data we're surfacing + _does_ belong to the integration installation. + """ + pass + issue_service = IssueService.create_delegation() diff --git a/tests/sentry/issues/services/test_issue_service.py b/tests/sentry/issues/services/test_issue_service.py new file mode 100644 index 00000000000000..7a4a38f990ac50 --- /dev/null +++ b/tests/sentry/issues/services/test_issue_service.py @@ -0,0 +1,223 @@ +from dataclasses import dataclass + +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.integrations.models.integration import Integration +from sentry.integrations.types import IntegrationProviderSlug +from sentry.interfaces.user import User +from sentry.issues.services.issue.model import RpcLinkedIssueSummary +from sentry.issues.services.issue.service import issue_service +from sentry.models.group import Group +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import all_silo_test, create_test_regions + + +@dataclass +class HcExternalIssueContext: + org_owner: User + region_name: str + organization: Organization + project: Project + + +@all_silo_test(regions=create_test_regions("de", "us")) +class TestIssueService(TestCase): + def setUp(self): + super().setUp() + self.default_jira_integration = self.create_integration( + provider=IntegrationProviderSlug.JIRA.value, + organization=self.organization, + external_id="jira-123", + name="Jira", + ) + self.us_region_context = self.create_region_context( + "us", + org_owner=self.create_user(email="us_test@example.com"), + integration=self.default_jira_integration, + ) + self.de_region_context = self.create_region_context( + "de", + org_owner=self.create_user(email="de_test@example.com"), + integration=self.default_jira_integration, + ) + + def create_region_context( + self, + region_name: str, + org_owner: User | None = None, + integration: Integration | None = None, + ) -> HcExternalIssueContext: + organization = self.create_organization( + name="test_org", + slug="test", + owner=org_owner, + region=region_name, + ) + + project = self.create_project(organization=organization) + + return HcExternalIssueContext( + region_name=region_name, + organization=organization, + project=project, + org_owner=org_owner, + ) + + def create_linked_issue( + self, + key: str, + region_context: HcExternalIssueContext, + group: Group, + integration: Integration, + title: str | None = None, + ) -> ExternalIssue: + + external_issue = self.create_integration_external_issue( + organization=region_context.organization, + group=group, + integration=integration, + key=key, + title=title, + ) + + return external_issue + + def test_get_linked_issues(self): + group = self.create_group(project=self.us_region_context.project) + self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=group, + integration=self.default_jira_integration, + title="US Group Link", + ) + + response = issue_service.get_linked_issues( + region_name=self.us_region_context.region_name, + integration_id=self.default_jira_integration.id, + organization_ids=[self.us_region_context.organization.id], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=group.get_absolute_url(), + title=group.title, + ) + ] + + def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self): + us_group = self.create_group(project=self.us_region_context.project) + us_linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=us_group, + integration=self.default_jira_integration, + title="US Group Link", + ) + + de_group = self.create_group(project=self.de_region_context.project) + de_linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.de_region_context, + group=de_group, + integration=self.default_jira_integration, + title="DE Group Link", + ) + + response = issue_service.get_linked_issues( + region_name=self.us_region_context.region_name, + integration_id=self.default_jira_integration.id, + organization_ids=[ + self.us_region_context.organization.id, + self.de_region_context.organization.id, + ], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + title=us_linked_issue.title, + ), + RpcLinkedIssueSummary( + issue_link=de_group.get_absolute_url(), + title=de_linked_issue.title, + ), + ] + + def test_get_empty_response_when_no_linked_issues(self): + response = issue_service.get_linked_issues( + region_name=self.us_region_context.region_name, + integration_id=self.default_jira_integration.id, + organization_ids=[self.us_region_context.organization.id], + external_issue_key="TEST-123", + ) + + assert response == [] + + def test_get_single_linked_issue_when_multiple_organizations_share_integration(self): + us_group = self.create_group(project=self.us_region_context.project) + us_linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=us_group, + integration=self.default_jira_integration, + title="US Group Link", + ) + + response = issue_service.get_linked_issues( + region_name=self.us_region_context.region_name, + integration_id=self.default_jira_integration.id, + organization_ids=[self.us_region_context.organization.id], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + title=us_linked_issue.title, + ) + ] + + def test_filters_out_issues_from_other_organizations(self): + us_group = self.create_group(project=self.us_region_context.project) + us_linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=us_group, + integration=self.default_jira_integration, + title="US Group Link", + ) + + other_integration = self.create_integration( + provider=IntegrationProviderSlug.JIRA.value, + organization=self.organization, + external_id="jira-456", + name="Other Jira", + ) + + unrelated_us_group = self.create_group(project=self.us_region_context.project) + # Create another linked issue with the same key but different integration. + self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=unrelated_us_group, + integration=other_integration, + title="Unrelated US Group Link", + ) + + response = issue_service.get_linked_issues( + region_name=self.us_region_context.region_name, + integration_id=self.default_jira_integration.id, + organization_ids=[self.us_region_context.organization.id], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + title=us_linked_issue.title, + ) + ] From 204442340858ab6af664eb2483ff316453f11975 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Wed, 30 Jul 2025 10:14:13 -0700 Subject: [PATCH 2/5] Removes title from linked issues payload, adds date_added --- src/sentry/issues/services/issue/impl.py | 4 ++-- src/sentry/issues/services/issue/model.py | 21 +++---------------- .../issues/services/test_issue_service.py | 20 +++++++----------- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/sentry/issues/services/issue/impl.py b/src/sentry/issues/services/issue/impl.py index 412928bd95c019..e0ea13e7f6de7a 100644 --- a/src/sentry/issues/services/issue/impl.py +++ b/src/sentry/issues/services/issue/impl.py @@ -6,6 +6,7 @@ from sentry.integrations.models.external_issue import ExternalIssue from sentry.issues.services.issue.model import RpcGroupShareMetadata, RpcLinkedIssueSummary +from sentry.issues.services.issue.serial import serialize_linked_issue_summary from sentry.issues.services.issue.service import IssueService from sentry.models.group import Group from sentry.models.organization import Organization @@ -65,6 +66,5 @@ def get_linked_issues( ).order_by("date_added") return [ - RpcLinkedIssueSummary.from_external_issue(external_issue) - for external_issue in external_issues + serialize_linked_issue_summary(external_issue) for external_issue in external_issues ] diff --git a/src/sentry/issues/services/issue/model.py b/src/sentry/issues/services/issue/model.py index 2ca3aa3af7a7cc..fae2971b43aa6b 100644 --- a/src/sentry/issues/services/issue/model.py +++ b/src/sentry/issues/services/issue/model.py @@ -1,8 +1,8 @@ from __future__ import annotations +from datetime import datetime + from sentry.hybridcloud.rpc import RpcModel -from sentry.integrations.models.external_issue import ExternalIssue -from sentry.models.grouplink import GroupLink class RpcGroupShareMetadata(RpcModel): @@ -11,20 +11,5 @@ class RpcGroupShareMetadata(RpcModel): class RpcLinkedIssueSummary(RpcModel): - title: str issue_link: str - - @classmethod - def from_external_issue(cls, external_issue: ExternalIssue) -> RpcLinkedIssueSummary: - group_link = GroupLink.objects.get( - linked_id=external_issue.id, - linked_type=GroupLink.LinkedType.issue, - relationship=GroupLink.Relationship.references, - ) - - group_url = group_link.group.get_absolute_url() - - return RpcLinkedIssueSummary( - title=external_issue.title or "", - issue_link=group_url, - ) + date_added: datetime diff --git a/tests/sentry/issues/services/test_issue_service.py b/tests/sentry/issues/services/test_issue_service.py index 7a4a38f990ac50..6bae1e64535b74 100644 --- a/tests/sentry/issues/services/test_issue_service.py +++ b/tests/sentry/issues/services/test_issue_service.py @@ -85,12 +85,11 @@ def create_linked_issue( def test_get_linked_issues(self): group = self.create_group(project=self.us_region_context.project) - self.create_linked_issue( + linked_issue = self.create_linked_issue( key="TEST-123", region_context=self.us_region_context, group=group, integration=self.default_jira_integration, - title="US Group Link", ) response = issue_service.get_linked_issues( @@ -103,7 +102,7 @@ def test_get_linked_issues(self): assert response == [ RpcLinkedIssueSummary( issue_link=group.get_absolute_url(), - title=group.title, + date_added=linked_issue.date_added, ) ] @@ -114,7 +113,6 @@ def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self) region_context=self.us_region_context, group=us_group, integration=self.default_jira_integration, - title="US Group Link", ) de_group = self.create_group(project=self.de_region_context.project) @@ -123,7 +121,6 @@ def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self) region_context=self.de_region_context, group=de_group, integration=self.default_jira_integration, - title="DE Group Link", ) response = issue_service.get_linked_issues( @@ -139,11 +136,11 @@ def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self) assert response == [ RpcLinkedIssueSummary( issue_link=us_group.get_absolute_url(), - title=us_linked_issue.title, + date_added=us_linked_issue.date_added, ), RpcLinkedIssueSummary( issue_link=de_group.get_absolute_url(), - title=de_linked_issue.title, + date_added=de_linked_issue.date_added, ), ] @@ -159,12 +156,11 @@ def test_get_empty_response_when_no_linked_issues(self): def test_get_single_linked_issue_when_multiple_organizations_share_integration(self): us_group = self.create_group(project=self.us_region_context.project) - us_linked_issue = self.create_linked_issue( + linked_issue = self.create_linked_issue( key="TEST-123", region_context=self.us_region_context, group=us_group, integration=self.default_jira_integration, - title="US Group Link", ) response = issue_service.get_linked_issues( @@ -177,7 +173,7 @@ def test_get_single_linked_issue_when_multiple_organizations_share_integration(s assert response == [ RpcLinkedIssueSummary( issue_link=us_group.get_absolute_url(), - title=us_linked_issue.title, + date_added=linked_issue.date_added, ) ] @@ -188,7 +184,6 @@ def test_filters_out_issues_from_other_organizations(self): region_context=self.us_region_context, group=us_group, integration=self.default_jira_integration, - title="US Group Link", ) other_integration = self.create_integration( @@ -205,7 +200,6 @@ def test_filters_out_issues_from_other_organizations(self): region_context=self.us_region_context, group=unrelated_us_group, integration=other_integration, - title="Unrelated US Group Link", ) response = issue_service.get_linked_issues( @@ -218,6 +212,6 @@ def test_filters_out_issues_from_other_organizations(self): assert response == [ RpcLinkedIssueSummary( issue_link=us_group.get_absolute_url(), - title=us_linked_issue.title, + date_added=us_linked_issue.date_added, ) ] From 3ce9ab122e188aefb889cdfc304e4f0d45d7f10d Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Wed, 30 Jul 2025 11:33:20 -0700 Subject: [PATCH 3/5] Adds missing serial file --- src/sentry/issues/services/issue/serial.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/sentry/issues/services/issue/serial.py diff --git a/src/sentry/issues/services/issue/serial.py b/src/sentry/issues/services/issue/serial.py new file mode 100644 index 00000000000000..5b06c5ca1025a6 --- /dev/null +++ b/src/sentry/issues/services/issue/serial.py @@ -0,0 +1,18 @@ +from sentry.integrations.models.external_issue import ExternalIssue +from sentry.issues.services.issue.model import RpcLinkedIssueSummary +from sentry.models.grouplink import GroupLink + + +def serialize_linked_issue_summary(external_issue: ExternalIssue) -> RpcLinkedIssueSummary: + group_link = GroupLink.objects.get( + linked_id=external_issue.id, + linked_type=GroupLink.LinkedType.issue, + relationship=GroupLink.Relationship.references, + ) + + group_url = group_link.group.get_absolute_url() + + return RpcLinkedIssueSummary( + issue_link=group_url, + date_added=external_issue.date_added, + ) From fcd1517a7e0cf20a6178d0407af67bd12df419b4 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Wed, 30 Jul 2025 12:46:17 -0700 Subject: [PATCH 4/5] Renames issue summary rpc method --- src/sentry/issues/services/issue/impl.py | 2 +- src/sentry/issues/services/issue/service.py | 2 +- tests/sentry/issues/services/test_issue_service.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/sentry/issues/services/issue/impl.py b/src/sentry/issues/services/issue/impl.py index e0ea13e7f6de7a..4917d8a2023a65 100644 --- a/src/sentry/issues/services/issue/impl.py +++ b/src/sentry/issues/services/issue/impl.py @@ -48,7 +48,7 @@ def upsert_issue_email_reply( # correctly should this fail. process_inbound_email(from_email, group_id, text) - def get_linked_issues( + def get_integration_linked_issue_summaries( self, *, region_name: str, diff --git a/src/sentry/issues/services/issue/service.py b/src/sentry/issues/services/issue/service.py index 2ecb1739f42070..3e655274aa3fbd 100644 --- a/src/sentry/issues/services/issue/service.py +++ b/src/sentry/issues/services/issue/service.py @@ -56,7 +56,7 @@ def upsert_issue_email_reply( @regional_rpc_method(resolve=ByRegionName(), return_none_if_mapping_not_found=True) @abstractmethod - def get_linked_issues( + def get_integration_linked_issue_summaries( self, *, region_name: str, diff --git a/tests/sentry/issues/services/test_issue_service.py b/tests/sentry/issues/services/test_issue_service.py index 6bae1e64535b74..1c472c5c6cb5eb 100644 --- a/tests/sentry/issues/services/test_issue_service.py +++ b/tests/sentry/issues/services/test_issue_service.py @@ -92,7 +92,7 @@ def test_get_linked_issues(self): integration=self.default_jira_integration, ) - response = issue_service.get_linked_issues( + response = issue_service.get_integration_linked_issue_summaries( region_name=self.us_region_context.region_name, integration_id=self.default_jira_integration.id, organization_ids=[self.us_region_context.organization.id], @@ -123,7 +123,7 @@ def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self) integration=self.default_jira_integration, ) - response = issue_service.get_linked_issues( + response = issue_service.get_integration_linked_issue_summaries( region_name=self.us_region_context.region_name, integration_id=self.default_jira_integration.id, organization_ids=[ @@ -145,7 +145,7 @@ def test_get_linked_issues_with_multiple_organizations_in_multiple_regions(self) ] def test_get_empty_response_when_no_linked_issues(self): - response = issue_service.get_linked_issues( + response = issue_service.get_integration_linked_issue_summaries( region_name=self.us_region_context.region_name, integration_id=self.default_jira_integration.id, organization_ids=[self.us_region_context.organization.id], @@ -163,7 +163,7 @@ def test_get_single_linked_issue_when_multiple_organizations_share_integration(s integration=self.default_jira_integration, ) - response = issue_service.get_linked_issues( + response = issue_service.get_integration_linked_issue_summaries( region_name=self.us_region_context.region_name, integration_id=self.default_jira_integration.id, organization_ids=[self.us_region_context.organization.id], @@ -202,7 +202,7 @@ def test_filters_out_issues_from_other_organizations(self): integration=other_integration, ) - response = issue_service.get_linked_issues( + response = issue_service.get_integration_linked_issue_summaries( region_name=self.us_region_context.region_name, integration_id=self.default_jira_integration.id, organization_ids=[self.us_region_context.organization.id], From 68dd66156fcff52c8878b4d552c3cc7feac10e68 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Wed, 30 Jul 2025 14:01:33 -0700 Subject: [PATCH 5/5] Fixes tests and typing --- tests/sentry/issues/services/test_issue_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/sentry/issues/services/test_issue_service.py b/tests/sentry/issues/services/test_issue_service.py index 1c472c5c6cb5eb..a3077544457586 100644 --- a/tests/sentry/issues/services/test_issue_service.py +++ b/tests/sentry/issues/services/test_issue_service.py @@ -3,7 +3,6 @@ from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.models.integration import Integration from sentry.integrations.types import IntegrationProviderSlug -from sentry.interfaces.user import User from sentry.issues.services.issue.model import RpcLinkedIssueSummary from sentry.issues.services.issue.service import issue_service from sentry.models.group import Group @@ -11,11 +10,12 @@ from sentry.models.project import Project from sentry.testutils.cases import TestCase from sentry.testutils.silo import all_silo_test, create_test_regions +from sentry.users.models import User @dataclass class HcExternalIssueContext: - org_owner: User + org_owner: User | None region_name: str organization: Organization project: Project @@ -34,19 +34,16 @@ def setUp(self): self.us_region_context = self.create_region_context( "us", org_owner=self.create_user(email="us_test@example.com"), - integration=self.default_jira_integration, ) self.de_region_context = self.create_region_context( "de", org_owner=self.create_user(email="de_test@example.com"), - integration=self.default_jira_integration, ) def create_region_context( self, region_name: str, org_owner: User | None = None, - integration: Integration | None = None, ) -> HcExternalIssueContext: organization = self.create_organization( name="test_org",