diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index b06d6aa591ff27..1f68aa0fc4f336 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -58,6 +58,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -558,8 +559,9 @@ class DetailedOrganizationSerializerResponse(_DetailedOrganizationSerializerResp defaultSeerScannerAutomation: bool enableSeerEnhancedAlerts: bool enableSeerCoding: bool - defaultCodingAgent: str | None + defaultCodingAgent: str defaultCodingAgentIntegrationId: int | None + defaultAutomatedRunStoppingPoint: str autoEnableCodeReview: bool autoOpenPrs: bool defaultCodeReviewTriggers: list[str] @@ -734,12 +736,14 @@ def serialize( # type: ignore[override] ) ), "defaultCodingAgent": obj.get_option( - "sentry:seer_default_coding_agent", - SEER_DEFAULT_CODING_AGENT_DEFAULT, + "sentry:seer_default_coding_agent", SEER_DEFAULT_CODING_AGENT_DEFAULT ), "defaultCodingAgentIntegrationId": obj.get_option( - "sentry:seer_default_coding_agent_integration_id", - None, + "sentry:seer_default_coding_agent_integration_id", None + ), + "defaultAutomatedRunStoppingPoint": obj.get_option( + "sentry:default_automated_run_stopping_point", + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ), "autoOpenPrs": bool( obj.get_option( diff --git a/src/sentry/apidocs/examples/organization_examples.py b/src/sentry/apidocs/examples/organization_examples.py index 67acd600769107..fd238fc93f95ce 100644 --- a/src/sentry/apidocs/examples/organization_examples.py +++ b/src/sentry/apidocs/examples/organization_examples.py @@ -304,8 +304,9 @@ class OrganizationExamples: "enableSeerCoding": True, "enableSeerEnhancedAlerts": True, "autoOpenPrs": False, - "defaultCodingAgent": None, + "defaultCodingAgent": "seer", "defaultCodingAgentIntegrationId": None, + "defaultAutomatedRunStoppingPoint": "code_changes", "issueAlertsThreadFlag": True, "metricAlertsThreadFlag": True, "trustedRelays": [], diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 5eecb3461606ec..a3c7fd1f39b75a 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -68,6 +68,7 @@ ROLLBACK_ENABLED_DEFAULT, SAMPLING_MODE_DEFAULT, SCRAPE_JAVASCRIPT_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, TARGET_SAMPLE_RATE_DEFAULT, ObjectStatus, @@ -254,12 +255,17 @@ None, ), ( - # Informs UI default for automated_run_stopping_point in project preferences "autoOpenPrs", "sentry:auto_open_prs", bool, AUTO_OPEN_PRS_DEFAULT, ), + ( + "defaultAutomatedRunStoppingPoint", + "sentry:default_automated_run_stopping_point", + str, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, + ), ( "autoEnableCodeReview", "sentry:auto_enable_code_review", @@ -371,8 +377,15 @@ class OrganizationSerializer(BaseOrganizationSerializer): dashboardsAsyncQueueParallelLimit = serializers.IntegerField(required=False, min_value=1) enableSeerEnhancedAlerts = serializers.BooleanField(required=False) enableSeerCoding = serializers.BooleanField(required=False) - defaultCodingAgent = serializers.CharField(required=False, allow_null=True) + defaultCodingAgent = serializers.ChoiceField( + choices=["seer", "cursor", "claude_code", "cursor_background_agent", "claude_code_agent"], + required=False, + allow_null=True, + ) defaultCodingAgentIntegrationId = serializers.IntegerField(required=False, allow_null=True) + defaultAutomatedRunStoppingPoint = serializers.ChoiceField( + choices=["code_changes", "open_pr"], required=False + ) autoOpenPrs = serializers.BooleanField(required=False) autoEnableCodeReview = serializers.BooleanField(required=False) defaultCodeReviewTriggers = serializers.ListField( @@ -401,6 +414,15 @@ def validate_relayPiiConfig(self, value): organization = self.context["organization"] return validate_pii_config_update(organization, value) + def validate_defaultCodingAgent(self, value: str | None) -> str: + if value is None: + return SEER_DEFAULT_CODING_AGENT_DEFAULT + coding_agent_aliases: dict[str, str] = { + "cursor": "cursor_background_agent", + "claude_code": "claude_code_agent", + } + return coding_agent_aliases.get(value, value) + def validate_defaultCodingAgentIntegrationId(self, value: int | None) -> int | None: if value is None: return None diff --git a/src/sentry/core/endpoints/team_projects.py b/src/sentry/core/endpoints/team_projects.py index ae2d9b8b254cc8..b82545bfdbb5d4 100644 --- a/src/sentry/core/endpoints/team_projects.py +++ b/src/sentry/core/endpoints/team_projects.py @@ -31,8 +31,8 @@ from sentry.models.team import Team from sentry.seer.similarity.utils import ( project_is_seer_eligible, - set_default_project_auto_open_prs, set_default_project_autofix_automation_tuning, + set_default_project_seer_preferences, set_default_project_seer_scanner_automation, ) from sentry.signals import project_created @@ -56,7 +56,7 @@ def apply_default_project_settings(organization: Organization, project: Project) set_default_project_autofix_automation_tuning(organization, project) set_default_project_seer_scanner_automation(organization, project) - set_default_project_auto_open_prs(organization, project) + set_default_project_seer_preferences(organization, project) class ProjectPostSerializer(serializers.Serializer): diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 12e4d5feb2092a..8ac05e2efa1eba 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -15,6 +15,7 @@ from sentry import features, options, ratelimits from sentry.constants import ( + AUTO_OPEN_PRS_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory, ObjectStatus, @@ -42,6 +43,10 @@ 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 @@ -384,14 +389,47 @@ def validate(self, data): def default_seer_project_preference(project: Project) -> SeerProjectPreference: + stopping_point, handoff = get_org_default_seer_automation_handoff(project.organization) return SeerProjectPreference( organization_id=project.organization.id, project_id=project.id, repositories=[], - automated_run_stopping_point=AutofixStoppingPoint.CODE_CHANGES.value, - automation_handoff=None, + automated_run_stopping_point=stopping_point, + automation_handoff=handoff, + ) + + +def get_org_default_seer_automation_handoff( + organization: Organization, +) -> tuple[str, SeerAutomationHandoffConfiguration | None]: + """Get the default stopping point and automation handoff for an organization.""" + stopping_point = organization.get_option( + "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) + auto_open_prs = organization.get_option("sentry:auto_open_prs", AUTO_OPEN_PRS_DEFAULT) + + automation_handoff: SeerAutomationHandoffConfiguration | None = None + coding_agent = organization.get_option("sentry:seer_default_coding_agent") + coding_agent_integration_id = organization.get_option( + "sentry:seer_default_coding_agent_integration_id" + ) + if coding_agent and coding_agent != "seer" and coding_agent_integration_id is not None: + automation_handoff = SeerAutomationHandoffConfiguration( + handoff_point=AutofixHandoffPoint.ROOT_CAUSE, + target=coding_agent, + integration_id=coding_agent_integration_id, + auto_create_pr=auto_open_prs, + ) + # If Seer agent and auto open PRs, we can run up to open_pr. + elif auto_open_prs: + stopping_point = "open_pr" + # If Seer agent and no auto open PRs, we shouldn't go past code_changes. + elif stopping_point == "open_pr": + stopping_point = "code_changes" + + return stopping_point, automation_handoff + def get_project_seer_preferences(project_id: int) -> SeerRawPreferenceResponse: """ diff --git a/src/sentry/seer/similarity/utils.py b/src/sentry/seer/similarity/utils.py index 448ab305a0e13f..ff530ed98c12dd 100644 --- a/src/sentry/seer/similarity/utils.py +++ b/src/sentry/seer/similarity/utils.py @@ -9,7 +9,9 @@ from tokenizers import Tokenizer from sentry import features, options -from sentry.constants import DATA_ROOT +from sentry.constants import ( + DATA_ROOT, +) from sentry.grouping.api import get_contributing_variant_and_component from sentry.grouping.grouping_info import get_grouping_info_from_variants_legacy from sentry.grouping.variants import BaseVariant @@ -18,12 +20,14 @@ from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( - AutofixStoppingPoint, + get_org_default_seer_automation_handoff, is_seer_seat_based_tier_enabled, set_project_seer_preference, write_preference_to_sentry_db, ) -from sentry.seer.models import SeerProjectPreference +from sentry.seer.models import ( + SeerProjectPreference, +) from sentry.seer.similarity.types import GroupingVersion from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import metrics @@ -563,14 +567,14 @@ def set_default_project_seer_scanner_automation( project.update_option("sentry:seer_scanner_automation", org_default) -def set_default_project_auto_open_prs(organization: Organization, project: Project) -> None: - """Called once at project creation time to set the initial auto open PRs.""" +def set_default_project_seer_preferences(organization: Organization, project: Project) -> None: + """Called once at project creation time to set the initial automated run stopping + point and automation handoff. + """ if not is_seer_seat_based_tier_enabled(organization): return - stopping_point = AutofixStoppingPoint.CODE_CHANGES - if organization.get_option("sentry:auto_open_prs"): - stopping_point = AutofixStoppingPoint.OPEN_PR + stopping_point, automation_handoff = get_org_default_seer_automation_handoff(organization) # We need to make an API call to Seer to set this preference preference = SeerProjectPreference( @@ -578,7 +582,9 @@ def set_default_project_auto_open_prs(organization: Organization, project: Proje project_id=project.id, repositories=[], automated_run_stopping_point=stopping_point, + automation_handoff=automation_handoff, ) + try: set_project_seer_preference(preference) except Exception as e: diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 8671d98bee3281..1d4ac97d8abcb2 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -8,7 +8,9 @@ from sentry import analytics, features from sentry.analytics.events.autofix_automation_events import AiAutofixAutomationEvent -from sentry.constants import ObjectStatus +from sentry.constants import ( + ObjectStatus, +) from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project @@ -24,6 +26,7 @@ deduplicate_repositories, get_autofix_repos_from_project_code_mappings, get_autofix_state, + get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, resolve_repository_ids, ) @@ -238,34 +241,49 @@ def configure_seer_for_existing_org(organization_id: int) -> None: "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) + default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) + default_handoff_dict = default_handoff.dict() if default_handoff else None + + valid_stopping_points = {"open_pr", "code_changes"} + preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) # Determine which projects need updates preferences_to_set = [] projects_by_id = {p.id: p for p in projects} for project_id in project_ids: + stopping_point = default_stopping_point + handoff = default_handoff_dict + existing_pref = preferences_by_id.get(str(project_id)) if not existing_pref: # No existing preferences, get repositories from code mappings repositories = get_autofix_repos_from_project_code_mappings(projects_by_id[project_id]) else: - # Skip projects that already have an acceptable stopping point configured - if existing_pref.get("automated_run_stopping_point") in ("open_pr", "code_changes"): - continue repositories = existing_pref.get("repositories") or [] - repositories = deduplicate_repositories(repositories) + existing_stopping_point = existing_pref.get("automated_run_stopping_point") + existing_handoff = existing_pref.get("automation_handoff") + + # Skip projects that a) already have an acceptable stopping point configured + # AND b) already have a handoff configured or no org default handoff. + if existing_stopping_point in valid_stopping_points and ( + existing_handoff or default_handoff_dict is None + ): + continue + + if existing_stopping_point in valid_stopping_points: + stopping_point = existing_stopping_point + if existing_handoff: + handoff = existing_handoff - # Preserve existing repositories and automation_handoff, only update the stopping point preferences_to_set.append( { "organization_id": organization_id, "project_id": project_id, - "repositories": repositories or [], - "automated_run_stopping_point": "code_changes", - "automation_handoff": ( - existing_pref.get("automation_handoff") if existing_pref else None - ), + "repositories": deduplicate_repositories(repositories) or [], + "automated_run_stopping_point": stopping_point, + "automation_handoff": handoff, } ) diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index 60679a0f2e9085..f01d258575ad5f 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -21,6 +21,7 @@ from sentry.auth.authenticators.totp import TotpInterface from sentry.constants import ( RESERVED_ORGANIZATION_SLUGS, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, SEER_DEFAULT_CODING_AGENT_DEFAULT, ObjectStatus, ) @@ -1499,19 +1500,50 @@ def test_default_coding_agent_default(self) -> None: response = self.get_success_response(self.organization.slug) assert response.data["defaultCodingAgent"] == SEER_DEFAULT_CODING_AGENT_DEFAULT - def test_default_coding_agent_can_be_set(self) -> None: + def test_default_coding_agent_can_be_set_to_seer(self) -> None: data = {"defaultCodingAgent": "seer"} response = self.get_success_response(self.organization.slug, **data) assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" assert response.data["defaultCodingAgent"] == "seer" - def test_default_coding_agent_null_on_first_write_create_path(self) -> None: - # Tests the create path (no OrganizationOption row exists yet): sending null - # must store null rather than the string "None" via str(None). + def test_default_coding_agent_can_be_set_to_cursor(self) -> None: + for value in ("cursor", "cursor_background_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "cursor_background_agent" + ) + assert response.data["defaultCodingAgent"] == "cursor_background_agent" + + def test_default_coding_agent_can_be_set_to_claude(self) -> None: + for value in ("claude_code", "claude_code_agent"): + data = {"defaultCodingAgent": value} + response = self.get_success_response(self.organization.slug, **data) + assert ( + self.organization.get_option("sentry:seer_default_coding_agent") + == "claude_code_agent" + ) + assert response.data["defaultCodingAgent"] == "claude_code_agent" + + def test_default_coding_agent_none_casts_to_seer(self) -> None: + data = {"defaultCodingAgent": None} + response = self.get_success_response(self.organization.slug, **data) + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_none_resets_to_seer(self) -> None: + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) data = {"defaultCodingAgent": None} response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + assert self.organization.get_option("sentry:seer_default_coding_agent") == "seer" + assert response.data["defaultCodingAgent"] == "seer" + + def test_default_coding_agent_rejects_invalid_choice(self) -> None: + data = {"defaultCodingAgent": "invalid_agent"} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_writing_default_value_stores_but_skips_audit_log( self, @@ -1581,12 +1613,25 @@ def test_default_coding_agent_integration_id_null_on_first_write_create_path(sel ) assert response.data["defaultCodingAgentIntegrationId"] is None - def test_default_coding_agent_can_be_cleared(self) -> None: - self.organization.update_option("sentry:seer_default_coding_agent", "seer") - data = {"defaultCodingAgent": None} - response = self.get_success_response(self.organization.slug, **data) - assert self.organization.get_option("sentry:seer_default_coding_agent") is None - assert response.data["defaultCodingAgent"] is None + def test_default_automated_run_stopping_point_default(self) -> None: + response = self.get_success_response(self.organization.slug) + assert ( + response.data["defaultAutomatedRunStoppingPoint"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + def test_default_automated_run_stopping_point_can_be_set(self) -> None: + for choice in ("code_changes", "open_pr"): + with self.subTest(choice=choice): + data = {"defaultAutomatedRunStoppingPoint": choice} + response = self.get_success_response(self.organization.slug, **data) + assert response.data["defaultAutomatedRunStoppingPoint"] == choice + + def test_default_automated_run_stopping_point_rejects_invalid(self) -> None: + for invalid in ("root_cause", "solution", "invalid_point"): + with self.subTest(value=invalid): + data = {"defaultAutomatedRunStoppingPoint": invalid} + self.get_error_response(self.organization.slug, status_code=400, **data) def test_default_coding_agent_integration_id_can_be_cleared(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 123) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 96260e838075e7..2f5c8c401d6ed3 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -15,6 +15,7 @@ deduplicate_repositories, get_autofix_prompt, get_coding_agent_prompt, + get_org_default_seer_automation_handoff, has_project_connected_repos, is_seer_seat_based_tier_enabled, resolve_repository_ids, @@ -1218,3 +1219,91 @@ def test_bulk_write_replaces_per_project(self) -> None: assert p1_repo.branch_name == "new-branch" p2_repo = SeerProjectRepository.objects.get(project=project2) assert p2_repo.branch_name == "project-2-branch" + + +class TestGetOrgDefaultSeerAutomationHandoff(TestCase): + def test_defaults(self): + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_respects_org_stopping_point_option(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_auto_open_prs_forces_open_pr(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_agent_no_auto_open_prs_caps_open_pr_to_code_changes(self): + self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is None + + def test_external_agent_returns_handoff_config(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + assert handoff.handoff_point == "root_cause" + assert handoff.target == "cursor_background_agent" + assert handoff.integration_id == 42 + assert handoff.auto_create_pr is False + + def test_external_agent_auto_open_prs_sets_auto_create_pr(self): + self.organization.update_option("sentry:seer_default_coding_agent", "claude_code_agent") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 99) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert handoff is not None + assert handoff.auto_create_pr is True + + def test_external_agent_auto_open_prs_does_not_override_stopping_point(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "code_changes" + ) + self.organization.update_option("sentry:auto_open_prs", True) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "code_changes" + assert handoff is not None + + def test_external_agent_without_integration_id_falls_back_to_seer(self): + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None + + def test_seer_coding_agent_treated_as_no_external_agent(self): + self.organization.update_option("sentry:seer_default_coding_agent", "seer") + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == "open_pr" + assert handoff is None diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index 20566413060f75..ed92f065fb2c17 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -4,6 +4,7 @@ import pytest from django.test import TestCase +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT 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 @@ -197,23 +198,166 @@ def test_overrides_autofix_off_to_medium( @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") - def test_skips_projects_with_existing_stopping_point( + def test_new_project_gets_stopping_point_and_no_handoff_from_org_defaults( self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock ) -> None: - """Test that projects with open_pr or code_changes stopping point are skipped.""" - project1 = self.create_project(organization=self.organization) - project2 = self.create_project(organization=self.organization) + """Project with no existing prefs gets stopping point and no handoff (seer coding agent) from org defaults.""" + project = self.create_project(organization=self.organization) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "code_changes" + assert prefs_by_project[project.id]["automation_handoff"] is None + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_new_project_gets_stopping_point_and_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with no existing prefs gets stopping point and external agent handoff from org defaults.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = {} + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + # auto_open_prs should NOT override stopping point for external agents + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_no_default_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and the org has no default handoff (seer agent).""" + project = self.create_project(organization=self.organization) mock_bulk_get.return_value = { - str(project1.id): {"automated_run_stopping_point": "open_pr"}, - str(project2.id): {"automated_run_stopping_point": "code_changes"}, + str(project.id): {"automated_run_stopping_point": "open_pr"}, } configure_seer_for_existing_org(organization_id=self.organization.id) - # bulk_set should not be called since both projects are skipped mock_bulk_set.assert_not_called() + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_skips_project_with_valid_stopping_point_and_existing_handoff( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project is skipped when it has a valid stopping point and an existing handoff configured.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "code_changes", + "automation_handoff": { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + }, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_not_called() + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_valid_stopping_point_gets_handoff_from_org_defaults( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with valid stopping point but no handoff gets org default handoff applied. + Existing stopping point is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + self.organization.update_option("sentry:auto_open_prs", True) + + mock_bulk_get.return_value = { + str(project.id): {"automated_run_stopping_point": "open_pr"}, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert prefs_by_project[project.id]["automated_run_stopping_point"] == "open_pr" + assert prefs_by_project[project.id]["automation_handoff"] == { + "handoff_point": "root_cause", + "target": "cursor_background_agent", + "integration_id": 42, + "auto_create_pr": True, + } + + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_project_with_invalid_stopping_point_gets_org_default_stopping_point( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with unrecognized stopping point gets org default stopping point applied. + Existing handoff (if any) is preserved.""" + project = self.create_project(organization=self.organization) + self.organization.update_option( + "sentry:seer_default_coding_agent", "cursor_background_agent" + ) + self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) + + existing_handoff = { + "handoff_point": "root_cause", + "target": "claude_code_agent", + "integration_id": 99, + "auto_create_pr": False, + } + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "root_cause", + "automation_handoff": existing_handoff, + }, + } + + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_called_once() + prefs = mock_bulk_set.call_args[0][1] + prefs_by_project = {p["project_id"]: p for p in prefs} + assert ( + prefs_by_project[project.id]["automated_run_stopping_point"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + assert prefs_by_project[project.id]["automation_handoff"] == existing_handoff + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") def test_raises_on_bulk_get_api_failure(self, mock_bulk_get: MagicMock) -> None: """Test that task raises on bulk GET API failure to trigger retry."""