-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(seer): Add single-project Seer settings endpoint #115230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
srest2021
wants to merge
5
commits into
master
Choose a base branch
from
srest2021/CW-1285-single
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b03bd02
feat(seer): Add single-project Seer settings endpoint
srest2021 45afc6b
extra test coverage
srest2021 fb9c776
convert project id to string
srest2021 459b1e8
address review comments
srest2021 256c07b
address review comments
srest2021 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,) | ||
|
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)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
195 changes: 195 additions & 0 deletions
195
tests/sentry/seer/endpoints/test_project_seer_settings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | ||
|
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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.