Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/seer/settings/$",
ProjectSeerSettingsEndpoint.as_view(),
name="sentry-api-0-project-seer-settings",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/(?P<project_id_or_slug>[^/]+)/seer/preferences/$",
ProjectSeerPreferencesEndpoint.as_view(),
Expand Down
1 change: 1 addition & 0 deletions src/sentry/projectoptions/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
160 changes: 160 additions & 0 deletions src/sentry/seer/endpoints/project_seer_settings.py
Original file line number Diff line number Diff line change
@@ -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,)
Comment thread
srest2021 marked this conversation as resolved.
Comment thread
srest2021 marked this conversation as resolved.

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))
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down
195 changes: 195 additions & 0 deletions tests/sentry/seer/endpoints/test_project_seer_settings.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
srest2021 marked this conversation as resolved.
"""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
Loading