Skip to content

Commit 5d29108

Browse files
authored
Record the state of linked merge requests and show it on the page (schema 7) (#33)
Closes #30.
1 parent bc359ac commit 5d29108

8 files changed

Lines changed: 190 additions & 23 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ The report is versioned by `schema_version`; the models live in
9393
[`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py).
9494
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
9595
issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
96+
A linked merge request in a collected service has its `state` (`opened`, `closed`, `locked`, or `merged`) and
97+
`target_branch`. When a row holds it, it is merged into the default branch and costs no request; otherwise it is read
98+
from GitLab, and a merged one is cached. The state stays `null` for a commit, for a project outside the report, and when
99+
the merge request cannot be read.
96100
Warnings and errors, on services and in `jira`, are messages: a `code`, its `params`, and the English `text`.
97101
Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
98102
rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
@@ -151,12 +155,15 @@ reload starts from the release tags again.
151155
**Issues**, at the top of the Release column when the report has Jira issues, picks tags from issues instead. It lists
152156
every issue of the report, Done first and then the rest, by Jira's status category, and filters them by key or
153157
summary or shows only the chosen ones. Under each issue are the services it touches: the tag that ships it, `no tag
154-
yet`, `in prod or not merged` when it links to a merge request or commit outside the range, `not in this report`, or
155-
`failed to collect`. Ticking an issue picks, in every service it touches, the tag a `--jira` run would make its
158+
yet`, `not in this report`, `failed to collect`, or, when it links only to changes outside the range, why: `not
159+
merged` while a linked merge request is open, `in prod` when one was merged into the default branch (`older than the
160+
range` when the range was cut), `merged into <branch>`, `closed`, or `in prod or not merged` for a commit or a
161+
merge request that could not be read. Ticking an issue picks, in every service it touches, the tag a `--jira` run would make its
156162
release tag: the nearest tag at or above the newest row the issue links to or names. When several chosen issues touch
157163
a service, the newest of their tags wins; when one of them has no tag yet, the service gets no pick and its line
158164
links to create the next tag. Each touched service line names the chosen issues it is picked for and the other Jira
159-
keys its picked tag also ships; in the list those issues are marked `ships anyway`. Services no chosen issue
165+
keys its picked tag also ships; in the list those issues are marked `ships anyway`. It also says why a chosen issue
166+
that links only to changes outside the range has nothing to ship there, such as `SHOP-150 not merged`. Services no chosen issue
160167
touches are dimmed, and projects the issues link to that are not in the report are listed under the services.
161168
Unticking an issue clears the picks only it needed. A pick made by hand stays until the chosen issues change, and the
162169
line says which chosen issues it does not ship. The address keeps the issues before the picks, as
@@ -188,7 +195,7 @@ release-report:
188195
rules:
189196
- if: $CI_PIPELINE_SOURCE == "schedule"
190197
script:
191-
- uvx --from 'release-scope>=0.8,<0.9' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
198+
- uvx --from 'release-scope>=0.9,<0.10' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
192199
pages: true
193200
```
194201
@@ -227,4 +234,4 @@ npx skills add modern-python/release-scope
227234
```
228235

229236
The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
230-
The skill runs `release-scope>=0.8,<0.9`, the range whose flags and report schema it describes.
237+
The skill runs `release-scope>=0.9,<0.10`, the range whose flags and report schema it describes.

‎release_scope/_report.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pydantic
66

77

8-
SCHEMA_VERSION: typing.Final = 6
8+
SCHEMA_VERSION: typing.Final = 7
99

1010

1111
class MessageCode(enum.StrEnum):
@@ -140,6 +140,8 @@ class LinkedChange(pydantic.BaseModel):
140140
url: str
141141
iid: int | None = None
142142
sha: str | None = None
143+
state: str | None = None
144+
target_branch: str | None = None
143145

144146

145147
class JiraIssue(pydantic.BaseModel):
@@ -159,7 +161,7 @@ class JiraState(pydantic.BaseModel):
159161

160162

161163
class Report(pydantic.BaseModel):
162-
schema_version: typing.Literal[6] = SCHEMA_VERSION
164+
schema_version: typing.Literal[7] = SCHEMA_VERSION
163165
collected_at: dt.datetime
164166
production_environment: str
165167
services: list[Service]

‎release_scope/_static/index.html‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@
105105
.cell { padding: 0 6px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
106106
.cell-tag { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
107107
.cell-untagged { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
108-
.cell-elsewhere { background: color-mix(in srgb, var(--good) 14%, transparent); color: var(--good); }
108+
.cell-production, .cell-older { background: color-mix(in srgb, var(--good) 14%, transparent); color: var(--good); }
109+
.cell-open, .cell-branch { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
110+
.cell-closed, .cell-elsewhere { background: var(--panel); color: var(--muted); }
109111
.cell-outside, .cell-failed { background: color-mix(in srgb, var(--bad) 12%, transparent); color: var(--bad); }
110112
.issue-tools { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0; }
111113
.issue-tools input { flex: 1; min-width: 140px; font: inherit; padding: 2px 8px; border: 1px solid var(--border); border-radius: 6px;
@@ -205,8 +207,8 @@ <h2 x-text="t('services')"></h2>
205207
x-text="service.untagged && service.untagged.next_tag ? t('createTag', { name: service.untagged.next_tag }) : t('createAnyTag')"></a>
206208
</span>
207209
</template>
208-
<template x-if="planOf(service).elsewhere.length && !planOf(service).best && !planOf(service).untagged.length">
209-
<span class="muted" x-text="t('planElsewhere', { keys: planOf(service).elsewhere.join(', ') })"></span>
210+
<template x-if="planOf(service).elsewhere.length">
211+
<span class="muted" x-text="outOfRangeText(planOf(service))"></span>
210212
</template>
211213
<template x-if="missedBy(service).length">
212214
<span class="warn" x-text="t('planMissed', { tag: picks[service.project], keys: missedBy(service).join(', ') })"></span>
@@ -535,12 +537,17 @@ <h2 x-text="t('release')"></h2>
535537
shipsAnywayTitle: "Not chosen, but a picked tag ships it",
536538
reachUntagged: "no tag yet",
537539
reachElsewhere: "in prod or not merged",
540+
reachProduction: "in prod",
541+
reachOlder: "older than the range",
542+
reachOpen: "not merged",
543+
reachClosed: "closed",
544+
reachBranch: "merged into {branch}",
538545
notInReport: "not in this report",
539546
linkedTo: "linked to",
540547
planFor: "for",
541548
planAlso: "also ships",
542549
planNoTag: "{keys}: no tag yet.",
543-
planElsewhere: "{keys}: in production or not merged, nothing to pick",
550+
planElsewhere: "{items}, nothing to pick",
544551
planMissed: "{tag} does not ship {keys}",
545552
},
546553
ru: {
@@ -631,12 +638,17 @@ <h2 x-text="t('release')"></h2>
631638
shipsAnywayTitle: "Задача не выбрана, но выбранный тег её содержит",
632639
reachUntagged: "нет тега",
633640
reachElsewhere: "на проде или не влита",
641+
reachProduction: "на проде",
642+
reachOlder: "старше диапазона",
643+
reachOpen: "не влита",
644+
reachClosed: "закрыта",
645+
reachBranch: "влита в {branch}",
634646
notInReport: "нет в этом отчёте",
635647
linkedTo: "связан с",
636648
planFor: "для",
637649
planAlso: "также войдут",
638650
planNoTag: "{keys}: тега ещё нет.",
639-
planElsewhere: "{keys}: уже на проде или не влиты, выбирать нечего",
651+
planElsewhere: "{items}, выбирать нечего",
640652
planMissed: "{tag} не содержит {keys}",
641653
},
642654
};
@@ -703,6 +715,19 @@ <h2 x-text="t('release')"></h2>
703715

704716
const byKey = (a, b) => a.localeCompare(b, undefined, { numeric: true });
705717

718+
const OUT_OF_RANGE = new Set(["production", "older", "open", "branch", "closed", "elsewhere"]);
719+
720+
const outOfRange = (service, links) => {
721+
if (links.some((link) => link.state === "opened" || link.state === "locked")) return { state: "open" };
722+
if (links.some((link) => link.state === "merged" && link.target_branch === service.default_branch)) {
723+
return { state: service.truncated ? "older" : "production" };
724+
}
725+
const merged = links.find((link) => link.state === "merged");
726+
if (merged) return { state: "branch", branch: merged.target_branch };
727+
if (links.some((link) => link.state === "closed")) return { state: "closed" };
728+
return { state: "elsewhere" };
729+
};
730+
706731
const reachOf = (report) => {
707732
const issues = report.jira ? report.jira.issues : {};
708733
const reach = Object.fromEntries(Object.keys(issues).map((key) => [key, []]));
@@ -726,7 +751,7 @@ <h2 x-text="t('release')"></h2>
726751
continue;
727752
}
728753
if (!indexes.length) {
729-
if (links.length) reach[key].push({ project, state: "elsewhere" });
754+
if (links.length) reach[key].push({ project, ...outOfRange(service, links) });
730755
continue;
731756
}
732757
const index = Math.min(...indexes);
@@ -916,7 +941,7 @@ <h2 x-text="t('release')"></h2>
916941
entry.keys.push(key);
917942
if (item.state === "tag" && (!entry.best || item.tagIndex < entry.best.tagIndex)) entry.best = item;
918943
if (item.state === "untagged") entry.untagged.push(key);
919-
if (item.state === "elsewhere") entry.elsewhere.push(key);
944+
if (OUT_OF_RANGE.has(item.state)) entry.elsewhere.push({ key, ...item });
920945
}
921946
}
922947
return plan;
@@ -941,10 +966,27 @@ <h2 x-text="t('release')"></h2>
941966
shipsAnyway(key) {
942967
return !this.chosen.includes(key) && this.picked.some(({ candidate }) => candidate.jira_keys.some((item) => item.key === key));
943968
},
969+
reachLabel(item) {
970+
if (item.state === "tag") return item.tag;
971+
if (item.state === "branch") return this.t("reachBranch", { branch: item.branch });
972+
const states = {
973+
untagged: "reachUntagged",
974+
production: "reachProduction",
975+
older: "reachOlder",
976+
open: "reachOpen",
977+
closed: "reachClosed",
978+
elsewhere: "reachElsewhere",
979+
outside: "notInReport",
980+
failed: "failedToCollect",
981+
};
982+
return this.t(states[item.state]);
983+
},
984+
outOfRangeText(entry) {
985+
const items = entry.elsewhere.map((item) => `${item.key} ${this.reachLabel(item)}`).join(", ");
986+
return entry.best || entry.untagged.length ? items : this.t("planElsewhere", { items });
987+
},
944988
reachText(item) {
945-
const name = item.project.split("/").pop();
946-
const states = { untagged: "reachUntagged", elsewhere: "reachElsewhere", outside: "notInReport", failed: "failedToCollect" };
947-
return `${name} ${item.state === "tag" ? item.tag : this.t(states[item.state])}`;
989+
return `${item.project.split("/").pop()} ${this.reachLabel(item)}`;
948990
},
949991
showIssues(view) {
950992
this.issueView = view;

‎release_scope/_use_case.py‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ def __call__(
154154
state: typing.Final = self._read_issues(self.jira, _row_keys(services), exclude) if self.jira else None
155155
if state is not None:
156156
self._join_linked_issues(services, state)
157+
self._record_link_states(
158+
services, state, {project.path_with_namespace: project for project in resolved}, cache
159+
)
157160
return Report(
158161
collected_at=dt.datetime.now(dt.UTC),
159162
production_environment=self.settings.production_environment,
@@ -169,14 +172,15 @@ def for_issues(
169172
raise ConfigError(msg)
170173
state: typing.Final = self._read_issues(self.jira, keys, exclude)
171174
services: typing.Final[list[Service]] = []
175+
projects: typing.Final[dict[str, Project]] = {}
172176
if state.error is None:
173177
links_by_project: dict[str, list[LinkedChange]] = {}
174178
for key in keys:
175179
issue = state.issues.get(key)
176180
for link in issue.links if issue else []:
177181
links_by_project.setdefault(link.project, []).append(link)
178182
services.extend(
179-
self._scoped_service(path, links_by_project[path], cache) for path in sorted(links_by_project)
183+
self._scoped_service(path, links_by_project[path], cache, projects) for path in sorted(links_by_project)
180184
)
181185
row_state: typing.Final = self._read_issues(
182186
self.jira, sorted(set(_row_keys(services)) - set(keys)), exclude
@@ -185,6 +189,7 @@ def for_issues(
185189
state.missing.extend(row_state.missing)
186190
state.error = row_state.error
187191
self._join_linked_issues(services, state)
192+
self._record_link_states(services, state, projects, cache)
188193
return Report(
189194
collected_at=dt.datetime.now(dt.UTC),
190195
production_environment=self.settings.production_environment,
@@ -193,12 +198,15 @@ def for_issues(
193198
jira_scope=list(keys),
194199
)
195200

196-
def _scoped_service(self, path: str, links: list[LinkedChange], cache: Cache) -> Service:
201+
def _scoped_service(
202+
self, path: str, links: list[LinkedChange], cache: Cache, projects: dict[str, Project]
203+
) -> Service:
197204
try:
198205
project: typing.Final = self.api.get_project(path)
199206
except GitLabError as exc:
200207
project_url: typing.Final = links[0].project_url
201208
return Service(project=path, project_url=project_url, error=explain_failure(path, project_url, exc))
209+
projects[path] = project
202210
return self._collect_or_explain(project, cache, links=links)
203211

204212
def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[LinkedChange] | None) -> Service:
@@ -233,6 +241,36 @@ def _join_linked_issues(self, services: list[Service], state: JiraState) -> None
233241
)
234242
service.candidates = build_candidates(service, production)
235243

244+
def _record_link_states(
245+
self, services: list[Service], state: JiraState, projects: dict[str, Project], cache: Cache
246+
) -> None:
247+
collected: typing.Final = {
248+
service.project: service
249+
for service in services
250+
if service.error is None and service.default_branch and skip_reason(projects[service.project]) is None
251+
}
252+
links: typing.Final = [
253+
(link, link.iid)
254+
for issue in state.issues.values()
255+
for link in issue.links
256+
if link.iid is not None and link.project in collected
257+
]
258+
known: typing.Final = {
259+
(link.project, iid): (link.state, link.target_branch) for link, iid in links if link.state is not None
260+
}
261+
for link, iid in links:
262+
service = collected[link.project]
263+
if (link.project, iid) not in known:
264+
if any(item.iid == iid for row in service.rows for item in row.merge_requests):
265+
known[link.project, iid] = ("merged", service.default_branch)
266+
else:
267+
try:
268+
merge_request = self._linked_merge_request(projects[link.project], iid, cache)
269+
except GitLabError:
270+
continue
271+
known[link.project, iid] = (merge_request.state, merge_request.target_branch)
272+
link.state, link.target_branch = known[link.project, iid]
273+
236274
def _read_issues(
237275
self, jira: JiraApi, keys: collections.abc.Sequence[str], exclude: collections.abc.Sequence[str]
238276
) -> JiraState:
@@ -408,6 +446,7 @@ def _link_target(
408446
if link.iid is None:
409447
continue
410448
merge_request = self._linked_merge_request(project, link.iid, cache)
449+
link.state, link.target_branch = merge_request.state, merge_request.target_branch
411450
if merge_request.state != "merged":
412451
pending.append(_merge_request_ref(merge_request))
413452
elif merge_request.target_branch != default_branch:

‎skills/release-scope/SKILL.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ description: >
1414
through uvx with this version range; it matches the flags and report schema described here:
1515

1616
```bash
17-
uvx --from 'release-scope>=0.8,<0.9' release-scope --help
17+
uvx --from 'release-scope>=0.9,<0.10' release-scope --help
1818
```
1919

2020
## Check the settings
@@ -58,7 +58,7 @@ run and only saves requests:
5858
```bash
5959
out="${XDG_CACHE_HOME:-$HOME/.cache}/release-scope"
6060
mkdir -p "$out"
61-
uvx --from 'release-scope>=0.8,<0.9' release-scope collect --project team/backend/shop \
61+
uvx --from 'release-scope>=0.9,<0.10' release-scope collect --project team/backend/shop \
6262
--output "$out/site" --cache "$out/cache.json"
6363
```
6464

@@ -97,7 +97,9 @@ Read `$out/site/report.json` and answer the user's question from it, briefly.
9797
`compare_url`.
9898
To answer which issues a set of tags releases, merge the `jira_keys` of the picked candidates.
9999
- `jira.issues` by key: `summary`, `status`, `status_category` (anything but `done` is not done), `links` to GitLab
100-
merge requests and commits. `jira.missing` lists keys Jira did not return; `jira.error` a failed request, as a message. `jira` is
100+
merge requests and commits. A merge request link into a collected service has `state` (`opened`, `closed`, `locked`,
101+
`merged`) and `target_branch`; `null` for commits, other projects, and unreadable merge requests. A merged link that
102+
no row holds was merged before production, or below a cut range, or into another branch. `jira.missing` lists keys Jira did not return; `jira.error` a failed request, as a message. `jira` is
101103
`null` without a Jira token.
102104
- In a `--jira` report, `jira_scope` lists the issues, rows with `linked: true` are the ones the issues point to,
103105
rows with `in_scope: false` sit above the latest linked change and do not ship with the issues, and each service

‎tests/test_cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib
4949
"report.json",
5050
]
5151
report: typing.Final = json.loads((output / "report.json").read_text())
52-
assert report["schema_version"] == 6
52+
assert report["schema_version"] == 7
5353
assert report["jira"] is None
5454
assert report["production_environment"] == "production"
5555
assert [len(item["rows"]) for item in report["services"]] == [5]

‎tests/test_scope.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,23 @@ def test_missing_scope_issue_yields_no_services(scoped: respx.Router) -> None:
225225
assert scoped["jira_search"].call_count == 1
226226

227227

228+
@pytest.mark.httpx2(assert_all_called=False)
229+
def test_scope_links_record_merge_request_states_without_reading_them_twice(scoped: respx.Router) -> None:
230+
scoped["remote_links:SHOP-12"].respond(
231+
json=_links(f"{ENDPOINT}/team/svc/-/merge_requests/12", f"{ENDPOINT}/team/svc/-/merge_requests/13")
232+
)
233+
scoped.get(f"{SERVICE_API}/merge_requests/13", name="mr:13").respond(json=merge_request(13, "WIP", state="opened"))
234+
235+
report: typing.Final = _scope()
236+
237+
assert report.jira is not None
238+
assert [(item.iid, item.state, item.target_branch) for item in report.jira.issues["SHOP-12"].links] == [
239+
(12, "merged", "main"),
240+
(13, "opened", "main"),
241+
]
242+
assert (scoped["mr:12"].call_count, scoped["mr:13"].call_count) == (1, 1)
243+
244+
228245
@pytest.mark.httpx2(assert_all_called=False)
229246
def test_scope_stops_when_jira_fails(scoped: respx.Router) -> None:
230247
scoped["jira_search"].respond(401)

0 commit comments

Comments
 (0)