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.
Files changed (21) hide show
  1. {release_scope-0.2.0 → release_scope-0.3.0}/PKG-INFO +37 -11
  2. {release_scope-0.2.0 → release_scope-0.3.0}/README.md +36 -10
  3. {release_scope-0.2.0 → release_scope-0.3.0}/pyproject.toml +1 -1
  4. {release_scope-0.2.0 → release_scope-0.3.0}/pyproject.toml.orig +1 -1
  5. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/__main__.py +54 -11
  6. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_cache.py +32 -1
  7. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_errors.py +4 -0
  8. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_gitlab.py +5 -0
  9. release_scope-0.3.0/release_scope/_jira.py +118 -0
  10. release_scope-0.3.0/release_scope/_links.py +28 -0
  11. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_render.py +94 -20
  12. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_report.py +37 -2
  13. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_settings.py +8 -0
  14. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_use_case.py +218 -36
  15. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/ioc.py +33 -3
  16. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/__init__.py +0 -0
  17. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_files.py +0 -0
  18. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_jira_keys.py +0 -0
  19. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_messages.py +0 -0
  20. {release_scope-0.2.0 → release_scope-0.3.0}/release_scope/_rows.py +0 -0
  21. {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.2.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
  [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](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 keys, failed jobs.
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
- One row, trimmed:
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, where the change is
110
- deployed, and the failed jobs of its main-branch and tag pipelines.
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, 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.
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
  [![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](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 keys, failed jobs.
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
- One row, trimmed:
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, where the change is
77
- deployed, and the failed jobs of its main-branch and tag pipelines.
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, and the failed jobs of a finished
87
- pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Entries the run did not use are
88
- dropped. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves requests and
89
- never changes the report.
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.
@@ -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.0"
29
29
  dependencies = [
30
30
  "typer>=0.13",
31
31
  "pydantic>=2; python_version < '3.12'",
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
- version = "0.2.0"
20
+ version = "0.3.0"
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
@@ -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 EnvironmentState, FailedJob, PipelineState, Report, Row, Service, TagRef
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
- untagged: typing.Final = "" if service.rows[0].tags else " · untagged head"
107
- return f"{_plural(len(service.rows), 'change')}{untagged}"
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 _row(row: Row) -> list[str]:
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
- ", ".join(_link(_cell(key.key), key.url) for key in row.jira_keys),
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(_ROW_HEADER, (_row(row) for row in service.rows)),
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 = ["Service", *(_cell(name) for name in environments), "Pending", "Failed jobs"]
233
- lines.extend([*_table(header, (_summary_row(service, environments) for service in attention)), ""])
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 = 1
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[1] = SCHEMA_VERSION
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
- for project in self._resolve_projects(groups=groups, projects=projects, include_subgroups=include_subgroups):
88
- try:
89
- services.append(self._collect_service(project, cache))
90
- except GitLabError as exc:
91
- cache.keep_project(project.id)
92
- services.append(
93
- Service(
94
- project=project.path_with_namespace,
95
- project_url=project.web_url,
96
- error=explain_failure(project, exc),
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"{production.sha}..{project.default_branch}", max_items=self.settings.max_commits
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 service
156
-
280
+ return walk
157
281
  since: typing.Final = min(commit.committed_date for commit in commits)
158
- drafts: typing.Final = group_rows(
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: typing.Final = self._latest_by_sha(
168
- self.api.list_push_pipelines(project.id, ref=project.default_branch, updated_after=since)
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
- service.rows.extend(
171
- self._build_row(
172
- project=project,
173
- draft=draft,
174
- tags_by_sha=tags_by_sha,
175
- main_pipelines=main_pipelines,
176
- environments=service.environments,
177
- cache=cache,
178
- )
179
- for draft in drafts
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 _close_client(client: httpware.Client) -> None:
27
- client.close()
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(scope=Scope.APP, creator=CollectUseCase)
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]