Skip to content

Commit 4c11a3a

Browse files
trevor-eclaude
authored andcommitted
feat(seer): Add candidate issue selection to night shift (#112521)
Implements the per-org worker for the night shift cron to query and rank candidate issues for autofix. This is the selection/logging phase — no autofix triggering yet. The task iterates eligible projects (automation enabled + connected repos via `SeerProjectRepository`), queries unresolved issues not already autofix-triggered, and ranks them globally across the org using a weighted scoring system. Top N candidates are logged with raw signals for inspection. Key design choices: - **Pluggable strategies**: Selection logic is extracted into strategy functions (`_fixability_score_strategy`) with a shared signature, making it easy to swap in different approaches (LLM-based scoring, agent triage, etc.) - **Weighted scoring**: `_ScoredCandidate` captures raw signals (fixability, severity, times_seen) with a `score` property driven by configurable weights. Only fixability is active to start — other weights are set to 0. - **No occurrence threshold**: Unlike the post_process flow's 10-event minimum, night shift can serve lower-volume projects - **No age cutoff**: No 14-day `first_seen` filter — night shift can look at older issues - **Bulk repo check**: Uses `SeerProjectRepository` DB query instead of per-project Seer API calls --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2e8466a commit 4c11a3a

File tree

2 files changed

+265
-6
lines changed

2 files changed

+265
-6
lines changed

src/sentry/tasks/seer/night_shift.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,37 @@
22

33
import logging
44
from collections.abc import Sequence
5+
from dataclasses import dataclass
56
from datetime import timedelta
67

78
import sentry_sdk
9+
from django.db.models import F
810

911
from sentry import features, options
12+
from sentry.constants import ObjectStatus
13+
from sentry.models.group import Group, GroupStatus
1014
from sentry.models.organization import Organization, OrganizationStatus
15+
from sentry.models.project import Project
16+
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
17+
from sentry.seer.autofix.utils import is_issue_category_eligible
18+
from sentry.seer.models.project_repository import SeerProjectRepository
1119
from sentry.tasks.base import instrumented_task
1220
from sentry.taskworker.namespaces import seer_tasks
21+
from sentry.types.group import PriorityLevel
1322
from sentry.utils.iterators import chunked
1423
from sentry.utils.query import RangeQuerySetWrapper
1524

1625
logger = logging.getLogger("sentry.tasks.seer.night_shift")
1726

1827
NIGHT_SHIFT_DISPATCH_STEP_SECONDS = 37
1928
NIGHT_SHIFT_SPREAD_DURATION = timedelta(hours=4)
29+
NIGHT_SHIFT_MAX_CANDIDATES = 10
30+
NIGHT_SHIFT_ISSUE_FETCH_LIMIT = 100
31+
32+
# Weights for candidate scoring. Set to 0 to disable a signal.
33+
WEIGHT_FIXABILITY = 1.0
34+
WEIGHT_SEVERITY = 0.0
35+
WEIGHT_TIMES_SEEN = 0.0
2036

2137
FEATURE_NAMES = [
2238
"organizations:seer-night-shift",
@@ -65,6 +81,28 @@ def schedule_night_shift() -> None:
6581
)
6682

6783

84+
@dataclass
85+
class _ScoredCandidate:
86+
"""A candidate issue with raw signals for ranking."""
87+
88+
group_id: int
89+
project_id: int
90+
fixability: float
91+
times_seen: int
92+
severity: float
93+
94+
@property
95+
def score(self) -> float:
96+
return (
97+
WEIGHT_FIXABILITY * self.fixability
98+
+ WEIGHT_SEVERITY * self.severity
99+
+ WEIGHT_TIMES_SEEN * min(self.times_seen / 1000.0, 1.0)
100+
)
101+
102+
def __lt__(self, other: _ScoredCandidate) -> bool:
103+
return self.score < other.score
104+
105+
68106
@instrumented_task(
69107
name="sentry.tasks.seer.night_shift.run_night_shift_for_org",
70108
namespace=seer_tasks,
@@ -85,11 +123,37 @@ def run_night_shift_for_org(organization_id: int) -> None:
85123
}
86124
)
87125

126+
eligible_projects = _get_eligible_projects(organization)
127+
if not eligible_projects:
128+
logger.info(
129+
"night_shift.no_eligible_projects",
130+
extra={
131+
"organization_id": organization_id,
132+
"organization_slug": organization.slug,
133+
},
134+
)
135+
return
136+
137+
top_candidates = _fixability_score_strategy(eligible_projects)
138+
88139
logger.info(
89-
"night_shift.org_dispatched",
140+
"night_shift.candidates_selected",
90141
extra={
91142
"organization_id": organization_id,
92143
"organization_slug": organization.slug,
144+
"num_eligible_projects": len(eligible_projects),
145+
"num_candidates": len(top_candidates),
146+
"candidates": [
147+
{
148+
"group_id": c.group_id,
149+
"project_id": c.project_id,
150+
"score": c.score,
151+
"fixability": c.fixability,
152+
"severity": c.severity,
153+
"times_seen": c.times_seen,
154+
}
155+
for c in top_candidates
156+
],
93157
},
94158
)
95159

@@ -114,3 +178,60 @@ def _get_eligible_orgs_from_batch(
114178
return []
115179

116180
return eligible
181+
182+
183+
def _get_eligible_projects(organization: Organization) -> list[Project]:
184+
"""Return active projects that have automation enabled and connected repos."""
185+
projects_with_repos = set(
186+
SeerProjectRepository.objects.filter(
187+
project__organization=organization,
188+
project__status=ObjectStatus.ACTIVE,
189+
).values_list("project_id", flat=True)
190+
)
191+
if not projects_with_repos:
192+
return []
193+
194+
projects = Project.objects.filter(id__in=projects_with_repos)
195+
return [
196+
p
197+
for p in projects
198+
if p.get_option("sentry:autofix_automation_tuning") != AutofixAutomationTuningSettings.OFF
199+
]
200+
201+
202+
def _fixability_score_strategy(
203+
projects: Sequence[Project],
204+
) -> list[_ScoredCandidate]:
205+
"""
206+
Rank issues by existing fixability score with times_seen as tiebreaker.
207+
Simple baseline — doesn't require any additional LLM calls.
208+
"""
209+
all_candidates: list[_ScoredCandidate] = []
210+
211+
for project_id_batch in chunked(projects, 100):
212+
groups = Group.objects.filter(
213+
project_id__in=[p.id for p in project_id_batch],
214+
status=GroupStatus.UNRESOLVED,
215+
seer_autofix_last_triggered__isnull=True,
216+
seer_explorer_autofix_last_triggered__isnull=True,
217+
).order_by(
218+
F("seer_fixability_score").desc(nulls_last=True),
219+
F("times_seen").desc(),
220+
)[:NIGHT_SHIFT_ISSUE_FETCH_LIMIT]
221+
222+
for group in groups:
223+
if not is_issue_category_eligible(group):
224+
continue
225+
226+
all_candidates.append(
227+
_ScoredCandidate(
228+
group_id=group.id,
229+
project_id=group.project_id,
230+
fixability=group.seer_fixability_score or 0.0,
231+
times_seen=group.times_seen,
232+
severity=(group.priority or 0) / PriorityLevel.HIGH,
233+
)
234+
)
235+
236+
all_candidates.sort(reverse=True)
237+
return all_candidates[:NIGHT_SHIFT_MAX_CANDIDATES]

tests/sentry/tasks/seer/test_night_shift.py

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from unittest.mock import patch
22

3+
from django.utils import timezone
4+
5+
from sentry.models.group import GroupStatus
6+
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
7+
from sentry.seer.models.project_repository import SeerProjectRepository
38
from sentry.tasks.seer.night_shift import (
9+
_fixability_score_strategy,
10+
_get_eligible_projects,
411
run_night_shift_for_org,
512
schedule_night_shift,
613
)
@@ -63,17 +70,148 @@ def test_skips_orgs_with_hidden_ai(self) -> None:
6370
mock_worker.apply_async.assert_not_called()
6471

6572

73+
@django_db_all
74+
class TestGetEligibleProjects(TestCase):
75+
def test_filters_by_automation_and_repos(self) -> None:
76+
org = self.create_organization()
77+
78+
# Eligible: automation on + connected repo
79+
eligible = self.create_project(organization=org)
80+
eligible.update_option(
81+
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
82+
)
83+
repo = self.create_repo(project=eligible, provider="github")
84+
SeerProjectRepository.objects.create(project=eligible, repository=repo)
85+
86+
# Automation off (even with repo)
87+
off = self.create_project(organization=org)
88+
off.update_option("sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF)
89+
repo2 = self.create_repo(project=off, provider="github")
90+
SeerProjectRepository.objects.create(project=off, repository=repo2)
91+
92+
# No connected repo
93+
self.create_project(organization=org)
94+
95+
assert _get_eligible_projects(org) == [eligible]
96+
97+
6698
@django_db_all
6799
class TestRunNightShiftForOrg(TestCase):
68-
def test_logs_for_valid_org(self) -> None:
100+
def _make_eligible(self, project):
101+
project.update_option(
102+
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
103+
)
104+
repo = self.create_repo(project=project, provider="github")
105+
SeerProjectRepository.objects.create(project=project, repository=repo)
106+
107+
def test_nonexistent_org(self) -> None:
108+
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
109+
run_night_shift_for_org(999999999)
110+
mock_logger.info.assert_not_called()
111+
112+
def test_no_eligible_projects(self) -> None:
69113
org = self.create_organization()
114+
self.create_project(organization=org)
70115

71116
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
72117
run_night_shift_for_org(org.id)
73118
mock_logger.info.assert_called_once()
74-
assert mock_logger.info.call_args.args[0] == "night_shift.org_dispatched"
119+
assert mock_logger.info.call_args.args[0] == "night_shift.no_eligible_projects"
120+
121+
def test_selects_candidates_and_skips_triggered(self) -> None:
122+
org = self.create_organization()
123+
project = self.create_project(organization=org)
124+
self._make_eligible(project)
125+
126+
high_fix = self.create_group(
127+
project=project,
128+
status=GroupStatus.UNRESOLVED,
129+
seer_fixability_score=0.9,
130+
times_seen=5,
131+
)
132+
low_fix = self.create_group(
133+
project=project,
134+
status=GroupStatus.UNRESOLVED,
135+
seer_fixability_score=0.2,
136+
times_seen=100,
137+
)
138+
# Already triggered — should be excluded
139+
self.create_group(
140+
project=project,
141+
status=GroupStatus.UNRESOLVED,
142+
seer_fixability_score=0.95,
143+
seer_autofix_last_triggered=timezone.now(),
144+
)
75145

76-
def test_nonexistent_org(self) -> None:
77146
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
78-
run_night_shift_for_org(999999999)
79-
mock_logger.info.assert_not_called()
147+
run_night_shift_for_org(org.id)
148+
149+
call_extra = mock_logger.info.call_args.kwargs["extra"]
150+
assert call_extra["num_candidates"] == 2
151+
candidates = call_extra["candidates"]
152+
assert candidates[0]["group_id"] == high_fix.id
153+
assert candidates[1]["group_id"] == low_fix.id
154+
155+
def test_global_ranking_across_projects(self) -> None:
156+
org = self.create_organization()
157+
project_a = self.create_project(organization=org)
158+
project_b = self.create_project(organization=org)
159+
self._make_eligible(project_a)
160+
self._make_eligible(project_b)
161+
162+
low_group = self.create_group(
163+
project=project_a,
164+
status=GroupStatus.UNRESOLVED,
165+
seer_fixability_score=0.3,
166+
)
167+
high_group = self.create_group(
168+
project=project_b,
169+
status=GroupStatus.UNRESOLVED,
170+
seer_fixability_score=0.95,
171+
)
172+
173+
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
174+
run_night_shift_for_org(org.id)
175+
176+
candidates = mock_logger.info.call_args.kwargs["extra"]["candidates"]
177+
assert candidates[0]["group_id"] == high_group.id
178+
assert candidates[0]["project_id"] == project_b.id
179+
assert candidates[1]["group_id"] == low_group.id
180+
assert candidates[1]["project_id"] == project_a.id
181+
182+
183+
@django_db_all
184+
class TestFixabilityScoreStrategy(TestCase):
185+
@patch("sentry.tasks.seer.night_shift.NIGHT_SHIFT_ISSUE_FETCH_LIMIT", 3)
186+
def test_ranks_and_captures_signals(self) -> None:
187+
project = self.create_project()
188+
high = self.create_group(
189+
project=project,
190+
status=GroupStatus.UNRESOLVED,
191+
seer_fixability_score=0.9,
192+
times_seen=5,
193+
priority=75,
194+
)
195+
low = self.create_group(
196+
project=project,
197+
status=GroupStatus.UNRESOLVED,
198+
seer_fixability_score=0.2,
199+
times_seen=500,
200+
)
201+
# NULL-scored issues should sort after scored ones even with a tight DB limit.
202+
# Without nulls_last these would fill the limit and exclude scored issues.
203+
for _ in range(3):
204+
self.create_group(
205+
project=project,
206+
status=GroupStatus.UNRESOLVED,
207+
seer_fixability_score=None,
208+
times_seen=100,
209+
)
210+
211+
result = _fixability_score_strategy([project])
212+
213+
assert result[0].group_id == high.id
214+
assert result[0].fixability == 0.9
215+
assert result[0].times_seen == 5
216+
assert result[0].severity == 1.0
217+
assert result[1].group_id == low.id

0 commit comments

Comments
 (0)