Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
150 changes: 150 additions & 0 deletions src/sentry/seer/endpoints/project_seer_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from __future__ import annotations

from typing import Any, 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.projectoptions.defaults import SEER_PROJECT_PREFERENCE_OPTION_KEYS
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


class SeerProjectSettingsResponse(TypedDict):
projectId: str
projectSlug: str
agent: str
integrationId: str | None
stoppingPoint: str
scannerAutomation: bool
reposCount: int


def _serialize_seer_project_settings(
project: Project, attrs: dict[str, Any]
) -> SeerProjectSettingsResponse:
# Only use the real stopping point if tuning is on.
tuning = attrs["sentry:autofix_automation_tuning"]
stopping_point = (
"off"
if tuning == AutofixAutomationTuningSettings.OFF
else attrs["sentry:seer_automated_run_stopping_point"]
)
Comment thread
srest2021 marked this conversation as resolved.
Outdated

# No configured external handoff means use Seer agent.
handoff = build_automation_handoff(attrs.get)
if handoff is None:
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=attrs["sentry:seer_scanner_automation"],
reposCount=attrs["repos_count"],
)


def _get_attrs_for_project(project: Project) -> dict[str, Any]:
Comment thread
srest2021 marked this conversation as resolved.
Outdated
attrs: dict[str, Any] = {}
Comment thread
srest2021 marked this conversation as resolved.
Outdated

for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS:
attrs[key] = project.get_option(key)

attrs["repos_count"] = SeerProjectRepository.objects.filter(
project=project, repository__status=ObjectStatus.ACTIVE
).count()

return attrs


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:
attrs = _get_attrs_for_project(project)
return Response(_serialize_seer_project_settings(project, attrs))

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_seer_project_settings(project, _get_attrs_for_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
181 changes: 181 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,181 @@
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_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