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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: release-scope
3
- Version: 0.1.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
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.1.0"
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.0"
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
 
@@ -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.project}: {service.error}", err=True)
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
- 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
- )
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
- return GitLabError(f"GitLab returned {exc.response.status_code} for {url}.")
143
- return GitLabError(f"GitLab request {url} failed: {type(exc).__name__}.")
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", {}, _MergeRequests
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", {"scope[]": "failed"}, _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", {"scope[]": "failed"}, _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(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
+ )
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
- 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)
101
119
  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
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: