Skip to content
Closed
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
41 changes: 40 additions & 1 deletion src/sentry/issues/endpoints/organization_group_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases import OrganizationEventPermission
from sentry.api.bases.organization import OrganizationEndpoint
from sentry.api.event_search import SearchFilter
from sentry.api.event_search import AggregateFilter, SearchFilter
from sentry.api.helpers.group_index import (
build_query_params_from_request,
calculate_stats_period,
Expand All @@ -28,6 +28,7 @@
track_slo_response,
update_groups_with_search_fn,
)
from sentry.api.helpers.group_index.index import parse_and_convert_issue_search_query
from sentry.api.helpers.group_index.types import MutateIssueResponse
from sentry.api.helpers.group_index.validators import ValidationError
from sentry.api.helpers.group_index.validators.group import GroupValidator
Expand Down Expand Up @@ -164,6 +165,29 @@ def inbox_search(
return results


def _get_issue_id_shortcut_ids(
search_filters: Sequence[SearchFilter | AggregateFilter],
) -> list[int] | None:
"""If the search is exclusively an issue.id lookup with no other filters,
return the group IDs to short-circuit Snuba. Returns None otherwise."""
if len(search_filters) != 1:
return None
sf = search_filters[0]
if isinstance(sf, AggregateFilter):
return None
if sf.key.name != "issue.id" or sf.operator not in EQUALITY_OPERATORS:
return None
raw = sf.value.raw_value
try:
if isinstance(raw, (list, tuple)):
return [int(v) for v in raw]
if isinstance(raw, (int, float, str)):
return [int(raw)]
except (ValueError, TypeError):
pass
return None


@extend_schema(tags=["Events"])
@cell_silo_endpoint
class OrganizationGroupIndexEndpoint(OrganizationEndpoint):
Expand Down Expand Up @@ -343,6 +367,21 @@ def get(self, request: Request, organization: Organization) -> Response:
except ValueError:
return Response({"detail": "Group ids must be integers"}, status=400)

# Also extract group IDs from issue.id search filters when the query
# has no Snuba-dependent filters, so we can skip Snuba entirely.
if not group_ids and query and "issue.id:" in query:
try:
parsed_filters = parse_and_convert_issue_search_query(
query, organization, projects, environments, request.user
)
except ValidationError:
parsed_filters = None

if parsed_filters is not None:
shortcut_ids = _get_issue_id_shortcut_ids(parsed_filters)
if shortcut_ids:
group_ids = set(shortcut_ids)

if group_ids:
groups = list(Group.objects.filter(id__in=group_ids, project_id__in=project_ids))
if any(g for g in groups if not request.access.has_project_access(g.project)):
Expand Down
80 changes: 80 additions & 0 deletions tests/sentry/issues/endpoints/test_organization_group_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,86 @@ def test_lookup_by_group_id_no_perms(self) -> None:
response = self.get_response(group=[group.id])
assert response.status_code == 403

def test_issue_id_shortcut_single(self) -> None:
event = self.store_event(
data={"timestamp": before_now(seconds=1).isoformat()},
project_id=self.project.id,
)
self.login_as(user=self.user)

with mock.patch(
"sentry.search.snuba.executors.PostgresSnubaQueryExecutor.query"
) as mock_query:
response = self.get_success_response(query=f"issue.id:{event.group.id}")

mock_query.assert_not_called()
assert len(response.data) == 1
assert response.data[0]["id"] == str(event.group.id)

def test_issue_id_shortcut_multiple(self) -> None:
event1 = self.store_event(
data={
"timestamp": before_now(seconds=2).isoformat(),
"fingerprint": ["group-1"],
},
project_id=self.project.id,
)
event2 = self.store_event(
data={
"timestamp": before_now(seconds=1).isoformat(),
"fingerprint": ["group-2"],
},
project_id=self.project.id,
)
self.login_as(user=self.user)

with mock.patch(
"sentry.search.snuba.executors.PostgresSnubaQueryExecutor.query"
) as mock_query:
response = self.get_success_response(
query=f"issue.id:[{event1.group.id},{event2.group.id}]"
)

mock_query.assert_not_called()
assert len(response.data) == 2
returned_ids = {r["id"] for r in response.data}
assert returned_ids == {str(event1.group.id), str(event2.group.id)}

def test_issue_id_shortcut_with_snuba_filter_falls_through(self) -> None:
"""When issue.id is combined with a Snuba-dependent filter like message,
the shortcut should NOT be used and the normal search path runs."""
event = self.store_event(
data={
"timestamp": before_now(seconds=1).isoformat(),
"message": "test message",
},
project_id=self.project.id,
)
self.login_as(user=self.user)

with mock.patch(
"sentry.search.snuba.executors.PostgresSnubaQueryExecutor.query",
wraps=PostgresSnubaQueryExecutor().query,
) as mock_query:
self.get_success_response(query=f"issue.id:{event.group.id} message:test")

mock_query.assert_called()

def test_issue_id_shortcut_respects_project_scope(self) -> None:
"""issue.id shortcut should only return groups within the requested projects."""
other_project = self.create_project(organization=self.organization)
event = self.store_event(
data={"timestamp": before_now(seconds=1).isoformat()},
project_id=other_project.id,
)
self.login_as(user=self.user)

# Request scoped to self.project — should not return the other project's group
response = self.get_success_response(
query=f"issue.id:{event.group.id}", project=[self.project.id]
)
assert len(response.data) == 0

def test_lookup_by_first_release(self) -> None:
self.login_as(self.user)
project = self.project
Expand Down
Loading