Public: anyone can read this; the crew can change it.

The /releases page speaks the home page's language #9758

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9753-the-releases-page-speaks-the-home-page-s → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9753-the-releases-page-speaks-the-home-page-s was merged into main as 014251c.

Review

Sign in to review this change.
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
1212 ## Rows, not boxes (#9688)
1313
1414 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 @@
77 * `--rollback`, then this process drains.
88 */
99
10 import { getReposByIds, type Repo } from "../../core/repos.ts";
10 import { getReposByIds } from "../../core/repos.ts";
1111 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1212 import { running } from "../../kernel/version.ts";
1313 import type { Ctx } from "../../http/context.ts";
14 import { listReleases, type Release } from "../../release/record.ts";
14 import { listReleases } from "../../release/record.ts";
1515 import { BOOTED, canRollBackTo, mayRead, mayRollBack, rollBackTo } from "../../release/gate.ts";
1616 import { liveSince, NO_RELEASES_YET } from "../../release/words.ts";
1717 import { notFoundPage, signInFirst, NOT_ADMIN_HERE, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
1818 import { readForm } from "../form.ts";
1919 import { html, when, type Html } from "../html.ts";
2020 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";
2222 import { holdAs, liftAs } from "../../keeper/hold.ts";
2323 import { keeperPanel } from "./keeper-panel.ts";
2424
@@ -31,14 +31,6 @@ export function liveLine(ctx: Ctx): Html | undefined {
3131 </p>`;
3232 }
3333
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
4234 export async function releasesPage(ctx: Ctx): Promise<Response> {
4335 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
4436 const user = ctx.auth.user;
@@ -61,25 +53,23 @@ export async function releasesPage(ctx: Ctx): Promise<Response> {
6153 const rows: Html[] = [];
6254 for (const [i, r] of releases.entries()) {
6355 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 );
7568 }
7669 const body =
7770 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>`
outdated

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.

72 : html`<ul class="list setting-list">${rows}</ul>`;
8373 const response = page({
8474 ctx,
8575 title: "Releases",
test/releases-page.test.ts +54
@@ -18,6 +18,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1818 import { releaseDir } from "../src/release/checkout.ts";
1919 import { beginSwap, endSwap } from "../src/release/swap.ts";
2020 import { recordRelease } from "../src/release/record.ts";
21 import { NO_RELEASES_YET } from "../src/release/words.ts";
2122 import { quietLogs } from "./helpers/globals.ts";
2223
2324 quietLogs();
@@ -106,6 +107,59 @@ describe("the releases page", () => {
106107 expect(body).toContain('href="/alice/forge/pulls/6"');
107108 });
108109
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("&#39;", "'")).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
109163 test("a writer gets a button on a kept release, not on the live or a pruned one", async () => {
110164 const body = await (await fetch(`${servers.url}/releases`, { headers: { cookie: aliceCookie } })).text();
111165 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
102102 const body = await res.text();
103103 expect(reads).toBe(1);
104104 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");
107108 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs