Skip to content

Commit 586c550

Browse files
authored
Show what picks leave behind and why each Jira key is there (#53)
1 parent 386127b commit 586c550

4 files changed

Lines changed: 195 additions & 12 deletions

File tree

‎demo/scenario.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class DemoIssue:
6161
category: str
6262
issue_type: str = "Story"
6363
links: tuple[str, ...] = ()
64+
blocked_by: tuple[str, ...] = ()
6465

6566

6667
SERVICES: typing.Final = (
@@ -175,7 +176,12 @@ class DemoIssue:
175176
),
176177
),
177178
),
178-
DemoService(id=14, name="admin", production="3.1.0"),
179+
DemoService(
180+
id=14,
181+
name="admin",
182+
production="3.1.0",
183+
linked=(LinkedMergeRequest(iid=15, title="SHOP-44 Export permission for managers", state="opened"),),
184+
),
179185
)
180186

181187
ISSUES: typing.Final = (
@@ -200,6 +206,7 @@ class DemoIssue:
200206
status="In Review",
201207
category="indeterminate",
202208
links=(f"{ENDPOINT}/shop/api/-/merge_requests/40",),
209+
blocked_by=("SHOP-45",),
203210
),
204211
DemoIssue(
205212
key="SHOP-38",
@@ -211,6 +218,7 @@ class DemoIssue:
211218
f"{ENDPOINT}/shop/web/-/merge_requests/120",
212219
f"{ENDPOINT}/shop/mobile/-/merge_requests/7",
213220
),
221+
blocked_by=("SHOP-44",),
214222
),
215223
DemoIssue(
216224
key="SHOP-40",
@@ -226,6 +234,14 @@ class DemoIssue:
226234
category="indeterminate",
227235
links=(f"{ENDPOINT}/shop/web/-/merge_requests/125",),
228236
),
237+
DemoIssue(
238+
key="SHOP-44",
239+
summary="Only managers may export orders",
240+
status="In Progress",
241+
category="indeterminate",
242+
links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15",),
243+
),
244+
DemoIssue(key="SHOP-45", summary="Pick the dark palette with design", status="To Do", category="new"),
229245
)
230246

231247

@@ -383,6 +399,13 @@ def _issue(issue: DemoIssue) -> dict[str, typing.Any]:
383399
"summary": issue.summary,
384400
"status": {"name": issue.status, "statusCategory": {"key": issue.category}},
385401
"issuetype": {"name": issue.issue_type},
402+
"issuelinks": [
403+
{
404+
"type": {"name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
405+
"inwardIssue": {"key": key},
406+
}
407+
for key in issue.blocked_by
408+
],
386409
},
387410
}
388411

‎docs/page.md‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ Opening a line shows:
3939
- a link to create the next tag on the head, when rows have no tag yet;
4040
- the rows, each with its tags and their pipelines, its merge requests or commits with when they were merged or
4141
committed, Jira keys with their status, the time each environment first ran it, and failed jobs. Rows out of scope
42-
are dimmed.
42+
are dimmed. Each Jira key says why the row carries it: `named` when the merge request or commit mentions it,
43+
`linked` when the Jira issue links to the change, or both.
4344

4445
Links open in a new tab, including the GitLab settings pages that warnings and errors point to.
4546

@@ -57,7 +58,9 @@ starts from the release tags again.
5758
## Choosing issues
5859

5960
When the report has Jira issues, the **Issues** list at the top of the Release column picks tags from issues instead.
60-
It lists every issue in the report, Done first and the rest after, by Jira's status category. You can filter it by
61+
It lists every issue in the report, Done first and the rest after, by Jira's status category. Related issues, which
62+
the report holds only because another issue relates to them, come last in a group of their own. Hover over a key to
63+
see its relations, such as `is blocked by SHOP-44`. You can filter it by
6164
key or summary, or show only the chosen issues.
6265

6366
Under each issue are the services it touches, each with one of:
@@ -96,11 +99,22 @@ The **Release** column sits next to the services and stays in view while they sc
9699
screen. While a service is open, the column folds into a strip with the number of picked tags and tasks, so the rows
97100
get the full width. Clicking the strip closes the service.
98101

102+
Above the tabs, **Left behind by these picks** lists what the picked tags leave out for every Jira task they ship,
103+
and for every issue related to one:
104+
105+
- a service where the task or related issue has changes that no pick covers, with the tag it needs or why there is
106+
none: `no tag yet`, `not merged`, `merged into <branch>`, `in prod or not merged`, `not in this report`, or
107+
`failed to collect`;
108+
- a related issue with no changes at all that is not done in Jira, with its status.
109+
110+
Changes already in production and closed merge requests are not listed.
111+
99112
It turns the picked tags into four tabs. Each has a copy button and a text box to copy from by hand, since browsers
100113
allow the copy button only over HTTPS.
101114

102115
- **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with
103-
summary and a status badge: grey for to do, blue for in progress, green for done. Copy them one per line, or as a
116+
summary, a status badge (grey for to do, blue for in progress, green for done), and the merge requests or commits
117+
that bring each one, such as `from api !57, web !120`. Copy them one per line, or as a
104118
JQL `key in (...)` clause.
105119
- **Tag pipelines**: the pipeline of each picked tag, as a Markdown list.
106120
- **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. A service

‎release_scope/_static/index.html‎

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
.issue-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
127127
.issue-row.chosen { background: color-mix(in srgb, var(--accent) 10%, transparent); }
128128
.issue-row code { font-weight: 500; }
129+
.gaps ul { margin: 4px 0 12px; padding-left: 18px; }
129130
.issue-row .summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
130131
.issue-row .reach { grid-column: 3 / -1; display: flex; gap: 4px; overflow: hidden; white-space: nowrap; }
131132
.rider-mark { padding: 0 6px; border: 1px dashed var(--warn); border-radius: 999px; color: var(--warn); font-size: 11px; white-space: nowrap; }
@@ -284,8 +285,9 @@ <h2 x-text="t('services')"></h2>
284285
</td>
285286
<td>
286287
<template x-for="key in row.jira_keys" :key="key.key">
287-
<div>
288+
<div class="row-key">
288289
<a :href="key.url" x-text="key.key"></a>
290+
<span class="muted via" :title="t('viaTitle')" x-text="viaText(key)"></span>
289291
<span x-show="issue(key.key)" :class="statusClass(issue(key.key))" x-text="issue(key.key) && issue(key.key).status"></span>
290292
<span x-text="issue(key.key) ? issue(key.key).summary : ''"></span>
291293
</div>
@@ -354,7 +356,7 @@ <h2><span x-text="t('chooseIssues')"></span>
354356
<label class="issue-row" :class="{ chosen: chosen.includes(key) }">
355357
<input type="checkbox" :checked="chosen.includes(key)" @change="toggleIssue(key)">
356358
<span class="dot" :class="'dot-' + (issue(key).status_category || 'new')" :title="issue(key).status"></span>
357-
<code x-text="key"></code>
359+
<code :title="relationsText(key)" x-text="key"></code>
358360
<span class="summary" :title="issue(key).summary" x-text="issue(key).summary"></span>
359361
<span class="rider-mark" :title="t('shipsAnywayTitle')" x-text="t('shipsAnyway')"
360362
:style="{ visibility: shipsAnyway(key) ? 'visible' : 'hidden' }"></span>
@@ -374,6 +376,12 @@ <h2 x-text="t('release')"></h2>
374376
<p x-show="!pickable.length" class="muted" x-text="t('noCandidates')"></p>
375377
<p x-show="pickable.length && !picked.length" class="muted" x-text="t('pickToFill')"></p>
376378
<p x-show="copyFailed" class="warn" x-text="t('copyFailed')"></p>
379+
<div class="gaps" x-show="picked.length && gaps.length">
380+
<p class="warn" x-text="t('gapsTitle')"></p>
381+
<ul>
382+
<template x-for="gap in gaps" :key="gap"><li x-text="gap"></li></template>
383+
</ul>
384+
</div>
377385

378386
<div class="outputs" x-show="picked.length">
379387
<div class="tabs">
@@ -394,6 +402,7 @@ <h2 x-text="t('release')"></h2>
394402
<span x-show="task.issue" :class="statusClass(task.issue)" x-text="task.issue && task.issue.status"></span>
395403
<span x-text="task.issue ? task.issue.summary : ''"></span>
396404
<span x-show="isMissing(task.key)" class="warn" x-text="t('notInJira')"></span>
405+
<span x-show="task.sources.length" class="muted sources" x-text="t('taskFrom', { items: task.sources.join(', ') })"></span>
397406
</li>
398407
</template>
399408
</ul>
@@ -551,6 +560,15 @@ <h2 x-text="t('release')"></h2>
551560
planAlso: "also ships",
552561
planNoTag: "{keys}: no tag yet.",
553562
planElsewhere: "{items}, nothing to pick",
563+
groupRelated: "Related",
564+
viaNamed: "named",
565+
viaLinked: "linked",
566+
viaTitle: "named: the merge request or commit mentions the key; linked: the Jira issue links to this change",
567+
taskFrom: "from {items}",
568+
gapsTitle: "Left behind by these picks:",
569+
gapNeeds: "needs {tag}",
570+
relationParent: "has parent",
571+
relationSubtask: "has subtask",
554572
},
555573
ru: {
556574
language: "Язык",
@@ -651,6 +669,15 @@ <h2 x-text="t('release')"></h2>
651669
planAlso: "также войдут",
652670
planNoTag: "{keys}: тега ещё нет.",
653671
planElsewhere: "{items}, выбирать нечего",
672+
groupRelated: "Связанные",
673+
viaNamed: "в тексте",
674+
viaLinked: "по ссылке",
675+
viaTitle: "в тексте: ключ упомянут в MR или коммите; по ссылке: задача в Jira ссылается на это изменение",
676+
taskFrom: "из {items}",
677+
gapsTitle: "Не войдёт с этими тегами:",
678+
gapNeeds: "нужен {tag}",
679+
relationParent: "родитель",
680+
relationSubtask: "подзадача",
654681
},
655682
};
656683

@@ -881,13 +908,64 @@ <h2 x-text="t('release')"></h2>
881908
},
882909
get tasks() {
883910
const tasks = new Map();
884-
for (const { candidate } of this.picked) {
911+
for (const { service, candidate } of this.picked) {
912+
const shipped = service.rows.slice(this.tagRow(service, candidate.tag.name)).filter((row) => row.in_scope);
885913
for (const key of candidate.jira_keys) {
886-
if (!tasks.has(key.key)) tasks.set(key.key, { key: key.key, url: key.url, issue: this.issue(key.key) });
914+
if (!tasks.has(key.key)) tasks.set(key.key, { key: key.key, url: key.url, issue: this.issue(key.key), sources: [] });
915+
tasks.get(key.key).sources.push(
916+
...shipped
917+
.filter((row) => row.jira_keys.some((item) => item.key === key.key))
918+
.map((row) => `${this.shortName(service.project)} ${this.changeLabel(row)}`),
919+
);
887920
}
888921
}
889922
return [...tasks.values()];
890923
},
924+
get gaps() {
925+
const starts = new Map(this.picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)]));
926+
const gaps = new Set();
927+
const check = (source, relation, key) => {
928+
const via = relation ? ` ${this.relationLabel(relation)} ${key}` : "";
929+
const reach = this.reach[key] || [];
930+
for (const item of reach) {
931+
const reason = this.gapReason(item, starts);
932+
if (reason) gaps.add(`${source}${via} → ${this.shortName(item.project)}: ${reason}`);
933+
}
934+
const issue = this.issue(key);
935+
if (relation && !reach.length && issue && issue.status_category !== "done") gaps.add(`${source}${via}: ${issue.status}`);
936+
};
937+
for (const task of this.tasks) {
938+
check(task.key, null, task.key);
939+
for (const relation of task.issue ? task.issue.relations : []) check(task.key, relation, relation.key);
940+
}
941+
return [...gaps];
942+
},
943+
gapReason(item, starts) {
944+
if (item.state === "tag" || item.state === "untagged") {
945+
const start = starts.get(item.project);
946+
if (start !== undefined && start <= item.index) return "";
947+
return item.state === "tag" ? this.t("gapNeeds", { tag: item.tag }) : this.t("reachUntagged");
948+
}
949+
return ["production", "older", "closed"].includes(item.state) ? "" : this.reachLabel(item);
950+
},
951+
tagRow(service, tag) {
952+
return service.rows.findIndex((row) => row.tags.some((item) => item.name === tag));
953+
},
954+
changeLabel(row) {
955+
return row.merge_requests.length ? row.merge_requests.map((item) => "!" + item.iid).join(" ") : row.commits[0].short_sha;
956+
},
957+
viaText(key) {
958+
return key.via.map((source) => this.t(source === "named" ? "viaNamed" : "viaLinked")).join(", ");
959+
},
960+
relationLabel(relation) {
961+
if (relation.kind === "parent") return this.t("relationParent");
962+
if (relation.kind === "subtask") return this.t("relationSubtask");
963+
return relation.label;
964+
},
965+
relationsText(key) {
966+
const issue = this.issue(key);
967+
return issue ? issue.relations.map((relation) => `${this.relationLabel(relation)} ${relation.key}`).join("\n") : "";
968+
},
891969
get taskKeys() {
892970
return this.tasks.map((task) => task.key).join("\n");
893971
},
@@ -938,9 +1016,11 @@ <h2 x-text="t('release')"></h2>
9381016
(!text || `${key} ${this.issue(key).summary}`.toLowerCase().includes(text)),
9391017
);
9401018
const done = (key) => this.issue(key).status_category === "done";
1019+
const related = (key) => this.issue(key).via_relation;
9411020
return [
942-
{ name: "groupDone", keys: keys.filter(done) },
943-
{ name: "groupNotDone", keys: keys.filter((key) => !done(key)) },
1021+
{ name: "groupDone", keys: keys.filter((key) => !related(key) && done(key)) },
1022+
{ name: "groupNotDone", keys: keys.filter((key) => !related(key) && !done(key)) },
1023+
{ name: "groupRelated", keys: keys.filter(related) },
9441024
].filter((group) => group.keys.length);
9451025
},
9461026
get plan() {
@@ -1023,7 +1103,7 @@ <h2 x-text="t('release')"></h2>
10231103
},
10241104
untickMissed(service) {
10251105
const tag = this.picks[service.project];
1026-
const picked = tag ? service.rows.findIndex((row) => row.tags.some((item) => item.name === tag)) : -1;
1106+
const picked = tag ? this.tagRow(service, tag) : -1;
10271107
const missed = this.chosen.filter((key) =>
10281108
this.reach[key].some((item) => item.project === service.project && item.state === "tag" && (picked === -1 || picked > item.index)));
10291109
if (!missed.length) return;

‎tests/test_browser.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def test_demo_lists_its_services_and_picks_tags_for_an_issue(page: Page, demo_si
117117
names = page.locator("details.service .name")
118118
expect(names).to_have_text(["admin", "api", "web", "worker"])
119119
expect(names.first).to_have_attribute("title", "shop/admin")
120-
expect(page.locator(".issue-row code")).to_have_count(6)
120+
expect(page.locator(".issue-row code")).to_have_count(8)
121121
_issue(page, "SHOP-38").check()
122122
expect(page).to_have_url(re.compile(r"#issues=SHOP-38&shop/api=2\.5\.0&shop/web=1\.9\.0$"))
123123
assert errors == []
@@ -311,3 +311,69 @@ def test_demo_service_rows_line_up_with_and_without_a_pick(page: Page, demo_site
311311
assert names[0] == pytest.approx(names[1], abs=0.5)
312312
assert max(states) - min(states) < 0.5, states
313313
assert errors == []
314+
315+
316+
def test_row_keys_say_whether_the_change_names_them_or_jira_links_them(
317+
page: Page, site: str, errors: list[str]
318+
) -> None:
319+
page.goto(site)
320+
321+
page.locator("details.service summary").click()
322+
323+
row_key: typing.Final = page.locator(".row-key", has=page.locator("a", has_text="SHOP-12")).first
324+
expect(row_key.locator(".via")).to_have_text("named, linked")
325+
expect(page.locator(".row-key", has=page.locator("a", has_text="SHOP-9")).locator(".via")).to_have_text("named")
326+
assert errors == []
327+
328+
329+
def test_demo_a_hand_pick_lists_the_changes_it_leaves_behind(page: Page, demo_site: str, errors: list[str]) -> None:
330+
page.goto(demo_site)
331+
gaps: typing.Final = page.locator(".gaps li")
332+
333+
api: typing.Final = _demo_service(page, "shop/api")
334+
api.locator("summary").click()
335+
api.locator(".tag", has_text="2.5.0").get_by_role("button").click()
336+
337+
expect(gaps).to_have_text(
338+
[
339+
"SHOP-38 → web: needs 1.9.0",
340+
"SHOP-38 → mobile: not in this report",
341+
"SHOP-38 is blocked by SHOP-44 → admin: not merged",
342+
"SHOP-35 → worker: needs 0.15.0",
343+
]
344+
)
345+
web: typing.Final = _demo_service(page, "shop/web")
346+
web.locator("summary").click()
347+
web.locator(".tag", has_text="1.9.0").get_by_role("button").click()
348+
expect(gaps).to_have_text(
349+
[
350+
"SHOP-38 → mobile: not in this report",
351+
"SHOP-38 is blocked by SHOP-44 → admin: not merged",
352+
"SHOP-35 → worker: needs 0.15.0",
353+
"SHOP-36 is blocked by SHOP-45: To Do",
354+
]
355+
)
356+
assert errors == []
357+
358+
359+
def test_demo_tasks_name_the_changes_that_bring_them(page: Page, demo_site: str, errors: list[str]) -> None:
360+
page.goto(demo_site)
361+
362+
_issue(page, "SHOP-38").check()
363+
364+
task: typing.Final = page.locator(".outputs li", has=page.locator("a", has_text="SHOP-38"))
365+
expect(task.locator(".sources")).to_have_text("from api !57, web !120")
366+
expect(page.locator(".outputs li", has=page.locator("a", has_text="SHOP-35")).locator(".sources")).to_have_text(
367+
"from api !55"
368+
)
369+
assert errors == []
370+
371+
372+
def test_demo_related_issues_have_their_own_group_and_relations_show_on_hover(
373+
page: Page, demo_site: str, errors: list[str]
374+
) -> None:
375+
page.goto(demo_site)
376+
377+
expect(page.locator(".issue-group")).to_have_text(["Done · 3", "Not done · 3", "Related · 2"])
378+
expect(page.locator(".issue-row code", has_text="SHOP-38")).to_have_attribute("title", "is blocked by SHOP-44")
379+
assert errors == []

0 commit comments

Comments
 (0)