diff --git a/src/sentry/issues/services/issue/impl.py b/src/sentry/issues/services/issue/impl.py index 819613f07e4bb6..4917d8a2023a65 100644 --- a/src/sentry/issues/services/issue/impl.py +++ b/src/sentry/issues/services/issue/impl.py @@ -4,7 +4,9 @@ # 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.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 @@ -45,3 +47,24 @@ 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_integration_linked_issue_summaries( + 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 [ + 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 b90c628f8966bf..fae2971b43aa6b 100644 --- a/src/sentry/issues/services/issue/model.py +++ b/src/sentry/issues/services/issue/model.py @@ -1,6 +1,15 @@ +from __future__ import annotations + +from datetime import datetime + from sentry.hybridcloud.rpc import RpcModel class RpcGroupShareMetadata(RpcModel): title: str message: str + + +class RpcLinkedIssueSummary(RpcModel): + issue_link: str + date_added: datetime 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, + ) diff --git a/src/sentry/issues/services/issue/service.py b/src/sentry/issues/services/issue/service.py index 3da4d316d670a9..3e655274aa3fbd 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_integration_linked_issue_summaries( + 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..a3077544457586 --- /dev/null +++ b/tests/sentry/issues/services/test_issue_service.py @@ -0,0 +1,214 @@ +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.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 +from sentry.users.models import User + + +@dataclass +class HcExternalIssueContext: + org_owner: User | None + 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"), + ) + self.de_region_context = self.create_region_context( + "de", + org_owner=self.create_user(email="de_test@example.com"), + ) + + def create_region_context( + self, + region_name: str, + org_owner: User | 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) + linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=group, + integration=self.default_jira_integration, + ) + + 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], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=group.get_absolute_url(), + date_added=linked_issue.date_added, + ) + ] + + 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, + ) + + 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, + ) + + 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, + self.de_region_context.organization.id, + ], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + date_added=us_linked_issue.date_added, + ), + RpcLinkedIssueSummary( + issue_link=de_group.get_absolute_url(), + date_added=de_linked_issue.date_added, + ), + ] + + def test_get_empty_response_when_no_linked_issues(self): + 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], + 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) + linked_issue = self.create_linked_issue( + key="TEST-123", + region_context=self.us_region_context, + group=us_group, + integration=self.default_jira_integration, + ) + + 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], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + date_added=linked_issue.date_added, + ) + ] + + 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, + ) + + 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, + ) + + 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], + external_issue_key="TEST-123", + ) + + assert response == [ + RpcLinkedIssueSummary( + issue_link=us_group.get_absolute_url(), + date_added=us_linked_issue.date_added, + ) + ]