From 4ea453c96c07185f2222128a7dcd11bff457452c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 18 May 2026 14:05:03 -0700 Subject: [PATCH 1/2] feat(repositories): Add project repo-link endpoint POST /projects/{org}/{project}/repo-link/ creates a ProjectRepository linking a project to an existing repository. Used by the SCM onboarding flow to persist the user's repo selection. Idempotent: returns 200 if the link already exists, 201 if created. Scopes the repo lookup to the project's organization to prevent IDOR. Co-Authored-By: Claude Opus 4 --- src/sentry/api/endpoints/project_repo_link.py | 79 +++++++++++++++ src/sentry/api/urls.py | 6 ++ .../api/endpoints/test_project_repo_link.py | 95 +++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 src/sentry/api/endpoints/project_repo_link.py create mode 100644 tests/sentry/api/endpoints/test_project_repo_link.py diff --git a/src/sentry/api/endpoints/project_repo_link.py b/src/sentry/api/endpoints/project_repo_link.py new file mode 100644 index 00000000000000..f12defb9ee8a09 --- /dev/null +++ b/src/sentry/api/endpoints/project_repo_link.py @@ -0,0 +1,79 @@ +from typing import Any + +from rest_framework import serializers, status +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.project import ProjectEndpoint, ProjectPermission +from sentry.constants import ObjectStatus +from sentry.models.project import Project +from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource +from sentry.models.repository import Repository + + +class ProjectRepoLinkSerializer(serializers.Serializer[ProjectRepository]): + repositoryId = serializers.IntegerField(required=True) + + def __init__(self, *args: Any, project: Project, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.project = project + self.repository: Repository | None = None + + def validate_repositoryId(self, value: int) -> int: + try: + self.repository = Repository.objects.get( + id=value, + organization_id=self.project.organization_id, + status=ObjectStatus.ACTIVE, + ) + except Repository.DoesNotExist: + raise serializers.ValidationError("Repository not found.") + return value + + def create(self, validated_data: dict[str, Any]) -> ProjectRepository: + assert self.repository is not None + project_repo, created = ProjectRepository.objects.get_or_create( + project=self.project, + repository=self.repository, + defaults={"source": ProjectRepositorySource.SCM_ONBOARDING}, + ) + self._created = created + return project_repo + + +@cell_silo_endpoint +class ProjectRepoLinkEndpoint(ProjectEndpoint): + owner = ApiOwner.ISSUES + publish_status = { + "POST": ApiPublishStatus.PRIVATE, + } + permission_classes = (ProjectPermission,) + + def post(self, request: Request, project: Project) -> Response: + serializer = ProjectRepoLinkSerializer(data=request.data, project=project) + if not serializer.is_valid(): + errors = serializer.errors + repo_errors = errors.get("repositoryId", []) + if any("not found" in str(e).lower() for e in repo_errors): + return Response( + {"detail": repo_errors[0]}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(errors, status=status.HTTP_400_BAD_REQUEST) + + project_repo = serializer.save() + created = serializer._created + + return Response( + { + "id": str(project_repo.id), + "projectId": str(project.id), + "repositoryId": str(project_repo.repository_id), + "source": project_repo.get_source_display(), + "created": created, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 7f9b48643460f1..ea23503c548721 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -829,6 +829,7 @@ ProjectProfilingRawChunkEndpoint, ProjectProfilingRawProfileEndpoint, ) +from .endpoints.project_repo_link import ProjectRepoLinkEndpoint from .endpoints.project_repo_path_parsing import ProjectRepoPathParsingEndpoint from .endpoints.project_reprocessing import ProjectReprocessingEndpoint from .endpoints.project_rule_actions import ProjectRuleActionsEndpoint @@ -3277,6 +3278,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ProjectStacktraceSourceContextEndpoint.as_view(), name="sentry-api-0-project-stacktrace-source-context", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/repo-link/$", + ProjectRepoLinkEndpoint.as_view(), + name="sentry-api-0-project-repo-link", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/repo-path-parsing/$", ProjectRepoPathParsingEndpoint.as_view(), diff --git a/tests/sentry/api/endpoints/test_project_repo_link.py b/tests/sentry/api/endpoints/test_project_repo_link.py new file mode 100644 index 00000000000000..c5ebb75b3e1f5d --- /dev/null +++ b/tests/sentry/api/endpoints/test_project_repo_link.py @@ -0,0 +1,95 @@ +from sentry.constants import ObjectStatus +from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource +from sentry.models.repository import Repository +from sentry.testutils.cases import APITestCase + + +class ProjectRepoLinkTest(APITestCase): + endpoint = "sentry-api-0-project-repo-link" + method = "post" + + def setUp(self) -> None: + super().setUp() + self.login_as(self.user) + self.repo = Repository.objects.create( + organization_id=self.organization.id, + name="getsentry/sentry", + provider="integrations:github", + external_id="123", + ) + + def test_creates_link(self) -> None: + response = self.get_success_response( + self.organization.slug, + self.project.slug, + repositoryId=self.repo.id, + status_code=201, + ) + assert response.data["repositoryId"] == str(self.repo.id) + assert response.data["projectId"] == str(self.project.id) + assert response.data["created"] is True + + pr = ProjectRepository.objects.get(project=self.project, repository=self.repo) + assert pr.source == ProjectRepositorySource.SCM_ONBOARDING + + def test_idempotent(self) -> None: + ProjectRepository.objects.create( + project=self.project, + repository=self.repo, + source=ProjectRepositorySource.MANUAL, + ) + + response = self.get_success_response( + self.organization.slug, + self.project.slug, + repositoryId=self.repo.id, + status_code=200, + ) + assert response.data["created"] is False + assert response.data["source"] == "manual" + assert ( + ProjectRepository.objects.filter(project=self.project, repository=self.repo).count() + == 1 + ) + + def test_repo_not_found(self) -> None: + self.get_error_response( + self.organization.slug, + self.project.slug, + repositoryId=999999, + status_code=404, + ) + + def test_repo_from_other_org(self) -> None: + other_org = self.create_organization() + other_repo = Repository.objects.create( + organization_id=other_org.id, + name="other/repo", + provider="integrations:github", + external_id="456", + ) + + self.get_error_response( + self.organization.slug, + self.project.slug, + repositoryId=other_repo.id, + status_code=404, + ) + + def test_inactive_repo(self) -> None: + self.repo.status = ObjectStatus.HIDDEN + self.repo.save() + + self.get_error_response( + self.organization.slug, + self.project.slug, + repositoryId=self.repo.id, + status_code=404, + ) + + def test_missing_repository_id(self) -> None: + self.get_error_response( + self.organization.slug, + self.project.slug, + status_code=400, + ) From c37b3518179e4dc961b5432ff25574de7684cfcd Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 21:45:42 +0000 Subject: [PATCH 2/2] :hammer_and_wrench: Sync API Urls to TypeScript --- static/app/utils/api/knownSentryApiUrls.generated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 66fe4ab287ca2a..d05148c5842284 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -714,6 +714,7 @@ export type KnownSentryApiUrls = | '/projects/$organizationIdOrSlug/$projectIdOrSlug/replays/$replayId/viewed-by/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/replays/jobs/delete/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/replays/jobs/delete/$jobId/' + | '/projects/$organizationIdOrSlug/$projectIdOrSlug/repo-link/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/repo-path-parsing/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/reprocessing/' | '/projects/$organizationIdOrSlug/$projectIdOrSlug/rule-actions/'