diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 3823aebdac42..7a4a4abe22c3 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -550,6 +550,7 @@ from sentry.seer.endpoints.project_seer_preferences import ProjectSeerPreferencesEndpoint from sentry.seer.endpoints.project_seer_repos import ( ProjectSeerRepoEndpoint, + ProjectSeerReposEndpoint, ) from sentry.seer.endpoints.project_seer_settings import ( OrganizationSeerProjectSettingsEndpoint, @@ -3383,6 +3384,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ProjectSeerPreferencesEndpoint.as_view(), name="sentry-api-0-project-seer-preferences", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/seer/repos/$", + ProjectSeerReposEndpoint.as_view(), + name="sentry-api-0-project-seer-repos", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/seer/repos/(?P\d+)/$", ProjectSeerRepoEndpoint.as_view(), diff --git a/src/sentry/seer/endpoints/project_seer_repos.py b/src/sentry/seer/endpoints/project_seer_repos.py index 9e015716802a..19c43d7b71fd 100644 --- a/src/sentry/seer/endpoints/project_seer_repos.py +++ b/src/sentry/seer/endpoints/project_seer_repos.py @@ -1,8 +1,12 @@ from __future__ import annotations +from collections.abc import Sequence +from functools import partial from typing import TypedDict from django.db import router, transaction +from django.db.models import Value +from django.db.models.functions import Replace from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -11,13 +15,34 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint +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.api.serializers.rest_framework import CamelSnakeSerializer from sentry.constants import ObjectStatus +from sentry.exceptions import InvalidSearchQuery from sentry.models.project import Project -from sentry.seer.autofix.utils import replace_all_branch_overrides +from sentry.models.repository import Repository +from sentry.seer.autofix.utils import ( + add_seer_project_repos, + replace_all_branch_overrides, + replace_all_seer_project_repos, +) from sentry.seer.models.project_repository import SeerProjectRepository from sentry.seer.seer_setup import get_supported_scm_providers +SORT_FIELDS_MAPPING: dict[str, str] = { + "name": "project_repository__repository__name", + "-name": "-project_repository__repository__name", + "provider": "provider_normalized", + "-provider": "-provider_normalized", +} + +search_config = SearchConfig.create_from( + SearchConfig(), allowed_keys={"name", "provider"}, allow_boolean=False, free_text_key="name" +) +parse_search_query = partial(base_parse_search_query, config=search_config) + class BranchOverrideResponse(TypedDict): id: str @@ -79,6 +104,55 @@ def _valid_project_repos_queryset(project: Project): ).select_related("project_repository", "project_repository__repository") +def _validate_repo_ids(project: Project, repo_ids: list[int]) -> None: + """Raise ValueError if any repo IDs are invalid, inactive, or have providers not supported by Seer.""" + organization = project.organization + valid_ids = set( + Repository.objects.filter( + id__in=repo_ids, + organization_id=organization.id, + status=ObjectStatus.ACTIVE, + provider__in=get_supported_scm_providers(organization), + ).values_list("id", flat=True) + ) + invalid_ids = set(repo_ids) - valid_ids + if invalid_ids: + raise ValueError(sorted(invalid_ids)) + + +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 == "name": + if op not in ("=", "!="): + raise InvalidSearchQuery(f"name does not support the {op} operator.") + if op == "=": + queryset = queryset.filter(project_repository__repository__name__icontains=value) + elif op == "!=": + queryset = queryset.exclude(project_repository__repository__name__icontains=value) + + elif key == "provider": + if op not in ("=", "!=", "IN", "NOT IN"): + raise InvalidSearchQuery(f"provider does not support the {op} operator.") + normalize = lambda v: v.removeprefix("integrations:") + if op == "=": + queryset = queryset.filter(provider_normalized=normalize(value)) + elif op == "!=": + queryset = queryset.exclude(provider_normalized=normalize(value)) + elif op == "IN": + queryset = queryset.filter(provider_normalized__in=[normalize(v) for v in value]) + elif op == "NOT IN": + queryset = queryset.exclude(provider_normalized__in=[normalize(v) for v in value]) + + return queryset + + def _validate_branch_overrides(value): if not value: return value @@ -113,6 +187,26 @@ def validate(self, data): return data +class SeerProjectRepoCreateSerializer(CamelSnakeSerializer): + repository_id = serializers.IntegerField(required=True) + 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) + + +class SeerProjectReposBulkSerializer(CamelSnakeSerializer): + repos = SeerProjectRepoCreateSerializer(many=True, required=True, allow_empty=True) + + def validate_repos(self, value): + repo_ids = [r["repository_id"] for r in value] + if len(repo_ids) != len(set(repo_ids)): + raise serializers.ValidationError("Duplicate repository IDs are not allowed.") + return value + + @cell_silo_endpoint class ProjectSeerRepoEndpoint(ProjectEndpoint): owner = ApiOwner.ML_AI @@ -175,3 +269,80 @@ def delete(self, request: Request, project: Project, repo_id: int) -> Response: return Response(status=404) return Response(status=204) + + +@cell_silo_endpoint +class ProjectSeerReposEndpoint(ProjectEndpoint): + owner = ApiOwner.ML_AI + publish_status = { + "GET": ApiPublishStatus.EXPERIMENTAL, + "POST": ApiPublishStatus.EXPERIMENTAL, + "PUT": ApiPublishStatus.EXPERIMENTAL, + } + + def get(self, request: Request, project: Project) -> Response: + queryset = ( + _valid_project_repos_queryset(project) + .prefetch_related("branch_overrides") + .annotate( + provider_normalized=Replace( + "project_repository__repository__provider", Value("integrations:"), Value("") + ) + ) + ) + + search_query = request.GET.get("query", "") + 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) + + 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) + + return self.paginate( + request=request, + queryset=queryset, + order_by=order_by, + on_results=lambda results: [_serialize_project_repo(r) for r in results], + paginator_cls=OffsetPaginator, + ) + + def post(self, request: Request, project: Project) -> Response: + serializer = SeerProjectReposBulkSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + repos_data = serializer.validated_data["repos"] + if not repos_data: + return Response({"detail": "repos must not be empty."}, status=400) + + try: + _validate_repo_ids(project, [d["repository_id"] for d in repos_data]) + except ValueError as e: + return Response({"detail": f"Invalid repository IDs: {e.args[0]}"}, status=400) + + add_seer_project_repos(project, repos_data) + + return Response(status=204) + + def put(self, request: Request, project: Project) -> Response: + serializer = SeerProjectReposBulkSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=400) + + repos_data = serializer.validated_data["repos"] + + if repos_data: + try: + _validate_repo_ids(project, [d["repository_id"] for d in repos_data]) + except ValueError as e: + return Response({"detail": f"Invalid repository IDs: {e.args[0]}"}, status=400) + + replace_all_seer_project_repos(project, repos_data) + + return Response(status=204) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 997ea98e30ee..93a197daf2d0 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -722,6 +722,7 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/rules/preview/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/night-shift/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/preferences/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/repos/$repoId/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/seer/settings/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-coverage/' diff --git a/tests/sentry/seer/endpoints/test_project_seer_repos.py b/tests/sentry/seer/endpoints/test_project_seer_repos.py index 8987327ef5a1..883a46829815 100644 --- a/tests/sentry/seer/endpoints/test_project_seer_repos.py +++ b/tests/sentry/seer/endpoints/test_project_seer_repos.py @@ -1,6 +1,7 @@ from django.urls import reverse from sentry.constants import ObjectStatus +from sentry.models.repository import Repository from sentry.seer.models.project_repository import ( SeerProjectRepository, SeerProjectRepositoryBranchOverride, @@ -170,19 +171,10 @@ def test_partial_update_preserves_branch_overrides(self): 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) + project_repo = self.create_seer_project_repository(self.project, repository=self.repo1) SeerProjectRepositoryBranchOverride.objects.create( - seer_project_repository=pr, + seer_project_repository=project_repo, tag_name="environment", tag_value="production", branch_name="release", @@ -191,10 +183,21 @@ def test_clear_branch_overrides(self): response = self.get_success_response(branchOverrides=[]) assert response.data["branchOverrides"] == [] assert ( - SeerProjectRepositoryBranchOverride.objects.filter(seer_project_repository=pr).count() + SeerProjectRepositoryBranchOverride.objects.filter( + seer_project_repository=project_repo + ).count() == 0 ) + 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_not_connected_returns_404(self): + self.get_error_response(branchName="main", status_code=404) + def test_inactive_repo_returns_404(self): self.create_seer_project_repository(self.project, repository=self.repo1) self.repo1.status = ObjectStatus.PENDING_DELETION @@ -214,6 +217,12 @@ def test_empty_body_returns_400(self): self.get_error_response(status_code=400) + def test_other_project_repo_returns_404(self): + other_project = self.create_project(organization=self.organization) + self.create_seer_project_repository(other_project, repository=self.repo1) + + self.get_error_response(branchName="develop", status_code=404) + def test_duplicate_branch_overrides_returns_400(self): self.create_seer_project_repository(self.project, repository=self.repo1) @@ -240,12 +249,6 @@ def test_branch_overrides_does_not_bump_date_updated(self): project_repo.refresh_from_db() assert project_repo.date_updated == original_date_updated - def test_other_project_repo_returns_404(self): - other_project = self.create_project(organization=self.organization) - self.create_seer_project_repository(other_project, repository=self.repo1) - - self.get_error_response(branchName="develop", status_code=404) - class ProjectSeerRepoDeleteTest(APITestCase): endpoint = "sentry-api-0-project-seer-repo" @@ -314,3 +317,442 @@ def test_other_project_repo_returns_404(self): self.create_seer_project_repository(other_project, repository=self.repo1) self.get_error_response(status_code=404) + + +class ProjectSeerReposGetTest(APITestCase): + endpoint = "sentry-api-0-project-seer-repos" + + def reverse_url(self): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.integration = self.create_integration( + organization=self.organization, provider="github", external_id="ext123" + ) + self.repo1 = self.create_repo( + project=self.project, + name="getsentry/sentry", + provider="integrations:github", + external_id="111", + integration_id=self.integration.id, + ) + self.repo2 = self.create_repo( + project=self.project, + name="getsentry/relay", + provider="integrations:github", + external_id="222", + integration_id=self.integration.id, + ) + + def test_empty(self): + response = self.get_success_response() + assert len(response.data) == 0 + + def test_returns_connected_repos(self): + self.create_seer_project_repository( + self.project, + repository=self.repo1, + branch_name="main", + instructions="use pytest", + ) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response() + assert len(response.data) == 2 + + project_repos_by_name = {r["name"]: r for r in response.data} + project_repo_sentry = project_repos_by_name["sentry"] + assert project_repo_sentry["repositoryId"] == str(self.repo1.id) + assert project_repo_sentry["provider"] == "integrations:github" + assert project_repo_sentry["owner"] == "getsentry" + assert project_repo_sentry["externalId"] == "111" + assert project_repo_sentry["integrationId"] == str(self.integration.id) + assert project_repo_sentry["branchName"] == "main" + assert project_repo_sentry["instructions"] == "use pytest" + + project_repo_relay = project_repos_by_name["relay"] + assert project_repo_relay["repositoryId"] == str(self.repo2.id) + assert project_repo_relay["branchName"] is None + assert project_repo_relay["instructions"] is None + + def test_excludes_inactive_repos(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.repo1.status = ObjectStatus.HIDDEN + self.repo1.save() + + response = self.get_success_response() + assert len(response.data) == 0 + + def test_returns_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", + ) + + response = self.get_success_response() + assert len(response.data[0]["branchOverrides"]) == 1 + branch_overrides = response.data[0]["branchOverrides"][0] + assert branch_overrides["tagName"] == "environment" + assert branch_overrides["tagValue"] == "production" + assert branch_overrides["branchName"] == "release" + + def test_search_by_name(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response(qs_params={"query": "relay"}) + assert len(response.data) == 1 + assert response.data[0]["name"] == "relay" + + def test_search_by_name_exclude(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response(qs_params={"query": "!name:relay"}) + assert len(response.data) == 1 + assert response.data[0]["name"] == "sentry" + + def test_search_by_provider(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response(qs_params={"query": "provider:github"}) + assert len(response.data) == 2 + + response = self.get_success_response(qs_params={"query": "provider:integrations:github"}) + assert len(response.data) == 2 + + def test_sort_by_name_ascending(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response(qs_params={"sortBy": "name"}) + names = [r["name"] for r in response.data] + assert names == ["relay", "sentry"] + + def test_sort_by_name_descending(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + response = self.get_success_response(qs_params={"sortBy": "-name"}) + names = [r["name"] for r in response.data] + assert names == ["sentry", "relay"] + + def test_sort_by_provider(self): + gitlab_repo = self.create_repo( + project=self.project, + name="getsentry/other", + provider="integrations:github_enterprise", + external_id="333", + integration_id=self.integration.id, + ) + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=gitlab_repo) + + response = self.get_success_response(qs_params={"sortBy": "provider"}) + providers = [r["provider"] for r in response.data] + assert providers == sorted(providers) + + response = self.get_success_response(qs_params={"sortBy": "-provider"}) + providers = [r["provider"] for r in response.data] + assert providers == sorted(providers, reverse=True) + + def test_invalid_sort_field(self): + response = self.get_error_response(qs_params={"sortBy": "invalid"}, status_code=400) + assert "Invalid sortBy" in response.data["detail"] + + def test_invalid_search_query(self): + self.get_error_response(qs_params={"query": "invalid:field:value"}, status_code=400) + + def test_excludes_unsupported_providers(self): + unsupported_repo = self.create_repo( + project=self.project, + name="getsentry/other", + provider="integrations:bitbucket", + external_id="333", + ) + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=unsupported_repo) + + response = self.get_success_response() + assert len(response.data) == 1 + assert response.data[0]["name"] == "sentry" + + def test_other_project_repo_not_returned(self): + other_project = self.create_project(organization=self.organization) + self.create_seer_project_repository(other_project, repository=self.repo1) + + response = self.get_success_response() + assert len(response.data) == 0 + + +class ProjectSeerReposPostTest(APITestCase): + endpoint = "sentry-api-0-project-seer-repos" + method = "post" + + def reverse_url(self): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + + 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", + ) + self.repo2 = self.create_repo( + project=self.project, + name="getsentry/relay", + provider="integrations:github", + external_id="222", + ) + + def test_add_repos(self): + self.get_success_response( + repos=[ + { + "repositoryId": self.repo1.id, + "branchName": "main", + "instructions": "run tests", + }, + {"repositoryId": self.repo2.id}, + ], + status_code=204, + ) + + project_repos = SeerProjectRepository.objects.filter( + project_repository__project=self.project + ).select_related("project_repository") + assert project_repos.count() == 2 + by_repo_id = {pr.project_repository.repository_id: pr for pr in project_repos} + assert by_repo_id[self.repo1.id].branch_name == "main" + assert by_repo_id[self.repo1.id].instructions == "run tests" + assert by_repo_id[self.repo2.id].branch_name is None + + def test_add_repos_with_branch_overrides(self): + self.get_success_response( + repos=[ + { + "repositoryId": self.repo1.id, + "branchOverrides": [ + { + "tagName": "environment", + "tagValue": "production", + "branchName": "release", + } + ], + } + ], + status_code=204, + ) + + project_repo = SeerProjectRepository.objects.get( + project_repository__project=self.project, + project_repository__repository=self.repo1, + ) + overrides = list(project_repo.branch_overrides.all()) + assert len(overrides) == 1 + assert overrides[0].branch_name == "release" + + def test_empty_repos_returns_400(self): + response = self.get_error_response(repos=[], status_code=400) + assert "repos must not be empty" in response.data["detail"] + + def test_invalid_repo_id_returns_400(self): + response = self.get_error_response(repos=[{"repositoryId": 99999}], status_code=400) + assert "Invalid repository IDs" in response.data["detail"] + + def test_other_org_repo_returns_400(self): + other_org = self.create_organization(owner=self.user) + other_repo = Repository.objects.create( + organization_id=other_org.id, name="other/repo", provider="github", external_id="999" + ) + + self.get_error_response(repos=[{"repositoryId": other_repo.id}], status_code=400) + + def test_unsupported_provider_returns_400(self): + unsupported_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/unsupported", + provider="integrations:gitlab", + external_id="999", + ) + + self.get_error_response(repos=[{"repositoryId": unsupported_repo.id}], status_code=400) + + def test_upsert_existing_repo(self): + self.create_seer_project_repository( + self.project, repository=self.repo1, branch_name="old-branch" + ) + + self.get_success_response( + repos=[{"repositoryId": self.repo1.id, "branchName": "new-branch"}], + status_code=204, + ) + + project_repo = SeerProjectRepository.objects.get( + project_repository__project=self.project, + project_repository__repository=self.repo1, + ) + assert project_repo.branch_name == "new-branch" + + def test_inactive_repo_returns_400(self): + self.repo1.status = ObjectStatus.HIDDEN + self.repo1.save() + + self.get_error_response(repos=[{"repositoryId": self.repo1.id}], status_code=400) + + def test_duplicate_branch_override_returns_400(self): + self.get_error_response( + repos=[ + { + "repositoryId": self.repo1.id, + "branchOverrides": [ + { + "tagName": "environment", + "tagValue": "production", + "branchName": "release", + }, + { + "tagName": "environment", + "tagValue": "production", + "branchName": "hotfix", + }, + ], + } + ], + status_code=400, + ) + + def test_missing_repository_id_returns_400(self): + self.get_error_response(repos=[{"branchName": "main"}], status_code=400) + + def test_duplicate_repository_ids_returns_400(self): + self.get_error_response( + repos=[ + {"repositoryId": self.repo1.id}, + {"repositoryId": self.repo1.id}, + ], + status_code=400, + ) + + +class ProjectSeerReposPutTest(APITestCase): + endpoint = "sentry-api-0-project-seer-repos" + method = "put" + + def reverse_url(self): + return reverse( + self.endpoint, + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + + 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", + ) + self.repo2 = self.create_repo( + project=self.project, + name="getsentry/relay", + provider="integrations:github", + external_id="222", + ) + + def test_replace_all_repos(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + + self.get_success_response( + repos=[{"repositoryId": self.repo2.id, "branchName": "develop"}], + status_code=204, + ) + + assert not SeerProjectRepository.objects.filter( + project_repository__project=self.project, + project_repository__repository=self.repo1, + ).exists() + new_repo = SeerProjectRepository.objects.get( + project_repository__project=self.project, + project_repository__repository=self.repo2, + ) + assert new_repo.branch_name == "develop" + + def test_replace_with_empty_clears_all(self): + self.create_seer_project_repository(self.project, repository=self.repo1) + self.create_seer_project_repository(self.project, repository=self.repo2) + + self.get_success_response(repos=[], status_code=204) + assert ( + SeerProjectRepository.objects.filter(project_repository__project=self.project).count() + == 0 + ) + + def test_replace_invalid_repo_returns_400(self): + self.get_error_response(repos=[{"repositoryId": 99999}], status_code=400) + + def test_replace_with_branch_overrides(self): + self.get_success_response( + repos=[ + { + "repositoryId": self.repo1.id, + "branchOverrides": [ + { + "tagName": "environment", + "tagValue": "staging", + "branchName": "staging-branch", + } + ], + } + ], + status_code=204, + ) + + project_repo = SeerProjectRepository.objects.get( + project_repository__project=self.project, + project_repository__repository=self.repo1, + ) + assert project_repo.branch_overrides.count() == 1 + + def test_replace_unsupported_provider_returns_400(self): + unsupported_repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/unsupported", + provider="integrations:gitlab", + external_id="999", + ) + + self.get_error_response(repos=[{"repositoryId": unsupported_repo.id}], status_code=400) + + def test_other_org_repo_returns_400(self): + other_org = self.create_organization(owner=self.user) + other_repo = Repository.objects.create( + organization_id=other_org.id, name="other/repo", provider="github", external_id="999" + ) + + self.get_error_response(repos=[{"repositoryId": other_repo.id}], status_code=400)