Skip to content

Commit 0197ef6

Browse files
roggenkemperclaude
andcommitted
feat(search): Add issue progress sort
Add a "progress" issue sort that orders issues by how far they are through the fix cycle (fix_applied > fix_proposed > diagnosed > triaged > identified), with last_seen as a secondary key so the most recently active issue ranks highest within a tier. It reuses the existing PostgresSortStrategy framework: a signal resolver derives each group's rank from the same Activity records as the issue.progress filter (via get_group_progress_states), and the score encodes rank as the integer primary key plus a sub-1 recency fraction. On candidate overflow it falls back to the chunked Snuba last_seen path. The secondary key stands in for issue.last_progressed_at, which does not exist yet; swapping it in later is a one-line change to the score_fn. Gated behind organizations:issue-stream-progress-sort. Backend only; the frontend sort dropdown is not wired up yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b53d820 commit 0197ef6

5 files changed

Lines changed: 137 additions & 4 deletions

File tree

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
326326
manager.add("organizations:issue-stream-progress-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
327327
# Enable the experimental "recommended" sort option in the issue stream
328328
manager.add("organizations:issue-stream-recommended-sort", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
329+
# Enable the "progress" sort option (by issue fix-cycle progress) in the issue stream
330+
manager.add("organizations:issue-stream-progress-sort", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
329331

330332
# Lets organizations manage grouping configs
331333
manager.add("organizations:set-grouping-config", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True)

src/sentry/issues/endpoints/organization_group_index.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from rest_framework.response import Response
1313
from sentry_sdk import start_span
1414

15-
from sentry import analytics, search
15+
from sentry import analytics, features, search
1616
from sentry.analytics.events.issue_search_endpoint_queried import IssueSearchEndpointQueriedEvent
1717
from sentry.api.api_owners import ApiOwner
1818
from sentry.api.api_publish_status import ApiPublishStatus
@@ -54,7 +54,7 @@
5454
)
5555
from sentry.apidocs.response_types import DetailResponse, ValidationErrorResponse
5656
from sentry.apidocs.utils import inline_sentry_response_serializer
57-
from sentry.constants import ALLOWED_FUTURE_DELTA
57+
from sentry.constants import ALLOWED_FUTURE_DELTA, DEFAULT_SORT_OPTION
5858
from sentry.exceptions import InvalidSearchQuery
5959
from sentry.models.environment import Environment
6060
from sentry.models.group import QUERY_STATUS_LOOKUP, Group, GroupStatus
@@ -183,6 +183,10 @@ def search_issues(
183183
query_kwargs["environments"] = environments if environments else None
184184

185185
query_kwargs["actor"] = request.user
186+
if query_kwargs["sort_by"] == "progress" and not features.has(
187+
"organizations:issue-stream-progress-sort", organization, actor=request.user
188+
):
189+
query_kwargs["sort_by"] = DEFAULT_SORT_OPTION
186190
if query_kwargs["sort_by"] == "inbox":
187191
query_kwargs.pop("sort_by")
188192
query_kwargs.pop("referrer")

src/sentry/search/snuba/executors.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sentry.constants import ALLOWED_FUTURE_DELTA
2525
from sentry.db.models.manager.base_query_set import BaseQuerySet
2626
from sentry.issues.grouptype import GroupCategory
27+
from sentry.issues.progress import IssueProgressState, get_group_progress_states
2728
from sentry.issues.search import (
2829
SEARCH_FILTER_UPDATERS,
2930
IntermediateSearchQueryPartial,
@@ -986,6 +987,54 @@ def score_fn(data: dict[str, Any]) -> float:
986987
)
987988

988989

990+
# Numeric rank for the "progress" sort: higher means further along the fix cycle, so it
991+
# sorts towards the top. Every state has a rank so issues without seer activity (the
992+
# identified/triaged base states) still order correctly relative to progressed issues.
993+
PROGRESS_STATE_SORT_RANK: dict[IssueProgressState, int] = {
994+
IssueProgressState.IDENTIFIED: 1,
995+
IssueProgressState.TRIAGED: 2,
996+
IssueProgressState.DIAGNOSED: 3,
997+
IssueProgressState.FIX_PROPOSED: 4,
998+
IssueProgressState.FIX_APPLIED: 5,
999+
}
1000+
1001+
# last_seen comes back from Snuba as epoch milliseconds (< 1e13 until the year 2286), so
1002+
# dividing by this collapses it into a [0, 1) recency fraction. The score is then
1003+
# `rank + fraction`: rank stays the primary (integer) key and last_seen only breaks ties.
1004+
LAST_SEEN_TIEBREAK_DIVISOR = 10**13
1005+
1006+
1007+
def resolve_progress_signal(
1008+
actor: Any | None, organization: Organization, group_ids: list[int]
1009+
) -> dict[int, int]:
1010+
"""Progress-cycle rank per group (identified=1 .. fix_applied=5), derived from the same
1011+
Activity records as the ``issue.progress`` filter. Every group gets a rank."""
1012+
states = get_group_progress_states(group_ids)
1013+
return {
1014+
group_id: PROGRESS_STATE_SORT_RANK[IssueProgressState(state)]
1015+
for group_id, state in states.items()
1016+
}
1017+
1018+
1019+
def progress_strategy() -> PostgresSortStrategy:
1020+
"""Progress sort: primary by fix-cycle rank (fix_applied > fix_proposed > diagnosed >
1021+
triaged > identified), secondary by last_seen. The secondary key stands in for
1022+
``issue.last_progressed_at`` until that field exists; for now most-recently-active issues
1023+
rank highest within a tier."""
1024+
1025+
def score_fn(data: dict[str, Any]) -> float:
1026+
rank = data.get("progress_rank") or 0
1027+
last_seen = data.get("last_seen") or 0
1028+
return rank + last_seen / LAST_SEEN_TIEBREAK_DIVISOR
1029+
1030+
return PostgresSortStrategy(
1031+
postgres_fields={},
1032+
snuba_aggregations=["last_seen"],
1033+
signal_resolvers={"progress_rank": resolve_progress_signal},
1034+
score_fn=score_fn,
1035+
)
1036+
1037+
9891038
class PostgresSnubaQueryExecutor(AbstractQueryExecutor):
9901039
ISSUE_FIELD_NAME = "group_id"
9911040

@@ -1006,6 +1055,10 @@ class PostgresSnubaQueryExecutor(AbstractQueryExecutor):
10061055
# Snuba path can take over when there are too many candidates to score in memory.
10071056
"recommended_v2": "recommended",
10081057
"user": "user_count",
1058+
# Postgres-data sort; mapped to last_seen so the chunked Snuba path can take over
1059+
# (degrading to a plain last_seen sort) when there are too many candidates to score
1060+
# the progress rank in memory.
1061+
"progress": "last_seen",
10091062
# We don't need a corresponding snuba field here, since this sort only happens
10101063
# in Postgres
10111064
"inbox": "",
@@ -1030,7 +1083,10 @@ def dataset(self) -> Dataset:
10301083

10311084
@property
10321085
def postgres_sort_strategies(self) -> dict[str, PostgresSortStrategy]:
1033-
return {"recommended_v2": recommended_v2_strategy()}
1086+
return {
1087+
"recommended_v2": recommended_v2_strategy(),
1088+
"progress": progress_strategy(),
1089+
}
10341090

10351091
def _apply_type_visibility_filter(
10361092
self,

tests/sentry/issues/endpoints/test_organization_group_index.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,29 @@ def test_sort_by_trends(self) -> None:
190190
assert len(response.data) == 2
191191
assert [item["id"] for item in response.data] == [str(group.id), str(group_2.id)]
192192

193+
def test_sort_by_progress_requires_feature_flag(self) -> None:
194+
# group_1 has the newer event (wins last_seen / default sort); group_2 is older but
195+
# diagnosed, so it wins the progress sort once the flag is on.
196+
group_1 = self.store_event(
197+
data={"timestamp": before_now(seconds=1).isoformat(), "fingerprint": ["group-1"]},
198+
project_id=self.project.id,
199+
).group
200+
group_2 = self.store_event(
201+
data={"timestamp": before_now(hours=1).isoformat(), "fingerprint": ["group-2"]},
202+
project_id=self.project.id,
203+
).group
204+
self.create_group_activity(group=group_2, type=ActivityType.SEER_RCA_COMPLETED.value)
205+
self.login_as(user=self.user)
206+
207+
# Without the flag, the sort falls back to the default (date) order.
208+
response = self.get_success_response(sort="progress", query="is:unresolved")
209+
assert [item["id"] for item in response.data] == [str(group_1.id), str(group_2.id)]
210+
211+
# With the flag, the diagnosed group is promoted above the more recently seen one.
212+
with self.feature("organizations:issue-stream-progress-sort"):
213+
response = self.get_success_response(sort="progress", query="is:unresolved")
214+
assert [item["id"] for item in response.data] == [str(group_2.id), str(group_1.id)]
215+
193216
def test_sort_by_inbox(self) -> None:
194217
group_1 = self.store_event(
195218
data={

tests/snuba/search/test_postgres_sort_framework.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,59 @@ def test_agent_boost_reset_by_regression(self):
455455
assert self._query(actor=self.user) == [self.groups[1], self.groups[2], self.groups[0]]
456456

457457

458+
class TestProgressSort(PostgresSortTestBase):
459+
"""progress: primary sort by fix-cycle rank (fix_applied > fix_proposed > diagnosed >
460+
triaged > identified), secondary by last_seen.
461+
462+
The base fixture's groups have events ~8d, ~5d, and ~3d old, so on last_seen alone they
463+
order [2, 1, 0] (newest first).
464+
"""
465+
466+
def _query(self):
467+
return list(
468+
self.backend.query(
469+
[self.project],
470+
search_filters=[],
471+
environments=None,
472+
count_hits=False,
473+
sort_by="progress",
474+
date_from=None,
475+
date_to=None,
476+
cursor=None,
477+
referrer=Referrer.TESTING_TEST,
478+
)
479+
)
480+
481+
def test_rank_outranks_last_seen(self):
482+
# Give the oldest group the furthest progress and the newest group none: rank must
483+
# invert the last_seen ordering.
484+
self.create_group_activity(group=self.groups[0], type=ActivityType.SEER_PR_CREATED.value)
485+
self.create_group_activity(group=self.groups[1], type=ActivityType.SEER_RCA_COMPLETED.value)
486+
# groups[2] has no progress activity -> identified (lowest rank).
487+
assert self._query() == [self.groups[0], self.groups[1], self.groups[2]]
488+
489+
def test_last_seen_breaks_ties_within_rank(self):
490+
# groups[0] and groups[1] are both diagnosed; the more recently seen (groups[1])
491+
# sorts first. groups[2] stays identified and sorts last.
492+
self.create_group_activity(group=self.groups[0], type=ActivityType.SEER_RCA_COMPLETED.value)
493+
self.create_group_activity(group=self.groups[1], type=ActivityType.SEER_RCA_COMPLETED.value)
494+
assert self._query() == [self.groups[1], self.groups[0], self.groups[2]]
495+
496+
458497
class TestDefaultPostgresSortStrategies(TestCase):
459498
def test_recommended_v2_registered(self):
460499
strategies = PostgresSnubaQueryExecutor().postgres_sort_strategies
461-
assert set(strategies) == {"recommended_v2"}
500+
assert set(strategies) == {"recommended_v2", "progress"}
462501
strategy = strategies["recommended_v2"]
463502
assert strategy.snuba_aggregations == ["recommended"]
464503
assert strategy.exclude_null_postgres is False
465504
assert set(strategy.signal_resolvers) == {"assignment", "agent"}
505+
506+
def test_progress_registered(self):
507+
strategies = PostgresSnubaQueryExecutor().postgres_sort_strategies
508+
strategy = strategies["progress"]
509+
assert strategy.snuba_aggregations == ["last_seen"]
510+
assert set(strategy.signal_resolvers) == {"progress_rank"}
511+
# progress maps to last_seen in sort_strategies so the chunked Snuba path has a
512+
# real aggregation to fall back to on candidate overflow.
513+
assert PostgresSnubaQueryExecutor.sort_strategies["progress"] == "last_seen"

0 commit comments

Comments
 (0)