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.
Files changed (22) hide show
  1. {release_scope-0.2.0 → release_scope-0.3.1}/PKG-INFO +69 -12
  2. release_scope-0.3.1/README.md +146 -0
  3. {release_scope-0.2.0 → release_scope-0.3.1}/pyproject.toml +1 -1
  4. {release_scope-0.2.0 → release_scope-0.3.1}/pyproject.toml.orig +1 -1
  5. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/__main__.py +54 -11
  6. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_cache.py +32 -1
  7. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_errors.py +4 -0
  8. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_gitlab.py +6 -0
  9. release_scope-0.3.1/release_scope/_jira.py +118 -0
  10. release_scope-0.3.1/release_scope/_links.py +28 -0
  11. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_render.py +109 -21
  12. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_report.py +38 -2
  13. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_settings.py +8 -0
  14. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_use_case.py +219 -36
  15. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/ioc.py +33 -3
  16. release_scope-0.2.0/README.md +0 -89
  17. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/__init__.py +0 -0
  18. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_files.py +0 -0
  19. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_jira_keys.py +0 -0
  20. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_messages.py +0 -0
  21. {release_scope-0.2.0 → release_scope-0.3.1}/release_scope/_rows.py +0 -0
  22. {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.2.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
  [![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
- Jira keys, failed jobs.
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
- One row, trimmed:
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, where the change is
110
- deployed, and the failed jobs of its main-branch and tag pipelines.
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, and the failed jobs of a finished
120
- pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Entries the run did not use are
121
- dropped. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves requests and
122
- never changes the report.
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
+ [![PyPI version](https://img.shields.io/pypi/v/release-scope.svg)](https://pypi.org/project/release-scope/)
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)
12
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/modern-python/release-scope/actions/workflows/ci.yml)
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)
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)
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.
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.2.0"
28
+ version = "0.3.1"
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.2.0"
20
+ version = "0.3.1"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -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
- if not group and not project:
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
- report = _resolve_use_case(ctx=ctx)(
84
- groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache
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
- if failed:
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
- report = Report.model_validate_json(report_path.read_bytes())
109
- except (OSError, pydantic.ValidationError) as exc:
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.current.model_dump_json(indent=2))
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
+ }
@@ -21,3 +21,7 @@ class GitLabError(ReleaseScopeError):
21
21
  self.resource = resource
22
22
  self.status = status
23
23
  self.reason = reason
24
+
25
+
26
+ class JiraError(ReleaseScopeError):
27
+ pass
@@ -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
+ )