Skip to content

Commit 3436983

Browse files
committed
fix: a forbidden service fails alone and its error says where to look
1 parent f77b17a commit 3436983

9 files changed

Lines changed: 210 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
2424
```
2525

2626
`--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to
27-
collect; the report is still written and names the error on that service.
27+
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
28+
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
29+
disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project
30+
passed on the command line that the token cannot see, stops the run.
2831

2932
## Configuration
3033

‎release_scope/__main__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def _collect_command( # noqa: PLR0913, PLR0917
9191
rows: typing.Final = sum(len(service.rows) for service in report.services)
9292
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
9393
for service in failed:
94-
typer.echo(f"Error: {service.project}: {service.error}", err=True)
94+
typer.echo(f"Error: {service.error}", err=True)
9595
if failed:
9696
raise typer.Exit(code=1)
9797

‎release_scope/_errors.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ class AuthError(ReleaseScopeError):
1515

1616
class GitLabError(ReleaseScopeError):
1717
exit_code: typing.ClassVar[int] = 4
18+
19+
def __init__(self, message: str, *, path: str, status: int | None = None, reason: str = "") -> None:
20+
super().__init__(message)
21+
self.path = path
22+
self.status = status
23+
self.reason = reason

‎release_scope/_gitlab.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import datetime
33
import math
44
import typing
5-
from urllib.parse import quote
5+
from urllib.parse import quote, unquote
66

77
import httpware
88
import pydantic
@@ -22,6 +22,8 @@ class Project(pydantic.BaseModel):
2222
path_with_namespace: str
2323
web_url: str
2424
default_branch: str | None = None
25+
builds_access_level: str | None = None
26+
environments_access_level: str | None = None
2527

2628

2729
class Deployable(pydantic.BaseModel):
@@ -134,13 +136,13 @@ class _Bridges(pydantic.RootModel[list[Bridge]]):
134136

135137

136138
def _translate(exc: httpware.ClientError, *, url: str) -> Exception:
137-
if isinstance(exc, (httpware.UnauthorizedError, httpware.ForbiddenError)):
138-
return AuthError(
139-
f"GitLab rejected the token ({exc.response.status_code}) for {url}. It needs 'read_api' scope."
140-
)
139+
if isinstance(exc, httpware.UnauthorizedError):
140+
return AuthError("GitLab rejected the token (401). Check that it is valid and not expired.")
141141
if isinstance(exc, httpware.StatusError):
142-
return GitLabError(f"GitLab returned {exc.response.status_code} for {url}.")
143-
return GitLabError(f"GitLab request {url} failed: {type(exc).__name__}.")
142+
status: typing.Final = exc.response.status_code
143+
return GitLabError(f"GitLab returned {status} for {unquote(url)}.", path=url, status=status)
144+
reason: typing.Final = type(exc).__name__
145+
return GitLabError(f"GitLab request {unquote(url)} failed: {reason}.", path=url, reason=reason)
144146

145147

146148
def _quote(value: str) -> str:

‎release_scope/_messages.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import re
2+
import typing
3+
4+
from release_scope._errors import GitLabError
5+
from release_scope._gitlab import Project
6+
7+
8+
_FORBIDDEN: typing.Final = 403
9+
_PROJECT_RESOURCE: typing.Final = re.compile(r"/projects/[^/]+/([^/?]+)")
10+
_RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = {
11+
"deployments": ("deployments", ("Environments", "CI/CD")),
12+
"pipelines": ("pipelines", ("CI/CD",)),
13+
"repository": ("the repository", ("Repository",)),
14+
"merge_requests": ("merge requests", ("Merge requests",)),
15+
}
16+
_PLURAL_FEATURES: typing.Final = frozenset({"Environments", "Merge requests"})
17+
18+
19+
def _feature_setting(project: Project, feature: str) -> str:
20+
return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}"
21+
22+
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+
28+
def explain_failure(project: Project, error: GitLabError) -> str:
29+
name: typing.Final = project.path_with_namespace
30+
resource, features = _resource(error.path)
31+
if error.status is None:
32+
return f"{name}: GitLab request for {resource} failed ({error.reason})."
33+
if error.status != _FORBIDDEN:
34+
return f"{name}: GitLab returned {error.status} for {resource}."
35+
checks: typing.Final = [
36+
f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}"
37+
for feature in features
38+
]
39+
checks.append(f"- the token's user has a role that can read them: {project.web_url}/-/project_members")
40+
return "\n".join([f"{name}: GitLab denied access to {resource} (403). Check that:", *checks])
41+
42+
43+
def skip_reason(project: Project) -> str | None:
44+
if project.builds_access_level == "disabled":
45+
return (
46+
"CI/CD is disabled, so it has no pipelines or deployments. "
47+
f"Enable it at {_feature_setting(project, 'CI/CD')}."
48+
)
49+
if project.environments_access_level == "disabled":
50+
return (
51+
"Environments are disabled, so it has no deployments. "
52+
f"Enable them at {_feature_setting(project, 'Environments')}."
53+
)
54+
return None

‎release_scope/_use_case.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from urllib.parse import quote
66

77
from release_scope._cache import Cache, CachedPipeline
8-
from release_scope._errors import GitLabError
8+
from release_scope._errors import AuthError, GitLabError
99
from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project
1010
from release_scope._jira_keys import extract_jira_keys
11+
from release_scope._messages import explain_failure, skip_reason
1112
from release_scope._report import (
1213
CommitRef,
1314
EnvironmentState,
@@ -24,9 +25,19 @@
2425
from release_scope._settings import Settings
2526

2627

28+
_FORBIDDEN: typing.Final = 403
2729
_SETTLED_PIPELINE_STATUSES: typing.Final = frozenset({"success", "failed", "canceled", "skipped"})
2830

2931

32+
def _resolution_error(error: GitLabError, target: str) -> Exception:
33+
if error.status == _FORBIDDEN:
34+
return AuthError(
35+
f"GitLab denied access to {target} (403). "
36+
"Check that the token has the 'read_api' scope and that its user can see it."
37+
)
38+
return error
39+
40+
3041
def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
3142
return EnvironmentState(
3243
name=name,
@@ -79,7 +90,11 @@ def __call__(
7990
except GitLabError as exc:
8091
cache.keep_project(project.id)
8192
services.append(
82-
Service(project=project.path_with_namespace, project_url=project.web_url, error=str(exc))
93+
Service(
94+
project=project.path_with_namespace,
95+
project_url=project.web_url,
96+
error=explain_failure(project, exc),
97+
)
8398
)
8499
return Report(
85100
collected_at=datetime.datetime.now(datetime.UTC),
@@ -96,17 +111,27 @@ def _resolve_projects(
96111
) -> list[Project]:
97112
resolved: dict[int, Project] = {}
98113
for group in groups:
99-
for project in self.api.list_group_projects(group, include_subgroups=include_subgroups):
100-
resolved[project.id] = project
114+
try:
115+
listed = self.api.list_group_projects(group, include_subgroups=include_subgroups)
116+
except GitLabError as exc:
117+
raise _resolution_error(exc, f"group '{group}'") from exc
118+
resolved.update((project.id, project) for project in listed)
101119
for path in projects:
102-
project = self.api.get_project(path)
120+
try:
121+
project = self.api.get_project(path)
122+
except GitLabError as exc:
123+
raise _resolution_error(exc, f"project '{path}'") from exc
103124
resolved[project.id] = project
104125
return sorted(resolved.values(), key=lambda item: item.path_with_namespace)
105126

106127
def _collect_service(self, project: Project, cache: Cache) -> Service:
107128
service: typing.Final = Service(
108129
project=project.path_with_namespace, project_url=project.web_url, default_branch=project.default_branch
109130
)
131+
reason: typing.Final = skip_reason(project)
132+
if reason is not None:
133+
service.warnings.append(reason)
134+
return service
110135
for name in self.settings.environments:
111136
deployment = self.api.latest_deployment(project.id, name)
112137
if deployment is not None:

‎tests/payloads.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66
SERVICE_API: typing.Final = f"{API}/projects/1"
77

88

9-
def project(project_id: int, path: str, *, default_branch: str | None = "main") -> dict[str, typing.Any]:
9+
def project(
10+
project_id: int,
11+
path: str,
12+
*,
13+
default_branch: str | None = "main",
14+
**fields: typing.Any, # noqa: ANN401
15+
) -> dict[str, typing.Any]:
1016
return {
1117
"id": project_id,
1218
"path_with_namespace": path,
1319
"web_url": f"{ENDPOINT}/{path}",
1420
"default_branch": default_branch,
21+
**fields,
1522
}
1623

1724

‎tests/test_cli.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def test_failed_service_is_reported_and_exits_non_zero(gitlab: respx.Router, tmp
7777
result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output))
7878

7979
assert result.exit_code == 1
80-
assert "Error: team/broken: GitLab returned 400" in result.output
80+
assert "Error: team/broken: GitLab returned 400 for deployments." in result.output
8181
assert [item["error"] is None for item in json.loads(output.read_text())["services"]] == [False, True]
8282

8383

@@ -89,7 +89,7 @@ def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router,
8989
result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output))
9090

9191
assert result.exit_code == 3
92-
assert "Error: GitLab rejected the token (403)" in result.output
92+
assert "Error: GitLab denied access to group 'team' (403)" in result.output
9393
assert not output.exists()
9494

9595

‎tests/test_use_case.py‎

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import typing
22

3+
import httpcore2
34
import httpware
45
import httpx
56
import pydantic
@@ -156,12 +157,105 @@ def test_a_failing_service_keeps_its_cache_and_does_not_stop_the_others(gitlab:
156157
report: typing.Final = _collect(next_cache)
157158

158159
assert [(item.project, item.error) for item in report.services] == [
159-
("team/broken", "GitLab returned 500 for /api/v4/projects/2/deployments."),
160-
("team/svc", "GitLab returned 502 for /api/v4/projects/1/deployments."),
160+
("team/broken", "team/broken: GitLab returned 500 for deployments."),
161+
("team/svc", "team/svc: GitLab returned 502 for deployments."),
161162
]
162163
assert next_cache.current.pipelines["1"] == cache.current.pipelines["1"]
163164

164165

166+
def test_forbidden_deployments_fail_only_that_service_and_say_where_to_look(gitlab: respx.Router) -> None:
167+
gitlab["group"].respond(json=[SERVICE, project(2, "team/nodeploy")])
168+
gitlab.get(f"{API}/projects/2/deployments").respond(403)
169+
settings: typing.Final = f"{ENDPOINT}/team/nodeploy/edit#js-shared-permissions"
170+
171+
report: typing.Final = _collect()
172+
173+
assert [item.project for item in report.services] == ["team/nodeploy", "team/svc"]
174+
assert report.services[0].error == (
175+
"team/nodeploy: GitLab denied access to deployments (403). Check that:\n"
176+
f"- Environments are enabled: {settings} → Visibility, project features, permissions → Environments\n"
177+
f"- CI/CD is enabled: {settings} → Visibility, project features, permissions → CI/CD\n"
178+
f"- the token's user has a role that can read them: {ENDPOINT}/team/nodeploy/-/project_members"
179+
)
180+
assert len(report.services[1].rows) == 5
181+
182+
183+
@pytest.mark.httpx2(assert_all_called=False)
184+
@pytest.mark.parametrize(
185+
("route", "resource", "feature", "enabled"),
186+
[
187+
("commits", "the repository", "Repository", "Repository is enabled"),
188+
("merged_mrs", "merge requests", "Merge requests", "Merge requests are enabled"),
189+
("push_pipelines", "pipelines", "CI/CD", "CI/CD is enabled"),
190+
],
191+
)
192+
def test_forbidden_resource_lists_the_feature_that_guards_it(
193+
gitlab: respx.Router, route: str, resource: str, feature: str, enabled: str
194+
) -> None:
195+
gitlab[route].respond(403)
196+
settings: typing.Final = f"{ENDPOINT}/team/svc/edit#js-shared-permissions"
197+
198+
error: typing.Final = _only_service(_collect()).error
199+
200+
assert error == (
201+
f"team/svc: GitLab denied access to {resource} (403). Check that:\n"
202+
f"- {enabled}: {settings} → Visibility, project features, permissions → {feature}\n"
203+
f"- the token's user has a role that can read them: {ENDPOINT}/team/svc/-/project_members"
204+
)
205+
206+
207+
@pytest.mark.httpx2(assert_all_called=False)
208+
def test_network_failure_names_the_project(gitlab: respx.Router) -> None:
209+
gitlab["tags"].mock(side_effect=httpcore2.ConnectError("down"))
210+
211+
assert _only_service(_collect()).error == "team/svc: GitLab request for the repository failed (NetworkError)."
212+
213+
214+
_LIB_FEATURES: typing.Final = (
215+
f"{ENDPOINT}/team/lib/edit#js-shared-permissions → Visibility, project features, permissions"
216+
)
217+
218+
219+
@pytest.mark.parametrize(
220+
("access_levels", "warning"),
221+
[
222+
(
223+
{"environments_access_level": "disabled"},
224+
f"Environments are disabled, so it has no deployments. Enable them at {_LIB_FEATURES} → Environments.",
225+
),
226+
(
227+
{"builds_access_level": "disabled"},
228+
f"CI/CD is disabled, so it has no pipelines or deployments. Enable it at {_LIB_FEATURES} → CI/CD.",
229+
),
230+
],
231+
)
232+
def test_project_without_deployments_is_skipped_with_a_warning(
233+
gitlab: respx.Router, access_levels: dict[str, str], warning: str
234+
) -> None:
235+
gitlab["group"].respond(json=[SERVICE, project(2, "team/lib", **access_levels)])
236+
237+
report: typing.Final = _collect()
238+
239+
skipped: typing.Final = report.services[0]
240+
assert (skipped.project, skipped.rows, skipped.error, skipped.warnings) == ("team/lib", [], None, [warning])
241+
assert len(report.services[1].rows) == 5
242+
243+
244+
def test_enabled_or_private_features_are_collected(gitlab: respx.Router) -> None:
245+
gitlab["group"].respond(
246+
json=[project(1, "team/svc", environments_access_level="private", builds_access_level="enabled")]
247+
)
248+
249+
assert len(_only_service(_collect()).rows) == 5
250+
251+
252+
def test_forbidden_group_stops_the_run(httpx2_mock: respx.Router) -> None:
253+
httpx2_mock.get(f"{API}/groups/team/projects").respond(403)
254+
255+
with pytest.raises(AuthError, match=r"GitLab denied access to group 'team' \(403\)"):
256+
_collect()
257+
258+
165259
def test_authentication_failure_stops_the_run(httpx2_mock: respx.Router) -> None:
166260
httpx2_mock.get(f"{API}/groups/team/projects").respond(401)
167261

0 commit comments

Comments
 (0)