diff --git a/src/sentry/utils/groupreference.py b/src/sentry/utils/groupreference.py index 64bcdb8c9b7442..cc158e23c6e253 100644 --- a/src/sentry/utils/groupreference.py +++ b/src/sentry/utils/groupreference.py @@ -9,11 +9,21 @@ _markdown_strip_re = re.compile(r"\[([^]]+)\]\([^)]+\)", re.I) _fixes_re = re.compile( - r"\b(?:Fix|Fixes|Fixed|Close|Closes|Closed|Resolve|Resolves|Resolved):?\s+([A-Za-z0-9_\-\s\,]+)\b", - re.I, + r"\b(?:Fix|Fixes|Fixed|Close|Closes|Closed|Resolve|Resolves|Resolved):?\s+(.+?)(?=\n\n|\Z)", + re.I | re.DOTALL, ) _short_id_re = re.compile(r"\b([A-Z0-9_-]+-[A-Z0-9]+)\b", re.I) +# Match Sentry issue URLs in various formats: +# - https://sentry.io/organizations/{org}/issues/{id}/ +# - https://{domain}/organizations/{org}/issues/{id}/ +# - https://sentry.sentry.io/issues/{id}/ +# The ID can be either a numeric ID or a qualified short ID like SENTRY-123 +_sentry_url_re = re.compile( + r"https?://[^/]+(?:/organizations/[^/]+)?/issues/([A-Z0-9_-]+(?:-[A-Z0-9]+)?|\d+)/?", + re.I, +) + def find_referenced_groups(text: str | None, org_id: int) -> set[Group]: from sentry.models.group import Group @@ -28,8 +38,15 @@ def find_referenced_groups(text: str | None, org_id: int) -> set[Group]: text = _markdown_strip_re.sub(r"\1", text) results = set() + + # Look for issue references after "Fixes/Resolves/Closes" keywords + # This handles both short IDs (SENTRY-123) and Sentry URLs for fmatch in _fixes_re.finditer(text): - for smatch in _short_id_re.finditer(fmatch.group(1)): + # The captured group contains everything after the keyword + ref_text = fmatch.group(1) + + # First, look for short IDs in "Fixes SENTRY-123" style references + for smatch in _short_id_re.finditer(ref_text): short_id = smatch.group(1) try: group = Group.objects.by_qualified_short_id( @@ -39,4 +56,27 @@ def find_referenced_groups(text: str | None, org_id: int) -> set[Group]: continue else: results.add(group) + + # Second, look for Sentry issue URLs after "Fixes/Resolves/Closes" + for url_match in _sentry_url_re.finditer(ref_text): + issue_id = url_match.group(1) + + # Try to determine if this is a short ID or numeric ID + if "-" in issue_id: + # This looks like a short ID (e.g., SENTRY-123) + try: + group = Group.objects.by_qualified_short_id( + organization_id=org_id, short_id=issue_id + ) + results.add(group) + except Group.DoesNotExist: + continue + else: + # This is a numeric ID + try: + group = Group.objects.get(id=int(issue_id), project__organization_id=org_id) + results.add(group) + except (Group.DoesNotExist, ValueError): + continue + return results diff --git a/tests/sentry/models/test_commit.py b/tests/sentry/models/test_commit.py index 52ebd4584ac992..e6470e1ac66652 100644 --- a/tests/sentry/models/test_commit.py +++ b/tests/sentry/models/test_commit.py @@ -95,3 +95,125 @@ def test_markdown_links(self) -> None: assert len(groups) == 2 assert group in groups assert group2 in groups + + def test_sentry_issue_url_with_numeric_id(self) -> None: + """Test that pasting a Sentry issue URL with numeric ID works""" + group = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + # Test URL with org slug format (URL on same line as Fixes) + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group.organization.id, + message=f"Fixes https://sentry.io/organizations/test-org/issues/{group.id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 1 + assert group in groups + + def test_sentry_issue_url_short_format(self) -> None: + """Test URL in short format like https://sentry.sentry.io/issues/123/""" + group = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group.organization.id, + message=f"Fix n+1 issue\nhttps://sentry.sentry.io/issues/{group.id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 1 + assert group in groups + + def test_sentry_issue_url_with_short_id(self) -> None: + """Test that URLs with short IDs like SENTRY-123 work""" + group = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group.organization.id, + message=f"Fixes https://sentry.io/organizations/test-org/issues/{group.qualified_short_id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 1 + assert group in groups + + def test_sentry_issue_url_without_fixes_keyword(self) -> None: + """Test that issue URLs require 'Fixes' keyword, just like short IDs""" + group = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + # URL without "Fixes" keyword should NOT be detected + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group.organization.id, + message=f"Reduce insert # on /broadcasts/\n\nn+1 issue\nhttps://sentry.sentry.io/issues/{group.id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 0 + + # But WITH "Fixes" it should work + commit2 = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group.organization.id, + message=f"Reduce insert # on /broadcasts/\n\nFixes n+1 issue\nhttps://sentry.sentry.io/issues/{group.id}/", + ) + + groups2 = commit2.find_referenced_groups() + assert len(groups2) == 1 + assert group in groups2 + + def test_sentry_issue_url_multiple(self) -> None: + """Test multiple issue URLs in one message""" + group1 = self.create_group() + group2 = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group1.organization.id, + message=f"Fixes multiple issues\n" + f"https://sentry.io/organizations/test-org/issues/{group1.id}/\n" + f"https://sentry.sentry.io/issues/{group2.id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 2 + assert group1 in groups + assert group2 in groups + + def test_sentry_issue_url_mixed_with_short_ids(self) -> None: + """Test that URLs and short IDs can be mixed in the same message""" + group1 = self.create_group() + group2 = self.create_group() + + repo = Repository.objects.create(name="example", organization_id=self.group.organization.id) + + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + repository_id=repo.id, + organization_id=group1.organization.id, + message=f"Fixes {group1.qualified_short_id} and\n" + f"https://sentry.io/organizations/test-org/issues/{group2.id}/", + ) + + groups = commit.find_referenced_groups() + assert len(groups) == 2 + assert group1 in groups + assert group2 in groups diff --git a/tests/sentry/models/test_pullrequest.py b/tests/sentry/models/test_pullrequest.py index 900c2b2cfaa8a5..8112883e850545 100644 --- a/tests/sentry/models/test_pullrequest.py +++ b/tests/sentry/models/test_pullrequest.py @@ -75,6 +75,47 @@ def test_resolve_in_pull_request(self) -> None: group.refresh_from_db() assert group.status == GroupStatus.UNRESOLVED + def test_resolve_with_sentry_issue_url(self) -> None: + """Test that pasting a Sentry issue URL in PR body associates the PR""" + group = self.create_group() + repo = Repository.objects.create(name="example", organization_id=group.organization.id) + + pr = PullRequest.objects.create( + key="1", + repository_id=repo.id, + organization_id=group.organization.id, + title="Fix n+1 query issue", + message=f"Reduce insert # on /broadcasts/ by bulk inserting\n\n" + f"Fixes n+1 issue\nhttps://sentry.sentry.io/issues/{group.id}/", + ) + + groups = pr.find_referenced_groups() + assert len(groups) == 1 + assert group in groups + # Verify GroupLink was created + assert GroupLink.objects.filter( + group=group, + linked_type=GroupLink.LinkedType.pull_request, + linked_id=pr.id, + ).exists() + + def test_resolve_with_issue_url_and_short_id(self) -> None: + """Test that issue URL with qualified short ID works""" + group = self.create_group() + repo = Repository.objects.create(name="example", organization_id=group.organization.id) + + pr = PullRequest.objects.create( + key="2", + repository_id=repo.id, + organization_id=group.organization.id, + title="Fix the bug", + message=f"Fixes https://sentry.io/organizations/test-org/issues/{group.qualified_short_id}/", + ) + + groups = pr.find_referenced_groups() + assert len(groups) == 1 + assert group in groups + class PullRequestRetentionTest(TestCase): def setUp(self):