From ebae67690fb0bcd96d3d350ffdb582eb1743fae4 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 10:47:56 -0700 Subject: [PATCH 01/12] fix(autofix): Fall back to code mappings when preference has empty repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_preference_from_sentry_db now always returns a SeerProjectPreference instead of None, removing the has_configured_options gate that could incorrectly treat mechanically-written default options as "configured". _resolve_project_preference now checks preference.repositories instead of just truthiness — when repos are empty it falls through to the code mapping fallback while preserving the user's existing stopping point and handoff settings. Co-Authored-By: Claude Opus 4.6 --- src/sentry/seer/autofix/autofix.py | 24 +++++++++++++++++------- src/sentry/seer/autofix/issue_summary.py | 3 +-- src/sentry/seer/autofix/utils.py | 24 +++++------------------- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 8b7284dfd43a2f..76ab20bc664195 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -643,9 +643,11 @@ def _resolve_project_preference( """ Resolve the Seer project preference for a project before triggering autofix. - If an existing preference is found in Seer, returns it. - If not, creates one from fallback_repos. + If an existing preference with repositories is found, returns it. + Otherwise, creates one using fallback repos and preserves any existing + stopping point / handoff settings. """ + preference: SeerProjectPreference | None = None if features.has("organizations:seer-project-settings-read-from-sentry", organization): preference = read_preference_from_sentry_db(project) else: @@ -658,16 +660,24 @@ def _resolve_project_preference( ) return None - if preference: + if preference and preference.repositories: return preference - default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) + if preference: + # Preference exists but has no repos — + # keep the user's existing stopping point and handoff settings. + stopping_point = preference.automated_run_stopping_point + handoff = preference.automation_handoff + else: + # No preference at all — use org defaults. + stopping_point, handoff = get_org_default_seer_automation_handoff(organization) + preference = SeerProjectPreference( organization_id=organization.id, project_id=project.id, repositories=fallback_repos, - automated_run_stopping_point=default_stopping_point, - automation_handoff=default_handoff, + automated_run_stopping_point=stopping_point, + automation_handoff=handoff, ) try: @@ -684,7 +694,7 @@ def _resolve_project_preference( write_preference_to_sentry_db(project, preference) except Exception: logger.exception( - "seer.write_preferences.resolve_project_preference.sentry_db_write_failed", + "seer.resolve_project_preference.write_failed", extra={"project_id": project.id, "organization_id": organization.id}, exc_info=True, ) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 4ab3bb9039ae26..94bc9e0710c629 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -506,8 +506,7 @@ def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint: fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): - preference = read_preference_from_sentry_db(group.project) - user_preference = preference.automated_run_stopping_point if preference else None + user_preference = read_preference_from_sentry_db(group.project).automated_run_stopping_point else: user_preference = _fetch_user_preference(group.project.id) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 187411356c1474..dc5d72d7012433 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -734,7 +734,7 @@ def _build_automation_handoff( ) -def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: +def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference: """Read a single project's Seer preferences from Sentry DB. For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" @@ -749,12 +749,6 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | if (repo_def := build_repo_definition_from_project_repo(project_repo)) is not None ] - has_configured_options = any( - ProjectOption.objects.isset(project, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS - ) - if not repo_definitions and not has_configured_options: - return None - return SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, @@ -767,7 +761,7 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | def bulk_read_preferences_from_sentry_db( organization_id: int, project_ids: list[int] -) -> dict[int, SeerProjectPreference | None]: +) -> dict[int, SeerProjectPreference]: """Bulk read Seer preferences from Sentry DB. For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" @@ -793,15 +787,8 @@ def bulk_read_preferences_from_sentry_db( for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS } - result: dict[int, SeerProjectPreference | None] = {} + result: dict[int, SeerProjectPreference] = {} for project in projects: - has_configured_options = any( - project_options[key][project.id] is not None - for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS - ) - if project.id not in repo_definitions_by_project and not has_configured_options: - result[project.id] = None - continue def _get_project_option(key: str) -> Any: value = project_options[key][project.id] @@ -831,7 +818,7 @@ def bulk_read_preferences( 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) + return bulk_read_preferences_from_sentry_db(organization.id, project_ids) # type: ignore[return-value] raw = bulk_get_project_preferences(organization.id, project_ids) tuning_by_id = ProjectOption.objects.get_value_bulk_id( @@ -887,8 +874,7 @@ def has_project_connected_repos( has_repos = False if features.has("organizations:seer-project-settings-read-from-sentry", organization): - preference = read_preference_from_sentry_db(project) - has_repos = bool(preference and preference.repositories) + has_repos = bool(read_preference_from_sentry_db(project).repositories) else: try: preference = get_project_seer_preferences(project.id).preference From 2c4053462b8f668ff0654695b3ded19a5a559636 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 11:55:52 -0700 Subject: [PATCH 02/12] tests --- tests/sentry/seer/autofix/test_autofix.py | 69 ++++++++++++------- .../sentry/seer/autofix/test_autofix_utils.py | 43 +++++++----- 2 files changed, 70 insertions(+), 42 deletions(-) diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 12518967915e56..452e05550db8b9 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -24,7 +24,12 @@ from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.autofix.types import AutofixSelectRootCausePayload from sentry.seer.explorer.utils import _convert_profile_to_execution_tree -from sentry.seer.models import SeerApiError, SeerProjectPreference, SeerRawPreferenceResponse +from sentry.seer.models import ( + SeerApiError, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRawPreferenceResponse, +) from sentry.seer.utils import get_github_username_for_user from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import before_now @@ -1138,7 +1143,43 @@ def test_returns_existing_preference(self, mock_get_prefs, mock_set_pref, mock_w @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_creates_preference_from_code_mappings_and_org_defaults( + def test_empty_repos_falls_back_to_code_mappings_and_preserves_settings( + self, mock_get_prefs, mock_set_pref, mock_write_sentry + ): + mock_get_prefs.return_value = SeerRawPreferenceResponse( + preference=SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automated_run_stopping_point="root_cause", + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point="root_cause", + target="cursor_background_agent", + integration_id=42, + auto_create_pr=True, + ), + ) + ) + + fallback_repos = [self._mock_repo("sentry", "123")] + result = _resolve_project_preference(self.organization, self.project, fallback_repos) + + assert result is not None + assert len(result.repositories) == 1 + assert result.repositories[0].name == "sentry" + assert result.automated_run_stopping_point == "root_cause" + assert result.automation_handoff is not None + assert result.automation_handoff.handoff_point == "root_cause" + assert result.automation_handoff.target == "cursor_background_agent" + assert result.automation_handoff.integration_id == 42 + assert result.automation_handoff.auto_create_pr is True + mock_set_pref.assert_called_once() + mock_write_sentry.assert_called_once() + + @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") + @patch("sentry.seer.autofix.autofix.set_project_seer_preference") + @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") + def test_no_preference_falls_back_to_code_mappings_and_org_defaults( self, mock_get_prefs, mock_set_pref, mock_write_sentry ): mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) @@ -1213,30 +1254,6 @@ def test_returns_preference_on_sentry_db_write_error( mock_set_pref.assert_called_once() mock_write_sentry.assert_called_once() - @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") - @patch("sentry.seer.autofix.autofix.set_project_seer_preference") - @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_returns_preference_with_empty_repos( - self, mock_get_prefs, mock_set_pref, mock_write_sentry - ): - mock_get_prefs.return_value = SeerRawPreferenceResponse( - preference=SeerProjectPreference( - organization_id=self.organization.id, - project_id=self.project.id, - repositories=[], - automated_run_stopping_point="root_cause", - ) - ) - - fallback_repos = [self._mock_repo("sentry", "123")] - result = _resolve_project_preference(self.organization, self.project, fallback_repos) - - assert result is not None - assert len(result.repositories) == 0 - assert result.automated_run_stopping_point == "root_cause" - mock_set_pref.assert_not_called() - mock_write_sentry.assert_not_called() - @with_feature("organizations:seer-project-settings-read-from-sentry") @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index ac2bde7dc05256..e287128fa74b9b 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1268,9 +1268,13 @@ def setUp(self): name="test-org/other-repo", ) - def test_unconfigured_project_returns_none(self): + def test_unconfigured_project_returns_default_preference(self): result = read_preference_from_sentry_db(self.project) - assert result is None + assert result is not None + assert result.repositories == [] + assert result.automated_run_stopping_point == "code_changes" + assert result.automation_handoff is None + assert result.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF def test_project_with_repos_only(self): spr = SeerProjectRepository.objects.create( @@ -1470,9 +1474,14 @@ def test_empty_project_ids_returns_empty(self): result = bulk_read_preferences_from_sentry_db(self.organization.id, []) assert result == {} - def test_unconfigured_project_returns_none(self): + def test_unconfigured_project_returns_default_preference(self): result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id]) - assert result == {self.project1.id: None} + pref = result[self.project1.id] + assert pref is not None + assert pref.repositories == [] + assert pref.automated_run_stopping_point == "code_changes" + assert pref.automation_handoff is None + assert pref.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF def test_bulk_returns_correct_preferences(self): SeerProjectRepository.objects.create( @@ -1510,6 +1519,17 @@ 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_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_autofix_automation_tuning_populated(self): SeerProjectRepository.objects.create( project=self.project1, repository=self.repo, branch_name="main" @@ -1526,18 +1546,9 @@ def test_autofix_automation_tuning_populated(self): 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 + pref2 = result[self.project2.id] + assert pref2 is not None + assert pref2.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF def test_wrong_organization_excluded(self): other_org = self.create_organization() From ebcb65afdecd311153619ab5092734aefa7eb374 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 11:57:35 -0700 Subject: [PATCH 03/12] fix test ordering --- .../sentry/seer/autofix/test_autofix_utils.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index e287128fa74b9b..2466a0463f970d 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1519,17 +1519,6 @@ 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_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_autofix_automation_tuning_populated(self): SeerProjectRepository.objects.create( project=self.project1, repository=self.repo, branch_name="main" @@ -1550,6 +1539,17 @@ def test_autofix_automation_tuning_populated(self): assert pref2 is not None assert pref2.autofix_automation_tuning == AutofixAutomationTuningSettings.OFF + 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( From 6e74c180f346e6002fc495a04eb59d2441acfae1 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 12:15:02 -0700 Subject: [PATCH 04/12] fix typing --- src/sentry/seer/autofix/autofix_agent.py | 17 +++++++-------- src/sentry/seer/autofix/coding_agent.py | 8 +++---- src/sentry/seer/autofix/on_completion_hook.py | 21 ++++++++++--------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index a0f7c11e3d1c94..6c13835836a7b1 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -513,17 +513,16 @@ def trigger_coding_agent_handoff( repo_definitions: list[SeerRepoDefinition] = [] if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): preference = read_preference_from_sentry_db(group.project) - if preference: - repo_definitions = preference.repositories - if preference.automation_handoff: - auto_create_pr = preference.automation_handoff.auto_create_pr + repo_definitions = preference.repositories + if preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr else: try: - preference = get_project_seer_preferences(group.project_id).preference - if preference: - repo_definitions = preference.repositories - if preference.automation_handoff: - auto_create_pr = preference.automation_handoff.auto_create_pr + pref = get_project_seer_preferences(group.project_id).preference + if pref: + repo_definitions = pref.repositories + if pref.automation_handoff: + auto_create_pr = pref.automation_handoff.auto_create_pr except Exception: logger.exception( "autofix.coding_agent_handoff.get_preferences_error", diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 5b99ea907cc4cc..76568a0e9eefef 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -237,7 +237,7 @@ def _launch_agents_for_repos( try: project = Project.objects.get_from_cache(id=autofix_state.request.project_id) preference = read_preference_from_sentry_db(project) - if preference and preference.automation_handoff: + if preference.automation_handoff: auto_create_pr = preference.automation_handoff.auto_create_pr except Project.DoesNotExist: logger.exception( @@ -250,9 +250,9 @@ def _launch_agents_for_repos( ) else: try: - preference = get_project_seer_preferences(autofix_state.request.project_id).preference - if preference and preference.automation_handoff: - auto_create_pr = preference.automation_handoff.auto_create_pr + pref = get_project_seer_preferences(autofix_state.request.project_id).preference + if pref and pref.automation_handoff: + auto_create_pr = pref.automation_handoff.auto_create_pr except (SeerApiError, SeerApiResponseValidationError): logger.exception( "coding_agent.get_project_seer_preferences_error", diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 7ec8a3254943a9..46d9f00d6cecd6 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -490,16 +490,16 @@ def _get_handoff_config_if_applicable( # Check project preferences if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): - preference = read_preference_from_sentry_db(group.project) - else: - try: - preference = get_project_seer_preferences(group.project_id).preference - except (SeerApiError, SeerApiResponseValidationError): - logger.exception( - "autofix.on_completion_hook.get_preferences_failed", - extra={"group_id": group.id, "project_id": group.project_id}, - ) - return None + return read_preference_from_sentry_db(group.project).automation_handoff + + try: + preference = get_project_seer_preferences(group.project_id).preference + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "autofix.on_completion_hook.get_preferences_failed", + extra={"group_id": group.id, "project_id": group.project_id}, + ) + return None if not preference: return None @@ -510,6 +510,7 @@ def _clear_handoff_preference( cls, project: Project, run_id: int, organization: Organization ) -> None: """Clear automation_handoff from project preferences after integration is not found.""" + preference: SeerProjectPreference | None = None if features.has("organizations:seer-project-settings-read-from-sentry", organization): preference = read_preference_from_sentry_db(project) else: From 31928664ff5666c3318d325aeba8dffd7a6250fa Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 12:34:03 -0700 Subject: [PATCH 05/12] fix(autofix): Add type annotations to fix mypy errors in seer_rpc.py Co-Authored-By: Claude Opus 4.6 --- src/sentry/seer/endpoints/seer_rpc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 9d5a4c76cb3374..50f99e03517648 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -626,6 +626,7 @@ def trigger_coding_agent_launch( if features.has("organizations:seer-project-settings-dual-write", organization): project = Project.objects.get_from_cache(id=project_id) + preference: SeerProjectPreference | None = None if features.has( "organizations:seer-project-settings-read-from-sentry", organization ): @@ -886,6 +887,7 @@ def get_project_preferences(*, organization_id: int, project_id: int) -> dict | raise Project.DoesNotExist organization = Organization.objects.get_from_cache(id=organization_id) + preference: SeerProjectPreference | None = None if features.has("organizations:seer-project-settings-read-from-sentry", organization): preference = read_preference_from_sentry_db(project) else: From c86888093dfd9072128c58386640f5411222eba2 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 15:28:48 -0700 Subject: [PATCH 06/12] remove code mappings fallback in trigger autofix --- src/sentry/seer/autofix/autofix.py | 63 +++++++++-------------- tests/sentry/seer/autofix/test_autofix.py | 57 ++++++-------------- 2 files changed, 40 insertions(+), 80 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 76ab20bc664195..5346ade3ee64d7 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -35,7 +35,6 @@ ) from sentry.seer.autofix.utils import ( AutofixStoppingPoint, - get_autofix_repos_from_project_code_mappings, get_org_default_seer_automation_handoff, get_project_seer_preferences, make_autofix_start_request, @@ -638,46 +637,36 @@ def get_all_tags_overview( def _resolve_project_preference( - organization: Organization, project: Project, fallback_repos: list[dict] + organization: Organization, project: Project ) -> SeerProjectPreference | None: """ Resolve the Seer project preference for a project before triggering autofix. - If an existing preference with repositories is found, returns it. - Otherwise, creates one using fallback repos and preserves any existing - stopping point / handoff settings. + Returns the existing preference if one exists. If not, creates a new one + with empty repos and org default settings. """ - preference: SeerProjectPreference | None = None if features.has("organizations:seer-project-settings-read-from-sentry", organization): - preference = read_preference_from_sentry_db(project) - else: - try: - preference = get_project_seer_preferences(project.id).preference - except (SeerApiError, SeerApiResponseValidationError): - logger.exception( - "seer.resolve_project_preference.get_failed", - extra={"project_id": project.id, "organization_id": organization.id}, - ) - return None + return read_preference_from_sentry_db(project) - if preference and preference.repositories: - return preference - - if preference: - # Preference exists but has no repos — - # keep the user's existing stopping point and handoff settings. - stopping_point = preference.automated_run_stopping_point - handoff = preference.automation_handoff - else: - # No preference at all — use org defaults. - stopping_point, handoff = get_org_default_seer_automation_handoff(organization) + try: + preference = get_project_seer_preferences(project.id).preference + if preference: + return preference + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "seer.resolve_project_preference.get_failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + return None + # No preference exists — create one with org defaults. + default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) preference = SeerProjectPreference( organization_id=organization.id, project_id=project.id, - repositories=fallback_repos, - automated_run_stopping_point=stopping_point, - automation_handoff=handoff, + repositories=[], + automated_run_stopping_point=default_stopping_point, + automation_handoff=default_handoff, ) try: @@ -748,19 +737,15 @@ def trigger_autofix( return _respond_with_error("Cannot fix issues without an event.", 400) code_mappings = get_sorted_code_mapping_configs(group.project) - code_mappings_repos = get_autofix_repos_from_project_code_mappings( - group.project, code_mappings=code_mappings - ) - # Resolve the project preference from Seer, or bootstrap one from code mapping repos. - # On success, preference.repositories becomes the source of truth for repos - # (even if empty — matching Seer's behavior of unconditionally using preference repos). - # On failure, we fall back to the original code mapping repos above. - preference = _resolve_project_preference(group.organization, group.project, code_mappings_repos) + # Resolve the project preference, or create a new one with org defaults. + # Preference repos are the source of truth (even if empty). + # On failure, fall back to code mapping repos. + preference = _resolve_project_preference(group.organization, group.project) if preference: repos = [repo.dict() for repo in preference.repositories] else: - repos = code_mappings_repos + repos = [] # Pre-resolve stacktrace frame paths using code mappings so Seer can skip # expensive git tree fetches for large repos. diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 452e05550db8b9..4b6aa388bf6204 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1118,19 +1118,16 @@ def _mock_repo(self, name: str = "sentry", external_id: str = "123") -> dict: @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") def test_returns_existing_preference(self, mock_get_prefs, mock_set_pref, mock_write_sentry): - existing_repos = [self._mock_repo("seer", "999")] mock_get_prefs.return_value = SeerRawPreferenceResponse( preference=SeerProjectPreference( organization_id=self.organization.id, project_id=self.project.id, - repositories=existing_repos, + repositories=[self._mock_repo("seer", "999")], automated_run_stopping_point="root_cause", ) ) - result = _resolve_project_preference( - self.organization, self.project, [self._mock_repo("sentry", "123")] - ) + result = _resolve_project_preference(self.organization, self.project) assert result is not None assert len(result.repositories) == 1 @@ -1143,7 +1140,7 @@ def test_returns_existing_preference(self, mock_get_prefs, mock_set_pref, mock_w @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_empty_repos_falls_back_to_code_mappings_and_preserves_settings( + def test_returns_existing_preference_with_empty_repos( self, mock_get_prefs, mock_set_pref, mock_write_sentry ): mock_get_prefs.return_value = SeerRawPreferenceResponse( @@ -1161,56 +1158,36 @@ def test_empty_repos_falls_back_to_code_mappings_and_preserves_settings( ) ) - fallback_repos = [self._mock_repo("sentry", "123")] - result = _resolve_project_preference(self.organization, self.project, fallback_repos) + result = _resolve_project_preference(self.organization, self.project) assert result is not None - assert len(result.repositories) == 1 - assert result.repositories[0].name == "sentry" + assert result.repositories == [] assert result.automated_run_stopping_point == "root_cause" assert result.automation_handoff is not None assert result.automation_handoff.handoff_point == "root_cause" assert result.automation_handoff.target == "cursor_background_agent" assert result.automation_handoff.integration_id == 42 assert result.automation_handoff.auto_create_pr is True - mock_set_pref.assert_called_once() - mock_write_sentry.assert_called_once() + mock_set_pref.assert_not_called() + mock_write_sentry.assert_not_called() @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @patch("sentry.seer.autofix.autofix.set_project_seer_preference") @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_no_preference_falls_back_to_code_mappings_and_org_defaults( + def test_no_preference_creates_one_with_org_defaults( self, mock_get_prefs, mock_set_pref, mock_write_sentry ): mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") self.organization.update_option("sentry:auto_open_prs", True) - code_mapping_repos = [self._mock_repo("sentry", "123")] - result = _resolve_project_preference(self.organization, self.project, code_mapping_repos) + result = _resolve_project_preference(self.organization, self.project) assert result is not None assert result.project_id == self.project.id assert result.organization_id == self.organization.id - assert len(result.repositories) == 1 - assert result.repositories[0].name == "sentry" - assert result.repositories[0].external_id == "123" - assert result.automated_run_stopping_point == "open_pr" - mock_set_pref.assert_called_once() - mock_write_sentry.assert_called_once() - - @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") - @patch("sentry.seer.autofix.autofix.set_project_seer_preference") - @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") - def test_creates_preference_with_empty_repos_when_no_fallback( - self, mock_get_prefs, mock_set_pref, mock_write_sentry - ): - mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) - - result = _resolve_project_preference(self.organization, self.project, []) - - assert result is not None assert result.repositories == [] + assert result.automated_run_stopping_point == "open_pr" mock_set_pref.assert_called_once() mock_write_sentry.assert_called_once() @@ -1220,7 +1197,7 @@ def test_creates_preference_with_empty_repos_when_no_fallback( def test_returns_none_on_get_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): mock_get_prefs.side_effect = SeerApiError("test error", 500) - result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project) assert result is None mock_set_pref.assert_not_called() @@ -1233,7 +1210,7 @@ def test_returns_none_on_set_api_error(self, mock_get_prefs, mock_set_pref, mock mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) mock_set_pref.side_effect = SeerApiError("test error", 500) - result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project) assert result is None mock_write_sentry.assert_not_called() @@ -1247,10 +1224,11 @@ def test_returns_preference_on_sentry_db_write_error( mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) mock_write_sentry.side_effect = Exception() - result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project) assert result is not None assert result.project_id == self.project.id + assert result.repositories == [] mock_set_pref.assert_called_once() mock_write_sentry.assert_called_once() @@ -1263,17 +1241,14 @@ def test_reads_from_sentry_db( self, mock_get_prefs, mock_read_db, mock_set_pref, mock_write_sentry ): """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" - existing_repos = [self._mock_repo("seer", "999")] mock_read_db.return_value = SeerProjectPreference( organization_id=self.organization.id, project_id=self.project.id, - repositories=existing_repos, + repositories=[self._mock_repo("seer", "999")], automated_run_stopping_point="root_cause", ) - result = _resolve_project_preference( - self.organization, self.project, [self._mock_repo("sentry", "123")] - ) + result = _resolve_project_preference(self.organization, self.project) assert result is not None assert result.repositories[0].name == "seer" From c525212cab9a078470007ece9b370616d44977ee Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 15:32:31 -0700 Subject: [PATCH 07/12] small fixes --- src/sentry/seer/autofix/autofix.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 5346ade3ee64d7..5a6547aeb291f7 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -740,7 +740,6 @@ def trigger_autofix( # Resolve the project preference, or create a new one with org defaults. # Preference repos are the source of truth (even if empty). - # On failure, fall back to code mapping repos. preference = _resolve_project_preference(group.organization, group.project) if preference: repos = [repo.dict() for repo in preference.repositories] From bf4bd0ed874d1cae921a17903d3c1574b11131c7 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 15:41:15 -0700 Subject: [PATCH 08/12] refactor --- src/sentry/seer/endpoints/seer_rpc.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 50f99e03517648..48cd0afc147355 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -887,12 +887,10 @@ def get_project_preferences(*, organization_id: int, project_id: int) -> dict | raise Project.DoesNotExist organization = Organization.objects.get_from_cache(id=organization_id) - preference: SeerProjectPreference | None = None if features.has("organizations:seer-project-settings-read-from-sentry", organization): - preference = read_preference_from_sentry_db(project) - else: - preference = get_project_seer_preferences(project_id).preference + return read_preference_from_sentry_db(project).dict() + preference = get_project_seer_preferences(project_id).preference if preference is None: return None return preference.dict() @@ -911,8 +909,8 @@ def bulk_get_project_preferences(*, organization_id: int, project_ids: list[int] str(project_id): preference.dict() if preference else None for project_id, preference in preferences.items() } - else: - return bulk_get_project_seer_preferences(organization_id, project_ids) + + return bulk_get_project_seer_preferences(organization_id, project_ids) seer_method_registry: dict[str, Callable] = { # return type must be serialized From a76ae51df91140ad3ca7e7055f1f9d4115ccd317 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 15:50:24 -0700 Subject: [PATCH 09/12] fix docstring --- src/sentry/seer/endpoints/seer_rpc.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 48cd0afc147355..208f1dfbc2e8e7 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -880,7 +880,7 @@ def get_project_preferences(*, organization_id: int, project_id: int) -> dict | """Get Seer project preferences for a single project. Raises Project.DoesNotExist if the project is not found or doesn't belong to the org. - Returns None if the project has no configured preferences. + Returns None if the project has no preference row in Seer DB. """ project = Project.objects.get_from_cache(id=project_id) if project.organization_id != organization_id: @@ -891,9 +891,7 @@ def get_project_preferences(*, organization_id: int, project_id: int) -> dict | return read_preference_from_sentry_db(project).dict() preference = get_project_seer_preferences(project_id).preference - if preference is None: - return None - return preference.dict() + return preference.dict() if preference else None def bulk_get_project_preferences(*, organization_id: int, project_ids: list[int]) -> dict: From acfb22fa61f486e18d2be5fa3537852c5efeccc3 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 15:55:12 -0700 Subject: [PATCH 10/12] test(autofix): Fix endpoint tests for removed code mapping fallback Mock _resolve_project_preference to return a SeerProjectPreference with repos instead of None, since trigger_autofix no longer falls back to code mapping repos. Co-Authored-By: Claude Opus 4.6 --- .../seer/endpoints/test_group_ai_autofix.py | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index c6bad444847f48..7b182583580849 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -6,6 +6,7 @@ from sentry.seer.autofix.constants import AutofixReferrer, AutofixStatus from sentry.seer.autofix.utils import AutofixState, AutofixStoppingPoint, CodebaseState from sentry.seer.explorer.client_models import SeerRunState +from sentry.seer.models import SeerProjectPreference from sentry.testutils.cases import APITestCase, SnubaTestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature @@ -315,7 +316,7 @@ def __init__(self): @patch("sentry.seer.autofix.autofix._call_autofix") @patch("sentry.seer.autofix.autofix._get_trace_tree_for_event") @patch("sentry.tasks.seer.autofix.check_autofix_status.apply_async") - @patch("sentry.seer.autofix.autofix._resolve_project_preference", return_value=None) + @patch("sentry.seer.autofix.autofix._resolve_project_preference") def test_ai_autofix_post_endpoint( self, mock_resolve_pref, @@ -331,14 +332,18 @@ def test_ai_autofix_post_endpoint( release = self.create_release(project=self.project, version="1.0.0") - repo = self.create_repo( - project=self.project, - name="getsentry/sentry", - provider="integrations:github", - external_id="123", - integration_id=234, + mock_resolve_pref.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + { + "provider": "integrations:github", + "owner": "getsentry", + "name": "sentry", + "external_id": "123", + } + ], ) - self.create_code_mapping(project=self.project, repo=repo) data = load_data("python", timestamp=before_now(minutes=1)) event = self.store_event( @@ -475,7 +480,7 @@ def test_ai_autofix_post_without_code_mappings( @patch("sentry.seer.autofix.autofix._call_autofix") @patch("sentry.seer.autofix.autofix._get_trace_tree_for_event") @patch("sentry.tasks.seer.autofix.check_autofix_status.apply_async") - @patch("sentry.seer.autofix.autofix._resolve_project_preference", return_value=None) + @patch("sentry.seer.autofix.autofix._resolve_project_preference") def test_ai_autofix_post_without_event_id( self, mock_resolve_pref, @@ -491,14 +496,18 @@ def test_ai_autofix_post_without_event_id( release = self.create_release(project=self.project, version="1.0.0") - repo = self.create_repo( - project=self.project, - name="getsentry/sentry", - provider="integrations:github", - external_id="123", - integration_id=234, + mock_resolve_pref.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + { + "provider": "integrations:github", + "owner": "getsentry", + "name": "sentry", + "external_id": "123", + } + ], ) - self.create_code_mapping(project=self.project, repo=repo) data = load_data("python", timestamp=before_now(minutes=1)) event = self.store_event( @@ -562,7 +571,7 @@ def test_ai_autofix_post_without_event_id( @patch("sentry.seer.autofix.autofix._call_autofix") @patch("sentry.seer.autofix.autofix._get_trace_tree_for_event") @patch("sentry.tasks.seer.autofix.check_autofix_status.apply_async") - @patch("sentry.seer.autofix.autofix._resolve_project_preference", return_value=None) + @patch("sentry.seer.autofix.autofix._resolve_project_preference") def test_ai_autofix_post_without_event_id_no_recommended_event( self, mock_resolve_pref, @@ -578,14 +587,18 @@ def test_ai_autofix_post_without_event_id_no_recommended_event( release = self.create_release(project=self.project, version="1.0.0") - repo = self.create_repo( - project=self.project, - name="getsentry/sentry", - provider="integrations:github", - external_id="123", - integration_id=234, + mock_resolve_pref.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + { + "provider": "integrations:github", + "owner": "getsentry", + "name": "sentry", + "external_id": "123", + } + ], ) - self.create_code_mapping(project=self.project, repo=repo) data = load_data("python", timestamp=before_now(minutes=1)) event = self.store_event( From 0cf70d04d4a76afac2eb48287e2e839b0ed7f532 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 16:09:39 -0700 Subject: [PATCH 11/12] fix test --- tests/sentry/seer/endpoints/test_seer_rpc.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index 697fb232c9f095..ac0df8c3184b8a 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -1547,15 +1547,18 @@ def test_get_project_preferences_returns_preference(self, mock_read: Any) -> Non mock_read.assert_called_once() @with_feature("organizations:seer-project-settings-read-from-sentry") - @patch("sentry.seer.endpoints.seer_rpc.read_preference_from_sentry_db") - def test_get_project_preferences_returns_none_when_no_preference(self, mock_read: Any) -> None: + def test_get_project_preferences_returns_default_when_no_preference(self) -> None: project = self.create_project(organization=self.organization) - mock_read.return_value = None result = get_project_preferences( organization_id=self.organization.id, project_id=project.id, ) - assert result is None + assert result is not None + assert result["project_id"] == project.id + assert result["organization_id"] == self.organization.id + assert result["repositories"] == [] + assert result["automated_run_stopping_point"] == "code_changes" + assert result["automation_handoff"] is None def test_get_project_preferences_raises_for_nonexistent_project(self) -> None: with pytest.raises(Project.DoesNotExist): From c4109f05d112adb9419e8063ef1975cc7b843f63 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 15 Apr 2026 16:09:52 -0700 Subject: [PATCH 12/12] cleaning up --- tests/sentry/seer/endpoints/test_seer_rpc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_seer_rpc.py b/tests/sentry/seer/endpoints/test_seer_rpc.py index ac0df8c3184b8a..c0ce038d23c475 100644 --- a/tests/sentry/seer/endpoints/test_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_seer_rpc.py @@ -1550,8 +1550,7 @@ def test_get_project_preferences_returns_preference(self, mock_read: Any) -> Non def test_get_project_preferences_returns_default_when_no_preference(self) -> None: project = self.create_project(organization=self.organization) result = get_project_preferences( - organization_id=self.organization.id, - project_id=project.id, + organization_id=self.organization.id, project_id=project.id ) assert result is not None assert result["project_id"] == project.id