diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 1f6a72684793ef..b1cd973ef29f63 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -304,6 +304,8 @@ from sentry.issues.endpoints.organization_issues_resolved_in_release import ( OrganizationIssuesResolvedInReleaseEndpoint, ) +from sentry.issues.endpoints.project_codeowners_details import ProjectCodeOwnersDetailsEndpoint +from sentry.issues.endpoints.project_codeowners_index import ProjectCodeOwnersEndpoint from sentry.issues.endpoints.project_user_issue import ProjectUserIssueEndpoint from sentry.issues.endpoints.team_all_unresolved_issues import TeamAllUnresolvedIssuesEndpoint from sentry.issues.endpoints.team_issue_breakdown import TeamIssueBreakdownEndpoint @@ -548,7 +550,6 @@ from .endpoints.catchall import CatchallEndpoint from .endpoints.check_am2_compatibility import CheckAM2CompatibilityEndpoint from .endpoints.chunk import ChunkUploadEndpoint -from .endpoints.codeowners import ProjectCodeOwnersDetailsEndpoint, ProjectCodeOwnersEndpoint from .endpoints.custom_rules import CustomRulesEndpoint from .endpoints.data_scrubbing_selector_suggestions import DataScrubbingSelectorSuggestionsEndpoint from .endpoints.debug_files import ( diff --git a/src/sentry/issues/endpoints/project_codeowners_details.py b/src/sentry/issues/endpoints/project_codeowners_details.py new file mode 100644 index 00000000000000..6b1cfb5de0161e --- /dev/null +++ b/src/sentry/issues/endpoints/project_codeowners_details.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import logging +from typing import Any + +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import analytics +from sentry.analytics.events.codeowners_updated import CodeOwnersUpdated +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.project import ProjectEndpoint +from sentry.api.exceptions import ResourceDoesNotExist +from sentry.api.serializers import serialize +from sentry.api.serializers.models import projectcodeowners as projectcodeowners_serializers +from sentry.issues.serializers.codeowners import ProjectCodeOwnerSerializer, ProjectCodeOwnersMixin +from sentry.models.project import Project +from sentry.models.projectcodeowners import ProjectCodeOwners + +logger = logging.getLogger(__name__) + + +@region_silo_endpoint +class ProjectCodeOwnersDetailsEndpoint(ProjectEndpoint, ProjectCodeOwnersMixin): + owner = ApiOwner.ISSUES + publish_status = { + "DELETE": ApiPublishStatus.PRIVATE, + "PUT": ApiPublishStatus.PRIVATE, + } + + def convert_args( + self, + request: Request, + organization_id_or_slug: int | str, + project_id_or_slug: int | str, + codeowners_id: str, + *args: Any, + **kwargs: Any, + ) -> tuple[Any, Any]: + args, kwargs = super().convert_args( + request, organization_id_or_slug, project_id_or_slug, *args, **kwargs + ) + try: + kwargs["codeowners"] = ProjectCodeOwners.objects.get( + id=codeowners_id, project=kwargs["project"] + ) + except ProjectCodeOwners.DoesNotExist: + raise ResourceDoesNotExist + + return args, kwargs + + def put(self, request: Request, project: Project, codeowners: ProjectCodeOwners) -> Response: + """ + Update a CodeOwners + ````````````` + + :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. + :pparam string codeowners_id: id of codeowners object + :param string raw: the raw CODEOWNERS text + :param string codeMappingId: id of the RepositoryProjectPathConfig object + :auth: required + """ + if not self.has_feature(request, project): + self.track_response_code("update", PermissionDenied.status_code) + raise PermissionDenied + + serializer = ProjectCodeOwnerSerializer( + instance=codeowners, + context={"project": project}, + partial=True, + data={**request.data}, + ) + if serializer.is_valid(): + updated_codeowners = serializer.save() + + user_id = getattr(request.user, "id", None) or None + analytics.record( + CodeOwnersUpdated( + user_id=user_id, + organization_id=project.organization_id, + project_id=project.id, + codeowners_id=updated_codeowners.id, + ) + ) + self.track_response_code("update", status.HTTP_200_OK) + return Response( + serialize( + updated_codeowners, + request.user, + serializer=projectcodeowners_serializers.ProjectCodeOwnersSerializer( + expand=["ownershipSyntax", "errors"] + ), + ), + status=status.HTTP_200_OK, + ) + + self.track_response_code("update", status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request: Request, project: Project, codeowners: ProjectCodeOwners) -> Response: + """ + Delete a CodeOwners + """ + if not self.has_feature(request, project): + raise PermissionDenied + + codeowners.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/sentry/issues/endpoints/project_codeowners_index.py b/src/sentry/issues/endpoints/project_codeowners_index.py new file mode 100644 index 00000000000000..260bf4e45bad32 --- /dev/null +++ b/src/sentry/issues/endpoints/project_codeowners_index.py @@ -0,0 +1,137 @@ +import sentry_sdk +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry import analytics +from sentry.analytics.events.codeowners_created import CodeOwnersCreated +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import region_silo_endpoint +from sentry.api.bases.project import ProjectEndpoint +from sentry.api.serializers import serialize +from sentry.api.serializers.models import projectcodeowners as projectcodeowners_serializers +from sentry.api.validators.project_codeowners import validate_codeowners_associations +from sentry.issues.ownership.grammar import ( + convert_codeowners_syntax, + create_schema_from_issue_owners, +) +from sentry.issues.serializers.codeowners import ProjectCodeOwnerSerializer, ProjectCodeOwnersMixin +from sentry.models.project import Project +from sentry.models.projectcodeowners import ProjectCodeOwners + + +@region_silo_endpoint +class ProjectCodeOwnersEndpoint(ProjectEndpoint, ProjectCodeOwnersMixin): + owner = ApiOwner.ISSUES + publish_status = { + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, + } + + def refresh_codeowners_schema(self, codeowner: ProjectCodeOwners, project: Project) -> None: + if hasattr(codeowner, "schema") and ( + codeowner.schema is None or codeowner.schema.get("rules") is None + ): + return + + # Convert raw to issue owners syntax so that the schema can be created + raw = codeowner.raw + associations, _ = validate_codeowners_associations(codeowner.raw, project) + codeowner.raw = convert_codeowners_syntax( + codeowner.raw, + associations, + codeowner.repository_project_path_config, + ) + codeowner.schema = create_schema_from_issue_owners( + project_id=project.id, + issue_owners=codeowner.raw, + add_owner_ids=True, + remove_deleted_owners=True, + ) + + # Convert raw back to codeowner type to be saved + codeowner.raw = raw + + codeowner.save() + + def get(self, request: Request, project: Project) -> Response: + """ + Retrieve List of CODEOWNERS configurations for a project + ```````````````````````````````````````````` + + Return a list of a project's CODEOWNERS configuration. + + :auth: required + """ + + if not self.has_feature(request, project): + raise PermissionDenied + + expand = request.GET.getlist("expand", []) + expand.append("errors") + + codeowners = list(ProjectCodeOwners.objects.filter(project=project).order_by("-date_added")) + for codeowner in codeowners: + self.refresh_codeowners_schema(codeowner, project) + expand.append("renameIdentifier") + expand.append("hasTargetingContext") + + return Response( + serialize( + codeowners, + request.user, + serializer=projectcodeowners_serializers.ProjectCodeOwnersSerializer(expand=expand), + ), + status.HTTP_200_OK, + ) + + def post(self, request: Request, project: Project) -> Response: + """ + Upload a CODEOWNERS for 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 + """ + if not self.has_feature(request, project): + self.track_response_code("create", PermissionDenied.status_code) + raise PermissionDenied + + serializer = ProjectCodeOwnerSerializer(context={"project": project}, data=request.data) + + if serializer.is_valid(): + project_codeowners = serializer.save() + self.track_response_code("create", status.HTTP_201_CREATED) + user_id = getattr(request.user, "id", None) or None + try: + analytics.record( + CodeOwnersCreated( + user_id=user_id, + organization_id=project.organization_id, + project_id=project.id, + codeowners_id=project_codeowners.id, + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + + expand = ["ownershipSyntax", "errors", "hasTargetingContext"] + + return Response( + serialize( + project_codeowners, + request.user, + serializer=projectcodeowners_serializers.ProjectCodeOwnersSerializer( + expand=expand + ), + ), + 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) diff --git a/src/sentry/issues/serializers/__init__.py b/src/sentry/issues/serializers/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/issues/serializers/codeowners.py b/src/sentry/issues/serializers/codeowners.py new file mode 100644 index 00000000000000..826affe120c330 --- /dev/null +++ b/src/sentry/issues/serializers/codeowners.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping +from typing import Any + +import sentry_sdk +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request + +from sentry import analytics, features +from sentry.analytics.events.codeowners_max_length_exceeded import CodeOwnersMaxLengthExceeded +from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer +from sentry.api.validators.project_codeowners import validate_codeowners_associations +from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig +from sentry.issues.ownership.grammar import ( + convert_codeowners_syntax, + create_schema_from_issue_owners, +) +from sentry.models.project import Project +from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.utils import metrics +from sentry.utils.codeowners import MAX_RAW_LENGTH + + +class ProjectCodeOwnerSerializer(CamelSnakeModelSerializer[ProjectCodeOwners]): + code_mapping_id = serializers.IntegerField(required=True) + raw = serializers.CharField(required=True) + organization_integration_id = serializers.IntegerField(required=False) + date_updated = serializers.CharField(required=False) + + class Meta: + model = ProjectCodeOwners + fields = ["raw", "code_mapping_id", "organization_integration_id", "date_updated"] + + def get_max_length(self) -> int: + return MAX_RAW_LENGTH + + def validate(self, attrs: Mapping[str, Any]) -> Mapping[str, Any]: + # If it already exists, set default attrs with existing values + if self.instance: + attrs = { + "raw": self.instance.raw, + "code_mapping_id": self.instance.repository_project_path_config, + **attrs, + } + + if not attrs.get("raw", "").strip(): + return attrs + + # We want to limit `raw` to a reasonable length, so that people don't end up with values + # that are several megabytes large. To not break this functionality for existing customers + # we temporarily allow rows that already exceed this limit to still be updated. + # We do something similar with ProjectOwnership at the API level. + existing_raw = self.instance.raw if self.instance else "" + max_length = self.get_max_length() + if len(attrs["raw"]) > max_length and len(existing_raw) <= max_length: + try: + analytics.record( + CodeOwnersMaxLengthExceeded( + organization_id=self.context["project"].organization.id, + ) + ) + except Exception as e: + sentry_sdk.capture_exception(e) + raise serializers.ValidationError( + {"raw": f"Raw needs to be <= {max_length} characters in length"} + ) + + # Ignore association errors and continue parsing CODEOWNERS for valid lines. + # Allow users to incrementally fix association errors; for CODEOWNERS with many external mappings. + associations, _ = validate_codeowners_associations(attrs["raw"], self.context["project"]) + + issue_owner_rules = convert_codeowners_syntax( + attrs["raw"], associations, attrs["code_mapping_id"] + ) + + # Convert IssueOwner syntax into schema syntax + try: + validated_data = create_schema_from_issue_owners( + issue_owners=issue_owner_rules, + project_id=self.context["project"].id, + add_owner_ids=True, + ) + return { + **attrs, + "schema": validated_data, + } + except ValidationError as e: + raise serializers.ValidationError(str(e)) + + def validate_code_mapping_id(self, code_mapping_id: int) -> RepositoryProjectPathConfig: + if ProjectCodeOwners.objects.filter( + repository_project_path_config=code_mapping_id + ).exists() and ( + not self.instance + or (self.instance.repository_project_path_config_id != code_mapping_id) + ): + raise serializers.ValidationError("This code mapping is already in use.") + + try: + return RepositoryProjectPathConfig.objects.get( + id=code_mapping_id, project=self.context["project"] + ) + except RepositoryProjectPathConfig.DoesNotExist: + raise serializers.ValidationError("This code mapping does not exist.") + + def create(self, validated_data: MutableMapping[str, Any]) -> ProjectCodeOwners: + # Save projectcodeowners record + repository_project_path_config = validated_data.pop("code_mapping_id", None) + project = self.context["project"] + return ProjectCodeOwners.objects.create( + repository_project_path_config=repository_project_path_config, + project=project, + **validated_data, + ) + + def update( + self, instance: ProjectCodeOwners, validated_data: MutableMapping[str, Any] + ) -> ProjectCodeOwners: + if "id" in validated_data: + validated_data.pop("id") + for key, value in validated_data.items(): + setattr(instance, key, value) + instance.save() + return instance + + +class ProjectCodeOwnersMixin: + def has_feature(self, request: Request, project: Project) -> bool: + return bool( + features.has( + "organizations:integrations-codeowners", project.organization, actor=request.user + ) + ) + + def track_response_code(self, type: str, status: int | str) -> None: + if type in ["create", "update"]: + metrics.incr( + f"codeowners.{type}.http_response", + sample_rate=1.0, + tags={"status": status}, + ) + + +__all__ = ( + "ProjectCodeOwnerSerializer", + "ProjectCodeOwnersMixin", +) diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_details.py b/tests/sentry/issues/endpoints/test_project_codeowners_details.py new file mode 100644 index 00000000000000..82f3594dc88115 --- /dev/null +++ b/tests/sentry/issues/endpoints/test_project_codeowners_details.py @@ -0,0 +1,191 @@ +from datetime import datetime, timezone +from unittest import mock +from unittest.mock import MagicMock, patch + +from django.urls import reverse +from rest_framework.exceptions import ErrorDetail + +from sentry.analytics.events.codeowners_max_length_exceeded import CodeOwnersMaxLengthExceeded +from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.analytics import assert_last_analytics_event + + +class ProjectCodeOwnersDetailsEndpointTestCase(APITestCase): + def setUp(self) -> None: + self.user = self.create_user("admin@sentry.io", is_superuser=True) + + self.login_as(user=self.user) + + self.team = self.create_team( + organization=self.organization, slug="tiger-team", members=[self.user] + ) + + self.project = self.project = self.create_project( + organization=self.organization, teams=[self.team], slug="bengal" + ) + + self.code_mapping = self.create_code_mapping(project=self.project) + self.external_user = self.create_external_user( + external_name="@NisanthanNanthakumar", integration=self.integration + ) + self.external_team = self.create_external_team(integration=self.integration) + self.data = { + "raw": "docs/* @NisanthanNanthakumar @getsentry/ecosystem\n", + } + self.codeowners = self.create_codeowners( + project=self.project, code_mapping=self.code_mapping + ) + self.url = reverse( + "sentry-api-0-project-codeowners-details", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + "codeowners_id": self.codeowners.id, + }, + ) + + # Mock the external HTTP request to prevent real network calls + self.codeowner_patcher = patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={ + "html_url": "https://github.com/test/CODEOWNERS", + "filepath": "CODEOWNERS", + "raw": "test content", + }, + ) + self.codeowner_mock = self.codeowner_patcher.start() + self.addCleanup(self.codeowner_patcher.stop) + + def test_basic_delete(self) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.delete(self.url) + assert response.status_code == 204 + assert not ProjectCodeOwners.objects.filter(id=str(self.codeowners.id)).exists() + + @patch("django.utils.timezone.now") + def test_basic_update(self, mock_timezone_now: MagicMock) -> None: + self.create_external_team(external_name="@getsentry/frontend", integration=self.integration) + self.create_external_team(external_name="@getsentry/docs", integration=self.integration) + date = datetime(2023, 10, 3, tzinfo=timezone.utc) + mock_timezone_now.return_value = date + raw = "\n# cool stuff comment\n*.js @getsentry/frontend @NisanthanNanthakumar\n# good comment\n\n\n docs/* @getsentry/docs @getsentry/ecosystem\n\n" + data = { + "raw": raw, + } + + # Reset call count to verify this specific test's calls + self.codeowner_mock.reset_mock() + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, data) + + # Verify our mock was called instead of making real HTTP requests + assert ( + self.codeowner_mock.called + ), "Mock should have been called - no external HTTP requests made" + + assert response.status_code == 200 + assert response.data["id"] == str(self.codeowners.id) + assert response.data["raw"] == raw.strip() + codeowner = ProjectCodeOwners.objects.filter(id=self.codeowners.id)[0] + assert codeowner.date_updated == date + + def test_wrong_codeowners_id(self) -> None: + self.url = reverse( + "sentry-api-0-project-codeowners-details", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + "codeowners_id": 1000, + }, + ) + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, self.data) + assert response.status_code == 404 + assert response.data == {"detail": "The requested resource does not exist"} + + def test_missing_external_associations_update(self) -> None: + data = { + "raw": "\n# cool stuff comment\n*.js @getsentry/frontend @NisanthanNanthakumar\n# good comment\n\n\n docs/* @getsentry/docs @getsentry/ecosystem\nsrc/sentry/* @AnotherUser\n\n" + } + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, data) + assert response.status_code == 200 + assert response.data["id"] == str(self.codeowners.id) + assert response.data["codeMappingId"] == str(self.code_mapping.id) + + errors = response.data["errors"] + assert set(errors["missing_external_teams"]) == {"@getsentry/frontend", "@getsentry/docs"} + assert set(errors["missing_external_users"]) == {"@AnotherUser"} + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + def test_invalid_code_mapping_id_update(self) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, {"codeMappingId": 500}) + assert response.status_code == 400 + assert response.data == {"codeMappingId": ["This code mapping does not exist."]} + + def test_no_duplicates_code_mappings(self) -> None: + new_code_mapping = self.create_code_mapping(project=self.project, stack_root="blah") + self.create_codeowners(project=self.project, code_mapping=new_code_mapping) + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, {"codeMappingId": new_code_mapping.id}) + assert response.status_code == 400 + assert response.data == {"codeMappingId": ["This code mapping is already in use."]} + + def test_codeowners_email_update(self) -> None: + data = {"raw": f"\n# cool stuff comment\n*.js {self.user.email}\n# good comment\n\n\n"} + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, data) + assert response.status_code == 200 + assert response.data["raw"] == "# cool stuff comment\n*.js admin@sentry.io\n# good comment" + + @patch("sentry.analytics.record") + def test_codeowners_max_raw_length(self, mock_record: MagicMock) -> None: + with mock.patch( + "sentry.api.endpoints.codeowners.MAX_RAW_LENGTH", len(self.data["raw"]) + 1 + ): + data = { + "raw": f"# cool stuff comment\n*.js {self.user.email}\n# good comment" + } + + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(self.url, data) + assert response.status_code == 400 + assert response.data == { + "raw": [ + ErrorDetail( + string=f"Raw needs to be <= {len(self.data['raw']) + 1} characters in length", + code="invalid", + ) + ] + } + + assert_last_analytics_event( + mock_record, + CodeOwnersMaxLengthExceeded( + organization_id=self.organization.id, + ), + ) + # Test that we allow this to be modified for existing large rows + code_mapping = self.create_code_mapping(project=self.project, stack_root="/") + codeowners = self.create_codeowners( + project=self.project, + code_mapping=code_mapping, + raw=f"*.py test@localhost #{self.team.slug}", + ) + url = reverse( + "sentry-api-0-project-codeowners-details", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + "codeowners_id": codeowners.id, + }, + ) + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.put(url, data) + + assert ProjectCodeOwners.objects.get(id=codeowners.id).raw == data.get("raw") diff --git a/tests/sentry/issues/endpoints/test_project_codeowners_index.py b/tests/sentry/issues/endpoints/test_project_codeowners_index.py new file mode 100644 index 00000000000000..992def6afafce9 --- /dev/null +++ b/tests/sentry/issues/endpoints/test_project_codeowners_index.py @@ -0,0 +1,562 @@ +from unittest.mock import MagicMock, patch + +from django.urls import reverse + +from sentry.models.projectcodeowners import ProjectCodeOwners +from sentry.testutils.cases import APITestCase + + +class ProjectCodeOwnersEndpointTestCase(APITestCase): + def setUp(self) -> None: + self.user = self.create_user("admin@sentry.io", is_superuser=True) + + self.login_as(user=self.user) + + self.team = self.create_team( + organization=self.organization, slug="tiger-team", members=[self.user] + ) + + self.project = self.project = self.create_project( + organization=self.organization, teams=[self.team], slug="bengal" + ) + self.code_mapping = self.create_code_mapping( + project=self.project, + ) + self.external_user = self.create_external_user( + external_name="@NisanthanNanthakumar", integration=self.integration + ) + self.external_team = self.create_external_team(integration=self.integration) + self.url = reverse( + "sentry-api-0-project-codeowners", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "project_id_or_slug": self.project.slug, + }, + ) + self.data = { + "raw": "docs/* @NisanthanNanthakumar @getsentry/ecosystem\n", + "codeMappingId": self.code_mapping.id, + } + + def test_no_codeowners(self) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + resp = self.client.get(self.url) + assert resp.status_code == 200 + assert resp.data == [] + + def test_without_feature_flag(self) -> None: + with self.feature({"organizations:integrations-codeowners": False}): + resp = self.client.get(self.url) + assert resp.status_code == 403 + assert resp.data == {"detail": "You do not have permission to perform this action."} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_codeowners_with_integration(self, get_codeowner_mock_file: MagicMock) -> None: + code_owner = self.create_codeowners(self.project, self.code_mapping, raw="*.js @tiger-team") + with self.feature({"organizations:integrations-codeowners": True}): + resp = self.client.get(self.url) + assert resp.status_code == 200 + resp_data = resp.data[0] + assert resp_data["raw"] == code_owner.raw + assert resp_data["dateCreated"] == code_owner.date_added + assert resp_data["dateUpdated"] == code_owner.date_updated + assert resp_data["codeMappingId"] == str(self.code_mapping.id) + assert resp_data["provider"] == self.integration.provider + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_get_expanded_codeowners_with_integration( + self, get_codeowner_mock_file: MagicMock + ) -> None: + code_owner = self.create_codeowners(self.project, self.code_mapping, raw="*.js @tiger-team") + with self.feature({"organizations:integrations-codeowners": True}): + resp = self.client.get(f"{self.url}?expand=codeMapping") + assert resp.status_code == 200 + resp_data = resp.data[0] + assert resp_data["raw"] == code_owner.raw + assert resp_data["dateCreated"] == code_owner.date_added + assert resp_data["dateUpdated"] == code_owner.date_updated + assert resp_data["codeMappingId"] == str(self.code_mapping.id) + assert resp_data["provider"] == self.integration.provider + assert resp_data["codeMapping"]["id"] == str(self.code_mapping.id) + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_basic_post(self, get_codeowner_mock_file: MagicMock) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201, response.content + assert response.data["id"] + assert response.data["raw"] == "docs/* @NisanthanNanthakumar @getsentry/ecosystem" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "codeowners:docs/* admin@sentry.io #tiger-team\n" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert errors["missing_external_users"] == [] + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + def test_empty_codeowners_text(self) -> None: + self.data["raw"] = "" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 400 + assert response.data == {"raw": ["This field may not be blank."]} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_invalid_codeowners_text(self, get_codeowner_mock_file: MagicMock) -> None: + self.data["raw"] = "docs/*" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/*" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert errors["missing_external_users"] == [] + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_cannot_find_external_user_name_association( + self, get_codeowner_mock_file: MagicMock + ) -> None: + self.data["raw"] = "docs/* @MeredithAnya" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* @MeredithAnya" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert set(errors["missing_external_users"]) == {"@MeredithAnya"} + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_cannot_find_sentry_user_with_email(self, get_codeowner_mock_file: MagicMock) -> None: + self.data["raw"] = "docs/* someuser@sentry.io" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* someuser@sentry.io" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert errors["missing_external_users"] == [] + assert set(errors["missing_user_emails"]) == {"someuser@sentry.io"} + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_cannot_find_external_team_name_association( + self, get_codeowner_mock_file: MagicMock + ) -> None: + self.data["raw"] = "docs/* @getsentry/frontend\nstatic/* @getsentry/frontend" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* @getsentry/frontend\nstatic/* @getsentry/frontend" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert set(errors["missing_external_teams"]) == {"@getsentry/frontend"} + assert errors["missing_external_users"] == [] + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_cannot_find__multiple_external_name_association( + self, get_codeowner_mock_file: MagicMock + ) -> None: + self.data["raw"] = "docs/* @AnotherUser @getsentry/frontend @getsentry/docs" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* @AnotherUser @getsentry/frontend @getsentry/docs" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert set(errors["missing_external_teams"]) == {"@getsentry/frontend", "@getsentry/docs"} + assert set(errors["missing_external_users"]) == {"@AnotherUser"} + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + def test_missing_code_mapping_id(self) -> None: + self.data.pop("codeMappingId") + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 400 + assert response.data == {"codeMappingId": ["This field is required."]} + + def test_invalid_code_mapping_id(self) -> None: + self.data["codeMappingId"] = 500 + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 400 + assert response.data == {"codeMappingId": ["This code mapping does not exist."]} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_no_duplicates_allowed(self, get_codeowner_mock_file: MagicMock) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201, response.content + response = self.client.post(self.url, self.data) + assert response.status_code == 400 + assert response.data == {"codeMappingId": ["This code mapping is already in use."]} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_schema_is_correct(self, get_codeowner_mock_file: MagicMock) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201, response.content + assert response.data["id"] + project_codeowners = ProjectCodeOwners.objects.get(id=response.data["id"]) + assert project_codeowners.schema == { + "$version": 1, + "rules": [ + { + "matcher": {"pattern": "docs/*", "type": "codeowners"}, + "owners": [ + {"id": self.user.id, "identifier": self.user.email, "type": "user"}, + {"id": self.team.id, "identifier": self.team.slug, "type": "team"}, + ], + } + ], + } + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_schema_preserves_comments(self, get_codeowner_mock_file: MagicMock) -> None: + self.data["raw"] = "docs/* @NisanthanNanthakumar @getsentry/ecosystem\n" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201, response.content + assert response.data["id"] + project_codeowners = ProjectCodeOwners.objects.get(id=response.data["id"]) + assert project_codeowners.schema == { + "$version": 1, + "rules": [ + { + "matcher": {"pattern": "docs/*", "type": "codeowners"}, + "owners": [ + {"id": self.user.id, "identifier": self.user.email, "type": "user"}, + {"id": self.team.id, "identifier": self.team.slug, "type": "team"}, + ], + } + ], + } + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_raw_email_correct_schema(self, get_codeowner_mock_file: MagicMock) -> None: + self.data["raw"] = f"docs/* {self.user.email} @getsentry/ecosystem\n" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201, response.content + assert response.data["id"] + project_codeowners = ProjectCodeOwners.objects.get(id=response.data["id"]) + assert project_codeowners.schema == { + "$version": 1, + "rules": [ + { + "matcher": {"pattern": "docs/*", "type": "codeowners"}, + "owners": [ + {"id": self.user.id, "identifier": self.user.email, "type": "user"}, + {"id": self.team.id, "identifier": self.team.slug, "type": "team"}, + ], + } + ], + } + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_codeowners_scope_emails_to_org_security( + self, get_codeowner_mock_file: MagicMock + ) -> None: + self.user2 = self.create_user("user2@sentry.io") + self.data = { + "raw": "docs/* @NisanthanNanthakumar user2@sentry.io\n", + "codeMappingId": self.code_mapping.id, + } + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["id"] + assert response.data["raw"] == "docs/* @NisanthanNanthakumar user2@sentry.io" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "codeowners:docs/* admin@sentry.io\n" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert errors["missing_external_users"] == [] + assert set(errors["missing_user_emails"]) == {self.user2.email} + assert errors["teams_without_access"] == [] + assert errors["users_without_access"] == [] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_multiple_codeowners_for_project(self, get_codeowner_mock_file: MagicMock) -> None: + code_mapping_2 = self.create_code_mapping(stack_root="src/") + self.create_codeowners(code_mapping=code_mapping_2) + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_users_without_access(self, get_codeowner_mock_file: MagicMock) -> None: + user_2 = self.create_user("bar@example.com") + self.create_member(organization=self.organization, user=user_2, role="member") + team_2 = self.create_team(name="foo", organization=self.organization, members=[user_2]) + self.create_project(organization=self.organization, teams=[team_2], slug="bass") + self.create_external_user( + user=user_2, external_name="@foobarSentry", integration=self.integration + ) + self.data["raw"] = "docs/* @foobarSentry\nstatic/* @foobarSentry" + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* @foobarSentry\nstatic/* @foobarSentry" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["provider"] == "github" + assert response.data["ownershipSyntax"] == "" + + errors = response.data["errors"] + assert errors["missing_external_teams"] == [] + assert errors["missing_external_users"] == [] + assert errors["missing_user_emails"] == [] + assert errors["teams_without_access"] == [] + assert set(errors["users_without_access"]) == {user_2.email} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_post_with_schema(self, get_codeowner_mock_file: MagicMock) -> None: + with self.feature({"organizations:integrations-codeowners": True}): + response = self.client.post(self.url, self.data) + assert response.status_code == 201 + assert response.data["raw"] == "docs/* @NisanthanNanthakumar @getsentry/ecosystem" + assert response.data["codeMappingId"] == str(self.code_mapping.id) + assert response.data["schema"] == { + "$version": 1, + "rules": [ + { + "matcher": {"type": "codeowners", "pattern": "docs/*"}, + "owners": [ + {"type": "user", "id": self.user.id, "identifier": "admin@sentry.io"}, + {"type": "team", "id": self.team.id, "identifier": "tiger-team"}, + ], + } + ], + } + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_get(self, get_codeowner_mock_file: MagicMock) -> None: + self.client.post(self.url, self.data) + response = self.client.get(self.url) + + response_data = response.data[0] + assert response.status_code == 200 + assert response_data["raw"] == "docs/* @NisanthanNanthakumar @getsentry/ecosystem" + assert response_data["codeMappingId"] == str(self.code_mapping.id) + assert response_data["schema"] == { + "$version": 1, + "rules": [ + { + "matcher": {"type": "codeowners", "pattern": "docs/*"}, + "owners": [ + { + "type": "user", + "id": self.user.id, + "name": "admin@sentry.io", + }, + {"type": "team", "id": self.team.id, "name": "tiger-team"}, + ], + } + ], + } + assert response_data["codeOwnersUrl"] == "https://github.com/test/CODEOWNERS" + + # Assert that "identifier" is not renamed to "name" in the backend + ownership = ProjectCodeOwners.objects.get(project=self.project) + assert ownership.schema["rules"] == [ + { + "matcher": {"type": "codeowners", "pattern": "docs/*"}, + "owners": [ + {"type": "user", "identifier": "admin@sentry.io", "id": self.user.id}, + {"type": "team", "identifier": "tiger-team", "id": self.team.id}, + ], + } + ] + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_get_rule_one_deleted_owner(self, get_codeowner_mock_file: MagicMock) -> None: + self.member_user_delete = self.create_user("member_delete@localhost", is_superuser=False) + self.create_member( + user=self.member_user_delete, + organization=self.organization, + role="member", + teams=[self.team], + ) + self.external_delete_user = self.create_external_user( + user=self.member_user_delete, external_name="@delete", integration=self.integration + ) + self.data["raw"] = "docs/* @delete @getsentry/ecosystem" + + with self.feature({"organizations:integrations-codeowners": True}): + self.client.post(self.url, self.data) + self.external_delete_user.delete() + response = self.client.get(self.url) + assert response.data[0]["schema"] == { + "$version": 1, + "rules": [ + { + "matcher": {"type": "codeowners", "pattern": "docs/*"}, + "owners": [{"type": "team", "name": "tiger-team", "id": self.team.id}], + } + ], + } + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_get_no_rule_deleted_owner(self, get_codeowner_mock_file: MagicMock) -> None: + self.member_user_delete = self.create_user("member_delete@localhost", is_superuser=False) + self.create_member( + user=self.member_user_delete, + organization=self.organization, + role="member", + teams=[self.team], + ) + self.external_delete_user = self.create_external_user( + user=self.member_user_delete, external_name="@delete", integration=self.integration + ) + self.data["raw"] = "docs/* @delete" + + with self.feature({"organizations:integrations-codeowners": True}): + self.client.post(self.url, self.data) + self.external_delete_user.delete() + response = self.client.get(self.url) + assert response.data[0]["schema"] == {"$version": 1, "rules": []} + + @patch( + "sentry.integrations.source_code_management.repository.RepositoryIntegration.get_codeowner_file", + return_value={"html_url": "https://github.com/test/CODEOWNERS"}, + ) + def test_get_multiple_rules_deleted_owners(self, get_codeowner_mock_file: MagicMock) -> None: + self.member_user_delete = self.create_user("member_delete@localhost", is_superuser=False) + self.create_member( + user=self.member_user_delete, + organization=self.organization, + role="member", + teams=[self.team], + ) + self.external_delete_user = self.create_external_user( + user=self.member_user_delete, external_name="@delete", integration=self.integration + ) + self.member_user_delete2 = self.create_user("member_delete2@localhost", is_superuser=False) + self.create_member( + user=self.member_user_delete2, + organization=self.organization, + role="member", + teams=[self.team], + ) + self.external_delete_user2 = self.create_external_user( + user=self.member_user_delete, external_name="@delete2", integration=self.integration + ) + self.data["raw"] = ( + "docs/* @delete\n*.py @getsentry/ecosystem @delete\n*.css @delete2\n*.rb @NisanthanNanthakumar" + ) + + with self.feature({"organizations:integrations-codeowners": True}): + self.client.post(self.url, self.data) + self.external_delete_user.delete() + self.external_delete_user2.delete() + response = self.client.get(self.url) + assert response.data[0]["schema"] == { + "$version": 1, + "rules": [ + { + "matcher": {"type": "codeowners", "pattern": "*.py"}, + "owners": [{"type": "team", "name": "tiger-team", "id": self.team.id}], + }, + { + "matcher": {"type": "codeowners", "pattern": "*.rb"}, + "owners": [ + { + "type": "user", + "name": "admin@sentry.io", + "id": self.user.id, + } + ], + }, + ], + }