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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11409-changelog-opens-with-how-close-each-open → main 5 commits fork 3 threads · 3 unresolved
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 | ||
| 6 | 6 | |
| 7 | 7 | One bounded `git log` read, cached per head oid; Server-Timing names `releases` and `render`. |
| 8 | 8 | |
| 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 | ||
| 9 | 13 | ## What went live (#7672) |
| 10 | 14 | |
| 11 | 15 | `/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 @@ | ||
| 8 | 8 | import { gather, plainTitle } from "../../story/gather.ts"; |
| 9 | 9 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; |
| 10 | 10 | import { readLandings } from "../../release/landings.ts"; |
| 11 | import { nearMilestones, nearWords, type Near } from "../../web/pages/milestones_near.ts"; | |
| 11 | 12 | import { sourceFor } from "../../view/source.ts"; |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 14 | import { binding } from "../app/keys.ts"; |
| @@ -35,6 +36,12 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | ||
| 35 | 36 | })); |
| 36 | 37 | } |
| 37 | 38 | |
| 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 | ||
| 38 | 45 | type Repo = { owner: string; name: string }; |
| 39 | 46 | |
| 40 | 47 | /** 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 { | ||
| 89 | 96 | let loaded = false; |
| 90 | 97 | let repoRef: Repo | undefined; |
| 91 | 98 | let credits = new Map<number, string>(); |
| 99 | let near: string[] = []; | |
| 92 | 100 | const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e)); |
| 93 | 101 | return { |
| 94 | 102 | title: "Changelog", |
| @@ -111,6 +119,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 111 | 119 | if (words !== "" && l.repo.toLowerCase() === repoName) credits.set(l.number, words); |
| 112 | 120 | } |
| 113 | 121 | } |
| 122 | near = nearLines(nearMilestones(f.app, f.app.config.forgeRepo, f.user)?.near ?? []); | |
| 114 | 123 | state.clamp(entries.length); |
| 115 | 124 | loaded = true; |
| 116 | 125 | ctx.invalidate(); |
| @@ -125,7 +134,8 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 125 | 134 | return; |
| 126 | 135 | } |
| 127 | 136 | 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)); | |
| 129 | 139 | const count = filter.query === "" ? `${entries.length} releases` : `${list.length} of ${entries.length} releases match`; |
| 130 | 140 | s.text(head!.x, head!.y, truncate(count, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 131 | 141 | 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"; | ||
| 15 | 15 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 16 | 16 | import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts"; |
| 17 | 17 | 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"; | |
| 18 | 20 | import { liveRegionsJs } from "../client.ts"; |
| 19 | import { pageHead } from "../chrome.ts"; | |
| 21 | import { pageHead, repoBase } from "../chrome.ts"; | |
| 20 | 22 | import { changelogTag, holds } from "../feeds.ts"; |
| 21 | 23 | import { EDGE_FRESH, EDGE_STALE, EDGE_VARY } from "../../http/edge_cache.ts"; |
| 22 | 24 | import { json, securityFloor } from "../../http/respond.ts"; |
| @@ -28,6 +30,19 @@ import { badge, row, seg, sha, time, timeAgo } from "../ui.ts"; | ||
| 28 | 30 | |
| 29 | 31 | /** Where the live client looks: a merge lands, or production serves a commit. */ |
| 30 | 32 | 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 | ||
| 31 | 46 | /** Other repositories offered as filters: more than this is a search, not a chip. */ |
| 32 | 47 | const FILTERS = 6; |
| 33 | 48 | |
| @@ -192,6 +207,9 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 192 | 207 | const ownRepo = landed.own === undefined ? undefined : getRepo(ctx.app.db, ...(landed.own.split("/") as [string, string])); |
| 193 | 208 | const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user); |
| 194 | 209 | 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); | |
| 212 | const near = planned?.near ?? []; | |
| 195 | 213 | const everything = [...read.onItsWay, ...read.live]; |
| 196 | 214 | const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own); |
| 197 | 215 | 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> { | ||
| 199 | 217 | // a match answers 304 without building the page. |
| 200 | 218 | const toLive = newestLive(ctx.app.db); |
| 201 | 219 | 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]); | |
| 203 | 221 | const keep = tag === undefined ? undefined : cacheHeaders(ctx, tag); |
| 204 | 222 | if (keep !== undefined && keep.matched) return keep.response; |
| 205 | 223 | const rendering = performance.now(); |
| @@ -215,7 +233,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 215 | 233 | { label: "Every bet", href: "/changelog", current: bet === "" }, |
| 216 | 234 | ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })), |
| 217 | 235 | ]); |
| 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} | |
| 219 | 237 | <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable)}</div>`; |
| 220 | 238 | 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}` }); |
| 221 | 239 | 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 | }); |
Non-blocking: nearMilestones already fetches this repo; return it and drop this second getRepo.