Skip to content

Commit e704614

Browse files
committed
ref(night-shift): Use seer-project-settings helpers for project eligibility
Replace direct SeerProjectRepository queries in _get_eligible_projects with bulk_read_preferences_from_sentry_db / bulk_get_project_preferences behind the seer-project-settings-read-from-sentry feature flag, matching the pattern used in context_engine_index and autofix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Agent transcript: https://claudescope.sentry.dev/share/TusCoPLrgCYHLxdSUeDTGIDPYU1f6BITAaO7rK6QDl0
1 parent 50f0b20 commit e704614

File tree

6 files changed

+140
-28
lines changed

6 files changed

+140
-28
lines changed

src/sentry/projectoptions/defaults.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,19 @@
196196
register(key="sentry:seer_automation_handoff_integration_id", default=None)
197197
register(key="sentry:seer_automation_handoff_auto_create_pr", default=False)
198198

199-
SEER_PROJECT_PREFERENCE_OPTION_KEYS = [
199+
DEFAULTING_SEER_PROJECT_PREFERENCE_OPTION_KEYS = [
200200
"sentry:seer_automated_run_stopping_point",
201201
"sentry:seer_automation_handoff_point",
202202
"sentry:seer_automation_handoff_target",
203203
"sentry:seer_automation_handoff_integration_id",
204204
"sentry:seer_automation_handoff_auto_create_pr",
205205
]
206206

207+
SEER_PROJECT_PREFERENCE_OPTION_KEYS = [
208+
*DEFAULTING_SEER_PROJECT_PREFERENCE_OPTION_KEYS,
209+
"sentry:autofix_automation_tuning",
210+
]
211+
207212
# Boolean to enable/disable preprod size analysis for this project.
208213
register(key="sentry:preprod_size_enabled_by_customer", default=True)
209214

src/sentry/seer/autofix/utils.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
from sentry.models.project import Project
3333
from sentry.models.repository import Repository
3434
from sentry.net.http import connection_from_url
35-
from sentry.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS
35+
from sentry.projectoptions.defaults import (
36+
DEFAULTING_SEER_PROJECT_PREFERENCE_OPTION_KEYS,
37+
SEER_PROJECT_PREFERENCE_OPTION_KEYS,
38+
)
3639
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus
3740
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
3841
from sentry.seer.models import (
@@ -747,7 +750,8 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference |
747750
]
748751

749752
has_configured_options = any(
750-
ProjectOption.objects.isset(project, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS
753+
ProjectOption.objects.isset(project, key)
754+
for key in DEFAULTING_SEER_PROJECT_PREFERENCE_OPTION_KEYS
751755
)
752756
if not repo_definitions and not has_configured_options:
753757
return None
@@ -758,6 +762,7 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference |
758762
repositories=repo_definitions,
759763
automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"),
760764
automation_handoff=_build_automation_handoff(project.get_option),
765+
autofix_automation_tuning=project.get_option("sentry:autofix_automation_tuning"),
761766
)
762767

763768

@@ -793,7 +798,7 @@ def bulk_read_preferences_from_sentry_db(
793798
for project in projects:
794799
has_configured_options = any(
795800
project_options[key][project.id] is not None
796-
for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS
801+
for key in DEFAULTING_SEER_PROJECT_PREFERENCE_OPTION_KEYS
797802
)
798803
if project.id not in repo_definitions_by_project and not has_configured_options:
799804
result[project.id] = None
@@ -813,11 +818,39 @@ def _get_project_option(key: str) -> Any:
813818
"sentry:seer_automated_run_stopping_point"
814819
),
815820
automation_handoff=_build_automation_handoff(_get_project_option),
821+
autofix_automation_tuning=_get_project_option("sentry:autofix_automation_tuning"),
816822
)
817823

818824
return result
819825

820826

827+
def bulk_read_preferences(
828+
organization: Organization, project_ids: list[int]
829+
) -> dict[int, SeerProjectPreference | None]:
830+
"""Read Seer project preferences in bulk, using the correct source based on feature flag.
831+
832+
Always returns ``dict[int, SeerProjectPreference | None]`` regardless of the
833+
underlying read path (Sentry DB or Seer API)."""
834+
if features.has("organizations:seer-project-settings-read-from-sentry", organization):
835+
return bulk_read_preferences_from_sentry_db(organization.id, project_ids)
836+
837+
raw = bulk_get_project_preferences(organization.id, project_ids)
838+
result: dict[int, SeerProjectPreference | None] = {}
839+
for pid, data in raw.items():
840+
if data is None:
841+
result[int(pid)] = None
842+
continue
843+
try:
844+
result[int(pid)] = SeerProjectPreference.validate(data)
845+
except pydantic.ValidationError:
846+
logger.exception(
847+
"seer.bulk_read_preferences.validation_error",
848+
extra={"project_id": pid, "organization_id": organization.id},
849+
)
850+
result[int(pid)] = None
851+
return result
852+
853+
821854
def set_project_seer_preference(preference: SeerProjectPreference) -> None:
822855
"""Set Seer project preference for a single project via Seer API."""
823856
response = make_set_project_preference_request(

src/sentry/seer/models/seer_api_models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from pydantic import BaseModel, Field
77

8+
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
9+
810

911
class BranchOverride(BaseModel):
1012
tag_name: str = Field(description="The tag key to match against")
@@ -96,6 +98,7 @@ class SeerProjectPreference(BaseModel):
9698
repositories: list[SeerRepoDefinition]
9799
automated_run_stopping_point: str | None = None
98100
automation_handoff: SeerAutomationHandoffConfiguration | None = None
101+
autofix_automation_tuning: AutofixAutomationTuningSettings | None = None
99102

100103

101104
class SeerRawPreferenceResponse(BaseModel):

src/sentry/tasks/seer/night_shift/cron.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from sentry.models.organization import Organization, OrganizationStatus
1212
from sentry.models.project import Project
1313
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
14+
from sentry.seer.autofix.utils import bulk_read_preferences
1415
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue
15-
from sentry.seer.models.project_repository import SeerProjectRepository
1616
from sentry.tasks.base import instrumented_task
1717
from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy
1818
from sentry.taskworker.namespaces import seer_tasks
@@ -173,18 +173,19 @@ def _get_eligible_orgs_from_batch(
173173

174174
def _get_eligible_projects(organization: Organization) -> list[Project]:
175175
"""Return active projects that have automation enabled and connected repos."""
176-
projects_with_repos = set(
177-
SeerProjectRepository.objects.filter(
178-
project__organization=organization,
179-
project__status=ObjectStatus.ACTIVE,
180-
).values_list("project_id", flat=True)
181-
)
182-
if not projects_with_repos:
176+
projects = list(Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE))
177+
if not projects:
183178
return []
184179

185-
projects = Project.objects.filter(id__in=projects_with_repos)
180+
project_ids = [p.id for p in projects]
181+
project_map = {p.id: p for p in projects}
182+
preferences = bulk_read_preferences(organization, project_ids)
183+
186184
return [
187-
p
188-
for p in projects
189-
if p.get_option("sentry:autofix_automation_tuning") != AutofixAutomationTuningSettings.OFF
185+
project_map[pid]
186+
for pid, pref in preferences.items()
187+
if pref is not None
188+
and pid in project_map
189+
and pref.repositories
190+
and pref.autofix_automation_tuning != AutofixAutomationTuningSettings.OFF
190191
]

tests/sentry/seer/autofix/test_autofix_utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory
8-
from sentry.seer.autofix.constants import AutofixStatus
8+
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus
99
from sentry.seer.autofix.trigger import is_issue_eligible_for_seer_automation
1010
from sentry.seer.autofix.utils import (
1111
AutofixState,
@@ -1311,6 +1311,35 @@ def test_project_with_repos_only(self):
13111311
assert result.automated_run_stopping_point == "code_changes"
13121312
assert result.automation_handoff is None
13131313

1314+
def test_autofix_automation_tuning_default(self):
1315+
SeerProjectRepository.objects.create(
1316+
project=self.project, repository=self.repo, branch_name="main"
1317+
)
1318+
1319+
result = read_preference_from_sentry_db(self.project)
1320+
assert result is not None
1321+
assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF
1322+
1323+
def test_autofix_automation_tuning_explicit(self):
1324+
SeerProjectRepository.objects.create(
1325+
project=self.project, repository=self.repo, branch_name="main"
1326+
)
1327+
self.project.update_option(
1328+
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
1329+
)
1330+
1331+
result = read_preference_from_sentry_db(self.project)
1332+
assert result is not None
1333+
assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.MEDIUM
1334+
1335+
def test_autofix_automation_tuning_alone_does_not_create_preference(self):
1336+
self.project.update_option(
1337+
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH
1338+
)
1339+
1340+
result = read_preference_from_sentry_db(self.project)
1341+
assert result is None
1342+
13141343
def test_project_with_stopping_point_only(self):
13151344
self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr")
13161345

@@ -1479,6 +1508,35 @@ def test_bulk_returns_correct_preferences(self):
14791508
assert pref2.automation_handoff.integration_id == 99
14801509
assert pref2.automation_handoff.auto_create_pr is False
14811510

1511+
def test_autofix_automation_tuning_populated(self):
1512+
SeerProjectRepository.objects.create(
1513+
project=self.project1, repository=self.repo, branch_name="main"
1514+
)
1515+
self.project1.update_option(
1516+
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH
1517+
)
1518+
1519+
result = bulk_read_preferences_from_sentry_db(
1520+
self.organization.id, [self.project1.id, self.project2.id]
1521+
)
1522+
1523+
pref1 = result[self.project1.id]
1524+
assert pref1 is not None
1525+
assert pref1.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH
1526+
1527+
assert result[self.project2.id] is None
1528+
1529+
def test_autofix_automation_tuning_defaults_to_off(self):
1530+
SeerProjectRepository.objects.create(
1531+
project=self.project1, repository=self.repo, branch_name="main"
1532+
)
1533+
1534+
result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id])
1535+
1536+
pref = result[self.project1.id]
1537+
assert pref is not None
1538+
assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF
1539+
14821540
def test_wrong_organization_excluded(self):
14831541
other_org = self.create_organization()
14841542
SeerProjectRepository.objects.create(

tests/sentry/tasks/seer/test_night_shift.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,20 @@ def test_filters_by_automation_and_repos(self) -> None:
9292
eligible.update_option(
9393
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
9494
)
95-
repo = self.create_repo(project=eligible, provider="github")
95+
repo = self.create_repo(project=eligible, provider="github", name="owner/eligible-repo")
9696
SeerProjectRepository.objects.create(project=eligible, repository=repo)
9797

9898
# Automation off (even with repo)
9999
off = self.create_project(organization=org)
100100
off.update_option("sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF)
101-
repo2 = self.create_repo(project=off, provider="github")
101+
repo2 = self.create_repo(project=off, provider="github", name="owner/off-repo")
102102
SeerProjectRepository.objects.create(project=off, repository=repo2)
103103

104104
# No connected repo
105105
self.create_project(organization=org)
106106

107-
assert _get_eligible_projects(org) == [eligible]
107+
with self.feature("organizations:seer-project-settings-read-from-sentry"):
108+
assert _get_eligible_projects(org) == [eligible]
108109

109110

110111
@django_db_all
@@ -115,7 +116,7 @@ def _make_eligible(self, project):
115116
project.update_option(
116117
"sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM
117118
)
118-
repo = self.create_repo(project=project, provider="github")
119+
repo = self.create_repo(project=project, provider="github", name=f"owner/{project.slug}")
119120
SeerProjectRepository.objects.create(project=project, repository=repo)
120121

121122
def _store_event_and_update_group(self, project, fingerprint, **group_attrs):
@@ -139,7 +140,10 @@ def test_no_eligible_projects(self) -> None:
139140
org = self.create_organization()
140141
self.create_project(organization=org)
141142

142-
with patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger:
143+
with (
144+
self.feature("organizations:seer-project-settings-read-from-sentry"),
145+
patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger,
146+
):
143147
run_night_shift_for_org(org.id)
144148
mock_logger.info.assert_called_once()
145149
assert mock_logger.info.call_args.args[0] == "night_shift.no_eligible_projects"
@@ -166,6 +170,7 @@ def test_selects_candidates_and_skips_triggered(self) -> None:
166170
)
167171

168172
with (
173+
self.feature("organizations:seer-project-settings-read-from-sentry"),
169174
patch(
170175
"sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request",
171176
return_value=_mock_llm_response([high_fix.id, low_fix.id]),
@@ -204,6 +209,7 @@ def test_global_ranking_across_projects(self) -> None:
204209
)
205210

206211
with (
212+
self.feature("organizations:seer-project-settings-read-from-sentry"),
207213
patch(
208214
"sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request",
209215
return_value=_mock_llm_response([high_group.id, low_group.id]),
@@ -225,9 +231,12 @@ def test_triage_error_records_error_message(self) -> None:
225231
project, "fixable", seer_fixability_score=0.9, times_seen=5
226232
)
227233

228-
with patch(
229-
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
230-
side_effect=RuntimeError("boom"),
234+
with (
235+
self.feature("organizations:seer-project-settings-read-from-sentry"),
236+
patch(
237+
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
238+
side_effect=RuntimeError("boom"),
239+
),
231240
):
232241
run_night_shift_for_org(org.id)
233242

@@ -244,9 +253,12 @@ def test_empty_candidates_creates_run_with_no_issues(self) -> None:
244253
project, "fixable", seer_fixability_score=0.9, times_seen=5
245254
)
246255

247-
with patch(
248-
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
249-
return_value=[],
256+
with (
257+
self.feature("organizations:seer-project-settings-read-from-sentry"),
258+
patch(
259+
"sentry.tasks.seer.night_shift.cron.agentic_triage_strategy",
260+
return_value=[],
261+
),
250262
):
251263
run_night_shift_for_org(org.id)
252264

0 commit comments

Comments
 (0)