Skip to content

Commit b5a7ca7

Browse files
authored
feat: collect the services Jira issues link to, up to their latest change (#6)
collect --jira KEY (repeatable) reads the issues and their GitLab links and collects every linked project. Rows run from the production baseline to the newest row an issue links to, and each service records its release: the nearest tag at or above that row, or in production, not merged, or not found, with open merge requests and merges into other branches listed. The page names the issues, shows the tag to release, and marks linked rows. The cache now prunes only within the projects a run collected, so group and --jira runs can share one file, and it keeps merged merge requests.
1 parent 8567498 commit b5a7ca7

14 files changed

Lines changed: 702 additions & 70 deletions

‎CONTEXT.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,15 @@ names differently.
3535
A GitLab project that a Jira issue's Web links point to through a merge request or commit, other than the service
3636
the row belongs to. It need not be in the report.
3737

38+
**Issue scope**:
39+
The Jira issues a `--jira` run was given. Its services are the projects those issues link to.
40+
41+
**Target**:
42+
In an issue scope, the newest row on the default branch whose merge request or commit an issue links to. Rows run
43+
from the production baseline to the target.
44+
45+
**Release tag**:
46+
The nearest tag at or above the target: the earliest tag that ships it. Absent when a new tag is needed.
47+
3848
**Settled fact**:
3949
Data GitLab will not change for the same key, and so the only data the cache may hold.

‎README.md‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ disabled is reported with a warning and no rows, without querying it. Only a rej
3333
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
3434
and also exits `1`; the GitLab part is still written.
3535

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`.
50+
3651
## Configuration
3752

3853
Every setting is an environment variable; nothing about a GitLab or Jira instance is built in.
@@ -82,8 +97,9 @@ The page opens with a table of the services that have pending changes or problem
8297
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
8398
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
8499
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
85-
and tag pipelines. With Jira issues, the
86-
summary table also counts the issues per service whose status is not done.
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.
87103

88104
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
89105
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot
@@ -92,7 +108,8 @@ read the report or write the page.
92108
## Cache
93109

94110
`--cache` names a JSON file that is read if present and rewritten atomically after the run. It holds only facts
95-
that do not change once settled: which merge requests a commit belongs to, and the failed jobs of a finished
96-
pipeline keyed by its `updated_at`, so a retried job invalidates the entry. Entries the run did not use are
97-
dropped. A missing, corrupt, or older-schema cache is ignored with a warning; the cache only saves requests and
98-
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.

‎release_scope/__main__.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from release_scope._cache import Cache
1111
from release_scope._errors import ConfigError, ReleaseScopeError
1212
from release_scope._files import write_text_atomic
13+
from release_scope._jira_keys import JIRA_KEY_PATTERN
1314
from release_scope._render import render_markdown
1415
from release_scope._report import SCHEMA_VERSION, Report
1516
from release_scope._settings import Settings, load_settings
@@ -70,19 +71,25 @@ def _collect_command( # noqa: PLR0913, PLR0917
7071
pathlib.Path | None,
7172
typer.Option("--cache", help="Cache JSON; read if present, rewritten in place after the run."),
7273
] = 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,
7378
) -> None:
7479
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 [])
7881
settings = load_settings({})
7982
modern_di_typer.fetch_di_container(ctx).set_context(Settings, settings)
8083
cache, cache_warning = Cache.load(cache_path) if cache_path else (Cache(), None)
8184
if cache_warning:
8285
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+
)
8693
except ReleaseScopeError as err:
8794
typer.echo(f"Error: {err}", err=True)
8895
raise typer.Exit(code=err.exit_code) from err
@@ -95,13 +102,37 @@ def _collect_command( # noqa: PLR0913, PLR0917
95102
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
96103
for service in failed:
97104
typer.echo(f"Error: {service.error}", err=True)
98-
jira_error: typing.Final = report.jira.error if report.jira else None
99-
if jira_error:
100-
typer.echo(f"Error: {jira_error}", err=True)
101-
if failed or jira_error:
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:
102109
raise typer.Exit(code=1)
103110

104111

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+
105136
@MAIN_APP.command("render", help="Render a JSON report as a Markdown page for a GitLab wiki.")
106137
def _render_command(
107138
report_path: typing.Annotated[pathlib.Path, typer.Argument(help="Report JSON written by `collect`.")],

‎release_scope/_cache.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
CACHE_SCHEMA_VERSION: typing.Final = 1
1313

14+
_EntryT = typing.TypeVar("_EntryT")
15+
1416

1517
class CachedPipeline(pydantic.BaseModel):
1618
updated_at: str
@@ -21,12 +23,14 @@ class CacheData(pydantic.BaseModel):
2123
schema_version: typing.Literal[1] = CACHE_SCHEMA_VERSION
2224
commit_merge_requests: dict[str, dict[str, list[MergeRequest]]] = pydantic.Field(default_factory=dict)
2325
pipelines: dict[str, dict[str, CachedPipeline]] = pydantic.Field(default_factory=dict)
26+
merge_requests: dict[str, dict[str, MergeRequest]] = pydantic.Field(default_factory=dict)
2427

2528

2629
@dataclasses.dataclass(slots=True, kw_only=True)
2730
class Cache:
2831
previous: CacheData = dataclasses.field(default_factory=CacheData)
2932
current: CacheData = dataclasses.field(default_factory=CacheData)
33+
visited: set[str] = dataclasses.field(default_factory=set)
3034

3135
@classmethod
3236
def load(cls, path: pathlib.Path) -> tuple["Cache", str | None]:
@@ -38,7 +42,30 @@ def load(cls, path: pathlib.Path) -> tuple["Cache", str | None]:
3842
return cls(), f"Ignoring unreadable cache {path}: {type(exc).__name__}."
3943

4044
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
4269

4370
def get_commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest] | None:
4471
cached = self.previous.commit_merge_requests.get(str(project_id), {}).get(sha)
@@ -69,3 +96,7 @@ def keep_project(self, project_id: int) -> None:
6996
**self.previous.pipelines.get(key, {}),
7097
**self.current.pipelines.get(key, {}),
7198
}
99+
self.current.merge_requests[key] = {
100+
**self.previous.merge_requests.get(key, {}),
101+
**self.current.merge_requests.get(key, {}),
102+
}

‎release_scope/_gitlab.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ def list_merged_merge_requests(
237237
)
238238
return merge_requests
239239

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+
240245
def commit_merge_requests(self, project_id: int, sha: str) -> list[MergeRequest]:
241246
merge_requests, _ = self._pages(
242247
f"{_API}/projects/{project_id}/repository/commits/{sha}/merge_requests",

‎release_scope/_render.py‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
"Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure"
3535
)
3636
_FAILED, _SKIPPED, _PENDING = 0, 1, 2
37+
_UNRELEASED: typing.Final = {"not_merged": "⏳ not merged", "not_found": "⚠️ linked change not found"}
38+
_LINKED: typing.Final = "🎯"
3739

3840

3941
def _inline(value: str) -> str:
@@ -79,6 +81,8 @@ def _state(service: Service) -> int | None:
7981
return _FAILED
8082
if service.rows:
8183
return _PENDING
84+
if service.release and service.release.state in _UNRELEASED:
85+
return _SKIPPED
8286
return _SKIPPED if service.warnings else None
8387

8488

@@ -109,12 +113,16 @@ def _failure_counts(service: Service) -> str:
109113

110114
def _pending(service: Service) -> str:
111115
state: typing.Final = _state(service)
116+
release: typing.Final = service.release
112117
if state == _FAILED:
113118
return "❌ failed to collect"
114119
if state == _SKIPPED:
115-
return "⚠️ see below"
116-
untagged: typing.Final = "" if service.rows[0].tags else " · untagged head"
117-
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}"
118126

119127

120128
def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str:
@@ -199,7 +207,7 @@ def _row(row: Row, project: str, jira: JiraState | None) -> list[str]:
199207
issues: typing.Final = jira.issues if jira else {}
200208
return [
201209
"<br>".join(_tag(tag) for tag in row.tags),
202-
_merge_requests_or_commits(row),
210+
f"{_LINKED} {_merge_requests_or_commits(row)}" if row.linked else _merge_requests_or_commits(row),
203211
"<br>".join(_jira_key(key, issues) for key in row.jira_keys),
204212
*([_related_services(row, project, issues)] if jira else []),
205213
", ".join(_cell(name) for name in row.environments),
@@ -238,26 +246,44 @@ def _service_section(service: Service, production: str, jira: JiraState | None)
238246
if service.error:
239247
lines.extend([f"❌ {_inline(service.error)}", ""])
240248
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.", ""])
241258
if service.rows:
242259
lines.extend(_rows_section(service, production, jira))
243260
return lines
244261

245262

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+
246270
def render_markdown(report: Report) -> str:
247271
production: typing.Final = report.production_environment
248272
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
249275
lines: typing.Final = [
250-
"# Release scope",
276+
f"# Release scope: {', '.join(_inline(key) for key in scope)}" if scope else "# Release scope",
251277
"",
252278
(
253279
f"Collected {collected} UTC. Changes run from the commit on `{_inline(production)}` "
254-
"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.")
255281
),
256282
"",
257-
_LEGEND,
258-
"",
259283
]
260-
jira: typing.Final = report.jira
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, ""])
261287
if jira and jira.error:
262288
lines.extend([f"❌ {_inline(jira.error)}", ""])
263289
if not report.services:

‎release_scope/_report.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class Row(pydantic.BaseModel):
6161
jira_keys: list[JiraKeyRef]
6262
environments: list[str]
6363
main_pipeline: PipelineState | None
64+
linked: bool = False
6465

6566

6667
class EnvironmentState(pydantic.BaseModel):
@@ -71,6 +72,12 @@ class EnvironmentState(pydantic.BaseModel):
7172
deployment_url: str | None
7273

7374

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+
7481
class Service(pydantic.BaseModel):
7582
project: str
7683
project_url: str
@@ -79,6 +86,7 @@ class Service(pydantic.BaseModel):
7986
rows: list[Row] = pydantic.Field(default_factory=list)
8087
warnings: list[str] = pydantic.Field(default_factory=list)
8188
error: str | None = None
89+
release: Release | None = None
8290

8391

8492
class LinkedChange(pydantic.BaseModel):
@@ -96,6 +104,7 @@ class JiraIssue(pydantic.BaseModel):
96104
status: str
97105
status_category: str | None
98106
issue_type: str | None
107+
url: str | None = None
99108
links: list[LinkedChange] = pydantic.Field(default_factory=list)
100109

101110

@@ -111,3 +120,4 @@ class Report(pydantic.BaseModel):
111120
production_environment: str
112121
services: list[Service]
113122
jira: JiraState | None = None
123+
jira_scope: list[str] = pydantic.Field(default_factory=list)

0 commit comments

Comments
 (0)