Skip to content
12 changes: 11 additions & 1 deletion src/sentry/seer/autofix/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@
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
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,
Expand Down Expand Up @@ -832,6 +833,15 @@ def update_autofix(
"""
Issue an update to an autofix run. Intentionally matching the output of trigger_autofix.
"""
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):
return Response(
{"detail": "Code generation is disabled for this organization"}, status=403
)
except Organization.DoesNotExist:
return Response({"detail": "Organization not found"}, status=404)

data = AutofixUpdateRequest(organization_id=organization_id, run_id=run_id, payload=payload)
body = orjson.dumps(data)
Expand Down
13 changes: 12 additions & 1 deletion src/sentry/seer/autofix/autofix_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from typing import TYPE_CHECKING, Literal

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,
Expand Down Expand Up @@ -428,7 +430,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:
Expand Down Expand Up @@ -509,6 +515,11 @@ def trigger_push_changes(
state: SeerRunState | None = None,
repo_name: str | 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:
Expand Down
5 changes: 4 additions & 1 deletion src/sentry/seer/autofix/coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/seer/autofix/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import enum

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
Comment thread
geoffg-sentry marked this conversation as resolved.
Expand Down
16 changes: 14 additions & 2 deletions src/sentry/seer/endpoints/group_autofix_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
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.autofix.constants import CODING_PAYLOAD_TYPES
from sentry.seer.models import SeerApiError
from sentry.seer.signed_seer_api import (
make_signed_seer_api_request,
Expand All @@ -36,7 +37,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
Expand All @@ -46,6 +47,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(
Expand Down
15 changes: 14 additions & 1 deletion src/sentry/seer/endpoints/organization_seer_explorer_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
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.autofix.constants import CODING_PAYLOAD_TYPES
from sentry.seer.explorer.client_utils import (
explorer_connection_pool,
has_seer_explorer_access_with_detail,
Expand Down Expand Up @@ -43,9 +45,20 @@ 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 = request.data.get("payload", {})
payload_type = payload.get("type") if isinstance(payload, dict) else None
if payload_type in CODING_PAYLOAD_TYPES:
Comment thread
geoffg-sentry marked this conversation as resolved.
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(
Expand Down
7 changes: 7 additions & 0 deletions src/sentry/seer/explorer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -550,7 +551,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")
Comment thread
geoffg-sentry marked this conversation as resolved.

# Trigger PR creation
payload: dict[str, Any] = {"type": "create_pr"}
if repo_name:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,22 @@ def test_github_copilot_not_shown_without_feature_flag(self) -> None:
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."""

Expand Down
42 changes: 42 additions & 0 deletions tests/sentry/seer/autofix/test_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1519,6 +1519,48 @@ def test_update_autofix_success(self, mock_request):
assert response.status_code == 200
assert response.data == mock_response.json.return_value

@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)

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=payload,
)

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"):
Expand Down
24 changes: 24 additions & 0 deletions tests/sentry/seer/autofix/test_autofix_agent.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -774,6 +777,17 @@ def test_trigger_coding_agent_handoff_falls_back_when_relevant_repo_doesnt_match
},
)

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,
referrer=AutofixReferrer.UNKNOWN,
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")
Expand Down Expand Up @@ -875,6 +889,16 @@ def setUp(self):
super().setUp()
self.group = self.create_group(project=self.project)

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_push_changes(
group=self.group,
run_id=123,
referrer=AutofixReferrer.UNKNOWN,
)

@patch("sentry.seer.explorer.client.make_explorer_update_request")
def test_passes_correct_pr_description_suffix(self, mock_post):
"""push_changes is called with pr_description_suffix matching the group's qualified short id."""
Expand Down
17 changes: 17 additions & 0 deletions tests/sentry/seer/autofix/test_coding_agent.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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,
)
18 changes: 18 additions & 0 deletions tests/sentry/seer/endpoints/test_group_ai_autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,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")
Expand Down
37 changes: 37 additions & 0 deletions tests/sentry/seer/endpoints/test_group_autofix_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,40 @@ 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)

@patch("sentry.seer.endpoints.group_autofix_update.make_signed_seer_api_request")
def test_coding_payload_blocked_when_coding_disabled(self, mock_request: MagicMock) -> 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={
"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()
Loading
Loading