Skip to content

Commit bbbabfb

Browse files
authored
Name services by repo and group what picks leave behind by task (#56)
1 parent d43f22d commit bbbabfb

4 files changed

Lines changed: 214 additions & 69 deletions

File tree

‎demo/scenario.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ class DemoIssue:
221221
links=(
222222
f"{ENDPOINT}/shop/api/-/merge_requests/57",
223223
f"{ENDPOINT}/shop/web/-/merge_requests/120",
224-
f"{ENDPOINT}/shop/mobile/-/merge_requests/7",
224+
f"{ENDPOINT}/partners/mobile/-/merge_requests/7",
225225
),
226226
blocked_by=("SHOP-44",),
227227
),
@@ -244,7 +244,7 @@ class DemoIssue:
244244
summary="Only managers may export orders",
245245
status="In Progress",
246246
category="indeterminate",
247-
links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15",),
247+
links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15", f"{ENDPOINT}/legacy/admin/-/merge_requests/3"),
248248
),
249249
DemoIssue(key="SHOP-45", summary="Pick the dark palette with design", status="To Do", category="new"),
250250
DemoIssue(key="SHOP-46", summary="Dark mode in emails", status="To Do", category="new", parent="SHOP-36"),

‎docs/page.md‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ Times show in the browser's time zone. Hover over one to see the value the repor
1616

1717
## Services
1818

19-
Each service is one line, named without the group path all services share, such as `api` for `shop/api`.
19+
Each service is one line, named by its repository, such as `api` for `shop/api`. When two projects on the page share
20+
a name, each keeps as many parent groups as it takes to tell them apart, such as `shop/admin` and `legacy/admin`.
2021
Hover over a name to see the full path. It shows:
2122

2223
- what production runs and the picked tag;
@@ -99,14 +100,21 @@ The **Release** column sits next to the services and stays in view while they sc
99100
screen. While a service is open, the column folds into a strip with the number of picked tags and tasks, so the rows
100101
get the full width. Clicking the strip closes the service.
101102

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 a task depends on: one that blocks it through Jira's `Blocks` link type, and its subtasks.
103+
Above the tabs, **Left behind by these picks** lists what the picked tags leave out, grouped by each Jira task they
104+
ship. A group opens with the task's key, linked to Jira, and its summary. Under it come the services the task still
105+
misses, then each issue the task depends on: one that blocks it through Jira's `Blocks` link type, and its subtasks.
106+
Those issues show their key and summary too, and the services they miss sit under them.
104107

105108
- a service where the task or an issue it depends on has changes that no pick covers, with the tag it needs or why
106109
there is none: `no tag yet`, `not merged`, `merged into <branch>`, `in prod or not merged`, `not in this report`, or
107110
`failed to collect`;
108111
- an issue the task depends on with no changes at all that is not done in Jira, with its status.
109112

113+
A service that needs a tag has a **Pick** button for it, which picks the tag without opening the service. `no tag yet`
114+
links to create the next tag, and `not merged`, `merged into <branch>`, and `in prod or not merged` link to the merge
115+
requests or commits. When any line needs a tag, a button next to the title picks every needed tag at once, the newest
116+
one per service. A newer pick can ship more tasks, and the list then shows what those leave behind.
117+
110118
Changes already in production and closed merge requests are not listed. Other relations, such as `blocks`,
111119
`relates to`, or a parent, do not hold a release back; they show only when you hover over the issue's key.
112120

‎release_scope/_static/index.html‎

Lines changed: 117 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,14 @@
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; }
129+
.gaps { margin: 4px 0 12px; }
130+
.gaps > p { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
131+
.gap-task { padding: 6px 0; border-top: 1px solid var(--border); }
132+
.gap-head, .gap-dep-head, .gap-line { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-height: 24px; }
133+
.gap-head .dot, .gap-dep-head .dot { align-self: center; flex: none; }
134+
.gap-dep-head, .gap-line { padding-left: 16px; }
135+
.gap-line.nested { padding-left: 32px; }
136+
.gap-line button { padding: 0 8px; font-size: 12px; }
130137
.row-key.via-jira > a { text-decoration: underline dashed; text-underline-offset: 3px; }
131138
.link-icon { width: 13px; height: 13px; margin-right: 2px; vertical-align: -2px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
132139
.issue-row .summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -380,10 +387,48 @@ <h2 x-text="t('release')"></h2>
380387
<p x-show="pickable.length && !picked.length" class="muted" x-text="t('pickToFill')"></p>
381388
<p x-show="copyFailed" class="warn" x-text="t('copyFailed')"></p>
382389
<div class="gaps" x-show="picked.length && gaps.length">
383-
<p class="warn" x-text="t('gapsTitle')"></p>
384-
<ul>
385-
<template x-for="gap in gaps" :key="gap"><li x-text="gap"></li></template>
386-
</ul>
390+
<p class="warn"><span x-text="t('gapsTitle')"></span>
391+
<button type="button" x-show="neededPicks.length" @click="pickNeeded()" x-text="t('gapPickAll', { count: neededPicks.length })"></button></p>
392+
<template x-for="task in gaps" :key="task.key">
393+
<div class="gap-task">
394+
<div class="gap-head">
395+
<span class="dot" :class="'dot-' + (task.issue ? task.issue.status_category : 'new')" :title="task.issue && task.issue.status"></span>
396+
<a :href="task.url" x-text="task.key"></a>
397+
<span x-text="task.issue ? task.issue.summary : ''"></span>
398+
</div>
399+
<template x-for="(item, index) in gapRows(task)" :key="index">
400+
<div>
401+
<template x-if="item.dep">
402+
<div class="gap-dep-head">
403+
<span class="muted" x-text="relationLabel(item.dep.relation)"></span>
404+
<span class="dot" :class="'dot-' + (item.dep.issue.status_category || 'new')" :title="item.dep.issue.status"></span>
405+
<a :href="item.dep.issue.url" x-text="item.dep.key"></a>
406+
<span class="muted" x-text="item.dep.issue.summary"></span>
407+
<template x-if="!item.dep.lines.length">
408+
<span :class="statusClass(item.dep.issue)" x-text="item.dep.issue.status"></span>
409+
</template>
410+
</div>
411+
</template>
412+
<template x-if="item.line">
413+
<div class="gap-line" :class="{ nested: item.nested }">
414+
<span :title="item.line.project" x-text="shortName(item.line.project)"></span>
415+
<span class="cell" :class="'cell-' + (item.line.state === 'tag' ? 'untagged' : item.line.state)" x-text="item.line.reason"></span>
416+
<template x-if="item.line.state === 'tag'">
417+
<button type="button" @click="pickGap(item.line)" x-text="t('gapPick', { tag: item.line.tag })"></button>
418+
</template>
419+
<template x-if="item.line.state === 'untagged'">
420+
<a :href="serviceOf(item.line.project).untagged.create_url"
421+
x-text="serviceOf(item.line.project).untagged.next_tag ? t('createTag', { name: serviceOf(item.line.project).untagged.next_tag }) : t('createAnyTag')"></a>
422+
</template>
423+
<template x-for="change in gapChanges(item.line)" :key="change.url">
424+
<a :href="change.url" x-text="change.label"></a>
425+
</template>
426+
</div>
427+
</template>
428+
</div>
429+
</template>
430+
</div>
431+
</template>
387432
</div>
388433

389434
<div class="outputs" x-show="picked.length">
@@ -569,6 +614,8 @@ <h2 x-text="t('release')"></h2>
569614
taskFrom: "from {items}",
570615
gapsTitle: "Left behind by these picks:",
571616
gapNeeds: "needs {tag}",
617+
gapPick: "Pick {tag}",
618+
gapPickAll: { one: "Pick {count} needed tag", other: "Pick {count} needed tags" },
572619
relationParent: "has parent",
573620
relationSubtask: "has subtask",
574621
},
@@ -677,6 +724,8 @@ <h2 x-text="t('release')"></h2>
677724
taskFrom: "из {items}",
678725
gapsTitle: "Не войдёт в релиз с выбранными тегами:",
679726
gapNeeds: "нужен тег {tag}",
727+
gapPick: "Выбрать {tag}",
728+
gapPickAll: { one: "Выбрать {count} нужный тег", few: "Выбрать {count} нужных тега", many: "Выбрать {count} нужных тегов" },
680729
relationParent: "входит в",
681730
relationSubtask: "включает подзадачу",
682731
},
@@ -760,6 +809,23 @@ <h2 x-text="t('release')"></h2>
760809
return { state: "elsewhere" };
761810
};
762811

812+
const namesOf = (report) => {
813+
const issues = report.jira ? Object.values(report.jira.issues) : [];
814+
const projects = new Set([
815+
...report.services.map((service) => service.project),
816+
...issues.flatMap((issue) => issue.links.map((link) => link.project)),
817+
]);
818+
const paths = [...projects].map((project) => project.split("/"));
819+
const suffix = (parts, size) => parts.slice(-size).join("/");
820+
return Object.fromEntries(
821+
paths.map((parts) => {
822+
let size = 1;
823+
while (size < parts.length && paths.some((other) => other !== parts && suffix(other, size) === suffix(parts, size))) size++;
824+
return [parts.join("/"), suffix(parts, size)];
825+
}),
826+
);
827+
};
828+
763829
const reachOf = (report) => {
764830
const issues = report.jira ? report.jira.issues : {};
765831
const reach = Object.fromEntries(Object.keys(issues).map((key) => [key, []]));
@@ -814,6 +880,7 @@ <h2 x-text="t('release')"></h2>
814880
lang: "en",
815881
theme: "auto",
816882
reach: {},
883+
names: {},
817884
chosen: [],
818885
autoPicks: {},
819886
issueFilter: "",
@@ -833,6 +900,7 @@ <h2 x-text="t('release')"></h2>
833900
this.picks[service.project] = service.candidates.some((item) => item.tag.name === tag) ? tag : "";
834901
}
835902
this.reach = reachOf(report);
903+
this.names = namesOf(report);
836904
this.report = report;
837905
this.readHash();
838906
window.addEventListener("hashchange", () => this.readHash());
@@ -883,17 +951,11 @@ <h2 x-text="t('release')"></h2>
883951
get listed() {
884952
return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length);
885953
},
886-
get sharedPrefix() {
887-
const paths = this.listed.map((service) => service.project.split("/"));
888-
if (paths.length < 2) return "";
889-
const shared = paths.reduce((prefix, parts) => {
890-
const end = prefix.findIndex((part, index) => part !== parts[index] || index >= parts.length - 1);
891-
return end === -1 ? prefix : prefix.slice(0, end);
892-
});
893-
return shared.length ? shared.join("/") + "/" : "";
894-
},
895954
shortName(project) {
896-
return this.sharedPrefix && project.startsWith(this.sharedPrefix) ? project.slice(this.sharedPrefix.length) : project;
955+
return this.names[project] || project;
956+
},
957+
serviceOf(project) {
958+
return this.report.services.find((service) => service.project === project);
897959
},
898960
get pickable() {
899961
return this.listed.filter((service) => service.candidates.length);
@@ -927,22 +989,46 @@ <h2 x-text="t('release')"></h2>
927989
},
928990
get gaps() {
929991
const starts = new Map(this.picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)]));
930-
const gaps = new Set();
931-
const check = (source, relation, key) => {
932-
const via = relation ? ` ${this.relationLabel(relation)} ${key}` : "";
933-
const reach = this.reach[key] || [];
934-
for (const item of reach) {
935-
const reason = this.gapReason(item, starts);
936-
if (reason) gaps.add(`${source}${via} → ${this.shortName(item.project)}: ${reason}`);
992+
const lines = (key) =>
993+
(this.reach[key] || []).map((item) => ({ ...item, key, reason: this.gapReason(item, starts) })).filter((item) => item.reason);
994+
return this.tasks
995+
.map((task) => ({
996+
...task,
997+
lines: lines(task.key),
998+
deps: (task.issue ? task.issue.relations.filter(dependsOn) : [])
999+
.map((relation) => ({ relation, key: relation.key, issue: this.issue(relation.key), lines: lines(relation.key) }))
1000+
.filter((dep) =>
1001+
dep.lines.length || (dep.issue && !(this.reach[dep.key] || []).length && dep.issue.status_category !== "done")),
1002+
}))
1003+
.filter((task) => task.lines.length || task.deps.length);
1004+
},
1005+
gapRows(task) {
1006+
return [
1007+
...task.lines.map((line) => ({ line })),
1008+
...task.deps.flatMap((dep) => [{ dep }, ...dep.lines.map((line) => ({ line, nested: true }))]),
1009+
];
1010+
},
1011+
gapChanges(line) {
1012+
if (!["open", "branch", "elsewhere"].includes(line.state)) return [];
1013+
return this.issue(line.key).links
1014+
.filter((link) => link.project === line.project)
1015+
.map((link) => ({ url: link.url, label: link.iid != null ? "!" + link.iid : link.sha.slice(0, 8) }));
1016+
},
1017+
get neededPicks() {
1018+
const needed = new Map();
1019+
for (const task of this.gaps) {
1020+
for (const line of [...task.lines, ...task.deps.flatMap((dep) => dep.lines)]) {
1021+
const known = needed.get(line.project);
1022+
if (line.state === "tag" && (!known || line.tagIndex < known.tagIndex)) needed.set(line.project, line);
9371023
}
938-
const issue = this.issue(key);
939-
if (relation && !reach.length && issue && issue.status_category !== "done") gaps.add(`${source}${via}: ${issue.status}`);
940-
};
941-
for (const task of this.tasks) {
942-
check(task.key, null, task.key);
943-
for (const relation of task.issue ? task.issue.relations.filter(dependsOn) : []) check(task.key, relation, relation.key);
9441024
}
945-
return [...gaps];
1025+
return [...needed.values()];
1026+
},
1027+
pickGap(line) {
1028+
this.pick(this.serviceOf(line.project), line.tag);
1029+
},
1030+
pickNeeded() {
1031+
for (const line of this.neededPicks) this.pickGap(line);
9461032
},
9471033
gapReason(item, starts) {
9481034
if (item.state === "tag" || item.state === "untagged") {
@@ -1076,7 +1162,7 @@ <h2 x-text="t('release')"></h2>
10761162
return entry.best || entry.untagged.length ? items : this.t("planElsewhere", { items });
10771163
},
10781164
reachText(item) {
1079-
return `${item.project.split("/").pop()} ${this.reachLabel(item)}`;
1165+
return `${this.shortName(item.project)} ${this.reachLabel(item)}`;
10801166
},
10811167
showIssues(view) {
10821168
this.issueView = view;
@@ -1102,7 +1188,7 @@ <h2 x-text="t('release')"></h2>
11021188
pick(service, tag, element) {
11031189
this.picks[service.project] = tag;
11041190
this.untickMissed(service);
1105-
element.closest("details").open = false;
1191+
if (element) element.closest("details").open = false;
11061192
this.writeHash();
11071193
},
11081194
untickMissed(service) {

0 commit comments

Comments
 (0)