Skip to content

Commit e978f71

Browse files
committed
fix: name the resource at the API layer so a forbidden MR lookup points at Merge requests
1 parent 3436983 commit e978f71

6 files changed

Lines changed: 78 additions & 25 deletions

File tree

‎release_scope/_errors.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class AuthError(ReleaseScopeError):
1616
class GitLabError(ReleaseScopeError):
1717
exit_code: typing.ClassVar[int] = 4
1818

19-
def __init__(self, message: str, *, path: str, status: int | None = None, reason: str = "") -> None:
19+
def __init__(self, message: str, *, resource: str, status: int | None = None, reason: str = "") -> None:
2020
super().__init__(message)
21-
self.path = path
21+
self.resource = resource
2222
self.status = status
2323
self.reason = reason

‎release_scope/_gitlab.py‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,19 @@ class _Bridges(pydantic.RootModel[list[Bridge]]):
135135
pass
136136

137137

138-
def _translate(exc: httpware.ClientError, *, url: str) -> Exception:
138+
Resource: typing.TypeAlias = typing.Literal[
139+
"group", "project", "deployments", "pipelines", "repository", "merge_requests"
140+
]
141+
142+
143+
def _translate(exc: httpware.ClientError, *, url: str, resource: Resource) -> Exception:
139144
if isinstance(exc, httpware.UnauthorizedError):
140145
return AuthError("GitLab rejected the token (401). Check that it is valid and not expired.")
141146
if isinstance(exc, httpware.StatusError):
142147
status: typing.Final = exc.response.status_code
143-
return GitLabError(f"GitLab returned {status} for {unquote(url)}.", path=url, status=status)
148+
return GitLabError(f"GitLab returned {status} for {unquote(url)}.", resource=resource, status=status)
144149
reason: typing.Final = type(exc).__name__
145-
return GitLabError(f"GitLab request {unquote(url)} failed: {reason}.", path=url, reason=reason)
150+
return GitLabError(f"GitLab request {unquote(url)} failed: {reason}.", resource=resource, reason=reason)
146151

147152

148153
def _quote(value: str) -> str:
@@ -153,18 +158,19 @@ def _quote(value: str) -> str:
153158
class GitLabApi:
154159
http: httpware.Client
155160

156-
def _get(self, url: str, params: dict[str, typing.Any], model: type[_ModelT]) -> _ModelT:
161+
def _get(self, url: str, params: dict[str, typing.Any], model: type[_ModelT], *, resource: Resource) -> _ModelT:
157162
try:
158163
return self.http.get(url, params=params, response_model=model)
159164
except httpware.ClientError as exc:
160-
raise _translate(exc, url=url) from exc
165+
raise _translate(exc, url=url, resource=resource) from exc
161166

162167
def _pages(
163168
self,
164169
url: str,
165170
params: dict[str, typing.Any],
166171
model: type[pydantic.RootModel[list[_ModelT]]],
167172
*,
173+
resource: Resource,
168174
max_items: int | None = None,
169175
) -> tuple[list[_ModelT], bool]:
170176
max_pages: typing.Final = _MAX_PAGES if max_items is None else math.ceil(max_items / _PER_PAGE)
@@ -175,7 +181,7 @@ def _pages(
175181
url, params={**params, "per_page": _PER_PAGE, "page": page}, response_model=model
176182
)
177183
except httpware.ClientError as exc:
178-
raise _translate(exc, url=url) from exc
184+
raise _translate(exc, url=url, resource=resource) from exc
179185
items.extend(batch.root)
180186
if not response.headers.get("x-next-page"):
181187
break
@@ -186,13 +192,14 @@ def _pages(
186192
return items, False
187193

188194
def get_project(self, path: str) -> Project:
189-
return self._get(f"{_API}/projects/{_quote(path)}", {}, Project)
195+
return self._get(f"{_API}/projects/{_quote(path)}", {}, Project, resource="project")
190196

191197
def list_group_projects(self, group: str, *, include_subgroups: bool) -> list[Project]:
192198
projects, _ = self._pages(
193199
f"{_API}/groups/{_quote(group)}/projects",
194200
{"archived": "false", "with_shared": "false", "include_subgroups": str(include_subgroups).lower()},
195201
_Projects,
202+
resource="group",
196203
)
197204
return projects
198205

@@ -201,11 +208,12 @@ def latest_deployment(self, project_id: int, environment: str) -> Deployment | N
201208
f"{_API}/projects/{project_id}/deployments",
202209
{"environment": environment, "status": "success", "order_by": "id", "sort": "desc", "per_page": 1},
203210
_Deployments,
211+
resource="deployments",
204212
)
205213
return deployments.root[0] if deployments.root else None
206214

207215
def list_tags(self, project_id: int) -> tuple[list[Tag], bool]:
208-
return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags)
216+
return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags, resource="repository")
209217

210218
def list_first_parent_commits(
211219
self, project_id: int, ref_range: str, *, max_items: int
@@ -214,6 +222,7 @@ def list_first_parent_commits(
214222
f"{_API}/projects/{project_id}/repository/commits",
215223
{"ref_name": ref_range, "first_parent": "true"},
216224
_Commits,
225+
resource="repository",
217226
max_items=max_items,
218227
)
219228

@@ -224,12 +233,16 @@ def list_merged_merge_requests(
224233
f"{_API}/projects/{project_id}/merge_requests",
225234
{"state": "merged", "target_branch": target_branch, "updated_after": updated_after.isoformat()},
226235
_MergeRequests,
236+
resource="merge_requests",
227237
)
228238
return merge_requests
229239

230240
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
231241
merge_requests, _ = self._pages(
232-
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests", {}, _MergeRequests
242+
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests",
243+
{},
244+
_MergeRequests,
245+
resource="merge_requests",
233246
)
234247
return merge_requests
235248

@@ -238,6 +251,7 @@ def list_push_pipelines(self, project_id: int, *, ref: str, updated_after: datet
238251
f"{_API}/projects/{project_id}/pipelines",
239252
{"ref": ref, "source": "push", "updated_after": updated_after.isoformat()},
240253
_Pipelines,
254+
resource="pipelines",
241255
)
242256
return pipelines
243257

@@ -246,18 +260,25 @@ def latest_pipeline(self, project_id: int, *, ref: str) -> Pipeline | None:
246260
f"{_API}/projects/{project_id}/pipelines",
247261
{"ref": ref, "order_by": "id", "sort": "desc", "per_page": 1},
248262
_Pipelines,
263+
resource="pipelines",
249264
)
250265
return pipelines.root[0] if pipelines.root else None
251266

252267
def failed_jobs(self, project_id: int, pipeline_id: int) -> list[Job]:
253268
jobs, _ = self._pages(
254-
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/jobs", {"scope[]": "failed"}, _Jobs
269+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/jobs",
270+
{"scope[]": "failed"},
271+
_Jobs,
272+
resource="pipelines",
255273
)
256274
return jobs
257275

258276
def failed_bridges(self, project_id: int, pipeline_id: int) -> list[Bridge]:
259277
# `trigger_jobs` replaces this route only from GitLab 19.2; older instances have `bridges` alone.
260278
bridges, _ = self._pages(
261-
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/bridges", {"scope[]": "failed"}, _Bridges
279+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/bridges",
280+
{"scope[]": "failed"},
281+
_Bridges,
282+
resource="pipelines",
262283
)
263284
return bridges

‎release_scope/_messages.py‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import re
1+
import http
22
import typing
33

44
from release_scope._errors import GitLabError
55
from release_scope._gitlab import Project
66

77

8-
_FORBIDDEN: typing.Final = 403
9-
_PROJECT_RESOURCE: typing.Final = re.compile(r"/projects/[^/]+/([^/?]+)")
108
_RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = {
119
"deployments": ("deployments", ("Environments", "CI/CD")),
1210
"pipelines": ("pipelines", ("CI/CD",)),
@@ -20,17 +18,12 @@ def _feature_setting(project: Project, feature: str) -> str:
2018
return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}"
2119

2220

23-
def _resource(path: str) -> tuple[str, tuple[str, ...]]:
24-
match: typing.Final = _PROJECT_RESOURCE.search(path)
25-
return _RESOURCES.get(match.group(1), ("project data", ())) if match else ("project data", ())
26-
27-
2821
def explain_failure(project: Project, error: GitLabError) -> str:
2922
name: typing.Final = project.path_with_namespace
30-
resource, features = _resource(error.path)
23+
resource, features = _RESOURCES.get(error.resource, ("project data", ()))
3124
if error.status is None:
3225
return f"{name}: GitLab request for {resource} failed ({error.reason})."
33-
if error.status != _FORBIDDEN:
26+
if error.status != http.HTTPStatus.FORBIDDEN:
3427
return f"{name}: GitLab returned {error.status} for {resource}."
3528
checks: typing.Final = [
3629
f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}"

‎release_scope/_use_case.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections.abc
22
import dataclasses
33
import datetime
4+
import http
45
import typing
56
from urllib.parse import quote
67

@@ -25,12 +26,11 @@
2526
from release_scope._settings import Settings
2627

2728

28-
_FORBIDDEN: typing.Final = 403
2929
_SETTLED_PIPELINE_STATUSES: typing.Final = frozenset({"success", "failed", "canceled", "skipped"})
3030

3131

3232
def _resolution_error(error: GitLabError, target: str) -> Exception:
33-
if error.status == _FORBIDDEN:
33+
if error.status == http.HTTPStatus.FORBIDDEN:
3434
return AuthError(
3535
f"GitLab denied access to {target} (403). "
3636
"Check that the token has the 'read_api' scope and that its user can see it."

‎tests/test_cli.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,28 @@ def test_failed_service_is_reported_and_exits_non_zero(gitlab: respx.Router, tmp
8181
assert [item["error"] is None for item in json.loads(output.read_text())["services"]] == [False, True]
8282

8383

84+
@pytest.mark.usefixtures("cli_env")
85+
def test_forbidden_service_fails_alone_and_exits_non_zero(gitlab: respx.Router, tmp_path: pathlib.Path) -> None:
86+
gitlab["group"].respond(json=[SERVICE, project(2, "team/nodeploy")])
87+
gitlab.get(f"{API}/projects/2/deployments").respond(403)
88+
89+
result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json"))
90+
91+
assert result.exit_code == 1
92+
assert "Error: team/nodeploy: GitLab denied access to deployments (403). Check that:" in result.output
93+
assert "1 failed" in result.output
94+
95+
96+
@pytest.mark.usefixtures("cli_env")
97+
def test_rejected_token_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None:
98+
httpx2_mock.get(f"{API}/groups/team/projects").respond(401)
99+
100+
result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json"))
101+
102+
assert result.exit_code == 3
103+
assert "Error: GitLab rejected the token (401)." in result.output
104+
105+
84106
@pytest.mark.usefixtures("cli_env")
85107
def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None:
86108
httpx2_mock.get(f"{API}/groups/team/projects").respond(403)

‎tests/test_use_case.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,23 @@ def test_forbidden_resource_lists_the_feature_that_guards_it(
204204
)
205205

206206

207+
@pytest.mark.httpx2(assert_all_called=False)
208+
def test_forbidden_commit_lookup_points_at_merge_requests(gitlab: respx.Router) -> None:
209+
gitlab["commit_mrs:head"].respond(403)
210+
211+
error: typing.Final = _only_service(_collect()).error
212+
213+
assert error is not None
214+
assert error.startswith("team/svc: GitLab denied access to merge requests (403). Check that:\n- Merge requests are")
215+
216+
217+
def test_forbidden_project_stops_the_run(httpx2_mock: respx.Router) -> None:
218+
httpx2_mock.get(f"{API}/projects/team%2Fsecret").respond(403)
219+
220+
with pytest.raises(AuthError, match=r"GitLab denied access to project 'team/secret' \(403\)"):
221+
_collect(groups=(), projects=("team/secret",))
222+
223+
207224
@pytest.mark.httpx2(assert_all_called=False)
208225
def test_network_failure_names_the_project(gitlab: respx.Router) -> None:
209226
gitlab["tags"].mock(side_effect=httpcore2.ConnectError("down"))

0 commit comments

Comments
 (0)