Skip to content

Commit 3587dee

Browse files
srest2021claudecursoragent
authored andcommitted
feat(seer): Add dual-read helpers for Seer project preferences from Sentry DB (#111591)
relates to AIML-2611, AIML-2610 Add `read_preference_from_sentry_db()` and `bulk_read_preferences_from_sentry_db()` helpers to read Seer project preferences directly from Sentry DB instead of Seer API. This is the foundation for cutting over reads behind the `organizations:seer-project-settings-read-from-sentry` feature flag. We don't actually use the helpers in this PR. WIP followup PR: #111594 ### Extra We return None if a project is unconfigured. Matching Seer's behavior ([single](https://github.com/getsentry/seer/blob/3cec03cac3abd9f1f26ac42296d6c870669c66f0/src/seer/automation/preferences.py#L54), [bulk](https://github.com/getsentry/seer/blob/3cec03cac3abd9f1f26ac42296d6c870669c66f0/src/seer/automation/preferences.py#L98)), "unconfigured" means we don't have any SeerProjectRepository rows _and_ none of the project options are set. We use `project.get_option` and `ProjectOptions.objects.isset` for single reads, and `ProjectOption.objects.get_value_bulk_id` for bulk read. Why: 1. `project.get_option` and `ProjectOptions.objects.isset` use cache. For a single project we want to check the cache first. 2. `ProjectOption.objects.get_value_bulk_id` is one DB query. For our bulk use cases (`OrganizationAutofixAutomationSettingsEndpoint` get/post, `configure_seer_for_existing_org`) where we may be querying many projects for a single org, we do max 5 option queries, one for each project option. It also returns None for missing options so we can easily tell which projects have configured options and which don't. --------- Co-authored-by: Claude Sonnet 4 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 0a7965c commit 3587dee

File tree

4 files changed

+398
-6
lines changed

4 files changed

+398
-6
lines changed

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
382382
manager.add("organizations:data-browsing-widget-unfurl", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
383383
# Enable dual-write of Seer project preferences to Sentry DB and Seer API
384384
manager.add("organizations:seer-project-settings-dual-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
385+
# Enable reading of Seer project preferences from Sentry DB instead of Seer API
386+
manager.add("organizations:seer-project-settings-read-from-sentry", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
385387
# Enable public RPC endpoint for local seer development
386388
manager.add("organizations:seer-public-rpc", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
387389
# Organizations on the old usage-based (v0) Seer plan

src/sentry/projectoptions/defaults.py

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

199+
SEER_PROJECT_PREFERENCE_OPTION_KEYS = [
200+
"sentry:seer_automated_run_stopping_point",
201+
"sentry:seer_automation_handoff_point",
202+
"sentry:seer_automation_handoff_target",
203+
"sentry:seer_automation_handoff_integration_id",
204+
"sentry:seer_automation_handoff_auto_create_pr",
205+
]
206+
199207
# Boolean to enable/disable preprod size analysis for this project.
200208
register(key="sentry:preprod_size_enabled_by_customer", default=True)
201209

src/sentry/seer/autofix/utils.py

Lines changed: 147 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import logging
2-
from collections.abc import Iterable
2+
from collections import defaultdict
3+
from collections.abc import Callable, Iterable, Mapping
34
from datetime import UTC, datetime
45
from enum import StrEnum
56
from typing import Any, NotRequired, TypedDict
67

78
import orjson
89
import pydantic
10+
import sentry_sdk
911
from django.conf import settings
1012
from django.db import router, transaction
1113
from pydantic import BaseModel
1214
from rest_framework import serializers
1315
from urllib3 import BaseHTTPResponse, HTTPConnectionPool
1416
from urllib3.util.retry import Retry
1517

16-
from sentry import features, options, ratelimits
18+
from sentry import features, options, projectoptions, ratelimits
1719
from sentry.constants import (
1820
AUTO_OPEN_PRS_DEFAULT,
1921
SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT,
@@ -25,16 +27,20 @@
2527
get_sorted_code_mapping_configs,
2628
)
2729
from sentry.models.group import Group
30+
from sentry.models.options.project_option import ProjectOption
2831
from sentry.models.organization import Organization
2932
from sentry.models.project import Project
3033
from sentry.models.repository import Repository
3134
from sentry.net.http import connection_from_url
35+
from sentry.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS
3236
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus
3337
from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS
3438
from sentry.seer.models import (
39+
AutofixHandoffPoint,
3540
BranchOverride,
3641
SeerApiError,
3742
SeerApiResponseValidationError,
43+
SeerAutomationHandoffConfiguration,
3844
SeerPermissionError,
3945
SeerProjectPreference,
4046
SeerRawPreferenceResponse,
@@ -44,10 +50,6 @@
4450
SeerProjectRepository,
4551
SeerProjectRepositoryBranchOverride,
4652
)
47-
from sentry.seer.models.seer_api_models import (
48-
AutofixHandoffPoint,
49-
SeerAutomationHandoffConfiguration,
50-
)
5153
from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request
5254
from sentry.utils.cache import cache
5355
from sentry.utils.outcomes import Outcome, track_outcome
@@ -674,6 +676,145 @@ def bulk_write_preferences_to_sentry_db(
674676
_write_preferences_to_sentry_db(project_preferences)
675677

676678

679+
def build_repo_definition_from_project_repo(
680+
seer_project_repo: SeerProjectRepository,
681+
) -> SeerRepoDefinition | None:
682+
"""Build a SeerRepoDefinition from a SeerProjectRepository with its joined Repository.
683+
684+
Returns None if Repository name is invalid."""
685+
repo = seer_project_repo.repository
686+
repo_name_sections = repo.name.split("/")
687+
if len(repo_name_sections) < 2:
688+
sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}"))
689+
return None
690+
691+
return SeerRepoDefinition(
692+
repository_id=repo.id,
693+
organization_id=repo.organization_id,
694+
integration_id=str(repo.integration_id) if repo.integration_id is not None else None,
695+
provider=repo.provider or "",
696+
owner=repo_name_sections[0],
697+
name="/".join(repo_name_sections[1:]),
698+
external_id=repo.external_id or "",
699+
branch_name=seer_project_repo.branch_name,
700+
instructions=seer_project_repo.instructions,
701+
branch_overrides=[
702+
BranchOverride(
703+
tag_name=bo.tag_name,
704+
tag_value=bo.tag_value,
705+
branch_name=bo.branch_name,
706+
)
707+
for bo in seer_project_repo.branch_overrides.all()
708+
],
709+
)
710+
711+
712+
def _build_automation_handoff(
713+
get_option: Callable[[str], Any],
714+
) -> SeerAutomationHandoffConfiguration | None:
715+
"""Build a SeerAutomationHandoffConfiguration from option values, or None if incomplete."""
716+
handoff_point = get_option("sentry:seer_automation_handoff_point")
717+
handoff_target = get_option("sentry:seer_automation_handoff_target")
718+
handoff_integration_id = get_option("sentry:seer_automation_handoff_integration_id")
719+
720+
if handoff_point is None or handoff_target is None or handoff_integration_id is None:
721+
return None
722+
723+
return SeerAutomationHandoffConfiguration(
724+
handoff_point=handoff_point,
725+
target=handoff_target,
726+
integration_id=handoff_integration_id,
727+
auto_create_pr=get_option("sentry:seer_automation_handoff_auto_create_pr"),
728+
)
729+
730+
731+
def read_preference_from_sentry_db(project: Project) -> SeerProjectPreference | None:
732+
"""Read a single project's Seer preferences from Sentry DB.
733+
734+
For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`."""
735+
seer_project_repo_qs = (
736+
SeerProjectRepository.objects.filter(project=project)
737+
.select_related("repository")
738+
.prefetch_related("branch_overrides")
739+
)
740+
repo_definitions = [
741+
repo_def
742+
for project_repo in seer_project_repo_qs
743+
if (repo_def := build_repo_definition_from_project_repo(project_repo)) is not None
744+
]
745+
746+
has_configured_options = any(
747+
ProjectOption.objects.isset(project, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS
748+
)
749+
if not repo_definitions and not has_configured_options:
750+
return None
751+
752+
return SeerProjectPreference(
753+
organization_id=project.organization_id,
754+
project_id=project.id,
755+
repositories=repo_definitions,
756+
automated_run_stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"),
757+
automation_handoff=_build_automation_handoff(project.get_option),
758+
)
759+
760+
761+
def bulk_read_preferences_from_sentry_db(
762+
organization_id: int, project_ids: list[int]
763+
) -> dict[int, SeerProjectPreference | None]:
764+
"""Bulk read Seer preferences from Sentry DB.
765+
766+
For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`."""
767+
if not project_ids:
768+
return {}
769+
770+
projects = list(Project.objects.filter(id__in=project_ids, organization_id=organization_id))
771+
772+
repo_definitions_by_project: defaultdict[int, list[SeerRepoDefinition]] = defaultdict(list)
773+
for project_repo in (
774+
SeerProjectRepository.objects.filter(project_id__in=project_ids)
775+
.select_related("repository")
776+
.prefetch_related("branch_overrides")
777+
):
778+
repo_def = build_repo_definition_from_project_repo(project_repo)
779+
if repo_def is not None:
780+
repo_definitions_by_project[project_repo.project_id].append(repo_def)
781+
782+
# get_value_bulk_id returns None for missing options, unlike project.get_option
783+
# which automatically falls back to the registered well-known key default.
784+
project_options: dict[str, Mapping[int, Any]] = {
785+
key: ProjectOption.objects.get_value_bulk_id(project_ids, key)
786+
for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS
787+
}
788+
789+
result: dict[int, SeerProjectPreference | None] = {}
790+
for project in projects:
791+
has_configured_options = any(
792+
project_options[key][project.id] is not None
793+
for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS
794+
)
795+
if project.id not in repo_definitions_by_project and not has_configured_options:
796+
result[project.id] = None
797+
continue
798+
799+
def _get_project_option(key: str) -> Any:
800+
value = project_options[key][project.id]
801+
if value is None:
802+
return projectoptions.get_well_known_default(key, project=project)
803+
return value
804+
805+
result[project.id] = SeerProjectPreference(
806+
organization_id=project.organization_id,
807+
project_id=project.id,
808+
repositories=repo_definitions_by_project.get(project.id, []),
809+
automated_run_stopping_point=_get_project_option(
810+
"sentry:seer_automated_run_stopping_point"
811+
),
812+
automation_handoff=_build_automation_handoff(_get_project_option),
813+
)
814+
815+
return result
816+
817+
677818
def set_project_seer_preference(preference: SeerProjectPreference) -> None:
678819
"""Set Seer project preference for a single project via Seer API."""
679820
response = make_set_project_preference_request(

0 commit comments

Comments
 (0)