From 075ff57110a09b8ecb0770cca1b8f635d5de7851 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:05:13 -0400 Subject: [PATCH 01/10] fix(seer): prevent code generation setting bypasses --- src/sentry/seer/autofix/autofix.py | 13 ++++++ src/sentry/seer/autofix/autofix_agent.py | 8 +++- src/sentry/seer/autofix/coding_agent.py | 5 ++- .../seer/endpoints/group_autofix_update.py | 15 ++++++- .../organization_seer_explorer_update.py | 13 ++++++ src/sentry/seer/explorer/client.py | 7 +++ .../test_organization_coding_agents.py | 16 +++++++ tests/sentry/seer/autofix/test_autofix.py | 36 +++++++++++++++ .../sentry/seer/autofix/test_autofix_agent.py | 13 ++++++ .../sentry/seer/autofix/test_coding_agent.py | 17 +++++++ .../endpoints/test_group_autofix_update.py | 39 ++++++++++++++++ .../test_organization_seer_explorer_update.py | 45 +++++++++++++++++++ 12 files changed, 224 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 087dd2cf7f03e1..c0a26d9813ddfc 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -25,6 +25,7 @@ from sentry.issues.grouptype import WebVitalsGroup from sentry.models.commitauthor import CommitAuthor from sentry.models.group import Group +from sentry.models.organization import Organization from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import EventsResponse, SnubaParams @@ -822,6 +823,9 @@ def trigger_autofix( ) +CODING_UPDATE_PAYLOAD_TYPES = frozenset({"select_solution", "create_pr"}) + + def update_autofix( *, organization_id: int, @@ -831,6 +835,15 @@ def update_autofix( """ Issue an update to an autofix run. Intentionally matching the output of trigger_autofix. """ + if payload.get("type") in CODING_UPDATE_PAYLOAD_TYPES: + try: + org = Organization.objects.get(id=organization_id) + if not org.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + return Response( + {"detail": "Code generation is disabled for this organization"}, status=403 + ) + except Organization.DoesNotExist: + pass data = AutofixUpdateRequest(organization_id=organization_id, run_id=run_id, payload=payload) body = orjson.dumps(data) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index c7c6b2d27a09c8..e39f8f8ebe58da 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -7,7 +7,9 @@ from django.utils import timezone from pydantic import BaseModel +from rest_framework.exceptions import PermissionDenied +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.seer.autofix.artifact_schemas import ( ImpactAssessmentArtifact, RootCauseArtifact, @@ -422,7 +424,11 @@ def trigger_coding_agent_handoff( Returns: Dictionary with 'successes' and 'failures' lists """ - # Fetch project preferences for repos and auto_create_pr setting + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + auto_create_pr = False repo_definitions: list[SeerRepoDefinition] = [] try: diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index 76b68531da5b00..ad9e9fd3cca7db 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,7 +12,7 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features -from sentry.constants import ObjectStatus +from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus from sentry.integrations.claude_code.integration import ( ClaudeCodeIntegrationMetadata, ) @@ -428,6 +428,9 @@ def launch_coding_agents_for_run( except Organization.DoesNotExist: raise NotFound("Organization not found") + if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): + raise PermissionDenied("Code generation is disabled for this organization") + integration = None installation: CodingAgentIntegration | None = None client: CodingAgentClient | None = None diff --git a/src/sentry/seer/endpoints/group_autofix_update.py b/src/sentry/seer/endpoints/group_autofix_update.py index 09a815365de226..f6f32ddbde00fc 100644 --- a/src/sentry/seer/endpoints/group_autofix_update.py +++ b/src/sentry/seer/endpoints/group_autofix_update.py @@ -12,7 +12,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.helpers.deprecation import deprecated -from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.constants import CELL_API_DEPRECATION_DATE, ENABLE_SEER_CODING_DEFAULT from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group from sentry.seer.models import SeerApiError @@ -23,6 +23,8 @@ logger = logging.getLogger(__name__) +CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) + @cell_silo_endpoint class GroupAutofixUpdateEndpoint(GroupAiEndpoint): @@ -46,6 +48,17 @@ def post(self, request: Request, group: Group) -> Response: data={"error": "You must be authenticated to use this endpoint"}, ) + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/autofix/update" body = orjson.dumps( diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 873425d846f000..9cc80a7db14995 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -10,6 +10,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization from sentry.seer.explorer.client_utils import ( explorer_connection_pool, @@ -20,6 +21,8 @@ logger = logging.getLogger(__name__) +CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) + class OrganizationSeerExplorerUpdatePermission(OrganizationPermission): scope_map = { @@ -46,6 +49,16 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res if not request.data: return Response(status=400, data={"error": "Need a body with a payload"}) + payload_type = request.data.get("type") if isinstance(request.data, dict) else None + if payload_type in CODING_PAYLOAD_TYPES: + if not organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + return Response( + status=403, + data={"detail": "Code generation is disabled for this organization"}, + ) + path = "/v1/automation/explorer/update" body = orjson.dumps( diff --git a/src/sentry/seer/explorer/client.py b/src/sentry/seer/explorer/client.py index 62aea6c6371e95..6d83c1ad9e8687 100644 --- a/src/sentry/seer/explorer/client.py +++ b/src/sentry/seer/explorer/client.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from sentry import features, options +from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization from sentry.models.project import Project from sentry.seer.explorer.client_models import ExplorerRun, ExplorerRunWithPrs, SeerRunState @@ -548,7 +549,13 @@ def push_changes( Raises: TimeoutError: If polling exceeds timeout SeerApiError: If the Seer API request fails + SeerPermissionError: If code generation is disabled for the organization """ + if not self.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise SeerPermissionError("Code generation is disabled for this organization") + # Trigger PR creation payload: dict[str, Any] = {"type": "create_pr"} if repo_name: diff --git a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py index 1e3ccaea5fb5c9..c10898637e4d06 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_coding_agents.py @@ -429,6 +429,22 @@ def test_github_copilot_not_shown_without_feature_flag(self): assert len(integrations) == 0 +class OrganizationCodingAgentsPostCodingDisabledTest(BaseOrganizationCodingAgentsTest): + """Test that the endpoint returns 403 when code generation is disabled. + + The check lives in launch_coding_agents_for_run() which raises PermissionDenied. + """ + + def test_post_blocked_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + data = {"integration_id": str(self.integration.id), "run_id": 123} + response = self.get_error_response( + self.organization.slug, method="post", status_code=403, **data + ) + assert response.data["detail"] == "Code generation is disabled for this organization" + + class OrganizationCodingAgentsPostParameterValidationTest(BaseOrganizationCodingAgentsTest): """Test class for POST endpoint parameter validation.""" diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 5292fbcc704fe7..8b8b153c1c1cd3 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1519,6 +1519,42 @@ def test_update_autofix_success(self, mock_request): assert response.status_code == 200 assert response.data == mock_response.json.return_value + @pytest.mark.parametrize("payload_type", ["select_solution", "create_pr"]) + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request, payload_type): + from sentry.seer.autofix.autofix import update_autofix + + self.organization.update_option("sentry:enable_seer_coding", False) + + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload={"type": payload_type}, + ) + + assert response.status_code == 403 + assert response.data["detail"] == "Code generation is disabled for this organization" + mock_request.assert_not_called() + + @patch("sentry.seer.autofix.autofix.make_autofix_update_request") + def test_update_autofix_allows_select_root_cause_when_coding_disabled(self, mock_request): + from sentry.seer.autofix.autofix import update_autofix + + self.organization.update_option("sentry:enable_seer_coding", False) + mock_response = Mock() + mock_response.status = 200 + mock_response.json.return_value = {"run_id": self.run_id} + mock_request.return_value = mock_response + + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload={"type": "select_root_cause", "cause_id": 1}, + ) + + assert response.status_code == 200 + mock_request.assert_called_once() + class TestPreResolveStacktraceFrames(TestCase): def _make_serialized_event(self, frames, platform="python"): diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 1cb1507d07b5ea..b0034636238697 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -1,5 +1,8 @@ from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.seer.autofix.autofix_agent import ( AutofixStep, build_step_prompt, @@ -732,3 +735,13 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match "relevant_repo": "owner/nonexistent-repo", }, ) + + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_coding_agent_handoff( + group=self.group, + run_id=123, + integration_id=456, + ) diff --git a/tests/sentry/seer/autofix/test_coding_agent.py b/tests/sentry/seer/autofix/test_coding_agent.py index 9109c3a54bc795..e1349d46e162a1 100644 --- a/tests/sentry/seer/autofix/test_coding_agent.py +++ b/tests/sentry/seer/autofix/test_coding_agent.py @@ -1,6 +1,9 @@ from datetime import UTC, datetime from unittest.mock import MagicMock, patch +import pytest +from rest_framework.exceptions import PermissionDenied + from sentry.integrations.claude_code.utils import ClaudeSessionEvent from sentry.integrations.cursor.integration import CursorAgentIntegration from sentry.integrations.github_copilot.models import ( @@ -1096,3 +1099,17 @@ def test_caches_client_for_same_integration( mock_integration_service.get_integration.assert_called_once() assert mock_client.list_session_events.call_count == 2 + + +class TestLaunchCodingAgentsForRunCodingDisabled(TestCase): + def test_raises_permission_denied_when_coding_disabled(self): + from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run + + self.organization.update_option("sentry:enable_seer_coding", False) + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + launch_coding_agents_for_run( + organization_id=self.organization.id, + run_id=123, + integration_id=1, + ) diff --git a/tests/sentry/seer/endpoints/test_group_autofix_update.py b/tests/sentry/seer/endpoints/test_group_autofix_update.py index cbcc8bbf5ae13f..37c15bd7ff778b 100644 --- a/tests/sentry/seer/endpoints/test_group_autofix_update.py +++ b/tests/sentry/seer/endpoints/test_group_autofix_update.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import orjson +import pytest from rest_framework import status from sentry.testutils.cases import APITestCase @@ -90,3 +91,41 @@ def test_autofix_update_updates_last_triggered_field(self, mock_request): self.group.refresh_from_db() assert isinstance(self.group.seer_autofix_last_triggered, datetime) + + @pytest.mark.parametrize("payload_type", ["select_solution", "create_branch", "create_pr"]) + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled( + self, mock_request: MagicMock, payload_type: str + ) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": payload_type}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + mock_request.assert_not_called() + + @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") + def test_select_root_cause_allowed_when_coding_disabled(self, mock_request: MagicMock) -> None: + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 202 + mock_request.return_value.json.return_value = {} + + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": "select_root_cause", "cause_id": 1}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once() diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py index a7d0f3206f14aa..f0f986794887ea 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py @@ -140,3 +140,48 @@ def test_explorer_update_feature_flag_disabled(self, mock_has_access: MagicMock) assert response.status_code == status.HTTP_403_FORBIDDEN assert "Feature flag not enabled" in str(response.data) + + +@with_feature("organizations:seer-explorer") +@with_feature("organizations:gen-ai-features") +class TestOrganizationSeerExplorerUpdateCodingDisabled(APITestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.organization = self.create_organization(owner=self.user) + self.organization.flags.allow_joinleave = True + self.organization.save() + self.url = f"/api/0/organizations/{self.organization.slug}/seer/explorer-update/123/" + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_coding_payload_blocked_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post(self.url, data={"type": payload_type}, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" + + mock_request.assert_not_called() + + @patch( + "sentry.seer.endpoints.organization_seer_explorer_update.has_seer_explorer_access_with_detail" + ) + @patch("sentry.seer.endpoints.organization_seer_explorer_update.make_signed_seer_api_request") + def test_non_coding_payload_allowed_when_coding_disabled( + self, mock_request: MagicMock, mock_has_access: MagicMock + ) -> None: + mock_has_access.return_value = (True, None) + self.organization.update_option("sentry:enable_seer_coding", False) + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {} + + response = self.client.post(self.url, data={"type": "interrupt"}, format="json") + assert response.status_code == status.HTTP_202_ACCEPTED + mock_request.assert_called_once() From 57e41168ecd6e6a730f6cd34542bbffe8f48802b Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:31:10 -0400 Subject: [PATCH 02/10] skip use\ of unittest.TestCase --- tests/sentry/seer/autofix/test_autofix.py | 19 +++++++------ .../endpoints/test_group_autofix_update.py | 28 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 8b8b153c1c1cd3..001dbd40cbaecd 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1519,21 +1519,22 @@ def test_update_autofix_success(self, mock_request): assert response.status_code == 200 assert response.data == mock_response.json.return_value - @pytest.mark.parametrize("payload_type", ["select_solution", "create_pr"]) @patch("sentry.seer.autofix.autofix.make_autofix_update_request") - def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request, payload_type): + def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request): from sentry.seer.autofix.autofix import update_autofix self.organization.update_option("sentry:enable_seer_coding", False) - response = update_autofix( - organization_id=self.organization.id, - run_id=self.run_id, - payload={"type": payload_type}, - ) + for payload_type in ("select_solution", "create_pr"): + response = update_autofix( + organization_id=self.organization.id, + run_id=self.run_id, + payload={"type": payload_type}, + ) + + assert response.status_code == 403 + assert response.data["detail"] == "Code generation is disabled for this organization" - assert response.status_code == 403 - assert response.data["detail"] == "Code generation is disabled for this organization" mock_request.assert_not_called() @patch("sentry.seer.autofix.autofix.make_autofix_update_request") diff --git a/tests/sentry/seer/endpoints/test_group_autofix_update.py b/tests/sentry/seer/endpoints/test_group_autofix_update.py index 37c15bd7ff778b..2f20a9346240c3 100644 --- a/tests/sentry/seer/endpoints/test_group_autofix_update.py +++ b/tests/sentry/seer/endpoints/test_group_autofix_update.py @@ -2,7 +2,6 @@ from unittest.mock import MagicMock, patch import orjson -import pytest from rest_framework import status from sentry.testutils.cases import APITestCase @@ -92,24 +91,23 @@ def test_autofix_update_updates_last_triggered_field(self, mock_request): self.group.refresh_from_db() assert isinstance(self.group.seer_autofix_last_triggered, datetime) - @pytest.mark.parametrize("payload_type", ["select_solution", "create_branch", "create_pr"]) @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") - def test_coding_payload_blocked_when_coding_disabled( - self, mock_request: MagicMock, payload_type: str - ) -> None: + def test_coding_payload_blocked_when_coding_disabled(self, mock_request: MagicMock) -> None: self.organization.update_option("sentry:enable_seer_coding", False) - response = self.client.post( - self.url, - data={ - "run_id": 123, - "payload": {"type": payload_type}, - }, - format="json", - ) + for payload_type in ("select_solution", "create_branch", "create_pr"): + response = self.client.post( + self.url, + data={ + "run_id": 123, + "payload": {"type": payload_type}, + }, + format="json", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.data["detail"] == "Code generation is disabled for this organization" - assert response.status_code == status.HTTP_403_FORBIDDEN - assert response.data["detail"] == "Code generation is disabled for this organization" mock_request.assert_not_called() @patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request") From e400d242454e7af40e7376985f23b44b8e58d68f Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:37:46 -0400 Subject: [PATCH 03/10] warden fix --- src/sentry/seer/autofix/autofix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index c0a26d9813ddfc..5cdcdf141c060d 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -843,7 +843,7 @@ def update_autofix( {"detail": "Code generation is disabled for this organization"}, status=403 ) except Organization.DoesNotExist: - pass + return Response({"detail": "Organization not found"}, status=404) data = AutofixUpdateRequest(organization_id=organization_id, run_id=run_id, payload=payload) body = orjson.dumps(data) From e82861a4b0bd501b4d99b39e03bab2189b002251 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:39:45 -0400 Subject: [PATCH 04/10] guard request.data unpack --- src/sentry/seer/endpoints/group_autofix_update.py | 2 +- .../seer/endpoints/organization_seer_explorer_update.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/endpoints/group_autofix_update.py b/src/sentry/seer/endpoints/group_autofix_update.py index f6f32ddbde00fc..a5d5f5510983fe 100644 --- a/src/sentry/seer/endpoints/group_autofix_update.py +++ b/src/sentry/seer/endpoints/group_autofix_update.py @@ -38,7 +38,7 @@ def post(self, request: Request, group: Group) -> Response: """ Send an update event to autofix for a given group. """ - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a run_id and payload"}) user = request.user diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index 9cc80a7db14995..fb53a8c3be22f1 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -46,10 +46,10 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res if not has_access: return Response({"detail": error}, status=403) - if not request.data: + if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a payload"}) - payload_type = request.data.get("type") if isinstance(request.data, dict) else None + payload_type = request.data.get("type") if payload_type in CODING_PAYLOAD_TYPES: if not organization.get_option( "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT From daa6e5bceae949d0d723fab71ebbf4c665839f1a Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:46:50 -0400 Subject: [PATCH 05/10] typing --- tests/sentry/seer/autofix/test_autofix.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/sentry/seer/autofix/test_autofix.py b/tests/sentry/seer/autofix/test_autofix.py index 001dbd40cbaecd..70060d8aec8b1d 100644 --- a/tests/sentry/seer/autofix/test_autofix.py +++ b/tests/sentry/seer/autofix/test_autofix.py @@ -1522,14 +1522,19 @@ def test_update_autofix_success(self, mock_request): @patch("sentry.seer.autofix.autofix.make_autofix_update_request") def test_update_autofix_blocks_coding_payloads_when_disabled(self, mock_request): from sentry.seer.autofix.autofix import update_autofix + from sentry.seer.autofix.types import AutofixCreatePRPayload, AutofixSelectSolutionPayload self.organization.update_option("sentry:enable_seer_coding", False) - for payload_type in ("select_solution", "create_pr"): + payloads: list[AutofixSelectSolutionPayload | AutofixCreatePRPayload] = [ + AutofixSelectSolutionPayload(type="select_solution"), + AutofixCreatePRPayload(type="create_pr"), + ] + for payload in payloads: response = update_autofix( organization_id=self.organization.id, run_id=self.run_id, - payload={"type": payload_type}, + payload=payload, ) assert response.status_code == 403 From 9b4a3fc8d5978a8328f9809d8bc811304002ca35 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:06:43 -0400 Subject: [PATCH 06/10] forgotten create_branch --- src/sentry/seer/autofix/autofix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 5cdcdf141c060d..6c80c52018f5ed 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -823,7 +823,7 @@ def trigger_autofix( ) -CODING_UPDATE_PAYLOAD_TYPES = frozenset({"select_solution", "create_pr"}) +CODING_UPDATE_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) def update_autofix( From cae6db1b8d13cc71fbaddb154337f4c66788cdc6 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:18:48 -0400 Subject: [PATCH 07/10] fix payload type and constants --- src/sentry/seer/autofix/autofix.py | 7 ++----- src/sentry/seer/autofix/constants.py | 2 ++ src/sentry/seer/endpoints/group_autofix_update.py | 3 +-- .../seer/endpoints/organization_seer_explorer_update.py | 6 +++--- .../endpoints/test_organization_seer_explorer_update.py | 8 ++++++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/sentry/seer/autofix/autofix.py b/src/sentry/seer/autofix/autofix.py index 6c80c52018f5ed..872fc9ab56c788 100644 --- a/src/sentry/seer/autofix/autofix.py +++ b/src/sentry/seer/autofix/autofix.py @@ -29,7 +29,7 @@ from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import EventsResponse, SnubaParams -from sentry.seer.autofix.constants import AutofixReferrer +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES, AutofixReferrer from sentry.seer.autofix.types import ( AutofixCreatePRPayload, AutofixSelectRootCausePayload, @@ -823,9 +823,6 @@ def trigger_autofix( ) -CODING_UPDATE_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) - - def update_autofix( *, organization_id: int, @@ -835,7 +832,7 @@ def update_autofix( """ Issue an update to an autofix run. Intentionally matching the output of trigger_autofix. """ - if payload.get("type") in CODING_UPDATE_PAYLOAD_TYPES: + if payload.get("type") in CODING_PAYLOAD_TYPES: try: org = Organization.objects.get(id=organization_id) if not org.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): diff --git a/src/sentry/seer/autofix/constants.py b/src/sentry/seer/autofix/constants.py index b0b07b871c19ed..e54ab1903b0d5b 100644 --- a/src/sentry/seer/autofix/constants.py +++ b/src/sentry/seer/autofix/constants.py @@ -1,5 +1,7 @@ import enum +CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) + class FixabilityScoreThresholds(enum.Enum): SUPER_HIGH = 0.76 diff --git a/src/sentry/seer/endpoints/group_autofix_update.py b/src/sentry/seer/endpoints/group_autofix_update.py index a5d5f5510983fe..a32535a492a6f3 100644 --- a/src/sentry/seer/endpoints/group_autofix_update.py +++ b/src/sentry/seer/endpoints/group_autofix_update.py @@ -15,6 +15,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE, ENABLE_SEER_CODING_DEFAULT from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, @@ -23,8 +24,6 @@ logger = logging.getLogger(__name__) -CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) - @cell_silo_endpoint class GroupAutofixUpdateEndpoint(GroupAiEndpoint): diff --git a/src/sentry/seer/endpoints/organization_seer_explorer_update.py b/src/sentry/seer/endpoints/organization_seer_explorer_update.py index fb53a8c3be22f1..722fedc69ac669 100644 --- a/src/sentry/seer/endpoints/organization_seer_explorer_update.py +++ b/src/sentry/seer/endpoints/organization_seer_explorer_update.py @@ -12,6 +12,7 @@ from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.models.organization import Organization +from sentry.seer.autofix.constants import CODING_PAYLOAD_TYPES from sentry.seer.explorer.client_utils import ( explorer_connection_pool, has_seer_explorer_access_with_detail, @@ -21,8 +22,6 @@ logger = logging.getLogger(__name__) -CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) - class OrganizationSeerExplorerUpdatePermission(OrganizationPermission): scope_map = { @@ -49,7 +48,8 @@ def post(self, request: Request, organization: Organization, run_id: int) -> Res if not request.data or not isinstance(request.data, dict): return Response(status=400, data={"error": "Need a body with a payload"}) - payload_type = request.data.get("type") + payload = request.data.get("payload", {}) + payload_type = payload.get("type") if isinstance(payload, dict) else None if payload_type in CODING_PAYLOAD_TYPES: if not organization.get_option( "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT diff --git a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py index f0f986794887ea..796ac9d598d12d 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_explorer_update.py @@ -164,7 +164,9 @@ def test_coding_payload_blocked_when_coding_disabled( self.organization.update_option("sentry:enable_seer_coding", False) for payload_type in ("select_solution", "create_branch", "create_pr"): - response = self.client.post(self.url, data={"type": payload_type}, format="json") + response = self.client.post( + self.url, data={"payload": {"type": payload_type}}, format="json" + ) assert response.status_code == status.HTTP_403_FORBIDDEN assert response.data["detail"] == "Code generation is disabled for this organization" @@ -182,6 +184,8 @@ def test_non_coding_payload_allowed_when_coding_disabled( mock_request.return_value.status = 200 mock_request.return_value.json.return_value = {} - response = self.client.post(self.url, data={"type": "interrupt"}, format="json") + response = self.client.post( + self.url, data={"payload": {"type": "interrupt"}}, format="json" + ) assert response.status_code == status.HTTP_202_ACCEPTED mock_request.assert_called_once() From d6c036323917a391d05cd4f1f9ab2f6fa609e874 Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:01:45 -0400 Subject: [PATCH 08/10] conflicts --- src/sentry/seer/autofix/constants.py | 4 + .../sentry/seer/autofix/test_autofix_agent.py | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/sentry/seer/autofix/constants.py b/src/sentry/seer/autofix/constants.py index e54ab1903b0d5b..28c08290afcfaf 100644 --- a/src/sentry/seer/autofix/constants.py +++ b/src/sentry/seer/autofix/constants.py @@ -2,6 +2,10 @@ CODING_PAYLOAD_TYPES = frozenset({"select_solution", "create_branch", "create_pr"}) +# An issue group must have >= this number of occurrences in order to be +# a target for 'workflow' autofix. +AUTOFIX_AUTOMATION_OCCURRENCE_THRESHOLD = 10 + class FixabilityScoreThresholds(enum.Enum): SUPER_HIGH = 0.76 diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index b0034636238697..217de1a291216d 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -745,3 +745,96 @@ def test_raises_permission_denied_when_coding_disabled(self): run_id=123, integration_id=456, ) + + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") + @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") + @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") + def test_trigger_coding_agent_handoff_enriches_branch_name_from_autofix_state( + self, mock_client_class, mock_get_prefs, mock_get_autofix_state + ): + """Test that branch_name is resolved from autofix state when unset in preferences.""" + from datetime import datetime, timezone + + from sentry.seer.autofix.constants import AutofixStatus + from sentry.seer.autofix.utils import AutofixRequest, AutofixState + from sentry.seer.models import SeerRepoDefinition + + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.get_run.return_value = self._make_run_state() + mock_client.launch_coding_agents.return_value = {"successes": [], "failures": []} + mock_get_prefs.return_value = self._make_preference_response( + repos=[ + SeerRepoDefinition(provider="github", owner="owner", name="repo", external_id="1") + ] + ) + mock_get_autofix_state.return_value = AutofixState( + run_id=123, + request=AutofixRequest( + organization_id=self.organization.id, + project_id=self.project.id, + issue={ + "id": 1, + "title": "Bug", + "short_id": "PROJ-1", + "first_seen": "2024-01-01T00:00:00Z", + }, + repos=[ + SeerRepoDefinition( + provider="github", + owner="owner", + name="repo", + external_id="1", + branch_name="main", + ) + ], + ), + updated_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + status=AutofixStatus.COMPLETED, + ) + + trigger_coding_agent_handoff( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + integration_id=456, + ) + + repos = mock_client.launch_coding_agents.call_args.kwargs["repos"] + assert repos[0].branch_name == "main" + + @patch("sentry.seer.autofix.autofix_agent.get_autofix_state") + @patch("sentry.seer.autofix.autofix_agent.get_project_seer_preferences") + @patch("sentry.seer.autofix.autofix_agent.SeerExplorerClient") + def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_set( + self, mock_client_class, mock_get_prefs, mock_get_autofix_state + ): + """Test that branch_name from preferences is used as-is when already set.""" + from sentry.seer.models import SeerRepoDefinition + + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_client.get_run.return_value = self._make_run_state() + mock_client.launch_coding_agents.return_value = {"successes": [], "failures": []} + mock_get_prefs.return_value = self._make_preference_response( + repos=[ + SeerRepoDefinition( + provider="github", + owner="owner", + name="repo", + external_id="1", + branch_name="release/v2", + ) + ] + ) + + trigger_coding_agent_handoff( + group=self.group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + integration_id=456, + ) + + mock_get_autofix_state.assert_not_called() + repos = mock_client.launch_coding_agents.call_args.kwargs["repos"] + assert repos[0].branch_name == "release/v2" From 8334244dbde10a3056bb9ff9226b63c7c2c9bece Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:38:19 -0400 Subject: [PATCH 09/10] http 403 and typing --- src/sentry/seer/endpoints/group_ai_autofix.py | 4 ++-- tests/sentry/seer/autofix/test_autofix_agent.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index f4d32cca2525e0..d6ebda6eeb9d87 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -269,8 +269,8 @@ def _post_explorer(self, request: Request, group: Group) -> Response: run_id, referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, ) - except SeerPermissionError: - return Response(status=status.HTTP_404_NOT_FOUND) + except SeerPermissionError as e: + raise PermissionDenied(str(e)) from e return Response({"run_id": run_id}, status=status.HTTP_202_ACCEPTED) # Handle all built-in Seer steps diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 01a4bb4d0b95ef..00e10e5a428d51 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -807,6 +807,7 @@ def test_raises_permission_denied_when_coding_disabled(self): trigger_coding_agent_handoff( group=self.group, run_id=123, + referrer=AutofixReferrer.UNKNOWN, integration_id=456, ) From 387b9ed1813e08edd1ea8ca5d602424e39f6e01f Mon Sep 17 00:00:00 2001 From: geoffg-sentry <165922362+geoffg-sentry@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:11:56 -0400 Subject: [PATCH 10/10] test fixes --- src/sentry/seer/autofix/autofix_agent.py | 5 +++++ src/sentry/seer/endpoints/group_ai_autofix.py | 4 ++-- .../sentry/seer/autofix/test_autofix_agent.py | 16 ++++++++++++++++ .../seer/endpoints/test_group_ai_autofix.py | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/autofix/autofix_agent.py b/src/sentry/seer/autofix/autofix_agent.py index bac9894e1b9534..79615bbc7f3c5a 100644 --- a/src/sentry/seer/autofix/autofix_agent.py +++ b/src/sentry/seer/autofix/autofix_agent.py @@ -517,6 +517,11 @@ def trigger_push_changes( referrer: AutofixReferrer, state: SeerRunState | None = None, ): + if not group.organization.get_option( + "sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT + ): + raise PermissionDenied("Code generation is disabled for this organization") + client = get_autofix_explorer_client(group) if state is None: diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index d6ebda6eeb9d87..f4d32cca2525e0 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -269,8 +269,8 @@ def _post_explorer(self, request: Request, group: Group) -> Response: run_id, referrer=AutofixReferrer.GROUP_AUTOFIX_ENDPOINT, ) - except SeerPermissionError as e: - raise PermissionDenied(str(e)) from e + except SeerPermissionError: + return Response(status=status.HTTP_404_NOT_FOUND) return Response({"run_id": run_id}, status=status.HTTP_202_ACCEPTED) # Handle all built-in Seer steps diff --git a/tests/sentry/seer/autofix/test_autofix_agent.py b/tests/sentry/seer/autofix/test_autofix_agent.py index 00e10e5a428d51..51031797482f87 100644 --- a/tests/sentry/seer/autofix/test_autofix_agent.py +++ b/tests/sentry/seer/autofix/test_autofix_agent.py @@ -9,6 +9,7 @@ generate_autofix_handoff_prompt, trigger_autofix_explorer, trigger_coding_agent_handoff, + trigger_push_changes, ) from sentry.seer.autofix.constants import AutofixReferrer from sentry.seer.explorer.client_models import ( @@ -903,3 +904,18 @@ def test_trigger_coding_agent_handoff_keeps_branch_name_from_preferences_when_se mock_get_autofix_state.assert_not_called() repos = mock_client.launch_coding_agents.call_args.kwargs["repos"] assert repos[0].branch_name == "release/v2" + + +class TestTriggerPushChanges(TestCase): + """Tests for trigger_push_changes function.""" + + def test_raises_permission_denied_when_coding_disabled(self): + self.organization.update_option("sentry:enable_seer_coding", False) + group = self.create_group() + + with pytest.raises(PermissionDenied, match="Code generation is disabled"): + trigger_push_changes( + group=group, + run_id=123, + referrer=AutofixReferrer.UNKNOWN, + ) diff --git a/tests/sentry/seer/endpoints/test_group_ai_autofix.py b/tests/sentry/seer/endpoints/test_group_ai_autofix.py index 0985e684ecf766..8911c1da794fec 100644 --- a/tests/sentry/seer/endpoints/test_group_ai_autofix.py +++ b/tests/sentry/seer/endpoints/test_group_ai_autofix.py @@ -1078,6 +1078,24 @@ def test_open_pr_permission_error(self, mock_explorer_state_request): assert response.status_code == 404, f"Failed for {flag}: {response.data}" + def test_open_pr_coding_disabled(self): + self.login_as(user=self.user) + group = self.create_group() + self.organization.update_option("sentry:enable_seer_coding", False) + + for flag in EXPLORER_FLAGS: + with self.feature(flag): + response = self.client.post( + self._get_url(group.id, mode="explorer"), + data={ + "step": "open_pr", + "run_id": 123, + }, + format="json", + ) + + assert response.status_code == 403, f"Failed for {flag}: {response.data}" + @with_feature("organizations:gen-ai-features") @with_feature("organizations:seer-explorer")