Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -21,6 +24,24 @@
OPTION_KEY = "sentry:autofix_automation_tuning"


class SeerAutofixSettingGetSerializer(serializers.Serializer):
"""Serializer for OrganizationAutofixAutomationSettingsEndpoint.get query params"""

projectIds = serializers.ListField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, so we can use this for the single page too right?

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"""

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading