From e018ca68979fcd4426579d7e67664d66e538c0f8 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Mon, 31 Aug 2026 10:11:54 -0700 Subject: [PATCH] feat(api): Publish CODEOWNERS collection endpoint Expose list and create operations with typed API schemas. Add public API documentation and permission coverage. Refs ENG-7807 Co-authored-by: Claude --- .../serializers/models/projectcodeowners.py | 82 +++++++---- .../api/validators/project_codeowners.py | 24 ++-- .../apidocs/examples/codeowners_examples.py | 56 ++++++++ .../models/repository_project_path_config.py | 32 ++++- .../endpoints/project_codeowners_index.py | 135 +++++++++++------- src/sentry/issues/endpoints/serializers.py | 9 ++ .../projects/test_project_codeowners.py | 53 +++++++ .../test_organization_agent_token.py | 17 +++ 8 files changed, 319 insertions(+), 89 deletions(-) create mode 100644 src/sentry/apidocs/examples/codeowners_examples.py create mode 100644 tests/apidocs/endpoints/projects/test_project_codeowners.py diff --git a/src/sentry/api/serializers/models/projectcodeowners.py b/src/sentry/api/serializers/models/projectcodeowners.py index 0b617b4afd5e..02763580c398 100644 --- a/src/sentry/api/serializers/models/projectcodeowners.py +++ b/src/sentry/api/serializers/models/projectcodeowners.py @@ -1,4 +1,6 @@ import logging +from datetime import datetime +from typing import TypedDict from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.projectownership import ( @@ -6,8 +8,10 @@ OwnershipRuleResponse, OwnershipSchemaResponse, ) +from sentry.api.validators.project_codeowners import CodeOwnersErrors, build_codeowners_associations from sentry.integrations.api.serializers.models.repository_project_path_config import ( RepositoryProjectPathConfigSerializer, + RepositoryProjectPathConfigSerializerResponse, ) from sentry.integrations.services.integration import integration_service from sentry.integrations.source_code_management.repository import RepositoryIntegration @@ -43,8 +47,33 @@ def _serialize_ownership_schema(schema: OwnershipSchema) -> OwnershipSchemaRespo return {"$version": schema["$version"], "rules": serialized_rules} +class EmptyOwnershipSchemaResponse(TypedDict): + """Legacy CODEOWNERS records can have an empty schema before it is built.""" + + +class ProjectCodeOwnersResponseOptional(TypedDict, total=False): + codeMapping: RepositoryProjectPathConfigSerializerResponse + ownershipSyntax: str + errors: CodeOwnersErrors + schema: OwnershipSchemaResponse | EmptyOwnershipSchemaResponse + codeOwnersUrl: str + + +class ProjectCodeOwnersResponse(ProjectCodeOwnersResponseOptional): + id: str + raw: str + dateCreated: datetime + dateUpdated: datetime + dateSynced: datetime | None + codeMappingId: str + provider: str + + +DEFAULT_CODEOWNERS_EXPAND = ("errors", "hasTargetingContext") + + @register(ProjectCodeOwners) -class ProjectCodeOwnersSerializer(Serializer): +class ProjectCodeOwnersSerializer(Serializer[ProjectCodeOwnersResponse]): def __init__( self, expand=None, @@ -56,47 +85,43 @@ def get_attrs(self, item_list, user, **kwargs): integrations = { i.id: i for i in integration_service.get_integrations( - integration_ids=[i.repository_project_path_config.integration_id for i in item_list] + integration_ids=[ + item.repository_project_path_config.integration_id for item in item_list + ] ) } for item in item_list: code_mapping = item.repository_project_path_config repository = code_mapping.project_repository.repository - integration = integrations[item.repository_project_path_config.integration_id] - install = integration.get_installation( - organization_id=item.repository_project_path_config.organization_id, - ) + integration = integrations.get(code_mapping.integration_id) + provider = "unknown" codeowners_url = "unknown" - if item.repository_project_path_config.organization_integration_id and ( - isinstance(install, RepositoryIntegration) - ): + if integration and code_mapping.organization_integration_id: + provider = integration.provider try: - codeowners_response = install.get_codeowner_file( - repository, ref=code_mapping.default_branch + install = integration.get_installation( + organization_id=code_mapping.organization_id, ) - if codeowners_response is not None: - codeowners_url = codeowners_response["html_url"] - + if isinstance(install, RepositoryIntegration): + codeowners_response = install.get_codeowner_file( + repository, ref=code_mapping.default_branch + ) + if codeowners_response is not None: + codeowners_url = codeowners_response["html_url"] except Exception: logger.exception("Could not get CODEOWNERS URL. Continuing execution.") attrs[item] = { - "provider": ( - integration.provider - if item.repository_project_path_config.organization_integration_id - else "unknown" - ), + "provider": provider, "codeMapping": code_mapping, "codeOwnersUrl": codeowners_url, } return attrs - def serialize(self, obj, attrs, user, **kwargs): - from sentry.api.validators.project_codeowners import build_codeowners_associations - - data = { + def serialize(self, obj, attrs, user, **kwargs) -> ProjectCodeOwnersResponse: + data: ProjectCodeOwnersResponse = { "id": str(obj.id), "raw": obj.raw, "dateCreated": obj.date_added, @@ -107,13 +132,18 @@ def serialize(self, obj, attrs, user, **kwargs): } if "codeMapping" in self.expand: - config = attrs.get("codeMapping", {}) data["codeMapping"] = serialize( - config, user=user, serializer=RepositoryProjectPathConfigSerializer() + attrs["codeMapping"], + user=user, + serializer=RepositoryProjectPathConfigSerializer(), ) if "ownershipSyntax" in self.expand: - data["ownershipSyntax"] = convert_schema_to_rules_text(obj.schema) + data["ownershipSyntax"] = ( + convert_schema_to_rules_text(obj.schema) + if obj.schema and "$version" in obj.schema + else "" + ) if "errors" in self.expand: _, errors = build_codeowners_associations(obj.raw, obj.project) diff --git a/src/sentry/api/validators/project_codeowners.py b/src/sentry/api/validators/project_codeowners.py index c022163de022..27424271c961 100644 --- a/src/sentry/api/validators/project_codeowners.py +++ b/src/sentry/api/validators/project_codeowners.py @@ -1,8 +1,8 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Collection, Mapping, Sequence -from typing import Any +from collections.abc import Collection, Sequence +from typing import TypedDict from django.db.models.functions import Lower @@ -17,6 +17,14 @@ from sentry.users.services.user.service import user_service +class CodeOwnersErrors(TypedDict): + missing_user_emails: list[str] + missing_external_users: list[str] + missing_external_teams: list[str] + teams_without_access: list[str] + users_without_access: list[str] + + def find_missing_associations( parsed_items: Sequence[str], associated_items: Collection[str], @@ -26,7 +34,7 @@ def find_missing_associations( def build_codeowners_associations( codeowners: str, project: Project -) -> tuple[Mapping[str, Any], Mapping[str, Any]]: +) -> tuple[dict[str, str], CodeOwnersErrors]: """ Build a dict of {external_name: sentry_name} associations for a raw codeowners file. Returns only the actors that exist and have access to the project. @@ -58,8 +66,8 @@ def build_codeowners_associations( external_actors = [] # Convert CODEOWNERS into IssueOwner syntax - users_dict = {} - teams_dict = {} + users_dict: dict[str, str] = {} + teams_dict: dict[str, str] = {} teams_without_access = set() teams_without_access_external_names = set() @@ -136,16 +144,16 @@ def build_codeowners_associations( teams_without_access.add(f"#{team.slug}") teams_without_access_external_names.update(team_ids_to_external_names[team.id]) - emails_dict = {} + emails_dict: dict[str, str] = {} user_emails = set() for user in users: for user_email in user.emails: emails_dict[user_email] = user_email user_emails.add(user_email) - associations = {**users_dict, **teams_dict, **emails_dict} + associations: dict[str, str] = {**users_dict, **teams_dict, **emails_dict} - errors = { + errors: CodeOwnersErrors = { "missing_user_emails": find_missing_associations(emails, user_emails), "missing_external_users": find_missing_associations( usernames, set(associations.keys()) | users_without_access_external_names diff --git a/src/sentry/apidocs/examples/codeowners_examples.py b/src/sentry/apidocs/examples/codeowners_examples.py new file mode 100644 index 000000000000..b4d54a156525 --- /dev/null +++ b/src/sentry/apidocs/examples/codeowners_examples.py @@ -0,0 +1,56 @@ +from drf_spectacular.utils import OpenApiExample + +PROJECT_CODEOWNERS_RESPONSE = { + "id": "42", + "raw": "src/* user@example.com", + "dateCreated": "2024-01-15T10:30:00Z", + "dateUpdated": "2024-01-15T10:30:00Z", + "dateSynced": "2024-01-15T10:30:00Z", + "codeMappingId": "7", + "provider": "github", + "errors": { + "missing_external_teams": [], + "missing_external_users": [], + "missing_user_emails": [], + "teams_without_access": [], + "users_without_access": [], + }, + "schema": { + "$version": 1, + "rules": [ + { + "matcher": {"type": "codeowners", "pattern": "src/*"}, + "owners": [{"type": "user", "id": "1", "name": "user@example.com"}], + } + ], + }, + "codeOwnersUrl": "https://github.com/example/repository/blob/main/CODEOWNERS", +} + +PROJECT_CODEOWNERS_RESPONSE_WITH_OWNERSHIP_SYNTAX = { + **PROJECT_CODEOWNERS_RESPONSE, + "ownershipSyntax": "codeowners:src/* user@example.com\n", +} + +LIST_PROJECT_CODEOWNERS = [ + OpenApiExample( + "List a project's CODEOWNERS configurations", + value=[PROJECT_CODEOWNERS_RESPONSE], + status_codes=["200"], + response_only=True, + ) +] + +CREATE_PROJECT_CODEOWNERS = [ + OpenApiExample( + "Create a CODEOWNERS configuration", + value={"raw": "src/* user@example.com", "codeMappingId": "7"}, + request_only=True, + ), + OpenApiExample( + "Created CODEOWNERS configuration", + value=PROJECT_CODEOWNERS_RESPONSE_WITH_OWNERSHIP_SYNTAX, + status_codes=["201"], + response_only=True, + ), +] diff --git a/src/sentry/integrations/api/serializers/models/repository_project_path_config.py b/src/sentry/integrations/api/serializers/models/repository_project_path_config.py index 0522bbfd2270..a79f740546d0 100644 --- a/src/sentry/integrations/api/serializers/models/repository_project_path_config.py +++ b/src/sentry/integrations/api/serializers/models/repository_project_path_config.py @@ -1,14 +1,35 @@ +from typing import TypedDict + from django.db.models import prefetch_related_objects from sentry.api.serializers import Serializer, register -from sentry.integrations.api.serializers.models.integration import serialize_provider +from sentry.integrations.api.serializers.models.integration import ( + IntegrationProviderInfo, + serialize_provider, +) from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcIntegration +class RepositoryProjectPathConfigSerializerResponse(TypedDict): + id: str + projectId: str + projectSlug: str + repoId: str + repoName: str + integrationId: str | None + provider: IntegrationProviderInfo | None + stackRoot: str + sourceRoot: str + defaultBranch: str | None + automaticallyGenerated: bool + + @register(RepositoryProjectPathConfig) -class RepositoryProjectPathConfigSerializer(Serializer): +class RepositoryProjectPathConfigSerializer( + Serializer[RepositoryProjectPathConfigSerializerResponse] +): def get_attrs(self, item_list, user, **kwargs): if not item_list: return {} @@ -51,7 +72,9 @@ def get_attrs(self, item_list, user, **kwargs): for item in item_list } - def serialize(self, obj, attrs, user, **kwargs): + def serialize( + self, obj, attrs, user, **kwargs + ) -> RepositoryProjectPathConfigSerializerResponse: integration = attrs.get("integration") provider = integration.get_provider() if integration else None @@ -61,7 +84,7 @@ def serialize(self, obj, attrs, user, **kwargs): project = obj.project_repository.project repository = obj.project_repository.repository - return { + response: RepositoryProjectPathConfigSerializerResponse = { "id": str(obj.id), "projectId": str(project.id), "projectSlug": project.slug, @@ -74,3 +97,4 @@ def serialize(self, obj, attrs, user, **kwargs): "defaultBranch": obj.default_branch, "automaticallyGenerated": obj.automatically_generated, } + return response diff --git a/src/sentry/issues/endpoints/project_codeowners_index.py b/src/sentry/issues/endpoints/project_codeowners_index.py index f56c74b9fe26..22a711cc9ec0 100644 --- a/src/sentry/issues/endpoints/project_codeowners_index.py +++ b/src/sentry/issues/endpoints/project_codeowners_index.py @@ -1,4 +1,5 @@ import sentry_sdk +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request @@ -10,61 +11,102 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.serializers import serialize -from sentry.api.serializers.models import projectcodeowners as projectcodeowners_serializers +from sentry.api.serializers.models.projectcodeowners import ( + DEFAULT_CODEOWNERS_EXPAND, + ProjectCodeOwnersResponse, + ProjectCodeOwnersSerializer, +) +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.examples import codeowners_examples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.issues.endpoints.bases.codeowners import ProjectCodeOwnersBase -from sentry.issues.endpoints.serializers import ProjectCodeOwnerSerializer +from sentry.issues.endpoints.serializers import ( + ProjectCodeOwnersCreateRequestSerializer, + ProjectCodeOwnerSerializer, +) from sentry.models.project import Project from sentry.models.projectcodeowners import ProjectCodeOwners @cell_silo_endpoint +@extend_schema(tags=["Projects"]) class ProjectCodeOwnersEndpoint(ProjectCodeOwnersBase): owner = ApiOwner.ISSUES publish_status = { - "GET": ApiPublishStatus.PRIVATE, - "POST": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PUBLIC, + "POST": ApiPublishStatus.PUBLIC, } - def get(self, request: Request, project: Project) -> Response: - """ - Retrieve the list of CODEOWNERS configurations for a project - ```````````````````````````````````````````` - - Return a list of a project's CODEOWNERS configuration. - - :auth: required - """ - + @extend_schema( + operation_id="listProjectCodeOwners", + summary="List a Project's CODEOWNERS Configurations", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + OpenApiParameter( + name="expand", + location=OpenApiParameter.QUERY, + required=False, + many=True, + type=str, + enum=["codeMapping", "ownershipSyntax"], + description="Optional fields to expand.", + ), + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "ProjectCodeOwnersList", list[ProjectCodeOwnersResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=codeowners_examples.LIST_PROJECT_CODEOWNERS, + ) + def get(self, request: Request, project: Project) -> Response[list[ProjectCodeOwnersResponse]]: + """Return the CODEOWNERS configurations for a project.""" if not self.has_feature(request, project): raise PermissionDenied - expand = request.GET.getlist("expand", []) - expand.extend(["errors", "renameIdentifier", "hasTargetingContext"]) + expand = [*request.GET.getlist("expand", []), *DEFAULT_CODEOWNERS_EXPAND] codeowners: list[ProjectCodeOwners] = list( ProjectCodeOwners.objects.filter(project=project).order_by("-date_added") ) - return Response( - serialize( - codeowners, - request.user, - serializer=projectcodeowners_serializers.ProjectCodeOwnersSerializer(expand=expand), - ), - status.HTTP_200_OK, + body: list[ProjectCodeOwnersResponse] = serialize( + codeowners, + request.user, + serializer=ProjectCodeOwnersSerializer(expand=expand), ) - - def post(self, request: Request, project: Project) -> Response: - """ - Upload a CODEOWNERS for a project - ````````````` - - :pparam string organization_id_or_slug: the id or slug of the organization. - :pparam string project_id_or_slug: the id or slug of the project to get. - :param string raw: the raw CODEOWNERS text - :param string codeMappingId: id of the RepositoryProjectPathConfig object - :auth: required - """ + return Response(body, status=status.HTTP_200_OK) + + @extend_schema( + operation_id="createProjectCodeOwners", + summary="Create a CODEOWNERS Configuration for a Project", + parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], + request=ProjectCodeOwnersCreateRequestSerializer, + responses={ + 201: inline_sentry_response_serializer("ProjectCodeOwners", ProjectCodeOwnersResponse), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=codeowners_examples.CREATE_PROJECT_CODEOWNERS, + ) + def post( + self, request: Request, project: Project + ) -> Response[ProjectCodeOwnersResponse] | Response[ValidationErrorResponse]: + """Create a CODEOWNERS configuration for a project.""" if not self.has_feature(request, project): self.track_response_code("create", PermissionDenied.status_code) raise PermissionDenied @@ -87,23 +129,14 @@ def post(self, request: Request, project: Project) -> Response: except Exception as e: sentry_sdk.capture_exception(e) - expand = [ - "ownershipSyntax", - "errors", - "renameIdentifier", - "hasTargetingContext", - ] - - return Response( - serialize( - project_codeowners, - request.user, - serializer=projectcodeowners_serializers.ProjectCodeOwnersSerializer( - expand=expand - ), + body: ProjectCodeOwnersResponse = serialize( + project_codeowners, + request.user, + serializer=ProjectCodeOwnersSerializer( + expand=(*DEFAULT_CODEOWNERS_EXPAND, "ownershipSyntax") ), - status=status.HTTP_201_CREATED, ) + return Response(body, status=status.HTTP_201_CREATED) self.track_response_code("create", status.HTTP_400_BAD_REQUEST) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(as_validation_errors(serializer), status=status.HTTP_400_BAD_REQUEST) diff --git a/src/sentry/issues/endpoints/serializers.py b/src/sentry/issues/endpoints/serializers.py index ab4082566a94..e0ed915580a3 100644 --- a/src/sentry/issues/endpoints/serializers.py +++ b/src/sentry/issues/endpoints/serializers.py @@ -21,6 +21,15 @@ from sentry.utils.codeowners import MAX_RAW_LENGTH +class ProjectCodeOwnersCreateRequestSerializer(serializers.Serializer[dict[str, str]]): + raw = serializers.CharField( + help_text="The raw contents of the CODEOWNERS file.", + ) + codeMappingId = serializers.CharField( + help_text="The ID of the code mapping used to translate repository paths to stack trace paths.", + ) + + class ProjectCodeOwnerSerializer(CamelSnakeModelSerializer[ProjectCodeOwners]): code_mapping_id = serializers.IntegerField(required=True) raw = serializers.CharField(required=True) diff --git a/tests/apidocs/endpoints/projects/test_project_codeowners.py b/tests/apidocs/endpoints/projects/test_project_codeowners.py new file mode 100644 index 000000000000..b4d3928c25df --- /dev/null +++ b/tests/apidocs/endpoints/projects/test_project_codeowners.py @@ -0,0 +1,53 @@ +from unittest.mock import patch + +from django.test.client import RequestFactory +from django.urls import reverse + +from fixtures.apidocs_test_case import APIDocsTestCase + + +class ProjectCodeOwnersDocs(APIDocsTestCase): + def setUp(self) -> None: + self.login_as(user=self.user) + self.code_mapping = self.create_code_mapping(project=self.project, default_branch=None) + self.data = { + "raw": f"src/* {self.user.email}", + "codeMappingId": str(self.code_mapping.id), + } + self.list_url = reverse( + "sentry-api-0-project-codeowners", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + self.codeowner_patcher = patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://example.com/CODEOWNERS"}, + ) + self.codeowner_patcher.start() + self.addCleanup(self.codeowner_patcher.stop) + + def create_project_codeowners(self) -> str: + with self.feature("organizations:integrations-codeowners"): + response = self.client.post(self.list_url, self.data) + assert response.status_code == 201, response.content + codeowners_id = response.data["id"] + assert isinstance(codeowners_id, str) + return codeowners_id + + def test_get_list(self) -> None: + self.create_project_codeowners() + url = f"{self.list_url}?expand=codeMapping" + with self.feature("organizations:integrations-codeowners"): + response = self.client.get(url) + request = RequestFactory().get(url) + + self.validate_schema(request, response) + + def test_post(self) -> None: + with self.feature("organizations:integrations-codeowners"): + response = self.client.post(self.list_url, self.data) + request = RequestFactory().post(self.list_url, self.data) + + self.validate_schema(request, response) diff --git a/tests/sentry/seer/endpoints/test_organization_agent_token.py b/tests/sentry/seer/endpoints/test_organization_agent_token.py index 62ed3ad9c38c..c0a1d71e81e9 100644 --- a/tests/sentry/seer/endpoints/test_organization_agent_token.py +++ b/tests/sentry/seer/endpoints/test_organization_agent_token.py @@ -895,6 +895,18 @@ def _resource(self, name: str) -> Any: provider="github", name="Matrix GitHub integration", ) + elif name == "code_mapping": + _integration, organization_integration = self.create_provider_integration_for( + self.org, + self.owner, + provider="example", + external_id=f"matrix-codeowners-{uuid4()}", + name="Matrix CODEOWNERS integration", + ) + resource = self.create_code_mapping( + project=self.project, + organization_integration=organization_integration, + ) elif name == "data_forwarder": resource = self.create_data_forwarder( organization=self.org, @@ -1310,6 +1322,7 @@ def _feature_flags( "OrganizationTraceItemAttributesEndpoint": "organizations:visibility-explore-view", "OrganizationTraceItemMetricsEndpoint": "organizations:visibility-explore-view", "ProjectProfilingProfileEndpoint": "organizations:profiling", + "ProjectCodeOwnersEndpoint": "organizations:integrations-codeowners", } if feature := endpoint_flags.get(endpoint.endpoint_name): flags[feature] = True @@ -1607,6 +1620,10 @@ def _mutation_payload(self, endpoint: PublicMutationEndpoint) -> dict[str, Any]: ("ProjectReleaseFilesEndpoint", "POST"): { "name": "https://example.com/permission-matrix.js" }, + ("ProjectCodeOwnersEndpoint", "POST"): { + "raw": f"src/* {self.owner.email}", + "codeMappingId": str(self._resource("code_mapping").id), + }, ("GroupIntegrationDetailsEndpoint", "POST"): {"assignee": "matrix@example.com"}, ("GroupIntegrationDetailsEndpoint", "PUT"): {"externalIssue": "MATRIX-456"}, ("OrganizationMemberDetailsEndpoint", "PUT"): {"role": "manager"},