Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit defc6a5

Browse files
authored
Optimize fetch_repository resolver (#1261)
1 parent d6b5c58 commit defc6a5

File tree

7 files changed

+302
-30
lines changed

7 files changed

+302
-30
lines changed

codecov_auth/tests/unit/test_authentication.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import datetime, timedelta
22
from http.cookies import SimpleCookie
3-
from unittest.mock import call, patch
3+
from unittest.mock import AsyncMock, call, patch
44

55
import pytest
66
from django.conf import settings
@@ -198,7 +198,9 @@ def test_impersonation(self):
198198
assert res.json()["data"]["me"] == {"user": {"username": "impersonateme"}}
199199

200200
@patch("core.commands.repository.repository.RepositoryCommands.fetch_repository")
201-
def test_impersonation_with_okta(self, mock_call_to_fetch_repository):
201+
def test_impersonation_with_okta(
202+
self, mock_call_to_fetch_repository, new_callable=AsyncMock
203+
):
202204
repo = RepositoryFactory(author=self.owner_to_impersonate, private=True)
203205
query_repositories = """{ owner(username: "%s") { repository(name: "%s") { ... on Repository { name } } } }"""
204206
query = query_repositories % (repo.author.username, repo.name)
@@ -228,12 +230,16 @@ def test_impersonation_with_okta(self, mock_call_to_fetch_repository):
228230
repo.name,
229231
[],
230232
exclude_okta_enforced_repos=True,
233+
needs_coverage=False,
234+
needs_commits=False,
231235
),
232236
call(
233237
self.owner_to_impersonate,
234238
repo.name,
235239
[],
236240
exclude_okta_enforced_repos=False,
241+
needs_coverage=False,
242+
needs_commits=False,
237243
),
238244
]
239245
)

core/commands/repository/interactors/fetch_repository.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,20 @@ def execute(
1515
name: str,
1616
okta_authenticated_accounts: list[int],
1717
exclude_okta_enforced_repos: bool = True,
18+
needs_coverage: bool = True,
19+
needs_commits: bool = True,
1820
) -> Repository | None:
1921
queryset = Repository.objects.viewable_repos(self.current_owner)
2022
if exclude_okta_enforced_repos:
2123
queryset = queryset.exclude_accounts_enforced_okta(
2224
okta_authenticated_accounts
2325
)
2426

25-
# TODO(swatinem): We should find a way to avoid these combinators:
26-
# The `with_recent_coverage` combinator is quite expensive.
27-
# We only need that in case we want to query these props via graphql:
28-
# `coverageAnalytics.{percentCovered,commitSha,hits,misses,lines}`
29-
# Similarly, `with_oldest_commit_at` is only needed for `oldestCommitAt`.
27+
if needs_coverage:
28+
queryset = queryset.with_recent_coverage()
29+
if needs_commits:
30+
queryset = queryset.with_oldest_commit_at()
3031

31-
repo = (
32-
queryset.filter(author=owner, name=name)
33-
.with_recent_coverage()
34-
.with_oldest_commit_at()
35-
.select_related("author")
36-
.first()
37-
)
32+
repo = queryset.filter(author=owner, name=name).select_related("author").first()
3833

3934
return repo

core/commands/repository/repository.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,8 @@
2121

2222

2323
class RepositoryCommands(BaseCommand):
24-
def fetch_repository(
25-
self,
26-
owner: Owner,
27-
name: str,
28-
okta_authenticated_accounts: list[int],
29-
exclude_okta_enforced_repos: bool = True,
30-
) -> Repository | None:
31-
return self.get_interactor(FetchRepositoryInteractor).execute(
32-
owner,
33-
name,
34-
okta_authenticated_accounts,
35-
exclude_okta_enforced_repos=exclude_okta_enforced_repos,
36-
)
24+
def fetch_repository(self, *args, **kwargs) -> Repository | None:
25+
return self.get_interactor(FetchRepositoryInteractor).execute(*args, **kwargs)
3726

3827
def regenerate_repository_upload_token(
3928
self,

core/commands/repository/tests/test_repository.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ def setUp(self):
1717
@patch("core.commands.repository.repository.FetchRepositoryInteractor.execute")
1818
def test_fetch_repository_to_interactor(self, interactor_mock):
1919
self.command.fetch_repository(self.org, self.repo.name, [])
20-
interactor_mock.assert_called_once_with(
21-
self.org, self.repo.name, [], exclude_okta_enforced_repos=True
22-
)
20+
interactor_mock.assert_called_once_with(self.org, self.repo.name, [])
2321

2422
@patch("core.commands.repository.repository.FetchRepositoryInteractor.execute")
2523
def test_fetch_repository_to_interactor_with_enforcing_okta(self, interactor_mock):
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# This was adapted from <https://github.com/mirumee/ariadne/discussions/1116#discussioncomment-6508603>
2+
from collections.abc import Generator, Iterable
3+
4+
from graphql import GraphQLResolveInfo
5+
from graphql.language import (
6+
FieldNode,
7+
FragmentSpreadNode,
8+
InlineFragmentNode,
9+
SelectionNode,
10+
)
11+
12+
13+
def selected_fields(info: GraphQLResolveInfo) -> set[str]:
14+
"""
15+
Given a GraphQL "sub-query", this recursively collects all the queried fields.
16+
17+
For example, if the original GraphQL query looks like `owner { repository { name } }`,
18+
this would resolve to `repository` and `repository.name`.
19+
20+
This function works by traversing the parts of the GraphQL Query AST which
21+
are exposed to each "resolver".
22+
"""
23+
names: set[str] = set()
24+
for node in info.field_nodes:
25+
if node.selection_set is None:
26+
continue
27+
names.update(_fields_from_selections(info, node.selection_set.selections))
28+
return names
29+
30+
31+
def _fields_from_selections(
32+
info: GraphQLResolveInfo, selections: Iterable[SelectionNode]
33+
) -> Generator[str, None, None]:
34+
for selection in selections:
35+
match selection:
36+
case FieldNode():
37+
name = selection.name.value
38+
yield name
39+
40+
if selection.selection_set is not None:
41+
yield from (
42+
f"{name}.{field}"
43+
for field in _fields_from_selections(
44+
info, selection.selection_set.selections
45+
)
46+
)
47+
48+
case InlineFragmentNode():
49+
yield from _fields_from_selections(
50+
info, selection.selection_set.selections
51+
)
52+
case FragmentSpreadNode():
53+
fragment = info.fragments[selection.name.value]
54+
yield from _fields_from_selections(
55+
info, fragment.selection_set.selections
56+
)
57+
58+
case _:
59+
raise NotImplementedError(f"field type {type(selection)} not supported")
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
from graphql import GraphQLResolveInfo
2+
from graphql.language import (
3+
FragmentDefinitionNode,
4+
OperationDefinitionNode,
5+
parse,
6+
)
7+
8+
from graphql_api.helpers.requested_fields import selected_fields
9+
10+
11+
def parse_into_resolveinfo(source: str) -> GraphQLResolveInfo:
12+
document = parse(source)
13+
14+
operation: OperationDefinitionNode | None = None
15+
fragments: dict[str, FragmentDefinitionNode] = {}
16+
17+
for definition in document.definitions:
18+
if isinstance(definition, OperationDefinitionNode):
19+
operation = definition
20+
elif isinstance(definition, FragmentDefinitionNode):
21+
fragments[definition.name.value] = definition
22+
23+
assert operation
24+
root_fields = [operation]
25+
26+
return GraphQLResolveInfo(
27+
"__root__",
28+
root_fields, # list[FieldNode]
29+
None,
30+
None,
31+
None,
32+
None,
33+
fragments, # dict[str, FragmentDefinitionNode]
34+
None,
35+
None,
36+
None,
37+
None,
38+
None,
39+
)
40+
41+
42+
QUERY_CoverageForFile = """
43+
query CoverageForFile(
44+
$owner: String!
45+
$repo: String!
46+
$ref: String!
47+
$path: String!
48+
$flags: [String]
49+
$components: [String]
50+
) {
51+
owner(username: $owner) {
52+
repository(name: $repo) {
53+
__typename
54+
... on Repository {
55+
commit(id: $ref) {
56+
...CoverageForFile
57+
}
58+
branch(name: $ref) {
59+
name
60+
head {
61+
...CoverageForFile
62+
}
63+
}
64+
}
65+
... on NotFoundError {
66+
message
67+
}
68+
... on OwnerNotActivatedError {
69+
message
70+
}
71+
}
72+
}
73+
}
74+
75+
fragment CoverageForFile on Commit {
76+
commitid
77+
coverageAnalytics {
78+
flagNames
79+
components {
80+
id
81+
name
82+
}
83+
coverageFile(path: $path, flags: $flags, components: $components) {
84+
hashedPath
85+
content
86+
coverage {
87+
line
88+
coverage
89+
}
90+
totals {
91+
percentCovered # Absolute coverage of the commit
92+
}
93+
}
94+
}
95+
}
96+
"""
97+
98+
QUERY_GetRepoConfigurationStatus = """
99+
query GetRepoConfigurationStatus($owner: String!, $repo: String!) {
100+
owner(username: $owner) {
101+
plan {
102+
isTeamPlan
103+
}
104+
repository(name:$repo) {
105+
__typename
106+
... on Repository {
107+
coverageEnabled
108+
bundleAnalysisEnabled
109+
testAnalyticsEnabled
110+
yaml
111+
languages
112+
coverageAnalytics {
113+
flagsCount
114+
componentsCount
115+
}
116+
}
117+
... on NotFoundError {
118+
message
119+
}
120+
... on OwnerNotActivatedError {
121+
message
122+
}
123+
}
124+
}
125+
}
126+
"""
127+
128+
QUERY_ReposForOwner = """
129+
query ReposForOwner(
130+
$filters: RepositorySetFilters!
131+
$owner: String!
132+
$ordering: RepositoryOrdering!
133+
$direction: OrderingDirection!
134+
$after: String
135+
$first: Int
136+
) {
137+
owner(username: $owner) {
138+
repositories(
139+
filters: $filters
140+
ordering: $ordering
141+
orderingDirection: $direction
142+
first: $first
143+
after: $after
144+
) {
145+
edges {
146+
node {
147+
name
148+
active
149+
activated
150+
private
151+
coverageAnalytics {
152+
percentCovered
153+
lines
154+
}
155+
updatedAt
156+
latestCommitAt
157+
author {
158+
username
159+
}
160+
coverageEnabled
161+
bundleAnalysisEnabled
162+
repositoryConfig {
163+
indicationRange {
164+
upperRange
165+
lowerRange
166+
}
167+
}
168+
}
169+
}
170+
pageInfo {
171+
hasNextPage
172+
endCursor
173+
}
174+
}
175+
}
176+
}
177+
"""
178+
179+
180+
def test_requested_fields():
181+
info = parse_into_resolveinfo(QUERY_CoverageForFile)
182+
fields = selected_fields(info)
183+
184+
assert "owner.repository.branch.name" in fields
185+
assert "owner.repository.branch.head.commitid" in fields
186+
assert (
187+
"owner.repository.branch.head.coverageAnalytics.coverageFile.totals.percentCovered"
188+
in fields
189+
)
190+
191+
assert "owner.repository.oldestCommitAt" not in fields
192+
assert "owner.repository.coverageAnalytics.percentCovered" not in fields
193+
assert "owner.repository.coverageAnalytics.commitSha" not in fields
194+
195+
info = parse_into_resolveinfo(QUERY_GetRepoConfigurationStatus)
196+
fields = selected_fields(info)
197+
198+
assert "owner.repository.coverageAnalytics" in fields
199+
assert "owner.repository.oldestCommitAt" not in fields
200+
assert "owner.repository.coverageAnalytics.percentCovered" not in fields
201+
202+
info = parse_into_resolveinfo(QUERY_ReposForOwner)
203+
fields = selected_fields(info)
204+
205+
assert "owner.repositories.edges.node.latestCommitAt" in fields
206+
assert "owner.repositories.edges.node.oldestCommitAt" not in fields
207+
assert "owner.repositories.edges.node.coverageAnalytics" in fields
208+
assert "owner.repositories.edges.node.coverageAnalytics.percentCovered" in fields

0 commit comments

Comments
 (0)