diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 37be6c656ffd..53ab3b764eca 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -277,9 +277,6 @@ from sentry.integrations.api.endpoints.organization_repository_platforms import ( OrganizationRepositoryPlatformsEndpoint, ) -from sentry.integrations.api.endpoints.organization_repository_platforms_test import ( - OrganizationRepositoryPlatformsTestEndpoint, -) from sentry.integrations.api.endpoints.organization_repository_settings import ( OrganizationRepositorySettingsEndpoint, ) @@ -2194,11 +2191,6 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationRepositoryPlatformsEndpoint.as_view(), name="sentry-api-0-organization-repository-platforms", ), - re_path( - r"^(?P[^/]+)/repos/(?P[^/]+)/platforms-test/$", - OrganizationRepositoryPlatformsTestEndpoint.as_view(), - name="sentry-api-0-organization-repository-platforms-test", - ), re_path( r"^(?P[^/]+)/legacy-webhooks/$", OrganizationLegacyWebhooksEndpoint.as_view(), diff --git a/src/sentry/integrations/api/endpoints/organization_repository_platforms_test.py b/src/sentry/integrations/api/endpoints/organization_repository_platforms_test.py deleted file mode 100644 index ae3b3edd6d62..000000000000 --- a/src/sentry/integrations/api/endpoints/organization_repository_platforms_test.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import sentry_sdk -from rest_framework.request import Request -from rest_framework.response import Response -from sentry_sdk import logger as sentry_logger - -from sentry import features -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.integrations.api.bases.organization_repository import ( - OrganizationRepositoryEndpoint, -) -from sentry.integrations.github.client import GitHubApiClient -from sentry.integrations.github.multi_platform_detection import detect_platforms_multi -from sentry.integrations.services.integration import integration_service -from sentry.integrations.types import IntegrationProviderSlug -from sentry.models.organization import Organization -from sentry.models.repository import Repository -from sentry.shared_integrations.exceptions import ApiConflictError, ApiError - - -@cell_silo_endpoint -class OrganizationRepositoryPlatformsTestEndpoint(OrganizationRepositoryEndpoint): - """Endpoint for the tree-based multi-platform detector. - - Will replace the existing `OrganizationRepositoryPlatformsEndpoint`. - """ - - owner = ApiOwner.INTEGRATION_PLATFORM - publish_status = { - "GET": ApiPublishStatus.PRIVATE, - } - - def get(self, request: Request, organization: Organization, repo: Repository) -> Response: - if not features.has( - "organizations:integrations-github-platform-detection", - organization, - actor=request.user, - ): - return Response(status=404) - - if ( - not repo.integration_id - or repo.provider != f"integrations:{IntegrationProviderSlug.GITHUB}" - ): - return Response( - {"detail": "Platform detection is only supported for GitHub repositories."}, - status=400, - ) - - integration = integration_service.get_integration(integration_id=repo.integration_id) - if integration is None: - return Response({"detail": "GitHub integration not found."}, status=400) - - org_integration = integration_service.get_organization_integration( - integration_id=repo.integration_id, organization_id=organization.id - ) - if org_integration is None: - return Response( - {"detail": "GitHub integration is not configured for this organization."}, - status=400, - ) - - client = GitHubApiClient(integration=integration, org_integration_id=org_integration.id) - - attributes = {"repo_id": repo.id, "repo_name": repo.name} - try: - result = detect_platforms_multi(client, repo.name) - except ApiConflictError: - # Empty / unprocessable repo (e.g. empty git tree). - sentry_logger.warning( - "github.platform_detection.multi.empty_repo", attributes=attributes - ) - with sentry_sdk.new_scope() as scope: - scope.set_tag("scm_platform_detection", "empty_repo") - sentry_sdk.capture_exception() - return Response({"platforms": []}) - except (ApiError, ValueError): - sentry_logger.error("github.platform_detection.multi.failed", attributes=attributes) - with sentry_sdk.new_scope() as scope: - scope.set_tag("scm_platform_detection", "failed") - sentry_sdk.capture_exception() - return Response({"detail": "Failed to detect platforms from GitHub."}, status=502) - - return Response({"platforms": result["platforms"]}) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 13ea92c33b41..84a8c03d79b1 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -324,7 +324,6 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/repos/' | '/organizations/$organizationIdOrSlug/repos/$repoId/' | '/organizations/$organizationIdOrSlug/repos/$repoId/commits/' - | '/organizations/$organizationIdOrSlug/repos/$repoId/platforms-test/' | '/organizations/$organizationIdOrSlug/repos/$repoId/platforms/' | '/organizations/$organizationIdOrSlug/repos/settings/' | '/organizations/$organizationIdOrSlug/request-project-creation/' diff --git a/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms.py b/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms.py index a2c0f2a79be3..f705cf6ecbbc 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms.py @@ -335,3 +335,106 @@ def test_multi_github_api_error_returns_502( scope.set_tag.assert_any_call("is_multi", True) scope.set_tag.assert_any_call("repo_id", self.repo.id) scope.set_tag.assert_any_call("repo_name", self.repo.name) + + @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_detects_multi_platforms(self, get_jwt: mock.MagicMock) -> None: + responses.add( + method=responses.GET, + url="https://api.github.com/repos/Test-Organization/foo/languages", + json={"Python": 50000}, + status=200, + ) + # Recursive git tree with no manifest files -> language only, no framework detection + responses.add( + method=responses.GET, + url="https://api.github.com/repos/Test-Organization/foo/git/trees/HEAD", + json={ + "sha": "abc", + "truncated": False, + "tree": [ + {"path": "src/app.py", "type": "blob", "size": 1234}, + {"path": "src", "type": "tree"}, + ], + }, + status=200, + ) + + with self.feature({FEATURE_FLAG: True, MULTI_FLAG: True}): + response = self.get_success_response( + self.organization.slug, self.repo.id, status_code=200 + ) + + assert response.data == { + "platforms": [ + { + "platform": "python", + "language": "Python", + "bytes": 50000, + "confidence": "medium", + "priority": 1, + }, + ] + } + + @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_detects_multi_framework(self, get_jwt: mock.MagicMock) -> None: + responses.add( + method=responses.GET, + url="https://api.github.com/repos/Test-Organization/foo/languages", + json={"Python": 50000}, + status=200, + ) + # Recursive git tree containing requirements.txt so a content read fires + responses.add( + method=responses.GET, + url="https://api.github.com/repos/Test-Organization/foo/git/trees/HEAD", + json={ + "sha": "abc", + "truncated": False, + "tree": [ + {"path": "requirements.txt", "type": "blob", "size": 42}, + ], + }, + status=200, + ) + + requirements_content = b64encode(b"Django==4.2\ncelery>=5.0\n").decode() + responses.add( + method=responses.GET, + url="https://api.github.com/repos/Test-Organization/foo/contents/requirements.txt", + json={"content": requirements_content}, + status=200, + ) + + with self.feature({FEATURE_FLAG: True, MULTI_FLAG: True}): + response = self.get_success_response( + self.organization.slug, self.repo.id, status_code=200 + ) + + assert response.data == { + "platforms": [ + { + "platform": "python-django", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 90, + }, + { + "platform": "python-celery", + "language": "Python", + "bytes": 50000, + "confidence": "high", + "priority": 40, + }, + { + "platform": "python", + "language": "Python", + "bytes": 50000, + "confidence": "medium", + "priority": 1, + }, + ] + } diff --git a/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms_test.py b/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms_test.py deleted file mode 100644 index fdfca9409ff5..000000000000 --- a/tests/sentry/integrations/api/endpoints/test_organization_repository_platforms_test.py +++ /dev/null @@ -1,255 +0,0 @@ -from __future__ import annotations - -from base64 import b64encode -from datetime import timedelta -from unittest import mock - -import responses -from django.utils import timezone - -from sentry.models.repository import Repository -from sentry.testutils.cases import APITestCase - -FEATURE_FLAG = "organizations:integrations-github-platform-detection" -ENDPOINT_MODULE = "sentry.integrations.api.endpoints.organization_repository_platforms_test" - - -class OrganizationRepositoryPlatformsTestGetTest(APITestCase): - endpoint = "sentry-api-0-organization-repository-platforms-test" - - def setUp(self) -> None: - super().setUp() - self.login_as(user=self.user) - - ten_days = timezone.now() + timedelta(days=10) - self.integration = self.create_integration( - organization=self.organization, - provider="github", - name="Github Test Org", - external_id="1", - metadata={ - "access_token": "12345token", - "expires_at": ten_days.strftime("%Y-%m-%dT%H:%M:%S"), - }, - ) - self.repo = Repository.objects.create( - organization_id=self.organization.id, - name="Test-Organization/foo", - url="https://github.com/Test-Organization/foo", - provider="integrations:github", - external_id="123", - integration_id=self.integration.id, - ) - - def test_feature_flag_required(self) -> None: - response = self.get_response(self.organization.slug, self.repo.id) - assert response.status_code == 404 - - def test_non_github_repo(self) -> None: - repo = Repository.objects.create( - organization_id=self.organization.id, - name="non-github-repo", - provider="integrations:bitbucket", - external_id="456", - ) - - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, repo.id) - assert response.status_code == 400 - assert "only supported for GitHub" in response.data["detail"] - - def test_github_enterprise_repo_rejected(self) -> None: - repo = Repository.objects.create( - organization_id=self.organization.id, - name="enterprise-repo", - provider="integrations:github_enterprise", - external_id="999", - integration_id=self.integration.id, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, repo.id) - assert response.status_code == 400 - assert "only supported for GitHub" in response.data["detail"] - - def test_repo_without_integration(self) -> None: - repo = Repository.objects.create( - organization_id=self.organization.id, - name="orphan-repo", - provider="integrations:github", - external_id="789", - integration_id=None, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, repo.id) - assert response.status_code == 400 - - def test_repo_not_found(self) -> None: - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, 99999) - assert response.status_code == 404 - - def test_other_orgs_repo_not_accessible(self) -> None: - other_org = self.create_organization(name="other-org") - other_repo = Repository.objects.create( - organization_id=other_org.id, - name="Test-Organization/secret", - provider="integrations:github", - external_id="secret", - integration_id=self.integration.id, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, other_repo.id) - assert response.status_code == 404 - - @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - @responses.activate - def test_detects_platforms(self, get_jwt: mock.MagicMock) -> None: - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/languages", - json={"Python": 50000}, - status=200, - ) - # Recursive git tree with no manifest files -> no framework detection - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/git/trees/HEAD", - json={ - "sha": "abc", - "truncated": False, - "tree": [ - {"path": "src/app.py", "type": "blob", "size": 1234}, - {"path": "src", "type": "tree"}, - ], - }, - status=200, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_success_response( - self.organization.slug, self.repo.id, status_code=200 - ) - - assert response.data == { - "platforms": [ - { - "platform": "python", - "language": "Python", - "bytes": 50000, - "confidence": "medium", - "priority": 1, - }, - ] - } - - @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - @responses.activate - def test_detects_framework(self, get_jwt: mock.MagicMock) -> None: - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/languages", - json={"Python": 50000}, - status=200, - ) - # Recursive git tree containing requirements.txt so a content read fires - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/git/trees/HEAD", - json={ - "sha": "abc", - "truncated": False, - "tree": [ - {"path": "requirements.txt", "type": "blob", "size": 42}, - ], - }, - status=200, - ) - - requirements_content = b64encode(b"Django==4.2\ncelery>=5.0\n").decode() - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/contents/requirements.txt", - json={"content": requirements_content}, - status=200, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_success_response( - self.organization.slug, self.repo.id, status_code=200 - ) - - assert response.data == { - "platforms": [ - { - "platform": "python-django", - "language": "Python", - "bytes": 50000, - "confidence": "high", - "priority": 90, - }, - { - "platform": "python-celery", - "language": "Python", - "bytes": 50000, - "confidence": "high", - "priority": 40, - }, - { - "platform": "python", - "language": "Python", - "bytes": 50000, - "confidence": "medium", - "priority": 1, - }, - ] - } - - @mock.patch(f"{ENDPOINT_MODULE}.sentry_sdk") - @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - @responses.activate - def test_empty_repo_returns_empty_list( - self, get_jwt: mock.MagicMock, sentry_sdk: mock.MagicMock - ) -> None: - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/languages", - json={"Python": 50000}, - status=200, - ) - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/git/trees/HEAD", - json={"message": "Git Repository is empty."}, - status=409, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_success_response( - self.organization.slug, self.repo.id, status_code=200 - ) - - assert response.data == {"platforms": []} - assert sentry_sdk.capture_exception.called - - @mock.patch(f"{ENDPOINT_MODULE}.sentry_sdk") - @mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") - @responses.activate - def test_github_api_error_returns_502( - self, get_jwt: mock.MagicMock, sentry_sdk: mock.MagicMock - ) -> None: - responses.add( - method=responses.GET, - url="https://api.github.com/repos/Test-Organization/foo/languages", - json={"message": "Server Error"}, - status=500, - ) - - with self.feature(FEATURE_FLAG): - response = self.get_response(self.organization.slug, self.repo.id) - - assert response.status_code == 502 - assert "Failed to detect" in response.data["detail"] - assert sentry_sdk.capture_exception.called