diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 34a7443dd3b38c..7dc97c64214768 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterable, Mapping from datetime import UTC, datetime from enum import StrEnum -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict import orjson import sentry_sdk @@ -728,16 +728,19 @@ def _get_project_option(key: str) -> Any: class SeerProjectSettingsUpdate(TypedDict, total=False): agent: AutomationCodingAgent - integrationId: int - stoppingPoint: AutofixStoppingPoint | Literal["off"] - scannerAutomation: bool + integration_id: int + stopping_point: str + automation_tuning: str + scanner_automation: bool + auto_create_pr: bool -def _get_seer_project_options_to_update( - data: SeerProjectSettingsUpdate, -) -> tuple[dict[str, Any], list[str]]: - """Return (options_to_set, options_to_clear) for the given Seer project settings update. - Clear the option if it's the default; otherwise, set it.""" +def update_seer_project_settings(project_ids: list[int], data: SeerProjectSettingsUpdate) -> None: + """Apply Seer project settings to one or more projects. + For any ProjectOptions, delete the row if we're setting that field to its default.""" + if not project_ids or not data: + return + options_to_set: dict[str, Any] = {} options_to_clear: list[str] = [] @@ -756,84 +759,44 @@ def _set_or_clear(key: str, value: Any, default: Any) -> None: "sentry:seer_automation_handoff_integration_id", ] else: - integration_id = data.get("integrationId") + integration_id = data.get("integration_id") if integration_id is None: raise ValueError("integrationId is required for external coding agents") options_to_set["sentry:seer_automation_handoff_point"] = AutofixHandoffPoint.ROOT_CAUSE options_to_set["sentry:seer_automation_handoff_target"] = agent options_to_set["sentry:seer_automation_handoff_integration_id"] = integration_id - if "scannerAutomation" in data: - _set_or_clear("sentry:seer_scanner_automation", data["scannerAutomation"], default=True) + if "scanner_automation" in data: + _set_or_clear("sentry:seer_scanner_automation", data["scanner_automation"], default=True) - if "stoppingPoint" not in data: - return options_to_set, options_to_clear - elif data["stoppingPoint"] == "off": - # Disable automation and leave stopping point and handoff_auto_create_pr unchanged - # so that reenabling restores the prior state. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.OFF, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) - else: - # Enable automation and set the stopping point. - _set_or_clear( - "sentry:autofix_automation_tuning", - AutofixAutomationTuningSettings.MEDIUM, - default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, - ) + if "stopping_point" in data: _set_or_clear( "sentry:seer_automated_run_stopping_point", - data["stoppingPoint"], + data["stopping_point"], default=SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ) - if data["stoppingPoint"] == AutofixStoppingPoint.OPEN_PR: - # Safe to set even if no external handoff is configured - # since we'll only read it if the other handoff options are all non-null. - options_to_set["sentry:seer_automation_handoff_auto_create_pr"] = True - else: - options_to_clear.append("sentry:seer_automation_handoff_auto_create_pr") - return options_to_set, options_to_clear - - -def update_seer_project_settings(project: Project, data: SeerProjectSettingsUpdate) -> None: - """Apply high-level Seer settings to a single project.""" - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - Project.objects.select_for_update().filter(id=project.id).first() - - for key in options_to_delete: - project.delete_option(key) - for key, value in options_to_set.items(): - project.update_option(key, value) - + if "auto_create_pr" in data: + _set_or_clear( + "sentry:seer_automation_handoff_auto_create_pr", data["auto_create_pr"], default=False + ) -def bulk_update_seer_project_settings( - projects: list[Project], data: SeerProjectSettingsUpdate -) -> None: - """Apply high-level Seer settings to multiple projects in bulk.""" - if not projects: - return + if "automation_tuning" in data: + _set_or_clear( + "sentry:autofix_automation_tuning", + data["automation_tuning"], + default=AUTOFIX_AUTOMATION_TUNING_DEFAULT, + ) - options_to_set, options_to_delete = _get_seer_project_options_to_update(data) - if not options_to_set and not options_to_delete: + if not options_to_set and not options_to_clear: return - project_ids = [p.id for p in projects] - with transaction.atomic(using=router.db_for_write(ProjectOption)): - # Lock project rows to serialize concurrent writes. - list(Project.objects.select_for_update().filter(id__in=project_ids).order_by("id")) - - if options_to_delete: + if options_to_clear: # Use _raw_delete to skip per-row post_delete signals that each trigger reload_cache. # For efficiency, we reload once per project after the transaction instead. ProjectOption.objects.filter( - project_id__in=project_ids, key__in=options_to_delete + project_id__in=project_ids, key__in=options_to_clear )._raw_delete(using=router.db_for_write(ProjectOption)) if options_to_set: diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py index 9b13fdb3e3707a..8249b26f73cd63 100644 --- a/src/sentry/seer/endpoints/project_seer_settings.py +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -19,6 +19,7 @@ from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter from sentry.api.event_search import parse_search_query as base_parse_search_query from sentry.api.paginator import OffsetPaginator +from sentry.api.serializers.rest_framework import CamelSnakeSerializer from sentry.constants import ( AUTOFIX_AUTOMATION_TUNING_DEFAULT, SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, @@ -36,7 +37,6 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, AutomationCodingAgent, - bulk_update_seer_project_settings, get_automation_handoff, get_valid_automated_run_stopping_points, update_seer_project_settings, @@ -80,6 +80,8 @@ class SeerProjectSettingsResponse(TypedDict): agent: str integrationId: str | None stoppingPoint: str + autoCreatePr: bool | None + automationTuning: str scannerAutomation: bool reposCount: int @@ -154,9 +156,11 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe # No configured external handoff means use Seer agent. agent: str = "seer" integration_id: str | None = None + auto_create_pr: bool | None = None else: agent = handoff.target integration_id = str(handoff.integration_id) + auto_create_pr = handoff.auto_create_pr return SeerProjectSettingsResponse( projectId=str(project.id), @@ -164,6 +168,8 @@ def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSe agent=agent, integrationId=integration_id, stoppingPoint=stopping_point, + autoCreatePr=auto_create_pr, + automationTuning=settings["automation_tuning"], scannerAutomation=settings["scanner_automation"], reposCount=settings["repos_count"], ) @@ -307,22 +313,22 @@ def _apply_search_filters(queryset, filters: Sequence[QueryToken]): return queryset -class ProjectSettingsUpdateSerializer(serializers.Serializer): +class ProjectSettingsUpdateSerializer(CamelSnakeSerializer): agent = serializers.ChoiceField(choices=[*AutomationCodingAgent], required=False) - integrationId = serializers.IntegerField(required=False) - stoppingPoint = serializers.ChoiceField(choices=["off", *AutofixStoppingPoint], required=False) - scannerAutomation = serializers.BooleanField(required=False) - - def validate_stoppingPoint(self, value: str) -> str: - if value == "off": - return value + integration_id = serializers.IntegerField(required=False) + stopping_point = serializers.ChoiceField(choices=[*AutofixStoppingPoint], required=False) + scanner_automation = serializers.BooleanField(required=False) + automation_tuning = serializers.ChoiceField( + choices=[AutofixAutomationTuningSettings.OFF, AutofixAutomationTuningSettings.MEDIUM], + required=False, + ) - organization = self.context["organization"] - if value not in get_valid_automated_run_stopping_points(organization): + def validate_stopping_point(self, value: str) -> str: + if value not in get_valid_automated_run_stopping_points(self.context["organization"]): raise serializers.ValidationError(f'"{value}" is not a valid choice.') return value - def validate_integrationId(self, value: int) -> int: + def validate_integration_id(self, value: int) -> int: organization = self.context["organization"] org_integrations = integration_service.get_organization_integrations( organization_id=organization.id, integration_id=value @@ -332,25 +338,31 @@ def validate_integrationId(self, value: int) -> int: return value def validate(self, data): - if "agent" in data and data["agent"] != "seer" and "integrationId" not in data: + if "agent" in data and data["agent"] != "seer" and "integration_id" not in data: raise serializers.ValidationError( - {"integrationId": "Required when agent is an external coding agent."} + {"integration_id": "Required when agent is an external coding agent."} ) - if "integrationId" in data: + if "integration_id" in data: if "agent" not in data: raise serializers.ValidationError( - {"agent": "Required when integrationId is provided."} + {"agent": "Required when integration_id is provided."} ) elif data["agent"] == "seer": raise serializers.ValidationError( - {"agent": "Must be an external coding agent when integrationId is provided."} + {"agent": "Must be an external coding agent when integration_id is provided."} ) - has_update = any(k in data for k in ("agent", "stoppingPoint", "scannerAutomation")) - if not has_update: + if not any( + k in data + for k in ("agent", "stopping_point", "scanner_automation", "automation_tuning") + ): raise serializers.ValidationError("At least one update field must be provided.") + # Keep stopping point in sync with handoff auto_create_pr. + if "stopping_point" in data and "auto_create_pr" not in data: + data["auto_create_pr"] = data["stopping_point"] == AutofixStoppingPoint.OPEN_PR + return data @@ -373,20 +385,15 @@ def put(self, request: Request, project: Project) -> Response: if not serializer.is_valid(): return Response(serializer.errors, status=400) - update_seer_project_settings(project, serializer.validated_data) + data = serializer.validated_data + update_seer_project_settings([project.id], data) self.create_audit_entry( request=request, organization=project.organization, target_object=project.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_id": project.id, - "agent": serializer.validated_data.get("agent"), - "integration_id": serializer.validated_data.get("integrationId"), - "stopping_point": serializer.validated_data.get("stoppingPoint"), - "scanner_automation": serializer.validated_data.get("scannerAutomation"), - }, + data={"project_id": project.id, **data}, ) return Response(serialize_project(project)) @@ -455,21 +462,15 @@ def put(self, request: Request, organization: Organization) -> Response: return Response({"detail": "Invalid search query"}, status=400) projects = list(queryset) - bulk_update_seer_project_settings(projects, data) + if projects: + update_seer_project_settings([p.id for p in projects], data) self.create_audit_entry( request=request, organization=organization, target_object=organization.id, event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), - data={ - "project_count": len(projects), - "project_ids": [p.id for p in projects], - "agent": data.get("agent"), - "integration_id": data.get("integrationId"), - "stopping_point": data.get("stoppingPoint"), - "scanner_automation": data.get("scannerAutomation"), - }, + data={"project_count": len(projects), "project_ids": [p.id for p in projects], **data}, ) return Response(status=204) diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 0c1796a3c1be8d..dbf59c6bf630f8 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -25,7 +25,6 @@ CodingAgentStatus, add_seer_project_repos, bulk_read_preferences_from_sentry_db, - bulk_update_seer_project_settings, bulk_write_preferences_to_sentry_db, clear_preference_automation_handoff, deduplicate_repositories, @@ -1609,188 +1608,134 @@ def test_returns_none_when_message_is_not_a_string(self) -> None: class TestUpdateSeerProjectSettings(TestCase): def setUp(self) -> None: super().setUp() - self.project = self.create_project(organization=self.organization) + self.project1 = self.create_project(organization=self.organization) + self.project2 = self.create_project(organization=self.organization) + + def test_updates_settings(self) -> None: + """All fields should be written to the correct project options.""" + update_seer_project_settings( + [self.project1.id], + { + "agent": AutomationCodingAgent.SEER, + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "automation_tuning": AutofixAutomationTuningSettings.MEDIUM, + "scanner_automation": False, + }, + ) + + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project1.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert self.project1.get_option("sentry:seer_scanner_automation") is False + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + + def test_mixed_sets_and_clears_settings(self) -> None: + """New and existing fields are upserted. Fields set to their defaults are cleared.""" + self.project1.update_option( + "sentry:seer_automation_handoff_target", + CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, + ) + self.project1.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) + + update_seer_project_settings( + [self.project1.id], + {"agent": AutomationCodingAgent.SEER, "scanner_automation": False}, + ) + + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_scanner_automation") is False + + assert not ProjectOption.objects.filter( + project=self.project1, key="sentry:seer_automation_handoff_target" + ).exists() def test_agent_seer_clears_handoff_options(self) -> None: """Setting agent=seer should delete all handoff-related project options.""" - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_target", CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, ) - self.project.update_option( + self.project1.update_option( "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE ) - self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project1.update_option("sentry:seer_automation_handoff_integration_id", 42) - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.SEER}) + update_seer_project_settings([self.project1.id], {"agent": AutomationCodingAgent.SEER}) - assert self.project.get_option("sentry:seer_automation_handoff_target") is None - assert self.project.get_option("sentry:seer_automation_handoff_point") is None - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") is None + assert self.project1.get_option("sentry:seer_automation_handoff_target") is None + assert self.project1.get_option("sentry:seer_automation_handoff_point") is None + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") is None def test_agent_external_sets_handoff_options(self) -> None: - """Setting agent=cursor with integrationId should set handoff target, point, and integration ID.""" + """Setting agent=cursor with integration_id should set handoff target, point, and integration ID.""" update_seer_project_settings( - self.project, {"agent": AutomationCodingAgent.CURSOR, "integrationId": 99} + [self.project1.id], + {"agent": AutomationCodingAgent.CURSOR, "integration_id": 99}, ) assert ( - self.project.get_option("sentry:seer_automation_handoff_target") + self.project1.get_option("sentry:seer_automation_handoff_target") == CodingAgentProviderType.CURSOR_BACKGROUND_AGENT ) assert ( - self.project.get_option("sentry:seer_automation_handoff_point") + self.project1.get_option("sentry:seer_automation_handoff_point") == AutofixHandoffPoint.ROOT_CAUSE ) - assert self.project.get_option("sentry:seer_automation_handoff_integration_id") == 99 + assert self.project1.get_option("sentry:seer_automation_handoff_integration_id") == 99 def test_agent_external_requires_integration_id(self) -> None: - """Setting an external agent without integrationId should raise ValueError.""" + """Setting an external agent without integration_id should raise ValueError.""" with pytest.raises(ValueError): - update_seer_project_settings(self.project, {"agent": AutomationCodingAgent.CURSOR}) - - def test_agent_external_with_open_pr_sets_auto_create_pr(self) -> None: - """External agent + stoppingPoint=open_pr should set auto_create_pr=True.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_agent_external_with_non_open_pr_does_not_set_auto_create_pr(self) -> None: - """External agent + stoppingPoint!=open_pr should not set auto_create_pr.""" - update_seer_project_settings( - self.project, - { - "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.CODE_CHANGES, - }, - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint=off should set tuning to OFF and preserve stopping point and auto_create_pr.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM - ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - update_seer_project_settings(self.project, {"stoppingPoint": "off"}) + update_seer_project_settings( + [self.project1.id], {"agent": AutomationCodingAgent.CURSOR} + ) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + def test_stopping_point_omitted_preserves_existing(self) -> None: + """Omitting stopping_point should leave stopping point and auto_create_pr unchanged.""" + self.project1.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + self.project1.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - def test_stopping_point_sets_tuning_medium_and_stores_value(self) -> None: - """A non-off stoppingPoint should set tuning to MEDIUM and store the value.""" - update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.ROOT_CAUSE} - ) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) - assert ( - self.project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) - assert ( - self.project.get_option("sentry:seer_automated_run_stopping_point") - == AutofixStoppingPoint.ROOT_CAUSE - ) + assert self.project1.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project1.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - def test_stopping_point_omitted_preserves_existing_options(self) -> None: - """Omitting stoppingPoint from data should leave tuning, stopping point, and auto_create_pr unchanged.""" - self.project.update_option( + def test_automation_tuning_omitted_preserves_existing(self) -> None: + """Omitting automation_tuning should leave the existing value unchanged.""" + self.project1.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - update_seer_project_settings(self.project, {"scannerAutomation": False}) + update_seer_project_settings([self.project1.id], {"scanner_automation": False}) assert ( - self.project.get_option("sentry:autofix_automation_tuning") + self.project1.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM ) - assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: - """Changing stoppingPoint away from open_pr should clear auto_create_pr.""" - self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - self.project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) + def test_bulk_updates_settings(self) -> None: + """The provided settings fields should be applied to every project.""" update_seer_project_settings( - self.project, {"stoppingPoint": AutofixStoppingPoint.CODE_CHANGES} - ) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_automation_handoff_auto_create_pr" - ).exists() - - def test_stopping_point_open_pr_sets_auto_create_pr(self) -> None: - """stoppingPoint=open_pr should set auto_create_pr, even if no handoff is configured.""" - update_seer_project_settings(self.project, {"stoppingPoint": AutofixStoppingPoint.OPEN_PR}) - - assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True - - def test_scanner_automation_false(self) -> None: - """scannerAutomation=false should update the project option.""" - update_seer_project_settings(self.project, {"scannerAutomation": False}) - - assert self.project.get_option("sentry:seer_scanner_automation") is False - - def test_deletes_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - self.project.update_option("sentry:seer_scanner_automation", False) - assert ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - update_seer_project_settings(self.project, {"scannerAutomation": True}) - - assert not ProjectOption.objects.filter( - project=self.project, key="sentry:seer_scanner_automation" - ).exists() - - -class TestBulkUpdateSeerProjectSettings(TestCase): - def setUp(self) -> None: - super().setUp() - self.project_a = self.create_project(organization=self.organization) - self.project_b = self.create_project(organization=self.organization) - self.projects = [self.project_a, self.project_b] - - def test_empty_projects(self) -> None: - """Empty project list should be a no-op without errors.""" - bulk_update_seer_project_settings([], {"scannerAutomation": False}) - - def test_sets_options(self) -> None: - """All provided settings fields should be applied to every project.""" - bulk_update_seer_project_settings( - self.projects, + [self.project1.id, self.project2.id], { "agent": AutomationCodingAgent.CURSOR, - "integrationId": 99, - "stoppingPoint": AutofixStoppingPoint.OPEN_PR, - "scannerAutomation": False, + "integration_id": 99, + "stopping_point": AutofixStoppingPoint.OPEN_PR, + "scanner_automation": False, }, ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:seer_automation_handoff_target") == AutomationCodingAgent.CURSOR @@ -1800,113 +1745,45 @@ def test_sets_options(self) -> None: == AutofixHandoffPoint.ROOT_CAUSE ) assert project.get_option("sentry:seer_automation_handoff_integration_id") == 99 - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.MEDIUM - ) assert ( project.get_option("sentry:seer_automated_run_stopping_point") == AutofixStoppingPoint.OPEN_PR ) - assert project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True assert project.get_option("sentry:seer_scanner_automation") is False - def test_agent_seer_clears_handoff_options(self) -> None: - """Switching to seer agent should delete handoff options across all projects.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings(self.projects, {"agent": AutomationCodingAgent.SEER}) - - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - - def test_upserts_existing_options(self) -> None: - """Existing options should be overwritten, not duplicated.""" - for project in self.projects: - project.update_option("sentry:seer_scanner_automation", True) - - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) - - for project in self.projects: - assert project.get_option("sentry:seer_scanner_automation") is False - assert ( - ProjectOption.objects.filter( - project=project, key="sentry:seer_scanner_automation" - ).count() - == 1 - ) - - def test_clears_option_when_value_is_default(self) -> None: - """Setting a value equal to its registered default should delete the ProjectOption row.""" - for project in self.projects: - project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + def test_empty_projects(self) -> None: + """Empty project list should be a no-op without errors.""" + update_seer_project_settings([], {"scanner_automation": False}) - bulk_update_seer_project_settings( - self.projects, - {"stoppingPoint": AutofixStoppingPoint(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT)}, + def test_does_not_modify_excluded_projects(self) -> None: + """Projects not included in the update list should be completely unaffected.""" + self.project1.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR + ) + self.project2.update_option( + "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR ) - for project in self.projects: - assert ( - project.get_option("sentry:seer_automated_run_stopping_point") - == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT - ) - assert not ProjectOption.objects.filter( - project=project, key="sentry:seer_automated_run_stopping_point" - ).exists() - - def test_stopping_point_off_sets_tuning_off(self) -> None: - """stoppingPoint='off' should set tuning to OFF and preserve existing stopping point.""" - for project in self.projects: - project.update_option( - "sentry:seer_automated_run_stopping_point", AutofixStoppingPoint.OPEN_PR - ) - project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - - bulk_update_seer_project_settings(self.projects, {"stoppingPoint": "off"}) - - for project in self.projects: - assert ( - project.get_option("sentry:autofix_automation_tuning") - == AutofixAutomationTuningSettings.OFF - ) - - def test_mixed_sets_and_clears_options(self) -> None: - """Test that sets new options and deletes existing ones.""" - for project in self.projects: - project.update_option( - "sentry:seer_automation_handoff_target", - CodingAgentProviderType.CURSOR_BACKGROUND_AGENT, - ) - project.update_option( - "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE - ) - project.update_option("sentry:seer_automation_handoff_integration_id", 42) - - bulk_update_seer_project_settings( - self.projects, - {"agent": AutomationCodingAgent.SEER, "scannerAutomation": False}, + update_seer_project_settings( + [self.project1.id], + { + "stopping_point": AutofixStoppingPoint.CODE_CHANGES, + "agent": AutomationCodingAgent.SEER, + }, ) - for project in self.projects: - assert project.get_option("sentry:seer_automation_handoff_target") is None - assert project.get_option("sentry:seer_automation_handoff_point") is None - assert project.get_option("sentry:seer_automation_handoff_integration_id") is None - assert project.get_option("sentry:seer_scanner_automation") is False + assert ( + self.project1.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.CODE_CHANGES + ) + assert ( + self.project2.get_option("sentry:seer_automated_run_stopping_point") + == AutofixStoppingPoint.OPEN_PR + ) - def test_omitted_fields_preserve_existing_options(self) -> None: - """Updating one field should not clobber unrelated existing options.""" - for project in self.projects: + def test_bulk_omitted_fields_preserve_existing_options(self) -> None: + """Updating one field should not clobber unrelated existing options across multiple projects.""" + for project in [self.project1, self.project2]: project.update_option( "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM ) @@ -1915,9 +1792,12 @@ def test_omitted_fields_preserve_existing_options(self) -> None: ) project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) - bulk_update_seer_project_settings(self.projects, {"scannerAutomation": False}) + update_seer_project_settings( + [self.project1.id, self.project2.id], + {"scanner_automation": False}, + ) - for project in self.projects: + for project in [self.project1, self.project2]: assert ( project.get_option("sentry:autofix_automation_tuning") == AutofixAutomationTuningSettings.MEDIUM diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py index ffc0634513cd5b..ae1c2bcc8495d3 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_settings.py +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -32,6 +32,8 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } @@ -48,10 +50,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "open_pr" + assert response.data["autoCreatePr"] is None + assert response.data["automationTuning"] == "medium" assert response.data["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project with an external handoff should return the agent alias and integration ID.""" + def test_get_external_agent_with_integration_id(self) -> None: + """A project with an external handoff should return the agent, integration ID, + and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -65,6 +70,23 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data["agent"] == "cursor_background_agent" assert response.data["integrationId"] == "42" + assert response.data["autoCreatePr"] is False + + def test_get_external_agent_with_auto_create_pr(self) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["autoCreatePr"] is True def test_get_stopping_point_off_when_tuning_off(self) -> None: """stoppingPoint should be 'off' when tuning is OFF.""" @@ -77,6 +99,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "off" + assert response.data["automationTuning"] == "off" def test_get_stopping_point_when_tuning_on(self) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" @@ -89,6 +112,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data["stoppingPoint"] == "root_cause" + assert response.data["automationTuning"] == "medium" def test_get_repos_count(self) -> None: """reposCount should reflect active SeerProjectRepository rows.""" @@ -119,7 +143,9 @@ def test_get_repos_count_excludes_inactive_repos(self) -> None: def test_put_returns_updated_settings(self) -> None: """PUT response should contain the full updated settings object.""" response = self.client.put( - self.url, data={"agent": "seer", "stoppingPoint": "code_changes"}, format="json" + self.url, + data={"agent": "seer", "stoppingPoint": "code_changes", "automationTuning": "medium"}, + format="json", ) assert response.status_code == 200 @@ -152,17 +178,50 @@ def test_put_scanner_automation(self) -> None: assert response.status_code == 200 assert response.data["scannerAutomation"] is False - def test_put_stopping_point_off(self) -> None: - """PUT stoppingPoint=off should disable automation.""" - self.project.update_option( - "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM - ) - self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + def test_put_stopping_point(self) -> None: + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" - response = self.client.put(self.url, data={"stoppingPoint": "off"}, format="json") + def test_put_stopping_point_open_pr_syncs_auto_create_pr(self) -> None: + """Setting stoppingPoint to open_pr should also set auto_create_pr to True.""" + response = self.client.put(self.url, data={"stoppingPoint": "open_pr"}, format="json") assert response.status_code == 200 - assert response.data["stoppingPoint"] == "off" + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is True + + def test_put_stopping_point_non_open_pr_clears_auto_create_pr(self) -> None: + """Setting stoppingPoint to non-open_pr should clear auto_create_pr.""" + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.put(self.url, data={"stoppingPoint": "code_changes"}, format="json") + + assert response.status_code == 200 + assert self.project.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert self.project.get_option("sentry:seer_automation_handoff_auto_create_pr") is False + + def test_put_automation_tuning(self) -> None: + """automationTuning accepts off and medium.""" + response = self.client.put(self.url, data={"automationTuning": "off"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.OFF + ) + + response = self.client.put(self.url, data={"automationTuning": "medium"}, format="json") + assert response.status_code == 200 + assert ( + self.project.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + + def test_put_automation_tuning_rejects_granular(self) -> None: + """Granular tuning values like 'high' should be rejected.""" + response = self.client.put(self.url, data={"automationTuning": "high"}, format="json") + assert response.status_code == 400 def test_put_requires_at_least_one_update_field(self) -> None: """Sending no update fields should return 400.""" @@ -270,6 +329,8 @@ def test_get_returns_defaults(self) -> None: "agent": "seer", "integrationId": None, "stoppingPoint": "off", + "autoCreatePr": None, + "automationTuning": "off", "scannerAutomation": True, "reposCount": 0, } @@ -286,11 +347,13 @@ def test_get_returns_configured_project_options(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "open_pr" + assert response.data[0]["autoCreatePr"] is None + assert response.data[0]["automationTuning"] == "medium" assert response.data[0]["scannerAutomation"] is False - def test_get_returns_external_agent_with_integration_id(self) -> None: - """A project configured with an external handoff target should return - the alias and integration ID.""" + def test_get_external_agent_with_integration_id(self) -> None: + """A project configured with an external handoff should return the agent, + integration ID, and autoCreatePr from the handoff config.""" self.project.update_option( "sentry:seer_automation_handoff_target", "cursor_background_agent" ) @@ -304,6 +367,23 @@ def test_get_returns_external_agent_with_integration_id(self) -> None: assert response.status_code == 200 assert response.data[0]["agent"] == "cursor_background_agent" assert response.data[0]["integrationId"] == "42" + assert response.data[0]["autoCreatePr"] is False + + def test_get_external_agent_with_auto_create_pr(self) -> None: + """autoCreatePr should reflect the handoff config value.""" + self.project.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + self.project.update_option( + "sentry:seer_automation_handoff_point", AutofixHandoffPoint.ROOT_CAUSE + ) + self.project.update_option("sentry:seer_automation_handoff_integration_id", 42) + self.project.update_option("sentry:seer_automation_handoff_auto_create_pr", True) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data[0]["autoCreatePr"] is True def test_get_stopping_point_off_when_tuning_off(self) -> None: """When tuning is OFF, stoppingPoint should be 'off' regardless of the @@ -317,6 +397,7 @@ def test_get_stopping_point_off_when_tuning_off(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "off" + assert response.data[0]["automationTuning"] == "off" def test_get_stopping_point_when_tuning_on(self) -> None: """When tuning is not OFF, stoppingPoint should reflect the stored value.""" @@ -329,6 +410,7 @@ def test_get_stopping_point_when_tuning_on(self) -> None: assert response.status_code == 200 assert response.data[0]["stoppingPoint"] == "root_cause" + assert response.data[0]["automationTuning"] == "medium" def test_get_repos_count(self) -> None: """reposCount should reflect the number of active SeerProjectRepository rows.""" @@ -635,41 +717,56 @@ def test_put_applies_to_filtered_projects_only(self) -> None: assert project2.get_option("sentry:seer_scanner_automation") is False assert self.project.get_option("sentry:seer_scanner_automation") is True - def test_put_requires_at_least_one_update_field(self) -> None: - """Sending only query with no update fields should return 400.""" - response = self.client.put(self.url, data={"query": ""}, format="json") - assert response.status_code == 400 + def test_put_updates_settings(self) -> None: + """Bulk update with multiple seer agent fields should apply all of them.""" + project2 = self.create_project(organization=self.organization) - def test_put_requires_integration_id_for_external_agent(self) -> None: - """External agent without integrationId should return 400.""" response = self.client.put( - self.url, data={"agent": "cursor_background_agent"}, format="json" + self.url, + data={ + "agent": "seer", + "stoppingPoint": "code_changes", + "automationTuning": "medium", + "scannerAutomation": False, + }, + format="json", ) - assert response.status_code == 400 - def test_put_rejects_invalid_agent(self) -> None: - """An unrecognized agent value should return 400.""" - response = self.client.put(self.url, data={"agent": "invalid"}, format="json") - assert response.status_code == 400 - - def test_put_rejects_invalid_stopping_point(self) -> None: - """An unrecognized stoppingPoint value should return 400.""" - response = self.client.put(self.url, data={"stoppingPoint": "invalid"}, format="json") - assert response.status_code == 400 + assert response.status_code == 204 + for p in (self.project, project2): + assert p.get_option("sentry:seer_automated_run_stopping_point") == "code_changes" + assert ( + p.get_option("sentry:autofix_automation_tuning") + == AutofixAutomationTuningSettings.MEDIUM + ) + assert p.get_option("sentry:seer_scanner_automation") is False - def test_put_rejects_integration_id_from_other_org(self) -> None: - """An integration ID that doesn't belong to this org should return 400.""" - other_org = self.create_organization() + def test_put_updates_settings_with_external_agent(self) -> None: + """Bulk update with external agent fields should set agent, integration, and stopping point.""" + project2 = self.create_project(organization=self.organization) integration = self.create_integration( - organization=other_org, external_id="other", provider="github" + organization=self.organization, external_id="ext", provider="github" ) response = self.client.put( self.url, - data={"agent": "cursor_background_agent", "integrationId": integration.id}, + data={ + "agent": "cursor_background_agent", + "integrationId": integration.id, + "stoppingPoint": "open_pr", + "scannerAutomation": True, + }, format="json", ) - assert response.status_code == 400 + + assert response.status_code == 204 + for p in (self.project, project2): + assert ( + p.get_option("sentry:seer_automation_handoff_target") == "cursor_background_agent" + ) + assert p.get_option("sentry:seer_automation_handoff_integration_id") == integration.id + assert p.get_option("sentry:seer_automated_run_stopping_point") == "open_pr" + assert p.get_option("sentry:seer_scanner_automation") is True def test_put_invalid_search_query_returns_400(self) -> None: """A malformed query value should return 400."""