release-scope 0.2.0__tar.gz → 0.3.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.2.0 → release_scope-0.3.0}/PKG-INFO +37 -11
- {release_scope-0.2.0 → release_scope-0.3.0}/README.md +36 -10
- {release_scope-0.2.0 → release_scope-0.3.0}/pyproject.toml +1 -1
- {release_scope-0.2.0 → release_scope-0.3.0}/pyproject.toml.orig +1 -1
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/__main__.py +54 -11
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_cache.py +32 -1
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_errors.py +4 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_gitlab.py +5 -0
- release_scope-0.3.0/release_scope/_jira.py +118 -0
- release_scope-0.3.0/release_scope/_links.py +28 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_render.py +94 -20
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_report.py +37 -2
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_settings.py +8 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_use_case.py +218 -36
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/ioc.py +33 -3
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/__init__.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_files.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_jira_keys.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_messages.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_rows.py +0 -0
- {release_scope-0.2.0 → release_scope-0.3.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.3.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
|
|
@@ -38,12 +38,15 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
[](https://github.com/modern-python/release-scope/blob/main/LICENSE)
|
|
39
39
|
|
|
40
40
|
`release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
|
|
41
|
-
Jira
|
|
41
|
+
Jira issues, failed jobs.
|
|
42
42
|
|
|
43
43
|
For every service it reads the latest successful production deployment, walks the default branch down to that
|
|
44
44
|
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
|
|
45
45
|
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.
|
|
46
|
+
and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
|
|
47
|
+
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
|
|
48
|
+
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
|
|
49
|
+
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
|
|
47
50
|
|
|
48
51
|
## Quickstart
|
|
49
52
|
|
|
@@ -60,7 +63,23 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
60
63
|
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
|
|
61
64
|
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
|
|
62
65
|
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.
|
|
66
|
+
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
|
|
67
|
+
and also exits `1`; the GitLab part is still written.
|
|
68
|
+
|
|
69
|
+
## Jira issues
|
|
70
|
+
|
|
71
|
+
`--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run
|
|
78
|
+
from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service
|
|
79
|
+
records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is
|
|
80
|
+
needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge
|
|
81
|
+
requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way.
|
|
82
|
+
`--jira` is repeatable and cannot be combined with `--group` or `--project`; an issue Jira does not return exits `1`.
|
|
64
83
|
|
|
65
84
|
## Configuration
|
|
66
85
|
|
|
@@ -73,6 +92,7 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
73
92
|
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
|
|
74
93
|
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
|
|
75
94
|
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
|
|
95
|
+
| `RELEASE_SCOPE_JIRA_TOKEN` or `JIRA_TOKEN` | unset | Jira Server/Data Center personal access token; when set, issues are fetched |
|
|
76
96
|
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
|
|
77
97
|
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
|
|
78
98
|
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
|
|
@@ -81,7 +101,9 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
81
101
|
|
|
82
102
|
The report is versioned by `schema_version`; the models live in
|
|
83
103
|
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
|
|
84
|
-
|
|
104
|
+
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status
|
|
105
|
+
category, issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira
|
|
106
|
+
request failed. One row, trimmed:
|
|
85
107
|
|
|
86
108
|
```json
|
|
87
109
|
{
|
|
@@ -106,8 +128,11 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
106
128
|
|
|
107
129
|
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
|
|
108
130
|
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
|
|
131
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
|
|
132
|
+
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
|
|
133
|
+
and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done.
|
|
134
|
+
A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to
|
|
135
|
+
the issues.
|
|
111
136
|
|
|
112
137
|
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
113
138
|
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
@@ -116,7 +141,8 @@ read the report or write the page.
|
|
|
116
141
|
## Cache
|
|
117
142
|
|
|
118
143
|
`--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
|
-
|
|
144
|
+
that do not change once settled: which merge requests a commit belongs to, a merged merge request, and the failed
|
|
145
|
+
jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Within each project
|
|
146
|
+
the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
|
|
147
|
+
group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
|
|
148
|
+
requests and never changes the report.
|
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
[](https://github.com/modern-python/release-scope/blob/main/LICENSE)
|
|
6
6
|
|
|
7
7
|
`release-scope` collects what sits between production and the default branch across GitLab services: tags, MRs,
|
|
8
|
-
Jira
|
|
8
|
+
Jira issues, failed jobs.
|
|
9
9
|
|
|
10
10
|
For every service it reads the latest successful production deployment, walks the default branch down to that
|
|
11
11
|
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
|
|
12
12
|
point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
|
|
13
|
-
and tag pipelines.
|
|
13
|
+
and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
|
|
14
|
+
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
|
|
15
|
+
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
|
|
16
|
+
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
|
|
14
17
|
|
|
15
18
|
## Quickstart
|
|
16
19
|
|
|
@@ -27,7 +30,23 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
27
30
|
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
|
|
28
31
|
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
|
|
29
32
|
disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project
|
|
30
|
-
passed on the command line that the token cannot see, stops the run.
|
|
33
|
+
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
|
|
34
|
+
and also exits `1`; the GitLab part is still written.
|
|
35
|
+
|
|
36
|
+
## Jira issues
|
|
37
|
+
|
|
38
|
+
`--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run
|
|
45
|
+
from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service
|
|
46
|
+
records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is
|
|
47
|
+
needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge
|
|
48
|
+
requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way.
|
|
49
|
+
`--jira` is repeatable and cannot be combined with `--group` or `--project`; an issue Jira does not return exits `1`.
|
|
31
50
|
|
|
32
51
|
## Configuration
|
|
33
52
|
|
|
@@ -40,6 +59,7 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
40
59
|
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
|
|
41
60
|
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
|
|
42
61
|
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
|
|
62
|
+
| `RELEASE_SCOPE_JIRA_TOKEN` or `JIRA_TOKEN` | unset | Jira Server/Data Center personal access token; when set, issues are fetched |
|
|
43
63
|
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
|
|
44
64
|
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
|
|
45
65
|
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
|
|
@@ -48,7 +68,9 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
|
|
|
48
68
|
|
|
49
69
|
The report is versioned by `schema_version`; the models live in
|
|
50
70
|
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
|
|
51
|
-
|
|
71
|
+
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status
|
|
72
|
+
category, issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira
|
|
73
|
+
request failed. One row, trimmed:
|
|
52
74
|
|
|
53
75
|
```json
|
|
54
76
|
{
|
|
@@ -73,8 +95,11 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
|
|
|
73
95
|
|
|
74
96
|
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
|
|
75
97
|
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
|
|
76
|
-
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys
|
|
77
|
-
deployed, and the failed jobs of its main-branch
|
|
98
|
+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
|
|
99
|
+
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
|
|
100
|
+
and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done.
|
|
101
|
+
A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to
|
|
102
|
+
the issues.
|
|
78
103
|
|
|
79
104
|
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
|
|
80
105
|
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
|
|
@@ -83,7 +108,8 @@ read the report or write the page.
|
|
|
83
108
|
## Cache
|
|
84
109
|
|
|
85
110
|
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
|
|
86
|
-
that do not change once settled: which merge requests a commit belongs to,
|
|
87
|
-
pipeline keyed by its `updated_at`, so a retried job invalidates the entry.
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
that do not change once settled: which merge requests a commit belongs to, a merged merge request, and the failed
|
|
112
|
+
jobs of a finished pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Within each project
|
|
113
|
+
the run collected, entries it did not use are dropped; other projects keep theirs, so one cache file serves both
|
|
114
|
+
group and `--jira` runs. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves
|
|
115
|
+
requests and never changes the report.
|
|
@@ -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
|
+
}
|
|
@@ -237,6 +237,11 @@ class GitLabApi:
|
|
|
237
237
|
)
|
|
238
238
|
return merge_requests
|
|
239
239
|
|
|
240
|
+
def get_merge_request(self, project_id: int, iid: int) -> MergeRequest:
|
|
241
|
+
return self._get(
|
|
242
|
+
f"{_API}/projects/{project_id}/merge_requests/{iid}", {}, MergeRequest, resource="merge_requests"
|
|
243
|
+
)
|
|
244
|
+
|
|
240
245
|
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
|
|
241
246
|
merge_requests, _ = self._pages(
|
|
242
247
|
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
|
+
)
|
|
@@ -3,7 +3,18 @@ import datetime
|
|
|
3
3
|
import html
|
|
4
4
|
import typing
|
|
5
5
|
|
|
6
|
-
from release_scope._report import
|
|
6
|
+
from release_scope._report import (
|
|
7
|
+
EnvironmentState,
|
|
8
|
+
FailedJob,
|
|
9
|
+
JiraIssue,
|
|
10
|
+
JiraKeyRef,
|
|
11
|
+
JiraState,
|
|
12
|
+
PipelineState,
|
|
13
|
+
Report,
|
|
14
|
+
Row,
|
|
15
|
+
Service,
|
|
16
|
+
TagRef,
|
|
17
|
+
)
|
|
7
18
|
|
|
8
19
|
|
|
9
20
|
_STATUS_ICONS: typing.Final = {
|
|
@@ -22,8 +33,9 @@ _STATUS_ICONS: typing.Final = {
|
|
|
22
33
|
_LEGEND: typing.Final = (
|
|
23
34
|
"Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure"
|
|
24
35
|
)
|
|
25
|
-
_ROW_HEADER: typing.Final = ("Tag", "Change", "Jira", "Deployed to", "Failed jobs")
|
|
26
36
|
_FAILED, _SKIPPED, _PENDING = 0, 1, 2
|
|
37
|
+
_UNRELEASED: typing.Final = {"not_merged": "⏳ not merged", "not_found": "⚠️ linked change not found"}
|
|
38
|
+
_LINKED: typing.Final = "🎯"
|
|
27
39
|
|
|
28
40
|
|
|
29
41
|
def _inline(value: str) -> str:
|
|
@@ -69,6 +81,8 @@ def _state(service: Service) -> int | None:
|
|
|
69
81
|
return _FAILED
|
|
70
82
|
if service.rows:
|
|
71
83
|
return _PENDING
|
|
84
|
+
if service.release and service.release.state in _UNRELEASED:
|
|
85
|
+
return _SKIPPED
|
|
72
86
|
return _SKIPPED if service.warnings else None
|
|
73
87
|
|
|
74
88
|
|
|
@@ -99,19 +113,30 @@ def _failure_counts(service: Service) -> str:
|
|
|
99
113
|
|
|
100
114
|
def _pending(service: Service) -> str:
|
|
101
115
|
state: typing.Final = _state(service)
|
|
116
|
+
release: typing.Final = service.release
|
|
102
117
|
if state == _FAILED:
|
|
103
118
|
return "❌ failed to collect"
|
|
104
119
|
if state == _SKIPPED:
|
|
105
|
-
return "⚠️ see below"
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
return _UNRELEASED.get(release.state, "⚠️ see below") if release else "⚠️ see below"
|
|
121
|
+
if release:
|
|
122
|
+
suffix = f" · release {_tag(release.tag)}" if release.tag else " · needs a new tag"
|
|
123
|
+
else:
|
|
124
|
+
suffix = "" if service.rows[0].tags else " · untagged head"
|
|
125
|
+
return f"{_plural(len(service.rows), 'change')}{suffix}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str:
|
|
129
|
+
keys: typing.Final = {key.key for row in service.rows for key in row.jira_keys}
|
|
130
|
+
count: typing.Final = sum(1 for key in keys if key in issues and issues[key].status_category != "done")
|
|
131
|
+
return f"{count} not done" if count else ""
|
|
108
132
|
|
|
109
133
|
|
|
110
|
-
def _summary_row(service: Service, environments: list[str]) -> list[str]:
|
|
134
|
+
def _summary_row(service: Service, environments: list[str], jira: JiraState | None) -> list[str]:
|
|
111
135
|
return [
|
|
112
136
|
_link(_cell(service.project), service.project_url),
|
|
113
137
|
*(_environment_ref(_environment(service, name)) for name in environments),
|
|
114
138
|
_pending(service),
|
|
139
|
+
*([_not_done(service, jira.issues)] if jira else []),
|
|
115
140
|
_failure_counts(service),
|
|
116
141
|
]
|
|
117
142
|
|
|
@@ -161,11 +186,30 @@ def _failed_jobs(row: Row) -> str:
|
|
|
161
186
|
return "<br>".join(parts)
|
|
162
187
|
|
|
163
188
|
|
|
164
|
-
def
|
|
189
|
+
def _jira_key(key: JiraKeyRef, issues: dict[str, JiraIssue]) -> str:
|
|
190
|
+
text: typing.Final = _link(_cell(key.key), key.url)
|
|
191
|
+
issue: typing.Final = issues.get(key.key)
|
|
192
|
+
return f"{text} {_cell(issue.summary)} · {_cell(issue.status)}" if issue else text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _related_services(row: Row, project: str, issues: dict[str, JiraIssue]) -> str:
|
|
196
|
+
related: typing.Final = {
|
|
197
|
+
link.project: link.project_url
|
|
198
|
+
for key in row.jira_keys
|
|
199
|
+
if key.key in issues
|
|
200
|
+
for link in issues[key.key].links
|
|
201
|
+
if link.project != project
|
|
202
|
+
}
|
|
203
|
+
return ", ".join(_link(_cell(name), related[name]) for name in sorted(related))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _row(row: Row, project: str, jira: JiraState | None) -> list[str]:
|
|
207
|
+
issues: typing.Final = jira.issues if jira else {}
|
|
165
208
|
return [
|
|
166
209
|
"<br>".join(_tag(tag) for tag in row.tags),
|
|
167
|
-
_merge_requests_or_commits(row),
|
|
168
|
-
"
|
|
210
|
+
f"{_LINKED} {_merge_requests_or_commits(row)}" if row.linked else _merge_requests_or_commits(row),
|
|
211
|
+
"<br>".join(_jira_key(key, issues) for key in row.jira_keys),
|
|
212
|
+
*([_related_services(row, project, issues)] if jira else []),
|
|
169
213
|
", ".join(_cell(name) for name in row.environments),
|
|
170
214
|
_failed_jobs(row),
|
|
171
215
|
]
|
|
@@ -179,7 +223,7 @@ def _counts(service: Service) -> str:
|
|
|
179
223
|
)
|
|
180
224
|
|
|
181
225
|
|
|
182
|
-
def _rows_section(service: Service, production: str) -> list[str]:
|
|
226
|
+
def _rows_section(service: Service, production: str, jira: JiraState | None) -> list[str]:
|
|
183
227
|
ordered: typing.Final = sorted(service.environments, key=lambda item: item.name != production)
|
|
184
228
|
environments: typing.Final = [f"{_cell(item.name)} {_environment_ref(item)}" for item in ordered]
|
|
185
229
|
production_state: typing.Final = _environment(service, production)
|
|
@@ -189,35 +233,59 @@ def _rows_section(service: Service, production: str) -> list[str]:
|
|
|
189
233
|
"",
|
|
190
234
|
*_collapsed(
|
|
191
235
|
f"{_plural(len(service.rows), 'change')}{since}",
|
|
192
|
-
_table(
|
|
236
|
+
_table(
|
|
237
|
+
["Tag", "Change", "Jira", *(["Related services"] if jira else []), "Deployed to", "Failed jobs"],
|
|
238
|
+
(_row(row, service.project, jira) for row in service.rows),
|
|
239
|
+
),
|
|
193
240
|
),
|
|
194
241
|
]
|
|
195
242
|
|
|
196
243
|
|
|
197
|
-
def _service_section(service: Service, production: str) -> list[str]:
|
|
244
|
+
def _service_section(service: Service, production: str, jira: JiraState | None) -> list[str]:
|
|
198
245
|
lines: typing.Final = [f"## {_inline(service.project)}", ""]
|
|
199
246
|
if service.error:
|
|
200
247
|
lines.extend([f"❌ {_inline(service.error)}", ""])
|
|
201
248
|
lines.extend(line for warning in service.warnings for line in (f"⚠️ {_inline(warning)}", ""))
|
|
249
|
+
release: typing.Final = service.release
|
|
250
|
+
if release and release.pending_merge_requests:
|
|
251
|
+
waiting: typing.Final = ", ".join(
|
|
252
|
+
_reference(f"!{item.iid}", item.url, item.title, f"@{item.author}" if item.author else None)
|
|
253
|
+
for item in release.pending_merge_requests
|
|
254
|
+
)
|
|
255
|
+
lines.extend([f"⏳ Not merged: {waiting}", ""])
|
|
256
|
+
if release and release.state == "not_found":
|
|
257
|
+
lines.extend(["⚠️ No linked change was found between production and the head of the default branch.", ""])
|
|
202
258
|
if service.rows:
|
|
203
|
-
lines.extend(_rows_section(service, production))
|
|
259
|
+
lines.extend(_rows_section(service, production, jira))
|
|
204
260
|
return lines
|
|
205
261
|
|
|
206
262
|
|
|
263
|
+
def _scope_issue(key: str, jira: JiraState | None) -> str:
|
|
264
|
+
issue: typing.Final = jira.issues.get(key) if jira else None
|
|
265
|
+
if issue is None:
|
|
266
|
+
return f"- {_inline(key)} · not found in Jira"
|
|
267
|
+
return f"- {_link(_inline(key), issue.url)} {_cell(issue.summary)} · {_cell(issue.status)}"
|
|
268
|
+
|
|
269
|
+
|
|
207
270
|
def render_markdown(report: Report) -> str:
|
|
208
271
|
production: typing.Final = report.production_environment
|
|
209
272
|
collected: typing.Final = report.collected_at.astimezone(datetime.UTC).strftime("%Y-%m-%d %H:%M")
|
|
273
|
+
jira: typing.Final = report.jira
|
|
274
|
+
scope: typing.Final = report.jira_scope
|
|
210
275
|
lines: typing.Final = [
|
|
211
|
-
"# Release scope",
|
|
276
|
+
f"# Release scope: {', '.join(_inline(key) for key in scope)}" if scope else "# Release scope",
|
|
212
277
|
"",
|
|
213
278
|
(
|
|
214
279
|
f"Collected {collected} UTC. Changes run from the commit on `{_inline(production)}` "
|
|
215
|
-
"to the head of the default branch."
|
|
280
|
+
+ ("to the latest change linked to the issues." if scope else "to the head of the default branch.")
|
|
216
281
|
),
|
|
217
282
|
"",
|
|
218
|
-
_LEGEND,
|
|
219
|
-
"",
|
|
220
283
|
]
|
|
284
|
+
if scope:
|
|
285
|
+
lines.extend([*(_scope_issue(key, jira) for key in scope), ""])
|
|
286
|
+
lines.extend([f"{_LEGEND} · {_LINKED} linked to the issues" if scope else _LEGEND, ""])
|
|
287
|
+
if jira and jira.error:
|
|
288
|
+
lines.extend([f"❌ {_inline(jira.error)}", ""])
|
|
221
289
|
if not report.services:
|
|
222
290
|
lines.append("No services were collected.")
|
|
223
291
|
return "\n".join(lines) + "\n"
|
|
@@ -229,8 +297,14 @@ def render_markdown(report: Report) -> str:
|
|
|
229
297
|
up_to_date: typing.Final = [service for service in report.services if _state(service) is None]
|
|
230
298
|
environments: typing.Final = _environment_names(report)
|
|
231
299
|
if attention:
|
|
232
|
-
header: typing.Final = [
|
|
233
|
-
|
|
300
|
+
header: typing.Final = [
|
|
301
|
+
"Service",
|
|
302
|
+
*(_cell(name) for name in environments),
|
|
303
|
+
"Pending",
|
|
304
|
+
*(["Jira"] if jira else []),
|
|
305
|
+
"Failed jobs",
|
|
306
|
+
]
|
|
307
|
+
lines.extend([*_table(header, (_summary_row(service, environments, jira) for service in attention)), ""])
|
|
234
308
|
else:
|
|
235
309
|
lines.extend(["All services are up to date.", ""])
|
|
236
310
|
if up_to_date:
|
|
@@ -247,5 +321,5 @@ def render_markdown(report: Report) -> str:
|
|
|
247
321
|
)
|
|
248
322
|
)
|
|
249
323
|
for service in attention:
|
|
250
|
-
lines.extend(_service_section(service, production))
|
|
324
|
+
lines.extend(_service_section(service, production, jira))
|
|
251
325
|
return "\n".join(lines).rstrip("\n") + "\n"
|
|
@@ -4,7 +4,7 @@ import typing
|
|
|
4
4
|
import pydantic
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
SCHEMA_VERSION: typing.Final =
|
|
7
|
+
SCHEMA_VERSION: typing.Final = 2
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class FailedJob(pydantic.BaseModel):
|
|
@@ -61,6 +61,7 @@ class Row(pydantic.BaseModel):
|
|
|
61
61
|
jira_keys: list[JiraKeyRef]
|
|
62
62
|
environments: list[str]
|
|
63
63
|
main_pipeline: PipelineState | None
|
|
64
|
+
linked: bool = False
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
class EnvironmentState(pydantic.BaseModel):
|
|
@@ -71,6 +72,12 @@ class EnvironmentState(pydantic.BaseModel):
|
|
|
71
72
|
deployment_url: str | None
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
class Release(pydantic.BaseModel):
|
|
76
|
+
state: typing.Literal["pending", "in_production", "not_merged", "not_found"]
|
|
77
|
+
tag: TagRef | None = None
|
|
78
|
+
pending_merge_requests: list[MergeRequestRef] = pydantic.Field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
74
81
|
class Service(pydantic.BaseModel):
|
|
75
82
|
project: str
|
|
76
83
|
project_url: str
|
|
@@ -79,10 +86,38 @@ class Service(pydantic.BaseModel):
|
|
|
79
86
|
rows: list[Row] = pydantic.Field(default_factory=list)
|
|
80
87
|
warnings: list[str] = pydantic.Field(default_factory=list)
|
|
81
88
|
error: str | None = None
|
|
89
|
+
release: Release | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LinkedChange(pydantic.BaseModel):
|
|
93
|
+
kind: typing.Literal["merge_request", "commit"]
|
|
94
|
+
project: str
|
|
95
|
+
project_url: str
|
|
96
|
+
url: str
|
|
97
|
+
iid: int | None = None
|
|
98
|
+
sha: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class JiraIssue(pydantic.BaseModel):
|
|
102
|
+
key: str
|
|
103
|
+
summary: str
|
|
104
|
+
status: str
|
|
105
|
+
status_category: str | None
|
|
106
|
+
issue_type: str | None
|
|
107
|
+
url: str | None = None
|
|
108
|
+
links: list[LinkedChange] = pydantic.Field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class JiraState(pydantic.BaseModel):
|
|
112
|
+
issues: dict[str, JiraIssue] = pydantic.Field(default_factory=dict)
|
|
113
|
+
missing: list[str] = pydantic.Field(default_factory=list)
|
|
114
|
+
error: str | None = None
|
|
82
115
|
|
|
83
116
|
|
|
84
117
|
class Report(pydantic.BaseModel):
|
|
85
|
-
schema_version: typing.Literal[
|
|
118
|
+
schema_version: typing.Literal[2] = SCHEMA_VERSION
|
|
86
119
|
collected_at: datetime.datetime
|
|
87
120
|
production_environment: str
|
|
88
121
|
services: list[Service]
|
|
122
|
+
jira: JiraState | None = None
|
|
123
|
+
jira_scope: list[str] = pydantic.Field(default_factory=list)
|
|
@@ -27,10 +27,15 @@ class Settings(pydantic_settings.BaseSettings):
|
|
|
27
27
|
env_nested_delimiter="__",
|
|
28
28
|
case_sensitive=False,
|
|
29
29
|
extra="ignore",
|
|
30
|
+
populate_by_name=True,
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
gitlab: GitLabConfig = pydantic.Field(default_factory=GitLabConfig)
|
|
33
34
|
jira_endpoint: str | None = None
|
|
35
|
+
jira_token: pydantic.SecretStr = pydantic.Field(
|
|
36
|
+
default=pydantic.SecretStr(""),
|
|
37
|
+
validation_alias=pydantic.AliasChoices("RELEASE_SCOPE_JIRA_TOKEN", "JIRA_TOKEN"),
|
|
38
|
+
)
|
|
34
39
|
jira_project_keys: list[str] = pydantic.Field(default_factory=list)
|
|
35
40
|
environments: list[str] = pydantic.Field(default_factory=lambda: ["production"])
|
|
36
41
|
production_environment: str = "production"
|
|
@@ -53,4 +58,7 @@ def load_settings(overrides: dict[str, typing.Any]) -> Settings:
|
|
|
53
58
|
if not settings.gitlab.token.get_secret_value():
|
|
54
59
|
msg = "GitLab token is missing. Set RELEASE_SCOPE_GITLAB__TOKEN or GITLAB_TOKEN."
|
|
55
60
|
raise ConfigError(msg)
|
|
61
|
+
if settings.jira_token.get_secret_value() and not settings.jira_endpoint:
|
|
62
|
+
msg = "Jira token is set but RELEASE_SCOPE_JIRA_ENDPOINT is not."
|
|
63
|
+
raise ConfigError(msg)
|
|
56
64
|
return settings
|
|
@@ -6,17 +6,23 @@ import typing
|
|
|
6
6
|
from urllib.parse import quote
|
|
7
7
|
|
|
8
8
|
from release_scope._cache import Cache, CachedPipeline
|
|
9
|
-
from release_scope._errors import AuthError, GitLabError
|
|
9
|
+
from release_scope._errors import AuthError, ConfigError, GitLabError, JiraError
|
|
10
10
|
from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project
|
|
11
|
+
from release_scope._jira import JiraApi
|
|
11
12
|
from release_scope._jira_keys import extract_jira_keys
|
|
13
|
+
from release_scope._links import parse_gitlab_link
|
|
12
14
|
from release_scope._messages import explain_failure, skip_reason
|
|
13
15
|
from release_scope._report import (
|
|
14
16
|
CommitRef,
|
|
15
17
|
EnvironmentState,
|
|
16
18
|
FailedJob,
|
|
19
|
+
JiraIssue,
|
|
17
20
|
JiraKeyRef,
|
|
21
|
+
JiraState,
|
|
22
|
+
LinkedChange,
|
|
18
23
|
MergeRequestRef,
|
|
19
24
|
PipelineState,
|
|
25
|
+
Release,
|
|
20
26
|
Report,
|
|
21
27
|
Row,
|
|
22
28
|
Service,
|
|
@@ -70,9 +76,35 @@ def _commit_ref(commit: Commit) -> CommitRef:
|
|
|
70
76
|
)
|
|
71
77
|
|
|
72
78
|
|
|
79
|
+
def _row_keys(services: list[Service]) -> list[str]:
|
|
80
|
+
return sorted({key.key for service in services for row in service.rows for key in row.jira_keys})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclasses.dataclass(slots=True, kw_only=True)
|
|
84
|
+
class _Walk:
|
|
85
|
+
truncated: bool
|
|
86
|
+
drafts: list[RowDraft] = dataclasses.field(default_factory=list)
|
|
87
|
+
tags_by_sha: dict[str, list[str]] = dataclasses.field(default_factory=dict)
|
|
88
|
+
main_pipelines: dict[str, Pipeline] = dataclasses.field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
92
|
+
class _LinkTarget:
|
|
93
|
+
merged: set[int]
|
|
94
|
+
shas: list[str]
|
|
95
|
+
pending: list[MergeRequestRef]
|
|
96
|
+
warnings: list[str]
|
|
97
|
+
|
|
98
|
+
def matches(self, draft: RowDraft) -> bool:
|
|
99
|
+
return any(item.iid in self.merged for item in draft.merge_requests) or any(
|
|
100
|
+
commit.id.startswith(sha) for commit in draft.commits for sha in self.shas
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
73
104
|
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
74
105
|
class CollectUseCase:
|
|
75
106
|
api: GitLabApi
|
|
107
|
+
jira: JiraApi | None
|
|
76
108
|
settings: Settings
|
|
77
109
|
|
|
78
110
|
def __call__(
|
|
@@ -83,25 +115,98 @@ class CollectUseCase:
|
|
|
83
115
|
include_subgroups: bool,
|
|
84
116
|
cache: Cache,
|
|
85
117
|
) -> Report:
|
|
86
|
-
services: typing.Final = [
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
services: typing.Final = [
|
|
119
|
+
self._collect_or_explain(project, cache, links=None)
|
|
120
|
+
for project in self._resolve_projects(groups=groups, projects=projects, include_subgroups=include_subgroups)
|
|
121
|
+
]
|
|
122
|
+
return Report(
|
|
123
|
+
collected_at=datetime.datetime.now(datetime.UTC),
|
|
124
|
+
production_environment=self.settings.production_environment,
|
|
125
|
+
services=services,
|
|
126
|
+
jira=self._read_issues(self.jira, _row_keys(services)) if self.jira else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def for_issues(self, *, keys: collections.abc.Sequence[str], cache: Cache) -> Report:
|
|
130
|
+
if self.jira is None:
|
|
131
|
+
msg = "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN."
|
|
132
|
+
raise ConfigError(msg)
|
|
133
|
+
state: typing.Final = self._read_issues(self.jira, keys)
|
|
134
|
+
services: typing.Final[list[Service]] = []
|
|
135
|
+
if state.error is None:
|
|
136
|
+
links_by_project: dict[str, list[LinkedChange]] = {}
|
|
137
|
+
for key in keys:
|
|
138
|
+
issue = state.issues.get(key)
|
|
139
|
+
for link in issue.links if issue else []:
|
|
140
|
+
links_by_project.setdefault(link.project, []).append(link)
|
|
141
|
+
services.extend(
|
|
142
|
+
self._scoped_service(path, links_by_project[path], cache) for path in sorted(links_by_project)
|
|
143
|
+
)
|
|
144
|
+
row_state: typing.Final = self._read_issues(self.jira, sorted(set(_row_keys(services)) - set(keys)))
|
|
145
|
+
state.issues.update(row_state.issues)
|
|
146
|
+
state.missing.extend(row_state.missing)
|
|
147
|
+
state.error = row_state.error
|
|
99
148
|
return Report(
|
|
100
149
|
collected_at=datetime.datetime.now(datetime.UTC),
|
|
101
150
|
production_environment=self.settings.production_environment,
|
|
102
151
|
services=services,
|
|
152
|
+
jira=state,
|
|
153
|
+
jira_scope=list(keys),
|
|
103
154
|
)
|
|
104
155
|
|
|
156
|
+
def _scoped_service(self, path: str, links: list[LinkedChange], cache: Cache) -> Service:
|
|
157
|
+
try:
|
|
158
|
+
project: typing.Final = self.api.get_project(path)
|
|
159
|
+
except GitLabError as exc:
|
|
160
|
+
return Service(project=path, project_url=links[0].project_url, error=f"{path}: {exc}")
|
|
161
|
+
return self._collect_or_explain(project, cache, links=links)
|
|
162
|
+
|
|
163
|
+
def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[LinkedChange] | None) -> Service:
|
|
164
|
+
try:
|
|
165
|
+
return self._collect_service(project, cache, links=links)
|
|
166
|
+
except GitLabError as exc:
|
|
167
|
+
cache.keep_project(project.id)
|
|
168
|
+
return Service(
|
|
169
|
+
project=project.path_with_namespace, project_url=project.web_url, error=explain_failure(project, exc)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> JiraState:
|
|
173
|
+
state: typing.Final = JiraState()
|
|
174
|
+
if not keys:
|
|
175
|
+
return state
|
|
176
|
+
try:
|
|
177
|
+
issues: typing.Final = {issue.key: issue for issue in jira.search_issues(keys)}
|
|
178
|
+
except JiraError as exc:
|
|
179
|
+
state.error = str(exc)
|
|
180
|
+
return state
|
|
181
|
+
for key in keys:
|
|
182
|
+
issue = issues.get(key)
|
|
183
|
+
if issue is None:
|
|
184
|
+
state.missing.append(key)
|
|
185
|
+
continue
|
|
186
|
+
fields = issue.fields
|
|
187
|
+
state.issues[key] = JiraIssue(
|
|
188
|
+
key=key,
|
|
189
|
+
summary=fields.summary,
|
|
190
|
+
status=fields.status.name,
|
|
191
|
+
status_category=fields.status.category.key if fields.status.category else None,
|
|
192
|
+
issue_type=fields.issuetype.name if fields.issuetype else None,
|
|
193
|
+
url=self._jira_url(key),
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
for key, issue in state.issues.items():
|
|
197
|
+
issue.links = self._linked_changes(jira, key)
|
|
198
|
+
except JiraError as exc:
|
|
199
|
+
state.error = str(exc)
|
|
200
|
+
return state
|
|
201
|
+
|
|
202
|
+
def _linked_changes(self, jira: JiraApi, key: str) -> list[LinkedChange]:
|
|
203
|
+
changes: typing.Final[dict[str, LinkedChange]] = {}
|
|
204
|
+
for link in jira.remote_links(key):
|
|
205
|
+
change = parse_gitlab_link(link.target.url if link.target else None, self.settings.gitlab.endpoint)
|
|
206
|
+
if change is not None:
|
|
207
|
+
changes.setdefault(change.url, change)
|
|
208
|
+
return list(changes.values())
|
|
209
|
+
|
|
105
210
|
def _resolve_projects(
|
|
106
211
|
self,
|
|
107
212
|
*,
|
|
@@ -124,7 +229,8 @@ class CollectUseCase:
|
|
|
124
229
|
resolved[project.id] = project
|
|
125
230
|
return sorted(resolved.values(), key=lambda item: item.path_with_namespace)
|
|
126
231
|
|
|
127
|
-
def _collect_service(self, project: Project, cache: Cache) -> Service:
|
|
232
|
+
def _collect_service(self, project: Project, cache: Cache, *, links: list[LinkedChange] | None) -> Service:
|
|
233
|
+
cache.visit(project.id)
|
|
128
234
|
service: typing.Final = Service(
|
|
129
235
|
project=project.path_with_namespace, project_url=project.web_url, default_branch=project.default_branch
|
|
130
236
|
)
|
|
@@ -146,39 +252,115 @@ class CollectUseCase:
|
|
|
146
252
|
service.warnings.append(f"No successful deployment to '{self.settings.production_environment}'.")
|
|
147
253
|
return service
|
|
148
254
|
|
|
255
|
+
walk: typing.Final = self._walk(project, project.default_branch, production.sha, service, cache)
|
|
256
|
+
drafts, linked = walk.drafts, [False] * len(walk.drafts)
|
|
257
|
+
if links is not None:
|
|
258
|
+
drafts, linked = self._scope_rows(project, project.default_branch, service, walk, links, cache)
|
|
259
|
+
service.rows.extend(
|
|
260
|
+
self._build_row(
|
|
261
|
+
project=project,
|
|
262
|
+
draft=draft,
|
|
263
|
+
tags_by_sha=walk.tags_by_sha,
|
|
264
|
+
main_pipelines=walk.main_pipelines,
|
|
265
|
+
environments=service.environments,
|
|
266
|
+
cache=cache,
|
|
267
|
+
).model_copy(update={"linked": is_linked})
|
|
268
|
+
for draft, is_linked in zip(drafts, linked, strict=True)
|
|
269
|
+
)
|
|
270
|
+
return service
|
|
271
|
+
|
|
272
|
+
def _walk(self, project: Project, default_branch: str, baseline: str, service: Service, cache: Cache) -> _Walk:
|
|
149
273
|
commits, truncated = self.api.list_first_parent_commits(
|
|
150
|
-
project.id, f"{
|
|
274
|
+
project.id, f"{baseline}..{default_branch}", max_items=self.settings.max_commits
|
|
151
275
|
)
|
|
276
|
+
walk: typing.Final = _Walk(truncated=truncated)
|
|
152
277
|
if truncated:
|
|
153
278
|
service.warnings.append(f"Stopped after {self.settings.max_commits} commits; older changes are omitted.")
|
|
154
279
|
if not commits:
|
|
155
|
-
return
|
|
156
|
-
|
|
280
|
+
return walk
|
|
157
281
|
since: typing.Final = min(commit.committed_date for commit in commits)
|
|
158
|
-
drafts
|
|
159
|
-
commits, self._commit_merge_requests(project, project.default_branch, commits, since, cache)
|
|
160
|
-
)
|
|
282
|
+
walk.drafts = group_rows(commits, self._commit_merge_requests(project, default_branch, commits, since, cache))
|
|
161
283
|
tags, tags_truncated = self.api.list_tags(project.id)
|
|
162
284
|
if tags_truncated:
|
|
163
285
|
service.warnings.append("Tag list was truncated; some tags may be missing from rows.")
|
|
164
|
-
tags_by_sha: typing.Final[dict[str, list[str]]] = {}
|
|
165
286
|
for tag in tags:
|
|
166
|
-
tags_by_sha.setdefault(tag.commit.id, []).append(tag.name)
|
|
167
|
-
main_pipelines
|
|
168
|
-
self.api.list_push_pipelines(project.id, ref=
|
|
287
|
+
walk.tags_by_sha.setdefault(tag.commit.id, []).append(tag.name)
|
|
288
|
+
walk.main_pipelines = self._latest_by_sha(
|
|
289
|
+
self.api.list_push_pipelines(project.id, ref=default_branch, updated_after=since)
|
|
169
290
|
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
291
|
+
return walk
|
|
292
|
+
|
|
293
|
+
def _scope_rows( # noqa: PLR0913, PLR0917
|
|
294
|
+
self,
|
|
295
|
+
project: Project,
|
|
296
|
+
default_branch: str,
|
|
297
|
+
service: Service,
|
|
298
|
+
walk: _Walk,
|
|
299
|
+
links: list[LinkedChange],
|
|
300
|
+
cache: Cache,
|
|
301
|
+
) -> tuple[list[RowDraft], list[bool]]:
|
|
302
|
+
target: typing.Final = self._link_target(project, default_branch, links, cache)
|
|
303
|
+
service.warnings.extend(target.warnings)
|
|
304
|
+
index: typing.Final = next(
|
|
305
|
+
(position for position, draft in enumerate(walk.drafts) if target.matches(draft)), None
|
|
306
|
+
)
|
|
307
|
+
service.release = self._release(project, target, walk, index, cache)
|
|
308
|
+
drafts: typing.Final = walk.drafts[index:] if index is not None else []
|
|
309
|
+
return drafts, [target.matches(draft) for draft in drafts]
|
|
310
|
+
|
|
311
|
+
def _link_target(
|
|
312
|
+
self, project: Project, default_branch: str, links: list[LinkedChange], cache: Cache
|
|
313
|
+
) -> _LinkTarget:
|
|
314
|
+
merged: typing.Final[set[int]] = set()
|
|
315
|
+
pending: typing.Final[list[MergeRequestRef]] = []
|
|
316
|
+
warnings: typing.Final[list[str]] = []
|
|
317
|
+
for link in links:
|
|
318
|
+
if link.iid is None:
|
|
319
|
+
continue
|
|
320
|
+
merge_request = self._linked_merge_request(project, link.iid, cache)
|
|
321
|
+
if merge_request.state != "merged":
|
|
322
|
+
pending.append(_merge_request_ref(merge_request))
|
|
323
|
+
elif merge_request.target_branch != default_branch:
|
|
324
|
+
warnings.append(
|
|
325
|
+
f"!{merge_request.iid} was merged into {merge_request.target_branch}, not {default_branch}."
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
merged.add(merge_request.iid)
|
|
329
|
+
return _LinkTarget(
|
|
330
|
+
merged=merged, shas=[link.sha for link in links if link.sha], pending=pending, warnings=warnings
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _linked_merge_request(self, project: Project, iid: int, cache: Cache) -> MergeRequest:
|
|
334
|
+
cached: typing.Final = cache.get_merge_request(project.id, iid)
|
|
335
|
+
if cached is not None:
|
|
336
|
+
return cached
|
|
337
|
+
merge_request: typing.Final = self.api.get_merge_request(project.id, iid)
|
|
338
|
+
if merge_request.state == "merged":
|
|
339
|
+
cache.put_merge_request(project.id, merge_request)
|
|
340
|
+
return merge_request
|
|
341
|
+
|
|
342
|
+
def _release(self, project: Project, target: _LinkTarget, walk: _Walk, index: int | None, cache: Cache) -> Release:
|
|
343
|
+
if index is None:
|
|
344
|
+
if target.merged and not walk.truncated:
|
|
345
|
+
state: typing.Literal["in_production", "not_merged", "not_found"] = "in_production"
|
|
346
|
+
elif target.pending and not target.merged:
|
|
347
|
+
state = "not_merged"
|
|
348
|
+
else:
|
|
349
|
+
state = "not_found"
|
|
350
|
+
return Release(state=state, pending_merge_requests=target.pending)
|
|
351
|
+
tag_name: typing.Final = next(
|
|
352
|
+
(
|
|
353
|
+
names[-1]
|
|
354
|
+
for draft in reversed(walk.drafts[: index + 1])
|
|
355
|
+
if (names := [name for commit in draft.commits for name in walk.tags_by_sha.get(commit.id, [])])
|
|
356
|
+
),
|
|
357
|
+
None,
|
|
358
|
+
)
|
|
359
|
+
return Release(
|
|
360
|
+
state="pending",
|
|
361
|
+
tag=self._tag_ref(project, tag_name, cache) if tag_name else None,
|
|
362
|
+
pending_merge_requests=target.pending,
|
|
180
363
|
)
|
|
181
|
-
return service
|
|
182
364
|
|
|
183
365
|
def _commit_merge_requests(
|
|
184
366
|
self, project: Project, default_branch: str, commits: list[Commit], since: datetime.datetime, cache: Cache
|
|
@@ -5,12 +5,14 @@ import modern_di
|
|
|
5
5
|
from modern_di import Scope, providers
|
|
6
6
|
|
|
7
7
|
from release_scope._gitlab import GitLabApi
|
|
8
|
+
from release_scope._jira import JiraApi
|
|
8
9
|
from release_scope._settings import Settings
|
|
9
10
|
from release_scope._use_case import CollectUseCase
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504})
|
|
13
14
|
_MAX_RESPONSE_BODY_BYTES: typing.Final = 16 * 1024 * 1024
|
|
15
|
+
_JIRA_RETRY_METHODS: typing.Final = frozenset({"GET", "POST"})
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
def _build_gitlab_client(settings: Settings) -> httpware.Client:
|
|
@@ -23,8 +25,25 @@ def _build_gitlab_client(settings: Settings) -> httpware.Client:
|
|
|
23
25
|
)
|
|
24
26
|
|
|
25
27
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
+
def _build_jira_client(settings: Settings) -> httpware.Client | None:
|
|
29
|
+
if not settings.jira_endpoint or not settings.jira_token.get_secret_value():
|
|
30
|
+
return None
|
|
31
|
+
return httpware.Client(
|
|
32
|
+
base_url=settings.jira_endpoint,
|
|
33
|
+
timeout=settings.request_timeout,
|
|
34
|
+
headers={"Authorization": f"Bearer {settings.jira_token.get_secret_value()}"},
|
|
35
|
+
middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES, retry_methods=_JIRA_RETRY_METHODS)],
|
|
36
|
+
max_response_body_bytes=_MAX_RESPONSE_BODY_BYTES,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_jira_api(http: httpware.Client | None) -> JiraApi | None:
|
|
41
|
+
return JiraApi(http=http) if http else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _close_client(client: httpware.Client | None) -> None:
|
|
45
|
+
if client is not None:
|
|
46
|
+
client.close()
|
|
28
47
|
|
|
29
48
|
|
|
30
49
|
class SettingsGroup(modern_di.Group):
|
|
@@ -39,10 +58,21 @@ class ClientsGroup(modern_di.Group):
|
|
|
39
58
|
cache=providers.CacheSettings(finalizer=_close_client),
|
|
40
59
|
)
|
|
41
60
|
gitlab_api = providers.Factory(scope=Scope.APP, creator=GitLabApi, kwargs={"http": gitlab_client})
|
|
61
|
+
jira_client = providers.Factory(
|
|
62
|
+
scope=Scope.APP,
|
|
63
|
+
creator=_build_jira_client,
|
|
64
|
+
bound_type=None,
|
|
65
|
+
cache=providers.CacheSettings(finalizer=_close_client),
|
|
66
|
+
)
|
|
67
|
+
jira_api = providers.Factory(
|
|
68
|
+
scope=Scope.APP, creator=_build_jira_api, bound_type=None, kwargs={"http": jira_client}
|
|
69
|
+
)
|
|
42
70
|
|
|
43
71
|
|
|
44
72
|
class UseCasesGroup(modern_di.Group):
|
|
45
|
-
collect_use_case = providers.Factory(
|
|
73
|
+
collect_use_case = providers.Factory(
|
|
74
|
+
scope=Scope.APP, creator=CollectUseCase, kwargs={"jira": ClientsGroup.jira_api}
|
|
75
|
+
)
|
|
46
76
|
|
|
47
77
|
|
|
48
78
|
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
|