diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index 26483944622f4e..01f7ebfe28ff1e 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -1,6 +1,7 @@ from __future__ import annotations from django.db import router, transaction +from django.db.models import Q from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -9,8 +10,10 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases import OrganizationEndpoint, OrganizationPermission +from sentry.api.paginator import OffsetPaginator from sentry.models.options.project_option import ProjectOption from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.seer.autofix.utils import ( AutofixStoppingPoint, @@ -21,6 +24,24 @@ OPTION_KEY = "sentry:autofix_automation_tuning" +class SeerAutofixSettingGetSerializer(serializers.Serializer): + """Serializer for OrganizationAutofixAutomationSettingsEndpoint.get query params""" + + projectIds = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + max_length=1000, + help_text="Optional list of project IDs to filter by. Maximum 1000 projects.", + ) + query = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + help_text="Optional search query to filter by project name or slug.", + ) + + class SeerAutofixSettingSerializer(serializers.Serializer): """Serializer for OrganizationAutofixAutomationSettingsEndpoint.put""" @@ -57,11 +78,86 @@ class OrganizationAutofixAutomationSettingsEndpoint(OrganizationEndpoint): owner = ApiOwner.CODING_WORKFLOWS publish_status = { + "GET": ApiPublishStatus.PRIVATE, "PUT": ApiPublishStatus.PRIVATE, } permission_classes = (OrganizationPermission,) + def _serialize_projects_with_settings( + self, projects: list[Project], organization: Organization + ) -> list[dict]: + if not projects: + return [] + + project_ids_list = [project.id for project in projects] + tuning_options = ProjectOption.objects.get_value_bulk(projects, OPTION_KEY) + seer_preferences = bulk_get_project_preferences(organization.id, project_ids_list) + + results = [] + for project in projects: + tuning_value = tuning_options.get(project) or AutofixAutomationTuningSettings.OFF.value + seer_pref = seer_preferences.get(str(project.id), {}) + + results.append( + { + "projectId": project.id, + "projectSlug": project.slug, + "projectName": project.name, + "projectPlatform": project.platform, + "fixes": tuning_value != AutofixAutomationTuningSettings.OFF.value, + "prCreation": seer_pref.get("automated_run_stopping_point") + == AutofixStoppingPoint.OPEN_PR.value, + "tuning": tuning_value, + "reposCount": len(seer_pref.get("repositories", [])), + } + ) + return results + + def get(self, request: Request, organization: Organization) -> Response: + """ + List projects with their autofix automation settings. + + :pparam string organization_id_or_slug: the id or slug of the organization. + :qparam list[int] projectIds: Optional list of project IDs to filter by. + :qparam string query: Optional search query to filter by project name or slug. + :auth: required + """ + serializer = SeerAutofixSettingGetSerializer( + data={ + "projectIds": request.GET.getlist("projectIds") or None, + "query": request.GET.get("query"), + } + ) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + data = serializer.validated_data + project_ids = data.get("projectIds") + + if project_ids: + authorized_projects = self.get_projects( + request, organization, project_ids=set(project_ids) + ) + authorized_project_ids = [p.id for p in authorized_projects] + queryset = Project.objects.filter(id__in=authorized_project_ids) + else: + queryset = Project.objects.filter(organization_id=organization.id) + + query = data.get("query") + if query: + queryset = queryset.filter(Q(name__icontains=query) | Q(slug__icontains=query)) + + return self.paginate( + request=request, + queryset=queryset, + order_by="slug", + on_results=lambda projects: self._serialize_projects_with_settings( + projects, organization + ), + paginator_cls=OffsetPaginator, + ) + def put(self, request: Request, organization: Organization) -> Response: """ Bulk update the autofix automation settings of projects in a single request. diff --git a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py index 014f6fdbd66263..33880cce2dd44c 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -217,3 +217,225 @@ def test_put_project_not_in_organization(self): ) assert response.status_code == 403 + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_returns_settings_for_projects(self, mock_bulk_get_preferences): + project1 = self.create_project( + organization=self.organization, name="Project One", platform="javascript" + ) + project2 = self.create_project( + organization=self.organization, name="Project Two", platform="python" + ) + + project1.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM.value + ) + project2.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.OFF.value + ) + + mock_bulk_get_preferences.return_value = { + str(project1.id): { + "automated_run_stopping_point": AutofixStoppingPoint.OPEN_PR.value, + "repositories": [{"name": "repo1"}, {"name": "repo2"}], + }, + str(project2.id): { + "automated_run_stopping_point": AutofixStoppingPoint.CODE_CHANGES.value, + "repositories": [], + }, + } + + response = self.client.get( + self.url, {"projectIds": [project1.id, project2.id]}, format="json" + ) + + assert response.status_code == 200 + assert len(response.data) == 2 + + result1 = next(r for r in response.data if r["projectId"] == project1.id) + result2 = next(r for r in response.data if r["projectId"] == project2.id) + + assert result1["fixes"] is True + assert result1["prCreation"] is True + assert result1["tuning"] == AutofixAutomationTuningSettings.MEDIUM.value + assert result1["projectSlug"] == project1.slug + assert result1["projectName"] == "Project One" + assert result1["projectPlatform"] == "javascript" + assert result1["reposCount"] == 2 + + assert result2["fixes"] is False + assert result2["prCreation"] is False + assert result2["tuning"] == AutofixAutomationTuningSettings.OFF.value + assert result2["projectName"] == "Project Two" + assert result2["projectPlatform"] == "python" + assert result2["reposCount"] == 0 + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_returns_defaults_for_projects_without_settings(self, mock_bulk_get_preferences): + project = self.create_project(organization=self.organization) + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"projectIds": [project.id]}, format="json") + + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["projectId"] == project.id + assert response.data[0]["fixes"] is False + assert response.data[0]["prCreation"] is False + assert response.data[0]["tuning"] == AutofixAutomationTuningSettings.OFF.value + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_all_projects_when_no_project_ids(self, mock_bulk_get_preferences): + project1 = self.create_project(organization=self.organization) + project2 = self.create_project(organization=self.organization) + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, format="json") + + assert response.status_code == 200 + + project_ids = [r["projectId"] for r in response.data] + assert project1.id in project_ids + assert project2.id in project_ids + + def test_get_invalid_project_ids(self): + response = self.client.get(self.url, {"projectIds": ["not-an-int"]}, format="json") + + assert response.status_code == 400 + assert "projectIds" in response.data + + def test_get_unauthorized_project_ids_returns_403(self): + project = self.create_project(organization=self.organization) + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) + + response = self.client.get( + self.url, {"projectIds": [project.id, other_project.id]}, format="json" + ) + + assert response.status_code == 403 + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_multiple_project_ids_query_params(self, mock_bulk_get_preferences): + project1 = self.create_project(organization=self.organization) + project2 = self.create_project(organization=self.organization) + mock_bulk_get_preferences.return_value = {} + response = self.client.get( + f"{self.url}?projectIds={project1.id}&projectIds={project2.id}", format="json" + ) + + assert response.status_code == 200 + assert len(response.data) == 2 + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_pagination(self, mock_bulk_get_preferences): + for i in range(5): + self.create_project(organization=self.organization, slug=f"project-{i}") + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"per_page": 2}, format="json") + + assert response.status_code == 200 + assert len(response.data) == 2 + assert "Link" in response + + def test_get_too_many_project_ids(self): + project_ids = list(range(1, 1002)) + query_string = "&".join([f"projectIds={pid}" for pid in project_ids]) + response = self.client.get(f"{self.url}?{query_string}", format="json") + + # Django will reject this with 400 before our code runs due to DATA_UPLOAD_MAX_NUMBER_FIELDS + assert response.status_code == 400 + + def test_get_nonexistent_project_ids_returns_403(self): + response = self.client.get(self.url, {"projectIds": [99999, 99998]}, format="json") + assert response.status_code == 403 + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_pr_creation_false_when_stopping_point_is_code_changes( + self, mock_bulk_get_preferences + ): + project = self.create_project(organization=self.organization) + project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM.value + ) + mock_bulk_get_preferences.return_value = { + str(project.id): { + "automated_run_stopping_point": AutofixStoppingPoint.CODE_CHANGES.value, + }, + } + + response = self.client.get(self.url, {"projectIds": [project.id]}, format="json") + + assert response.status_code == 200 + assert response.data[0]["fixes"] is True + assert response.data[0]["prCreation"] is False + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_pr_creation_false_when_no_seer_preference(self, mock_bulk_get_preferences): + project = self.create_project(organization=self.organization) + project.update_option( + "sentry:autofix_automation_tuning", AutofixAutomationTuningSettings.MEDIUM.value + ) + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"projectIds": [project.id]}, format="json") + + assert response.status_code == 200 + assert response.data[0]["fixes"] is True + assert response.data[0]["prCreation"] is False + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_search_by_name(self, mock_bulk_get_preferences): + self.create_project(organization=self.organization, name="Frontend App", slug="frontend") + self.create_project(organization=self.organization, name="Backend API", slug="backend") + self.create_project(organization=self.organization, name="Mobile App", slug="mobile") + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"query": "Frontend"}, format="json") + + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["projectName"] == "Frontend App" + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_search_by_slug(self, mock_bulk_get_preferences): + self.create_project(organization=self.organization, name="Frontend App", slug="frontend") + self.create_project(organization=self.organization, name="Backend API", slug="backend") + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"query": "backend"}, format="json") + + assert response.status_code == 200 + assert len(response.data) == 1 + assert response.data[0]["projectSlug"] == "backend" + + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + def test_get_search_no_results(self, mock_bulk_get_preferences): + self.create_project(organization=self.organization, name="Frontend App", slug="frontend") + mock_bulk_get_preferences.return_value = {} + + response = self.client.get(self.url, {"query": "nonexistent"}, format="json") + + assert response.status_code == 200 + assert len(response.data) == 0