Public: anyone can read this; the crew can change it.
The /releases page speaks the home page's language #9758
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9753-the-releases-page-speaks-the-home-page-s → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +78
−29
docs/design/areas/changelog.md +4
| @@ -12,3 +12,7 @@ the sample before its forks are made, so the preview's `/changelog` phone look s | ||
| 12 | 12 | ## Rows, not boxes (#9688) |
| 13 | 13 | |
| 14 | 14 | Each day is a `<section data-day>` with an `<h2>` (the day, the UTC mark when the zone is not the reader's, the count) over a `list setting-list`; "On its way" is an `<h2>` over the same list. The empty state is one muted line, `<p class="mt muted">Nothing has shipped yet.</p>`, the same quiet line the board's Landed region uses (`region()` in `pages/board.ts`); not `empty()`, which is a card. No card, no new class; `test/changelog.test.ts` holds it, and `localDaysJs` still finds `[data-day]`, `h2 time`, `[data-zone]` and `.badge`. |
| 15 | ||
| 16 | ## Releases as rows (#9753) | |
| 17 | ||
| 18 | `/releases` is one `list setting-list` of `row()`s, no table, no card. The title is the short sha with its `live` and `preflight failed` badges; the meta line is the pull (`#7`), when it landed and, for the live one, since when it has been healthy. A release with a pull is one `<a>` to that pull; the Roll back form sits in the row's `after` slot, outside the link, so it posts with JavaScript off. The empty state is one muted line. No new class; `test/releases-page.test.ts` holds the shape. |
src/web/pages/releases.ts +17 −27
| @@ -7,18 +7,18 @@ | ||
| 7 | 7 | * `--rollback`, then this process drains. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { getReposByIds, type Repo } from "../../core/repos.ts"; | |
| 10 | import { getReposByIds } from "../../core/repos.ts"; | |
| 11 | 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 12 | 12 | import { running } from "../../kernel/version.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { listReleases, type Release } from "../../release/record.ts"; | |
| 14 | import { listReleases } from "../../release/record.ts"; | |
| 15 | 15 | import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts"; |
| 16 | 16 | import { liveSince, NO_RELEASES_YET } from "../../release/words.ts"; |
| 17 | 17 | import { notFoundPage, signInFirst, NOT_ADMIN_HERE, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts"; |
| 18 | 18 | import { readForm } from "../form.ts"; |
| 19 | 19 | import { html, when, type Html } from "../html.ts"; |
| 20 | 20 | import { flashTo, page } from "../layout.ts"; |
| 21 | import { badge, btn, card, empty, sha, time } from "../ui.ts"; | |
| 21 | import { badge, btn, row, sha, time } from "../ui.ts"; | |
| 22 | 22 | import { holdAs, liftAs } from "../../keeper/hold.ts"; |
| 23 | 23 | import { keeperPanel } from "./keeper-panel.ts"; |
| 24 | 24 | |
| @@ -31,14 +31,6 @@ export function liveLine(ctx: Ctx): Html | undefined { | ||
| 31 | 31 | </p>`; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | function pullCell(repos: ReadonlyMap<string, Repo>, r: Release): Html { | |
| 35 | if (r.pullNumber === null) return html`—`; | |
| 36 | const repo = repos.get(r.repoId); | |
| 37 | return repo === undefined | |
| 38 | ? html`#${r.pullNumber}` | |
| 39 | : html`<a href="/${repo.ownerName}/${repo.name}/pulls/${r.pullNumber}">#${r.pullNumber}</a>`; | |
| 40 | } | |
| 41 | ||
| 42 | 34 | export async function releasesPage(ctx: Ctx): Promise<Response> { |
| 43 | 35 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 44 | 36 | const user = ctx.auth.user; |
| @@ -61,25 +53,23 @@ export async function releasesPage(ctx: Ctx): Promise<Response> { | ||
| 61 | 53 | const rows: Html[] = []; |
| 62 | 54 | for (const [i, r] of releases.entries()) { |
| 63 | 55 | const isLive = live !== null && r.sha === live; |
| 64 | const button = rollable[i] === true; | |
| 65 | rows.push(html`<tr> | |
| 66 | <td>${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))} | |
| 67 | ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}</td> | |
| 68 | <td>${pullCell(repos, r)}</td> | |
| 69 | <td>${time(r.createdAt)}</td> | |
| 70 | <td>${isLive ? html`since ${time(BOOTED)}` : html`—`}</td> | |
| 71 | <td>${when(button, () => | |
| 72 | html`<form method="post" action="/releases/${r.sha}/rollback">${btn("Roll back", { type: "submit" })}</form>`, | |
| 73 | )}</td> | |
| 74 | </tr>`); | |
| 56 | const repo = r.pullNumber === null ? undefined : repos.get(r.repoId); | |
| 57 | rows.push( | |
| 58 | row({ | |
| 59 | ...(repo === undefined || r.pullNumber === null ? {} : { href: `/${repo.ownerName}/${repo.name}/pulls/${r.pullNumber}` }), | |
| 60 | title: html`${sha(r.sha)} ${when(isLive, () => badge("live", { tone: "success" }))} | |
| 61 | ${when(!r.ok, () => badge("preflight failed", { tone: "danger" }))}`, | |
| 62 | meta: html`${r.pullNumber === null ? null : html`#${r.pullNumber} · `}landed ${time(r.createdAt)}${isLive ? html` · healthy since ${time(BOOTED)}` : null}`, | |
| 63 | ...(rollable[i] === true | |
| 64 | ? { after: html`<form method="post" action="/releases/${r.sha}/rollback">${btn("Roll back", { type: "submit" })}</form>` } | |
| 65 | : {}), | |
| 66 | }), | |
| 67 | ); | |
| 75 | 68 | } |
| 76 | 69 | const body = |
| 77 | 70 | rows.length === 0 |
| 78 | ? empty({ icon: "clock", title: "No releases yet", note: NO_RELEASES_YET }) | |
| 79 | : card(html`<table class="table"> | |
| 80 | <thead><tr><th>Release</th><th>Pull</th><th>Landed</th><th>Healthy</th><th></th></tr></thead> | |
| 81 | <tbody>${rows}</tbody> | |
| 82 | </table>`); | |
| 71 | ? html`<p class="mt muted">No releases yet. ${NO_RELEASES_YET}</p>` | |
| 72 | : html`<ul class="list setting-list">${rows}</ul>`; | |
| 83 | 73 | const response = page({ |
| 84 | 74 | ctx, |
| 85 | 75 | title: "Releases", |
test/releases-page.test.ts +54
| @@ -18,6 +18,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 18 | 18 | import { releaseDir } from "../src/release/checkout.ts"; |
| 19 | 19 | import { beginSwap, endSwap } from "../src/release/swap.ts"; |
| 20 | 20 | import { recordRelease } from "../src/release/record.ts"; |
| 21 | import { NO_RELEASES_YET } from "../src/release/words.ts"; | |
| 21 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 22 | 23 | |
| 23 | 24 | quietLogs(); |
| @@ -106,6 +107,59 @@ describe("the releases page", () => { | ||
| 106 | 107 | expect(body).toContain('href="/alice/forge/pulls/6"'); |
| 107 | 108 | }); |
| 108 | 109 | |
| 110 | test("the release list is rows, not a table or a card; the version is the one link", async () => { | |
| 111 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text(); | |
| 112 | expect(body).not.toContain("<table"); | |
| 113 | const list = body.slice(body.indexOf('<ul class="list setting-list">')); | |
| 114 | expect(list).not.toContain('class="card'); | |
| 115 | expect(list).toContain('<a href="/alice/forge/pulls/7" class="row"'); | |
| 116 | expect(list.match(/<li[ >]/g)!.length).toBeGreaterThanOrEqual(3); | |
| 117 | // The action stays a plain form beside the row, not inside its link. | |
| 118 | expect(list).toMatch(new RegExp(`<form method="post" action="/releases/${KEPT}/rollback">`)); | |
outdated
The meta line is untested here: assert the live row contains "healthy since" and that a non-live row does not, and that a release without a pull has no "#n · " link. Without these, dropping the old Healthy column loses nothing visibly. |
||
| 119 | }); | |
| 120 | ||
| 121 | test("the meta line: healthy since on the live row only; a pull link only where there is a pull", async () => { | |
| 122 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text(); | |
| 123 | const items = body.slice(body.indexOf('<ul class="list setting-list">')).split("<li").slice(1); | |
| 124 | const rowOf = (s: string) => items.find((i) => i.includes(s))!; | |
| 125 | expect(rowOf("aaaaaaa")).toContain("healthy since"); | |
| 126 | expect(rowOf("bbbbbbb")).not.toContain("healthy since"); | |
| 127 | expect(rowOf("ccccccc")).not.toContain("healthy since"); | |
| 128 | expect(rowOf("aaaaaaa")).toContain("#7 · "); | |
| 129 | expect(rowOf("ccccccc")).not.toMatch(/#\d+ · /); | |
| 130 | expect(rowOf("ccccccc")).not.toContain("/pulls/"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("with no releases the page says so in one muted line, no list and no card", async () => { | |
| 134 | const dir = mkdtempSync(join(tmpdir(), "forge-releases-empty-")); | |
| 135 | const empty = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge" }); | |
| 136 | const s = await serve(empty); | |
| 137 | try { | |
| 138 | empty.db | |
| 139 | .query("INSERT INTO user (id, username, email, password_hash, is_admin) VALUES ('u-alice','alice','[email protected]','x',1)") | |
| 140 | .run(); | |
| 141 | markVerified(empty.db, "u-alice", new Date().toISOString()); | |
| 142 | const made = await createRepo(empty, { id: "u-alice", username: "alice" } as never, { name: "forge" }); | |
| 143 | if (!made.ok) throw new Error(made.error); | |
| 144 | const cookie = `forge_session=${createSession(empty.db, "u-alice").token}`; | |
| 145 | const res = await fetch(`${s.url}/releases`, { headers: { cookie } }); | |
| 146 | expect(res.status).toBe(200); | |
| 147 | const body = await res.text(); | |
| 148 | const at = body.indexOf('<p class="mt muted">No releases yet. '); | |
| 149 | expect(at).toBeGreaterThan(-1); | |
| 150 | const line = body.slice(at, body.indexOf("</p>", at)); | |
| 151 | expect(line.replaceAll("'", "'")).toContain(NO_RELEASES_YET); | |
| 152 | // After the keeper panel, only the muted line: no list, no card. | |
| 153 | const rest = body.slice(at, body.indexOf("</main>")); | |
| 154 | expect(rest).not.toContain("<ul"); | |
| 155 | expect(rest).not.toContain('class="card'); | |
| 156 | } finally { | |
| 157 | s.stop(); | |
| 158 | empty.close(); | |
| 159 | rmSync(dir, { recursive: true, force: true }); | |
| 160 | } | |
| 161 | }); | |
| 162 | ||
| 109 | 163 | test("a writer gets a button on a kept release, not on the live or a pruned one", async () => { |
| 110 | 164 | const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text(); |
| 111 | 165 | expect(body).toContain(`/releases/${KEPT}/rollback`); |
test/releases-timing.test.ts +3 −2
| @@ -102,6 +102,7 @@ test("20 listed releases over 2 repositories cost one repository read; a deleted | ||
| 102 | 102 | const body = await res.text(); |
| 103 | 103 | expect(reads).toBe(1); |
| 104 | 104 | expect(res.headers.get("server-timing")).toMatch(/repos;dur=\d+\.\d/); |
| 105 | expect(body).toContain("/pulls/100\">#100</a>"); | |
| 106 | expect(body).toMatch(/<td>#777<\/td>/); | |
| 105 | expect(body).toContain('href="/alice/forge/pulls/100"'); | |
| 106 | expect(body).toMatch(/<span class="row-meta">#777 · landed/); | |
| 107 | expect(body).not.toContain("/pulls/777"); | |
| 107 | 108 | }); |
The empty state changed from a titled card to a bare muted line, and no test covers it. Add a test that renders /releases with no releases and asserts the muted line (NO_RELEASES_YET) and no card. Also consider keeping the "No releases yet" lead that the TUI screen (src/tui/screens/releases.ts) and docs both use, so the two doors say the same.