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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,27 @@ as one line: what production runs, the picked tag, how many merge requests or co
failed jobs of the picked tag, or of the newest tag before a pick, with the ones allowed to fail counted apart, a mark
when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`, and how many rows have no tag yet, with a `+` when the cut
range holds no tag, so more untagged rows may lie below it. The failed jobs are those of the tag's pipeline and of
the main pipeline of its commit; failures in other rows show only when the line is opened.
the main pipeline of its commit; failures in other rows show only when the line is opened. A service whose
production runs the head of the default branch, with no rows left to ship, is marked green as fully deployed.
Opening a line shows the service's environments with when each was deployed, warnings, a link to create the next tag
on the head when rows have no tag yet, merge requests that are not merged yet, and its rows with tags and their
pipelines, merge requests or commits with when they were merged or committed, Jira keys with their status,
environments with the time each first ran the row, and failed jobs; rows out of scope are dimmed.
Links, including the GitLab settings pages that warnings and errors point to, open in a new
tab. Each tag has a **pick** button: picking it highlights the rows it ships and closes the line again. A `--jira`
report starts with each service's release tag picked. Services with neither a production deployment nor rows are left
tab. Each tag has a **pick** button: picking it highlights the rows it ships and closes the line again, which then
shows the picked tag as a blue chip. The chip's `×` clears the pick without opening the line. A `--jira` report starts
with each service's release tag picked. Services with neither a production deployment nor rows are left
out of the page.

The address keeps the picks after `#`, as `team/api=2.4.0&team/web=5.12.0`, so sharing or reloading the page keeps
them. An address with picks replaces the release tags of a `--jira` report; picks that are not in the report, such as
a tag that a later run no longer lists, are skipped with a warning. Clearing every pick empties the address, so a
reload starts from the release tags again.

**Release** at the bottom turns the picked tags into three lists and a release post, each with a copy button and a
text box to copy from by hand, since browsers allow the copy button only over HTTPS:
**Release**, in a column next to the services that stays in view while they scroll, or below them on a narrow screen,
turns the picked tags into three lists and a release post. 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. Each has a tab of its own, with a copy button and a text box
to copy from by hand, since browsers allow the copy button only over HTTPS:

- **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with
summary and a status badge: grey to do, blue in progress, green done. Copy them one per line or as a JQL
Expand Down
89 changes: 64 additions & 25 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@
[x-cloak] { display: none !important; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
main { max-width: 1680px; margin: 0 auto; padding: 16px; }
h1 { font-size: 24px; margin: 0 0 4px; }
h2 { font-size: 18px; margin: 32px 0 8px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
h3 { font-size: 15px; margin: 0 0 8px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code { font: 12px ui-monospace, monospace; }
Expand All @@ -57,22 +56,40 @@
.warn { color: var(--warn); }
.message { white-space: pre-line; }
.scroll { overflow-x: auto; }
.outputs { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 16px; }
.outputs .wide { grid-column: 1 / -1; }
.out-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 24px; align-items: start; }
.layout > aside { position: sticky; top: 0; max-height: 100vh; overflow-y: auto; padding: 0 4px 16px; }
.strip { display: none; writing-mode: vertical-rl; width: 40px; min-height: 240px; margin-top: 32px; padding: 12px 4px;
border-color: var(--accent); color: var(--accent); font-weight: 600; }
.layout.folded { grid-template-columns: minmax(0, 1fr) 44px; gap: 12px; }
.layout.folded .strip { display: block; }
.layout.folded .aside-body { display: none; }
@media (max-width: 1000px) {
.layout { display: block; }
.layout > aside { position: static; max-height: none; }
.layout.folded .strip { display: none; }
.layout.folded .aside-body { display: block; }
}
.tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0; }
.outputs textarea { min-height: 160px; }
.out-head { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
tr.out-of-scope td { opacity: 0.55; }
tr.shipped td { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.tag { white-space: nowrap; }
.service { border: 1px solid var(--border); border-radius: 6px; margin: 6px 0; }
.service > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; padding: 8px 12px; cursor: pointer;
list-style: none; }
.service[open] > summary { border-bottom: 1px solid var(--border); }
.service.deployed { border-left: 4px solid var(--good); background: color-mix(in srgb, var(--good) 6%, transparent); }
.service.deployed .name { color: var(--good); }
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 0 4px 0 10px; border-radius: 999px;
background: var(--accent); color: var(--bg); font-weight: 600; }
.chip button { border: 0; border-radius: 999px; padding: 0 6px; background: transparent; color: inherit; }
.chip button:hover { background: color-mix(in srgb, var(--bg) 25%, transparent); }
.service > summary::before { content: "▸"; color: var(--muted); }
.service[open] > summary::before { content: "▾"; }
.service .name { min-width: 200px; font-weight: 600; }
.service .body { padding: 8px 12px; }
.service .body > p:first-child { margin-top: 0; }
.picked-tag { font-weight: 600; }
.status { display: inline-block; padding: 0 8px; border: 1px solid currentColor; border-radius: 10px; font-size: 12px;
font-weight: 600; white-space: nowrap; color: var(--muted); }
.status-indeterminate { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
Expand Down Expand Up @@ -119,18 +136,23 @@ <h1 x-text="report.jira_scope.length ? t('titleIssues', { keys: report.jira_scop
<p class="bad message"><template x-for="(part, index) in messageParts(item)" :key="index"><span><template x-if="part.url"><a :href="part.url" x-text="part.text"></a></template><template x-if="!part.url"><span x-text="part.text"></span></template></span></template></p>
</template>

<div class="layout" :class="{ folded: openCount > 0 }">
<section>
<h2 x-text="t('services')"></h2>
<p x-show="pickable.length" class="muted" x-text="t('servicesIntro')"></p>
<p x-show="ignored.length" class="warn" x-text="t('ignored', { picks: ignored.join(', ') })"></p>
<template x-for="service in listed" :key="service.project">
<details class="service" name="services">
<details class="service" :class="{ deployed: deployed(service) }" name="services"
@toggle="openCount = document.querySelectorAll('details.service[open]').length">
<summary>
<span class="name" x-text="service.project"></span>
<span x-show="service.error" class="bad" x-text="t('failedToCollect')"></span>
<span x-show="!service.error">
<span class="muted" x-text="productionRef(service) + ' →'"></span>
<span x-show="pickOf(service)" class="picked-tag" x-text="picks[service.project]"></span>
<span x-show="pickOf(service)" class="chip"><span x-text="picks[service.project]"></span><button type="button"
:title="t('clearPick')" :aria-label="t('clearPick')" @click.prevent="pick(service, '', $el)">×</button></span>
<span x-show="!pickOf(service)" class="muted" x-text="pickHint(service)"></span>
<span x-show="deployed(service)" class="status status-done" x-text="t('upToDate')"></span>
<span x-show="service.untagged" class="warn" x-text="untaggedText(service, 'untagged', 'untaggedAtLeast')"></span>
</span>
<span class="muted" x-text="lineDetails(service)"></span>
Expand Down Expand Up @@ -238,20 +260,28 @@ <h2 x-text="t('services')"></h2>
</div>
</details>
</template>
</section>

<aside>
<button type="button" class="strip" @click="closeServices()"
x-text="[t('release'), t('tagCount', { count: picked.length }), t('taskCount', { count: tasks.length })].join(' · ')"></button>
<div class="aside-body">
<h2 x-text="t('release')"></h2>
<p x-show="!pickable.length" class="muted" x-text="t('noCandidates')"></p>
<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="outputs" x-show="picked.length">
<section>
<div class="tabs">
<button type="button" :class="{ picked: tab === 'tasks' }" @click="tab = 'tasks'" x-text="t('tasks', { count: tasks.length })"></button>
<button type="button" :class="{ picked: tab === 'pipelines' }" @click="tab = 'pipelines'" x-text="t('tagPipelines')"></button>
<button type="button" :class="{ picked: tab === 'compare' }" @click="tab = 'compare'" x-text="t('compare')"></button>
<button type="button" :class="{ picked: tab === 'post' }" @click="tab = 'post'" x-text="t('releasePost')"></button>
</div>
<section x-show="tab === 'tasks'">
<div class="out-head">
<h3 x-text="t('tasks', { count: tasks.length })"></h3>
<span>
<button type="button" @click="copy(taskKeys)" x-text="t('copyKeys')"></button>
<button type="button" @click="copy(taskJql)" x-text="t('copyJql')"></button>
</span>
<button type="button" @click="copy(taskKeys)" x-text="t('copyKeys')"></button>
<button type="button" @click="copy(taskJql)" x-text="t('copyJql')"></button>
</div>
<ul>
<template x-for="task in tasks" :key="task.key">
Expand All @@ -266,9 +296,8 @@ <h3 x-text="t('tasks', { count: tasks.length })"></h3>
<textarea readonly x-text="taskKeys"></textarea>
<textarea readonly x-text="taskJql"></textarea>
</section>
<section>
<section x-show="tab === 'pipelines'">
<div class="out-head">
<h3 x-text="t('tagPipelines')"></h3>
<button type="button" @click="copy(pipelinesMarkdown)" x-text="t('copy')"></button>
</div>
<ul>
Expand All @@ -285,9 +314,8 @@ <h3 x-text="t('tagPipelines')"></h3>
</ul>
<textarea readonly x-text="pipelinesMarkdown"></textarea>
</section>
<section>
<section x-show="tab === 'compare'">
<div class="out-head">
<h3 x-text="t('compare')"></h3>
<button type="button" @click="copy(compareMarkdown)" x-text="t('copy')"></button>
</div>
<ul>
Expand All @@ -300,14 +328,16 @@ <h3 x-text="t('compare')"></h3>
</ul>
<textarea readonly x-text="compareMarkdown"></textarea>
</section>
<section class="wide">
<section x-show="tab === 'post'">
<div class="out-head">
<h3 x-text="t('releasePost')"></h3>
<button type="button" @click="copy(releasePost)" x-text="t('copy')"></button>
</div>
<textarea readonly rows="12" x-text="releasePost"></textarea>
<textarea readonly rows="16" x-text="releasePost"></textarea>
</section>
</div>
</div>
</aside>
</div>

<p class="muted" x-text="t('legend')"></p>
</div>
Expand Down Expand Up @@ -353,10 +383,11 @@ <h3 x-text="t('releasePost')"></h3>
},
createTag: "Create tag {name}",
createAnyTag: "Create a tag",
upToDate: "up to date",
upToDate: "✓ fully deployed",
rows: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
rowsOf: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
taskCount: { one: "{count} task", other: "{count} tasks" },
tagCount: { one: "{count} tag", other: "{count} tags" },
shipsOf: "{shipped} of {rows}",
failedCount: "❌ {count} failed",
allowedCount: "{count} failed, allowed to fail",
Expand Down Expand Up @@ -429,10 +460,11 @@ <h3 x-text="t('releasePost')"></h3>
},
createTag: "Создать тег {name}",
createAnyTag: "Создать тег",
upToDate: "всё на проде",
upToDate: "✓ всё на проде",
rows: { one: "{count} MR или коммит", few: "{count} MR или коммита", many: "{count} MR или коммитов" },
rowsOf: { one: "{count} MR или коммита", few: "{count} MR или коммитов", many: "{count} MR или коммитов" },
taskCount: { one: "{count} задача", few: "{count} задачи", many: "{count} задач" },
tagCount: { one: "{count} тег", few: "{count} тега", many: "{count} тегов" },
shipsOf: "{shipped} из {rows}",
failedCount: "❌ упало: {count}",
allowedCount: "упало, но падать разрешено: {count}",
Expand Down Expand Up @@ -539,6 +571,8 @@ <h3 x-text="t('releasePost')"></h3>
copyFailed: false,
ignored: [],
picks: {},
tab: "tasks",
openCount: 0,
lang: "en",
theme: "auto",

Expand Down Expand Up @@ -672,6 +706,9 @@ <h3 x-text="t('releasePost')"></h3>
element.closest("details").open = false;
this.writeHash();
},
closeServices() {
for (const item of document.querySelectorAll("details.service[open]")) item.open = false;
},
readHash() {
const decode = (value) => {
try {
Expand Down Expand Up @@ -704,8 +741,10 @@ <h3 x-text="t('releasePost')"></h3>
return service.candidates.find((item) => item.tag.name === this.picks[service.project]);
},
pickHint(service) {
if (service.candidates.length) return this.t("pickTag");
return service.rows.length ? "" : this.t("upToDate");
return service.candidates.length ? this.t("pickTag") : "";
},
deployed(service) {
return Boolean(!service.error && this.production(service) && !service.rows.length);
},
lineDetails(service) {
const candidate = this.pickOf(service);
Expand Down
Loading