Skip to content

Commit 309f073

Browse files
cvxluoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent dd04e7c commit 309f073

5 files changed

Lines changed: 60 additions & 36 deletions

File tree

src/sentry/api/helpers/group_index/index.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -176,27 +176,28 @@ def validate_search_filter_permissions(
176176
)
177177

178178

179-
def get_by_short_id(
179+
def get_by_short_ids(
180180
organization_id: int,
181181
is_short_id_lookup: str,
182182
query: str,
183183
*,
184184
project_ids: Collection[int] | None,
185-
) -> Group | None:
185+
) -> list[Group]:
186+
# Match a short id token anywhere in the query so it composes with filters
186187
if is_short_id_lookup != "1":
187-
return None
188-
# A short id token anywhere in the query is treated as a direct hit,
189-
# so it composes with filters. project_ids scopes the lookup so a short id for a project
190-
# the caller cannot access does not resolve; pass None only for org-wide callers.
188+
return []
189+
groups: dict[int, Group] = {}
191190
for token in query.split():
192-
if looks_like_short_id(token):
193-
try:
194-
return Group.objects.by_qualified_short_id(
195-
organization_id, token, project_ids=project_ids
196-
)
197-
except Group.DoesNotExist:
198-
continue
199-
return None
191+
if not looks_like_short_id(token):
192+
continue
193+
try:
194+
group = Group.objects.by_qualified_short_id(
195+
organization_id, token, project_ids=project_ids
196+
)
197+
except Group.DoesNotExist:
198+
continue
199+
groups.setdefault(group.id, group)
200+
return list(groups.values())
200201

201202

202203
def track_slo_response(name: str) -> Callable[[EndpointFunction], EndpointFunction]:

src/sentry/issues/endpoints/organization_group_index.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from sentry.api.helpers.group_index import (
2424
build_query_params_from_request,
2525
calculate_stats_period,
26-
get_by_short_id,
26+
get_by_short_ids,
2727
schedule_tasks_to_delete_groups,
2828
track_slo_response,
2929
update_groups_with_search_fn,
@@ -406,21 +406,23 @@ def get(
406406
)
407407
return Response(by_event)
408408

409-
group = get_by_short_id(
409+
short_id_groups = get_by_short_ids(
410410
organization.id,
411411
request.GET.get("shortIdLookup") or "0",
412412
query,
413413
project_ids=None,
414414
)
415-
if group is not None:
416-
# check all projects user has access to
417-
if request.access.has_project_access(group.project):
418-
by_short_id: list[StreamGroupSerializerSnubaResponse] = serialize(
419-
[group], request.user, serializer(), request=request
420-
)
421-
response = Response(by_short_id)
415+
accessible = [
416+
g for g in short_id_groups if request.access.has_project_access(g.project)
417+
]
418+
if accessible:
419+
by_short_id: list[StreamGroupSerializerSnubaResponse] = serialize(
420+
accessible, request.user, serializer(), request=request
421+
)
422+
response = Response(by_short_id)
423+
if len(accessible) == 1:
422424
response["X-Sentry-Direct-Hit"] = "1"
423-
return response
425+
return response
424426

425427
# If group ids specified, just ignore any query components
426428
try:

src/sentry/issues/endpoints/project_group_index.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission
1515
from sentry.api.helpers.environments import get_environment_func
1616
from sentry.api.helpers.group_index import (
17-
get_by_short_id,
17+
get_by_short_ids,
1818
prep_search,
1919
schedule_tasks_to_delete_groups,
2020
track_slo_response,
@@ -162,26 +162,26 @@ def get(self, request: Request, project: Project) -> Response:
162162
return Response(serialized_groups)
163163

164164
if query:
165-
matching_group = None
165+
matching_groups: list[Group] = []
166166
matching_event = None
167167
event_id = normalize_event_id(query)
168168
if event_id:
169169
# check to see if we've got an event ID
170170
try:
171-
matching_group = Group.objects.from_event_id(project, event_id)
171+
matching_groups = [Group.objects.from_event_id(project, event_id)]
172172
except Group.DoesNotExist:
173173
pass
174174
else:
175175
matching_event = eventstore.backend.get_event_by_id(project.id, event_id)
176-
elif matching_group is None:
177-
matching_group = get_by_short_id(
176+
else:
177+
matching_groups = get_by_short_ids(
178178
project.organization_id,
179179
request.GET.get("shortIdLookup", "0"),
180180
query,
181181
project_ids=[project.id],
182182
)
183183

184-
if matching_group is not None:
184+
if matching_groups:
185185
matching_event_environment = None
186186

187187
try:
@@ -191,18 +191,16 @@ def get(self, request: Request, project: Project) -> Response:
191191
except Environment.DoesNotExist:
192192
pass
193193

194-
serialized_groups = serialize([matching_group], request.user, serializer)
194+
serialized_groups = serialize(matching_groups, request.user, serializer)
195195
matching_event_id = getattr(matching_event, "event_id", None)
196196
if matching_event_id:
197-
serialized_groups[0]["matchingEventId"] = getattr(
198-
matching_event, "event_id", None
199-
)
197+
serialized_groups[0]["matchingEventId"] = matching_event_id
200198
if matching_event_environment:
201199
serialized_groups[0]["matchingEventEnvironment"] = matching_event_environment
202200

203201
response = Response(serialized_groups)
204-
205-
response["X-Sentry-Direct-Hit"] = "1"
202+
if len(matching_groups) == 1:
203+
response["X-Sentry-Direct-Hit"] = "1"
206204
return response
207205

208206
try:

tests/sentry/issues/endpoints/test_organization_group_index.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,17 @@ def test_lookup_by_short_id_with_filter_resolved(self) -> None:
782782
assert response.data[0]["id"] == str(group.id)
783783
assert response["X-Sentry-Direct-Hit"] == "1"
784784

785+
def test_lookup_by_multiple_short_ids(self) -> None:
786+
group = self.group
787+
group2 = self.create_group()
788+
789+
self.login_as(user=self.user)
790+
response = self.get_success_response(
791+
query=f"{group.qualified_short_id} {group2.qualified_short_id}", shortIdLookup=1
792+
)
793+
assert {r["id"] for r in response.data} == {str(group.id), str(group2.id)}
794+
assert response.get("X-Sentry-Direct-Hit") != "1"
795+
785796
def test_lookup_by_group_id(self) -> None:
786797
self.login_as(user=self.user)
787798
response = self.get_success_response(group=self.group.id)

tests/snuba/api/endpoints/test_project_group_index.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,18 @@ def test_lookup_by_short_id(self) -> None:
257257
response = self.client.get(f"{self.path}?query={short_id}&shortIdLookup=1", format="json")
258258
assert response.status_code == 200
259259
assert len(response.data) == 1
260+
assert response["X-Sentry-Direct-Hit"] == "1"
261+
262+
def test_lookup_by_multiple_short_ids(self) -> None:
263+
group = self.group
264+
group2 = self.create_group(project=self.project)
265+
266+
self.login_as(user=self.user)
267+
query = f"{group.qualified_short_id} {group2.qualified_short_id}"
268+
response = self.client.get(f"{self.path}?query={query}&shortIdLookup=1", format="json")
269+
assert response.status_code == 200
270+
assert {r["id"] for r in response.data} == {str(group.id), str(group2.id)}
271+
assert response.get("X-Sentry-Direct-Hit") != "1"
260272

261273
def test_lookup_by_short_id_no_perms(self) -> None:
262274
organization = self.create_organization()

0 commit comments

Comments
 (0)