Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions demo/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ class DemoIssue:
links=(
f"{ENDPOINT}/shop/api/-/merge_requests/57",
f"{ENDPOINT}/shop/web/-/merge_requests/120",
f"{ENDPOINT}/shop/mobile/-/merge_requests/7",
f"{ENDPOINT}/partners/mobile/-/merge_requests/7",
),
blocked_by=("SHOP-44",),
),
Expand All @@ -244,7 +244,7 @@ class DemoIssue:
summary="Only managers may export orders",
status="In Progress",
category="indeterminate",
links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15",),
links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15", f"{ENDPOINT}/legacy/admin/-/merge_requests/3"),
),
DemoIssue(key="SHOP-45", summary="Pick the dark palette with design", status="To Do", category="new"),
DemoIssue(key="SHOP-46", summary="Dark mode in emails", status="To Do", category="new", parent="SHOP-36"),
Expand Down
14 changes: 11 additions & 3 deletions docs/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ Times show in the browser's time zone. Hover over one to see the value the repor

## Services

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

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

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

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

A service that needs a tag has a **Pick** button for it, which picks the tag without opening the service. `no tag yet`
links to create the next tag, and `not merged`, `merged into <branch>`, and `in prod or not merged` link to the merge
requests or commits. When any line needs a tag, a button next to the title picks every needed tag at once, the newest
one per service. A newer pick can ship more tasks, and the list then shows what those leave behind.

Changes already in production and closed merge requests are not listed. Other relations, such as `blocks`,
`relates to`, or a parent, do not hold a release back; they show only when you hover over the issue's key.

Expand Down
148 changes: 117 additions & 31 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,14 @@
.issue-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.issue-row.chosen { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.issue-row code { font-weight: 500; }
.gaps ul { margin: 4px 0 12px; padding-left: 18px; }
.gaps { margin: 4px 0 12px; }
.gaps > p { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
.gap-task { padding: 6px 0; border-top: 1px solid var(--border); }
.gap-head, .gap-dep-head, .gap-line { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-height: 24px; }
.gap-head .dot, .gap-dep-head .dot { align-self: center; flex: none; }
.gap-dep-head, .gap-line { padding-left: 16px; }
.gap-line.nested { padding-left: 32px; }
.gap-line button { padding: 0 8px; font-size: 12px; }
.row-key.via-jira > a { text-decoration: underline dashed; text-underline-offset: 3px; }
.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; }
.issue-row .summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Expand Down Expand Up @@ -380,10 +387,48 @@ <h2 x-text="t('release')"></h2>
<p x-show="pickable.length && !picked.length" class="muted" x-text="t('pickToFill')"></p>
<p x-show="copyFailed" class="warn" x-text="t('copyFailed')"></p>
<div class="gaps" x-show="picked.length && gaps.length">
<p class="warn" x-text="t('gapsTitle')"></p>
<ul>
<template x-for="gap in gaps" :key="gap"><li x-text="gap"></li></template>
</ul>
<p class="warn"><span x-text="t('gapsTitle')"></span>
<button type="button" x-show="neededPicks.length" @click="pickNeeded()" x-text="t('gapPickAll', { count: neededPicks.length })"></button></p>
<template x-for="task in gaps" :key="task.key">
<div class="gap-task">
<div class="gap-head">
<span class="dot" :class="'dot-' + (task.issue ? task.issue.status_category : 'new')" :title="task.issue && task.issue.status"></span>
<a :href="task.url" x-text="task.key"></a>
<span x-text="task.issue ? task.issue.summary : ''"></span>
</div>
<template x-for="(item, index) in gapRows(task)" :key="index">
<div>
<template x-if="item.dep">
<div class="gap-dep-head">
<span class="muted" x-text="relationLabel(item.dep.relation)"></span>
<span class="dot" :class="'dot-' + (item.dep.issue.status_category || 'new')" :title="item.dep.issue.status"></span>
<a :href="item.dep.issue.url" x-text="item.dep.key"></a>
<span class="muted" x-text="item.dep.issue.summary"></span>
<template x-if="!item.dep.lines.length">
<span :class="statusClass(item.dep.issue)" x-text="item.dep.issue.status"></span>
</template>
</div>
</template>
<template x-if="item.line">
<div class="gap-line" :class="{ nested: item.nested }">
<span :title="item.line.project" x-text="shortName(item.line.project)"></span>
<span class="cell" :class="'cell-' + (item.line.state === 'tag' ? 'untagged' : item.line.state)" x-text="item.line.reason"></span>
<template x-if="item.line.state === 'tag'">
<button type="button" @click="pickGap(item.line)" x-text="t('gapPick', { tag: item.line.tag })"></button>
</template>
<template x-if="item.line.state === 'untagged'">
<a :href="serviceOf(item.line.project).untagged.create_url"
x-text="serviceOf(item.line.project).untagged.next_tag ? t('createTag', { name: serviceOf(item.line.project).untagged.next_tag }) : t('createAnyTag')"></a>
</template>
<template x-for="change in gapChanges(item.line)" :key="change.url">
<a :href="change.url" x-text="change.label"></a>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
</div>

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

const namesOf = (report) => {
const issues = report.jira ? Object.values(report.jira.issues) : [];
const projects = new Set([
...report.services.map((service) => service.project),
...issues.flatMap((issue) => issue.links.map((link) => link.project)),
]);
const paths = [...projects].map((project) => project.split("/"));
const suffix = (parts, size) => parts.slice(-size).join("/");
return Object.fromEntries(
paths.map((parts) => {
let size = 1;
while (size < parts.length && paths.some((other) => other !== parts && suffix(other, size) === suffix(parts, size))) size++;
return [parts.join("/"), suffix(parts, size)];
}),
);
};

const reachOf = (report) => {
const issues = report.jira ? report.jira.issues : {};
const reach = Object.fromEntries(Object.keys(issues).map((key) => [key, []]));
Expand Down Expand Up @@ -814,6 +880,7 @@ <h2 x-text="t('release')"></h2>
lang: "en",
theme: "auto",
reach: {},
names: {},
chosen: [],
autoPicks: {},
issueFilter: "",
Expand All @@ -833,6 +900,7 @@ <h2 x-text="t('release')"></h2>
this.picks[service.project] = service.candidates.some((item) => item.tag.name === tag) ? tag : "";
}
this.reach = reachOf(report);
this.names = namesOf(report);
this.report = report;
this.readHash();
window.addEventListener("hashchange", () => this.readHash());
Expand Down Expand Up @@ -883,17 +951,11 @@ <h2 x-text="t('release')"></h2>
get listed() {
return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length);
},
get sharedPrefix() {
const paths = this.listed.map((service) => service.project.split("/"));
if (paths.length < 2) return "";
const shared = paths.reduce((prefix, parts) => {
const end = prefix.findIndex((part, index) => part !== parts[index] || index >= parts.length - 1);
return end === -1 ? prefix : prefix.slice(0, end);
});
return shared.length ? shared.join("/") + "/" : "";
},
shortName(project) {
return this.sharedPrefix && project.startsWith(this.sharedPrefix) ? project.slice(this.sharedPrefix.length) : project;
return this.names[project] || project;
},
serviceOf(project) {
return this.report.services.find((service) => service.project === project);
},
get pickable() {
return this.listed.filter((service) => service.candidates.length);
Expand Down Expand Up @@ -927,22 +989,46 @@ <h2 x-text="t('release')"></h2>
},
get gaps() {
const starts = new Map(this.picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)]));
const gaps = new Set();
const check = (source, relation, key) => {
const via = relation ? ` ${this.relationLabel(relation)} ${key}` : "";
const reach = this.reach[key] || [];
for (const item of reach) {
const reason = this.gapReason(item, starts);
if (reason) gaps.add(`${source}${via} → ${this.shortName(item.project)}: ${reason}`);
const lines = (key) =>
(this.reach[key] || []).map((item) => ({ ...item, key, reason: this.gapReason(item, starts) })).filter((item) => item.reason);
return this.tasks
.map((task) => ({
...task,
lines: lines(task.key),
deps: (task.issue ? task.issue.relations.filter(dependsOn) : [])
.map((relation) => ({ relation, key: relation.key, issue: this.issue(relation.key), lines: lines(relation.key) }))
.filter((dep) =>
dep.lines.length || (dep.issue && !(this.reach[dep.key] || []).length && dep.issue.status_category !== "done")),
}))
.filter((task) => task.lines.length || task.deps.length);
},
gapRows(task) {
return [
...task.lines.map((line) => ({ line })),
...task.deps.flatMap((dep) => [{ dep }, ...dep.lines.map((line) => ({ line, nested: true }))]),
];
},
gapChanges(line) {
if (!["open", "branch", "elsewhere"].includes(line.state)) return [];
return this.issue(line.key).links
.filter((link) => link.project === line.project)
.map((link) => ({ url: link.url, label: link.iid != null ? "!" + link.iid : link.sha.slice(0, 8) }));
},
get neededPicks() {
const needed = new Map();
for (const task of this.gaps) {
for (const line of [...task.lines, ...task.deps.flatMap((dep) => dep.lines)]) {
const known = needed.get(line.project);
if (line.state === "tag" && (!known || line.tagIndex < known.tagIndex)) needed.set(line.project, line);
}
const issue = this.issue(key);
if (relation && !reach.length && issue && issue.status_category !== "done") gaps.add(`${source}${via}: ${issue.status}`);
};
for (const task of this.tasks) {
check(task.key, null, task.key);
for (const relation of task.issue ? task.issue.relations.filter(dependsOn) : []) check(task.key, relation, relation.key);
}
return [...gaps];
return [...needed.values()];
},
pickGap(line) {
this.pick(this.serviceOf(line.project), line.tag);
},
pickNeeded() {
for (const line of this.neededPicks) this.pickGap(line);
},
gapReason(item, starts) {
if (item.state === "tag" || item.state === "untagged") {
Expand Down Expand Up @@ -1076,7 +1162,7 @@ <h2 x-text="t('release')"></h2>
return entry.best || entry.untagged.length ? items : this.t("planElsewhere", { items });
},
reachText(item) {
return `${item.project.split("/").pop()} ${this.reachLabel(item)}`;
return `${this.shortName(item.project)} ${this.reachLabel(item)}`;
},
showIssues(view) {
this.issueView = view;
Expand All @@ -1102,7 +1188,7 @@ <h2 x-text="t('release')"></h2>
pick(service, tag, element) {
this.picks[service.project] = tag;
this.untickMissed(service);
element.closest("details").open = false;
if (element) element.closest("details").open = false;
this.writeHash();
},
untickMissed(service) {
Expand Down
Loading
Loading