diff --git a/src/sentry/api/api_owners.py b/src/sentry/api/api_owners.py index 9917779525beeb..4ee15fa6bedb3d 100644 --- a/src/sentry/api/api_owners.py +++ b/src/sentry/api/api_owners.py @@ -9,6 +9,7 @@ class ApiOwner(Enum): ALERTS_NOTIFICATIONS = "alerts-notifications" BILLING = "revenue" + CODECOV = "codecov" CRONS = "crons" ECOSYSTEM = "ecosystem" ENTERPRISE = "enterprise" diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index d084ce3d51951a..41dc694e9529e8 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -68,6 +68,7 @@ SourceMapDebugBlueThunderEditionEndpoint, ) from sentry.api.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup +from sentry.codecov.endpoints.TestResults.test_results import TestResultsEndpoint from sentry.data_export.endpoints.data_export import DataExportEndpoint from sentry.data_export.endpoints.data_export_details import DataExportDetailsEndpoint from sentry.data_secrecy.api.waive_data_secrecy import WaiveDataSecrecyEndpoint @@ -3206,6 +3207,14 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ), ] +PREVENT_URLS = [ + re_path( + r"^owner/(?P[^\/]+)/repository/(?P[^\/]+)/commit/(?P[^\/]+)/test-results/$", + TestResultsEndpoint.as_view(), + name="sentry-api-0-test-results", + ), +] + urlpatterns = [ # Relay re_path( @@ -3266,6 +3275,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: r"^broadcasts/", include(BROADCAST_URLS), ), + # Prevent + re_path( + r"^prevent/", + include(PREVENT_URLS), + ), # # # diff --git a/src/sentry/codecov/base.py b/src/sentry/codecov/base.py new file mode 100644 index 00000000000000..e4a20db536b68e --- /dev/null +++ b/src/sentry/codecov/base.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from rest_framework.request import Request + +from sentry.api.base import Endpoint + + +class CodecovEndpoint(Endpoint): + """ + Used for endpoints that are specific to Codecov / Prevent. + """ + + def convert_args(self, request: Request, *args, **kwargs): + parsed_args, parsed_kwargs = super().convert_args(request, *args, **kwargs) + # TODO: in case we need to modify args, do it here + return (parsed_args, parsed_kwargs) diff --git a/src/sentry/codecov/endpoints/TestResults/query.py b/src/sentry/codecov/endpoints/TestResults/query.py new file mode 100644 index 00000000000000..7cd4515b86112a --- /dev/null +++ b/src/sentry/codecov/endpoints/TestResults/query.py @@ -0,0 +1,55 @@ +query = """query GetTestResults( + $owner: String! + $repo: String! + $filters: TestResultsFilters + $ordering: TestResultsOrdering + $first: Int + $after: String + $last: Int + $before: String +) { + owner(username: $owner) { + repository: repository(name: $repo) { + __typename + ... on Repository { + testAnalytics { + testResults( + filters: $filters + ordering: $ordering + first: $first + after: $after + last: $last + before: $before + ) { + edges { + node { + updatedAt + avgDuration + name + failureRate + flakeRate + commitsFailed + totalFailCount + totalFlakyFailCount + totalSkipCount + totalPassCount + } + } + pageInfo { + endCursor + hasNextPage + } + totalCount + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +} +""" diff --git a/src/sentry/codecov/endpoints/TestResults/serializers.py b/src/sentry/codecov/endpoints/TestResults/serializers.py new file mode 100644 index 00000000000000..9ed25fb63c4717 --- /dev/null +++ b/src/sentry/codecov/endpoints/TestResults/serializers.py @@ -0,0 +1,55 @@ +import sentry_sdk +from rest_framework import serializers + + +class TestResultNodeSerializer(serializers.Serializer): + """ + Serializer for individual test result nodes from GraphQL response + """ + + updatedAt = serializers.CharField() + avgDuration = serializers.FloatField() + name = serializers.CharField() + failureRate = serializers.FloatField() + flakeRate = serializers.FloatField() + commitsFailed = serializers.IntegerField() + totalFailCount = serializers.IntegerField() + totalFlakyFailCount = serializers.IntegerField() + totalSkipCount = serializers.IntegerField() + totalPassCount = serializers.IntegerField() + lastDuration = serializers.FloatField() + + +class TestResultSerializer(serializers.ListSerializer): + """ + Serializer for a list of test results - inherits from ListSerializer to handle arrays + """ + + child = TestResultNodeSerializer() + + def to_representation(self, graphql_response): + """ + Transform the GraphQL response to the expected client format + """ + try: + # Extract test result nodes from the nested GraphQL structure + test_results = graphql_response["data"]["owner"]["repository"]["testAnalytics"][ + "testResults" + ]["edges"] + + # Transform each edge to just the node data + nodes = [] + for edge in test_results: + node = edge["node"] + # Add lastDuration fallback if not present + if "lastDuration" not in node: + node["lastDuration"] = node["avgDuration"] + nodes.append(node) + + # Use the parent ListSerializer to serialize each test result + return super().to_representation(nodes) + + except (KeyError, TypeError) as e: + # Handle malformed GraphQL response + sentry_sdk.capture_exception(e) + return [] diff --git a/src/sentry/codecov/endpoints/TestResults/test_results.py b/src/sentry/codecov/endpoints/TestResults/test_results.py new file mode 100644 index 00000000000000..8258e4f4a0736f --- /dev/null +++ b/src/sentry/codecov/endpoints/TestResults/test_results.py @@ -0,0 +1,85 @@ +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 region_silo_endpoint +from sentry.codecov.base import CodecovEndpoint +from sentry.codecov.client import CodecovApiClient +from sentry.codecov.endpoints.TestResults.query import query +from sentry.codecov.endpoints.TestResults.serializers import TestResultSerializer + +# Sample GraphQL response structure for reference +sample_graphql_response = { + "data": { + "owner": { + "repository": { + "__typename": "Repository", + "testAnalytics": { + "testResults": { + "edges": [ + { + "node": { + "updatedAt": "2025-05-22T16:21:18.763951+00:00", + "avgDuration": 0.04066228070175437, + "name": "../usr/local/lib/python3.13/site-packages/asgiref/sync.py::GetFinalYamlInteractorTest::test_when_commit_has_no_yaml", + "failureRate": 0.0, + "flakeRate": 0.0, + "commitsFailed": 0, + "totalFailCount": 0, + "totalFlakyFailCount": 0, + "totalSkipCount": 0, + "totalPassCount": 70, + } + }, + { + "node": { + "updatedAt": "2025-05-22T16:21:18.763961+00:00", + "avgDuration": 0.034125877192982455, + "name": "../usr/local/lib/python3.13/site-packages/asgiref/sync.py::GetFinalYamlInteractorTest::test_when_commit_has_yaml", + "failureRate": 0.0, + "flakeRate": 0.0, + "commitsFailed": 0, + "totalFailCount": 0, + "totalFlakyFailCount": 0, + "totalSkipCount": 0, + "totalPassCount": 70, + } + }, + ], + } + }, + } + } + } +} + + +@region_silo_endpoint +class TestResultsEndpoint(CodecovEndpoint): + owner = ApiOwner.CODECOV + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + } + + # Disable pagination requirement for this endpoint + def has_pagination(self, response): + return True + + def get(self, request: Request, owner: str, repository: str) -> Response: + """Retrieves the list of test results for a given commit.""" + + variables = { + "owner": owner, + "repo": repository, + } + + assert variables + + graphql_response = CodecovApiClient.query(query, variables) + + graphql_response = sample_graphql_response # Mock response for now + + test_results = TestResultSerializer().to_representation(graphql_response) + + return Response(test_results) diff --git a/tests/sentry/codecov/endpoints/test_test_results.py b/tests/sentry/codecov/endpoints/test_test_results.py new file mode 100644 index 00000000000000..2166c4090f5806 --- /dev/null +++ b/tests/sentry/codecov/endpoints/test_test_results.py @@ -0,0 +1,59 @@ +from unittest.mock import patch + +from django.urls import reverse + +from sentry.testutils.cases import APITestCase + + +class TestResultsEndpointTest(APITestCase): + endpoint = "sentry-api-0-test-results" + + def setUp(self): + super().setUp() + self.login_as(user=self.user) + + def reverse_url(self, owner="testowner", repository="testrepo", commit="testcommit"): + """Custom reverse URL method to handle required URL parameters""" + return reverse( + self.endpoint, + kwargs={ + "owner": owner, + "repository": repository, + "commit": commit, + }, + ) + + @patch( + "sentry.codecov.endpoints.TestResults.test_results.TestResultsEndpoint.permission_classes", + (), + ) + def test_get_returns_mock_response(self): + """Test that GET request returns the expected mock GraphQL response structure""" + url = self.reverse_url() + response = self.client.get(url) + + # With permissions bypassed, we should get a 200 response + assert response.status_code == 200 + + # Validate the response structure + assert isinstance(response.data, list) + # The sample response should contain 2 test result items + assert len(response.data) == 2 + + # Verify the first result has expected fields and values + first_result = response.data[0] + expected_fields = [ + "updatedAt", + "name", + "avgDuration", + "failureRate", + "flakeRate", + "commitsFailed", + "totalFailCount", + "totalFlakyFailCount", + "totalSkipCount", + "totalPassCount", + "lastDuration", + ] + for field in expected_fields: + assert field in first_result, f"Missing field: {field}"