From db1575cdd2fffe0228b19a81b93b0e5ade6f2d75 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 25 Mar 2026 16:36:56 -0700 Subject: [PATCH 01/30] feat(seer): Add read helpers for Seer project preferences from Sentry DB Add read_preference_from_sentry_db() and bulk_read_preferences_from_sentry_db() to read Seer project preferences directly from Sentry's database instead of proxying to the Seer API. This is the foundation for cutting over reads behind the organizations:seer-project-settings-read-from-sentry feature flag. - Register organizations:seer-project-settings-read-from-sentry feature flag - Add SEER_PREFERENCE_OPTION_KEYS constant in projectoptions/defaults.py - Add build_repo_definition_from_project_repo() and build_automation_handoff() shared helpers using a Callable interface for option getters - Single read uses project.get_option() with caching - Bulk read uses ProjectOption.objects.get_value_bulk_id() with fallback to registered defaults via projectoptions.lookup_well_known_key() Co-Authored-By: Claude Sonnet 4 Made-with: Cursor --- src/sentry/features/temporary.py | 2 + src/sentry/projectoptions/defaults.py | 8 ++ src/sentry/seer/autofix/utils.py | 131 +++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 0303d69a60c3..952350e455bc 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -345,6 +345,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:insights-alerts", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable dual-write of Seer project preferences to Sentry DB and Seer API manager.add("organizations:seer-project-settings-dual-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable reading of Seer project preferences from Sentry DB instead of Seer API + manager.add("organizations:seer-project-settings-read-from-sentry", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable public RPC endpoint for local seer development manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Organizations on the old usage-based (v0) Seer plan diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 4534fbdedbbe..935a527e7413 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -196,6 +196,14 @@ register(key="sentry:seer_automation_handoff_integration_id", default=None) register(key="sentry:seer_automation_handoff_auto_create_pr", default=False) +SEER_PREFERENCE_OPTION_KEYS = [ + "sentry:seer_automated_run_stopping_point", + "sentry:seer_automation_handoff_point", + "sentry:seer_automation_handoff_target", + "sentry:seer_automation_handoff_integration_id", + "sentry:seer_automation_handoff_auto_create_pr", +] + # Boolean to enable/disable preprod size analysis for this project. register(key="sentry:preprod_size_enabled_by_customer", default=True) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index ce728d375a96..bccad7f7b054 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,5 +1,6 @@ import logging -from collections.abc import Iterable +from collections import defaultdict +from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum from typing import Any, NotRequired, TypedDict @@ -13,7 +14,7 @@ from urllib3 import BaseHTTPResponse, HTTPConnectionPool from urllib3.util.retry import Retry -from sentry import features, options, ratelimits +from sentry import features, options, projectoptions, ratelimits from sentry.constants import ( SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory, @@ -24,15 +25,18 @@ get_sorted_code_mapping_configs, ) from sentry.models.group import Group +from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization from sentry.models.project import Project from sentry.models.repository import Repository from sentry.net.http import connection_from_url +from sentry.projectoptions.defaults import SEER_PREFERENCE_OPTION_KEYS from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus from sentry.seer.models import ( BranchOverride, SeerApiError, SeerApiResponseValidationError, + SeerAutomationHandoffConfiguration, SeerPermissionError, SeerProjectPreference, SeerRawPreferenceResponse, @@ -615,6 +619,129 @@ def bulk_write_preferences_to_sentry_db( _write_preferences_to_sentry_db(project_preferences) +def build_repo_definition_from_project_repo( + seer_project_repo: SeerProjectRepository, +) -> SeerRepoDefinition: + """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository.""" + repo = seer_project_repo.repository + repo_name_sections = repo.name.split("/") + + return SeerRepoDefinition( + repository_id=repo.id, + organization_id=repo.organization_id, + integration_id=str(repo.integration_id) if repo.integration_id is not None else None, + provider=repo.provider or "", + owner=repo_name_sections[0], + name="/".join(repo_name_sections[1:]), + external_id=repo.external_id or "", + branch_name=seer_project_repo.branch_name, + instructions=seer_project_repo.instructions, + branch_overrides=[ + BranchOverride( + tag_name=bo.tag_name, + tag_value=bo.tag_value, + branch_name=bo.branch_name, + ) + for bo in seer_project_repo.branch_overrides.all() + ], + ) + + +def build_automation_handoff( + get_option: Callable[[str], Any], +) -> SeerAutomationHandoffConfiguration | None: + """Build handoff config from an option getter (eg, project.get_option or dict.get).""" + handoff_point = get_option("sentry:seer_automation_handoff_point") + handoff_target = get_option("sentry:seer_automation_handoff_target") + handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id") + if handoff_point is None or handoff_target is None or handoff_integration_id is None: + return None + return SeerAutomationHandoffConfiguration( + handoff_point=handoff_point, + target=handoff_target, + integration_id=handoff_integration_id, + auto_create_pr=get_option("sentry:seer_automation_handoff_auto_create_pr"), + ) + + +def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: + """Read a single project's Seer preferences from Sentry DB.""" + seer_project_repo_qs = ( + SeerProjectRepository.objects.filter(project=project) + .select_related("repository") + .prefetch_related("branch_overrides") + ) + repo_definitions = [ + build_repo_definition_from_project_repo(project_repo) + for project_repo in seer_project_repo_qs + ] + + if ( + not repo_definitions + and not ProjectOption.objects.filter( + project=project, key__in=SEER_PREFERENCE_OPTION_KEYS + ).exists() + ): + return None + + return SeerProjectPreference( + organization_id=project.organization_id, + project_id=project.id, + repositories=repo_definitions, + automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), + automation_handoff=build_automation_handoff(project.get_option), + ) + + +def bulk_read_preferences_from_sentry_db( + organization_id: int, project_ids: list[int] +) -> dict[int, SeerProjectPreference]: + """Bulk read Seer preferences from Sentry DB.""" + if not project_ids: + return {} + + projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) + + repos_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) + for project_repo in ( + SeerProjectRepository.objects.filter(project_id__in=project_ids) + .select_related("repository") + .prefetch_related("branch_overrides") + ): + repos_by_project[project_repo.project_id].append( + build_repo_definition_from_project_repo(project_repo) + ) + + project_options: dict[str, Mapping[int, Any]] = { + key: ProjectOption.objects.get_value_bulk_id(project_ids, key) + for key in SEER_PREFERENCE_OPTION_KEYS + } + + result: dict[int, SeerProjectPreference] = {} + for project in projects: + has_configured_options = any( + project_options[key][project.id] is not None for key in SEER_PREFERENCE_OPTION_KEYS + ) + if project.id not in repos_by_project and not has_configured_options: + continue + + def get_option(key: str) -> Any: + value = project_options[key][project.id] + if value is not None: + return value + return projectoptions.lookup_well_known_key(key).default + + result[project.id] = SeerProjectPreference( + organization_id=project.organization_id, + project_id=project.id, + repositories=repos_by_project.get(project.id, []), + automated_run_stopping_point=get_option("sentry:seer_automated_run_stopping_point"), + automation_handoff=build_automation_handoff(get_option), + ) + + 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( From 3be1c6dc000eda25d09323d5fde89c3c6a953bbf Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 25 Mar 2026 16:58:52 -0700 Subject: [PATCH 02/30] test(seer): Add tests for Sentry DB read preference helpers Cover read_preference_from_sentry_db and bulk_read_preferences_from_sentry_db with tests for unconfigured projects, repos with branch overrides, options-only projects, full and partial handoff configs, org scoping, and configured-vs-absent filtering. Co-Authored-By: Claude Sonnet 4 Made-with: Cursor --- .../sentry/seer/autofix/test_autofix_utils.py | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index bffd5a4599d9..b724f46b1b26 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -10,6 +10,7 @@ AutofixState, AutofixTriggerSource, CodingAgentStatus, + bulk_read_preferences_from_sentry_db, bulk_write_preferences_to_sentry_db, deduplicate_repositories, get_autofix_prompt, @@ -17,6 +18,7 @@ has_project_connected_repos, is_issue_eligible_for_seer_automation, is_seer_seat_based_tier_enabled, + read_preference_from_sentry_db, resolve_repository_ids, set_project_seer_preference, write_preference_to_sentry_db, @@ -1218,3 +1220,154 @@ def test_bulk_write_replaces_per_project(self): assert p1_repo.branch_name == "new-branch" p2_repo = SeerProjectRepository.objects.get(project=project2) assert p2_repo.branch_name == "project-2-branch" + + +class TestReadPreferenceFromSentryDb(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.repo = self.create_repo( + project=self.project, + provider="integrations:github", + external_id="ext123", + name="test-org/test-repo", + ) + + def test_unconfigured_project_returns_none(self): + result = read_preference_from_sentry_db(self.project) + assert result is None + + def test_project_with_repos_only(self): + spr = SeerProjectRepository.objects.create( + project=self.project, + repository=self.repo, + branch_name="main", + instructions="Be helpful", + ) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=spr, + tag_name="environment", + tag_value="production", + branch_name="release", + ) + + result = read_preference_from_sentry_db(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].owner == "test-org" + assert result.repositories[0].name == "test-repo" + assert result.repositories[0].branch_name == "main" + assert result.repositories[0].instructions == "Be helpful" + assert len(result.repositories[0].branch_overrides) == 1 + assert result.repositories[0].branch_overrides[0].tag_name == "environment" + assert result.repositories[0].branch_overrides[0].tag_value == "production" + assert result.repositories[0].branch_overrides[0].branch_name == "release" + assert result.automated_run_stopping_point == "code_changes" + assert result.automation_handoff is None + + def test_project_with_stopping_point_only(self): + self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert result.automated_run_stopping_point == "open_pr" + assert result.repositories == [] + assert result.automation_handoff is None + + def test_project_with_handoff_only(self): + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + result = read_preference_from_sentry_db(self.project) + assert result is not None + 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 + + def test_partial_handoff_returns_none_handoff(self): + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert result.automation_handoff is None + + +class TestBulkReadPreferencesFromSentryDb(TestCase): + def setUp(self): + super().setUp() + self.organization = self.create_organization() + self.project1 = self.create_project(organization=self.organization) + self.project2 = self.create_project(organization=self.organization) + self.project3 = self.create_project(organization=self.organization) + self.repo = self.create_repo( + project=self.project1, + provider="integrations:github", + external_id="ext123", + name="test-org/test-repo", + ) + + def test_empty_project_ids_returns_empty(self): + result = bulk_read_preferences_from_sentry_db(self.organization.id, []) + assert result == {} + + def test_bulk_returns_correct_preferences(self): + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo, branch_name="main" + ) + self.project2.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + self.project2.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project2.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project2.update_option("sentry:seer_automation_handoff_integration_id", 99) + + result = bulk_read_preferences_from_sentry_db( + self.organization.id, + [self.project1.id, self.project2.id], + ) + + pref1 = result[self.project1.id] + assert len(pref1.repositories) == 1 + assert pref1.repositories[0].branch_name == "main" + assert pref1.automated_run_stopping_point == "code_changes" + assert pref1.automation_handoff is None + + pref2 = result[self.project2.id] + assert pref2.repositories == [] + assert pref2.automated_run_stopping_point == "open_pr" + assert pref2.automation_handoff is not None + assert pref2.automation_handoff.handoff_point == "root_cause" + assert pref2.automation_handoff.integration_id == 99 + + def test_only_configured_projects_returned(self): + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo, branch_name="main" + ) + self.project2.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + + result = bulk_read_preferences_from_sentry_db( + self.organization.id, + [self.project1.id, self.project2.id, self.project3.id], + ) + + assert self.project1.id in result + assert self.project2.id in result + assert self.project3.id not in result + + def test_wrong_organization_excluded(self): + other_org = self.create_organization() + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo, branch_name="main" + ) + + result = bulk_read_preferences_from_sentry_db(other_org.id, [self.project1.id]) + assert result == {} From 241b8bd637a2cddd3d9109d72a73edf78fac8a07 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Fri, 3 Apr 2026 16:48:18 -0700 Subject: [PATCH 03/30] fixes --- src/sentry/seer/autofix/utils.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index bccad7f7b054..426051702268 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -654,8 +654,10 @@ def build_automation_handoff( handoff_point = get_option("sentry:seer_automation_handoff_point") handoff_target = get_option("sentry:seer_automation_handoff_target") handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id") + if handoff_point is None or handoff_target is None or handoff_integration_id is None: return None + return SeerAutomationHandoffConfiguration( handoff_point=handoff_point, target=handoff_target, @@ -676,19 +678,13 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | for project_repo in seer_project_repo_qs ] - if ( - not repo_definitions - and not ProjectOption.objects.filter( - project=project, key__in=SEER_PREFERENCE_OPTION_KEYS - ).exists() - ): - return None - return SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, repositories=repo_definitions, - automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), + automated_run_stopping_point=project.get_option( + "sentry:seer_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ), automation_handoff=build_automation_handoff(project.get_option), ) @@ -727,9 +723,9 @@ def bulk_read_preferences_from_sentry_db( def get_option(key: str) -> Any: value = project_options[key][project.id] - if value is not None: - return value - return projectoptions.lookup_well_known_key(key).default + if value is None: + return projectoptions.lookup_well_known_key(key).default + return value result[project.id] = SeerProjectPreference( organization_id=project.organization_id, From e6ca4d141e6fe65644b55cf0e4d1f37d9445dfd0 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Fri, 3 Apr 2026 17:07:29 -0700 Subject: [PATCH 04/30] remove handoff helper --- src/sentry/seer/autofix/utils.py | 69 +++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 426051702268..31df74a40a1c 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum from typing import Any, NotRequired, TypedDict @@ -647,25 +647,6 @@ def build_repo_definition_from_project_repo( ) -def build_automation_handoff( - get_option: Callable[[str], Any], -) -> SeerAutomationHandoffConfiguration | None: - """Build handoff config from an option getter (eg, project.get_option or dict.get).""" - handoff_point = get_option("sentry:seer_automation_handoff_point") - handoff_target = get_option("sentry:seer_automation_handoff_target") - handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id") - - if handoff_point is None or handoff_target is None or handoff_integration_id is None: - return None - - return SeerAutomationHandoffConfiguration( - handoff_point=handoff_point, - target=handoff_target, - integration_id=handoff_integration_id, - auto_create_pr=get_option("sentry:seer_automation_handoff_auto_create_pr"), - ) - - def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: """Read a single project's Seer preferences from Sentry DB.""" seer_project_repo_qs = ( @@ -678,6 +659,25 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | for project_repo in seer_project_repo_qs ] + handoff_point = project.get_option("sentry:seer_automation_handoff_point") + handoff_target = project.get_option("sentry:seer_automation_handoff_target") + handoff_integration_id = project.get_option("sentry:seer_automation_handoff_integration_id") + + automation_handoff = None + if ( + handoff_point is not None + and handoff_target is not None + and handoff_integration_id is not None + ): + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=handoff_point, + target=handoff_target, + integration_id=handoff_integration_id, + auto_create_pr=project.get_option( + "sentry:seer_automation_handoff_auto_create_pr", False + ), + ) + return SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, @@ -685,7 +685,7 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | automated_run_stopping_point=project.get_option( "sentry:seer_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ), - automation_handoff=build_automation_handoff(project.get_option), + automation_handoff=automation_handoff, ) @@ -721,18 +721,39 @@ def bulk_read_preferences_from_sentry_db( if project.id not in repos_by_project and not has_configured_options: continue - def get_option(key: str) -> Any: + # get_value_bulk_id returns None for missing options, unlike project.get_option + # which automatically falls back to the registered well-known key default. + def get_project_option(key: str) -> Any: value = project_options[key][project.id] if value is None: return projectoptions.lookup_well_known_key(key).default return value + handoff_point = get_project_option("sentry:seer_automation_handoff_point") + handoff_target = get_project_option("sentry:seer_automation_handoff_target") + handoff_integration_id = get_project_option("sentry:seer_automation_handoff_integration_id") + + automation_handoff = None + if ( + handoff_point is not None + and handoff_target is not None + and handoff_integration_id is not None + ): + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=handoff_point, + target=handoff_target, + integration_id=handoff_integration_id, + auto_create_pr=get_project_option("sentry:seer_automation_handoff_auto_create_pr"), + ) + result[project.id] = SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, repositories=repos_by_project.get(project.id, []), - automated_run_stopping_point=get_option("sentry:seer_automated_run_stopping_point"), - automation_handoff=build_automation_handoff(get_option), + automated_run_stopping_point=get_project_option( + "sentry:seer_automated_run_stopping_point" + ), + automation_handoff=automation_handoff, ) return result From 24873f1b346b5cdf9de932fe660ae02616124746 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 10:24:32 -0700 Subject: [PATCH 05/30] ref(seer): Simplify preference reads using cached project options read_preference_from_sentry_db now returns None for unconfigured projects (no explicit option keys set and no repos), matching the bulk function's behavior. bulk_read_preferences_from_sentry_db is simplified to delegate to the single-project reader. Uses ProjectOption.objects.isset() and project.get_option() which both go through get_all_values(), a cached lookup, instead of the previous get_value_bulk_id() approach that issued a separate DB query per option key bypassing the cache. This is efficient for the small-project-count callers (settings endpoints, org setup). Co-Authored-By: Claude Opus 4.6 --- src/sentry/projectoptions/defaults.py | 2 +- src/sentry/seer/autofix/utils.py | 78 +++---------------- .../sentry/seer/autofix/test_autofix_utils.py | 21 ++--- 3 files changed, 19 insertions(+), 82 deletions(-) diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index 935a527e7413..474c618b92c4 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -196,7 +196,7 @@ register(key="sentry:seer_automation_handoff_integration_id", default=None) register(key="sentry:seer_automation_handoff_auto_create_pr", default=False) -SEER_PREFERENCE_OPTION_KEYS = [ +SEER_PROJECT_PREFERENCE_OPTION_KEYS = [ "sentry:seer_automated_run_stopping_point", "sentry:seer_automation_handoff_point", "sentry:seer_automation_handoff_target", diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 31df74a40a1c..9260c8f23c18 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,6 +1,5 @@ import logging -from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from datetime import UTC, datetime from enum import StrEnum from typing import Any, NotRequired, TypedDict @@ -14,7 +13,7 @@ from urllib3 import BaseHTTPResponse, HTTPConnectionPool from urllib3.util.retry import Retry -from sentry import features, options, projectoptions, ratelimits +from sentry import features, options, ratelimits from sentry.constants import ( SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory, @@ -30,7 +29,7 @@ from sentry.models.project import Project from sentry.models.repository import Repository from sentry.net.http import connection_from_url -from sentry.projectoptions.defaults import SEER_PREFERENCE_OPTION_KEYS +from sentry.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus from sentry.seer.models import ( BranchOverride, @@ -659,6 +658,12 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | for project_repo in seer_project_repo_qs ] + 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 + handoff_point = project.get_option("sentry:seer_automation_handoff_point") handoff_target = project.get_option("sentry:seer_automation_handoff_target") handoff_integration_id = project.get_option("sentry:seer_automation_handoff_integration_id") @@ -691,72 +696,13 @@ 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]: +) -> dict[int, SeerProjectPreference | None]: """Bulk read Seer preferences from Sentry DB.""" if not project_ids: return {} - projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) - - repos_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) - for project_repo in ( - SeerProjectRepository.objects.filter(project_id__in=project_ids) - .select_related("repository") - .prefetch_related("branch_overrides") - ): - repos_by_project[project_repo.project_id].append( - build_repo_definition_from_project_repo(project_repo) - ) - - project_options: dict[str, Mapping[int, Any]] = { - key: ProjectOption.objects.get_value_bulk_id(project_ids, key) - for key in SEER_PREFERENCE_OPTION_KEYS - } - - result: dict[int, SeerProjectPreference] = {} - for project in projects: - has_configured_options = any( - project_options[key][project.id] is not None for key in SEER_PREFERENCE_OPTION_KEYS - ) - if project.id not in repos_by_project and not has_configured_options: - continue - - # get_value_bulk_id returns None for missing options, unlike project.get_option - # which automatically falls back to the registered well-known key default. - def get_project_option(key: str) -> Any: - value = project_options[key][project.id] - if value is None: - return projectoptions.lookup_well_known_key(key).default - return value - - handoff_point = get_project_option("sentry:seer_automation_handoff_point") - handoff_target = get_project_option("sentry:seer_automation_handoff_target") - handoff_integration_id = get_project_option("sentry:seer_automation_handoff_integration_id") - - automation_handoff = None - if ( - handoff_point is not None - and handoff_target is not None - and handoff_integration_id is not None - ): - automation_handoff = SeerAutomationHandoffConfiguration( - handoff_point=handoff_point, - target=handoff_target, - integration_id=handoff_integration_id, - auto_create_pr=get_project_option("sentry:seer_automation_handoff_auto_create_pr"), - ) - - result[project.id] = SeerProjectPreference( - organization_id=project.organization_id, - project_id=project.id, - repositories=repos_by_project.get(project.id, []), - automated_run_stopping_point=get_project_option( - "sentry:seer_automated_run_stopping_point" - ), - automation_handoff=automation_handoff, - ) - - return result + projects = Project.objects.filter(id__in=project_ids, organization_id=organization_id) + return {project.id: read_preference_from_sentry_db(project) for project in projects} def set_project_seer_preference(preference: SeerProjectPreference) -> None: diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index b724f46b1b26..7875f1891219 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1319,6 +1319,10 @@ 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): + result = bulk_read_preferences_from_sentry_db(self.organization.id, [self.project1.id]) + assert result == {self.project1.id: None} + def test_bulk_returns_correct_preferences(self): SeerProjectRepository.objects.create( project=self.project1, repository=self.repo, branch_name="main" @@ -1346,22 +1350,9 @@ def test_bulk_returns_correct_preferences(self): assert pref2.automated_run_stopping_point == "open_pr" assert pref2.automation_handoff is not None assert pref2.automation_handoff.handoff_point == "root_cause" + assert pref2.automation_handoff.target == "cursor_background_agent" assert pref2.automation_handoff.integration_id == 99 - - def test_only_configured_projects_returned(self): - SeerProjectRepository.objects.create( - project=self.project1, repository=self.repo, branch_name="main" - ) - self.project2.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - - result = bulk_read_preferences_from_sentry_db( - self.organization.id, - [self.project1.id, self.project2.id, self.project3.id], - ) - - assert self.project1.id in result - assert self.project2.id in result - assert self.project3.id not in result + assert pref2.automation_handoff.auto_create_pr is False def test_wrong_organization_excluded(self): other_org = self.create_organization() From 14f573db8e34065fda51df288b7f75534db33e15 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 10:36:53 -0700 Subject: [PATCH 06/30] ref(seer): Restore batched queries in bulk_read_preferences The previous commit simplified bulk_read to delegate to the single-project reader, but that trades O(1) batched queries for O(N) per-project queries (one repo query + one cache/DB hit per project). The bulk function is called from configure_seer_for_existing_org which operates on all projects in an org, so N can be large. Keep the batched approach (1 repo query + 5 option queries total) for the bulk path, while the single-project reader uses the cached isset()/get_option() path which is efficient for its callers. Co-Authored-By: Claude Opus 4.6 --- src/sentry/seer/autofix/utils.py | 70 ++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 985a2e143c50..a6a6928305d8 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,5 +1,6 @@ import logging -from collections.abc import Iterable +from collections import defaultdict +from collections.abc import Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum from typing import Any, NotRequired, TypedDict @@ -13,7 +14,7 @@ from urllib3 import BaseHTTPResponse, HTTPConnectionPool from urllib3.util.retry import Retry -from sentry import features, options, ratelimits +from sentry import features, options, projectoptions, ratelimits from sentry.constants import ( AUTO_OPEN_PRS_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, @@ -756,8 +757,69 @@ def bulk_read_preferences_from_sentry_db( if not project_ids: return {} - projects = Project.objects.filter(id__in=project_ids, organization_id=organization_id) - return {project.id: read_preference_from_sentry_db(project) for project in projects} + projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id)) + + repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list) + for project_repo in ( + SeerProjectRepository.objects.filter(project_id__in=project_ids) + .select_related("repository") + .prefetch_related("branch_overrides") + ): + repo_definitions_by_project[project_repo.project_id].append( + build_repo_definition_from_project_repo(project_repo) + ) + + project_options: dict[str, Mapping[int, Any]] = { + key: ProjectOption.objects.get_value_bulk_id(project_ids, key) + for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS + } + + result: dict[int, SeerProjectPreference | None] = {} + 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 + + # get_value_bulk_id returns None for missing options, unlike project.get_option + # which automatically falls back to the registered well-known key default. + def get_project_option(key: str) -> Any: + value = project_options[key][project.id] + if value is None: + return projectoptions.lookup_well_known_key(key).default + return value + + handoff_point = get_project_option("sentry:seer_automation_handoff_point") + handoff_target = get_project_option("sentry:seer_automation_handoff_target") + handoff_integration_id = get_project_option("sentry:seer_automation_handoff_integration_id") + + automation_handoff = None + if ( + handoff_point is not None + and handoff_target is not None + and handoff_integration_id is not None + ): + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=handoff_point, + target=handoff_target, + integration_id=handoff_integration_id, + auto_create_pr=get_project_option("sentry:seer_automation_handoff_auto_create_pr"), + ) + + result[project.id] = SeerProjectPreference( + organization_id=project.organization_id, + project_id=project.id, + repositories=repo_definitions_by_project.get(project.id, []), + automated_run_stopping_point=get_project_option( + "sentry:seer_automated_run_stopping_point" + ), + automation_handoff=automation_handoff, + ) + + return result def set_project_seer_preference(preference: SeerProjectPreference) -> None: From 03f8fce6faf46ddbd28fc630f2daa8e137128174 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 10:49:03 -0700 Subject: [PATCH 07/30] fix typing --- tests/sentry/seer/autofix/test_autofix_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 4ffdd605d4fe..4f2b650f164b 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1341,12 +1341,14 @@ def test_bulk_returns_correct_preferences(self): ) pref1 = result[self.project1.id] + assert pref1 is not None assert len(pref1.repositories) == 1 assert pref1.repositories[0].branch_name == "main" assert pref1.automated_run_stopping_point == "code_changes" assert pref1.automation_handoff is None pref2 = result[self.project2.id] + assert pref2 is not None assert pref2.repositories == [] assert pref2.automated_run_stopping_point == "open_pr" assert pref2.automation_handoff is not None From 770c18787a8bbc2089214f719171467c83190861 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 11:11:44 -0700 Subject: [PATCH 08/30] more test coverage --- src/sentry/seer/autofix/utils.py | 8 +- .../sentry/seer/autofix/test_autofix_utils.py | 98 ++++++++++++++++--- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index a6a6928305d8..eb4ea942a3dd 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -734,18 +734,14 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | handoff_point=handoff_point, target=handoff_target, integration_id=handoff_integration_id, - auto_create_pr=project.get_option( - "sentry:seer_automation_handoff_auto_create_pr", False - ), + auto_create_pr=project.get_option("sentry:seer_automation_handoff_auto_create_pr"), ) return SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, repositories=repo_definitions, - automated_run_stopping_point=project.get_option( - "sentry:seer_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT - ), + automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), automation_handoff=automation_handoff, ) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 4f2b650f164b..b039c2058ca4 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1234,6 +1234,12 @@ def setUp(self): external_id="ext123", name="test-org/test-repo", ) + self.repo2 = self.create_repo( + project=self.project, + provider="integrations:github", + external_id="ext456", + name="test-org/other-repo", + ) def test_unconfigured_project_returns_none(self): result = read_preference_from_sentry_db(self.project) @@ -1252,20 +1258,29 @@ def test_project_with_repos_only(self): tag_value="production", branch_name="release", ) + SeerProjectRepository.objects.create( + project=self.project, + repository=self.repo2, + branch_name="develop", + ) result = read_preference_from_sentry_db(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].owner == "test-org" - assert result.repositories[0].name == "test-repo" - assert result.repositories[0].branch_name == "main" - assert result.repositories[0].instructions == "Be helpful" - assert len(result.repositories[0].branch_overrides) == 1 - assert result.repositories[0].branch_overrides[0].tag_name == "environment" - assert result.repositories[0].branch_overrides[0].tag_value == "production" - assert result.repositories[0].branch_overrides[0].branch_name == "release" + assert len(result.repositories) == 2 + repo_by_name = {r.name: r for r in result.repositories} + assert repo_by_name["test-repo"].owner == "test-org" + assert repo_by_name["test-repo"].branch_name == "main" + assert repo_by_name["test-repo"].instructions == "Be helpful" + assert len(repo_by_name["test-repo"].branch_overrides) == 1 + assert repo_by_name["test-repo"].branch_overrides[0].tag_name == "environment" + assert repo_by_name["test-repo"].branch_overrides[0].tag_value == "production" + assert repo_by_name["test-repo"].branch_overrides[0].branch_name == "release" + assert repo_by_name["other-repo"].owner == "test-org" + assert repo_by_name["other-repo"].branch_name == "develop" + assert repo_by_name["other-repo"].instructions is None + assert repo_by_name["other-repo"].branch_overrides == [] assert result.automated_run_stopping_point == "code_changes" assert result.automation_handoff is None @@ -1294,6 +1309,58 @@ def test_project_with_handoff_only(self): assert result.automation_handoff.integration_id == 42 assert result.automation_handoff.auto_create_pr is True + def test_project_with_repos_and_options(self): + SeerProjectRepository.objects.create( + project=self.project, + repository=self.repo, + branch_name="main", + ) + SeerProjectRepository.objects.create( + project=self.project, + repository=self.repo2, + branch_name="develop", + ) + self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert len(result.repositories) == 2 + assert result.automated_run_stopping_point == "open_pr" + 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 False + + def test_excludes_other_projects_data(self): + other_project = self.create_project(organization=self.organization) + other_repo = self.create_repo( + project=other_project, + provider="integrations:github", + external_id="ext789", + name="test-org/other-project-repo", + ) + SeerProjectRepository.objects.create( + project=other_project, repository=other_repo, branch_name="main" + ) + other_project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + + SeerProjectRepository.objects.create( + project=self.project, repository=self.repo, branch_name="develop" + ) + self.project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + + result = read_preference_from_sentry_db(self.project) + assert result is not None + assert len(result.repositories) == 1 + assert result.repositories[0].name == "test-repo" + assert result.automated_run_stopping_point == "root_cause" + def test_partial_handoff_returns_none_handoff(self): self.project.update_option("sentry:seer_automation_handoff_point", "root_cause") @@ -1315,6 +1382,12 @@ def setUp(self): external_id="ext123", name="test-org/test-repo", ) + self.repo2 = self.create_repo( + project=self.project1, + provider="integrations:github", + external_id="ext456", + name="test-org/other-repo", + ) def test_empty_project_ids_returns_empty(self): result = bulk_read_preferences_from_sentry_db(self.organization.id, []) @@ -1328,6 +1401,9 @@ def test_bulk_returns_correct_preferences(self): SeerProjectRepository.objects.create( project=self.project1, repository=self.repo, branch_name="main" ) + SeerProjectRepository.objects.create( + project=self.project1, repository=self.repo2, branch_name="develop" + ) self.project2.update_option("sentry:seer_automated_run_stopping_point", "open_pr") self.project2.update_option("sentry:seer_automation_handoff_point", "root_cause") self.project2.update_option( @@ -1342,8 +1418,8 @@ def test_bulk_returns_correct_preferences(self): pref1 = result[self.project1.id] assert pref1 is not None - assert len(pref1.repositories) == 1 - assert pref1.repositories[0].branch_name == "main" + assert len(pref1.repositories) == 2 + assert {r.branch_name for r in pref1.repositories} == {"main", "develop"} assert pref1.automated_run_stopping_point == "code_changes" assert pref1.automation_handoff is None From 061dc64586eaedd2fecbe189f401a4901ccd7c68 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 11:15:58 -0700 Subject: [PATCH 09/30] add build handoff helper --- src/sentry/seer/autofix/utils.py | 59 ++++++++++++-------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index eb4ea942a3dd..8e6d7a39ac69 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from collections.abc import Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum from typing import Any, NotRequired, TypedDict @@ -702,6 +702,25 @@ def build_repo_definition_from_project_repo( ) +def _build_automation_handoff( + get_option: Callable[[str], Any], +) -> SeerAutomationHandoffConfiguration | None: + """Build a SeerAutomationHandoffConfiguration from option values, or None if incomplete.""" + handoff_point = get_option("sentry:seer_automation_handoff_point") + handoff_target = get_option("sentry:seer_automation_handoff_target") + handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id") + + if handoff_point is None or handoff_target is None or handoff_integration_id is None: + return None + + return SeerAutomationHandoffConfiguration( + handoff_point=handoff_point, + target=handoff_target, + integration_id=handoff_integration_id, + auto_create_pr=get_option("sentry:seer_automation_handoff_auto_create_pr"), + ) + + def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: """Read a single project's Seer preferences from Sentry DB.""" seer_project_repo_qs = ( @@ -720,29 +739,12 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | if not repo_definitions and not has_configured_options: return None - handoff_point = project.get_option("sentry:seer_automation_handoff_point") - handoff_target = project.get_option("sentry:seer_automation_handoff_target") - handoff_integration_id = project.get_option("sentry:seer_automation_handoff_integration_id") - - automation_handoff = None - if ( - handoff_point is not None - and handoff_target is not None - and handoff_integration_id is not None - ): - automation_handoff = SeerAutomationHandoffConfiguration( - handoff_point=handoff_point, - target=handoff_target, - integration_id=handoff_integration_id, - auto_create_pr=project.get_option("sentry:seer_automation_handoff_auto_create_pr"), - ) - return SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, repositories=repo_definitions, automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), - automation_handoff=automation_handoff, + automation_handoff=_build_automation_handoff(project.get_option), ) @@ -788,23 +790,6 @@ def get_project_option(key: str) -> Any: return projectoptions.lookup_well_known_key(key).default return value - handoff_point = get_project_option("sentry:seer_automation_handoff_point") - handoff_target = get_project_option("sentry:seer_automation_handoff_target") - handoff_integration_id = get_project_option("sentry:seer_automation_handoff_integration_id") - - automation_handoff = None - if ( - handoff_point is not None - and handoff_target is not None - and handoff_integration_id is not None - ): - automation_handoff = SeerAutomationHandoffConfiguration( - handoff_point=handoff_point, - target=handoff_target, - integration_id=handoff_integration_id, - auto_create_pr=get_project_option("sentry:seer_automation_handoff_auto_create_pr"), - ) - result[project.id] = SeerProjectPreference( organization_id=project.organization_id, project_id=project.id, @@ -812,7 +797,7 @@ def get_project_option(key: str) -> Any: automated_run_stopping_point=get_project_option( "sentry:seer_automated_run_stopping_point" ), - automation_handoff=automation_handoff, + automation_handoff=_build_automation_handoff(get_project_option), ) return result From 35bbeffd1fcc028614739cd5ed68eb0bc23522bc Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 11:17:50 -0700 Subject: [PATCH 10/30] move comment --- src/sentry/seer/autofix/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 8e6d7a39ac69..d22d08c82fed 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -767,6 +767,8 @@ def bulk_read_preferences_from_sentry_db( build_repo_definition_from_project_repo(project_repo) ) + # get_value_bulk_id returns None for missing options, unlike project.get_option + # which automatically falls back to the registered well-known key default. project_options: dict[str, Mapping[int, Any]] = { key: ProjectOption.objects.get_value_bulk_id(project_ids, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS @@ -782,8 +784,6 @@ def bulk_read_preferences_from_sentry_db( result[project.id] = None continue - # get_value_bulk_id returns None for missing options, unlike project.get_option - # which automatically falls back to the registered well-known key default. def get_project_option(key: str) -> Any: value = project_options[key][project.id] if value is None: From c47e75838cf1125ba96750360467a3bd19606605 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 19:00:48 +0000 Subject: [PATCH 11/30] Use epoch-aware defaults in bulk preference reads Co-authored-by: Armen Zambrano G. Applied via @cursor push command --- src/sentry/seer/autofix/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index d22d08c82fed..55a34c165575 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -787,7 +787,7 @@ def bulk_read_preferences_from_sentry_db( def get_project_option(key: str) -> Any: value = project_options[key][project.id] if value is None: - return projectoptions.lookup_well_known_key(key).default + return projectoptions.lookup_well_known_key(key).get_default(project) return value result[project.id] = SeerProjectPreference( From 8718314e0a321cc4a27b08eb47c2a220ab65b239 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 13:52:41 -0700 Subject: [PATCH 12/30] update endpoints and other spots --- src/sentry/seer/autofix/autofix_agent.py | 41 ++++++++++------ src/sentry/seer/autofix/coding_agent.py | 49 +++++++++++++------ src/sentry/seer/autofix/issue_summary.py | 9 +++- src/sentry/seer/autofix/on_completion_hook.py | 31 +++++++----- src/sentry/seer/autofix/trigger.py | 2 +- src/sentry/seer/autofix/utils.py | 36 ++++++-------- .../endpoints/group_autofix_setup_check.py | 4 +- ...rganization_autofix_automation_settings.py | 30 +++++++++--- .../endpoints/project_seer_preferences.py | 43 +++++++++------- src/sentry/tasks/seer/autofix.py | 9 +++- 10 files changed, 160 insertions(+), 94 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 2e0898cc375f..92adfc9dae7f 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from rest_framework.exceptions import PermissionDenied +from sentry import features from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.seer.autofix.artifact_schemas import ( ImpactAssessmentArtifact, @@ -27,6 +28,7 @@ AutofixStoppingPoint, get_autofix_state, get_project_seer_preferences, + read_preference_from_sentry_db, ) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.client import SeerExplorerClient @@ -437,21 +439,30 @@ def trigger_coding_agent_handoff( auto_create_pr = False repo_definitions: list[SeerRepoDefinition] = [] - try: - preference_response = get_project_seer_preferences(group.project_id) - if preference_response and preference_response.preference: - repo_definitions = list(preference_response.preference.repositories) - if preference_response.preference.automation_handoff: - auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except Exception: - logger.exception( - "autofix.coding_agent_handoff.get_preferences_error", - extra={ - "organization_id": group.organization.id, - "run_id": run_id, - "project_id": group.project_id, - }, - ) + 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 and preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + else: + try: + preference_response = get_project_seer_preferences(group.project_id) + if preference_response and preference_response.preference: + repo_definitions = list(preference_response.preference.repositories) + if preference_response.preference.automation_handoff: + auto_create_pr = ( + preference_response.preference.automation_handoff.auto_create_pr + ) + except Exception: + logger.exception( + "autofix.coding_agent_handoff.get_preferences_error", + extra={ + "organization_id": group.organization.id, + "run_id": run_id, + "project_id": group.project_id, + }, + ) if not repo_definitions: return { diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 5d61bdb065e3..ef981afc47e0 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,6 +12,7 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features +from sentry.models.project import Project class IntegrationNotFound(NotFound): @@ -56,6 +57,7 @@ class StateReposNotFound(NotFound): get_coding_agent_prompt, get_project_seer_preferences, make_store_coding_agent_states_request, + read_preference_from_sentry_db, update_coding_agent_state, ) from sentry.seer.models import SeerApiError, SeerApiResponseValidationError @@ -230,20 +232,39 @@ def _launch_agents_for_repos( # Fetch project preferences to get auto_create_pr setting from automation_handoff auto_create_pr = False - try: - preference_response = get_project_seer_preferences(autofix_state.request.project_id) - if preference_response and preference_response.preference: - if preference_response.preference.automation_handoff: - auto_create_pr = preference_response.preference.automation_handoff.auto_create_pr - except (SeerApiError, SeerApiResponseValidationError): - logger.exception( - "coding_agent.get_project_seer_preferences_error", - extra={ - "organization_id": organization.id, - "run_id": run_id, - "project_id": autofix_state.request.project_id, - }, - ) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + try: + project = Project.objects.get_from_cache(id=autofix_state.request.project_id) + except Project.DoesNotExist: + logger.exception( + "coding_agent.project_not_found", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "project_id": autofix_state.request.project_id, + }, + ) + + preference = read_preference_from_sentry_db(project) + if preference and preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr + else: + try: + preference_response = get_project_seer_preferences(autofix_state.request.project_id) + if preference_response and preference_response.preference: + if preference_response.preference.automation_handoff: + auto_create_pr = ( + preference_response.preference.automation_handoff.auto_create_pr + ) + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "coding_agent.get_project_seer_preferences_error", + extra={ + "organization_id": organization.id, + "run_id": run_id, + "project_id": autofix_state.request.project_id, + }, + ) repos = set( _extract_repos_from_root_cause(autofix_state) diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index 08361e53317b..9d10ee531043 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -35,6 +35,7 @@ is_seer_autotriggered_autofix_rate_limited_and_increment, is_seer_seat_based_tier_enabled, make_get_project_preference_request, + read_preference_from_sentry_db, ) from sentry.seer.entrypoints.cache import SeerOperatorAutofixCache from sentry.seer.entrypoints.operator import SeerAutofixOperator @@ -511,7 +512,13 @@ def get_automation_stopping_point(group: Group) -> AutofixStoppingPoint: """ fixability_score = get_and_update_group_fixability_score(group) fixability_stopping_point = _get_stopping_point_from_fixability(fixability_score) - user_preference = _fetch_user_preference(group.project.id) + + 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 + else: + user_preference = _fetch_user_preference(group.project.id) + return _apply_user_preference_upper_bound(fixability_stopping_point, user_preference) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 0f86910bb207..65b3d4f4321a 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -20,6 +20,7 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, get_project_seer_preferences, + read_preference_from_sentry_db, resolve_repository_ids, set_project_seer_preference, write_preference_to_sentry_db, @@ -465,19 +466,23 @@ def _get_handoff_config_if_applicable( return None # Check project preferences - try: - preference_response = get_project_seer_preferences(group.project_id) - 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_response or not preference_response.preference: - return None - handoff_config = preference_response.preference.automation_handoff - if not handoff_config: - return None + if features.has("organizations:seer-project-settings-read-from-sentry", group.organization): + preference = read_preference_from_sentry_db(group.project) + if not preference: + return None + handoff_config = preference.automation_handoff + else: + try: + preference_response = get_project_seer_preferences(group.project_id) + 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_response or not preference_response.preference: + return None + handoff_config = preference_response.preference.automation_handoff return handoff_config diff --git a/src/sentry/seer/autofix/trigger.py b/src/sentry/seer/autofix/trigger.py index b52e3ca9478a..c4608e500e56 100644 --- a/src/sentry/seer/autofix/trigger.py +++ b/src/sentry/seer/autofix/trigger.py @@ -182,7 +182,7 @@ def get_seat_based_seer_automation_skip_reason( return "automation_already_dispatched" # Another process already dispatched automation # Check if project has connected repositories - requirement for new pricing - if not has_project_connected_repos(group.organization.id, group.project.id): + if not has_project_connected_repos(group.organization, group.project): return "no_connected_repos" return None diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index d22d08c82fed..68f156e9ce11 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -815,44 +815,38 @@ def set_project_seer_preference(preference: SeerProjectPreference) -> None: def has_project_connected_repos( - organization_id: int, project_id: int, *, skip_cache: bool = False + organization: Organization, project: Project, *, skip_cache: bool = False ) -> bool: """ Check if a project has connected repositories for Seer automation. Checks Seer preferences first, then falls back to Sentry code mappings. Results are cached for 15 minutes to minimize API calls. """ - cache_key = f"seer-project-has-repos:{organization_id}:{project_id}" + cache_key = f"seer-project-has-repos:{organization.id}:{project.id}" if not skip_cache: cached_value = cache.get(cache_key) if cached_value is not None: return cached_value - has_repos = False - - try: - project_preferences = get_project_seer_preferences(project_id) - has_repos = bool( - project_preferences.preference and project_preferences.preference.repositories - ) - except (SeerApiError, SeerApiResponseValidationError): - pass + 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) + else: + try: + project_preferences = get_project_seer_preferences(project.id) + has_repos = bool( + project_preferences.preference and project_preferences.preference.repositories + ) + except (SeerApiError, SeerApiResponseValidationError): + pass if not has_repos: # If it's the first autofix run of project we check code mapping. - try: - project = Project.objects.get(id=project_id) - has_repos = bool(get_autofix_repos_from_project_code_mappings(project)) - except Project.DoesNotExist: - pass + has_repos = bool(get_autofix_repos_from_project_code_mappings(project)) logger.info( "Checking if project has repositories connected", - extra={ - "org_id": organization_id, - "project_id": project_id, - "has_repos": has_repos, - }, + extra={"org_id": organization.id, "project_id": project.id, "has_repos": has_repos}, ) cache.set(cache_key, has_repos, timeout=60 * 15) # Cache for 15 minutes diff --git a/src/sentry/seer/endpoints/group_autofix_setup_check.py b/src/sentry/seer/endpoints/group_autofix_setup_check.py index c54e313341c8..6ec097c866ec 100644 --- a/src/sentry/seer/endpoints/group_autofix_setup_check.py +++ b/src/sentry/seer/endpoints/group_autofix_setup_check.py @@ -156,9 +156,7 @@ def get(self, request: Request, group: Group) -> Response: try: # Check if project has repos linked in Seer. # Skip cache to ensure latest data from Seer API. - seer_repos_linked = has_project_connected_repos( - org.id, group.project.id, skip_cache=True - ) + seer_repos_linked = has_project_connected_repos(org, group.project, skip_cache=True) except Exception as e: # Default to False if we can't check if the project has repos linked in Seer. sentry_sdk.capture_exception(e) diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index 399d73bf3248..6f42eb6f6104 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -25,6 +25,7 @@ AutofixStoppingPoint, SeerAutofixSettingsSerializer, bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, bulk_set_project_preferences, bulk_write_preferences_to_sentry_db, deduplicate_repositories, @@ -183,14 +184,22 @@ def _serialize_projects_with_settings( autofix_automation_tuning_map = ProjectOption.objects.get_value_bulk( projects, "sentry:autofix_automation_tuning" ) - seer_preferences_map = bulk_get_project_preferences(organization.id, project_ids_list) or {} + + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db(organization.id, project_ids_list) + preferences_map = { + str(project_id): preference.dict() for project_id, preference in preferences.items() + } + else: + preferences_map = bulk_get_project_preferences(organization.id, project_ids_list) or {} + results = [] for project in projects: autofix_automation_tuning = ( autofix_automation_tuning_map.get(project) or AutofixAutomationTuningSettings.OFF.value ) - seer_pref = seer_preferences_map.get(str(project.id)) or {} + seer_pref = preferences_map.get(str(project.id)) or {} results.append( { "projectId": project.id, @@ -294,9 +303,18 @@ def post(self, request: Request, organization: Organization) -> Response: preferences_to_set: list[dict[str, Any]] = [] if automated_run_stopping_point or filtered_repo_mappings: - existing_preferences = bulk_get_project_preferences( - organization.id, list(projects_by_id.keys()) - ) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + existing_preferences = bulk_read_preferences_from_sentry_db( + organization.id, list(projects_by_id.keys()) + ) + existing_preferences_map = { + str(project_id): preference.dict() + for project_id, preference in existing_preferences.items() + } + else: + existing_preferences_map = bulk_get_project_preferences( + organization.id, list(projects_by_id.keys()) + ) for proj_id, project in projects_by_id.items(): has_stopping_point_update = automated_run_stopping_point is not None @@ -306,7 +324,7 @@ def post(self, request: Request, organization: Organization) -> Response: continue project_id_str = str(proj_id) - existing_pref = existing_preferences.get(project_id_str) or {} + existing_pref = existing_preferences_map.get(project_id_str) or {} pref_update: dict[str, Any] = { **default_seer_project_preference(project).dict(), diff --git a/src/sentry/seer/endpoints/project_seer_preferences.py b/src/sentry/seer/endpoints/project_seer_preferences.py index 6836bc2595b8..68ce8f7a5f3f 100644 --- a/src/sentry/seer/endpoints/project_seer_preferences.py +++ b/src/sentry/seer/endpoints/project_seer_preferences.py @@ -21,6 +21,7 @@ get_autofix_repos_from_project_code_mappings, make_get_project_preference_request, make_set_project_preference_request, + read_preference_from_sentry_db, write_preference_to_sentry_db, ) from sentry.seer.endpoints.organization_autofix_automation_settings import ( @@ -152,28 +153,32 @@ def post(self, request: Request, project: Project) -> Response: return Response(status=204) def get(self, request: Request, project: Project) -> Response: - body = GetProjectPreferenceRequest(project_id=project.id) - viewer_context = SeerViewerContext( - organization_id=project.organization.id, user_id=request.user.id - ) - response = make_get_project_preference_request( - body, - connection_pool=seer_autofix_default_connection_pool, - viewer_context=viewer_context, - ) - - if response.status >= 400: - raise SeerApiError("Seer request failed", response.status) - - result = response.json() + if features.has( + "organizations:seer-project-settings-read-from-sentry", project.organization + ): + preference = read_preference_from_sentry_db(project) + else: + body = GetProjectPreferenceRequest(project_id=project.id) + viewer_context = SeerViewerContext( + organization_id=project.organization.id, user_id=request.user.id + ) + response = make_get_project_preference_request( + body, + connection_pool=seer_autofix_default_connection_pool, + viewer_context=viewer_context, + ) + + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + + result = response.json() + preference = result.get("preference") code_mapping_repos = get_autofix_repos_from_project_code_mappings(project) return Response( - PreferenceResponse.validate( - { - **result, - "code_mapping_repos": code_mapping_repos, - } + PreferenceResponse( + preference=preference, + code_mapping_repos=code_mapping_repos, ).dict() ) diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 0ab062fa8a2c..749e93ae6675 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -21,6 +21,7 @@ ) from sentry.seer.autofix.utils import ( bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, bulk_set_project_preferences, bulk_write_preferences_to_sentry_db, deduplicate_repositories, @@ -246,7 +247,13 @@ def configure_seer_for_existing_org(organization_id: int) -> None: default_handoff_dict = default_handoff.dict() if default_handoff else None valid_stopping_points = get_valid_automated_run_stopping_points(organization) - preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db(organization_id, project_ids) + preferences_by_id = { + str(project_id): preference.dict() for project_id, preference in preferences.items() + } + else: + preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) # Determine which projects need updates preferences_to_set = [] From c1cad19c272b7334b2ed25e793542d1b21127539 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 14:56:40 -0700 Subject: [PATCH 13/30] test coverage --- .../sentry/seer/autofix/test_autofix_agent.py | 83 +++++++++++++------ .../test_autofix_on_completion_hook.py | 22 +++++ .../sentry/seer/autofix/test_autofix_utils.py | 43 ++++++++-- .../sentry/seer/autofix/test_coding_agent.py | 60 +++++++++++++- .../sentry/seer/autofix/test_issue_summary.py | 18 ++++ 5 files changed, 189 insertions(+), 37 deletions(-) diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index d966e571c417..59427ca89cea 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest @@ -11,15 +12,24 @@ trigger_coding_agent_handoff, trigger_push_changes, ) -from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.constants import AutofixReferrer, AutofixStatus +from sentry.seer.autofix.utils import AutofixRequest, AutofixState from sentry.seer.explorer.client_models import ( Artifact, MemoryBlock, Message, SeerRunState, ) +from sentry.seer.models import ( + AutofixHandoffPoint, + PreferenceResponse, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRepoDefinition, +) from sentry.sentry_apps.utils.webhooks import SeerActionType from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestGenerateAutofixHandoffPrompt(TestCase): @@ -384,16 +394,8 @@ def _make_run_state(self, artifacts: list[Artifact] | None = None) -> SeerRunSta repo_pr_states={}, ) - def _make_preference_response(self, repos=None, auto_create_pr=False): - """Helper to create a PreferenceResponse with repos.""" - from sentry.seer.models import ( - AutofixHandoffPoint, - PreferenceResponse, - SeerAutomationHandoffConfiguration, - SeerProjectPreference, - SeerRepoDefinition, - ) - + def _make_preference(self, repos=None, auto_create_pr=False): + """Helper to create a SeerProjectPreference with repos.""" if repos is None: repos = [ SeerRepoDefinition(provider="github", owner="owner", name="repo", external_id="123") @@ -410,12 +412,16 @@ def _make_preference_response(self, repos=None, auto_create_pr=False): else None ) - preference = SeerProjectPreference( + return SeerProjectPreference( organization_id=self.organization.id, project_id=self.project.id, repositories=repos, automation_handoff=handoff_config, ) + + def _make_preference_response(self, repos=None, auto_create_pr=False) -> PreferenceResponse: + """Helper to create a PreferenceResponse with repos.""" + preference = self._make_preference(repos, auto_create_pr) return PreferenceResponse(preference=preference, code_mapping_repos=[]) @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") @@ -639,8 +645,6 @@ def test_trigger_coding_agent_handoff_filters_to_relevant_repo( self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that only the repo named in relevant_repo is passed to launch_coding_agents.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -684,8 +688,6 @@ def test_trigger_coding_agent_handoff_falls_back_to_first_repo_when_no_relevant_ self, mock_client_class, mock_get_prefs, mock_logger, mock_get_autofix_state ): """Test that when relevant_repo is absent, first configured repo is used and a warning is logged.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -731,8 +733,6 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match self, mock_client_class, mock_get_prefs, mock_logger, mock_get_autofix_state ): """Test that when relevant_repo doesn't match any configured repo, first repo is used.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state( @@ -795,12 +795,6 @@ def test_trigger_coding_agent_handoff_enriches_branch_name_from_autofix_state( self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that branch_name is resolved from autofix state when unset in preferences.""" - from datetime import datetime, timezone - - from sentry.seer.autofix.constants import AutofixStatus - from sentry.seer.autofix.utils import AutofixRequest, AutofixState - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -852,8 +846,6 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se self, mock_client_class, mock_get_prefs, mock_get_autofix_state ): """Test that branch_name from preferences is used as-is when already set.""" - from sentry.seer.models import SeerRepoDefinition - mock_client = MagicMock() mock_client_class.return_value = mock_client mock_client.get_run.return_value = self._make_run_state() @@ -881,6 +873,45 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se repos = mock_client.launch_coding_agents.call_args.kwargs["repos"] assert repos[0].branch_name == "release/v2" + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") + @patch("sentry.seer.autofix.autofix_agent.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") + @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") + def test_trigger_coding_agent_handoff_reads_from_sentry_db( + self, mock_client_class, mock_get_prefs, mock_read_db, mock_get_autofix_state + ): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_read_db.return_value = self._make_preference( + repos=[ + SeerRepoDefinition( + provider="github", + owner="owner", + name="repo", + external_id="123", + branch_name="main", + ) + ], + auto_create_pr=True, + ) + + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.get_run.return_value = self._make_run_state() + mock_client.launch_coding_agents.return_value = {"successes": [], "failures": []} + mock_get_autofix_state.return_value = None + + trigger_coding_agent_handoff( + group=self.group, run_id=123, referrer=AutofixReferrer.UNKNOWN, integration_id=456 + ) + + mock_get_prefs.assert_not_called() + + call_kwargs = mock_client.launch_coding_agents.call_args.kwargs + assert len(call_kwargs["repos"]) == 1 + assert call_kwargs["repos"][0].branch_name == "main" + assert call_kwargs["auto_create_pr"] is True + class TestTriggerPushChanges(TestCase): """Tests for trigger_push_changes function.""" diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 327669d82ec0..7573d2b4d9a4 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -586,6 +586,28 @@ def test_get_handoff_config_returns_config_when_applicable(self, mock_get_prefs) assert result == handoff_config + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.on_completion_hook.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + def test_get_handoff_config_reads_from_sentry_db(self, mock_get_prefs, mock_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + handoff_config = self._make_handoff_config() + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automation_handoff=handoff_config, + ) + + result = AutofixOnCompletionHook._get_handoff_config_if_applicable( + stopping_point=AutofixStoppingPoint.CODE_CHANGES, + current_step=AutofixStep.ROOT_CAUSE, + group=self.group, + ) + + mock_get_prefs.assert_not_called() + assert result == handoff_config + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") def test_maybe_continue_pipeline_triggers_handoff_when_configured( diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index b039c2058ca4..d4e625a8873d 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -36,6 +36,7 @@ SeerProjectRepositoryBranchOverride, ) from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature from sentry.utils.cache import cache @@ -468,7 +469,7 @@ def test_returns_true_when_repos_exist(self, mock_get_preferences, mock_cache): mock_response.preference = mock_preference mock_get_preferences.return_value = mock_response - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_cache.set.assert_called_once_with( @@ -492,7 +493,7 @@ def test_returns_false_when_no_repos( mock_get_preferences.return_value = mock_response mock_get_code_mappings.return_value = [] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_cache.set.assert_called_once_with( @@ -514,7 +515,7 @@ def test_returns_false_when_preference_is_none_and_no_code_mappings( mock_get_preferences.return_value = mock_response mock_get_code_mappings.return_value = [] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_cache.set.assert_called_once_with( @@ -538,7 +539,7 @@ def test_falls_back_to_code_mappings_when_no_seer_preference( {"provider": "github", "owner": "test", "name": "repo"} ] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_code_mappings.assert_called_once() @@ -554,7 +555,7 @@ def test_returns_cached_value_true(self, mock_get_preferences, mock_cache): """Test returns cached True value without calling API.""" mock_cache.get.return_value = True - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_preferences.assert_not_called() @@ -566,7 +567,7 @@ def test_returns_cached_value_false(self, mock_get_preferences, mock_cache): """Test returns cached False value without calling API.""" mock_cache.get.return_value = False - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is False mock_get_preferences.assert_not_called() @@ -583,7 +584,7 @@ def test_skip_cache_bypasses_cached_value(self, mock_get_preferences, mock_cache mock_response.preference = mock_preference mock_get_preferences.return_value = mock_response - result = has_project_connected_repos(self.organization.id, self.project.id, skip_cache=True) + result = has_project_connected_repos(self.organization, self.project, skip_cache=True) assert result is True # Fresh value from API, not cached False mock_cache.get.assert_not_called() # Cache not checked @@ -603,11 +604,37 @@ def test_falls_back_to_code_mappings_on_api_error( {"provider": "github", "owner": "test", "name": "repo"} ] - result = has_project_connected_repos(self.organization.id, self.project.id) + result = has_project_connected_repos(self.organization, self.project) assert result is True mock_get_code_mappings.assert_called_once() + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.utils.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.utils.get_project_seer_preferences") + @patch("sentry.seer.autofix.utils.cache") + def test_reads_from_sentry_db(self, mock_cache, mock_get_prefs, mock_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_cache.get.return_value = None + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition(provider="github", owner="owner", name="repo", external_id="123") + ], + ) + + result = has_project_connected_repos(self.organization, self.project) + + assert result is True + mock_get_prefs.assert_not_called() + mock_read_db.assert_called_once() + mock_cache.set.assert_called_once_with( + f"seer-project-has-repos:{self.organization.id}:{self.project.id}", + True, + timeout=60 * 15, + ) + class TestSetProjectSeerPreference(TestCase): """Test the set_project_seer_preference function.""" diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 793e239f2e94..badb2964ed42 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -25,9 +25,17 @@ CodingAgentState, CodingAgentStatus, ) -from sentry.seer.models import SeerApiError, SeerRepoDefinition +from sentry.seer.models import ( + AutofixHandoffPoint, + PreferenceResponse, + SeerApiError, + SeerAutomationHandoffConfiguration, + SeerProjectPreference, + SeerRepoDefinition, +) from sentry.shared_integrations.exceptions import ApiError from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestLaunchAgentsForRepos(TestCase): @@ -213,8 +221,6 @@ def test_auto_create_pr_defaults_to_false_when_no_automation_handoff( self, mock_get_preferences, mock_get_prompt, mock_store_states ): """Test that auto_create_pr defaults to False when automation_handoff is None.""" - from sentry.seer.models import PreferenceResponse, SeerProjectPreference - # Setup: Mock get_project_seer_preferences to return preference without automation_handoff preference = SeerProjectPreference( organization_id=self.organization.id, @@ -257,6 +263,54 @@ def test_auto_create_pr_defaults_to_false_when_no_automation_handoff( launch_request = mock_installation.launch.call_args[0][0] assert launch_request.auto_create_pr is False + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") + @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") + @patch("sentry.seer.autofix.coding_agent.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") + def test_auto_create_pr_reads_from_sentry_db( + self, mock_get_preferences, mock_read_db, mock_get_prompt, mock_store_states + ): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", owner="getsentry", name="sentry", external_id="123" + ) + ], + automation_handoff=SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target="cursor_background_agent", + integration_id=123, + auto_create_pr=True, + ), + ) + + mock_get_prompt.return_value = "Test prompt" + + mock_installation = MagicMock() + mock_installation.launch.return_value = { + "url": "https://example.com/agent", + "id": "agent-123", + } + + _launch_agents_for_repos( + installation=mock_installation, + autofix_state=self.autofix_state, + run_id=self.run_id, + organization=self.organization, + trigger_source=AutofixTriggerSource.SOLUTION, + ) + + mock_get_preferences.assert_not_called() + assert mock_installation.launch.called + launch_request = mock_installation.launch.call_args[0][0] + assert launch_request.auto_create_pr is True + assert launch_request.repository.owner == "getsentry" + assert launch_request.repository.name == "sentry" + @patch("sentry.seer.autofix.coding_agent.store_coding_agent_states_to_seer") @patch("sentry.seer.autofix.coding_agent.get_coding_agent_prompt") @patch("sentry.seer.autofix.coding_agent.get_project_seer_preferences") diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 05ea097c4e69..ed460f4d8fbb 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -26,6 +26,7 @@ ) from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.seer.models import SummarizeIssueResponse, SummarizeIssueScores +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase from sentry.testutils.helpers.datetime import before_now from sentry.testutils.helpers.features import with_feature @@ -1557,3 +1558,20 @@ def test_low_fixability_returns_root_cause(self, mock_fixability, mock_preferenc mock_preference.return_value = "open_pr" assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.ROOT_CAUSE + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.issue_summary.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.issue_summary._fetch_user_preference") + @patch("sentry.seer.autofix.issue_summary.get_and_update_group_fixability_score") + def test_reads_stopping_point_from_sentry_db( + self, mock_fixability, mock_preference, mock_read_db + ): + mock_fixability.return_value = 0.80 + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automated_run_stopping_point="solution", + ) + + assert get_automation_stopping_point(self.group) == AutofixStoppingPoint.SOLUTION From 7b1f1da0720d9c3b769c4214e406d1f4d05ef3b0 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 15:32:57 -0700 Subject: [PATCH 14/30] more test coverage --- .../test_project_seer_preferences.py | 39 ++++++++--- tests/sentry/tasks/seer/test_autofix.py | 68 ++++++++++++++++--- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index 7fc64863f280..5a6471b83f49 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -4,8 +4,8 @@ from sentry.models.repository import Repository from sentry.seer.models import PreferenceResponse, SeerProjectPreference, SeerRepoDefinition -from sentry.seer.models.project_repository import SeerProjectRepository from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature class ProjectSeerPreferencesEndpointTest(APITestCase): @@ -684,9 +684,13 @@ def test_post_rejects_mismatched_repo_name_or_owner(self, mock_request: MagicMoc assert response.data["detail"] == "Invalid repository" mock_request.assert_not_called() + @with_feature("organizations:seer-project-settings-dual-write") + @patch("sentry.seer.endpoints.project_seer_preferences.write_preference_to_sentry_db") @patch("sentry.seer.endpoints.project_seer_preferences.make_set_project_preference_request") - def test_post_creates_seer_project_repository(self, mock_request: MagicMock) -> None: - """Test that POST writes to SeerProjectRepository when feature flag is enabled.""" + def test_post_writes_to_sentry_db( + self, mock_request: MagicMock, mock_write_db: MagicMock + ) -> None: + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" mock_response = Mock() mock_response.status = 200 mock_request.return_value = mock_response @@ -707,11 +711,30 @@ def test_post_creates_seer_project_repository(self, mock_request: MagicMock) -> "automated_run_stopping_point": "open_pr", } - with self.feature("organizations:seer-project-settings-dual-write"): - response = self.client.post(self.url, data=request_data) + response = self.client.post(self.url, data=request_data) assert response.status_code == 204 - seer_repo = SeerProjectRepository.objects.get(project=self.project) - assert seer_repo.repository_id == self.repository.id - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + mock_write_db.assert_called_once() + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.endpoints.project_seer_preferences.read_preference_from_sentry_db") + def test_get_reads_from_sentry_db(self, mock_read_db: MagicMock) -> None: + """When feature flag enabled, reads from Sentry DB instead of Seer API.""" + preference = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[ + SeerRepoDefinition( + provider="github", owner="getsentry", name="sentry", external_id="123" + ) + ], + automated_run_stopping_point="open_pr", + ) + mock_read_db.return_value = preference + + response = self.client.get(self.url) + + mock_read_db.assert_called_once() + assert response.status_code == 200 + assert response.data["preference"] == preference.dict() diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index 201d437ecf4d..56a8d34f7248 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -8,14 +8,20 @@ from sentry.models.repository import Repository from sentry.seer.autofix.constants import AutofixStatus, SeerAutomationSource from sentry.seer.autofix.utils import AutofixState, get_seer_seat_based_tier_cache_key -from sentry.seer.models import SeerApiError, SummarizeIssueResponse, SummarizeIssueScores -from sentry.seer.models.project_repository import SeerProjectRepository +from sentry.seer.models import ( + SeerApiError, + SeerProjectPreference, + SeerRepoDefinition, + SummarizeIssueResponse, + SummarizeIssueScores, +) from sentry.tasks.seer.autofix import ( check_autofix_status, configure_seer_for_existing_org, generate_issue_summary_only, ) from sentry.testutils.cases import TestCase as SentryTestCase +from sentry.testutils.helpers.features import with_feature from sentry.utils.cache import cache @@ -466,14 +472,16 @@ def test_preserves_existing_repositories_when_preferences_exist( preferences = mock_bulk_set.call_args[0][1] assert preferences[0]["repositories"] == existing_repos + @with_feature("organizations:seer-project-settings-dual-write") + @patch("sentry.tasks.seer.autofix.bulk_write_preferences_to_sentry_db") @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") - def test_creates_seer_project_repository( - self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + def test_writes_to_sentry_db( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock, mock_bulk_write_db: MagicMock ) -> None: - """Test that SeerProjectRepository is created when feature flag is enabled.""" + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" project = self.create_project(organization=self.organization) - repo = Repository.objects.create( + Repository.objects.create( organization_id=self.organization.id, name="test-org/test-repo", provider="github", @@ -494,8 +502,48 @@ def test_creates_seer_project_repository( } } - with self.feature("organizations:seer-project-settings-dual-write"): - configure_seer_for_existing_org(organization_id=self.organization.id) + configure_seer_for_existing_org(organization_id=self.organization.id) - seer_repo = SeerProjectRepository.objects.get(project=project) - assert seer_repo.repository_id == repo.id + mock_bulk_write_db.assert_called_once() + preferences = mock_bulk_write_db.call_args[0][1] + assert len(preferences) == 1 + assert preferences[0].project_id == project.id + assert preferences[0].repositories[0].owner == "test-org" + assert preferences[0].repositories[0].name == "test-repo" + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_read_preferences_from_sentry_db") + def test_reads_from_sentry_db( + self, mock_bulk_read_db: MagicMock, mock_bulk_set: MagicMock + ) -> None: + project = self.create_project(organization=self.organization) + + mock_bulk_read_db.return_value = { + project.id: SeerProjectPreference( + organization_id=self.organization.id, + project_id=project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="test-org", + name="test-repo", + external_id="ext123", + ) + ], + automated_run_stopping_point=None, + ) + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_read_db.assert_called_once() + assert mock_bulk_set.called + preferences = mock_bulk_set.call_args[0][1] + assert len(preferences) == 1 + repos = preferences[0]["repositories"] + assert len(repos) == 1 + assert repos[0]["provider"] == "github" + assert repos[0]["owner"] == "test-org" + assert repos[0]["name"] == "test-repo" + assert repos[0]["external_id"] == "ext123" From 259533a48875176c26b83ebdaa6b2ceea11851b6 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 15:41:31 -0700 Subject: [PATCH 15/30] more test coverage --- ...rganization_autofix_automation_settings.py | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) 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 381ae9e7376a..20dd36f5518c 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -9,6 +9,7 @@ from sentry.seer.models.project_repository import SeerProjectRepository from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode @@ -145,6 +146,53 @@ def test_get_reads_project_preferences(self, mock_bulk_get_preferences): }, ] + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_read_preferences_from_sentry_db" + ) + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_reads_from_sentry_db(self, mock_bulk_get_preferences, mock_bulk_read_db): + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition + + project = self.create_project(organization=self.organization, name="DB Read Project") + project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM.value + ) + + mock_bulk_read_db.return_value = { + project.id: SeerProjectPreference( + organization_id=self.organization.id, + project_id=project.id, + repositories=[ + SeerRepoDefinition( + provider="github", + owner="test-org", + name="test-repo", + external_id="12345", + ) + ], + automated_run_stopping_point=AutofixStoppingPoint.OPEN_PR.value, + ) + } + + response = self.client.get(self.url) + + assert response.status_code == 200 + mock_bulk_read_db.assert_called_once() + mock_bulk_get_preferences.assert_not_called() + assert response.data == [ + { + "projectId": project.id, + "autofixAutomationTuning": AutofixAutomationTuningSettings.MEDIUM.value, + "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, + "automationHandoff": None, + "reposCount": 1, + } + ] + @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" ) @@ -911,18 +959,22 @@ def test_post_rejects_mismatched_repo_name_or_owner(self, mock_bulk_set_preferen assert response.data["detail"] == "Invalid repository" mock_bulk_set_preferences.assert_not_called() + @with_feature("organizations:seer-project-settings-dual-write") + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_write_preferences_to_sentry_db" + ) @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" ) @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" ) - def test_post_creates_seer_project_repository( - self, mock_bulk_get_preferences, mock_bulk_set_preferences + def test_post_writes_to_sentry_db( + self, mock_bulk_get_preferences, mock_bulk_set_preferences, mock_bulk_write_db ): - """Test that POST creates SeerProjectRepository when feature flag is enabled.""" + """When feature flag enabled, writes to Sentry DB instead of Seer API.""" project = self.create_project(organization=self.organization) - repo = Repository.objects.create( + Repository.objects.create( organization_id=self.organization.id, name="test-org/test-repo", provider="github", @@ -939,23 +991,25 @@ def test_post_creates_seer_project_repository( "organizationId": self.organization.id, } - with self.feature("organizations:seer-project-settings-dual-write"): - response = self.client.post( - self.url, - { - "projectIds": [project.id], - "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, - "projectRepoMappings": { - str(project.id): [repo_data], - }, + response = self.client.post( + self.url, + { + "projectIds": [project.id], + "automatedRunStoppingPoint": AutofixStoppingPoint.OPEN_PR.value, + "projectRepoMappings": { + str(project.id): [repo_data], }, - ) + }, + ) assert response.status_code == 204 - seer_repo = SeerProjectRepository.objects.get(project=project) - assert seer_repo.repository_id == repo.id - assert project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + mock_bulk_write_db.assert_called_once() + preferences = mock_bulk_write_db.call_args[0][1] + assert len(preferences) == 1 + assert preferences[0].project_id == project.id + assert preferences[0].repositories[0].owner == "test-org" + assert preferences[0].repositories[0].name == "test-repo" @patch( "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" From aaf382f23ff59ef70e9474c0c6b8809bd7dec6d3 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 16:01:45 -0700 Subject: [PATCH 16/30] fixes --- src/sentry/seer/autofix/coding_agent.py | 8 ++++---- src/sentry/seer/autofix/utils.py | 5 ++++- .../endpoints/organization_autofix_automation_settings.py | 5 +++-- src/sentry/tasks/seer/autofix.py | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index ef981afc47e0..744a1d0f521c 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -235,6 +235,10 @@ def _launch_agents_for_repos( if features.has("organizations:seer-project-settings-read-from-sentry", organization): 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: + auto_create_pr = preference.automation_handoff.auto_create_pr except Project.DoesNotExist: logger.exception( "coding_agent.project_not_found", @@ -244,10 +248,6 @@ def _launch_agents_for_repos( "project_id": autofix_state.request.project_id, }, ) - - preference = read_preference_from_sentry_db(project) - if preference and preference.automation_handoff: - auto_create_pr = preference.automation_handoff.auto_create_pr else: try: preference_response = get_project_seer_preferences(autofix_state.request.project_id) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 68f156e9ce11..917154ebed15 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -828,6 +828,7 @@ def has_project_connected_repos( if cached_value is not None: return cached_value + 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) @@ -853,7 +854,9 @@ def has_project_connected_repos( return has_repos -def bulk_get_project_preferences(organization_id: int, project_ids: list[int]) -> dict[str, dict]: +def bulk_get_project_preferences( + organization_id: int, project_ids: list[int] +) -> dict[str, dict | None]: """Bulk fetch Seer project preferences. Returns dict mapping project ID (string) to preference dict.""" viewer_context = SeerViewerContext(organization_id=organization_id) response = make_bulk_get_project_preferences_request( diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index 6f42eb6f6104..fc7f264f1beb 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -188,7 +188,8 @@ def _serialize_projects_with_settings( if features.has("organizations:seer-project-settings-read-from-sentry", organization): preferences = bulk_read_preferences_from_sentry_db(organization.id, project_ids_list) preferences_map = { - str(project_id): preference.dict() for project_id, preference in preferences.items() + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() } else: preferences_map = bulk_get_project_preferences(organization.id, project_ids_list) or {} @@ -308,7 +309,7 @@ def post(self, request: Request, organization: Organization) -> Response: organization.id, list(projects_by_id.keys()) ) existing_preferences_map = { - str(project_id): preference.dict() + str(project_id): preference.dict() if preference else None for project_id, preference in existing_preferences.items() } else: diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 749e93ae6675..273117335669 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -250,7 +250,8 @@ def configure_seer_for_existing_org(organization_id: int) -> None: if features.has("organizations:seer-project-settings-read-from-sentry", organization): preferences = bulk_read_preferences_from_sentry_db(organization_id, project_ids) preferences_by_id = { - str(project_id): preference.dict() for project_id, preference in preferences.items() + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() } else: preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) From 7aef4d3ef8bd40ee7720001e0b11d3ecf4f49ec1 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 16:10:26 -0700 Subject: [PATCH 17/30] cover trigger_coding_agent_launch --- src/sentry/seer/endpoints/seer_rpc.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index ec983725e077..1a3b026e7772 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -83,6 +83,7 @@ from sentry.seer.autofix.utils import ( AutofixTriggerSource, get_project_seer_preferences, + read_preference_from_sentry_db, resolve_repository_ids, write_preference_to_sentry_db, ) @@ -613,17 +614,23 @@ def trigger_coding_agent_launch( }, ) try: - project = Project.objects.get_from_cache(id=project_id) organization = Organization.objects.get_from_cache(id=organization_id) if features.has("organizations:seer-project-settings-dual-write", organization): - preference_response = get_project_seer_preferences(project.id) - if preference_response and preference_response.preference: - updated_preference = preference_response.preference.copy( - update={"automation_handoff": None} - ) - validated_pref = SeerProjectPreference.validate(updated_preference) - resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) - write_preference_to_sentry_db(project, resolved_pref[0]) + project = Project.objects.get_from_cache(id=project_id) + + 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 + + if preference: + updated_preference = preference.copy(update={"automation_handoff": None}) + resolved_pref = resolve_repository_ids( + organization.id, [SeerProjectPreference.validate(updated_preference)] + )[0] + write_preference_to_sentry_db(project, resolved_pref) except Exception: logger.exception( "coding_agent.clear_handoff_preference_failed", From fc797cc74846f05644a880238eb5069686b18944 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Tue, 7 Apr 2026 16:11:57 -0700 Subject: [PATCH 18/30] renaming --- src/sentry/seer/endpoints/seer_rpc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 1a3b026e7772..c5f491a8e35a 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -627,10 +627,10 @@ def trigger_coding_agent_launch( if preference: updated_preference = preference.copy(update={"automation_handoff": None}) - resolved_pref = resolve_repository_ids( + resolved_preference = resolve_repository_ids( organization.id, [SeerProjectPreference.validate(updated_preference)] )[0] - write_preference_to_sentry_db(project, resolved_pref) + write_preference_to_sentry_db(project, resolved_preference) except Exception: logger.exception( "coding_agent.clear_handoff_preference_failed", From 5cdd4d7ac88c5785a0661f0efa7302a96c92b141 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 10:30:04 -0700 Subject: [PATCH 19/30] simplify some stuff --- src/sentry/seer/autofix/autofix_agent.py | 12 +++++------- src/sentry/seer/autofix/coding_agent.py | 10 +++------- src/sentry/seer/autofix/on_completion_hook.py | 12 ++++-------- src/sentry/seer/autofix/utils.py | 6 ++---- src/sentry/seer/endpoints/seer_rpc.py | 1 + 5 files changed, 15 insertions(+), 26 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index 92adfc9dae7f..e41d3551ede5 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -447,13 +447,11 @@ def trigger_coding_agent_handoff( auto_create_pr = preference.automation_handoff.auto_create_pr else: try: - preference_response = get_project_seer_preferences(group.project_id) - if preference_response and preference_response.preference: - repo_definitions = list(preference_response.preference.repositories) - if preference_response.preference.automation_handoff: - auto_create_pr = ( - preference_response.preference.automation_handoff.auto_create_pr - ) + 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 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 744a1d0f521c..bd91bde7b2b1 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -235,7 +235,6 @@ def _launch_agents_for_repos( if features.has("organizations:seer-project-settings-read-from-sentry", organization): 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: auto_create_pr = preference.automation_handoff.auto_create_pr @@ -250,12 +249,9 @@ def _launch_agents_for_repos( ) else: try: - preference_response = get_project_seer_preferences(autofix_state.request.project_id) - if preference_response and preference_response.preference: - if preference_response.preference.automation_handoff: - auto_create_pr = ( - preference_response.preference.automation_handoff.auto_create_pr - ) + 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 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 65b3d4f4321a..2bf03063e8e5 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -468,23 +468,19 @@ 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) - if not preference: - return None - handoff_config = preference.automation_handoff else: try: - preference_response = get_project_seer_preferences(group.project_id) + 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_response or not preference_response.preference: - return None - handoff_config = preference_response.preference.automation_handoff - return handoff_config + if not preference: + return None + return preference.automation_handoff @classmethod def _clear_handoff_preference( diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 917154ebed15..f2ee2489beab 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -834,10 +834,8 @@ def has_project_connected_repos( has_repos = bool(preference and preference.repositories) else: try: - project_preferences = get_project_seer_preferences(project.id) - has_repos = bool( - project_preferences.preference and project_preferences.preference.repositories - ) + preference = get_project_seer_preferences(project.id).preference + has_repos = bool(preference and preference.repositories) except (SeerApiError, SeerApiResponseValidationError): pass diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index c5f491a8e35a..76726f549e8a 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -631,6 +631,7 @@ def trigger_coding_agent_launch( organization.id, [SeerProjectPreference.validate(updated_preference)] )[0] write_preference_to_sentry_db(project, resolved_preference) + # Returning the error code will prompt Seer to clear the preference handoff in its own DB too. except Exception: logger.exception( "coding_agent.clear_handoff_preference_failed", From bdb89e2713d69737555428fd8b6048747956a779 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 10:46:10 -0700 Subject: [PATCH 20/30] fix defualt --- src/sentry/seer/autofix/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 01d8314a39fb..92f1462f3102 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -723,7 +723,9 @@ def _build_automation_handoff( def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: - """Read a single project's Seer preferences from Sentry DB.""" + """Read a single project's Seer preferences from Sentry DB. + + Should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" seer_project_repo_qs = ( SeerProjectRepository.objects.filter(project=project) .select_related("repository") @@ -752,7 +754,9 @@ 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]: - """Bulk read Seer preferences from Sentry DB.""" + """Bulk read Seer preferences from Sentry DB. + + Should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" if not project_ids: return {} @@ -788,7 +792,7 @@ def bulk_read_preferences_from_sentry_db( def get_project_option(key: str) -> Any: value = project_options[key][project.id] if value is None: - return projectoptions.lookup_well_known_key(key).get_default(project) + return projectoptions.get_well_known_default(key, project=project) return value result[project.id] = SeerProjectPreference( From a0b46d0d48872c1521ca1929cd0535be2bede75c Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 10:50:24 -0700 Subject: [PATCH 21/30] cleaning up --- src/sentry/seer/autofix/utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 92f1462f3102..41aeb3851e19 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -725,7 +725,7 @@ def _build_automation_handoff( def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None: """Read a single project's Seer preferences from Sentry DB. - Should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" + For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" seer_project_repo_qs = ( SeerProjectRepository.objects.filter(project=project) .select_related("repository") @@ -756,7 +756,7 @@ def bulk_read_preferences_from_sentry_db( ) -> dict[int, SeerProjectPreference | None]: """Bulk read Seer preferences from Sentry DB. - Should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" + For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" if not project_ids: return {} @@ -789,7 +789,7 @@ def bulk_read_preferences_from_sentry_db( result[project.id] = None continue - def get_project_option(key: str) -> Any: + def _get_project_option(key: str) -> Any: value = project_options[key][project.id] if value is None: return projectoptions.get_well_known_default(key, project=project) @@ -799,10 +799,10 @@ def get_project_option(key: str) -> Any: organization_id=project.organization_id, project_id=project.id, repositories=repo_definitions_by_project.get(project.id, []), - automated_run_stopping_point=get_project_option( + automated_run_stopping_point=_get_project_option( "sentry:seer_automated_run_stopping_point" ), - automation_handoff=_build_automation_handoff(get_project_option), + automation_handoff=_build_automation_handoff(_get_project_option), ) return result From cdcff9ef83e3bd54ab68dc55b56ac648a0f1ca70 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 11:09:56 -0700 Subject: [PATCH 22/30] raise valueerror on malformed repo name explicitly --- src/sentry/seer/autofix/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 41aeb3851e19..364cdccd362c 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -681,6 +681,8 @@ def build_repo_definition_from_project_repo( """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository.""" repo = seer_project_repo.repository repo_name_sections = repo.name.split("/") + if len(repo_name_sections) < 2: + raise ValueError(f"Invalid repository name format: {repo.name}") return SeerRepoDefinition( repository_id=repo.id, From 46f2d143a917e68421fab4922575338587a42572 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 11:24:37 -0700 Subject: [PATCH 23/30] capture sentry exception --- src/sentry/seer/autofix/utils.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 364cdccd362c..15ac294d7ab9 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -7,6 +7,7 @@ import orjson import pydantic +import sentry_sdk from django.conf import settings from django.db import router, transaction from pydantic import BaseModel @@ -677,12 +678,15 @@ def bulk_write_preferences_to_sentry_db( def build_repo_definition_from_project_repo( seer_project_repo: SeerProjectRepository, -) -> SeerRepoDefinition: - """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository.""" +) -> SeerRepoDefinition | None: + """Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository. + + Returns None if Repository name is invalid.""" repo = seer_project_repo.repository repo_name_sections = repo.name.split("/") if len(repo_name_sections) < 2: - raise ValueError(f"Invalid repository name format: {repo.name}") + sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}")) + return None return SeerRepoDefinition( repository_id=repo.id, @@ -734,8 +738,9 @@ def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | .prefetch_related("branch_overrides") ) repo_definitions = [ - build_repo_definition_from_project_repo(project_repo) + repo_def for project_repo in seer_project_repo_qs + if (repo_def := build_repo_definition_from_project_repo(project_repo)) is not None ] has_configured_options = any( @@ -770,9 +775,9 @@ def bulk_read_preferences_from_sentry_db( .select_related("repository") .prefetch_related("branch_overrides") ): - repo_definitions_by_project[project_repo.project_id].append( - build_repo_definition_from_project_repo(project_repo) - ) + repo_def = build_repo_definition_from_project_repo(project_repo) + if repo_def is not None: + repo_definitions_by_project[project_repo.project_id].append(repo_def) # get_value_bulk_id returns None for missing options, unlike project.get_option # which automatically falls back to the registered well-known key default. From 4b6c36a9426e3e985f0939535a8557a747cb208f Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 11:26:10 -0700 Subject: [PATCH 24/30] add test --- .../sentry/seer/autofix/test_autofix_utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index b039c2058ca4..8cdf3bb573b5 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -1368,6 +1368,25 @@ def test_partial_handoff_returns_none_handoff(self): assert result is not None assert result.automation_handoff is None + def test_invalid_repo_name_is_skipped(self): + bad_repo = self.create_repo( + project=self.project, + provider="integrations:github", + external_id="ext_bad", + name="no-slash-repo", + ) + SeerProjectRepository.objects.create( + project=self.project, repository=bad_repo, branch_name="main" + ) + 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 len(result.repositories) == 1 + assert result.repositories[0].name == "test-repo" + class TestBulkReadPreferencesFromSentryDb(TestCase): def setUp(self): From e890305d8a630251f3eb1d98634f89646a677337 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 12:02:40 -0700 Subject: [PATCH 25/30] fix tests --- src/sentry/seer/autofix/on_completion_hook.py | 15 +++++++-------- tests/sentry/seer/autofix/test_autofix_agent.py | 4 ++-- .../autofix/test_autofix_on_completion_hook.py | 4 ++-- .../seer/autofix/test_on_completion_hook.py | 4 ++-- .../endpoints/test_project_seer_preferences.py | 1 + 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 81fd87f01b97..4a9efa929526 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -511,18 +511,17 @@ def _clear_handoff_preference( ) -> None: """Clear automation_handoff from project preferences after integration is not found.""" try: - preference_response = get_project_seer_preferences(project.id) - if preference_response and preference_response.preference: - updated_preference = preference_response.preference.copy( - update={"automation_handoff": None} - ) + preference = get_project_seer_preferences(project.id).preference + if preference: + updated_preference = preference.copy(update={"automation_handoff": None}) set_project_seer_preference(updated_preference) if features.has("organizations:seer-project-settings-dual-write", organization): try: - validated_pref = SeerProjectPreference.validate(updated_preference) - resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) - write_preference_to_sentry_db(project, resolved_pref[0]) + resolved_preference = resolve_repository_ids( + organization.id, [SeerProjectPreference.validate(updated_preference)] + )[0] + write_preference_to_sentry_db(project, resolved_preference) except Exception: logger.exception( "seer.write_preferences.failed", diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 59427ca89cea..745c8e0d742c 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest from rest_framework.exceptions import PermissionDenied @@ -625,7 +625,7 @@ def test_trigger_coding_agent_handoff_no_preferences_returns_failure( """Test handoff with None preference response returns failure.""" mock_client = MagicMock() mock_client_class.return_value = mock_client - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) result = trigger_coding_agent_handoff( group=self.group, diff --git a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py index 7573d2b4d9a4..28423a09e484 100644 --- a/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_autofix_on_completion_hook.py @@ -1,5 +1,5 @@ from typing import TypedDict -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from sentry.seer.autofix.autofix_agent import AutofixStep from sentry.seer.autofix.constants import AutofixReferrer @@ -275,7 +275,7 @@ def test_maybe_continue_pipeline_at_stopping_point(self, mock_trigger): def test_maybe_continue_pipeline_continues_to_next_step(self, mock_trigger, mock_get_prefs): """Continues to next step when not at stopping point.""" # No handoff configured - should continue with normal pipeline - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) state = run_state( blocks=[root_cause_memory_block()], diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 7fc51b0eb87c..70dbcf2b8f79 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from sentry.seer.autofix.coding_agent import IntegrationNotFound from sentry.seer.autofix.utils import CodingAgentProviderType @@ -59,7 +59,7 @@ def test_not_found_no_preference_response_does_not_call_set( from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook mock_trigger.side_effect = IntegrationNotFound("Integration not found") - mock_get_prefs.return_value = None + mock_get_prefs.return_value = Mock(preference=None) AutofixOnCompletionHook._trigger_coding_agent_handoff( organization=self.organization, diff --git a/tests/sentry/seer/endpoints/test_project_seer_preferences.py b/tests/sentry/seer/endpoints/test_project_seer_preferences.py index a3503817b436..50dd05a3b580 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_preferences.py +++ b/tests/sentry/seer/endpoints/test_project_seer_preferences.py @@ -684,6 +684,7 @@ def test_post_rejects_mismatched_repo_name_or_owner(self, mock_request: MagicMoc assert response.data["detail"] == "Invalid repository" mock_request.assert_not_called() + @patch("sentry.seer.endpoints.project_seer_preferences.make_set_project_preference_request") def test_post_rejects_unsupported_repo_provider(self, mock_request: MagicMock) -> None: request_data = { "repositories": [ From da22697399f568218b3662b6528964f8ba4e7d84 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 13:08:06 -0700 Subject: [PATCH 26/30] cover _clear_handoff_preference --- src/sentry/seer/autofix/autofix_agent.py | 4 +- src/sentry/seer/autofix/on_completion_hook.py | 47 ++++++++++++------- .../seer/autofix/test_on_completion_hook.py | 42 +++++++++++++++-- 3 files changed, 70 insertions(+), 23 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index a2a0aea385b6..a6ac4426b485 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -509,8 +509,8 @@ def trigger_coding_agent_handoff( preference = read_preference_from_sentry_db(group.project) if preference: repo_definitions = preference.repositories - if preference and preference.automation_handoff: - auto_create_pr = preference.automation_handoff.auto_create_pr + if preference.automation_handoff: + auto_create_pr = preference.automation_handoff.auto_create_pr else: try: preference = get_project_seer_preferences(group.project_id).preference diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 4a9efa929526..96c2c60ad2fb 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -510,28 +510,43 @@ def _clear_handoff_preference( cls, project: Project, run_id: int, organization: Organization ) -> None: """Clear automation_handoff from project preferences after integration is not found.""" - try: - preference = get_project_seer_preferences(project.id).preference - if preference: - updated_preference = preference.copy(update={"automation_handoff": None}) - set_project_seer_preference(updated_preference) + 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( + "autofix.on_completion_hook.clear_handoff_preference_failed", + extra={"run_id": run_id, "organization_id": organization.id}, + ) + return - if features.has("organizations:seer-project-settings-dual-write", organization): - try: - resolved_preference = resolve_repository_ids( - organization.id, [SeerProjectPreference.validate(updated_preference)] - )[0] - write_preference_to_sentry_db(project, resolved_preference) - except Exception: - logger.exception( - "seer.write_preferences.failed", - extra={"project_id": project.id, "organization_id": organization.id}, - ) + if not preference: + return + + updated_preference = preference.copy(update={"automation_handoff": None}) + + try: + set_project_seer_preference(updated_preference) except (SeerApiError, SeerApiResponseValidationError): logger.exception( "autofix.on_completion_hook.clear_handoff_preference_failed", extra={"run_id": run_id, "organization_id": organization.id}, ) + return + + if features.has("organizations:seer-project-settings-dual-write", organization): + try: + resolved_preference = resolve_repository_ids( + organization.id, [SeerProjectPreference.validate(updated_preference)] + )[0] + write_preference_to_sentry_db(project, resolved_preference) + except Exception: + logger.exception( + "seer.write_preferences.failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) @classmethod def _trigger_coding_agent_handoff( diff --git a/tests/sentry/seer/autofix/test_on_completion_hook.py b/tests/sentry/seer/autofix/test_on_completion_hook.py index 70dbcf2b8f79..68cb3d8d586f 100644 --- a/tests/sentry/seer/autofix/test_on_completion_hook.py +++ b/tests/sentry/seer/autofix/test_on_completion_hook.py @@ -3,9 +3,14 @@ from unittest.mock import MagicMock, Mock, patch from sentry.seer.autofix.coding_agent import IntegrationNotFound +from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook from sentry.seer.autofix.utils import CodingAgentProviderType -from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration +from sentry.seer.models.seer_api_models import ( + SeerAutomationHandoffConfiguration, + SeerProjectPreference, +) from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature class TestTriggerCodingAgentHandoff(TestCase): @@ -28,8 +33,6 @@ def _make_handoff_config(self, integration_id: int = 789) -> SeerAutomationHando def test_not_found_clears_automation_handoff( self, mock_trigger, mock_get_prefs, mock_set_pref ) -> None: - from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook - mock_trigger.side_effect = IntegrationNotFound("Integration not found") mock_pref = MagicMock() @@ -56,8 +59,6 @@ def test_not_found_clears_automation_handoff( def test_not_found_no_preference_response_does_not_call_set( self, mock_trigger, mock_get_prefs, mock_set_pref ) -> None: - from sentry.seer.autofix.on_completion_hook import AutofixOnCompletionHook - mock_trigger.side_effect = IntegrationNotFound("Integration not found") mock_get_prefs.return_value = Mock(preference=None) @@ -69,3 +70,34 @@ def test_not_found_no_preference_response_does_not_call_set( ) mock_set_pref.assert_not_called() + + @with_feature("organizations:seer-project-settings-read-from-sentry") + @patch("sentry.seer.autofix.on_completion_hook.set_project_seer_preference") + @patch("sentry.seer.autofix.on_completion_hook.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.on_completion_hook.get_project_seer_preferences") + @patch("sentry.seer.autofix.on_completion_hook.trigger_coding_agent_handoff") + def test_not_found_reads_from_sentry_db( + self, mock_trigger, mock_get_prefs, mock_read_db, mock_set_pref + ) -> None: + """When feature flag enabled, reads preferences from Sentry DB to clear handoff.""" + + mock_trigger.side_effect = IntegrationNotFound("Integration not found") + mock_read_db.return_value = SeerProjectPreference( + organization_id=self.organization.id, + project_id=self.project.id, + repositories=[], + automation_handoff=self._make_handoff_config(), + ) + + AutofixOnCompletionHook._trigger_coding_agent_handoff( + organization=self.organization, + run_id=1, + group=self.group, + handoff_config=self._make_handoff_config(), + ) + + mock_get_prefs.assert_not_called() + mock_read_db.assert_called_once_with(self.project) + mock_set_pref.assert_called_once() + updated_pref = mock_set_pref.call_args[0][0] + assert updated_pref.automation_handoff is None From 000957509475cb6d927ab57bc419b25b9455df39 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 13:25:55 -0700 Subject: [PATCH 27/30] cover _resolve_project_preference --- src/sentry/seer/autofix/autofix.py | 49 +++++++++++++++-------- tests/sentry/seer/autofix/test_autofix.py | 38 +++++++++++++++--- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 076e6583124d..7c3b34223cf7 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -43,11 +43,12 @@ get_project_seer_preferences, make_autofix_start_request, make_autofix_update_request, + read_preference_from_sentry_db, set_project_seer_preference, write_preference_to_sentry_db, ) from sentry.seer.explorer.utils import _convert_profile_to_execution_tree, fetch_profile_data -from sentry.seer.models import SeerProjectPreference +from sentry.seer.models import SeerApiError, SeerApiResponseValidationError, SeerProjectPreference from sentry.seer.signed_seer_api import SeerViewerContext from sentry.services import eventstore from sentry.services.eventstore.models import Event, GroupEvent @@ -700,16 +701,27 @@ def get_all_tags_overview( def _resolve_project_preference( organization: Organization, project: Project, fallback_repos: list[dict] -) -> SeerProjectPreference: +) -> SeerProjectPreference | None: """ 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. """ - preference_response = get_project_seer_preferences(project.id) - if preference_response.preference: - return preference_response.preference + 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 + + if preference: + return preference default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) preference = SeerProjectPreference( @@ -720,7 +732,14 @@ def _resolve_project_preference( automation_handoff=default_handoff, ) - set_project_seer_preference(preference) + try: + set_project_seer_preference(preference) + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "seer.resolve_project_preference.set_failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + return None if features.has("organizations:seer-project-settings-dual-write", organization): try: @@ -781,22 +800,18 @@ def trigger_autofix( return _respond_with_error("Cannot fix issues without an event.", 400) code_mappings = get_sorted_code_mapping_configs(group.project) - repos = get_autofix_repos_from_project_code_mappings(group.project, code_mappings=code_mappings) + 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: SeerProjectPreference | None = None - try: - preference = _resolve_project_preference(group.organization, group.project, repos) + preference = _resolve_project_preference(group.organization, group.project, code_mappings_repos) + if preference: repos = [repo.dict() for repo in preference.repositories] - except Exception: - logger.exception( - "seer.write_preferences.resolve_project_preference.failed", - extra={"project_id": group.project.id, "organization_id": group.organization.id}, - exc_info=True, - ) + else: + repos = code_mappings_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 530b26f235e4..cf42058aeb85 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1176,25 +1176,25 @@ def test_creates_preference_with_empty_repos_when_no_fallback( @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_raises_on_get_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): + 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) - with pytest.raises(SeerApiError): - _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + assert result is None 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_raises_on_set_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): + def test_returns_none_on_set_api_error(self, mock_get_prefs, mock_set_pref, mock_write_sentry): mock_get_prefs.return_value = SeerRawPreferenceResponse(preference=None) mock_set_pref.side_effect = SeerApiError("test error", 500) - with pytest.raises(SeerApiError): - _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + result = _resolve_project_preference(self.organization, self.project, [self._mock_repo()]) + assert result is None mock_write_sentry.assert_not_called() @patch("sentry.seer.autofix.autofix.write_preference_to_sentry_db") @@ -1237,6 +1237,32 @@ def test_returns_preference_with_empty_repos( 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") + @patch("sentry.seer.autofix.autofix.read_preference_from_sentry_db") + @patch("sentry.seer.autofix.autofix.get_project_seer_preferences") + 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, + automated_run_stopping_point="root_cause", + ) + + result = _resolve_project_preference( + self.organization, self.project, [self._mock_repo("sentry", "123")] + ) + + assert result is not None + assert result.repositories[0].name == "seer" + mock_get_prefs.assert_not_called() + mock_set_pref.assert_not_called() + class TestCallAutofix(TestCase): @patch("sentry.seer.autofix.autofix._get_github_username_for_user") From d7c08905414e9d9cf1c92edb65781bdbf0960db5 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 13:32:14 -0700 Subject: [PATCH 28/30] cover index_repos --- src/sentry/seer/autofix/autofix.py | 1 + src/sentry/tasks/seer/context_engine_index.py | 12 ++++- .../tasks/seer/test_context_engine_index.py | 46 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 7c3b34223cf7..ed45b9ddb806 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -807,6 +807,7 @@ def trigger_autofix( # 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) if preference: repos = [repo.dict() for repo in preference.repositories] diff --git a/src/sentry/tasks/seer/context_engine_index.py b/src/sentry/tasks/seer/context_engine_index.py index 15a9da37e49f..82745e3bc0b8 100644 --- a/src/sentry/tasks/seer/context_engine_index.py +++ b/src/sentry/tasks/seer/context_engine_index.py @@ -16,6 +16,7 @@ from sentry.search.events.types import SnubaParams from sentry.seer.autofix.utils import ( bulk_get_project_preferences, + bulk_read_preferences_from_sentry_db, get_autofix_repos_from_project_code_mappings, ) from sentry.seer.explorer.context_engine_utils import ( @@ -257,7 +258,16 @@ def index_repos(organization_id: int, *args, **kwargs) -> None: org_repo_definitions: dict[tuple[str, str, str], RepoDetails] = {} - preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) + if features.has("organizations:seer-project-settings-read-from-sentry", organization): + preferences = bulk_read_preferences_from_sentry_db( + organization_id, list(project_map.keys()) + ) + preferences_by_id = { + str(project_id): preference.dict() if preference else None + for project_id, preference in preferences.items() + } + else: + preferences_by_id = bulk_get_project_preferences(organization_id, list(project_map.keys())) for project_id, project in project_map.items(): existing_pref = preferences_by_id.get(str(project_id)) diff --git a/tests/sentry/tasks/seer/test_context_engine_index.py b/tests/sentry/tasks/seer/test_context_engine_index.py index f4ed9795f566..7b920e8d11fb 100644 --- a/tests/sentry/tasks/seer/test_context_engine_index.py +++ b/tests/sentry/tasks/seer/test_context_engine_index.py @@ -3,6 +3,7 @@ import pytest from sentry.seer.explorer.context_engine_utils import ProjectEventCounts +from sentry.seer.models import SeerProjectPreference, SeerRepoDefinition from sentry.tasks.seer.context_engine_index import ( get_allowed_org_ids_context_engine_indexing, index_org_project_knowledge, @@ -469,6 +470,51 @@ def test_skips_projects_without_seer_preferences( assert repos[0]["name"] == "sentry" assert repos[0]["project_ids"] == [self.project1.id] + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_read_preferences_from_sentry_db") + @mock.patch("sentry.tasks.seer.context_engine_index.bulk_get_project_preferences") + @mock.patch("sentry.tasks.seer.context_engine_index.make_org_repo_knowledge_index_request") + def test_reads_from_sentry_db( + self, + mock_make_org_repo_knowledge_index_request, + mock_bulk_get_preferences, + mock_bulk_read_db, + ) -> None: + """When feature flag enabled, reads preferences from Sentry DB instead of Seer API.""" + mock_make_org_repo_knowledge_index_request.return_value.status = 200 + mock_bulk_read_db.return_value = { + self.project1.id: SeerProjectPreference( + organization_id=self.org.id, + project_id=self.project1.id, + repositories=[ + SeerRepoDefinition( + provider="integrations:github", + owner="getsentry", + name="sentry", + external_id="123", + integration_id=str(self.integration.id), + ) + ], + ), + } + + with override_options({"explorer.context_engine_indexing.enable": True}): + with self.feature( + { + "organizations:context-engine-experiments": True, + "organizations:seer-project-settings-read-from-sentry": True, + } + ): + index_repos(self.org.id) + + mock_bulk_get_preferences.assert_not_called() + mock_bulk_read_db.assert_called_once() + mock_make_org_repo_knowledge_index_request.assert_called_once() + body = mock_make_org_repo_knowledge_index_request.call_args[0][0] + repos = body["repos"] + assert len(repos) == 1 + assert repos[0]["name"] == "sentry" + assert repos[0]["owner"] == "getsentry" + @django_db_all class TestScheduleContextEngineIndexingTasks(TestCase): From e2d9ecbf2c7ca1866395b4daa2a17c8ac6e7dab4 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Fri, 10 Apr 2026 10:19:22 -0700 Subject: [PATCH 29/30] add null/empty dict check to write fn --- src/sentry/seer/autofix/utils.py | 37 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 7dcbf1c935bc..3afa654a8863 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -626,24 +626,27 @@ def _write_preferences_to_sentry_db( repo_def.branch_overrides ) - # Create project repos. - created_project_repos = SeerProjectRepository.objects.bulk_create(project_repos_to_create) - - # Create branch overrides using the created project repos. - overrides_to_create: list[SeerProjectRepositoryBranchOverride] = [] - for seer_project_repo in created_project_repos: - for override in overrides_by_key.get( - (seer_project_repo.project_id, seer_project_repo.repository_id), [] - ): - overrides_to_create.append( - SeerProjectRepositoryBranchOverride( - seer_project_repository=seer_project_repo, - tag_name=override.tag_name, - tag_value=override.tag_value, - branch_name=override.branch_name, + if project_repos_to_create: + # Create project repos. + created_project_repos = SeerProjectRepository.objects.bulk_create( + project_repos_to_create + ) + + # Create branch overrides using the created project repos. + overrides_to_create: list[SeerProjectRepositoryBranchOverride] = [] + for seer_project_repo in created_project_repos: + for override in overrides_by_key.get( + (seer_project_repo.project_id, seer_project_repo.repository_id), [] + ): + overrides_to_create.append( + SeerProjectRepositoryBranchOverride( + seer_project_repository=seer_project_repo, + tag_name=override.tag_name, + tag_value=override.tag_value, + branch_name=override.branch_name, + ) ) - ) - SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) + SeerProjectRepositoryBranchOverride.objects.bulk_create(overrides_to_create) # Write ProjectOptions last so cache updates only happen after all DB writes succeed # (cache cannot be rolled back by the transaction). From 520655a4eb58230dffe3183a922c8964c89ca053 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Fri, 10 Apr 2026 10:47:36 -0700 Subject: [PATCH 30/30] early returns for none handoff --- src/sentry/seer/autofix/on_completion_hook.py | 2 +- src/sentry/seer/endpoints/seer_rpc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 96c2c60ad2fb..7ec8a3254943 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -522,7 +522,7 @@ def _clear_handoff_preference( ) return - if not preference: + if not preference or preference.automation_handoff is None: return updated_preference = preference.copy(update={"automation_handoff": None}) diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index 75505aab4639..2ef0fde5f3b5 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -627,7 +627,7 @@ def trigger_coding_agent_launch( else: preference = get_project_seer_preferences(project.id).preference - if preference: + if preference and preference.automation_handoff is not None: updated_preference = preference.copy(update={"automation_handoff": None}) resolved_preference = resolve_repository_ids( organization.id, [SeerProjectPreference.validate(updated_preference)]