release-scope 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {release_scope-0.1.0 → release_scope-0.2.0}/PKG-INFO +23 -2
- {release_scope-0.1.0 → release_scope-0.2.0}/README.md +22 -1
- {release_scope-0.1.0 → release_scope-0.2.0}/pyproject.toml +1 -1
- {release_scope-0.1.0 → release_scope-0.2.0}/pyproject.toml.orig +1 -1
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/__main__.py +22 -1
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_errors.py +6 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_gitlab.py +39 -16
- release_scope-0.2.0/release_scope/_messages.py +47 -0
- release_scope-0.2.0/release_scope/_render.py +251 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_use_case.py +30 -5
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/__init__.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_cache.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_files.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_jira_keys.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_report.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_rows.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/_settings.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/ioc.py +0 -0
- {release_scope-0.1.0 → release_scope-0.2.0}/release_scope/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: release-scope
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs
|
|
5
5
|
Keywords: release,gitlab,jira,deployments,merge-requests,ci,cli,python
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -57,7 +57,10 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
`--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to
|
|
60
|
-
collect; the report is still written and names the error on that service.
|
|
60
|
+
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
|
|
61
|
+
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
|
|
62
|
+
disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project
|
|
63
|
+
passed on the command line that the token cannot see, stops the run.
|
|
61
64
|
|
|
62
65
|
## Configuration
|
|
63
66
|
|
|
@@ -92,6 +95,24 @@ One row, trimmed:
|
|
|
92
95
|
}
|
|
93
96
|
```
|
|
94
97
|
|
|
98
|
+
## Page
|
|
99
|
+
|
|
100
|
+
`render` turns a report into a Markdown page for a GitLab wiki, without calling GitLab:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
uvx release-scope collect --group team/backend --output report.json --cache cache.json; \
|
|
104
|
+
uvx release-scope render report.json --output report.md
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
|
|
108
|
+
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
|
|
109
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys, where the change is
|
|
110
|
+
deployed, and the failed jobs of its main-branch and tag pipelines.
|
|
111
|
+
|
|
112
|
+
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
113
|
+
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
114
|
+
read the report or write the page.
|
|
115
|
+
|
|
95
116
|
## Cache
|
|
96
117
|
|
|
97
118
|
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
@@ -24,7 +24,10 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
`--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.
|
|
28
31
|
|
|
29
32
|
## Configuration
|
|
30
33
|
|
|
@@ -59,6 +62,24 @@ One row, trimmed:
|
|
|
59
62
|
}
|
|
60
63
|
```
|
|
61
64
|
|
|
65
|
+
## Page
|
|
66
|
+
|
|
67
|
+
`render` turns a report into a Markdown page for a GitLab wiki, without calling GitLab:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
uvx release-scope collect --group team/backend --output report.json --cache cache.json; \
|
|
71
|
+
uvx release-scope render report.json --output report.md
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
|
|
75
|
+
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
|
|
76
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys, where the change is
|
|
77
|
+
deployed, and the failed jobs of its main-branch and tag pipelines.
|
|
78
|
+
|
|
79
|
+
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
80
|
+
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
81
|
+
read the report or write the page.
|
|
82
|
+
|
|
62
83
|
## Cache
|
|
63
84
|
|
|
64
85
|
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
@@ -3,12 +3,15 @@ import pathlib
|
|
|
3
3
|
import typing
|
|
4
4
|
|
|
5
5
|
import modern_di_typer
|
|
6
|
+
import pydantic
|
|
6
7
|
import typer
|
|
7
8
|
|
|
8
9
|
from release_scope import ioc
|
|
9
10
|
from release_scope._cache import Cache
|
|
10
11
|
from release_scope._errors import ConfigError, ReleaseScopeError
|
|
11
12
|
from release_scope._files import write_text_atomic
|
|
13
|
+
from release_scope._render import render_markdown
|
|
14
|
+
from release_scope._report import Report
|
|
12
15
|
from release_scope._settings import Settings, load_settings
|
|
13
16
|
from release_scope._use_case import CollectUseCase
|
|
14
17
|
|
|
@@ -91,11 +94,29 @@ def _collect_command( # noqa: PLR0913, PLR0917
|
|
|
91
94
|
rows: typing.Final = sum(len(service.rows) for service in report.services)
|
|
92
95
|
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
|
|
93
96
|
for service in failed:
|
|
94
|
-
typer.echo(f"Error: {service.
|
|
97
|
+
typer.echo(f"Error: {service.error}", err=True)
|
|
95
98
|
if failed:
|
|
96
99
|
raise typer.Exit(code=1)
|
|
97
100
|
|
|
98
101
|
|
|
102
|
+
@MAIN_APP.command("render", help="Render a JSON report as a Markdown page for a GitLab wiki.")
|
|
103
|
+
def _render_command(
|
|
104
|
+
report_path: typing.Annotated[pathlib.Path, typer.Argument(help="Report JSON written by `collect`.")],
|
|
105
|
+
output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the Markdown page.")],
|
|
106
|
+
) -> None:
|
|
107
|
+
try:
|
|
108
|
+
report = Report.model_validate_json(report_path.read_bytes())
|
|
109
|
+
except (OSError, pydantic.ValidationError) as exc:
|
|
110
|
+
typer.echo(f"Error: Cannot read report {report_path}: {type(exc).__name__}.", err=True)
|
|
111
|
+
raise typer.Exit(code=ConfigError.exit_code) from exc
|
|
112
|
+
try:
|
|
113
|
+
write_text_atomic(output, render_markdown(report))
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
typer.echo(f"Error: Cannot write page {output}: {type(exc).__name__}.", err=True)
|
|
116
|
+
raise typer.Exit(code=ReleaseScopeError.exit_code) from exc
|
|
117
|
+
typer.echo(f"{len(report.services)} services -> {output}", err=True)
|
|
118
|
+
|
|
119
|
+
|
|
99
120
|
def main() -> None:
|
|
100
121
|
with ioc.container:
|
|
101
122
|
MAIN_APP()
|
|
@@ -15,3 +15,9 @@ class AuthError(ReleaseScopeError):
|
|
|
15
15
|
|
|
16
16
|
class GitLabError(ReleaseScopeError):
|
|
17
17
|
exit_code: typing.ClassVar[int] = 4
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, resource: str, status: int | None = None, reason: str = "") -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.resource = resource
|
|
22
|
+
self.status = status
|
|
23
|
+
self.reason = reason
|
|
@@ -2,7 +2,7 @@ import dataclasses
|
|
|
2
2
|
import datetime
|
|
3
3
|
import math
|
|
4
4
|
import typing
|
|
5
|
-
from urllib.parse import quote
|
|
5
|
+
from urllib.parse import quote, unquote
|
|
6
6
|
|
|
7
7
|
import httpware
|
|
8
8
|
import pydantic
|
|
@@ -22,6 +22,8 @@ class Project(pydantic.BaseModel):
|
|
|
22
22
|
path_with_namespace: str
|
|
23
23
|
web_url: str
|
|
24
24
|
default_branch: str | None = None
|
|
25
|
+
builds_access_level: str | None = None
|
|
26
|
+
environments_access_level: str | None = None
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
class Deployable(pydantic.BaseModel):
|
|
@@ -133,14 +135,19 @@ class _Bridges(pydantic.RootModel[list[Bridge]]):
|
|
|
133
135
|
pass
|
|
134
136
|
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
144
|
+
if isinstance(exc, httpware.UnauthorizedError):
|
|
145
|
+
return AuthError("GitLab rejected the token (401). Check that it is valid and not expired.")
|
|
141
146
|
if isinstance(exc, httpware.StatusError):
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
status: typing.Final = exc.response.status_code
|
|
148
|
+
return GitLabError(f"GitLab returned {status} for {unquote(url)}.", resource=resource, status=status)
|
|
149
|
+
reason: typing.Final = type(exc).__name__
|
|
150
|
+
return GitLabError(f"GitLab request {unquote(url)} failed: {reason}.", resource=resource, reason=reason)
|
|
144
151
|
|
|
145
152
|
|
|
146
153
|
def _quote(value: str) -> str:
|
|
@@ -151,11 +158,11 @@ def _quote(value: str) -> str:
|
|
|
151
158
|
class GitLabApi:
|
|
152
159
|
http: httpware.Client
|
|
153
160
|
|
|
154
|
-
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:
|
|
155
162
|
try:
|
|
156
163
|
return self.http.get(url, params=params, response_model=model)
|
|
157
164
|
except httpware.ClientError as exc:
|
|
158
|
-
raise _translate(exc, url=url) from exc
|
|
165
|
+
raise _translate(exc, url=url, resource=resource) from exc
|
|
159
166
|
|
|
160
167
|
def _pages(
|
|
161
168
|
self,
|
|
@@ -163,6 +170,7 @@ class GitLabApi:
|
|
|
163
170
|
params: dict[str, typing.Any],
|
|
164
171
|
model: type[pydantic.RootModel[list[_ModelT]]],
|
|
165
172
|
*,
|
|
173
|
+
resource: Resource,
|
|
166
174
|
max_items: int | None = None,
|
|
167
175
|
) -> tuple[list[_ModelT], bool]:
|
|
168
176
|
max_pages: typing.Final = _MAX_PAGES if max_items is None else math.ceil(max_items / _PER_PAGE)
|
|
@@ -173,7 +181,7 @@ class GitLabApi:
|
|
|
173
181
|
url, params={**params, "per_page": _PER_PAGE, "page": page}, response_model=model
|
|
174
182
|
)
|
|
175
183
|
except httpware.ClientError as exc:
|
|
176
|
-
raise _translate(exc, url=url) from exc
|
|
184
|
+
raise _translate(exc, url=url, resource=resource) from exc
|
|
177
185
|
items.extend(batch.root)
|
|
178
186
|
if not response.headers.get("x-next-page"):
|
|
179
187
|
break
|
|
@@ -184,13 +192,14 @@ class GitLabApi:
|
|
|
184
192
|
return items, False
|
|
185
193
|
|
|
186
194
|
def get_project(self, path: str) -> Project:
|
|
187
|
-
return self._get(f"{_API}/projects/{_quote(path)}", {}, Project)
|
|
195
|
+
return self._get(f"{_API}/projects/{_quote(path)}", {}, Project, resource="project")
|
|
188
196
|
|
|
189
197
|
def list_group_projects(self, group: str, *, include_subgroups: bool) -> list[Project]:
|
|
190
198
|
projects, _ = self._pages(
|
|
191
199
|
f"{_API}/groups/{_quote(group)}/projects",
|
|
192
200
|
{"archived": "false", "with_shared": "false", "include_subgroups": str(include_subgroups).lower()},
|
|
193
201
|
_Projects,
|
|
202
|
+
resource="group",
|
|
194
203
|
)
|
|
195
204
|
return projects
|
|
196
205
|
|
|
@@ -199,11 +208,12 @@ class GitLabApi:
|
|
|
199
208
|
f"{_API}/projects/{project_id}/deployments",
|
|
200
209
|
{"environment": environment, "status": "success", "order_by": "id", "sort": "desc", "per_page": 1},
|
|
201
210
|
_Deployments,
|
|
211
|
+
resource="deployments",
|
|
202
212
|
)
|
|
203
213
|
return deployments.root[0] if deployments.root else None
|
|
204
214
|
|
|
205
215
|
def list_tags(self, project_id: int) -> tuple[list[Tag], bool]:
|
|
206
|
-
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")
|
|
207
217
|
|
|
208
218
|
def list_first_parent_commits(
|
|
209
219
|
self, project_id: int, ref_range: str, *, max_items: int
|
|
@@ -212,6 +222,7 @@ class GitLabApi:
|
|
|
212
222
|
f"{_API}/projects/{project_id}/repository/commits",
|
|
213
223
|
{"ref_name": ref_range, "first_parent": "true"},
|
|
214
224
|
_Commits,
|
|
225
|
+
resource="repository",
|
|
215
226
|
max_items=max_items,
|
|
216
227
|
)
|
|
217
228
|
|
|
@@ -222,12 +233,16 @@ class GitLabApi:
|
|
|
222
233
|
f"{_API}/projects/{project_id}/merge_requests",
|
|
223
234
|
{"state": "merged", "target_branch": target_branch, "updated_after": updated_after.isoformat()},
|
|
224
235
|
_MergeRequests,
|
|
236
|
+
resource="merge_requests",
|
|
225
237
|
)
|
|
226
238
|
return merge_requests
|
|
227
239
|
|
|
228
240
|
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
|
|
229
241
|
merge_requests, _ = self._pages(
|
|
230
|
-
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests",
|
|
242
|
+
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests",
|
|
243
|
+
{},
|
|
244
|
+
_MergeRequests,
|
|
245
|
+
resource="merge_requests",
|
|
231
246
|
)
|
|
232
247
|
return merge_requests
|
|
233
248
|
|
|
@@ -236,6 +251,7 @@ class GitLabApi:
|
|
|
236
251
|
f"{_API}/projects/{project_id}/pipelines",
|
|
237
252
|
{"ref": ref, "source": "push", "updated_after": updated_after.isoformat()},
|
|
238
253
|
_Pipelines,
|
|
254
|
+
resource="pipelines",
|
|
239
255
|
)
|
|
240
256
|
return pipelines
|
|
241
257
|
|
|
@@ -244,18 +260,25 @@ class GitLabApi:
|
|
|
244
260
|
f"{_API}/projects/{project_id}/pipelines",
|
|
245
261
|
{"ref": ref, "order_by": "id", "sort": "desc", "per_page": 1},
|
|
246
262
|
_Pipelines,
|
|
263
|
+
resource="pipelines",
|
|
247
264
|
)
|
|
248
265
|
return pipelines.root[0] if pipelines.root else None
|
|
249
266
|
|
|
250
267
|
def failed_jobs(self, project_id: int, pipeline_id: int) -> list[Job]:
|
|
251
268
|
jobs, _ = self._pages(
|
|
252
|
-
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/jobs",
|
|
269
|
+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/jobs",
|
|
270
|
+
{"scope[]": "failed"},
|
|
271
|
+
_Jobs,
|
|
272
|
+
resource="pipelines",
|
|
253
273
|
)
|
|
254
274
|
return jobs
|
|
255
275
|
|
|
256
276
|
def failed_bridges(self, project_id: int, pipeline_id: int) -> list[Bridge]:
|
|
257
277
|
# `trigger_jobs` replaces this route only from GitLab 19.2; older instances have `bridges` alone.
|
|
258
278
|
bridges, _ = self._pages(
|
|
259
|
-
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/bridges",
|
|
279
|
+
f"{_API}/projects/{project_id}/pipelines/{pipeline_id}/bridges",
|
|
280
|
+
{"scope[]": "failed"},
|
|
281
|
+
_Bridges,
|
|
282
|
+
resource="pipelines",
|
|
260
283
|
)
|
|
261
284
|
return bridges
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import http
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from release_scope._errors import GitLabError
|
|
5
|
+
from release_scope._gitlab import Project
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_RESOURCES: typing.Final[dict[str, tuple[str, tuple[str, ...]]]] = {
|
|
9
|
+
"deployments": ("deployments", ("Environments", "CI/CD")),
|
|
10
|
+
"pipelines": ("pipelines", ("CI/CD",)),
|
|
11
|
+
"repository": ("the repository", ("Repository",)),
|
|
12
|
+
"merge_requests": ("merge requests", ("Merge requests",)),
|
|
13
|
+
}
|
|
14
|
+
_PLURAL_FEATURES: typing.Final = frozenset({"Environments", "Merge requests"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _feature_setting(project: Project, feature: str) -> str:
|
|
18
|
+
return f"{project.web_url}/edit#js-shared-permissions → Visibility, project features, permissions → {feature}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def explain_failure(project: Project, error: GitLabError) -> str:
|
|
22
|
+
name: typing.Final = project.path_with_namespace
|
|
23
|
+
resource, features = _RESOURCES.get(error.resource, ("project data", ()))
|
|
24
|
+
if error.status is None:
|
|
25
|
+
return f"{name}: GitLab request for {resource} failed ({error.reason})."
|
|
26
|
+
if error.status != http.HTTPStatus.FORBIDDEN:
|
|
27
|
+
return f"{name}: GitLab returned {error.status} for {resource}."
|
|
28
|
+
checks: typing.Final = [
|
|
29
|
+
f"- {feature} {'are' if feature in _PLURAL_FEATURES else 'is'} enabled: {_feature_setting(project, feature)}"
|
|
30
|
+
for feature in features
|
|
31
|
+
]
|
|
32
|
+
checks.append(f"- the token's user has a role that can read them: {project.web_url}/-/project_members")
|
|
33
|
+
return "\n".join([f"{name}: GitLab denied access to {resource} (403). Check that:", *checks])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def skip_reason(project: Project) -> str | None:
|
|
37
|
+
if project.builds_access_level == "disabled":
|
|
38
|
+
return (
|
|
39
|
+
"CI/CD is disabled, so it has no pipelines or deployments. "
|
|
40
|
+
f"Enable it at {_feature_setting(project, 'CI/CD')}."
|
|
41
|
+
)
|
|
42
|
+
if project.environments_access_level == "disabled":
|
|
43
|
+
return (
|
|
44
|
+
"Environments are disabled, so it has no deployments. "
|
|
45
|
+
f"Enable them at {_feature_setting(project, 'Environments')}."
|
|
46
|
+
)
|
|
47
|
+
return None
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import datetime
|
|
3
|
+
import html
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from release_scope._report import EnvironmentState, FailedJob, PipelineState, Report, Row, Service, TagRef
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_STATUS_ICONS: typing.Final = {
|
|
10
|
+
"success": "✅",
|
|
11
|
+
"failed": "❌",
|
|
12
|
+
"created": "🔄",
|
|
13
|
+
"waiting_for_resource": "🔄",
|
|
14
|
+
"preparing": "🔄",
|
|
15
|
+
"pending": "🔄",
|
|
16
|
+
"running": "🔄",
|
|
17
|
+
"scheduled": "🔄",
|
|
18
|
+
"canceled": "⏭",
|
|
19
|
+
"skipped": "⏭",
|
|
20
|
+
"manual": "⏭",
|
|
21
|
+
}
|
|
22
|
+
_LEGEND: typing.Final = (
|
|
23
|
+
"Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure"
|
|
24
|
+
)
|
|
25
|
+
_ROW_HEADER: typing.Final = ("Tag", "Change", "Jira", "Deployed to", "Failed jobs")
|
|
26
|
+
_FAILED, _SKIPPED, _PENDING = 0, 1, 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _inline(value: str) -> str:
|
|
30
|
+
return html.escape(value, quote=False).replace("|", "\\|").replace("[", "\\[").replace("]", "\\]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cell(value: str) -> str:
|
|
34
|
+
return _inline(value).replace("\r\n", "<br>").replace("\n", "<br>")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _link(label: str, url: str | None) -> str:
|
|
38
|
+
if not url:
|
|
39
|
+
return label
|
|
40
|
+
return f"[{label}]({url.replace(' ', '%20').replace(')', '%29').replace('|', '%7C')})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _icon(status: str) -> str:
|
|
44
|
+
return _STATUS_ICONS.get(status, f"`{status}`")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _plural(count: int, noun: str) -> str:
|
|
48
|
+
return f"{count} {noun}" if count == 1 else f"{count} {noun}s"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _nonzero(separator: str, parts: collections.abc.Iterable[tuple[int, str]]) -> str:
|
|
52
|
+
return separator.join(text for count, text in parts if count)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _table(
|
|
56
|
+
header: collections.abc.Sequence[str], rows: collections.abc.Iterable[collections.abc.Sequence[str]]
|
|
57
|
+
) -> list[str]:
|
|
58
|
+
lines: typing.Final = [f"| {' | '.join(header)} |", f"|{'---|' * len(header)}"]
|
|
59
|
+
lines.extend(f"| {' | '.join(row)} |" for row in rows)
|
|
60
|
+
return lines
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _collapsed(summary: str, body: list[str]) -> list[str]:
|
|
64
|
+
return ["<details>", f"<summary>{summary}</summary>", "", *body, "", "</details>", ""]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _state(service: Service) -> int | None:
|
|
68
|
+
if service.error:
|
|
69
|
+
return _FAILED
|
|
70
|
+
if service.rows:
|
|
71
|
+
return _PENDING
|
|
72
|
+
return _SKIPPED if service.warnings else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _environment_names(report: Report) -> list[str]:
|
|
76
|
+
names: dict[str, None] = {report.production_environment: None}
|
|
77
|
+
for service in report.services:
|
|
78
|
+
names.update((item.name, None) for item in service.environments)
|
|
79
|
+
return list(names)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _environment(service: Service, name: str) -> EnvironmentState | None:
|
|
83
|
+
return next((item for item in service.environments if item.name == name), None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _environment_ref(environment: EnvironmentState | None) -> str:
|
|
87
|
+
return _link(_cell(environment.ref), environment.deployment_url) if environment else "—"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _failure_counts(service: Service) -> str:
|
|
91
|
+
pipelines: typing.Final = [row.main_pipeline for row in service.rows] + [
|
|
92
|
+
tag.pipeline for row in service.rows for tag in row.tags
|
|
93
|
+
]
|
|
94
|
+
jobs: typing.Final = [job for pipeline in pipelines if pipeline for job in pipeline.failed_jobs]
|
|
95
|
+
allowed: typing.Final = sum(1 for job in jobs if job.allow_failure)
|
|
96
|
+
blocking: typing.Final = len(jobs) - allowed
|
|
97
|
+
return _nonzero(" · ", [(blocking, f"❌ {blocking}"), (allowed, f"⚠️ {allowed} allowed")])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _pending(service: Service) -> str:
|
|
101
|
+
state: typing.Final = _state(service)
|
|
102
|
+
if state == _FAILED:
|
|
103
|
+
return "❌ failed to collect"
|
|
104
|
+
if state == _SKIPPED:
|
|
105
|
+
return "⚠️ see below"
|
|
106
|
+
untagged: typing.Final = "" if service.rows[0].tags else " · untagged head"
|
|
107
|
+
return f"{_plural(len(service.rows), 'change')}{untagged}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _summary_row(service: Service, environments: list[str]) -> list[str]:
|
|
111
|
+
return [
|
|
112
|
+
_link(_cell(service.project), service.project_url),
|
|
113
|
+
*(_environment_ref(_environment(service, name)) for name in environments),
|
|
114
|
+
_pending(service),
|
|
115
|
+
_failure_counts(service),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _job(job: FailedJob) -> str:
|
|
120
|
+
text: str = _link(_cell(job.name), job.url)
|
|
121
|
+
if job.allow_failure:
|
|
122
|
+
text += " (allowed)"
|
|
123
|
+
if job.downstream_pipeline_url:
|
|
124
|
+
text += f" → {_link('child', job.downstream_pipeline_url)}"
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _jobs(pipeline: PipelineState) -> str:
|
|
129
|
+
return ", ".join(_job(job) for job in pipeline.failed_jobs)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _main_pipeline(pipeline: PipelineState) -> str:
|
|
133
|
+
text: typing.Final = f"main {_icon(pipeline.status)} {_link(str(pipeline.id), pipeline.url)}"
|
|
134
|
+
return f"{text}: {_jobs(pipeline)}" if pipeline.failed_jobs else text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _tag(tag: TagRef) -> str:
|
|
138
|
+
if tag.pipeline is None:
|
|
139
|
+
return f"{_link(_cell(tag.name), tag.url)} ⚠️ no pipeline"
|
|
140
|
+
return f"{_link(_cell(tag.name), tag.pipeline.url)} {_icon(tag.pipeline.status)}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _reference(label: str, url: str | None, title: str, author: str | None) -> str:
|
|
144
|
+
return f"{_link(label, url)} {_cell(title)}" + (f" · {_cell(author)}" if author else "")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _merge_requests_or_commits(row: Row) -> str:
|
|
148
|
+
if row.merge_requests:
|
|
149
|
+
return "<br>".join(
|
|
150
|
+
_reference(f"!{item.iid}", item.url, item.title, f"@{item.author}" if item.author else None)
|
|
151
|
+
for item in row.merge_requests
|
|
152
|
+
)
|
|
153
|
+
return "<br>".join(_reference(f"`{item.short_sha}`", item.url, item.title, item.author) for item in row.commits)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _failed_jobs(row: Row) -> str:
|
|
157
|
+
parts: typing.Final = [_main_pipeline(row.main_pipeline)] if row.main_pipeline else []
|
|
158
|
+
parts.extend(
|
|
159
|
+
f"tag {_cell(tag.name)}: {_jobs(tag.pipeline)}" for tag in row.tags if tag.pipeline and tag.pipeline.failed_jobs
|
|
160
|
+
)
|
|
161
|
+
return "<br>".join(parts)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _row(row: Row) -> list[str]:
|
|
165
|
+
return [
|
|
166
|
+
"<br>".join(_tag(tag) for tag in row.tags),
|
|
167
|
+
_merge_requests_or_commits(row),
|
|
168
|
+
", ".join(_link(_cell(key.key), key.url) for key in row.jira_keys),
|
|
169
|
+
", ".join(_cell(name) for name in row.environments),
|
|
170
|
+
_failed_jobs(row),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _counts(service: Service) -> str:
|
|
175
|
+
merge_requests: typing.Final = len({item.iid for row in service.rows for item in row.merge_requests})
|
|
176
|
+
commits: typing.Final = sum(1 for row in service.rows if row.kind == "commit")
|
|
177
|
+
return _nonzero(
|
|
178
|
+
", ", [(merge_requests, _plural(merge_requests, "merge request")), (commits, _plural(commits, "direct commit"))]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _rows_section(service: Service, production: str) -> list[str]:
|
|
183
|
+
ordered: typing.Final = sorted(service.environments, key=lambda item: item.name != production)
|
|
184
|
+
environments: typing.Final = [f"{_cell(item.name)} {_environment_ref(item)}" for item in ordered]
|
|
185
|
+
production_state: typing.Final = _environment(service, production)
|
|
186
|
+
since: typing.Final = f" since {_inline(production_state.ref)}" if production_state else ""
|
|
187
|
+
return [
|
|
188
|
+
" · ".join([*environments, _counts(service)]),
|
|
189
|
+
"",
|
|
190
|
+
*_collapsed(
|
|
191
|
+
f"{_plural(len(service.rows), 'change')}{since}",
|
|
192
|
+
_table(_ROW_HEADER, (_row(row) for row in service.rows)),
|
|
193
|
+
),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _service_section(service: Service, production: str) -> list[str]:
|
|
198
|
+
lines: typing.Final = [f"## {_inline(service.project)}", ""]
|
|
199
|
+
if service.error:
|
|
200
|
+
lines.extend([f"❌ {_inline(service.error)}", ""])
|
|
201
|
+
lines.extend(line for warning in service.warnings for line in (f"⚠️ {_inline(warning)}", ""))
|
|
202
|
+
if service.rows:
|
|
203
|
+
lines.extend(_rows_section(service, production))
|
|
204
|
+
return lines
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_markdown(report: Report) -> str:
|
|
208
|
+
production: typing.Final = report.production_environment
|
|
209
|
+
collected: typing.Final = report.collected_at.astimezone(datetime.UTC).strftime("%Y-%m-%d %H:%M")
|
|
210
|
+
lines: typing.Final = [
|
|
211
|
+
"# Release scope",
|
|
212
|
+
"",
|
|
213
|
+
(
|
|
214
|
+
f"Collected {collected} UTC. Changes run from the commit on `{_inline(production)}` "
|
|
215
|
+
"to the head of the default branch."
|
|
216
|
+
),
|
|
217
|
+
"",
|
|
218
|
+
_LEGEND,
|
|
219
|
+
"",
|
|
220
|
+
]
|
|
221
|
+
if not report.services:
|
|
222
|
+
lines.append("No services were collected.")
|
|
223
|
+
return "\n".join(lines) + "\n"
|
|
224
|
+
|
|
225
|
+
attention: typing.Final = sorted(
|
|
226
|
+
(service for service in report.services if _state(service) is not None),
|
|
227
|
+
key=lambda service: (_state(service), service.project),
|
|
228
|
+
)
|
|
229
|
+
up_to_date: typing.Final = [service for service in report.services if _state(service) is None]
|
|
230
|
+
environments: typing.Final = _environment_names(report)
|
|
231
|
+
if attention:
|
|
232
|
+
header: typing.Final = ["Service", *(_cell(name) for name in environments), "Pending", "Failed jobs"]
|
|
233
|
+
lines.extend([*_table(header, (_summary_row(service, environments) for service in attention)), ""])
|
|
234
|
+
else:
|
|
235
|
+
lines.extend(["All services are up to date.", ""])
|
|
236
|
+
if up_to_date:
|
|
237
|
+
lines.extend(
|
|
238
|
+
_collapsed(
|
|
239
|
+
f"{_plural(len(up_to_date), 'service')} up to date",
|
|
240
|
+
_table(
|
|
241
|
+
["Service", _cell(production)],
|
|
242
|
+
(
|
|
243
|
+
[_link(_cell(item.project), item.project_url), _environment_ref(_environment(item, production))]
|
|
244
|
+
for item in up_to_date
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
for service in attention:
|
|
250
|
+
lines.extend(_service_section(service, production))
|
|
251
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import collections.abc
|
|
2
2
|
import dataclasses
|
|
3
3
|
import datetime
|
|
4
|
+
import http
|
|
4
5
|
import typing
|
|
5
6
|
from urllib.parse import quote
|
|
6
7
|
|
|
7
8
|
from release_scope._cache import Cache, CachedPipeline
|
|
8
|
-
from release_scope._errors import GitLabError
|
|
9
|
+
from release_scope._errors import AuthError, GitLabError
|
|
9
10
|
from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project
|
|
10
11
|
from release_scope._jira_keys import extract_jira_keys
|
|
12
|
+
from release_scope._messages import explain_failure, skip_reason
|
|
11
13
|
from release_scope._report import (
|
|
12
14
|
CommitRef,
|
|
13
15
|
EnvironmentState,
|
|
@@ -27,6 +29,15 @@ from release_scope._settings import Settings
|
|
|
27
29
|
_SETTLED_PIPELINE_STATUSES: typing.Final = frozenset({"success", "failed", "canceled", "skipped"})
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
def _resolution_error(error: GitLabError, target: str) -> Exception:
|
|
33
|
+
if error.status == http.HTTPStatus.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
|
+
|
|
30
41
|
def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
|
|
31
42
|
return EnvironmentState(
|
|
32
43
|
name=name,
|
|
@@ -79,7 +90,11 @@ class CollectUseCase:
|
|
|
79
90
|
except GitLabError as exc:
|
|
80
91
|
cache.keep_project(project.id)
|
|
81
92
|
services.append(
|
|
82
|
-
Service(
|
|
93
|
+
Service(
|
|
94
|
+
project=project.path_with_namespace,
|
|
95
|
+
project_url=project.web_url,
|
|
96
|
+
error=explain_failure(project, exc),
|
|
97
|
+
)
|
|
83
98
|
)
|
|
84
99
|
return Report(
|
|
85
100
|
collected_at=datetime.datetime.now(datetime.UTC),
|
|
@@ -96,10 +111,16 @@ class CollectUseCase:
|
|
|
96
111
|
) -> list[Project]:
|
|
97
112
|
resolved: dict[int, Project] = {}
|
|
98
113
|
for group in groups:
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
101
119
|
for path in projects:
|
|
102
|
-
|
|
120
|
+
try:
|
|
121
|
+
project = self.api.get_project(path)
|
|
122
|
+
except GitLabError as exc:
|
|
123
|
+
raise _resolution_error(exc, f"project '{path}'") from exc
|
|
103
124
|
resolved[project.id] = project
|
|
104
125
|
return sorted(resolved.values(), key=lambda item: item.path_with_namespace)
|
|
105
126
|
|
|
@@ -107,6 +128,10 @@ class CollectUseCase:
|
|
|
107
128
|
service: typing.Final = Service(
|
|
108
129
|
project=project.path_with_namespace, project_url=project.web_url, default_branch=project.default_branch
|
|
109
130
|
)
|
|
131
|
+
reason: typing.Final = skip_reason(project)
|
|
132
|
+
if reason is not None:
|
|
133
|
+
service.warnings.append(reason)
|
|
134
|
+
return service
|
|
110
135
|
for name in self.settings.environments:
|
|
111
136
|
deployment = self.api.latest_deployment(project.id, name)
|
|
112
137
|
if deployment is not None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|