release-scope 0.3.0__tar.gz → 0.4.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.
Files changed (22) hide show
  1. {release_scope-0.3.0 → release_scope-0.4.0}/PKG-INFO +68 -3
  2. {release_scope-0.3.0 → release_scope-0.4.0}/README.md +67 -2
  3. {release_scope-0.3.0 → release_scope-0.4.0}/pyproject.toml +1 -1
  4. {release_scope-0.3.0 → release_scope-0.4.0}/pyproject.toml.orig +1 -1
  5. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/__main__.py +44 -25
  6. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_gitlab.py +19 -1
  7. release_scope-0.4.0/release_scope/_publish.py +51 -0
  8. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_render.py +16 -2
  9. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_report.py +1 -0
  10. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_settings.py +2 -2
  11. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_use_case.py +1 -0
  12. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/ioc.py +4 -2
  13. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/__init__.py +0 -0
  14. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_cache.py +0 -0
  15. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_errors.py +0 -0
  16. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_files.py +0 -0
  17. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_jira.py +0 -0
  18. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_jira_keys.py +0 -0
  19. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_links.py +0 -0
  20. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_messages.py +0 -0
  21. {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_rows.py +0 -0
  22. {release_scope-0.3.0 → release_scope-0.4.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.0
3
+ Version: 0.4.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
@@ -31,11 +31,24 @@ Project-URL: Issues, https://github.com/modern-python/release-scope/issues
31
31
  Project-URL: Changelog, https://github.com/modern-python/release-scope/releases
32
32
  Description-Content-Type: text/markdown
33
33
 
34
+ <p align="center">
35
+ <picture>
36
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup-dark.svg">
37
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup-light.svg">
38
+ <img alt="release-scope" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup.png" width="420">
39
+ </picture>
40
+ </p>
41
+
34
42
  [![PyPI version](https://img.shields.io/pypi/v/release-scope.svg)](https://pypi.org/project/release-scope/)
35
43
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/release-scope.svg)](https://pypi.org/project/release-scope/)
44
+ [![Downloads](https://static.pepy.tech/badge/release-scope/month)](https://pepy.tech/projects/release-scope)
36
45
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
37
46
  [![CI](https://github.com/modern-python/release-scope/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
38
47
  [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](https://github.com/modern-python/release-scope/blob/main/LICENSE)
48
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/release-scope)](https://github.com/modern-python/release-scope/stargazers)
49
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
50
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
51
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
39
52
 
40
53
  `release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
41
54
  Jira issues, failed jobs.
@@ -52,7 +65,7 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it
52
65
 
53
66
  ```sh
54
67
  export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
55
- export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
68
+ export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish
56
69
  export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
57
70
  export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
58
71
 
@@ -126,7 +139,8 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
126
139
  uvx release-scope render report.json --output report.md
127
140
  ```
128
141
 
129
- The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
142
+ The page opens with a table of the services that have pending changes or problems, with the ref each environment runs
143
+ and a GitLab compare link from production to the newest pending tag (to the release tag in a `--jira` report);
130
144
  services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
131
145
  of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
132
146
  the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
@@ -138,6 +152,39 @@ Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service fa
138
152
  should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
139
153
  read the report or write the page.
140
154
 
155
+ ## Wiki
156
+
157
+ `publish` replaces the content of an existing page in a project wiki with a rendered page:
158
+
159
+ ```sh
160
+ uvx release-scope publish report.md --project team/docs --page releases/backend
161
+ ```
162
+
163
+ `--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates
164
+ one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is
165
+ unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope
166
+ whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any
167
+ other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error
168
+ then names the size of the page.
169
+
170
+ A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job:
171
+
172
+ ```yaml
173
+ release-page:
174
+ image: python:3.13-slim
175
+ rules:
176
+ - if: $CI_PIPELINE_SOURCE == "schedule"
177
+ script:
178
+ - pip install 'release-scope>=0.4,<0.5'
179
+ - release-scope collect --group team/backend --output report.json || status=$?
180
+ - release-scope render report.json --output report.md
181
+ - release-scope publish report.md --project team/docs --page releases/backend
182
+ - exit "${status:-0}"
183
+ ```
184
+
185
+ Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project
186
+ that runs the job, along with the other settings.
187
+
141
188
  ## Cache
142
189
 
143
190
  `--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
@@ -146,3 +193,21 @@ jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalida
146
193
  the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
147
194
  group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
148
195
  requests and never changes the report.
196
+
197
+ ## Agent skill
198
+
199
+ [`skills/release-scope`](https://github.com/modern-python/release-scope/tree/main/skills/release-scope) is an agent
200
+ skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent
201
+ what in the current repository has not reached production, what a group will ship with the next tag, or whether a
202
+ Jira issue is released and which services it touches. For the current repository the skill takes `--project` from
203
+ the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask
204
+ for it and name the page.
205
+
206
+ Install it with [skills](https://github.com/vercel-labs/skills):
207
+
208
+ ```sh
209
+ npx skills add modern-python/release-scope
210
+ ```
211
+
212
+ The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
213
+ The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes.
@@ -1,8 +1,21 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup-light.svg">
5
+ <img alt="release-scope" src="https://raw.githubusercontent.com/modern-python/.github/main/brand/projects/release-scope/lockup.png" width="420">
6
+ </picture>
7
+ </p>
8
+
1
9
  [![PyPI version](https://img.shields.io/pypi/v/release-scope.svg)](https://pypi.org/project/release-scope/)
2
10
  [![Supported Python versions](https://img.shields.io/pypi/pyversions/release-scope.svg)](https://pypi.org/project/release-scope/)
11
+ [![Downloads](https://static.pepy.tech/badge/release-scope/month)](https://pepy.tech/projects/release-scope)
3
12
  [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
4
13
  [![CI](https://github.com/modern-python/release-scope/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
5
14
  [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](https://github.com/modern-python/release-scope/blob/main/LICENSE)
15
+ [![GitHub stars](https://img.shields.io/github/stars/modern-python/release-scope)](https://github.com/modern-python/release-scope/stargazers)
16
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
17
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
18
+ [![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
6
19
 
7
20
  `release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
8
21
  Jira issues, failed jobs.
@@ -19,7 +32,7 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it
19
32
 
20
33
  ```sh
21
34
  export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
22
- export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
35
+ export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish
23
36
  export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
24
37
  export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
25
38
 
@@ -93,7 +106,8 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
93
106
  uvx release-scope render report.json --output report.md
94
107
  ```
95
108
 
96
- The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
109
+ The page opens with a table of the services that have pending changes or problems, with the ref each environment runs
110
+ and a GitLab compare link from production to the newest pending tag (to the release tag in a `--jira` report);
97
111
  services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
98
112
  of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
99
113
  the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
@@ -105,6 +119,39 @@ Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service fa
105
119
  should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
106
120
  read the report or write the page.
107
121
 
122
+ ## Wiki
123
+
124
+ `publish` replaces the content of an existing page in a project wiki with a rendered page:
125
+
126
+ ```sh
127
+ uvx release-scope publish report.md --project team/docs --page releases/backend
128
+ ```
129
+
130
+ `--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates
131
+ one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is
132
+ unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope
133
+ whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any
134
+ other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error
135
+ then names the size of the page.
136
+
137
+ A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job:
138
+
139
+ ```yaml
140
+ release-page:
141
+ image: python:3.13-slim
142
+ rules:
143
+ - if: $CI_PIPELINE_SOURCE == "schedule"
144
+ script:
145
+ - pip install 'release-scope>=0.4,<0.5'
146
+ - release-scope collect --group team/backend --output report.json || status=$?
147
+ - release-scope render report.json --output report.md
148
+ - release-scope publish report.md --project team/docs --page releases/backend
149
+ - exit "${status:-0}"
150
+ ```
151
+
152
+ Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project
153
+ that runs the job, along with the other settings.
154
+
108
155
  ## Cache
109
156
 
110
157
  `--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
@@ -113,3 +160,21 @@ jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalida
113
160
  the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
114
161
  group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
115
162
  requests and never changes the report.
163
+
164
+ ## Agent skill
165
+
166
+ [`skills/release-scope`](https://github.com/modern-python/release-scope/tree/main/skills/release-scope) is an agent
167
+ skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent
168
+ what in the current repository has not reached production, what a group will ship with the next tag, or whether a
169
+ Jira issue is released and which services it touches. For the current repository the skill takes `--project` from
170
+ the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask
171
+ for it and name the page.
172
+
173
+ Install it with [skills](https://github.com/vercel-labs/skills):
174
+
175
+ ```sh
176
+ npx skills add modern-python/release-scope
177
+ ```
178
+
179
+ The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
180
+ The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes.
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.3.0"
28
+ version = "0.4.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.3.0"
20
+ version = "0.4.0"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -1,3 +1,4 @@
1
+ import functools
1
2
  import importlib.metadata
2
3
  import json
3
4
  import pathlib
@@ -11,9 +12,9 @@ from release_scope._cache import Cache
11
12
  from release_scope._errors import ConfigError, ReleaseScopeError
12
13
  from release_scope._files import write_text_atomic
13
14
  from release_scope._jira_keys import JIRA_KEY_PATTERN
15
+ from release_scope._publish import PublishUseCase
14
16
  from release_scope._render import render_markdown
15
17
  from release_scope._report import SCHEMA_VERSION, Report
16
- from release_scope._settings import Settings, load_settings
17
18
  from release_scope._use_case import CollectUseCase
18
19
 
19
20
 
@@ -47,16 +48,26 @@ def _main_callback(
47
48
  pass
48
49
 
49
50
 
50
- @modern_di_typer.inject
51
- def _resolve_use_case(
52
- use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)],
53
- ) -> CollectUseCase:
54
- return use_case
51
+ _P = typing.ParamSpec("_P")
52
+
53
+
54
+ def _exit_on_error(func: typing.Callable[_P, None]) -> typing.Callable[_P, None]:
55
+ @functools.wraps(func)
56
+ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
57
+ try:
58
+ func(*args, **kwargs)
59
+ except ReleaseScopeError as err:
60
+ typer.echo(f"Error: {err}", err=True)
61
+ raise typer.Exit(code=err.exit_code) from err
62
+
63
+ return wrapper
55
64
 
56
65
 
57
66
  @MAIN_APP.command("collect", help="Collect pending changes per service into a JSON report.")
67
+ @_exit_on_error
68
+ @modern_di_typer.inject
58
69
  def _collect_command( # noqa: PLR0913, PLR0917
59
- ctx: typer.Context,
70
+ use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)],
60
71
  output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the report JSON.")],
61
72
  group: typing.Annotated[
62
73
  list[str] | None, typer.Option("--group", "-g", help="GitLab group path; repeatable.")
@@ -76,24 +87,14 @@ def _collect_command( # noqa: PLR0913, PLR0917
76
87
  typer.Option("--jira", "-j", help="Jira issue key; collect only the services it links to. Repeatable."),
77
88
  ] = None,
78
89
  ) -> None:
79
- try:
80
- _check_selection(groups=group or [], projects=project or [], keys=jira or [])
81
- settings = load_settings({})
82
- modern_di_typer.fetch_di_container(ctx).set_context(Settings, settings)
83
- cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
84
- if cache_warning:
85
- typer.echo(f"Warning: {cache_warning}", err=True)
86
- use_case = _resolve_use_case(ctx=ctx)
87
- if jira:
88
- report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache)
89
- else:
90
- report = use_case(
91
- groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache
92
- )
93
- except ReleaseScopeError as err:
94
- typer.echo(f"Error: {err}", err=True)
95
- raise typer.Exit(code=err.exit_code) from err
96
-
90
+ _check_selection(groups=group or [], projects=project or [], keys=jira or [])
91
+ cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
92
+ if cache_warning:
93
+ typer.echo(f"Warning: {cache_warning}", err=True)
94
+ if jira:
95
+ report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache)
96
+ else:
97
+ report = use_case(groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache)
97
98
  write_text_atomic(output, report.model_dump_json(indent=2))
98
99
  if cache_path:
99
100
  cache.save(cache_path)
@@ -160,6 +161,24 @@ def _render_command(
160
161
  typer.echo(f"{len(report.services)} services -> {output}", err=True)
161
162
 
162
163
 
164
+ @MAIN_APP.command("publish", help="Replace the content of an existing GitLab wiki page with a rendered page.")
165
+ @_exit_on_error
166
+ @modern_di_typer.inject
167
+ def _publish_command(
168
+ use_case: typing.Annotated[PublishUseCase, modern_di_typer.FromDI(PublishUseCase)],
169
+ page_path: typing.Annotated[pathlib.Path, typer.Argument(help="Markdown page written by `render`.")],
170
+ project: typing.Annotated[str, typer.Option("--project", "-p", help="GitLab project path that holds the wiki.")],
171
+ page: typing.Annotated[str, typer.Option("--page", help="Slug of the wiki page, such as releases/backend.")],
172
+ ) -> None:
173
+ try:
174
+ content = page_path.read_text(encoding="utf-8")
175
+ except (OSError, ValueError) as exc:
176
+ typer.echo(f"Error: Cannot read page {page_path}: {type(exc).__name__}.", err=True)
177
+ raise typer.Exit(code=ConfigError.exit_code) from exc
178
+ published: typing.Final = use_case(project=project, slug=page, content=content)
179
+ typer.echo(f"{'Updated' if published.updated else 'Unchanged'} {published.url}", err=True)
180
+
181
+
163
182
  def main() -> None:
164
183
  with ioc.container:
165
184
  MAIN_APP()
@@ -28,6 +28,7 @@ class Project(pydantic.BaseModel):
28
28
 
29
29
  class Deployable(pydantic.BaseModel):
30
30
  web_url: str | None = None
31
+ tag: bool = False
31
32
 
32
33
 
33
34
  class Deployment(pydantic.BaseModel):
@@ -103,6 +104,11 @@ class Bridge(Job):
103
104
  downstream_pipeline: DownstreamPipeline | None = None
104
105
 
105
106
 
107
+ class WikiPage(pydantic.BaseModel):
108
+ slug: str
109
+ content: str
110
+
111
+
106
112
  class _Projects(pydantic.RootModel[list[Project]]):
107
113
  pass
108
114
 
@@ -136,7 +142,7 @@ class _Bridges(pydantic.RootModel[list[Bridge]]):
136
142
 
137
143
 
138
144
  Resource: typing.TypeAlias = typing.Literal[
139
- "group", "project", "deployments", "pipelines", "repository", "merge_requests"
145
+ "group", "project", "deployments", "pipelines", "repository", "merge_requests", "wiki"
140
146
  ]
141
147
 
142
148
 
@@ -164,6 +170,12 @@ class GitLabApi:
164
170
  except httpware.ClientError as exc:
165
171
  raise _translate(exc, url=url, resource=resource) from exc
166
172
 
173
+ def _put(self, url: str, body: dict[str, typing.Any], *, resource: Resource) -> None:
174
+ try:
175
+ self.http.put(url, json=body)
176
+ except httpware.ClientError as exc:
177
+ raise _translate(exc, url=url, resource=resource) from exc
178
+
167
179
  def _pages(
168
180
  self,
169
181
  url: str,
@@ -287,3 +299,9 @@ class GitLabApi:
287
299
  resource="pipelines",
288
300
  )
289
301
  return bridges
302
+
303
+ def get_wiki_page(self, project: str, slug: str) -> WikiPage:
304
+ return self._get(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {}, WikiPage, resource="wiki")
305
+
306
+ def update_wiki_page(self, project: str, slug: str, *, content: str) -> None:
307
+ self._put(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {"content": content}, resource="wiki")
@@ -0,0 +1,51 @@
1
+ import dataclasses
2
+ import http
3
+ import typing
4
+
5
+ from release_scope._errors import AuthError, GitLabError, ReleaseScopeError
6
+ from release_scope._gitlab import GitLabApi
7
+ from release_scope._settings import Settings
8
+
9
+
10
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
11
+ class Published:
12
+ url: str
13
+ updated: bool
14
+
15
+
16
+ def _wiki_error(error: GitLabError, *, project: str, slug: str, size: int | None) -> ReleaseScopeError:
17
+ if error.status == http.HTTPStatus.FORBIDDEN:
18
+ return AuthError(
19
+ f"GitLab denied access to the wiki of project '{project}' (403). Check that the token has the 'api' "
20
+ "scope and that its user has at least the Developer role there."
21
+ )
22
+ if error.status == http.HTTPStatus.NOT_FOUND:
23
+ return GitLabError(
24
+ f"Wiki page '{slug}' does not exist in project '{project}', or the token cannot see it. "
25
+ "Create the page in GitLab first.",
26
+ resource="wiki",
27
+ status=error.status,
28
+ )
29
+ if size is None:
30
+ return error
31
+ return GitLabError(f"{error} The page is {size} bytes.", resource="wiki", status=error.status, reason=error.reason)
32
+
33
+
34
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
35
+ class PublishUseCase:
36
+ api: GitLabApi
37
+ settings: Settings
38
+
39
+ def __call__(self, *, project: str, slug: str, content: str) -> Published:
40
+ url: typing.Final = f"{self.settings.gitlab.endpoint.rstrip('/')}/{project}/-/wikis/{slug}"
41
+ try:
42
+ page = self.api.get_wiki_page(project, slug)
43
+ except GitLabError as exc:
44
+ raise _wiki_error(exc, project=project, slug=slug, size=None) from exc
45
+ if page.content == content:
46
+ return Published(url=url, updated=False)
47
+ try:
48
+ self.api.update_wiki_page(project, slug, content=content)
49
+ except GitLabError as exc:
50
+ raise _wiki_error(exc, project=project, slug=slug, size=len(content.encode())) from exc
51
+ return Published(url=url, updated=True)
@@ -2,6 +2,7 @@ import collections.abc
2
2
  import datetime
3
3
  import html
4
4
  import typing
5
+ from urllib.parse import quote
5
6
 
6
7
  from release_scope._report import (
7
8
  EnvironmentState,
@@ -131,11 +132,23 @@ def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str:
131
132
  return f"{count} not done" if count else ""
132
133
 
133
134
 
134
- def _summary_row(service: Service, environments: list[str], jira: JiraState | None) -> list[str]:
135
+ def _compare(service: Service, production: str) -> str:
136
+ start: typing.Final = _environment(service, production)
137
+ release: typing.Final = service.release
138
+ tag: typing.Final = release.tag if release else next((tag for row in service.rows for tag in row.tags), None)
139
+ if start is None or tag is None:
140
+ return ""
141
+ base, label = (start.ref, _cell(start.ref)) if start.tag else (start.sha, f"`{start.sha[:8]}`")
142
+ return _link(f"{label}...{_cell(tag.name)}", f"{service.project_url}/-/compare/{quote(base)}...{quote(tag.name)}")
143
+
144
+
145
+ def _summary_row(service: Service, report: Report, environments: list[str]) -> list[str]:
146
+ jira: typing.Final = report.jira
135
147
  return [
136
148
  _link(_cell(service.project), service.project_url),
137
149
  *(_environment_ref(_environment(service, name)) for name in environments),
138
150
  _pending(service),
151
+ _compare(service, report.production_environment),
139
152
  *([_not_done(service, jira.issues)] if jira else []),
140
153
  _failure_counts(service),
141
154
  ]
@@ -301,10 +314,11 @@ def render_markdown(report: Report) -> str:
301
314
  "Service",
302
315
  *(_cell(name) for name in environments),
303
316
  "Pending",
317
+ "Compare",
304
318
  *(["Jira"] if jira else []),
305
319
  "Failed jobs",
306
320
  ]
307
- lines.extend([*_table(header, (_summary_row(service, environments, jira) for service in attention)), ""])
321
+ lines.extend([*_table(header, (_summary_row(service, report, environments) for service in attention)), ""])
308
322
  else:
309
323
  lines.extend(["All services are up to date.", ""])
310
324
  if up_to_date:
@@ -70,6 +70,7 @@ class EnvironmentState(pydantic.BaseModel):
70
70
  sha: str
71
71
  deployed_at: str
72
72
  deployment_url: str | None
73
+ tag: bool = False
73
74
 
74
75
 
75
76
  class Release(pydantic.BaseModel):
@@ -49,9 +49,9 @@ class Settings(pydantic_settings.BaseSettings):
49
49
  return self
50
50
 
51
51
 
52
- def load_settings(overrides: dict[str, typing.Any]) -> Settings:
52
+ def load_settings() -> Settings:
53
53
  try:
54
- settings: typing.Final = Settings(**overrides)
54
+ settings: typing.Final = Settings()
55
55
  except pydantic.ValidationError as exc:
56
56
  msg = f"Invalid configuration: {exc}"
57
57
  raise ConfigError(msg) from exc
@@ -51,6 +51,7 @@ def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
51
51
  sha=deployment.sha,
52
52
  deployed_at=deployment.created_at,
53
53
  deployment_url=deployment.deployable.web_url if deployment.deployable else None,
54
+ tag=deployment.deployable.tag if deployment.deployable else False,
54
55
  )
55
56
 
56
57
 
@@ -6,7 +6,8 @@ from modern_di import Scope, providers
6
6
 
7
7
  from release_scope._gitlab import GitLabApi
8
8
  from release_scope._jira import JiraApi
9
- from release_scope._settings import Settings
9
+ from release_scope._publish import PublishUseCase
10
+ from release_scope._settings import Settings, load_settings
10
11
  from release_scope._use_case import CollectUseCase
11
12
 
12
13
 
@@ -47,7 +48,7 @@ def _close_client(client: httpware.Client | None) -> None:
47
48
 
48
49
 
49
50
  class SettingsGroup(modern_di.Group):
50
- settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings)
51
+ settings = providers.Factory(scope=Scope.APP, creator=load_settings, cache=True)
51
52
 
52
53
 
53
54
  class ClientsGroup(modern_di.Group):
@@ -73,6 +74,7 @@ class UseCasesGroup(modern_di.Group):
73
74
  collect_use_case = providers.Factory(
74
75
  scope=Scope.APP, creator=CollectUseCase, kwargs={"jira": ClientsGroup.jira_api}
75
76
  )
77
+ publish_use_case = providers.Factory(scope=Scope.APP, creator=PublishUseCase)
76
78
 
77
79
 
78
80
  ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [SettingsGroup, ClientsGroup, UseCasesGroup]