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.
- {release_scope-0.3.0 → release_scope-0.4.0}/PKG-INFO +68 -3
- {release_scope-0.3.0 → release_scope-0.4.0}/README.md +67 -2
- {release_scope-0.3.0 → release_scope-0.4.0}/pyproject.toml +1 -1
- {release_scope-0.3.0 → release_scope-0.4.0}/pyproject.toml.orig +1 -1
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/__main__.py +44 -25
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_gitlab.py +19 -1
- release_scope-0.4.0/release_scope/_publish.py +51 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_render.py +16 -2
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_report.py +1 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_settings.py +2 -2
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_use_case.py +1 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/ioc.py +4 -2
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/__init__.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_cache.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_errors.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_files.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_jira.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_jira_keys.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_links.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_messages.py +0 -0
- {release_scope-0.3.0 → release_scope-0.4.0}/release_scope/_rows.py +0 -0
- {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
|
+
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
|
[](https://pypi.org/project/release-scope/)
|
|
35
43
|
[](https://pypi.org/project/release-scope/)
|
|
44
|
+
[](https://pepy.tech/projects/release-scope)
|
|
36
45
|
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
37
46
|
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
38
47
|
[](https://github.com/modern-python/release-scope/blob/main/LICENSE)
|
|
48
|
+
[](https://github.com/modern-python/release-scope/stargazers)
|
|
49
|
+
[](https://github.com/astral-sh/uv)
|
|
50
|
+
[](https://github.com/astral-sh/ruff)
|
|
51
|
+
[](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
|
[](https://pypi.org/project/release-scope/)
|
|
2
10
|
[](https://pypi.org/project/release-scope/)
|
|
11
|
+
[](https://pepy.tech/projects/release-scope)
|
|
3
12
|
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
4
13
|
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
5
14
|
[](https://github.com/modern-python/release-scope/blob/main/LICENSE)
|
|
15
|
+
[](https://github.com/modern-python/release-scope/stargazers)
|
|
16
|
+
[](https://github.com/astral-sh/uv)
|
|
17
|
+
[](https://github.com/astral-sh/ruff)
|
|
18
|
+
[](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.
|
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
) ->
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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,
|
|
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:
|
|
@@ -49,9 +49,9 @@ class Settings(pydantic_settings.BaseSettings):
|
|
|
49
49
|
return self
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def load_settings(
|
|
52
|
+
def load_settings() -> Settings:
|
|
53
53
|
try:
|
|
54
|
-
settings: typing.Final = Settings(
|
|
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.
|
|
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.
|
|
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]
|
|
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
|