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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: release-scope
3
- Version: 0.1.1
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
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.1.1"
28
+ version = "0.2.0"
29
29
  dependencies = [
30
30
  "typer>=0.13",
31
31
  "pydantic>=2; python_version < '3.12'",
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
- version = "0.1.1"
20
+ version = "0.2.0"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -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"