Skip to content

[Draft] feat: Initial codecov endpoint for fetching test results #91911

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sentry/api/api_owners.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ApiOwner(Enum):

ALERTS_NOTIFICATIONS = "alerts-notifications"
BILLING = "revenue"
CODECOV = "codecov"
CRONS = "crons"
ECOSYSTEM = "ecosystem"
ENTERPRISE = "enterprise"
Expand Down
14 changes: 14 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3206,6 +3207,14 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
),
]

PREVENT_URLS = [
re_path(
r"^owner/(?P<owner>[^\/]+)/repository/(?P<repository>[^\/]+)/commit/(?P<commit>[^\/]+)/test-results/$",
TestResultsEndpoint.as_view(),
name="sentry-api-0-test-results",
),
]

urlpatterns = [
# Relay
re_path(
Expand Down Expand Up @@ -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),
),
#
#
#
Expand Down
16 changes: 16 additions & 0 deletions src/sentry/codecov/base.py
Original file line number Diff line number Diff line change
@@ -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)
55 changes: 55 additions & 0 deletions src/sentry/codecov/endpoints/TestResults/query.py
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
"""
55 changes: 55 additions & 0 deletions src/sentry/codecov/endpoints/TestResults/serializers.py
Original file line number Diff line number Diff line change
@@ -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 []
85 changes: 85 additions & 0 deletions src/sentry/codecov/endpoints/TestResults/test_results.py
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 59 additions & 0 deletions tests/sentry/codecov/endpoints/test_test_results.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading