Skip to content

Commit 7dc82d0

Browse files
authored
Work around a GitLab GraphQL bug (#12941)
Work around a GitLab GraphQL bug that made merge requests metrics fail with a connection error on GitLab 18.9 and later. Fixes #12696 and #12725.
1 parent ccb2d63 commit 7dc82d0

3 files changed

Lines changed: 82 additions & 65 deletions

File tree

components/collector/src/source_collectors/gitlab/merge_requests.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,16 @@
44
from aiogqlc import GraphQLClient
55

66
from base_collectors import MergeRequestCollector
7-
from collector_utilities.exceptions import NotFoundError
7+
from collector_utilities.exceptions import CollectorError, NotFoundError
88
from collector_utilities.type import URL, Value
99
from model import Entities, Entity, SourceResponses
1010

1111
from .base import GitLabBase
1212

13-
# GraphQL query to find out which fields merge requests have in a GitLab instance. GitLab instances on the free plan
14-
# don't have the approved field, GitLab instances on the premium plan and up do.
15-
MERGE_REQUEST_FIELDS_QUERY = """
16-
{
17-
__type(name: "MergeRequest") {
18-
fields {
19-
name
20-
}
21-
}
22-
}
23-
"""
24-
2513
# GraphQL query to retrieve the merge requests for a specific project. The project id is passed as a variable. This
2614
# string needs to be formatted with or without the approved field name, depending on whether the GitLab instance has
27-
# the approved field (queried with the query above), and with the pagination arguments, depending on whether the
28-
# query is used for the first page or for consecutive pages.
15+
# the approved field, and with the pagination arguments, depending on whether the query is used for the first page or
16+
# for consecutive pages.
2917
MERGE_REQUEST_QUERY = """
3018
query MRs($projectId: ID!) {{
3119
project(fullPath: $projectId) {{
@@ -66,6 +54,10 @@ def __init__(self, project: str) -> None:
6654
super().__init__("Merge request info for project", project, extra=tip)
6755

6856

57+
class _ApprovedFieldUnknownError(Exception):
58+
"""Raised when GitLab reports the MergeRequest.approved field as unknown."""
59+
60+
6961
class GitLabMergeRequests(MergeRequestCollector, GitLabBase):
7062
"""Collector class to measure the number of merge requests."""
7163

@@ -84,8 +76,9 @@ async def _get_source_responses(self, *urls: URL) -> SourceResponses:
8476
"""
8577
api_url = await self._api_url()
8678
timeout = aiohttp.ClientTimeout(total=self._session.timeout.total)
87-
# We need to create a new session because the GraphQLClient expects the session to provide the headers:
88-
async with aiohttp.ClientSession(raise_for_status=True, timeout=timeout, headers=self._headers()) as session:
79+
# We need to create a new session because the GraphQLClient expects the session to provide the headers.
80+
# raise_for_status is False so we can inspect GraphQL error bodies (e.g. HTTP 400 on unknown field).
81+
async with aiohttp.ClientSession(raise_for_status=False, timeout=timeout, headers=self._headers()) as session:
8982
client = GraphQLClient(f"{api_url}/api/graphql", session=session)
9083
approved_field = await self._approved_field(client)
9184
responses, has_next_page, cursor = SourceResponses(), True, ""
@@ -94,13 +87,19 @@ async def _get_source_responses(self, *urls: URL) -> SourceResponses:
9487
responses.append(response)
9588
return responses
9689

97-
@classmethod
98-
async def _approved_field(cls, client: GraphQLClient) -> str:
99-
"""Determine whether the GitLab instance has the approved field for merge requests."""
100-
response = await client.execute(MERGE_REQUEST_FIELDS_QUERY)
101-
json = await response.json()
102-
fields = [field["name"] for field in json["data"]["__type"]["fields"]]
103-
return cls.APPROVED_FIELD if cls.APPROVED_FIELD in fields else ""
90+
async def _approved_field(self, client: GraphQLClient) -> str:
91+
"""Determine whether the GitLab instance has the approved field for merge requests.
92+
93+
This is done by running a dry-run first-page merge-requests query that includes the approved field. If
94+
GitLab accepts it, the field is supported; if GitLab rejects it as unknown, it isn't. The first page is
95+
then fetched again by the pagination loop, for a total call count that matches the previous introspection
96+
approach.
97+
"""
98+
try:
99+
await self._get_merge_request_response(client, self.APPROVED_FIELD)
100+
except _ApprovedFieldUnknownError:
101+
return ""
102+
return self.APPROVED_FIELD
104103

105104
async def _get_merge_request_response(
106105
self,
@@ -113,6 +112,13 @@ async def _get_merge_request_response(
113112
merge_request_query = MERGE_REQUEST_QUERY.format(pagination=pagination, approved=approved_field)
114113
response = await client.execute(merge_request_query, variables={"projectId": self._parameter("project")})
115114
json = await response.json()
115+
if errors := json.get("errors"):
116+
message = errors[0].get("message") or "unknown GitLab error"
117+
if self.APPROVED_FIELD in message and ("doesn't exist" in message or "Field '" in message):
118+
raise _ApprovedFieldUnknownError(message)
119+
response.raise_for_status() # Surface HTTP errors (e.g. 400) if any.
120+
raise CollectorError(message) # Otherwise surface the GraphQL error message.
121+
response.raise_for_status()
116122
if project := json["data"]["project"]:
117123
page_info = project["mergeRequests"]["pageInfo"]
118124
return response, page_info["hasNextPage"], page_info.get("endCursor", "")

components/collector/tests/source_collectors/gitlab/test_merge_requests.py

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Unit tests for the GitLab merge requests collector."""
22

3-
from unittest.mock import AsyncMock, patch
3+
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import aiohttp
66

@@ -59,14 +59,6 @@ def merge_requests_json(
5959
},
6060
}
6161

62-
@staticmethod
63-
def merge_request_fields_json(
64-
has_approved_field: bool = False,
65-
) -> dict[str, dict[str, dict[str, list[dict[str, str]]]]]:
66-
"""Return the GraphQL merge request fields response JSON."""
67-
fields = [{"name": "approved"}] if has_approved_field else []
68-
return {"data": {"__type": {"fields": fields}}}
69-
7062
@staticmethod
7163
def create_entity(
7264
number: int,
@@ -97,6 +89,15 @@ async def collect_merge_requests(self, execute_mock: AsyncMock):
9789
collector = MetricCollector(session, self.metric)
9890
return await collector.collect()
9991

92+
@staticmethod
93+
def mock_response(json_value) -> AsyncMock:
94+
"""Create a mock GraphQL response that returns the given JSON from its json() method."""
95+
response = AsyncMock()
96+
response.json = AsyncMock(return_value=json_value)
97+
# raise_for_status is sync on aiohttp.ClientResponse; override AsyncMock to avoid unawaited coroutines.
98+
response.raise_for_status = MagicMock()
99+
return response
100+
100101
async def test_merge_requests(self):
101102
"""Test that the number of merge requests can be measured."""
102103
self.set_source_parameter("merge_request_state", ["opened", "closed", "merged"])
@@ -110,11 +111,9 @@ async def test_merge_requests(self):
110111
self.merge_request_json(4, branch="dev"), # Excluded because of target branch
111112
],
112113
)
113-
merge_request_fields_response = AsyncMock()
114-
merge_requests_response = AsyncMock()
115-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_response])
116-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json())
117-
merge_requests_response.json = AsyncMock(return_value=merge_requests_json)
114+
merge_requests_response = self.mock_response(merge_requests_json)
115+
# The first call is the dry-run that detects the approved field, the second is the real first page.
116+
execute = AsyncMock(side_effect=[merge_requests_response, merge_requests_response])
118117
entities = [self.create_entity(1)]
119118
response = await self.collect_merge_requests(execute)
120119
self.assert_measurement(response, value="1", total="4", entities=entities, landing_url=self.LANDING_URL)
@@ -123,13 +122,10 @@ async def test_pagination(self):
123122
"""Test that pagination works."""
124123
merge_requests_json1 = self.merge_requests_json([self.merge_request_json(1)], count=2, has_next_page=True)
125124
merge_requests_json2 = self.merge_requests_json([self.merge_request_json(2)], count=2)
126-
merge_request_fields_response = AsyncMock()
127-
merge_requests_page1 = AsyncMock()
128-
merge_requests_page2 = AsyncMock()
129-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_page1, merge_requests_page2])
130-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json())
131-
merge_requests_page1.json = AsyncMock(return_value=merge_requests_json1)
132-
merge_requests_page2.json = AsyncMock(return_value=merge_requests_json2)
125+
merge_requests_page1 = self.mock_response(merge_requests_json1)
126+
merge_requests_page2 = self.mock_response(merge_requests_json2)
127+
# Dry-run, real page 1, page 2.
128+
execute = AsyncMock(side_effect=[merge_requests_page1, merge_requests_page1, merge_requests_page2])
133129
entities = [self.create_entity(1), self.create_entity(2)]
134130
response = await self.collect_merge_requests(execute)
135131
self.assert_measurement(response, value="2", total="2", entities=entities, landing_url=self.LANDING_URL)
@@ -140,23 +136,17 @@ async def test_approval(self):
140136
merge_requests_json = self.merge_requests_json(
141137
[self.merge_request_json(1, approved=True), self.merge_request_json(2)],
142138
)
143-
merge_request_fields_response = AsyncMock()
144-
merge_requests_response = AsyncMock()
145-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_response])
146-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json(True))
147-
merge_requests_response.json = AsyncMock(return_value=merge_requests_json)
139+
merge_requests_response = self.mock_response(merge_requests_json)
140+
execute = AsyncMock(side_effect=[merge_requests_response, merge_requests_response])
148141
entities = [self.create_entity(1, approved="yes")]
149142
response = await self.collect_merge_requests(execute)
150143
self.assert_measurement(response, value="1", total="2", entities=entities, landing_url=self.LANDING_URL)
151144

152145
async def test_insufficient_permissions(self):
153146
"""Test that the collector returns a helpful error message if no merge request info is returned."""
154147
merge_requests_json = {"data": {"project": None}}
155-
merge_request_fields_response = AsyncMock()
156-
merge_requests_response = AsyncMock()
157-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_response])
158-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json(True))
159-
merge_requests_response.json = AsyncMock(return_value=merge_requests_json)
148+
merge_requests_response = self.mock_response(merge_requests_json)
149+
execute = AsyncMock(side_effect=[merge_requests_response, merge_requests_response])
160150
response = await self.collect_merge_requests(execute)
161151
self.assert_measurement(
162152
response,
@@ -170,11 +160,8 @@ async def test_ignore_drafts(self):
170160
merge_requests_json = self.merge_requests_json(
171161
[self.merge_request_json(1, draft=True), self.merge_request_json(2)],
172162
)
173-
merge_request_fields_response = AsyncMock()
174-
merge_requests_response = AsyncMock()
175-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_response])
176-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json(True))
177-
merge_requests_response.json = AsyncMock(return_value=merge_requests_json)
163+
merge_requests_response = self.mock_response(merge_requests_json)
164+
execute = AsyncMock(side_effect=[merge_requests_response, merge_requests_response])
178165
entities = [self.create_entity(2)]
179166
response = await self.collect_merge_requests(execute)
180167
self.assert_measurement(response, value="1", total="2", entities=entities, landing_url=self.LANDING_URL)
@@ -185,11 +172,34 @@ async def test_ignore_drafts_without_drafts(self):
185172
merge_requests_json = self.merge_requests_json(
186173
[self.merge_request_json(1)],
187174
)
188-
merge_request_fields_response = AsyncMock()
189-
merge_requests_response = AsyncMock()
190-
execute = AsyncMock(side_effect=[merge_request_fields_response, merge_requests_response])
191-
merge_request_fields_response.json = AsyncMock(return_value=self.merge_request_fields_json(True))
192-
merge_requests_response.json = AsyncMock(return_value=merge_requests_json)
175+
merge_requests_response = self.mock_response(merge_requests_json)
176+
execute = AsyncMock(side_effect=[merge_requests_response, merge_requests_response])
193177
entities = [self.create_entity(1)]
194178
response = await self.collect_merge_requests(execute)
195179
self.assert_measurement(response, value="1", total="1", entities=entities, landing_url=self.LANDING_URL)
180+
181+
async def test_approved_field_unknown_falls_back(self):
182+
"""Test that the collector falls back to a query without the approved field if GitLab rejects it."""
183+
unknown_field_json = {
184+
"errors": [{"message": "Field 'approved' doesn't exist on type 'MergeRequest'"}],
185+
}
186+
merge_requests_json = self.merge_requests_json([self.merge_request_json(1)])
187+
unknown_field_response = self.mock_response(unknown_field_json)
188+
merge_requests_response = self.mock_response(merge_requests_json)
189+
# Dry-run returns the unknown-field error, then the real first page succeeds without the field.
190+
execute = AsyncMock(side_effect=[unknown_field_response, merge_requests_response])
191+
entities = [self.create_entity(1)]
192+
response = await self.collect_merge_requests(execute)
193+
self.assert_measurement(response, value="1", total="1", entities=entities, landing_url=self.LANDING_URL)
194+
195+
async def test_unrelated_graphql_error_surfaces(self):
196+
"""Test that unrelated GraphQL errors are surfaced as a connection error."""
197+
error_json = {"errors": [{"message": "Something else went wrong"}]}
198+
error_response = self.mock_response(error_json)
199+
execute = AsyncMock(side_effect=[error_response, error_response])
200+
response = await self.collect_merge_requests(execute)
201+
self.assert_measurement(
202+
response,
203+
landing_url=self.LANDING_URL,
204+
connection_error="Something else went wrong",
205+
)

docs/src/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ If your currently installed *Quality-time* version is not the penultimate versio
1414

1515
### Fixed
1616

17+
- Work around a GitLab GraphQL bug that made merge requests metrics fail with a connection error on GitLab 18.9 and later. Fixes [#12696](https://github.com/ICTU/quality-time/issues/12696) and [#12725](https://github.com/ICTU/quality-time/issues/12725).
1718
- Don't attempt to send notifications to notification destinations without a webhook. Fixes [#12906](https://github.com/ICTU/quality-time/issues/12906).
1819

1920
### Added

0 commit comments

Comments
 (0)