From 84c8cae2d803174c81dd07b6851dc6839d10749f Mon Sep 17 00:00:00 2001 From: Ajay Singh Date: Mon, 19 May 2025 15:42:37 -0700 Subject: [PATCH 1/5] init prevent url for test analytics --- src/sentry/api/api_owners.py | 1 + src/sentry/api/bases/codecov.py | 16 +++ src/sentry/api/endpoints/test_results.py | 157 +++++++++++++++++++++++ src/sentry/api/urls.py | 14 ++ 4 files changed, 188 insertions(+) create mode 100644 src/sentry/api/bases/codecov.py create mode 100644 src/sentry/api/endpoints/test_results.py 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/bases/codecov.py b/src/sentry/api/bases/codecov.py new file mode 100644 index 00000000000000..e4a20db536b68e --- /dev/null +++ b/src/sentry/api/bases/codecov.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/api/endpoints/test_results.py b/src/sentry/api/endpoints/test_results.py new file mode 100644 index 00000000000000..6cc213e0638847 --- /dev/null +++ b/src/sentry/api/endpoints/test_results.py @@ -0,0 +1,157 @@ +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.api.bases.codecov import CodecovEndpoint + +list_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 + } + } + } +} +""" + +# NOTE: There is no testResult resolver in GQL atm so if we need this, will need to build it. +get_query = """query GetTestResult( + $owner: String! + $repo: String! + $testResultId: String! +) { +} +""" + + +@region_silo_endpoint +class TestResultsEndpoint(CodecovEndpoint): + owner = ApiOwner.CODECOV + publish_status = { + "GET": ApiPublishStatus.PUBLIC, + } + + def get(self, request: Request) -> Response: + """Retrieves the list of test results for a given commit. If a test result id is also + provided, the endpoint will return the test result with that id.""" + + test_result_id = request.GET.get("test_result_id") + owner = request.GET.get("owner") + repo = request.GET.get("repo") + commit = request.GET.get("commit") + + variables = { + "owner": owner, + "repo": repo, + "commit": commit, + } + + assert variables # just to get rid of lint error + + if test_result_id: + # TODO: graphQL Query + # Passing in query into codecov client means we need the query to be structured by the time we call it. + + # res = CodecovClient.query(get_query, variables) + # transformed_res = CodecovClient.transform_response(res, serializer) + + return Response( + { + "updatedAt": "2021-01-01T00:00:00Z", + "name": "test", + "commitsFailed": 1, + "failureRate": 0.01, + "flakeRate": 100, + "avgDuration": 100, + "lastDuration": 100, + "totalFailCount": 1, + "totalFlakyFailCount": 1, + "totalSkipCount": 0, + "totalPassCount": 0, + } + ) + + # CodecovClient.query(list_query, variables) + + # TODO: Response filtering + + return Response( + { + [ + { + "updatedAt": "2021-01-01T00:00:00Z", + "name": "test", + "commitsFailed": 1, + "failureRate": 0.01, + "flakeRate": 100, + "avgDuration": 100, + "lastDuration": 100, + "totalFailCount": 1, + "totalFlakyFailCount": 1, + "totalSkipCount": 0, + "totalPassCount": 0, + }, + { + "updatedAt": "2021-01-01T00:00:00Z", + "name": "test", + "commitsFailed": 4, + "failureRate": 0.5, + "flakeRate": 0.2, + "avgDuration": 100, + "lastDuration": 100, + "totalFailCount": 4, + "totalFlakyFailCount": 0, + "totalSkipCount": 0, + "totalPassCount": 0, + }, + ] + } + ) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 055bb615125e28..8c313fac7a449b 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -64,6 +64,7 @@ from sentry.api.endpoints.source_map_debug_blue_thunder_edition import ( SourceMapDebugBlueThunderEditionEndpoint, ) +from sentry.api.endpoints.test_results import TestResultsEndpoint from sentry.api.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup from sentry.data_export.endpoints.data_export import DataExportEndpoint from sentry.data_export.endpoints.data_export_details import DataExportDetailsEndpoint @@ -3186,6 +3187,14 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ), ] +PREVENT_URLS = [ + re_path( + r"^owner/(?P[^\/]+)/repository/(?P[^\/]+)/commit/(?P[^\/]+)/test-results/(?P[^\/]+)$", + TestResultsEndpoint.as_view(), + name="sentry-api-0-test-results", + ), +] + urlpatterns = [ # Relay re_path( @@ -3246,6 +3255,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: r"^broadcasts/", include(BROADCAST_URLS), ), + # Prevent + re_path( + r"^prevent/", + include(PREVENT_URLS), + ), # # # From ba5d7f9b9dbc7bc7310f7c9616f90e4a18ebed11 Mon Sep 17 00:00:00 2001 From: Ajay Singh Date: Thu, 22 May 2025 15:52:30 -0700 Subject: [PATCH 2/5] somewhat working unit test and serializer. Need to clean up again after we add codecovClient convenience func --- src/sentry/api/endpoints/test_results.py | 203 +++++++++++------- src/sentry/api/urls.py | 2 +- .../sentry/api/endpoints/test_test_results.py | 56 +++++ 3 files changed, 185 insertions(+), 76 deletions(-) create mode 100644 tests/sentry/api/endpoints/test_test_results.py diff --git a/src/sentry/api/endpoints/test_results.py b/src/sentry/api/endpoints/test_results.py index 6cc213e0638847..521e8ee1c2053b 100644 --- a/src/sentry/api/endpoints/test_results.py +++ b/src/sentry/api/endpoints/test_results.py @@ -1,3 +1,5 @@ +import sentry_sdk +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -62,14 +64,114 @@ } """ -# NOTE: There is no testResult resolver in GQL atm so if we need this, will need to build it. -get_query = """query GetTestResult( - $owner: String! - $repo: String! - $testResultId: String! -) { + +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() + + +class TestResultSerializer(serializers.Serializer): + """ + Serializer to transform GraphQL response to client format + """ + + def transform_graphql_response(self, graphql_response): + """ + Transform the GraphQL response format 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 node to the expected client format + transformed_results = [] + for edge in test_results: + node = edge["node"] + # Note: lastDuration is not in the GraphQL response, using avgDuration as fallback + transformed_result = { + "updatedAt": node["updatedAt"], + "name": node["name"], + "commitsFailed": node["commitsFailed"], + "failureRate": node["failureRate"], + "flakeRate": node["flakeRate"], + "avgDuration": node["avgDuration"], + "lastDuration": node.get( + "lastDuration", node["avgDuration"] + ), # fallback to avgDuration + "totalFailCount": node["totalFailCount"], + "totalFlakyFailCount": node["totalFlakyFailCount"], + "totalSkipCount": node["totalSkipCount"], + "totalPassCount": node["totalPassCount"], + } + transformed_results.append(transformed_result) + + return transformed_results + + except (KeyError, TypeError) as e: + # Handle malformed GraphQL response + sentry_sdk.capture_exception(e) + + return [] + + +# 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 @@ -79,79 +181,30 @@ class TestResultsEndpoint(CodecovEndpoint): "GET": ApiPublishStatus.PUBLIC, } - def get(self, request: Request) -> Response: + # Disable pagination requirement for this endpoint + def has_pagination(self, response): + return True + + def get(self, request: Request, owner: str, repository: str, commit: str) -> Response: """Retrieves the list of test results for a given commit. If a test result id is also provided, the endpoint will return the test result with that id.""" - test_result_id = request.GET.get("test_result_id") - owner = request.GET.get("owner") - repo = request.GET.get("repo") - commit = request.GET.get("commit") - variables = { "owner": owner, - "repo": repo, + "repo": repository, "commit": commit, } - assert variables # just to get rid of lint error - - if test_result_id: - # TODO: graphQL Query - # Passing in query into codecov client means we need the query to be structured by the time we call it. - - # res = CodecovClient.query(get_query, variables) - # transformed_res = CodecovClient.transform_response(res, serializer) - - return Response( - { - "updatedAt": "2021-01-01T00:00:00Z", - "name": "test", - "commitsFailed": 1, - "failureRate": 0.01, - "flakeRate": 100, - "avgDuration": 100, - "lastDuration": 100, - "totalFailCount": 1, - "totalFlakyFailCount": 1, - "totalSkipCount": 0, - "totalPassCount": 0, - } - ) - - # CodecovClient.query(list_query, variables) - - # TODO: Response filtering - - return Response( - { - [ - { - "updatedAt": "2021-01-01T00:00:00Z", - "name": "test", - "commitsFailed": 1, - "failureRate": 0.01, - "flakeRate": 100, - "avgDuration": 100, - "lastDuration": 100, - "totalFailCount": 1, - "totalFlakyFailCount": 1, - "totalSkipCount": 0, - "totalPassCount": 0, - }, - { - "updatedAt": "2021-01-01T00:00:00Z", - "name": "test", - "commitsFailed": 4, - "failureRate": 0.5, - "flakeRate": 0.2, - "avgDuration": 100, - "lastDuration": 100, - "totalFailCount": 4, - "totalFlakyFailCount": 0, - "totalSkipCount": 0, - "totalPassCount": 0, - }, - ] - } - ) + assert variables + + # TODO: Uncomment when CodecovClient is available + # graphql_response = CodecovClient.query(list_query, variables) + + # For now, use the sample response for demonstration + graphql_response = sample_graphql_response + + # Transform the GraphQL response to client format + serializer = TestResultSerializer() + test_results = serializer.transform_graphql_response(graphql_response) + + return Response(test_results) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 8c313fac7a449b..816e5b8e701f65 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -3189,7 +3189,7 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: PREVENT_URLS = [ re_path( - r"^owner/(?P[^\/]+)/repository/(?P[^\/]+)/commit/(?P[^\/]+)/test-results/(?P[^\/]+)$", + r"^owner/(?P[^\/]+)/repository/(?P[^\/]+)/commit/(?P[^\/]+)/test-results/$", TestResultsEndpoint.as_view(), name="sentry-api-0-test-results", ), diff --git a/tests/sentry/api/endpoints/test_test_results.py b/tests/sentry/api/endpoints/test_test_results.py new file mode 100644 index 00000000000000..1770716933ba19 --- /dev/null +++ b/tests/sentry/api/endpoints/test_test_results.py @@ -0,0 +1,56 @@ +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.api.endpoints.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}" From 20ad481ab879e013e8d5632ed5df8ea43c62032a Mon Sep 17 00:00:00 2001 From: Ajay Singh Date: Thu, 22 May 2025 16:16:00 -0700 Subject: [PATCH 3/5] update to use to_representation built in from custom def --- src/sentry/api/endpoints/test_results.py | 52 +++++++++--------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/src/sentry/api/endpoints/test_results.py b/src/sentry/api/endpoints/test_results.py index 521e8ee1c2053b..b5e31de8070ac4 100644 --- a/src/sentry/api/endpoints/test_results.py +++ b/src/sentry/api/endpoints/test_results.py @@ -80,16 +80,19 @@ class TestResultNodeSerializer(serializers.Serializer): totalFlakyFailCount = serializers.IntegerField() totalSkipCount = serializers.IntegerField() totalPassCount = serializers.IntegerField() + lastDuration = serializers.FloatField() -class TestResultSerializer(serializers.Serializer): +class TestResultSerializer(serializers.ListSerializer): """ - Serializer to transform GraphQL response to client format + Serializer for a list of test results - inherits from ListSerializer to handle arrays """ - def transform_graphql_response(self, graphql_response): + child = TestResultNodeSerializer() + + def to_representation(self, graphql_response): """ - Transform the GraphQL response format to the expected client format + Transform the GraphQL response to the expected client format """ try: # Extract test result nodes from the nested GraphQL structure @@ -97,34 +100,21 @@ def transform_graphql_response(self, graphql_response): "testResults" ]["edges"] - # Transform each node to the expected client format - transformed_results = [] + # Transform each edge to just the node data + nodes = [] for edge in test_results: node = edge["node"] - # Note: lastDuration is not in the GraphQL response, using avgDuration as fallback - transformed_result = { - "updatedAt": node["updatedAt"], - "name": node["name"], - "commitsFailed": node["commitsFailed"], - "failureRate": node["failureRate"], - "flakeRate": node["flakeRate"], - "avgDuration": node["avgDuration"], - "lastDuration": node.get( - "lastDuration", node["avgDuration"] - ), # fallback to avgDuration - "totalFailCount": node["totalFailCount"], - "totalFlakyFailCount": node["totalFlakyFailCount"], - "totalSkipCount": node["totalSkipCount"], - "totalPassCount": node["totalPassCount"], - } - transformed_results.append(transformed_result) - - return transformed_results + # 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 [] @@ -186,8 +176,7 @@ def has_pagination(self, response): return True def get(self, request: Request, owner: str, repository: str, commit: str) -> Response: - """Retrieves the list of test results for a given commit. If a test result id is also - provided, the endpoint will return the test result with that id.""" + """Retrieves the list of test results for a given commit.""" variables = { "owner": owner, @@ -200,11 +189,10 @@ def get(self, request: Request, owner: str, repository: str, commit: str) -> Res # TODO: Uncomment when CodecovClient is available # graphql_response = CodecovClient.query(list_query, variables) - # For now, use the sample response for demonstration - graphql_response = sample_graphql_response + graphql_response = sample_graphql_response # Mock response for now - # Transform the GraphQL response to client format + # transform response to the response that we want serializer = TestResultSerializer() - test_results = serializer.transform_graphql_response(graphql_response) + test_results = serializer.to_representation(graphql_response) return Response(test_results) From 5ccd2cf2f3eae8e3d9df32561f811cef0847e040 Mon Sep 17 00:00:00 2001 From: Ajay Singh Date: Wed, 28 May 2025 09:41:27 -0700 Subject: [PATCH 4/5] move stuff around and rebase --- src/sentry/api/urls.py | 2 +- src/sentry/{api/bases/codecov.py => codecov/base.py} | 0 .../endpoints/TestResults}/test_results.py | 2 +- tests/sentry/{api => codecov}/endpoints/test_test_results.py | 5 ++++- 4 files changed, 6 insertions(+), 3 deletions(-) rename src/sentry/{api/bases/codecov.py => codecov/base.py} (100%) rename src/sentry/{api/endpoints => codecov/endpoints/TestResults}/test_results.py (99%) rename tests/sentry/{api => codecov}/endpoints/test_test_results.py (92%) diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 798ca90f9eeb94..41dc694e9529e8 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -67,8 +67,8 @@ from sentry.api.endpoints.source_map_debug_blue_thunder_edition import ( SourceMapDebugBlueThunderEditionEndpoint, ) -from sentry.api.endpoints.test_results import TestResultsEndpoint 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 diff --git a/src/sentry/api/bases/codecov.py b/src/sentry/codecov/base.py similarity index 100% rename from src/sentry/api/bases/codecov.py rename to src/sentry/codecov/base.py diff --git a/src/sentry/api/endpoints/test_results.py b/src/sentry/codecov/endpoints/TestResults/test_results.py similarity index 99% rename from src/sentry/api/endpoints/test_results.py rename to src/sentry/codecov/endpoints/TestResults/test_results.py index b5e31de8070ac4..a4056760a0167b 100644 --- a/src/sentry/api/endpoints/test_results.py +++ b/src/sentry/codecov/endpoints/TestResults/test_results.py @@ -6,7 +6,7 @@ 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.codecov import CodecovEndpoint +from sentry.codecov.base import CodecovEndpoint list_query = """query GetTestResults( $owner: String! diff --git a/tests/sentry/api/endpoints/test_test_results.py b/tests/sentry/codecov/endpoints/test_test_results.py similarity index 92% rename from tests/sentry/api/endpoints/test_test_results.py rename to tests/sentry/codecov/endpoints/test_test_results.py index 1770716933ba19..2166c4090f5806 100644 --- a/tests/sentry/api/endpoints/test_test_results.py +++ b/tests/sentry/codecov/endpoints/test_test_results.py @@ -23,7 +23,10 @@ def reverse_url(self, owner="testowner", repository="testrepo", commit="testcomm }, ) - @patch("sentry.api.endpoints.test_results.TestResultsEndpoint.permission_classes", ()) + @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() From 4a9f16e5c3405460dc877306d671640893dc1cc3 Mon Sep 17 00:00:00 2001 From: Ajay Singh Date: Wed, 28 May 2025 17:08:24 -0700 Subject: [PATCH 5/5] start splitting stuff out --- .../codecov/endpoints/TestResults/query.py | 55 ++++++++ .../endpoints/TestResults/serializers.py | 55 ++++++++ .../endpoints/TestResults/test_results.py | 125 +----------------- 3 files changed, 116 insertions(+), 119 deletions(-) create mode 100644 src/sentry/codecov/endpoints/TestResults/query.py create mode 100644 src/sentry/codecov/endpoints/TestResults/serializers.py 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 index a4056760a0167b..8258e4f4a0736f 100644 --- a/src/sentry/codecov/endpoints/TestResults/test_results.py +++ b/src/sentry/codecov/endpoints/TestResults/test_results.py @@ -1,5 +1,3 @@ -import sentry_sdk -from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response @@ -7,116 +5,9 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.codecov.base import CodecovEndpoint - -list_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 - } - } - } -} -""" - - -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 [] - +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 = { @@ -175,24 +66,20 @@ class TestResultsEndpoint(CodecovEndpoint): def has_pagination(self, response): return True - def get(self, request: Request, owner: str, repository: str, commit: str) -> Response: + 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, - "commit": commit, } assert variables - # TODO: Uncomment when CodecovClient is available - # graphql_response = CodecovClient.query(list_query, variables) + graphql_response = CodecovApiClient.query(query, variables) graphql_response = sample_graphql_response # Mock response for now - # transform response to the response that we want - serializer = TestResultSerializer() - test_results = serializer.to_representation(graphql_response) + test_results = TestResultSerializer().to_representation(graphql_response) return Response(test_results)