From cce78065c7fe0ff7ead625a7de8a2cd1e96f5877 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Thu, 18 Jun 2026 09:05:24 -0700 Subject: [PATCH] fix(issues): return all matched short ids instead of redirecting to one Scanning each query token for a short id meant a query with multiple short ids (e.g. SENTRY-1 SENTRY-2) redirected to whichever resolved first. Return every matched group instead, and only set the direct-hit header when a single short id matches, mirroring how multiple group ids are handled. Co-authored-by: Claude --- src/sentry/api/helpers/group_index/index.py | 30 +++++++++---------- .../endpoints/organization_group_index.py | 22 +++++++------- .../issues/endpoints/project_group_index.py | 22 +++++++------- .../test_organization_group_index.py | 11 +++++++ .../api/endpoints/test_project_group_index.py | 12 ++++++++ 5 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/sentry/api/helpers/group_index/index.py b/src/sentry/api/helpers/group_index/index.py index efb45cd0f64e4a..a518c7d71e02f0 100644 --- a/src/sentry/api/helpers/group_index/index.py +++ b/src/sentry/api/helpers/group_index/index.py @@ -176,27 +176,27 @@ def validate_search_filter_permissions( ) -def get_by_short_id( +def get_by_short_ids( organization_id: int, is_short_id_lookup: str, query: str, *, project_ids: Collection[int] | None, -) -> Group | None: +) -> list[Group]: + # Match short id tokens anywhere in the query if is_short_id_lookup != "1": - return None - # A short id token anywhere in the query is treated as a direct hit, - # so it composes with filters. project_ids scopes the lookup so a short id for a project - # the caller cannot access does not resolve; pass None only for org-wide callers. - for token in query.split(): - if looks_like_short_id(token): - try: - return Group.objects.by_qualified_short_id( - organization_id, token, project_ids=project_ids - ) - except Group.DoesNotExist: - continue - return None + return [] + groups: list[Group] = [] + for token in set(query.split()): + if not looks_like_short_id(token): + continue + try: + groups.append( + Group.objects.by_qualified_short_id(organization_id, token, project_ids=project_ids) + ) + except Group.DoesNotExist: + continue + return groups def track_slo_response(name: str) -> Callable[[EndpointFunction], EndpointFunction]: diff --git a/src/sentry/issues/endpoints/organization_group_index.py b/src/sentry/issues/endpoints/organization_group_index.py index 16191ec1149c93..5254ce5ec2a5b9 100644 --- a/src/sentry/issues/endpoints/organization_group_index.py +++ b/src/sentry/issues/endpoints/organization_group_index.py @@ -23,7 +23,7 @@ from sentry.api.helpers.group_index import ( build_query_params_from_request, calculate_stats_period, - get_by_short_id, + get_by_short_ids, schedule_tasks_to_delete_groups, track_slo_response, update_groups_with_search_fn, @@ -406,21 +406,23 @@ def get( ) return Response(by_event) - group = get_by_short_id( + short_id_groups = get_by_short_ids( organization.id, request.GET.get("shortIdLookup") or "0", query, project_ids=None, ) - if group is not None: - # check all projects user has access to - if request.access.has_project_access(group.project): - by_short_id: list[StreamGroupSerializerSnubaResponse] = serialize( - [group], request.user, serializer(), request=request - ) - response = Response(by_short_id) + accessible = [ + g for g in short_id_groups if request.access.has_project_access(g.project) + ] + if accessible: + by_short_id: list[StreamGroupSerializerSnubaResponse] = serialize( + accessible, request.user, serializer(), request=request + ) + response = Response(by_short_id) + if len(accessible) == 1: response["X-Sentry-Direct-Hit"] = "1" - return response + return response # If group ids specified, just ignore any query components try: diff --git a/src/sentry/issues/endpoints/project_group_index.py b/src/sentry/issues/endpoints/project_group_index.py index a2d49e8fb89e75..d7fd221d1ee05e 100644 --- a/src/sentry/issues/endpoints/project_group_index.py +++ b/src/sentry/issues/endpoints/project_group_index.py @@ -14,7 +14,7 @@ from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission from sentry.api.helpers.environments import get_environment_func from sentry.api.helpers.group_index import ( - get_by_short_id, + get_by_short_ids, prep_search, schedule_tasks_to_delete_groups, track_slo_response, @@ -162,26 +162,26 @@ def get(self, request: Request, project: Project) -> Response: return Response(serialized_groups) if query: - matching_group = None + matching_groups: list[Group] = [] matching_event = None event_id = normalize_event_id(query) if event_id: # check to see if we've got an event ID try: - matching_group = Group.objects.from_event_id(project, event_id) + matching_groups = [Group.objects.from_event_id(project, event_id)] except Group.DoesNotExist: pass else: matching_event = eventstore.backend.get_event_by_id(project.id, event_id) - elif matching_group is None: - matching_group = get_by_short_id( + else: + matching_groups = get_by_short_ids( project.organization_id, request.GET.get("shortIdLookup", "0"), query, project_ids=[project.id], ) - if matching_group is not None: + if matching_groups: matching_event_environment = None try: @@ -191,18 +191,16 @@ def get(self, request: Request, project: Project) -> Response: except Environment.DoesNotExist: pass - serialized_groups = serialize([matching_group], request.user, serializer) + serialized_groups = serialize(matching_groups, request.user, serializer) matching_event_id = getattr(matching_event, "event_id", None) if matching_event_id: - serialized_groups[0]["matchingEventId"] = getattr( - matching_event, "event_id", None - ) + serialized_groups[0]["matchingEventId"] = matching_event_id if matching_event_environment: serialized_groups[0]["matchingEventEnvironment"] = matching_event_environment response = Response(serialized_groups) - - response["X-Sentry-Direct-Hit"] = "1" + if len(matching_groups) == 1: + response["X-Sentry-Direct-Hit"] = "1" return response try: diff --git a/tests/sentry/issues/endpoints/test_organization_group_index.py b/tests/sentry/issues/endpoints/test_organization_group_index.py index 0f6397369cffdd..e7efd11b06704a 100644 --- a/tests/sentry/issues/endpoints/test_organization_group_index.py +++ b/tests/sentry/issues/endpoints/test_organization_group_index.py @@ -782,6 +782,17 @@ def test_lookup_by_short_id_with_filter_resolved(self) -> None: assert response.data[0]["id"] == str(group.id) assert response["X-Sentry-Direct-Hit"] == "1" + def test_lookup_by_multiple_short_ids(self) -> None: + group = self.group + group2 = self.create_group() + + self.login_as(user=self.user) + response = self.get_success_response( + query=f"{group.qualified_short_id} {group2.qualified_short_id}", shortIdLookup=1 + ) + assert {r["id"] for r in response.data} == {str(group.id), str(group2.id)} + assert response.get("X-Sentry-Direct-Hit") != "1" + def test_lookup_by_group_id(self) -> None: self.login_as(user=self.user) response = self.get_success_response(group=self.group.id) diff --git a/tests/snuba/api/endpoints/test_project_group_index.py b/tests/snuba/api/endpoints/test_project_group_index.py index ff66dba921c5a5..01d367a20fb533 100644 --- a/tests/snuba/api/endpoints/test_project_group_index.py +++ b/tests/snuba/api/endpoints/test_project_group_index.py @@ -257,6 +257,18 @@ def test_lookup_by_short_id(self) -> None: response = self.client.get(f"{self.path}?query={short_id}&shortIdLookup=1", format="json") assert response.status_code == 200 assert len(response.data) == 1 + assert response["X-Sentry-Direct-Hit"] == "1" + + def test_lookup_by_multiple_short_ids(self) -> None: + group = self.group + group2 = self.create_group(project=self.project) + + self.login_as(user=self.user) + query = f"{group.qualified_short_id} {group2.qualified_short_id}" + response = self.client.get(f"{self.path}?query={query}&shortIdLookup=1", format="json") + assert response.status_code == 200 + assert {r["id"] for r in response.data} == {str(group.id), str(group2.id)} + assert response.get("X-Sentry-Direct-Hit") != "1" def test_lookup_by_short_id_no_perms(self) -> None: organization = self.create_organization()