Skip to content
Draft
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
46 changes: 43 additions & 3 deletions src/sentry/utils/groupreference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
122 changes: 122 additions & 0 deletions tests/sentry/models/test_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions tests/sentry/models/test_pullrequest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading