From db1575cdd2fffe0228b19a81b93b0e5ade6f2d75 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Wed, 25 Mar 2026 16:36:56 -0700 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 bdb89e2713d69737555428fd8b6048747956a779 Mon Sep 17 00:00:00 2001 From: srest2021 Date: Thu, 9 Apr 2026 10:46:10 -0700 Subject: [PATCH 12/16] 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 13/16] 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 14/16] 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 15/16] 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 16/16] 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):