Skip to content

Commit 567ffec

Browse files
authored
feat(prevent): create endpoint that fetches repositories list (#95181)
This PR creates and endpoint that fetches a list of repositories belonging to a specific owner Notes - Added path to access endpoint - Added query file with GQL query - Added endpoint logic + its serializer - Added test file for said logic
1 parent db30771 commit 567ffec

File tree

6 files changed

+413
-2
lines changed

6 files changed

+413
-2
lines changed

src/sentry/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
)
7979
from sentry.api.endpoints.trace_explorer_ai_setup import TraceExplorerAISetup
8080
from sentry.auth_v2.urls import AUTH_V2_URLS
81+
from sentry.codecov.endpoints.Repositories.repositories import RepositoriesEndpoint
8182
from sentry.codecov.endpoints.TestResults.test_results import TestResultsEndpoint
8283
from sentry.codecov.endpoints.TestResultsAggregates.test_results_aggregates import (
8384
TestResultsAggregatesEndpoint,
@@ -1066,6 +1067,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
10661067
TestResultsAggregatesEndpoint.as_view(),
10671068
name="sentry-api-0-test-results-aggregates",
10681069
),
1070+
re_path(
1071+
r"^owner/(?P<owner>[^/]+)/repositories/$",
1072+
RepositoriesEndpoint.as_view(),
1073+
name="sentry-api-0-repositories",
1074+
),
10691075
]
10701076

10711077

src/sentry/apidocs/parameters.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,8 +1119,7 @@ class PreventParams:
11191119
location="query",
11201120
required=False,
11211121
type=str,
1122-
description="""The cursor to start the query from. Will return results after the cursor if used with `first` or before the cursor if used with `last`.
1123-
""",
1122+
description="""The cursor to start the query from. Will return results after the cursor if used with `first` or before the cursor if used with `last`.""",
11241123
)
11251124
TERM = OpenApiParameter(
11261125
name="term",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
query = """query ReposForOwner(
2+
$owner: String!
3+
$filters: RepositorySetFilters!
4+
$ordering: RepositoryOrdering!
5+
$direction: OrderingDirection!
6+
$first: Int
7+
$after: String
8+
$last: Int
9+
$before: String
10+
) {
11+
owner(username: $owner) {
12+
repositories(
13+
filters: $filters
14+
ordering: $ordering
15+
orderingDirection: $direction
16+
first: $first
17+
after: $after
18+
last: $last
19+
before: $before
20+
) {
21+
edges {
22+
node {
23+
name
24+
updatedAt
25+
latestCommitAt
26+
defaultBranch
27+
}
28+
}
29+
pageInfo {
30+
startCursor
31+
endCursor
32+
hasNextPage
33+
hasPreviousPage
34+
}
35+
totalCount
36+
}
37+
}
38+
}"""
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from drf_spectacular.utils import extend_schema
2+
from rest_framework import status
3+
from rest_framework.request import Request
4+
from rest_framework.response import Response
5+
6+
from sentry.api.api_owners import ApiOwner
7+
from sentry.api.api_publish_status import ApiPublishStatus
8+
from sentry.api.base import region_silo_endpoint
9+
from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND
10+
from sentry.apidocs.parameters import GlobalParams, PreventParams
11+
from sentry.codecov.base import CodecovEndpoint
12+
from sentry.codecov.client import CodecovApiClient
13+
from sentry.codecov.endpoints.Repositories.query import query
14+
from sentry.codecov.endpoints.Repositories.serializers import RepositoriesSerializer
15+
from sentry.codecov.enums import OrderingDirection
16+
17+
MAX_RESULTS_PER_PAGE = 50
18+
19+
20+
@extend_schema(tags=["Prevent"])
21+
@region_silo_endpoint
22+
class RepositoriesEndpoint(CodecovEndpoint):
23+
__test__ = False
24+
25+
owner = ApiOwner.CODECOV
26+
publish_status = {
27+
"GET": ApiPublishStatus.PUBLIC,
28+
}
29+
30+
@extend_schema(
31+
operation_id="Retrieves repository list for a given owner",
32+
parameters=[
33+
GlobalParams.ORG_ID_OR_SLUG,
34+
PreventParams.OWNER,
35+
PreventParams.FIRST,
36+
PreventParams.LAST,
37+
PreventParams.CURSOR,
38+
PreventParams.TERM,
39+
],
40+
request=None,
41+
responses={
42+
200: RepositoriesSerializer,
43+
400: RESPONSE_BAD_REQUEST,
44+
403: RESPONSE_FORBIDDEN,
45+
404: RESPONSE_NOT_FOUND,
46+
},
47+
)
48+
def get(self, request: Request, owner: str, **kwargs) -> Response:
49+
"""
50+
Retrieves repository data for a given owner.
51+
"""
52+
53+
first_param = request.query_params.get("first")
54+
last_param = request.query_params.get("last")
55+
cursor = request.query_params.get("cursor")
56+
57+
# When calling request.query_params, the URL is decoded so + is replaced with spaces. We need to change them back so Codecov can properly fetch the next page.
58+
if cursor:
59+
cursor = cursor.replace(" ", "+")
60+
61+
try:
62+
first = int(first_param) if first_param is not None else None
63+
last = int(last_param) if last_param is not None else None
64+
except ValueError:
65+
return Response(
66+
status=status.HTTP_400_BAD_REQUEST,
67+
data={"details": "Query parameters 'first' and 'last' must be integers."},
68+
)
69+
70+
if first is not None and last is not None:
71+
return Response(
72+
status=status.HTTP_400_BAD_REQUEST,
73+
data={"details": "Cannot specify both `first` and `last`"},
74+
)
75+
76+
if first is None and last is None:
77+
first = MAX_RESULTS_PER_PAGE
78+
79+
variables = {
80+
"owner": owner,
81+
"filters": {"term": request.query_params.get("term")},
82+
"direction": OrderingDirection.DESC.value,
83+
"ordering": "COMMIT_DATE",
84+
"first": first,
85+
"last": last,
86+
"before": cursor if cursor and last else None,
87+
"after": cursor if cursor and first else None,
88+
}
89+
90+
client = CodecovApiClient(git_provider_org=owner)
91+
graphql_response = client.query(query=query, variables=variables)
92+
repositories = RepositoriesSerializer().to_representation(graphql_response.json())
93+
94+
return Response(repositories)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import logging
2+
3+
import sentry_sdk
4+
from rest_framework import serializers
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class RepositoryNodeSerializer(serializers.Serializer):
10+
"""
11+
Serializer for individual repository nodes from GraphQL response
12+
"""
13+
14+
__test__ = False
15+
16+
name = serializers.CharField()
17+
updatedAt = serializers.DateTimeField()
18+
latestCommitAt = serializers.DateTimeField()
19+
defaultBranch = serializers.CharField()
20+
21+
22+
class PageInfoTempSerializer(serializers.Serializer):
23+
"""
24+
Serializer for pagination information
25+
"""
26+
27+
startCursor = serializers.CharField(allow_null=True)
28+
endCursor = serializers.CharField(allow_null=True)
29+
hasNextPage = serializers.BooleanField()
30+
hasPreviousPage = serializers.BooleanField()
31+
32+
33+
class RepositoriesSerializer(serializers.Serializer):
34+
"""
35+
Serializer for repositories response
36+
"""
37+
38+
__test__ = False
39+
40+
results = RepositoryNodeSerializer(many=True)
41+
pageInfo = PageInfoTempSerializer()
42+
totalCount = serializers.IntegerField()
43+
44+
def to_representation(self, graphql_response):
45+
"""
46+
Transform the GraphQL response to the serialized format
47+
"""
48+
try:
49+
repository_data = graphql_response["data"]["owner"]["repositories"]
50+
repositories = repository_data["edges"]
51+
page_info = repository_data.get("pageInfo", {})
52+
53+
nodes = []
54+
for edge in repositories:
55+
node = edge["node"]
56+
nodes.append(node)
57+
58+
response_data = {
59+
"results": nodes,
60+
"pageInfo": repository_data.get(
61+
"pageInfo",
62+
{
63+
"hasNextPage": page_info.get("hasNextPage", False),
64+
"hasPreviousPage": page_info.get("hasPreviousPage", False),
65+
"startCursor": page_info.get("startCursor"),
66+
"endCursor": page_info.get("endCursor"),
67+
},
68+
),
69+
"totalCount": repository_data.get("totalCount", len(nodes)),
70+
}
71+
72+
return super().to_representation(response_data)
73+
74+
except (KeyError, TypeError) as e:
75+
sentry_sdk.capture_exception(e)
76+
logger.exception(
77+
"Error parsing GraphQL response",
78+
extra={
79+
"error": str(e),
80+
"endpoint": "repositories",
81+
"response_keys": (
82+
list(graphql_response.keys())
83+
if isinstance(graphql_response, dict)
84+
else None
85+
),
86+
},
87+
)
88+
raise

0 commit comments

Comments
 (0)