From 63d15444150a0022097affafb1b843b971e55a41 Mon Sep 17 00:00:00 2001 From: Hector Date: Tue, 14 Apr 2026 17:32:24 +0100 Subject: [PATCH] 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 Agent transcript: https://claudescope.sentry.dev/share/TusCoPLrgCYHLxdSUeDTGIDPYU1f6BITAaO7rK6QDl0 --- src/sentry/projectoptions/defaults.py | 1 + src/sentry/seer/autofix/utils.py | 39 ++++++++++++ src/sentry/seer/models/seer_api_models.py | 3 + src/sentry/tasks/seer/night_shift/cron.py | 44 ++++++++----- .../sentry/seer/autofix/test_autofix_utils.py | 62 ++++++++++++++++++- ...rganization_autofix_automation_settings.py | 2 + tests/sentry/tasks/seer/test_night_shift.py | 34 ++++++---- 7 files changed, 156 insertions(+), 29 deletions(-) diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 72da29b078c3ae..39a3ef7439a759 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -202,6 +202,7 @@ "sentry:seer_automation_handoff_target", "sentry:seer_automation_handoff_integration_id", "sentry:seer_automation_handoff_auto_create_pr", + "sentry:autofix_automation_tuning", ] # Boolean to enable/disable preprod size analysis for this project. diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 125949640c91b4..33aadf417ffffc 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -758,6 +758,7 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | repositories=repo_definitions, automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), automation_handoff=_build_automation_handoff(project.get_option), + autofix_automation_tuning=project.get_option("sentry:autofix_automation_tuning"), ) @@ -813,11 +814,49 @@ def _get_project_option(key: str) -> Any: "sentry:seer_automated_run_stopping_point" ), automation_handoff=_build_automation_handoff(_get_project_option), + autofix_automation_tuning=_get_project_option("sentry:autofix_automation_tuning"), ) return result +def bulk_read_preferences( + organization: Organization, project_ids: list[int] +) -> dict[int, SeerProjectPreference | None]: + """Read Seer project preferences in bulk, using the correct source based on feature flag. + + Always returns ``dict[int, SeerProjectPreference | None]`` regardless of the + underlying read path (Sentry DB or Seer API).""" + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + return bulk_read_preferences_from_sentry_db(organization.id, project_ids) + + raw = bulk_get_project_preferences(organization.id, project_ids) + tuning_by_id = ProjectOption.objects.get_value_bulk_id( + project_ids, "sentry:autofix_automation_tuning" + ) + result: dict[int, SeerProjectPreference | None] = {} + for pid, data in raw.items(): + int_pid = int(pid) + if data is None: + result[int_pid] = None + continue + try: + pref = SeerProjectPreference.validate(data) + except pydantic.ValidationError: + logger.exception( + "seer.bulk_read_preferences.validation_error", + extra={"project_id": pid, "organization_id": organization.id}, + ) + result[int_pid] = None + continue + tuning = tuning_by_id.get(int_pid) + if tuning is None: + tuning = projectoptions.get_well_known_default("sentry:autofix_automation_tuning") + pref.autofix_automation_tuning = tuning + result[int_pid] = pref + return result + + def set_project_seer_preference(preference: SeerProjectPreference) -> None: """Set Seer project preference for a single project via Seer API.""" response = make_set_project_preference_request( diff --git a/src/sentry/seer/models/seer_api_models.py b/src/sentry/seer/models/seer_api_models.py index 5051ba2cea2e80..ba910a166cd96c 100644 --- a/src/sentry/seer/models/seer_api_models.py +++ b/src/sentry/seer/models/seer_api_models.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, Field +from sentry.seer.autofix.constants import AutofixAutomationTuningSettings + class BranchOverride(BaseModel): tag_name: str = Field(description="The tag key to match against") @@ -96,6 +98,7 @@ class SeerProjectPreference(BaseModel): repositories: list[SeerRepoDefinition] automated_run_stopping_point: str | None = None automation_handoff: SeerAutomationHandoffConfiguration | None = None + autofix_automation_tuning: AutofixAutomationTuningSettings = AutofixAutomationTuningSettings.OFF class SeerRawPreferenceResponse(BaseModel): diff --git a/src/sentry/tasks/seer/night_shift/cron.py b/src/sentry/tasks/seer/night_shift/cron.py index 00d50a3354e562..a62e0ca1393053 100644 --- a/src/sentry/tasks/seer/night_shift/cron.py +++ b/src/sentry/tasks/seer/night_shift/cron.py @@ -12,8 +12,8 @@ from sentry.models.organization import Organization, OrganizationStatus from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings +from sentry.seer.autofix.utils import bulk_read_preferences from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue -from sentry.seer.models.project_repository import SeerProjectRepository from sentry.tasks.base import instrumented_task from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy from sentry.taskworker.namespaces import seer_tasks @@ -93,13 +93,22 @@ def run_night_shift_for_org(organization_id: int) -> None: start_time = time.monotonic() - eligible_projects = _get_eligible_projects(organization) - if not eligible_projects: - logger.info( - "night_shift.no_eligible_projects", + try: + eligible_projects = _get_eligible_projects(organization) + if not eligible_projects: + logger.info( + "night_shift.no_eligible_projects", + extra={ + "organization_id": organization_id, + "organization_slug": organization.slug, + }, + ) + return + except Exception: + logger.exception( + "night_shift.failed_to_get_eligible_projects", extra={ "organization_id": organization_id, - "organization_slug": organization.slug, }, ) return @@ -186,18 +195,19 @@ def _get_eligible_orgs_from_batch( def _get_eligible_projects(organization: Organization) -> list[Project]: """Return active projects that have automation enabled and connected repos.""" - projects_with_repos = set( - SeerProjectRepository.objects.filter( - project__organization=organization, - project__status=ObjectStatus.ACTIVE, - ).values_list("project_id", flat=True) - ) - if not projects_with_repos: + project_map = { + p.id: p + for p in Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE) + } + if not project_map: return [] - projects = Project.objects.filter(id__in=projects_with_repos) + preferences = bulk_read_preferences(organization, list(project_map)) + return [ - p - for p in projects - if p.get_option("sentry:autofix_automation_tuning") != AutofixAutomationTuningSettings.OFF + project_map[pid] + for pid, pref in preferences.items() + if pref is not None + and pref.repositories + and pref.autofix_automation_tuning != AutofixAutomationTuningSettings.OFF ] diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 1aedaabcee4317..ac2bde7dc05256 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -5,7 +5,7 @@ import pytest from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory -from sentry.seer.autofix.constants import AutofixStatus +from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus from sentry.seer.autofix.trigger import is_issue_eligible_for_seer_automation from sentry.seer.autofix.utils import ( AutofixState, @@ -1311,6 +1311,37 @@ def test_project_with_repos_only(self): assert result.automated_run_stopping_point == "code_changes" assert result.automation_handoff is None + def test_autofix_automation_tuning_default(self): + SeerProjectRepository.objects.create( + project=self.project, repository=self.repo, branch_name="main" + ) + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF + + def test_autofix_automation_tuning_explicit(self): + SeerProjectRepository.objects.create( + project=self.project, repository=self.repo, branch_name="main" + ) + self.project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + ) + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.MEDIUM + + def test_autofix_automation_tuning_alone_creates_preference(self): + self.project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH + ) + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH + assert result.repositories == [] + def test_project_with_stopping_point_only(self): self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") @@ -1479,6 +1510,35 @@ def test_bulk_returns_correct_preferences(self): assert pref2.automation_handoff.integration_id == 99 assert pref2.automation_handoff.auto_create_pr is False + def test_autofix_automation_tuning_populated(self): + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo, branch_name="main" + ) + self.project1.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.HIGH + ) + + result = bulk_read_preferences_from_sentry_db( + self.organization.id, [self.project1.id, self.project2.id] + ) + + pref1 = result[self.project1.id] + assert pref1 is not None + assert pref1.autofix_automation_tuning == AutofixAutomationTuningSettings.HIGH + + assert result[self.project2.id] is None + + def test_autofix_automation_tuning_defaults_to_off(self): + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo, branch_name="main" + ) + + result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id]) + + pref = result[self.project1.id] + assert pref is not None + assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF + def test_wrong_organization_excluded(self): other_org = self.create_organization() SeerProjectRepository.objects.create( diff --git a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py index ea1fbbb33402a1..7542fe9f71095e 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -232,6 +232,7 @@ def test_post_creates_project_preferences( "repositories": [], "automated_run_stopping_point": AutofixStoppingPoint.OPEN_PR.value, "automation_handoff": None, + "autofix_automation_tuning": AutofixAutomationTuningSettings.OFF, } ] @@ -293,6 +294,7 @@ def test_post_updates_each_preference_field_independently( "repositories": [], "automated_run_stopping_point": AutofixStoppingPoint.OPEN_PR.value, "automation_handoff": None, + "autofix_automation_tuning": AutofixAutomationTuningSettings.OFF, } ] diff --git a/tests/sentry/tasks/seer/test_night_shift.py b/tests/sentry/tasks/seer/test_night_shift.py index 512702f07e9857..c75539db61b17c 100644 --- a/tests/sentry/tasks/seer/test_night_shift.py +++ b/tests/sentry/tasks/seer/test_night_shift.py @@ -92,19 +92,20 @@ def test_filters_by_automation_and_repos(self) -> None: eligible.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) - repo = self.create_repo(project=eligible, provider="github") + repo = self.create_repo(project=eligible, provider="github", name="owner/eligible-repo") SeerProjectRepository.objects.create(project=eligible, repository=repo) # Automation off (even with repo) off = self.create_project(organization=org) off.update_option("sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF) - repo2 = self.create_repo(project=off, provider="github") + repo2 = self.create_repo(project=off, provider="github", name="owner/off-repo") SeerProjectRepository.objects.create(project=off, repository=repo2) # No connected repo self.create_project(organization=org) - assert _get_eligible_projects(org) == [eligible] + with self.feature("organizations:seer-project-settings-read-from-sentry"): + assert _get_eligible_projects(org) == [eligible] @django_db_all @@ -115,7 +116,7 @@ def _make_eligible(self, project): project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) - repo = self.create_repo(project=project, provider="github") + repo = self.create_repo(project=project, provider="github", name=f"owner/{project.slug}") SeerProjectRepository.objects.create(project=project, repository=repo) def _store_event_and_update_group(self, project, fingerprint, **group_attrs): @@ -139,7 +140,10 @@ def test_no_eligible_projects(self) -> None: org = self.create_organization() self.create_project(organization=org) - with patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger: + with ( + self.feature("organizations:seer-project-settings-read-from-sentry"), + patch("sentry.tasks.seer.night_shift.cron.logger") as mock_logger, + ): run_night_shift_for_org(org.id) mock_logger.info.assert_called_once() 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: ) with ( + self.feature("organizations:seer-project-settings-read-from-sentry"), patch( "sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request", return_value=_mock_llm_response([high_fix.id, low_fix.id]), @@ -204,6 +209,7 @@ def test_global_ranking_across_projects(self) -> None: ) with ( + self.feature("organizations:seer-project-settings-read-from-sentry"), patch( "sentry.tasks.seer.night_shift.agentic_triage.make_llm_generate_request", return_value=_mock_llm_response([high_group.id, low_group.id]), @@ -225,9 +231,12 @@ def test_triage_error_records_error_message(self) -> None: project, "fixable", seer_fixability_score=0.9, times_seen=5 ) - with patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - side_effect=RuntimeError("boom"), + with ( + self.feature("organizations:seer-project-settings-read-from-sentry"), + patch( + "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", + side_effect=RuntimeError("boom"), + ), ): run_night_shift_for_org(org.id) @@ -244,9 +253,12 @@ def test_empty_candidates_creates_run_with_no_issues(self) -> None: project, "fixable", seer_fixability_score=0.9, times_seen=5 ) - with patch( - "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", - return_value=[], + with ( + self.feature("organizations:seer-project-settings-read-from-sentry"), + patch( + "sentry.tasks.seer.night_shift.cron.agentic_triage_strategy", + return_value=[], + ), ): run_night_shift_for_org(org.id)