diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 5a771c7d6624..724055927634 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -380,6 +380,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:data-browsing-widget-unfurl", 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 79098a2222d5..72da29b078c3 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_PROJECT_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 a881238aaf9f..15ac294d7ab9 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -1,11 +1,13 @@ 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 import orjson import pydantic +import sentry_sdk from django.conf import settings from django.db import router, transaction from pydantic import BaseModel @@ -13,7 +15,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, @@ -25,16 +27,20 @@ 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_PROJECT_PREFERENCE_OPTION_KEYS from sentry.seer.autofix.constants import AutofixAutomationTuningSettings, AutofixStatus from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.models import ( + AutofixHandoffPoint, BranchOverride, SeerApiError, SeerApiResponseValidationError, + SeerAutomationHandoffConfiguration, SeerPermissionError, SeerProjectPreference, SeerRawPreferenceResponse, @@ -44,10 +50,6 @@ SeerProjectRepository, SeerProjectRepositoryBranchOverride, ) -from sentry.seer.models.seer_api_models import ( - AutofixHandoffPoint, - SeerAutomationHandoffConfiguration, -) from sentry.seer.signed_seer_api import SeerViewerContext, make_signed_seer_api_request from sentry.utils.cache import cache from sentry.utils.outcomes import Outcome, track_outcome @@ -674,6 +676,145 @@ 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 | 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: + sentry_sdk.capture_exception(ValueError(f"Invalid repository name format: {repo.name}")) + return None + + 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 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. + + 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") + .prefetch_related("branch_overrides") + ) + repo_definitions = [ + 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( + ProjectOption.objects.isset(project, key) for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS + ) + if not repo_definitions and not has_configured_options: + return None + + return SeerProjectPreference( + organization_id=project.organization_id, + project_id=project.id, + 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 | None]: + """Bulk read Seer preferences from Sentry DB. + + For now, should only be used under feature flag `organizations:seer-project-settings-read-from-sentry`.""" + if not project_ids: + return {} + + 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_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. + 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 + + 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) + return value + + 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=_build_automation_handoff(_get_project_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( diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 58a9697bdab6..8cdf3bb573b5 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -11,6 +11,7 @@ AutofixState, AutofixTriggerSource, CodingAgentStatus, + bulk_read_preferences_from_sentry_db, bulk_write_preferences_to_sentry_db, deduplicate_repositories, get_autofix_prompt, @@ -18,6 +19,7 @@ get_org_default_seer_automation_handoff, has_project_connected_repos, is_seer_seat_based_tier_enabled, + read_preference_from_sentry_db, resolve_repository_ids, set_project_seer_preference, write_preference_to_sentry_db, @@ -1221,6 +1223,245 @@ def test_bulk_write_replaces_per_project(self) -> None: 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", + ) + 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) + 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", + ) + 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) == 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 + + 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_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") + + result = read_preference_from_sentry_db(self.project) + 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): + 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", + ) + 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, []) + 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" + ) + 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( + "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 pref1 is not None + 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 + + 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 + assert pref2.automation_handoff.handoff_point == "root_cause" + assert pref2.automation_handoff.target == "cursor_background_agent" + assert pref2.automation_handoff.integration_id == 99 + assert pref2.automation_handoff.auto_create_pr is False + + 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 == {} + + class TestGetOrgDefaultSeerAutomationHandoff(TestCase): def test_defaults(self): stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization)