Skip to content

Commit 1774b19

Browse files
authored
feat: Initial codecov endpoint for fetching test results (#91911)
This PR creates our first new Sentry endpoint using the API Client and Auth Middleware to successfully send a request to the Codecov Backend. It is the endpoint for returning a list of test results for a particular repository. A lot of this PR pulls aspects from these [helpful Sentry API Docs](https://develop.sentry.dev/backend/api/public/). For this proof of concept the endpoint is currently hard coded to return just the first 10 test results from the codecov gazebo repository to unblock creation of the hook. For my reviewers, here's roughly where everything is in this PR: - urls.py -- Where the new endpoint is located - build.py -- This populates the sidebar for the sentry [API doc page](https://docs.sentry.io/api/auth/) - parameters.py -- This creates a new label for our "usual" parameters for our API docs - base.py -- implements Sentry endpoint class. We can modify this as needed overtime. Holds permission classes - client.py -- adds json attribute to post function / updates the callsite in query fn, since the GQL endpoint returns a json object - Endpoints/TestResults -- Holds the query / serializer / endpoint business logic and the hardcoded stuff <!-- Sentry employees and contractors can delete or ignore the following. --> ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms.
1 parent 0c342a0 commit 1774b19

File tree

13 files changed

+417
-10
lines changed

13 files changed

+417
-10
lines changed

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ tests/sentry/api/endpoints/test_organization_dashboard_widget_details.py @ge
374374
/static/app/views/codecov/ @getsentry/codecov-merge-ux
375375
## End of Codecov Merge UX
376376

377+
/src/sentry/codecov/ @getsentry/codecov
378+
377379
## Frontend
378380
/static/app/components/analyticsArea.spec.tsx @getsentry/app-frontend
379381
/static/app/components/analyticsArea.tsx @getsentry/app-frontend

src/sentry/api/api_owners.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class ApiOwner(Enum):
99

1010
ALERTS_NOTIFICATIONS = "alerts-notifications"
1111
BILLING = "revenue"
12+
CODECOV = "codecov"
1213
CRONS = "crons"
1314
ECOSYSTEM = "ecosystem"
1415
ENTERPRISE = "enterprise"

src/sentry/api/urls.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
SourceMapDebugBlueThunderEditionEndpoint,
6969
)
7070
from sentry.api.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup
71+
from sentry.codecov.endpoints.TestResults.test_results import TestResultsEndpoint
7172
from sentry.data_export.endpoints.data_export import DataExportEndpoint
7273
from sentry.data_export.endpoints.data_export_details import DataExportDetailsEndpoint
7374
from sentry.data_secrecy.api.waive_data_secrecy import WaiveDataSecrecyEndpoint
@@ -3237,6 +3238,14 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
32373238
),
32383239
]
32393240

3241+
PREVENT_URLS = [
3242+
re_path(
3243+
r"^owner/(?P<owner>[^\/]+)/repository/(?P<repository>[^\/]+)/test-results/$",
3244+
TestResultsEndpoint.as_view(),
3245+
name="sentry-api-0-test-results",
3246+
),
3247+
]
3248+
32403249
urlpatterns = [
32413250
# Relay
32423251
re_path(
@@ -3297,6 +3306,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
32973306
r"^broadcasts/",
32983307
include(BROADCAST_URLS),
32993308
),
3309+
# Prevent
3310+
re_path(
3311+
r"^prevent/",
3312+
include(PREVENT_URLS),
3313+
),
33003314
#
33013315
#
33023316
#

src/sentry/apidocs/build.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,14 @@ def get_old_json_components(filename: str) -> Any:
174174
"url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/integration-platform/&template=api_error_template.md",
175175
},
176176
},
177+
{
178+
"name": "Prevent",
179+
"x-sidebar-name": "Prevent",
180+
"description": "Endpoints for Prevent",
181+
"x-display-description": False,
182+
"externalDocs": {
183+
"description": "Found an error? Let us know.",
184+
"url": "https://github.com/getsentry/sentry-docs/issues/new/?title=API%20Documentation%20Error:%20/api/prevent/&template=api_error_template.md",
185+
},
186+
},
177187
]

src/sentry/apidocs/parameters.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,3 +1024,20 @@ class ExploreSavedQueriesParams:
10241024
- `myqueries`
10251025
""",
10261026
)
1027+
1028+
1029+
class PreventParams:
1030+
OWNER = OpenApiParameter(
1031+
name="owner",
1032+
location="path",
1033+
required=True,
1034+
type=str,
1035+
description="The owner of the repository.",
1036+
)
1037+
REPOSITORY = OpenApiParameter(
1038+
name="repository",
1039+
location="path",
1040+
required=True,
1041+
type=str,
1042+
description="The name of the repository.",
1043+
)

src/sentry/codecov/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from rest_framework.request import Request
4+
5+
from sentry.api.base import Endpoint
6+
7+
8+
class CodecovEndpoint(Endpoint):
9+
"""
10+
Used for endpoints that are specific to Codecov / Prevent.
11+
"""
12+
13+
permission_classes = ()
14+
15+
def convert_args(self, request: Request, *args, **kwargs):
16+
parsed_args, parsed_kwargs = super().convert_args(request, *args, **kwargs)
17+
# TODO: in case we need to modify args, do it here
18+
return (parsed_args, parsed_kwargs)

src/sentry/codecov/client.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def __init__(
8181
"g_p": git_provider,
8282
}
8383

84-
def get(self, endpoint: str, params=None, headers=None) -> requests.Response | None:
84+
def get(self, endpoint: str, params=None, headers=None) -> requests.Response:
8585
"""
8686
Makes a GET request to the specified endpoint of the configured Codecov
8787
API host with the provided params and headers.
@@ -104,7 +104,7 @@ def get(self, endpoint: str, params=None, headers=None) -> requests.Response | N
104104

105105
return response
106106

107-
def post(self, endpoint: str, data=None, headers=None) -> requests.Response | None:
107+
def post(self, endpoint: str, data=None, json=None, headers=None) -> requests.Response:
108108
"""
109109
Makes a POST request to the specified endpoint of the configured Codecov
110110
API host with the provided data and headers.
@@ -119,7 +119,9 @@ def post(self, endpoint: str, data=None, headers=None) -> requests.Response | No
119119
headers.update(jwt.authorization_header(token))
120120
url = f"{self.base_url}{endpoint}"
121121
try:
122-
response = requests.post(url, data=data, headers=headers, timeout=TIMEOUT_SECONDS)
122+
response = requests.post(
123+
url, data=data, json=json, headers=headers, timeout=TIMEOUT_SECONDS
124+
)
123125
except Exception:
124126
logger.exception("Error when making POST request")
125127
raise
@@ -128,7 +130,7 @@ def post(self, endpoint: str, data=None, headers=None) -> requests.Response | No
128130

129131
def query(
130132
self, query: str, variables: dict, provider: GitProvider = GitProvider.GitHub
131-
) -> requests.Response | None:
133+
) -> requests.Response:
132134
"""
133135
Convenience method for making a GraphQL query to the Codecov API, using the post method of this client.
134136
This method is used to make GraphQL queries to the Codecov API. Adds headers similar to what's done in Gazebo,
@@ -141,16 +143,16 @@ def query(
141143
:return: The response from the Codecov API.
142144
"""
143145

144-
data = {
146+
json = {
145147
"query": query,
146148
"variables": variables,
147149
}
148150

149151
return self.post(
150152
f"/graphql/sentry/{provider.value}",
151-
data=data,
153+
json=json,
152154
headers={
153-
"Content-Type": "application/json",
155+
"Content-Type": "application/json; charset=utf-8",
154156
"Accept": "application/json",
155157
"Token-Type": f"{provider.value}-token",
156158
},
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
query = """query GetTestResults(
2+
$owner: String!
3+
$repo: String!
4+
$filters: TestResultsFilters
5+
$ordering: TestResultsOrdering
6+
$first: Int
7+
$after: String
8+
$last: Int
9+
$before: String
10+
) {
11+
owner(username: $owner) {
12+
repository: repository(name: $repo) {
13+
__typename
14+
... on Repository {
15+
testAnalytics {
16+
testResults(
17+
filters: $filters
18+
ordering: $ordering
19+
first: $first
20+
after: $after
21+
last: $last
22+
before: $before
23+
) {
24+
edges {
25+
node {
26+
updatedAt
27+
avgDuration
28+
lastDuration
29+
name
30+
failureRate
31+
flakeRate
32+
commitsFailed
33+
totalFailCount
34+
totalFlakyFailCount
35+
totalSkipCount
36+
totalPassCount
37+
}
38+
}
39+
pageInfo {
40+
endCursor
41+
hasNextPage
42+
}
43+
totalCount
44+
}
45+
}
46+
}
47+
... on NotFoundError {
48+
message
49+
}
50+
... on OwnerNotActivatedError {
51+
message
52+
}
53+
}
54+
}
55+
}
56+
"""
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import logging
2+
3+
import sentry_sdk
4+
from rest_framework import serializers
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class TestResultNodeSerializer(serializers.Serializer):
10+
"""
11+
Serializer for individual test result nodes from GraphQL response
12+
"""
13+
14+
updatedAt = serializers.CharField()
15+
avgDuration = serializers.FloatField()
16+
name = serializers.CharField()
17+
failureRate = serializers.FloatField()
18+
flakeRate = serializers.FloatField()
19+
commitsFailed = serializers.IntegerField()
20+
totalFailCount = serializers.IntegerField()
21+
totalFlakyFailCount = serializers.IntegerField()
22+
totalSkipCount = serializers.IntegerField()
23+
totalPassCount = serializers.IntegerField()
24+
lastDuration = serializers.FloatField()
25+
26+
27+
class PageInfoSerializer(serializers.Serializer):
28+
"""
29+
Serializer for pagination information
30+
"""
31+
32+
endCursor = serializers.CharField(allow_null=True)
33+
hasNextPage = serializers.BooleanField()
34+
35+
36+
class TestResultSerializer(serializers.Serializer):
37+
"""
38+
Serializer for test results response including pagination metadata
39+
"""
40+
41+
results = TestResultNodeSerializer(many=True)
42+
pageInfo = PageInfoSerializer()
43+
totalCount = serializers.IntegerField()
44+
45+
def to_representation(self, graphql_response):
46+
"""
47+
Transform the GraphQL response to the serialized format
48+
"""
49+
try:
50+
test_results_data = graphql_response["data"]["owner"]["repository"]["testAnalytics"][
51+
"testResults"
52+
]
53+
test_results = test_results_data["edges"]
54+
55+
nodes = []
56+
for edge in test_results:
57+
node = edge["node"]
58+
nodes.append(node)
59+
60+
response_data = {
61+
"results": nodes,
62+
"pageInfo": test_results_data.get(
63+
"pageInfo", {"endCursor": None, "hasNextPage": False}
64+
),
65+
"totalCount": test_results_data.get("totalCount", len(nodes)),
66+
}
67+
68+
return super().to_representation(response_data)
69+
70+
except (KeyError, TypeError) as e:
71+
sentry_sdk.capture_exception(e)
72+
logger.exception(
73+
"Error parsing GraphQL response",
74+
extra={
75+
"error": str(e),
76+
"response_keys": (
77+
list(graphql_response.keys())
78+
if isinstance(graphql_response, dict)
79+
else None
80+
),
81+
},
82+
)
83+
raise

0 commit comments

Comments
 (0)