diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index b4c2c8944b3954..05ec3eb8daa8ab 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -554,6 +554,10 @@ 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_settings import ( + OrganizationSeerProjectSettingsEndpoint, + 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 @@ -2467,6 +2471,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationAutofixAutomationSettingsEndpoint.as_view(), name="sentry-api-0-organization-autofix-automation-settings", ), + re_path( + r"^(?P[^/]+)/seer/projects/$", + OrganizationSeerProjectSettingsEndpoint.as_view(), + name="sentry-api-0-organization-seer-project-settings", + ), re_path( r"^(?P[^/]+)/seer-rpc/(?P\w+)/$", OrganizationSeerRpcEndpoint.as_view(), @@ -3372,6 +3381,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: name="sentry-api-0-project-tempest-credentials-details", ), # Seer + re_path( + r"^(?P[^/]+)/(?P[^/]+)/seer/settings/$", + ProjectSeerSettingsEndpoint.as_view(), + name="sentry-api-0-project-seer-settings", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/seer/preferences/$", ProjectSeerPreferencesEndpoint.as_view(), diff --git a/src/sentry/projectoptions/defaults.py b/src/sentry/projectoptions/defaults.py index a5c0cd0cfce0b4..637634d96b4c71 100644 --- a/src/sentry/projectoptions/defaults.py +++ b/src/sentry/projectoptions/defaults.py @@ -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. diff --git a/src/sentry/seer/endpoints/project_settings.py b/src/sentry/seer/endpoints/project_settings.py new file mode 100644 index 00000000000000..c84109da91b11f --- /dev/null +++ b/src/sentry/seer/endpoints/project_settings.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from functools import partial +from typing import Any, TypedDict + +from django.db.models import Case, Count, IntegerField, OuterRef, Q, Subquery, Value, When +from django.db.models.functions import Coalesce +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import audit_log, projectoptions +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.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.bases.project import ProjectEndpoint, ProjectEventPermission +from sentry.api.event_search import QueryToken, SearchConfig, SearchFilter +from sentry.api.event_search import parse_search_query as base_parse_search_query +from sentry.api.paginator import OffsetPaginator +from sentry.constants import ( + AUTOFIX_AUTOMATION_TUNING_DEFAULT, + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, + ObjectStatus, +) +from sentry.db.models.fields.jsonfield import LegacyTextJSONField +from sentry.exceptions import InvalidSearchQuery +from sentry.integrations.services.integration import integration_service +from sentry.models.options.project_option import ProjectOption +from sentry.models.organization import Organization +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.issue_summary import STOPPING_POINT_HIERARCHY +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.utils import json + +SORT_FIELDS_MAPPING: dict[str, str] = { + "name": "slug", + "-name": "-slug", + "reposCount": "repos_count", + "-reposCount": "-repos_count", + "agent": "agent", + "-agent": "-agent", + "stoppingPoint": "stopping_point_rank", + "-stoppingPoint": "-stopping_point_rank", +} + +search_config = SearchConfig.create_from( + SearchConfig(), + allowed_keys={"id", "name", "reposCount", "stoppingPoint", "agent"}, + numeric_keys={"id", "reposCount"}, + allow_boolean=False, + free_text_key="name", +) +parse_search_query = partial(base_parse_search_query, config=search_config) + + +class SeerProjectSettingsResponse(TypedDict): + projectId: int + 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"] + ) + + # 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=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_projects( + projects: list[Project], +) -> dict[int, dict[str, Any]]: + """For each project, construct a dict containing repos_count and the relevant Seer project options.""" + if not projects: + return {} + + project_ids = [p.id for p in projects] + + project_options: dict[str, Mapping[int, Any]] = { + key: ProjectOption.objects.get_value_bulk_id(project_ids, key) + for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS + } + + repo_counts: dict[int, int] = dict( + SeerProjectRepository.objects.filter( + project_id__in=project_ids, repository__status=ObjectStatus.ACTIVE + ) + .values_list("project_id") + .annotate(count=Count("id")) + .values_list("project_id", "count") + ) + + attrs_by_project: dict[int, dict[str, Any]] = {} + for project in projects: + attrs_by_project[project.id] = {} + + for key in SEER_PROJECT_PREFERENCE_OPTION_KEYS: + value = project_options[key].get(project.id) + if value is None: + value = projectoptions.get_well_known_default(key, project=project) + attrs_by_project[project.id][key] = value + + attrs_by_project[project.id]["repos_count"] = repo_counts.get(project.id, 0) + + return attrs_by_project + + +def _get_attrs_for_project(project: Project) -> dict[str, Any]: + attrs: dict[str, Any] = {} + + 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 + + +def _annotate_queryset(queryset): + # ProjectOption.value is a LegacyTextJSONField — a text column storing JSON. + # Use LegacyTextJSONField as output_field. Coalesce fallback values must also + # be JSON-encoded to match what the DB stores. + + def _project_option_subquery(key: str) -> Subquery: + return Subquery( + ProjectOption.objects.filter(project_id=OuterRef("id"), key=key).values("value")[:1], + output_field=LegacyTextJSONField(), + ) + + return queryset.annotate( + repos_count=Count( + "seerprojectrepository", + filter=Q(seerprojectrepository__repository__status=ObjectStatus.ACTIVE), + ), + _tuning=Coalesce( + _project_option_subquery("sentry:autofix_automation_tuning"), + Value(json.dumps(AUTOFIX_AUTOMATION_TUNING_DEFAULT)), + output_field=LegacyTextJSONField(), + ), + stopping_point=Case( + When(_tuning=AutofixAutomationTuningSettings.OFF, then=Value(json.dumps("off"))), + default=Coalesce( + _project_option_subquery("sentry:seer_automated_run_stopping_point"), + Value(json.dumps(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT)), + output_field=LegacyTextJSONField(), + ), + output_field=LegacyTextJSONField(), + ), + # Map stopping point strings to integer ranks so we can order by them. + stopping_point_rank=Case( + When(_tuning=AutofixAutomationTuningSettings.OFF, then=Value(0)), + *[ + When(stopping_point=point, then=Value(rank)) + for point, rank in STOPPING_POINT_HIERARCHY.items() + ], + default=Value(0), + output_field=IntegerField(), + ), + _handoff_target=_project_option_subquery("sentry:seer_automation_handoff_target"), + # We only check handoff_target here (not handoff_point/integration_id) because the + # write path always sets or clears all three atomically. + agent=Case( + # No configured external handoff means use Seer agent. + When( + Q(_handoff_target__isnull=True) + | Q(_handoff_target=Value(json.dumps(None), output_field=LegacyTextJSONField())), + then=Value(json.dumps(AutomationCodingAgent.SEER)), + ), + default="_handoff_target", + output_field=LegacyTextJSONField(), + ), + ) + + +def _apply_search_filters(queryset, filters: Sequence[QueryToken]): + for f in filters: + if not isinstance(f, SearchFilter): + continue + + key = f.key.name + op = f.operator + value = f.value.value + + if key == "id": + if op in (">", "<", ">=", "<="): + raise InvalidSearchQuery("id does not support range operators.") + if op == "IN": + queryset = queryset.filter(id__in=[int(v) for v in value]) + elif op == "NOT IN": + queryset = queryset.exclude(id__in=[int(v) for v in value]) + elif op == "=": + queryset = queryset.filter(id=int(value)) + elif op == "!=": + queryset = queryset.exclude(id=int(value)) + + elif key == "name": + if op == "=": + queryset = queryset.filter(Q(name__icontains=value) | Q(slug__icontains=value)) + elif op == "!=": + queryset = queryset.exclude(Q(name__icontains=value) | Q(slug__icontains=value)) + + elif key == "reposCount": + if op in ("IN", "NOT IN"): + raise InvalidSearchQuery("reposCount does not support IN/NOT IN operators.") + count = int(value) + if op == "=": + queryset = queryset.filter(repos_count=count) + elif op == "!=": + queryset = queryset.exclude(repos_count=count) + elif op == ">": + queryset = queryset.filter(repos_count__gt=count) + elif op == "<": + queryset = queryset.filter(repos_count__lt=count) + elif op == ">=": + queryset = queryset.filter(repos_count__gte=count) + elif op == "<=": + queryset = queryset.filter(repos_count__lte=count) + + elif key == "stoppingPoint": + if op == "IN": + queryset = queryset.filter(stopping_point__in=value) + elif op == "NOT IN": + queryset = queryset.exclude(stopping_point__in=value) + elif op == "=": + queryset = queryset.filter(stopping_point=value) + elif op == "!=": + queryset = queryset.exclude(stopping_point=value) + + elif key == "agent": + if op == "IN": + queryset = queryset.filter(agent__in=value) + elif op == "NOT IN": + queryset = queryset.exclude(agent__in=value) + elif op == "=": + queryset = queryset.filter(agent=value) + elif op == "!=": + queryset = queryset.exclude(agent=value) + + return queryset + + +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 + + +class BulkProjectSettingsUpdateSerializer(ProjectSettingsUpdateSerializer): + query = serializers.CharField(required=False, default="") + + +@cell_silo_endpoint +class OrganizationSeerProjectSettingsEndpoint(OrganizationEndpoint): + owner = ApiOwner.ML_AI + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (OrganizationPermission,) + + def get(self, request: Request, organization: Organization) -> Response: + sort_by = request.GET.get("sortBy", "name") + order_by = SORT_FIELDS_MAPPING.get(sort_by) + if order_by is None: + return Response({"detail": f"Invalid sortBy: {sort_by}"}, status=400) + + accessible_projects = self.get_projects(request, organization, include_all_accessible=True) + queryset = _annotate_queryset( + Project.objects.filter(id__in={p.id for p in accessible_projects}) + ) + + search_query = request.GET.get("query", "") + if search_query: + try: + search_filters = parse_search_query(search_query) + queryset = _apply_search_filters(queryset, search_filters) + except (InvalidSearchQuery, ValueError): + return Response({"detail": "Invalid search query"}, status=400) + + def on_results(projects: list[Project]) -> list[SeerProjectSettingsResponse]: + attrs_by_project = _get_attrs_for_projects(projects) + return [_serialize_seer_project_settings(p, attrs_by_project[p.id]) for p in projects] + + return self.paginate( + request=request, + queryset=queryset, + order_by=order_by, + on_results=on_results, + paginator_cls=OffsetPaginator, + ) + + def put(self, request: Request, organization: Organization) -> Response: + serializer = BulkProjectSettingsUpdateSerializer( + data=request.data, context={"organization": organization} + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + data = serializer.validated_data + search_query = data.pop("query") + + accessible_projects = self.get_projects(request, organization) + queryset = _annotate_queryset( + Project.objects.filter(id__in={p.id for p in accessible_projects}) + ) + + if search_query: + try: + filters = parse_search_query(search_query) + queryset = _apply_search_filters(queryset, filters) + except (InvalidSearchQuery, ValueError): + return Response({"detail": "Invalid search query"}, status=400) + + projects = list(queryset) + for project in projects: + update_seer_project_settings(project, data) + + self.create_audit_entry( + request=request, + organization=organization, + target_object=organization.id, + event=audit_log.get_event_id("AUTOFIX_SETTINGS_EDIT"), + data={ + "project_count": len(projects), + "project_ids": [p.id for p in projects], + }, + ) + + return Response(status=204) + + +@cell_silo_endpoint +class ProjectSeerSettingsEndpoint(ProjectEndpoint): + owner = ApiOwner.ML_AI + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (ProjectEventPermission,) + + 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))) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 9158d3bc68fd84..55a0ca37e19d9d 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -562,6 +562,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/seer/explorer-runs/' | '/organizations/$organizationIdOrSlug/seer/explorer-update/$runId/' | '/organizations/$organizationIdOrSlug/seer/onboarding-check/' + | '/organizations/$organizationIdOrSlug/seer/projects/' | '/organizations/$organizationIdOrSlug/seer/setup-check/' | '/organizations/$organizationIdOrSlug/seer/supergroups/$supergroupId/' | '/organizations/$organizationIdOrSlug/seer/supergroups/by-group/' @@ -723,6 +724,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/' diff --git a/tests/sentry/seer/endpoints/test_project_seer_settings.py b/tests/sentry/seer/endpoints/test_project_seer_settings.py new file mode 100644 index 00000000000000..547b47ae7ba518 --- /dev/null +++ b/tests/sentry/seer/endpoints/test_project_seer_settings.py @@ -0,0 +1,599 @@ +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 OrganizationSeerProjectSettingsEndpointTest(APITestCase): + endpoint = "sentry-api-0-organization-seer-project-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}, + ) + + def test_get_returns_defaults(self) -> None: + """Projects with no options set should return default values.""" + response = self.client.get(self.url) + + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0] == { + "projectId": 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: + """Projects with explicit option values should reflect those 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[0]["stoppingPoint"] == "open_pr" + assert response.data[0]["scannerAutomation"] is False + + def test_get_returns_external_agent_with_integration_id(self) -> None: + """A project configured with an external handoff target should return + the 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[0]["agent"] == "cursor_background_agent" + assert response.data[0]["integrationId"] == "42" + + def test_get_stopping_point_off_when_tuning_off(self) -> None: + """When tuning is OFF, stoppingPoint should be 'off' regardless of the + stored seer_automated_run_stopping_point value.""" + 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[0]["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[0]["stoppingPoint"] == "root_cause" + + def test_get_repos_count(self) -> None: + """reposCount should reflect the number of 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[0]["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[0]["reposCount"] == 1 + + def test_get_only_returns_accessible_projects(self) -> None: + """Response should only include projects the user has access to.""" + self.organization.flags.allow_joinleave = False + self.organization.save() + + team = self.create_team(organization=self.organization) + self.create_project(organization=self.organization, teams=[team]) + inaccessible_project = self.create_project(organization=self.organization) + + member = self.create_user() + self.create_member(user=member, organization=self.organization, role="member", teams=[team]) + self.login_as(user=member) + + response = self.client.get(self.url) + + assert response.status_code == 200 + project_ids = [r["projectId"] for r in response.data] + assert len(project_ids) == 1 + assert inaccessible_project.id not in project_ids + + def test_get_paginates_results(self) -> None: + """Results should be paginated with Link headers indicating next/previous.""" + for i in range(5): + self.create_project(organization=self.organization, slug=f"paginate-{i}") + + response1 = self.client.get(self.url, {"per_page": "3"}) + assert response1.status_code == 200 + assert len(response1.data) == 3 + assert 'rel="next"; results="true"' in response1.headers["Link"] + + response2 = self.client.get(self.url, {"per_page": "3", "cursor": "3:1:0"}) + assert response2.status_code == 200 + assert 'rel="previous"; results="true"' in response2.headers["Link"] + assert 'rel="next"; results="false"' in response2.headers["Link"] + + def test_get_sort_by_name(self) -> None: + """sortBy=name should order by project slug.""" + project_b = self.create_project(organization=self.organization, slug="banana") + project_a = self.create_project(organization=self.organization, slug="apple") + + response = self.client.get(self.url, {"sortBy": "name"}) + + assert response.status_code == 200 + slugs = [r["projectSlug"] for r in response.data] + assert slugs.index(project_a.slug) < slugs.index(project_b.slug) + + def test_get_sort_by_repos_count(self) -> None: + """sortBy=reposCount should order by SeerProjectRepository count.""" + project1 = self.create_project(organization=self.organization) + for i in range(2): + repo = self.create_repo(project=project1, name=f"owner/repo-{i}") + SeerProjectRepository.objects.create(project=project1, repository=repo) + project2 = self.create_project(organization=self.organization) + + response = self.client.get(self.url, {"sortBy": "reposCount"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert ids.index(project2.id) < ids.index(project1.id) + + def test_get_sort_by_agent(self) -> None: + """sortBy=agent should order alphabetically by agent alias.""" + project_seer = self.create_project(organization=self.organization) + + project_cursor = self.create_project(organization=self.organization) + project_cursor.update_option( + "sentry:seer_automation_handoff_target", "cursor_background_agent" + ) + + project_claude = self.create_project(organization=self.organization) + project_claude.update_option("sentry:seer_automation_handoff_target", "claude_code_agent") + + response = self.client.get(self.url, {"sortBy": "agent"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert ids.index(project_claude.id) < ids.index(project_cursor.id) + assert ids.index(project_cursor.id) < ids.index(project_seer.id) + + def test_get_sort_by_stopping_point(self) -> None: + """sortBy=stoppingPoint should order by hierarchy rank (off < root_cause < code_changes < open_pr).""" + project_open_pr = self.create_project(organization=self.organization) + project_open_pr.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + ) + project_open_pr.update_option("sentry:seer_automated_run_stopping_point", "open_pr") + + project_root_cause = self.create_project(organization=self.organization) + project_root_cause.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + ) + project_root_cause.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + + # self.project has default tuning=OFF → stoppingPoint="off" (rank 0) + + response = self.client.get(self.url, {"sortBy": "stoppingPoint"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert ids.index(self.project.id) < ids.index(project_root_cause.id) + assert ids.index(project_root_cause.id) < ids.index(project_open_pr.id) + + def test_get_sort_by_invalid_field_returns_400(self) -> None: + """An unrecognized sortBy value should return 400.""" + response = self.client.get(self.url, {"sortBy": "invalid"}) + assert response.status_code == 400 + + def test_get_filter_empty_results(self) -> None: + """A filter that matches nothing should return an empty list.""" + response = self.client.get(self.url, {"query": "id:999999999"}) + + assert response.status_code == 200 + assert response.data == [] + + def test_get_filter_by_free_text_name(self) -> None: + """Free text query should match against both name and slug.""" + project1 = self.create_project( + organization=self.organization, name="", slug="matching-slug" + ) + project2 = self.create_project( + organization=self.organization, name="Matching Name", slug="" + ) + project3 = self.create_project(organization=self.organization) + + response = self.client.get(self.url, {"query": "matching"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert len(ids) == 2 + assert project1.id in ids + assert project2.id in ids + assert project3.id not in ids + + def test_get_filter_by_id(self) -> None: + """id:N should return only the project with that ID.""" + self.create_project(organization=self.organization) + project = self.create_project(organization=self.organization) + + response = self.client.get(self.url, {"query": f"id:{project.id}"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert ids == [project.id] + + def test_get_filter_by_id_list(self) -> None: + """id:[N,M] should return only the projects with those IDs.""" + project1 = self.create_project(organization=self.organization) + project2 = self.create_project(organization=self.organization) + self.create_project(organization=self.organization) + + response = self.client.get(self.url, {"query": f"id:[{project1.id},{project2.id}]"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert sorted(ids) == sorted([project1.id, project2.id]) + + def test_get_filter_by_repos_count(self) -> None: + """reposCount with numeric operators.""" + project1 = self.create_project(organization=self.organization) + for i in range(2): + repo = self.create_repo(project=project1, name=f"owner/filter-repo-{i}") + SeerProjectRepository.objects.create(project=project1, repository=repo) + project2 = self.create_project(organization=self.organization) + + response = self.client.get(self.url, {"query": "reposCount:>0"}) + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert project1.id in ids + assert project2.id not in ids + + response = self.client.get(self.url, {"query": "reposCount:0"}) + ids = [r["projectId"] for r in response.data] + assert project2.id in ids + assert project1.id not in ids + + def test_get_filter_by_stopping_point(self) -> None: + """stoppingPoint filter should account for tuning state.""" + project1 = self.create_project(organization=self.organization) + project1.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM + ) + project1.update_option("sentry:seer_automated_run_stopping_point", "code_changes") + + response = self.client.get(self.url, {"query": "stoppingPoint:off"}) + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert self.project.id in ids + assert project1.id not in ids + + response = self.client.get(self.url, {"query": "stoppingPoint:code_changes"}) + ids = [r["projectId"] for r in response.data] + assert project1.id in ids + assert self.project.id not in ids + + def test_get_filter_by_agent_seer(self) -> None: + """agent:seer should return projects with no handoff target (NULL).""" + project1 = self.create_project(organization=self.organization) + project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + + response = self.client.get(self.url, {"query": "agent:seer"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert self.project.id in ids + assert project1.id not in ids + + def test_get_filter_by_agent_external(self) -> None: + """agent:cursor_background_agent should return projects with cursor handoff target.""" + project1 = self.create_project(organization=self.organization) + project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + + response = self.client.get(self.url, {"query": "agent:cursor_background_agent"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert project1.id in ids + assert self.project.id not in ids + + def test_get_filter_negation(self) -> None: + """!agent:seer should exclude projects with no handoff target.""" + project1 = self.create_project(organization=self.organization) + project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + + response = self.client.get(self.url, {"query": "!agent:seer"}) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert project1.id in ids + assert self.project.id not in ids + + def test_get_multiple_filters(self) -> None: + """Combining multiple filters should intersect the results.""" + project1 = self.create_project(organization=self.organization) + project1.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + repo = self.create_repo(project=project1, name="owner/repo-1") + SeerProjectRepository.objects.create(project=project1, repository=repo) + + project2 = self.create_project(organization=self.organization) + project2.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + + response = self.client.get( + self.url, {"query": "agent:cursor_background_agent reposCount:>0"} + ) + + assert response.status_code == 200 + ids = [r["projectId"] for r in response.data] + assert ids == [project1.id] + + def test_get_invalid_search_query_returns_400(self) -> None: + """A malformed search query should return 400 with detail.""" + response = self.client.get(self.url, {"query": "bogusKey:value"}) + assert response.status_code == 400 + assert "detail" in response.data + + def test_put_updates_all_projects(self) -> None: + """Empty query should update all accessible projects.""" + project2 = self.create_project(organization=self.organization) + + response = self.client.put(self.url, data={"scannerAutomation": False}, format="json") + + assert response.status_code == 204 + assert self.project.get_option("sentry:seer_scanner_automation") is False + assert project2.get_option("sentry:seer_scanner_automation") is False + + def test_put_applies_to_filtered_projects_only(self) -> None: + """The query parameter should scope which projects get updated.""" + project2 = self.create_project(organization=self.organization) + project2.update_option("sentry:seer_automation_handoff_target", "cursor_background_agent") + + response = self.client.put( + self.url, + data={"query": "agent:cursor_background_agent", "scannerAutomation": False}, + format="json", + ) + + assert response.status_code == 204 + assert project2.get_option("sentry:seer_scanner_automation") is False + assert self.project.get_option("sentry:seer_scanner_automation") is True + + def test_put_requires_at_least_one_update_field(self) -> None: + """Sending only query with no update fields should return 400.""" + response = self.client.put(self.url, data={"query": ""}, format="json") + assert response.status_code == 400 + + def test_put_requires_integration_id_for_external_agent(self) -> None: + """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_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_invalid_search_query_returns_400(self) -> None: + """A malformed query value should return 400.""" + response = self.client.put( + self.url, data={"query": "invalidKey:value", "scannerAutomation": False}, format="json" + ) + assert response.status_code == 400 + + def test_put_creates_audit_log_entry(self) -> None: + """Bulk update should create an audit log entry with project count and IDs.""" + 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 + + project2 = self.create_project(organization=self.organization) + + 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_count"] == 2 + assert set(entry.data["project_ids"]) == {self.project.id, project2.id} + + +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": self.project.id, + "projectSlug": self.project.slug, + "agent": "seer", + "integrationId": None, + "stoppingPoint": "off", + "scannerAutomation": True, + "reposCount": 0, + } + + def test_get_returns_configured_settings(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(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_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_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"] == 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: + """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