release-scope 0.2.0__tar.gz → 0.3.1__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.2.0 → release_scope-0.3.1}/PKG-INFO +69 -12
- release_scope-0.3.1/README.md +146 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/pyproject.toml +1 -1
- {release_scope-0.2.0 → release_scope-0.3.1}/pyproject.toml.orig +1 -1
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/__main__.py +54 -11
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_cache.py +32 -1
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_errors.py +4 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_gitlab.py +6 -0
- release_scope-0.3.1/release_scope/_jira.py +118 -0
- release_scope-0.3.1/release_scope/_links.py +28 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_render.py +109 -21
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_report.py +38 -2
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_settings.py +8 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_use_case.py +219 -36
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/ioc.py +33 -3
- release_scope-0.2.0/README.md +0 -89
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/__init__.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_files.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_jira_keys.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_messages.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_rows.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.1}/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.3.1
|
|
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,19 +31,35 @@ 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
|
-
Jira
|
|
54
|
+
Jira issues, failed jobs.
|
|
42
55
|
|
|
43
56
|
For every service it reads the latest successful production deployment, walks the default branch down to that
|
|
44
57
|
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
|
|
45
58
|
point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
|
|
46
|
-
and tag pipelines.
|
|
59
|
+
and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
|
|
60
|
+
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
|
|
61
|
+
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
|
|
62
|
+
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
|
|
47
63
|
|
|
48
64
|
## Quickstart
|
|
49
65
|
|
|
@@ -60,7 +76,23 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
60
76
|
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
|
|
61
77
|
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
|
|
62
78
|
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.
|
|
79
|
+
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
|
|
80
|
+
and also exits `1`; the GitLab part is still written.
|
|
81
|
+
|
|
82
|
+
## Jira issues
|
|
83
|
+
|
|
84
|
+
`--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run
|
|
91
|
+
from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service
|
|
92
|
+
records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is
|
|
93
|
+
needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge
|
|
94
|
+
requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way.
|
|
95
|
+
`--jira` is repeatable and cannot be combined with `--group` or `--project`; an issue Jira does not return exits `1`.
|
|
64
96
|
|
|
65
97
|
## Configuration
|
|
66
98
|
|
|
@@ -73,6 +105,7 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
73
105
|
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
|
|
74
106
|
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
|
|
75
107
|
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
|
|
108
|
+
| `RELEASE_SCOPE_JIRA_TOKEN` or `JIRA_TOKEN` | unset | Jira Server/Data Center personal access token; when set, issues are fetched |
|
|
76
109
|
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
|
|
77
110
|
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
|
|
78
111
|
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
|
|
@@ -81,7 +114,9 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
81
114
|
|
|
82
115
|
The report is versioned by `schema_version`; the models live in
|
|
83
116
|
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
|
|
84
|
-
|
|
117
|
+
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status
|
|
118
|
+
category, issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira
|
|
119
|
+
request failed. One row, trimmed:
|
|
85
120
|
|
|
86
121
|
```json
|
|
87
122
|
{
|
|
@@ -104,10 +139,14 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
104
139
|
uvx release-scope render report.json --output report.md
|
|
105
140
|
```
|
|
106
141
|
|
|
107
|
-
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);
|
|
108
144
|
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
|
|
110
|
-
deployed, and the failed jobs of its main-branch
|
|
145
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
|
|
146
|
+
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
|
|
147
|
+
and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done.
|
|
148
|
+
A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to
|
|
149
|
+
the issues.
|
|
111
150
|
|
|
112
151
|
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
113
152
|
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
@@ -116,7 +155,25 @@ read the report or write the page.
|
|
|
116
155
|
## Cache
|
|
117
156
|
|
|
118
157
|
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
119
|
-
that do not change once settled: which merge requests a commit belongs to,
|
|
120
|
-
pipeline keyed by its `updated_at`, so a retried job invalidates the entry.
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
that do not change once settled: which merge requests a commit belongs to, a merged merge request, and the failed
|
|
159
|
+
jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Within each project
|
|
160
|
+
the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
|
|
161
|
+
group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
|
|
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 never writes to GitLab or Jira.
|
|
171
|
+
|
|
172
|
+
Install it with [skills](https://github.com/vercel-labs/skills):
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
npx skills add modern-python/release-scope
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
|
|
179
|
+
The skill runs `release-scope>=0.3,<0.4`, the range whose flags and report schema it describes.
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
|
|
9
|
+
[](https://pypi.org/project/release-scope/)
|
|
10
|
+
[](https://pypi.org/project/release-scope/)
|
|
11
|
+
[](https://pepy.tech/projects/release-scope)
|
|
12
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
13
|
+
[](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
|
|
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)
|
|
19
|
+
|
|
20
|
+
`release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
|
|
21
|
+
Jira issues, failed jobs.
|
|
22
|
+
|
|
23
|
+
For every service it reads the latest successful production deployment, walks the default branch down to that
|
|
24
|
+
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
|
|
25
|
+
point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
|
|
26
|
+
and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
|
|
27
|
+
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
|
|
28
|
+
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
|
|
29
|
+
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com
|
|
35
|
+
export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope
|
|
36
|
+
export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]'
|
|
37
|
+
export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod
|
|
38
|
+
|
|
39
|
+
uvx release-scope collect --group team/backend --output report.json --cache cache.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to
|
|
43
|
+
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
|
|
44
|
+
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
|
|
45
|
+
disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project
|
|
46
|
+
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
|
|
47
|
+
and also exits `1`; the GitLab part is still written.
|
|
48
|
+
|
|
49
|
+
## Jira issues
|
|
50
|
+
|
|
51
|
+
`--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run
|
|
58
|
+
from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service
|
|
59
|
+
records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is
|
|
60
|
+
needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge
|
|
61
|
+
requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way.
|
|
62
|
+
`--jira` is repeatable and cannot be combined with `--group` or `--project`; an issue Jira does not return exits `1`.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Every setting is an environment variable; nothing about a GitLab or Jira instance is built in.
|
|
67
|
+
|
|
68
|
+
| Variable | Default | Meaning |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `RELEASE_SCOPE_GITLAB__ENDPOINT` | `https://gitlab.com` | GitLab base URL |
|
|
71
|
+
| `RELEASE_SCOPE_GITLAB__TOKEN` or `GITLAB_TOKEN` | required | Token with `read_api` |
|
|
72
|
+
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
|
|
73
|
+
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
|
|
74
|
+
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
|
|
75
|
+
| `RELEASE_SCOPE_JIRA_TOKEN` or `JIRA_TOKEN` | unset | Jira Server/Data Center personal access token; when set, issues are fetched |
|
|
76
|
+
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
|
|
77
|
+
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
|
|
78
|
+
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
|
|
79
|
+
|
|
80
|
+
## Report
|
|
81
|
+
|
|
82
|
+
The report is versioned by `schema_version`; the models live in
|
|
83
|
+
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
|
|
84
|
+
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status
|
|
85
|
+
category, issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira
|
|
86
|
+
request failed. One row, trimmed:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"kind": "merge_request",
|
|
91
|
+
"tags": [{"name": "1.2.0", "url": "...", "pipeline": {"id": 201, "status": "success", "failed_jobs": []}}],
|
|
92
|
+
"merge_requests": [{"iid": 12, "title": "SHOP-12 new endpoint", "url": "..."}],
|
|
93
|
+
"commits": [{"sha": "c3...", "title": "Merge branch 'feature/SHOP-12'"}],
|
|
94
|
+
"jira_keys": [{"key": "SHOP-12", "url": "https://jira.example.com/browse/SHOP-12"}],
|
|
95
|
+
"environments": ["preview"],
|
|
96
|
+
"main_pipeline": {"id": 103, "status": "failed", "failed_jobs": [{"kind": "job", "name": "lint", "allow_failure": false}]}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Page
|
|
101
|
+
|
|
102
|
+
`render` turns a report into a Markdown page for a GitLab wiki, without calling GitLab:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
uvx release-scope collect --group team/backend --output report.json --cache cache.json; \
|
|
106
|
+
uvx release-scope render report.json --output report.md
|
|
107
|
+
```
|
|
108
|
+
|
|
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);
|
|
111
|
+
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
|
|
112
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
|
|
113
|
+
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
|
|
114
|
+
and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done.
|
|
115
|
+
A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to
|
|
116
|
+
the issues.
|
|
117
|
+
|
|
118
|
+
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
119
|
+
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
120
|
+
read the report or write the page.
|
|
121
|
+
|
|
122
|
+
## Cache
|
|
123
|
+
|
|
124
|
+
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
125
|
+
that do not change once settled: which merge requests a commit belongs to, a merged merge request, and the failed
|
|
126
|
+
jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Within each project
|
|
127
|
+
the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
|
|
128
|
+
group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
|
|
129
|
+
requests and never changes the report.
|
|
130
|
+
|
|
131
|
+
## Agent skill
|
|
132
|
+
|
|
133
|
+
[`skills/release-scope`](https://github.com/modern-python/release-scope/tree/main/skills/release-scope) is an agent
|
|
134
|
+
skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent
|
|
135
|
+
what in the current repository has not reached production, what a group will ship with the next tag, or whether a
|
|
136
|
+
Jira issue is released and which services it touches. For the current repository the skill takes `--project` from
|
|
137
|
+
the git remote. It keeps the report and cache outside the repository and never writes to GitLab or Jira.
|
|
138
|
+
|
|
139
|
+
Install it with [skills](https://github.com/vercel-labs/skills):
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npx skills add modern-python/release-scope
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
|
|
146
|
+
The skill runs `release-scope>=0.3,<0.4`, the range whose flags and report schema it describes.
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
|
+
import json
|
|
2
3
|
import pathlib
|
|
3
4
|
import typing
|
|
4
5
|
|
|
5
6
|
import modern_di_typer
|
|
6
|
-
import pydantic
|
|
7
7
|
import typer
|
|
8
8
|
|
|
9
9
|
from release_scope import ioc
|
|
10
10
|
from release_scope._cache import Cache
|
|
11
11
|
from release_scope._errors import ConfigError, ReleaseScopeError
|
|
12
12
|
from release_scope._files import write_text_atomic
|
|
13
|
+
from release_scope._jira_keys import JIRA_KEY_PATTERN
|
|
13
14
|
from release_scope._render import render_markdown
|
|
14
|
-
from release_scope._report import Report
|
|
15
|
+
from release_scope._report import SCHEMA_VERSION, Report
|
|
15
16
|
from release_scope._settings import Settings, load_settings
|
|
16
17
|
from release_scope._use_case import CollectUseCase
|
|
17
18
|
|
|
@@ -70,19 +71,25 @@ def _collect_command( # noqa: PLR0913, PLR0917
|
|
|
70
71
|
pathlib.Path | None,
|
|
71
72
|
typer.Option("--cache", help="Cache JSON; read if present, rewritten in place after the run."),
|
|
72
73
|
] = None,
|
|
74
|
+
jira: typing.Annotated[
|
|
75
|
+
list[str] | None,
|
|
76
|
+
typer.Option("--jira", "-j", help="Jira issue key; collect only the services it links to. Repeatable."),
|
|
77
|
+
] = None,
|
|
73
78
|
) -> None:
|
|
74
79
|
try:
|
|
75
|
-
|
|
76
|
-
msg = "Pass at least one --group or --project."
|
|
77
|
-
raise ConfigError(msg)
|
|
80
|
+
_check_selection(groups=group or [], projects=project or [], keys=jira or [])
|
|
78
81
|
settings = load_settings({})
|
|
79
82
|
modern_di_typer.fetch_di_container(ctx).set_context(Settings, settings)
|
|
80
83
|
cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
|
|
81
84
|
if cache_warning:
|
|
82
85
|
typer.echo(f"Warning: {cache_warning}", err=True)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
+
)
|
|
86
93
|
except ReleaseScopeError as err:
|
|
87
94
|
typer.echo(f"Error: {err}", err=True)
|
|
88
95
|
raise typer.Exit(code=err.exit_code) from err
|
|
@@ -95,18 +102,54 @@ def _collect_command( # noqa: PLR0913, PLR0917
|
|
|
95
102
|
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
|
|
96
103
|
for service in failed:
|
|
97
104
|
typer.echo(f"Error: {service.error}", err=True)
|
|
98
|
-
|
|
105
|
+
jira_errors: typing.Final = _jira_errors(report)
|
|
106
|
+
for message in jira_errors:
|
|
107
|
+
typer.echo(f"Error: {message}", err=True)
|
|
108
|
+
if failed or jira_errors:
|
|
99
109
|
raise typer.Exit(code=1)
|
|
100
110
|
|
|
101
111
|
|
|
112
|
+
def _check_selection(
|
|
113
|
+
*, groups: typing.Sequence[str], projects: typing.Sequence[str], keys: typing.Sequence[str]
|
|
114
|
+
) -> None:
|
|
115
|
+
if keys and (groups or projects):
|
|
116
|
+
msg = "Pass either --jira or --group/--project, not both."
|
|
117
|
+
raise ConfigError(msg)
|
|
118
|
+
if not keys and not groups and not projects:
|
|
119
|
+
msg = "Pass --jira, or at least one --group or --project."
|
|
120
|
+
raise ConfigError(msg)
|
|
121
|
+
for key in keys:
|
|
122
|
+
if not JIRA_KEY_PATTERN.fullmatch(key):
|
|
123
|
+
msg = f"Not a Jira issue key: {key}."
|
|
124
|
+
raise ConfigError(msg)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _jira_errors(report: Report) -> list[str]:
|
|
128
|
+
if report.jira is None:
|
|
129
|
+
return []
|
|
130
|
+
errors: typing.Final = [f"Jira has no issue {key}." for key in report.jira_scope if key in report.jira.missing]
|
|
131
|
+
if report.jira.error:
|
|
132
|
+
errors.append(report.jira.error)
|
|
133
|
+
return errors
|
|
134
|
+
|
|
135
|
+
|
|
102
136
|
@MAIN_APP.command("render", help="Render a JSON report as a Markdown page for a GitLab wiki.")
|
|
103
137
|
def _render_command(
|
|
104
138
|
report_path: typing.Annotated[pathlib.Path, typer.Argument(help="Report JSON written by `collect`.")],
|
|
105
139
|
output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the Markdown page.")],
|
|
106
140
|
) -> None:
|
|
107
141
|
try:
|
|
108
|
-
|
|
109
|
-
|
|
142
|
+
raw = json.loads(report_path.read_bytes())
|
|
143
|
+
version = raw.get("schema_version") if isinstance(raw, dict) else None
|
|
144
|
+
if isinstance(version, int) and version < SCHEMA_VERSION:
|
|
145
|
+
typer.echo(
|
|
146
|
+
f"Error: Cannot read report {report_path}: schema_version {version} is not supported; "
|
|
147
|
+
"run collect again.",
|
|
148
|
+
err=True,
|
|
149
|
+
)
|
|
150
|
+
raise typer.Exit(code=ConfigError.exit_code)
|
|
151
|
+
report = Report.model_validate(raw)
|
|
152
|
+
except (OSError, ValueError) as exc:
|
|
110
153
|
typer.echo(f"Error: Cannot read report {report_path}: {type(exc).__name__}.", err=True)
|
|
111
154
|
raise typer.Exit(code=ConfigError.exit_code) from exc
|
|
112
155
|
try:
|
|
@@ -11,6 +11,8 @@ from release_scope._report import FailedJob
|
|
|
11
11
|
|
|
12
12
|
CACHE_SCHEMA_VERSION: typing.Final = 1
|
|
13
13
|
|
|
14
|
+
_EntryT = typing.TypeVar("_EntryT")
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
class CachedPipeline(pydantic.BaseModel):
|
|
16
18
|
updated_at: str
|
|
@@ -21,12 +23,14 @@ class CacheData(pydantic.BaseModel):
|
|
|
21
23
|
schema_version: typing.Literal[1] = CACHE_SCHEMA_VERSION
|
|
22
24
|
commit_merge_requests: dict[str, dict[str, list[MergeRequest]]] = pydantic.Field(default_factory=dict)
|
|
23
25
|
pipelines: dict[str, dict[str, CachedPipeline]] = pydantic.Field(default_factory=dict)
|
|
26
|
+
merge_requests: dict[str, dict[str, MergeRequest]] = pydantic.Field(default_factory=dict)
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
@dataclasses.dataclass(slots=True, kw_only=True)
|
|
27
30
|
class Cache:
|
|
28
31
|
previous: CacheData = dataclasses.field(default_factory=CacheData)
|
|
29
32
|
current: CacheData = dataclasses.field(default_factory=CacheData)
|
|
33
|
+
visited: set[str] = dataclasses.field(default_factory=set)
|
|
30
34
|
|
|
31
35
|
@classmethod
|
|
32
36
|
def load(cls, path: pathlib.Path) -> tuple["Cache", str | None]:
|
|
@@ -38,7 +42,30 @@ class Cache:
|
|
|
38
42
|
return cls(), f"Ignoring unreadable cache {path}: {type(exc).__name__}."
|
|
39
43
|
|
|
40
44
|
def save(self, path: pathlib.Path) -> None:
|
|
41
|
-
write_text_atomic(path, self.
|
|
45
|
+
write_text_atomic(path, self.pruned().model_dump_json(indent=2))
|
|
46
|
+
|
|
47
|
+
def visit(self, project_id: int) -> None:
|
|
48
|
+
self.visited.add(str(project_id))
|
|
49
|
+
|
|
50
|
+
def pruned(self) -> CacheData:
|
|
51
|
+
return CacheData(
|
|
52
|
+
commit_merge_requests=self._unvisited(self.previous.commit_merge_requests)
|
|
53
|
+
| self.current.commit_merge_requests,
|
|
54
|
+
pipelines=self._unvisited(self.previous.pipelines) | self.current.pipelines,
|
|
55
|
+
merge_requests=self._unvisited(self.previous.merge_requests) | self.current.merge_requests,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _unvisited(self, entries: dict[str, _EntryT]) -> dict[str, _EntryT]:
|
|
59
|
+
return {project: entry for project, entry in entries.items() if project not in self.visited}
|
|
60
|
+
|
|
61
|
+
def get_merge_request(self, project_id: int, iid: int) -> MergeRequest | None:
|
|
62
|
+
cached = self.previous.merge_requests.get(str(project_id), {}).get(str(iid))
|
|
63
|
+
if cached is not None:
|
|
64
|
+
self.put_merge_request(project_id, cached)
|
|
65
|
+
return cached
|
|
66
|
+
|
|
67
|
+
def put_merge_request(self, project_id: int, merge_request: MergeRequest) -> None:
|
|
68
|
+
self.current.merge_requests.setdefault(str(project_id), {})[str(merge_request.iid)] = merge_request
|
|
42
69
|
|
|
43
70
|
def get_commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest] | None:
|
|
44
71
|
cached = self.previous.commit_merge_requests.get(str(project_id), {}).get(sha)
|
|
@@ -69,3 +96,7 @@ class Cache:
|
|
|
69
96
|
**self.previous.pipelines.get(key, {}),
|
|
70
97
|
**self.current.pipelines.get(key, {}),
|
|
71
98
|
}
|
|
99
|
+
self.current.merge_requests[key] = {
|
|
100
|
+
**self.previous.merge_requests.get(key, {}),
|
|
101
|
+
**self.current.merge_requests.get(key, {}),
|
|
102
|
+
}
|
|
@@ -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):
|
|
@@ -237,6 +238,11 @@ class GitLabApi:
|
|
|
237
238
|
)
|
|
238
239
|
return merge_requests
|
|
239
240
|
|
|
241
|
+
def get_merge_request(self, project_id: int, iid: int) -> MergeRequest:
|
|
242
|
+
return self._get(
|
|
243
|
+
f"{_API}/projects/{project_id}/merge_requests/{iid}", {}, MergeRequest, resource="merge_requests"
|
|
244
|
+
)
|
|
245
|
+
|
|
240
246
|
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
|
|
241
247
|
merge_requests, _ = self._pages(
|
|
242
248
|
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import collections.abc
|
|
2
|
+
import dataclasses
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import httpware
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
from release_scope._errors import JiraError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_SEARCH: typing.Final = "/rest/api/2/search"
|
|
12
|
+
_ISSUE: typing.Final = "/rest/api/2/issue"
|
|
13
|
+
_BATCH_SIZE: typing.Final = 100
|
|
14
|
+
_FIELDS: typing.Final = ("summary", "status", "issuetype")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StatusCategory(pydantic.BaseModel):
|
|
18
|
+
key: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Status(pydantic.BaseModel):
|
|
22
|
+
name: str
|
|
23
|
+
category: StatusCategory | None = pydantic.Field(default=None, alias="statusCategory")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IssueType(pydantic.BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IssueFields(pydantic.BaseModel):
|
|
31
|
+
summary: str
|
|
32
|
+
status: Status
|
|
33
|
+
issuetype: IssueType | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Issue(pydantic.BaseModel):
|
|
37
|
+
key: str
|
|
38
|
+
fields: IssueFields
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _SearchResults(pydantic.BaseModel):
|
|
42
|
+
total: int
|
|
43
|
+
issues: list[Issue]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RemoteObject(pydantic.BaseModel):
|
|
47
|
+
url: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RemoteLink(pydantic.BaseModel):
|
|
51
|
+
target: RemoteObject | None = pydantic.Field(default=None, alias="object")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _RemoteLinks(pydantic.RootModel[list[RemoteLink]]):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _error_messages(exc: httpware.StatusError) -> str:
|
|
59
|
+
try:
|
|
60
|
+
payload: typing.Final = exc.response.json()
|
|
61
|
+
except ValueError:
|
|
62
|
+
return ""
|
|
63
|
+
messages: typing.Final = payload.get("errorMessages") if isinstance(payload, dict) else None
|
|
64
|
+
if not isinstance(messages, list):
|
|
65
|
+
return ""
|
|
66
|
+
return " ".join(str(message) for message in messages)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _translate(exc: httpware.ClientError, *, target: str) -> JiraError:
|
|
70
|
+
if isinstance(exc, httpware.UnauthorizedError):
|
|
71
|
+
return JiraError("Jira rejected the token (401). Check that it is valid and not expired.")
|
|
72
|
+
if isinstance(exc, httpware.StatusError):
|
|
73
|
+
status: typing.Final = exc.response.status_code
|
|
74
|
+
details: typing.Final = _error_messages(exc)
|
|
75
|
+
suffix: typing.Final = f": {details.rstrip('.')}." if details else "."
|
|
76
|
+
return JiraError(f"Jira returned {status} for {target}{suffix}")
|
|
77
|
+
return JiraError(f"Jira request for {target} failed: {type(exc).__name__}.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _batches(keys: collections.abc.Sequence[str]) -> collections.abc.Iterator[collections.abc.Sequence[str]]:
|
|
81
|
+
for start in range(0, len(keys), _BATCH_SIZE):
|
|
82
|
+
yield keys[start : start + _BATCH_SIZE]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
86
|
+
class JiraApi:
|
|
87
|
+
http: httpware.Client
|
|
88
|
+
|
|
89
|
+
def search_issues(self, keys: collections.abc.Sequence[str]) -> list[Issue]:
|
|
90
|
+
issues: typing.Final[list[Issue]] = []
|
|
91
|
+
for batch in _batches(keys):
|
|
92
|
+
quoted = ", ".join(f'"{key}"' for key in batch)
|
|
93
|
+
issues.extend(self._search(f"key in ({quoted})"))
|
|
94
|
+
return issues
|
|
95
|
+
|
|
96
|
+
def _search(self, jql: str) -> list[Issue]:
|
|
97
|
+
issues: typing.Final[list[Issue]] = []
|
|
98
|
+
while True:
|
|
99
|
+
body = {
|
|
100
|
+
"jql": jql,
|
|
101
|
+
"fields": list(_FIELDS),
|
|
102
|
+
"startAt": len(issues),
|
|
103
|
+
"maxResults": _BATCH_SIZE,
|
|
104
|
+
"validateQuery": False,
|
|
105
|
+
}
|
|
106
|
+
try:
|
|
107
|
+
page = self.http.post(_SEARCH, json=body, response_model=_SearchResults)
|
|
108
|
+
except httpware.ClientError as exc:
|
|
109
|
+
raise _translate(exc, target="the issue search") from exc
|
|
110
|
+
issues.extend(page.issues)
|
|
111
|
+
if not page.issues or len(issues) >= page.total:
|
|
112
|
+
return issues
|
|
113
|
+
|
|
114
|
+
def remote_links(self, key: str) -> list[RemoteLink]:
|
|
115
|
+
try:
|
|
116
|
+
return self.http.get(f"{_ISSUE}/{key}/remotelink", response_model=_RemoteLinks).root
|
|
117
|
+
except httpware.ClientError as exc:
|
|
118
|
+
raise _translate(exc, target=f"the remote links of {key}") from exc
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
from release_scope._report import LinkedChange
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_CHANGE_PATH: typing.Final = re.compile(
|
|
8
|
+
r"(?P<project>[^?#]+?)/-/(?:merge_requests/(?P<iid>\d+)|commit/(?P<sha>[0-9a-fA-F]{7,64}))(?:[/?#].*)?"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_gitlab_link(url: str | None, gitlab_endpoint: str) -> LinkedChange | None:
|
|
13
|
+
base: typing.Final = gitlab_endpoint.rstrip("/") + "/"
|
|
14
|
+
if not url or not url.startswith(base):
|
|
15
|
+
return None
|
|
16
|
+
match: typing.Final = _CHANGE_PATH.fullmatch(url.removeprefix(base))
|
|
17
|
+
if match is None:
|
|
18
|
+
return None
|
|
19
|
+
project: typing.Final = match["project"]
|
|
20
|
+
iid: typing.Final = match["iid"]
|
|
21
|
+
return LinkedChange(
|
|
22
|
+
kind="merge_request" if iid else "commit",
|
|
23
|
+
project=project,
|
|
24
|
+
project_url=base + project,
|
|
25
|
+
url=url,
|
|
26
|
+
iid=int(iid) if iid else None,
|
|
27
|
+
sha=match["sha"],
|
|
28
|
+
)
|