diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index b4c2c8944b3954..638b9a47d9bf2f 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -554,6 +554,7 @@ from sentry.seer.endpoints.organization_seer_workflows import OrganizationSeerWorkflowsEndpoint from sentry.seer.endpoints.project_seer_night_shift import ProjectSeerNightShiftEndpoint from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint +from sentry.seer.endpoints.project_seer_settings import ProjectSeerSettingsEndpoint from sentry.seer.endpoints.search_agent_start import SearchAgentStartEndpoint from sentry.seer.endpoints.search_agent_state import SearchAgentStateEndpoint from sentry.seer.endpoints.seer_rpc import SeerRpcServiceEndpoint @@ -3372,6 +3373,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: name="sentry-api-0-project-tempest-credentials-details", ), # Seer + re_path( + r"^(?P[^/]+)/(?P[^/]+)/seer/settings/$", + ProjectSeerSettingsEndpoint.as_view(), + name="sentry-api-0-project-seer-settings", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/seer/preferences/$", ProjectSeerPreferencesEndpoint.as_view(), diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index a5c0cd0cfce0b4..637634d96b4c71 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -208,6 +208,7 @@ "sentry:seer_automation_handoff_integration_id", "sentry:seer_automation_handoff_auto_create_pr", "sentry:autofix_automation_tuning", + "sentry:seer_scanner_automation", ] # Boolean to enable/disable preprod size analysis for this project. diff --git a/src/sentry/seer/endpoints/project_seer_settings.py b/src/sentry/seer/endpoints/project_seer_settings.py new file mode 100644 index 00000000000000..a499a57cfdd4cb --- /dev/null +++ b/src/sentry/seer/endpoints/project_seer_settings.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from typing import TypedDict + +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import audit_log +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import cell_silo_endpoint +from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission +from sentry.constants import ObjectStatus +from sentry.integrations.services.integration import integration_service +from sentry.models.project import Project +from sentry.seer.autofix.constants import AutofixAutomationTuningSettings +from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + AutomationCodingAgent, + build_automation_handoff, + get_valid_automated_run_stopping_points, + update_seer_project_settings, +) +from sentry.seer.models.project_repository import SeerProjectRepository +from sentry.seer.models.seer_api_models import SeerAutomationHandoffConfiguration + + +class SeerProjectSettings(TypedDict): + automation_tuning: str + handoff: SeerAutomationHandoffConfiguration | None + repos_count: int + scanner_automation: bool + stopping_point: str + + +class SeerProjectSettingsResponse(TypedDict): + projectId: str + projectSlug: str + agent: str + integrationId: str | None + stoppingPoint: str + scannerAutomation: bool + reposCount: int + + +def _get_project_settings(project: Project) -> SeerProjectSettings: + return SeerProjectSettings( + automation_tuning=project.get_option("sentry:autofix_automation_tuning"), + scanner_automation=project.get_option("sentry:seer_scanner_automation"), + stopping_point=project.get_option("sentry:seer_automated_run_stopping_point"), + handoff=build_automation_handoff(project.get_option), + repos_count=SeerProjectRepository.objects.filter( + project=project, repository__status=ObjectStatus.ACTIVE + ).count(), + ) + + +def _serialize(project: Project, settings: SeerProjectSettings) -> SeerProjectSettingsResponse: + # Automation tuning is a high-level toggle (OFF / LOW / MEDIUM / HIGH) that + # controls whether Seer runs automatically at all. When it's OFF, report + # stopping point as "off" regardless of the stored value so the UI reports + # disabled automation instead of an active stopping point. + stopping_point = ( + "off" + if settings["automation_tuning"] == AutofixAutomationTuningSettings.OFF + else settings["stopping_point"] + ) + + handoff = settings["handoff"] + if handoff is None: + # No configured external handoff means use Seer agent. + agent: str = "seer" + integration_id: str | None = None + else: + agent = handoff.target + integration_id = str(handoff.integration_id) + + return SeerProjectSettingsResponse( + projectId=str(project.id), + projectSlug=project.slug, + agent=agent, + integrationId=integration_id, + stoppingPoint=stopping_point, + scannerAutomation=settings["scanner_automation"], + reposCount=settings["repos_count"], + ) + + +def serialize_project(project: Project) -> SeerProjectSettingsResponse: + return _serialize(project, _get_project_settings(project)) + + +class ProjectSettingsUpdateSerializer(serializers.Serializer): + 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 + + organization = self.context["organization"] + if value not in get_valid_automated_run_stopping_points(organization): + raise serializers.ValidationError(f'"{value}" is not a valid choice.') + return value + + def validate_integrationId(self, value: int) -> int: + organization = self.context["organization"] + org_integrations = integration_service.get_organization_integrations( + organization_id=organization.id, integration_id=value + ) + if not org_integrations: + raise serializers.ValidationError(f"{value} is not a valid integration.") + return value + + def validate(self, data): + if "agent" in data and data["agent"] != "seer" and "integrationId" not in data: + raise serializers.ValidationError( + {"integrationId": "Required when agent is an external coding agent."} + ) + + has_update = any(k in data for k in ("agent", "stoppingPoint", "scannerAutomation")) + if not has_update: + raise serializers.ValidationError("At least one update field must be provided.") + + return data + + +@cell_silo_endpoint +class ProjectSeerSettingsEndpoint(ProjectEndpoint): + owner = ApiOwner.ML_AI + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (ProjectEventPermission,) + + def get(self, request: Request, project: Project) -> Response: + return Response(serialize_project(project)) + + def put(self, request: Request, project: Project) -> Response: + serializer = ProjectSettingsUpdateSerializer( + data=request.data, context={"organization": project.organization} + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + update_seer_project_settings(project, serializer.validated_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}, + ) + + return Response(serialize_project(project)) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 9158d3bc68fd84..7d437f26e1fa71 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -723,6 +723,7 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/preview/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/night-shift/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/preferences/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/settings/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-coverage/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-link/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-source-context/' diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py new file mode 100644 index 00000000000000..135d00443c19ac --- /dev/null +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -0,0 +1,195 @@ +from django.urls import reverse + +from sentry.constants import ObjectStatus +from sentry.seer.autofix.constants import AutofixAutomationTuningSettings +from sentry.seer.models import AutofixHandoffPoint +from sentry.seer.models.project_repository import SeerProjectRepository +from sentry.testutils.cases import APITestCase + + +class ProjectSeerSettingsEndpointTest(APITestCase): + endpoint = "sentry-api-0-project-seer-settings" + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.project = self.create_project(organization=self.organization) + self.url = reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + + def test_get_returns_defaults(self) -> None: + """A project with no options set should return defaults.""" + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data == { + "projectId": str(self.project.id), + "projectSlug": self.project.slug, + "agent": "seer", + "integrationId": None, + "stoppingPoint": "off", + "scannerAutomation": True, + "reposCount": 0, + } + + def test_get_returns_configured_project_options(self) -> None: + """A project with explicit options should reflect them in the response.""" + 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_scanner_automation", False) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["stoppingPoint"] == "open_pr" + 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.""" + 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) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["agent"] == "cursor_background_agent" + assert response.data["integrationId"] == "42" + + def test_get_stopping_point_off_when_tuning_off(self) -> None: + """stoppingPoint should be 'off' when tuning is OFF.""" + self.project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF + ) + self.project.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["stoppingPoint"] == "off" + + def test_get_stopping_point_when_tuning_on(self) -> None: + """When tuning is not OFF, stoppingPoint should reflect the stored value.""" + self.project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + ) + self.project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["stoppingPoint"] == "root_cause" + + def test_get_repos_count(self) -> None: + """reposCount should reflect active SeerProjectRepository rows.""" + repo1 = self.create_repo(project=self.project, name="owner/repo-1") + repo2 = self.create_repo(project=self.project, name="owner/repo-2") + SeerProjectRepository.objects.create(project=self.project, repository=repo1) + SeerProjectRepository.objects.create(project=self.project, repository=repo2) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["reposCount"] == 2 + + def test_get_repos_count_excludes_inactive_repos(self) -> None: + """Repos with non-active status should not be counted.""" + active_repo = self.create_repo(project=self.project, name="owner/active") + disabled_repo = self.create_repo(project=self.project, name="owner/deleted") + disabled_repo.status = ObjectStatus.DISABLED + disabled_repo.save() + SeerProjectRepository.objects.create(project=self.project, repository=active_repo) + SeerProjectRepository.objects.create(project=self.project, repository=disabled_repo) + + response = self.client.get(self.url) + + assert response.status_code == 200 + assert response.data["reposCount"] == 1 + + 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" + ) + + assert response.status_code == 200 + assert response.data["projectId"] == str(self.project.id) + assert response.data["projectSlug"] == self.project.slug + assert response.data["agent"] == "seer" + assert response.data["stoppingPoint"] == "code_changes" + assert "scannerAutomation" in response.data + assert "reposCount" in response.data + + def test_put_requires_at_least_one_update_field(self) -> None: + """Sending no update fields should return 400.""" + response = self.client.put(self.url, data={}, format="json") + assert response.status_code == 400 + + 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" + ) + assert response.status_code == 400 + + 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() + integration = self.create_integration( + organization=other_org, external_id="other", provider="github" + ) + + response = self.client.put( + self.url, + data={"agent": "cursor_background_agent", "integrationId": integration.id}, + format="json", + ) + assert response.status_code == 400 + + def test_put_seer_agent_does_not_require_integration_id(self) -> None: + """agent=seer should not require integrationId.""" + response = self.client.put(self.url, data={"agent": "seer"}, format="json") + assert response.status_code == 200 + + 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 + + def test_put_creates_audit_log_entry(self) -> None: + """PUT should create an audit log entry with the project ID.""" + from sentry.models.auditlogentry import AuditLogEntry + from sentry.silo.base import SiloMode + from sentry.testutils.outbox import outbox_runner + from sentry.testutils.silo import assume_test_silo_mode + + with outbox_runner(): + self.client.put( + self.url, + data={"scannerAutomation": False}, + format="json", + ) + + with assume_test_silo_mode(SiloMode.CONTROL): + entry = AuditLogEntry.objects.filter( + organization_id=self.organization.id, + ).first() + + assert entry is not None + assert entry.data["project_id"] == self.project.id