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

/changelog opens with how close each open milestone is: one line each, done of total, linked to /milestones #11428

Merged opened this

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

Merged

mason/forge:mason/11409-changelog-opens-with-how-close-each-open → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/11409-changelog-opens-with-how-close-each-open was merged into main as dc49c65.

Review

Sign in to review this change.
5 files changed +186 −4
docs/features/changelog.md +4
@@ -6,6 +6,10 @@ The terminal's palette has `Changelog`, the same read for anyone, a guest includ
66
77 One bounded `git log` read, cached per head oid; Server-Timing names `releases` and `render`.
88
9 ## How close each open milestone is (#11409)
10
11 `/changelog` opens with one line per open milestone of the forge's own repository: its name (linked to its milestone page), `N of M done` and a progress bar. A deferred or closed milestone is left out, and the block is absent when none is open or when the reader may not open the repository (a guest on a private one). The terminal's Changelog screen shows the same lines above the releases (name, `N of M done`, at most five), under the same read check. Both read `nearMilestones` in `web/pages/milestones_near.ts`; the page's `ETag` includes them.
12
913 ## What went live (#7672)
1014
1115 `/changelog` lists the pulls merged into a default branch, newest first, grouped by UTC day: title, link, repository, when it landed. A pull to the forge's own repository (`FORGE_REPO`) counts only once the running commit (`FORGE_COMMIT`, the one `/api/v1/health` reports; the default branch head for a forge run from source) contains its merge commit, asked of git with `merge-base --is-ancestor` and kept while live. Landed but not yet contained shows above as "On its way". A pull to any other public repository is live when it lands. The top line says the live version and commit and how many shipped today. `?repo=all` or `?repo=owner/name` filters; the forge's own repository is the default.
src/tui/screens/changelog.ts +11 −1
@@ -8,6 +8,7 @@
88 import { gather, plainTitle } from "../../story/gather.ts";
99 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
1010 import { readLandings } from "../../release/landings.ts";
11 import { nearMilestones, nearWords, type Near } from "../../web/pages/milestones_near.ts";
1112 import { sourceFor } from "../../view/source.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
1314 import { binding } from "../app/keys.ts";
@@ -35,6 +36,12 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
3536 }));
3637 }
3738
39 /** The open milestones as lines above the releases: "name N of M done", at most `MOST` of them. */
40 const MOST = 5;
41 export function nearLines(near: readonly Near[]): string[] {
42 return near.slice(0, MOST).map((m) => `${m.name} ${nearWords(m)}`);
43 }
44
3845 type Repo = { owner: string; name: string };
3946
4047 /** Who built a pull, in plain words: "by Scout, with Claude Sonnet 5.5"; empty when the commit says nothing. */
@@ -89,6 +96,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
8996 let loaded = false;
9097 let repoRef: Repo | undefined;
9198 let credits = new Map<number, string>();
99 let near: string[] = [];
92100 const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e));
93101 return {
94102 title: "Changelog",
@@ -111,6 +119,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
111119 if (words !== "" && l.repo.toLowerCase() === repoName) credits.set(l.number, words);
112120 }
113121 }
122 near = nearLines(nearMilestones(f.app, f.app.config.forgeRepo, f.user)?.near ?? []);
114123 state.clamp(entries.length);
115124 loaded = true;
116125 ctx.invalidate();
@@ -125,7 +134,8 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
125134 return;
126135 }
127136 const list = shown();
128 const [head, box, body] = split(area, "v", [len(1), len(filter.on ? 1 : 0), fill(1)]);
137 const [head, ms, box, body] = split(area, "v", [len(1), len(near.length), len(filter.on ? 1 : 0), fill(1)]);
138 near.forEach((line, i) => s.text(ms!.x, ms!.y + i, truncate(line, ms!.w), style(c.fg, c.bg), ms!.x + ms!.w));
129139 const count = filter.query === "" ? `${entries.length} releases` : `${list.length} of ${entries.length} releases match`;
130140 s.text(head!.x, head!.y, truncate(count, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
131141 ctx.setCursor(filter.on ? filter.render(s, box!, c, "find a version or pull") : null);
src/web/pages/changelog.ts +21 −3
@@ -15,8 +15,10 @@ import { newestLive, secondsWords } from "../../keeper/landed.ts";
1515 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1616 import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts";
1717 import { gather, plainTitle, type Gathered } from "../../story/gather.ts";
18 import { milestoneHref, progress } from "./milestones.ts";
19 import { nearMilestones, nearWords, type Near } from "./milestones_near.ts";
1820 import { liveRegionsJs } from "../client.ts";
19 import { pageHead } from "../chrome.ts";
21 import { pageHead, repoBase } from "../chrome.ts";
2022 import { changelogTag, holds } from "../feeds.ts";
2123 import { EDGE_FRESH, EDGE_STALE, EDGE_VARY } from "../../http/edge_cache.ts";
2224 import { json, securityFloor } from "../../http/respond.ts";
@@ -28,6 +30,19 @@ import { badge, row, seg, sha, time, timeAgo } from "../ui.ts";
2830
2931 /** Where the live client looks: a merge lands, or production serves a commit. */
3032 const LIVE = "pull.merged,app.serving";
33 /** How close each open milestone is, above the waves: absent when there are none. */
34 function nearBlock(base: string, near: readonly Near[]): Html | null {
35 if (near.length === 0) return null;
36 return html`<ul class="list setting-list" id="milestones-near" aria-label="Open milestones">${near.map((m) =>
37 row({
38 nav: true,
39 title: html`<a href="${milestoneHref(base, m.number)}">${m.name}</a>`,
40 meta: progress(m.done, m.total, `${m.name}: ${m.done} of ${m.total} done`),
41 trail: html`${nearWords(m)}`,
42 }),
43 )}</ul>`;
44 }
45
3146 /** Other repositories offered as filters: more than this is a search, not a chip. */
3247 const FILTERS = 6;
3348
@@ -192,6 +207,9 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
192207 const ownRepo = landed.own === undefined ? undefined : getRepo(ctx.app.db, ...(landed.own.split("/") as [string, string]));
193208 const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user);
194209 const { read, bets, bet } = withBets(ctx, landed, readable ? ownRepo : undefined);
210 // The forge's own repository, even before anything has landed in it.
211 const planned = nearMilestones(ctx.app, landed.own ?? ctx.app.config.forgeRepo, ctx.auth.user);
outdated

Non-blocking: nearMilestones already fetches this repo; return it and drop this second getRepo.

212 const near = planned?.near ?? [];
195213 const everything = [...read.onItsWay, ...read.live];
196214 const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own);
197215 const others = [...new Set(everything.map((l) => l.repo))].filter((r) => r !== read.own).slice(0, FILTERS);
@@ -199,7 +217,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
199217 // a match answers 304 without building the page.
200218 const toLive = newestLive(ctx.app.db);
201219 const flashed = readFlash(ctx.request) !== undefined;
202 const tag = flashed ? undefined : changelogTag(read.version, everything, [choice, bet, read.commit, read.live.map((l) => `${l.repo}#${l.number}`), readable, toLive ?? null, everything.map((l) => timeAgo(l.at)), new Date().toISOString().slice(0, 10), ctx.auth.user?.id ?? null, ctx.auth.user?.isAdmin === true, ctx.theme ?? null]);
220 const tag = flashed ? undefined : changelogTag(read.version, everything, [choice, bet, read.commit, read.live.map((l) => `${l.repo}#${l.number}`), readable, toLive ?? null, everything.map((l) => timeAgo(l.at)), new Date().toISOString().slice(0, 10), ctx.auth.user?.id ?? null, ctx.auth.user?.isAdmin === true, ctx.theme ?? null, near]);
203221 const keep = tag === undefined ? undefined : cacheHeaders(ctx, tag);
204222 if (keep !== undefined && keep.matched) return keep.response;
205223 const rendering = performance.now();
@@ -215,7 +233,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
215233 { label: "Every bet", href: "/changelog", current: bet === "" },
216234 ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })),
217235 ]);
218 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters}${betFilters}
236 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters}
219237 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable)}</div>`;
220238 const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
221239 notePhase(ctx.request, "render", performance.now() - rendering);
src/web/pages/milestones_near.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * How close each open milestone of the forge's own repository is, as plain
3 * data: the changelog page and the terminal's Changelog screen read it, so
4 * both doors say the same lines. Deferred and closed limbs are left out, and a
5 * repository the reader may not open yields none.
6 */
7
8 import type { App } from "../../app.ts";
9 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
10 import type { User } from "../../core/users.ts";
11 import { planFor } from "./milestones.ts";
12
13 /** One open milestone's line: its number, name and how far it is. */
14 export type Near = { number: number; name: string; done: number; total: number };
15
16 /** The open milestones of `slug` ("owner/name"), for `user` (undefined is a guest), with the repository read for them. */
17 export function nearMilestones(app: App, slug: string | undefined, user: User | undefined): { repo: Repo; near: Near[] } | undefined {
18 const [owner = "", name = ""] = (slug ?? "").split("/");
19 if (owner === "" || name === "") return undefined;
20 const repo = getRepo(app.db, owner, name);
21 if (repo === undefined || !canRead(app.db, repo, user)) return undefined;
22 const near = planFor({ app }, repo, false, new Date(), false)
23 .limbs.filter((l) => l.issue.state === "open" && !l.deferred)
24 .map((l) => ({ number: l.issue.number, name: l.name, done: l.weight.done, total: l.weight.total }));
25 return { repo, near };
26 }
27
28 /** One line of words for a milestone, as the trail says it. */
29 export const nearWords = (m: Near): string => `${m.done} of ${m.total} done`;
test/changelog-milestones.test.ts added +121
@@ -0,0 +1,121 @@
1 /** /changelog opens with how close each open milestone is, and with none shows no block (#11409). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createUser, getUserById } from "../src/core/users.ts";
10 import { nearLines } from "../src/tui/screens/changelog.ts";
11 import { nearMilestones } from "../src/web/pages/milestones_near.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13
14 let dir: string;
15 let app: App;
16 let servers: Servers;
17 let repoId: string;
18 let userId: string;
19 let n = 0;
20
21 /** A milestone: an umbrella waiting for `done` closed and `open` open issues. */
22 function milestone(name: string, done: number, open: number, inRepo = repoId): string {
23 const at = new Date(Date.now() - 3_600_000).toISOString();
24 const add = (title: string, state: string) => {
25 const repo = inRepo;
26 const id = `i${++n}`;
27 app.db.run(
28 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at, closed_at) VALUES (?1, ?2, ?3, 'issue', ?4, '', ?5, ?6, ?7, ?7, ?8)",
29 [id, repo, n, title, userId, state, at, state === "open" ? null : at],
30 );
31 return id;
32 };
33 const umbrella = add(name, "open");
34 for (let i = 0; i < done; i++) app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)", [umbrella, add(`${name} done ${i}`, "closed")]);
35 for (let i = 0; i < open; i++) app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)", [umbrella, add(`${name} open ${i}`, "open")]);
36 return umbrella;
37 }
38
39 const close = (id: string) => app.db.run("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1", [id, new Date().toISOString()]);
40 const read = async (as?: string) => (await fetch(`${servers.url}/changelog`, as === undefined ? {} : { headers: { cookie: as } })).text();
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-changelog-ms-"));
44 app = createApp({ dataDir: dir, port: 0, forgeRepo: "alice/forge" });
45 servers = await serve(app);
46 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
47 if (!u.ok) throw new Error(u.error);
48 userId = u.value.id;
49 const made = await createRepo(app, u.value, { name: "forge", visibility: "public" });
50 if (!made.ok) throw new Error(String(made.error));
51 repoId = getRepo(app.db, "alice", "forge")!.id;
52 }, 60_000);
53
54 afterAll(async () => {
55 servers.stop();
56 app.close();
57 await rm(dir, { recursive: true, force: true });
58 });
outdated

This test passes without the change (the block is absent either way). Render with milestones first, then remove them, and assert the block is gone; add a guest-on-private-repo case and a deferred-limb case.

59
60 test("each open milestone is one line: name, N of M done, a bar, linked to its page", async () => {
61 milestone("Alpha goal", 1, 2);
62 milestone("Beta goal", 3, 1);
63 const res = await fetch(`${servers.url}/changelog`);
64 const body = await res.text();
65 expect(res.status).toBe(200);
66 expect(body).toContain('id="milestones-near"');
67 expect(body).toContain("Alpha goal");
68 expect(body).toContain("1 of 3 done");
69 expect(body).toContain("Beta goal");
70 expect(body).toContain("3 of 4 done");
71 expect(body).toContain("<progress");
72 expect(body).toMatch(/href="\/alice\/forge\/milestones\/\d+"[^>]*>Alpha goal/);
73 expect(body.indexOf("milestones-near")).toBeLessThan(body.indexOf('id="shipped"'));
74 });
75
76 test("closing the milestones takes the block away", async () => {
77 const gone = milestone("Gamma goal", 0, 1);
78 expect(await read()).toContain("Gamma goal");
79 close(gone);
80 const body = await read();
81 expect(body).not.toContain("Gamma goal");
82 });
83
84 test("with every milestone closed the block is absent", async () => {
85 for (const r of app.db.query<{ id: string }, [string]>("SELECT id FROM issue WHERE repo_id = ?1 AND state = 'open' AND title LIKE '% goal'").all(repoId)) close(r.id);
86 expect(await read()).not.toContain("milestones-near");
87 });
88
89 test("a deferred milestone is left out", async () => {
90 const live = milestone("Delta goal", 0, 1);
91 const later = milestone("Epsilon goal", 0, 1);
92 const gate = `g${++n}`;
93 app.db.run(
94 "INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at) VALUES (?1, ?2, ?3, 'issue', 'Later gate: not yet', '', ?4, 'open', ?5, ?5)",
95 [gate, repoId, n, userId, new Date().toISOString()],
96 );
97 app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)", [later, gate]);
98 const body = await read();
99 expect(live).not.toBe(later);
100 expect(body).toContain("Delta goal");
101 expect(body).not.toContain("Epsilon goal");
102 });
103
104 test("a private repository's milestones are not shown to a guest, and are to its owner", async () => {
105 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [repoId]);
106 try {
107 expect(await read()).not.toContain("Delta goal");
108 expect(nearMilestones(app, "alice/forge", undefined)).toBeUndefined();
109 const owner = getUserById(app.db, userId)!;
110 expect(nearMilestones(app, "alice/forge", owner)?.near.map((m) => m.name)).toContain("Delta goal");
111 } finally {
112 app.db.run("UPDATE repo SET visibility = 'public' WHERE id = ?1", [repoId]);
113 }
114 expect(await read()).toContain("Delta goal");
115 });
116
117 test("the terminal says the same lines", () => {
118 const near = nearMilestones(app, "alice/forge", undefined)!.near;
119 expect(nearLines(near)).toContain("Delta goal 0 of 1 done");
120 expect(nearLines(near).join("\n")).not.toContain("Epsilon goal");
121 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs