release-scope 0.1.1__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.1 → release_scope-0.2.0}/PKG-INFO +19 -1
- {release_scope-0.1.1 → release_scope-0.2.0}/README.md +18 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/pyproject.toml +1 -1
- {release_scope-0.1.1 → release_scope-0.2.0}/pyproject.toml.orig +1 -1
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/__main__.py +21 -0
- release_scope-0.2.0/release_scope/_render.py +251 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/__init__.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_cache.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_errors.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_files.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_gitlab.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_jira_keys.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_messages.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_report.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_rows.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_settings.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/_use_case.py +0 -0
- {release_scope-0.1.1 → release_scope-0.2.0}/release_scope/ioc.py +0 -0
- {release_scope-0.1.1 → 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
|
|
@@ -95,6 +95,24 @@ One row, trimmed:
|
|
|
95
95
|
}
|
|
96
96
|
```
|
|
97
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
|
+
|
|
98
116
|
## Cache
|
|
99
117
|
|
|
100
118
|
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
@@ -62,6 +62,24 @@ One row, trimmed:
|
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
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
|
+
|
|
65
83
|
## Cache
|
|
66
84
|
|
|
67
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
|
|
|
@@ -96,6 +99,24 @@ def _collect_command( # noqa: PLR0913, PLR0917
|
|
|
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()
|
|
@@ -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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|