Skip to content

Commit 54908ec

Browse files
committed
feat: read Jira summary and status for every key in batched searches
With RELEASE_SCOPE_JIRA_TOKEN (or JIRA_TOKEN) set, collect reads all Jira keys of the report with POST /rest/api/2/search, 100 keys per query, and records their summary, status, status category and type under a top-level jira block. A failed search is kept in the report and makes collect exit 1. The page shows each key with summary and status and counts not-done issues per service. The report schema is now version 2; render rejects version 1 with a clear message.
1 parent 7aa7165 commit 54908ec

17 files changed

Lines changed: 528 additions & 52 deletions

‎AGENTS.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ issue title.
1515

1616
## Architecture
1717

18-
`_use_case.py` drives one run; `_gitlab.py` is the only module that speaks HTTP; `_rows.py` is pure and turns the range
19-
plus merge requests into rows; `_render.py` is pure and turns a report into the Markdown page. Tests mock GitLab only
20-
with respx routes (pytest-httpx2): the `gitlab` fixture in `tests/conftest.py` declares one named static route per call
21-
of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks,
18+
`_use_case.py` drives one run; `_gitlab.py` and `_jira.py` are the only modules that speak HTTP; `_rows.py` is pure and
19+
turns the range plus merge requests into rows; `_render.py` is pure and turns a report into the Markdown page. Tests
20+
mock GitLab and Jira only with respx routes (pytest-httpx2): the `gitlab` and `jira` fixtures in `tests/conftest.py`
21+
declare one named static route per call of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks,
2222
or stubs.
2323

2424
The package must stay free of any company's hostnames, group paths, job names, or Jira project keys: every such value

‎CONTEXT.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,9 @@ latest pipeline whose ref is a tag in the row.
2727
**Failed job**:
2828
A job or bridge whose status is `failed`, including those with `allow_failure`; the flag is reported, not filtered.
2929

30+
**Not done**:
31+
A Jira issue whose status category is anything but `done`. Jira's category, not the status name, which each workflow
32+
names differently.
33+
3034
**Settled fact**:
3135
Data GitLab will not change for the same key, and so the only data the cache may hold.

‎README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
[![License](https://img.shields.io/github/license/modern-python/release-scope.svg)](https://github.com/modern-python/release-scope/blob/main/LICENSE)
66

77
`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.
99

1010
For every service it reads the latest successful production deployment, walks the default branch down to that
1111
commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags that
1212
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.
1414

1515
## Quickstart
1616

@@ -27,7 +27,8 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
2727
collect; the report is still written and names the error on that service. A service GitLab denies access to fails
2828
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
2929
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.
30+
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
31+
and also exits `1`; the GitLab part is still written.
3132

3233
## Configuration
3334

@@ -40,6 +41,7 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
4041
| `RELEASE_SCOPE_ENVIRONMENTS` | `["production"]` | Environments shown per service, as a JSON list |
4142
| `RELEASE_SCOPE_PRODUCTION_ENVIRONMENT` | `production` | Environment whose deployed commit starts the range |
4243
| `RELEASE_SCOPE_JIRA_ENDPOINT` | unset | When set, Jira keys link to `<endpoint>/browse/<KEY>` |
44+
| `RELEASE_SCOPE_JIRA_TOKEN` or `JIRA_TOKEN` | unset | Jira Server/Data Center personal access token; when set, issues are fetched |
4345
| `RELEASE_SCOPE_JIRA_PROJECT_KEYS` | `[]` | Keep only keys of these Jira projects; empty keeps all |
4446
| `RELEASE_SCOPE_MAX_COMMITS` | `1000` | Stop walking a service's range after this many commits |
4547
| `RELEASE_SCOPE_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
@@ -48,7 +50,9 @@ Every setting is an environment variable; nothing about a GitLab or Jira instanc
4850

4951
The report is versioned by `schema_version`; the models live in
5052
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
51-
One row, trimmed:
53+
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status
54+
category, issue type), the `missing` keys Jira did not return, and an `error` if the search failed. One row,
55+
trimmed:
5256

5357
```json
5458
{
@@ -73,8 +77,9 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
7377

7478
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
7579
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.
80+
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
81+
where the change is deployed, and the failed jobs of its main-branch and tag pipelines. With Jira issues, the
82+
summary table also counts the issues per service whose status is not done.
7883

7984
Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page
8085
should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot

‎release_scope/__main__.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import importlib.metadata
2+
import json
23
import pathlib
34
import typing
45

56
import modern_di_typer
6-
import pydantic
77
import typer
88

99
from release_scope import ioc
1010
from release_scope._cache import Cache
1111
from release_scope._errors import ConfigError, ReleaseScopeError
1212
from release_scope._files import write_text_atomic
1313
from release_scope._render import render_markdown
14-
from release_scope._report import Report
14+
from release_scope._report import SCHEMA_VERSION, Report
1515
from release_scope._settings import Settings, load_settings
1616
from release_scope._use_case import CollectUseCase
1717

@@ -95,7 +95,10 @@ def _collect_command( # noqa: PLR0913, PLR0917
9595
typer.echo(f"{len(report.services)} services, {rows} rows, {len(failed)} failed -> {output}", err=True)
9696
for service in failed:
9797
typer.echo(f"Error: {service.error}", err=True)
98-
if failed:
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:
99102
raise typer.Exit(code=1)
100103

101104

@@ -105,8 +108,17 @@ def _render_command(
105108
output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the Markdown page.")],
106109
) -> None:
107110
try:
108-
report = Report.model_validate_json(report_path.read_bytes())
109-
except (OSError, pydantic.ValidationError) as exc:
111+
raw = json.loads(report_path.read_bytes())
112+
version = raw.get("schema_version") if isinstance(raw, dict) else None
113+
if isinstance(version, int) and version < SCHEMA_VERSION:
114+
typer.echo(
115+
f"Error: Cannot read report {report_path}: schema_version {version} is not supported; "
116+
"run collect again.",
117+
err=True,
118+
)
119+
raise typer.Exit(code=ConfigError.exit_code)
120+
report = Report.model_validate(raw)
121+
except (OSError, ValueError) as exc:
110122
typer.echo(f"Error: Cannot read report {report_path}: {type(exc).__name__}.", err=True)
111123
raise typer.Exit(code=ConfigError.exit_code) from exc
112124
try:

‎release_scope/_errors.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ def __init__(self, message: str, *, resource: str, status: int | None = None, re
2121
self.resource = resource
2222
self.status = status
2323
self.reason = reason
24+
25+
26+
class JiraError(ReleaseScopeError):
27+
pass

‎release_scope/_jira.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
_BATCH_SIZE: typing.Final = 100
13+
_FIELDS: typing.Final = ("summary", "status", "issuetype")
14+
15+
16+
class StatusCategory(pydantic.BaseModel):
17+
key: str | None = None
18+
19+
20+
class Status(pydantic.BaseModel):
21+
name: str
22+
category: StatusCategory | None = pydantic.Field(default=None, alias="statusCategory")
23+
24+
25+
class IssueType(pydantic.BaseModel):
26+
name: str
27+
28+
29+
class IssueFields(pydantic.BaseModel):
30+
summary: str
31+
status: Status
32+
issuetype: IssueType | None = None
33+
34+
35+
class Issue(pydantic.BaseModel):
36+
key: str
37+
fields: IssueFields
38+
39+
40+
class _SearchResults(pydantic.BaseModel):
41+
total: int
42+
issues: list[Issue]
43+
44+
45+
def _error_messages(exc: httpware.StatusError) -> str:
46+
try:
47+
payload: typing.Final = exc.response.json()
48+
except ValueError:
49+
return ""
50+
messages: typing.Final = payload.get("errorMessages") if isinstance(payload, dict) else None
51+
if not isinstance(messages, list):
52+
return ""
53+
return " ".join(str(message) for message in messages)
54+
55+
56+
def _translate(exc: httpware.ClientError) -> JiraError:
57+
if isinstance(exc, httpware.UnauthorizedError):
58+
return JiraError("Jira rejected the token (401). Check that it is valid and not expired.")
59+
if isinstance(exc, httpware.StatusError):
60+
status: typing.Final = exc.response.status_code
61+
details: typing.Final = _error_messages(exc)
62+
suffix: typing.Final = f": {details.rstrip('.')}." if details else "."
63+
return JiraError(f"Jira returned {status} for the issue search{suffix}")
64+
return JiraError(f"Jira issue search failed: {type(exc).__name__}.")
65+
66+
67+
def _batches(keys: collections.abc.Sequence[str]) -> collections.abc.Iterator[collections.abc.Sequence[str]]:
68+
for start in range(0, len(keys), _BATCH_SIZE):
69+
yield keys[start : start + _BATCH_SIZE]
70+
71+
72+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
73+
class JiraApi:
74+
http: httpware.Client
75+
76+
def search_issues(self, keys: collections.abc.Sequence[str]) -> list[Issue]:
77+
issues: typing.Final[list[Issue]] = []
78+
for batch in _batches(keys):
79+
quoted = ", ".join(f'"{key}"' for key in batch)
80+
issues.extend(self._search(f"key in ({quoted})"))
81+
return issues
82+
83+
def _search(self, jql: str) -> list[Issue]:
84+
issues: typing.Final[list[Issue]] = []
85+
while True:
86+
body = {
87+
"jql": jql,
88+
"fields": list(_FIELDS),
89+
"startAt": len(issues),
90+
"maxResults": _BATCH_SIZE,
91+
"validateQuery": False,
92+
}
93+
try:
94+
page = self.http.post(_SEARCH, json=body, response_model=_SearchResults)
95+
except httpware.ClientError as exc:
96+
raise _translate(exc) from exc
97+
issues.extend(page.issues)
98+
if not page.issues or len(issues) >= page.total:
99+
return issues

‎release_scope/_render.py‎

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,18 @@
33
import html
44
import typing
55

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+
)
718

819

920
_STATUS_ICONS: typing.Final = {
@@ -107,11 +118,18 @@ def _pending(service: Service) -> str:
107118
return f"{_plural(len(service.rows), 'change')}{untagged}"
108119

109120

110-
def _summary_row(service: Service, environments: list[str]) -> list[str]:
121+
def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str:
122+
keys: typing.Final = {key.key for row in service.rows for key in row.jira_keys}
123+
count: typing.Final = sum(1 for key in keys if key in issues and issues[key].status_category != "done")
124+
return f"{count} not done" if count else ""
125+
126+
127+
def _summary_row(service: Service, environments: list[str], jira: JiraState | None) -> list[str]:
111128
return [
112129
_link(_cell(service.project), service.project_url),
113130
*(_environment_ref(_environment(service, name)) for name in environments),
114131
_pending(service),
132+
*([_not_done(service, jira.issues)] if jira else []),
115133
_failure_counts(service),
116134
]
117135

@@ -161,11 +179,17 @@ def _failed_jobs(row: Row) -> str:
161179
return "<br>".join(parts)
162180

163181

164-
def _row(row: Row) -> list[str]:
182+
def _jira_key(key: JiraKeyRef, issues: dict[str, JiraIssue]) -> str:
183+
text: typing.Final = _link(_cell(key.key), key.url)
184+
issue: typing.Final = issues.get(key.key)
185+
return f"{text} {_cell(issue.summary)} · {_cell(issue.status)}" if issue else text
186+
187+
188+
def _row(row: Row, issues: dict[str, JiraIssue]) -> list[str]:
165189
return [
166190
"<br>".join(_tag(tag) for tag in row.tags),
167191
_merge_requests_or_commits(row),
168-
", ".join(_link(_cell(key.key), key.url) for key in row.jira_keys),
192+
"<br>".join(_jira_key(key, issues) for key in row.jira_keys),
169193
", ".join(_cell(name) for name in row.environments),
170194
_failed_jobs(row),
171195
]
@@ -179,7 +203,7 @@ def _counts(service: Service) -> str:
179203
)
180204

181205

182-
def _rows_section(service: Service, production: str) -> list[str]:
206+
def _rows_section(service: Service, production: str, issues: dict[str, JiraIssue]) -> list[str]:
183207
ordered: typing.Final = sorted(service.environments, key=lambda item: item.name != production)
184208
environments: typing.Final = [f"{_cell(item.name)} {_environment_ref(item)}" for item in ordered]
185209
production_state: typing.Final = _environment(service, production)
@@ -189,18 +213,18 @@ def _rows_section(service: Service, production: str) -> list[str]:
189213
"",
190214
*_collapsed(
191215
f"{_plural(len(service.rows), 'change')}{since}",
192-
_table(_ROW_HEADER, (_row(row) for row in service.rows)),
216+
_table(_ROW_HEADER, (_row(row, issues) for row in service.rows)),
193217
),
194218
]
195219

196220

197-
def _service_section(service: Service, production: str) -> list[str]:
221+
def _service_section(service: Service, production: str, issues: dict[str, JiraIssue]) -> list[str]:
198222
lines: typing.Final = [f"## {_inline(service.project)}", ""]
199223
if service.error:
200224
lines.extend([f"❌ {_inline(service.error)}", ""])
201225
lines.extend(line for warning in service.warnings for line in (f"⚠️ {_inline(warning)}", ""))
202226
if service.rows:
203-
lines.extend(_rows_section(service, production))
227+
lines.extend(_rows_section(service, production, issues))
204228
return lines
205229

206230

@@ -218,6 +242,9 @@ def render_markdown(report: Report) -> str:
218242
_LEGEND,
219243
"",
220244
]
245+
jira: typing.Final = report.jira
246+
if jira and jira.error:
247+
lines.extend([f"❌ {_inline(jira.error)}", ""])
221248
if not report.services:
222249
lines.append("No services were collected.")
223250
return "\n".join(lines) + "\n"
@@ -229,8 +256,14 @@ def render_markdown(report: Report) -> str:
229256
up_to_date: typing.Final = [service for service in report.services if _state(service) is None]
230257
environments: typing.Final = _environment_names(report)
231258
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)), ""])
259+
header: typing.Final = [
260+
"Service",
261+
*(_cell(name) for name in environments),
262+
"Pending",
263+
*(["Jira"] if jira else []),
264+
"Failed jobs",
265+
]
266+
lines.extend([*_table(header, (_summary_row(service, environments, jira) for service in attention)), ""])
234267
else:
235268
lines.extend(["All services are up to date.", ""])
236269
if up_to_date:
@@ -246,6 +279,7 @@ def render_markdown(report: Report) -> str:
246279
),
247280
)
248281
)
282+
issues: typing.Final = jira.issues if jira else {}
249283
for service in attention:
250-
lines.extend(_service_section(service, production))
284+
lines.extend(_service_section(service, production, issues))
251285
return "\n".join(lines).rstrip("\n") + "\n"

0 commit comments

Comments
 (0)