diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 4f7292c1b2b8b6..87654dca0763c7 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -542,6 +542,9 @@ OrganizationSeerAgentUpdateEndpoint, ) from sentry.seer.endpoints.organization_seer_onboarding_check import OrganizationSeerOnboardingCheck +from sentry.seer.endpoints.organization_seer_project_repos import ( + OrganizationSeerProjectRepoDetailsEndpoint, +) from sentry.seer.endpoints.organization_seer_rpc import OrganizationSeerRpcEndpoint from sentry.seer.endpoints.organization_seer_setup_check import OrganizationSeerSetupCheckEndpoint from sentry.seer.endpoints.organization_seer_workflows import OrganizationSeerWorkflowsEndpoint @@ -2449,6 +2452,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationSeerOnboardingCheck.as_view(), name="sentry-api-0-organization-seer-onboarding-check", ), + re_path( + r"^(?P[^/]+)/seer/projects/(?P\d+)/repos/(?P\d+)/$", + OrganizationSeerProjectRepoDetailsEndpoint.as_view(), + name="sentry-api-0-organization-seer-project-repo-details", + ), re_path( r"^(?P[^/]+)/autofix/automation-settings/$", OrganizationAutofixAutomationSettingsEndpoint.as_view(), diff --git a/src/sentry/seer/endpoints/organization_seer_project_repos.py b/src/sentry/seer/endpoints/organization_seer_project_repos.py new file mode 100644 index 00000000000000..59a0a159b53f8e --- /dev/null +++ b/src/sentry/seer/endpoints/organization_seer_project_repos.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from typing import TypedDict + +from django.db import router, transaction +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.response import Response + +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.serializers.rest_framework import CamelSnakeSerializer +from sentry.constants import ObjectStatus +from sentry.models.organization import Organization +from sentry.models.project import Project +from sentry.seer.autofix.utils import replace_all_branch_overrides +from sentry.seer.models.project_repository import SeerProjectRepository +from sentry.seer.seer_setup import get_supported_scm_providers + + +class BranchOverrideResponse(TypedDict): + id: str + tagName: str + tagValue: str + branchName: str + + +class ProjectRepoResponse(TypedDict): + id: str + repositoryId: str + organizationId: str + provider: str + owner: str + name: str + externalId: str + integrationId: str | None + branchName: str | None + branchOverrides: list[BranchOverrideResponse] + instructions: str | None + + +def _serialize_project_repo(project_repo: SeerProjectRepository) -> ProjectRepoResponse: + repo = project_repo.project_repository.repository + name_parts = repo.name.split("/", 1) + owner = name_parts[0] if len(name_parts) > 1 else "" + name = name_parts[1] if len(name_parts) > 1 else repo.name + + return ProjectRepoResponse( + id=str(project_repo.id), + repositoryId=str(repo.id), + organizationId=str(repo.organization_id), + provider=repo.provider or "", + owner=owner, + name=name, + externalId=repo.external_id or "", + integrationId=str(repo.integration_id) if repo.integration_id is not None else None, + branchName=project_repo.branch_name, + branchOverrides=[ + BranchOverrideResponse( + id=str(bo.id), + tagName=bo.tag_name, + tagValue=bo.tag_value, + branchName=bo.branch_name, + ) + for bo in project_repo.branch_overrides.all() + ], + instructions=project_repo.instructions, + ) + + +def _valid_project_repos_queryset(project: Project, organization: Organization): + """Get a project's base SeerProjectRepository queryset for active repos with providers supported by Seer.""" + return SeerProjectRepository.objects.filter( + project_repository__project=project, + project_repository__repository__status=ObjectStatus.ACTIVE, + project_repository__repository__provider__in=get_supported_scm_providers(organization), + ).select_related("project_repository", "project_repository__repository") + + +def _validate_branch_overrides(value): + if not value: + return value + seen: set[tuple[str, str]] = set() + for override in value: + key = (override["tag_name"], override["tag_value"]) + if key in seen: + raise serializers.ValidationError( + f"Duplicate branch override for tag {key[0]}={key[1]}" + ) + seen.add(key) + return value + + +class BranchOverrideSerializer(CamelSnakeSerializer): + tag_name = serializers.CharField(required=True) + tag_value = serializers.CharField(required=True) + branch_name = serializers.CharField(required=True) + + +class SeerProjectRepoUpdateSerializer(CamelSnakeSerializer): + branch_name = serializers.CharField(required=False, allow_null=True, allow_blank=True) + instructions = serializers.CharField(required=False, allow_null=True, allow_blank=True) + branch_overrides = BranchOverrideSerializer(many=True, required=False, allow_null=False) + + def validate_branch_overrides(self, value): + return _validate_branch_overrides(value) + + def validate(self, data): + if not data: + raise serializers.ValidationError("At least one field must be provided.") + return data + + +@cell_silo_endpoint +class OrganizationSeerProjectRepoDetailsEndpoint(OrganizationEndpoint): + owner = ApiOwner.ML_AI + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.EXPERIMENTAL, + "DELETE": ApiPublishStatus.EXPERIMENTAL, + } + permission_classes = (OrganizationPermission,) + + def get( + self, request: Request, organization: Organization, project_id: int, repo_id: int + ) -> Response: + project = self.get_projects(request, organization, project_ids={int(project_id)})[0] + + project_repo = ( + _valid_project_repos_queryset(project, organization) + .prefetch_related("branch_overrides") + .filter(project_repository__repository_id=repo_id) + .first() + ) + if project_repo is None: + return Response(status=404) + + return Response(_serialize_project_repo(project_repo)) + + def put( + self, request: Request, organization: Organization, project_id: int, repo_id: int + ) -> Response: + project = self.get_projects(request, organization, project_ids={int(project_id)})[0] + + serializer = SeerProjectRepoUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + data = serializer.validated_data + + with transaction.atomic(router.db_for_write(SeerProjectRepository)): + project_repo = ( + _valid_project_repos_queryset(project, organization) + .select_for_update() + .filter(project_repository__repository_id=repo_id) + .first() + ) + if project_repo is None: + return Response(status=404) + + if "branch_name" in data: + project_repo.branch_name = data["branch_name"] + if "instructions" in data: + project_repo.instructions = data["instructions"] + if "branch_name" in data or "instructions" in data: + project_repo.save() + + if "branch_overrides" in data: + replace_all_branch_overrides(project_repo, data["branch_overrides"]) + + return Response(_serialize_project_repo(project_repo)) + + def delete( + self, request: Request, organization: Organization, project_id: int, repo_id: int + ) -> Response: + project = self.get_projects(request, organization, project_ids={int(project_id)})[0] + + with transaction.atomic(router.db_for_write(SeerProjectRepository)): + deleted_count, _ = ( + _valid_project_repos_queryset(project, organization) + .filter(project_repository__repository_id=repo_id) + .delete() + ) + + if deleted_count == 0: + return Response(status=404) + + return Response(status=204) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 61408ae7391e9d..30c79e447db31c 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -559,6 +559,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/seer/explorer-update/$runId/' | '/organizations/$organizationIdOrSlug/seer/onboarding-check/' | '/organizations/$organizationIdOrSlug/seer/projects/' + | '/organizations/$organizationIdOrSlug/seer/projects/$projectId/repos/$repoId/' | '/organizations/$organizationIdOrSlug/seer/setup-check/' | '/organizations/$organizationIdOrSlug/seer/supergroups/$supergroupId/' | '/organizations/$organizationIdOrSlug/seer/supergroups/by-group/' diff --git a/tests/sentry/seer/endpoints/test_organization_seer_project_repos.py b/tests/sentry/seer/endpoints/test_organization_seer_project_repos.py new file mode 100644 index 00000000000000..1140efee32db2e --- /dev/null +++ b/tests/sentry/seer/endpoints/test_organization_seer_project_repos.py @@ -0,0 +1,298 @@ +from django.urls import reverse + +from sentry.constants import ObjectStatus +from sentry.seer.models.project_repository import ( + SeerProjectRepository, + SeerProjectRepositoryBranchOverride, +) +from sentry.testutils.cases import APITestCase + + +class OrganizationSeerProjectRepoDetailsGetTest(APITestCase): + endpoint = "sentry-api-0-organization-seer-project-repo-details" + + def detail_url(self, repo_id): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id": self.project.id, + "repo_id": repo_id, + }, + ) + + def reverse_url(self): + return self.detail_url(self.repo1.id) + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.repo1 = self.create_repo( + project=self.project, + name="getsentry/sentry", + provider="integrations:github", + external_id="111", + ) + + def test_get_repo(self): + project_repo = self.create_seer_project_repository( + self.project, repository=self.repo1, branch_name="main", instructions="hello" + ) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=project_repo, + tag_name="environment", + tag_value="production", + branch_name="release", + ) + + response = self.get_success_response() + assert response.data["repositoryId"] == str(self.repo1.id) + assert response.data["branchName"] == "main" + assert response.data["instructions"] == "hello" + assert len(response.data["branchOverrides"]) == 1 + assert response.data["branchOverrides"][0]["tagName"] == "environment" + assert response.data["branchOverrides"][0]["tagValue"] == "production" + assert response.data["branchOverrides"][0]["branchName"] == "release" + + def test_not_connected_repo_returns_404(self): + self.get_error_response(status_code=404) + + def test_inactive_repo_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.status = ObjectStatus.HIDDEN + self.repo1.save() + + self.get_error_response(status_code=404) + + def test_nonexistent_repo_returns_404(self): + response = self.client.get(self.detail_url(99999)) + assert response.status_code == 404 + + def test_unsupported_provider_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.provider = "integrations:bitbucket" + self.repo1.save() + + self.get_error_response(status_code=404) + + +class OrganizationSeerProjectRepoDetailsPutTest(APITestCase): + endpoint = "sentry-api-0-organization-seer-project-repo-details" + method = "put" + + def reverse_url(self): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id": self.project.id, + "repo_id": self.repo1.id, + }, + ) + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.repo1 = self.create_repo( + project=self.project, + name="getsentry/sentry", + provider="integrations:github", + external_id="111", + ) + + def test_update_branch_name(self): + self.create_seer_project_repository(self.project, repository=self.repo1, branch_name="main") + + response = self.get_success_response(branchName="develop") + assert response.data["branchName"] == "develop" + + def test_update_instructions(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + + response = self.get_success_response(instructions="new instructions") + assert response.data["instructions"] == "new instructions" + + def test_update_branch_overrides(self): + pr = self.create_seer_project_repository(self.project, repository=self.repo1) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=pr, + tag_name="environment", + tag_value="production", + branch_name="old-branch", + ) + + response = self.get_success_response( + branchOverrides=[ + { + "tagName": "environment", + "tagValue": "staging", + "branchName": "staging-branch", + } + ], + ) + assert len(response.data["branchOverrides"]) == 1 + assert response.data["branchOverrides"][0]["tagValue"] == "staging" + + assert ( + SeerProjectRepositoryBranchOverride.objects.filter(seer_project_repository=pr).count() + == 1 + ) + + def test_partial_update_preserves_other_fields(self): + self.create_seer_project_repository( + self.project, repository=self.repo1, branch_name="main", instructions="original" + ) + + response = self.get_success_response(branchName="develop") + assert response.data["branchName"] == "develop" + assert response.data["instructions"] == "original" + + def test_partial_update_preserves_branch_overrides(self): + project_repo = self.create_seer_project_repository( + self.project, repository=self.repo1, branch_name="main" + ) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=project_repo, + tag_name="env", + tag_value="prod", + branch_name="release", + ) + + response = self.get_success_response(branchName="develop") + assert response.data["branchName"] == "develop" + assert len(response.data["branchOverrides"]) == 1 + assert response.data["branchOverrides"][0]["tagName"] == "env" + assert response.data["branchOverrides"][0]["branchName"] == "release" + + def test_not_connected_returns_404(self): + self.get_error_response(branchName="main", status_code=404) + + def test_set_null_branch_name(self): + self.create_seer_project_repository(self.project, repository=self.repo1, branch_name="main") + + response = self.get_success_response(branchName=None) + assert response.data["branchName"] is None + + def test_clear_branch_overrides(self): + pr = self.create_seer_project_repository(self.project, repository=self.repo1) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=pr, + tag_name="environment", + tag_value="production", + branch_name="release", + ) + + response = self.get_success_response(branchOverrides=[]) + assert response.data["branchOverrides"] == [] + assert ( + SeerProjectRepositoryBranchOverride.objects.filter(seer_project_repository=pr).count() + == 0 + ) + + def test_inactive_repo_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.status = ObjectStatus.PENDING_DELETION + self.repo1.save() + + self.get_error_response(branchName="develop", status_code=404) + + def test_unsupported_provider_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.provider = "integrations:bitbucket" + self.repo1.save() + + self.get_error_response(branchName="develop", status_code=404) + + def test_empty_body_returns_400(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + + self.get_error_response(status_code=400) + + def test_duplicate_branch_overrides_returns_400(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + + self.get_error_response( + branchOverrides=[ + {"tagName": "environment", "tagValue": "production", "branchName": "release"}, + {"tagName": "environment", "tagValue": "production", "branchName": "hotfix"}, + ], + status_code=400, + ) + + def test_branch_overrides_does_not_bump_date_updated(self): + project_repo = self.create_seer_project_repository( + self.project, repository=self.repo1, branch_name="main" + ) + original_date_updated = project_repo.date_updated + + self.get_success_response( + branchOverrides=[ + {"tagName": "environment", "tagValue": "staging", "branchName": "staging-branch"}, + ], + ) + + project_repo.refresh_from_db() + assert project_repo.date_updated == original_date_updated + + +class OrganizationSeerProjectRepoDetailsDeleteTest(APITestCase): + endpoint = "sentry-api-0-organization-seer-project-repo-details" + method = "delete" + + def reverse_url(self): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id": self.project.id, + "repo_id": self.repo1.id, + }, + ) + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.repo1 = self.create_repo( + project=self.project, + name="getsentry/sentry", + provider="integrations:github", + external_id="111", + ) + + def test_delete_repo(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + + self.get_success_response() + assert not SeerProjectRepository.objects.filter( + project_repository__project=self.project, + project_repository__repository=self.repo1, + ).exists() + + def test_cascades_branch_overrides(self): + project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) + SeerProjectRepositoryBranchOverride.objects.create( + seer_project_repository=project_repo, + tag_name="environment", + tag_value="production", + branch_name="release", + ) + + self.get_success_response() + assert SeerProjectRepositoryBranchOverride.objects.count() == 0 + + def test_not_connected_repo_returns_404(self): + self.get_error_response(status_code=404) + + def test_inactive_repo_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.status = ObjectStatus.HIDDEN + self.repo1.save() + + self.get_error_response(status_code=404) + + def test_unsupported_provider_returns_404(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.provider = "integrations:bitbucket" + self.repo1.save() + + self.get_error_response(status_code=404)