Public: anyone can read this; the crew can change it.
/changelog lists the last 30 releases, finished from pull #7013's branch with a private forge repository kept off the page #7120
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7101-changelog-lists-the-last-30-releases-fin → main 8 commits fork 1 thread · 1 unresolved
14 files
changed +403
−2
README.md +2
| @@ -404,3 +404,5 @@ This page is the front door. Everything else is a file of its own under | ||
| 404 | 404 | |
| 405 | 405 | - [](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`. |
| 406 | 406 | - [](docs/SPIKE-CLOUDFLARE.md), [](docs/AUDIT-MANY-HANDS.md), [](docs/BACKLOG.md) and [](docs/research/) — a spike, an audit, a backlog and research notes, kept as written. |
| 407 | ||
| 408 | `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`). |
docs/design/audit.md +2 −1
| @@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one. | ||
| 33 | 33 | |
| 34 | 34 | ## Finding 2 — the pages, and what each is for |
| 35 | 35 | |
| 36 | There are 62 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 36 | There are 63 top-level pages, one entry each: every static `GET` route in `src/http/server.ts` | |
| 37 | 37 | that returns a page (`test/design-audit.test.ts` holds this list to the router, |
| 38 | 38 | both ways). Each entry reads `route` — module · what it is for · what |
| 39 | 39 | duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals |
| @@ -62,6 +62,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 62 | 62 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 63 | 63 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
| 64 | 64 | - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen. |
| 65 | - `/changelog` — `pages/changelog.ts` · the last 30 releases with their dates and pulls, public · `/releases` (the signed-in kept releases) · none seen. | |
| 65 | 66 | - `/contribute` — `pages/contribute.ts` · what this forge is and where outside work is welcome, open to anyone · `/llms.txt`, MCP `contribute` tool · none seen. |
| 66 | 67 | - `/search` — `pages/search.ts` · find across what I can read · the palette (`palette.ts`) · none seen. |
| 67 | 68 | - `/-/confirm` — `pages/search.ts` (`confirmPage`) · the "are you sure" step for a destructive action · none · none seen. |
docs/features/changelog.md added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # Changelog | |
| 2 | ||
| 3 | `GET /changelog` is public. It lists the last 30 `Release <version>: …` commits on the default branch of the forge's own repository (`FORGE_REPO`), newest first. Each has a heading anchored `#<version>`, its date, and one link per pull named in the subject (`title (#N);title (#N)`). When `FORGE_REPO` is unset or its repository is private, the page answers 200 with "No releases yet". The footer links to it. | |
| 4 | ||
| 5 | One bounded `git log` read, cached per head oid; Server-Timing names `releases` and `render`. |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/core/reserved.ts +1
| @@ -121,6 +121,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 121 | 121 | "git", |
| 122 | 122 | "ssh", |
| 123 | 123 | "status", |
| 124 | "changelog", | |
| 124 | 125 | "docs", |
| 125 | 126 | "open-platform", |
| 126 | 127 | ]; |
src/git/read.ts +3
| @@ -492,6 +492,8 @@ export interface LogOptions { | ||
| 492 | 492 | skip?: number; |
| 493 | 493 | /** Restrict to commits touching this tree path. */ |
| 494 | 494 | path?: string; |
| 495 | /** Only commits whose message matches this extended regular expression. */ | |
| 496 | grep?: string; | |
| 495 | 497 | } |
| 496 | 498 | |
| 497 | 499 | /** When `oid` was committed, as an ISO time; undefined when git cannot say. */ |
| @@ -536,6 +538,7 @@ export async function commitLog( | ||
| 536 | 538 | `--format=${LOG_FORMAT}`, |
| 537 | 539 | `--max-count=${options.limit ?? 50}`, |
| 538 | 540 | `--skip=${options.skip ?? 0}`, |
| 541 | ...(options.grep === undefined ? [] : ["--extended-regexp", `--grep=${options.grep}`]), | |
| 539 | 542 | rev, |
| 540 | 543 | "--", |
| 541 | 544 | ]; |
src/http/server.ts +2
| @@ -159,6 +159,7 @@ import { | ||
| 159 | 159 | backupsTest, |
| 160 | 160 | domainsMoved, |
| 161 | 161 | } from "../web/pages/backups.ts"; |
| 162 | import { changelogPage } from "../web/pages/changelog.ts"; | |
| 162 | 163 | import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 163 | 164 | import { |
| 164 | 165 | cloudAdd, |
| @@ -775,6 +776,7 @@ export function buildRouter(): Router<Handler> { | ||
| 775 | 776 | router.add("GET", "/new", newRepoPage); |
| 776 | 777 | router.add("GET", "/audit", auditPage); |
| 777 | 778 | router.add("GET", "/releases", releasesPage); |
| 779 | router.add("GET", "/changelog", changelogPage); | |
| 778 | 780 | router.add("POST", "/releases/:sha/rollback", rollbackSubmit); |
| 779 | 781 | router.add("GET", "/audit.csv", auditCsv); |
| 780 | 782 | router.add("POST", "/audit/:owner/:name/probe", auditProbeSubmit); |
src/release/changelog.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * The changelog's data: the last 30 `Release <version>: …` commits on the | |
| 3 | * forge's own default branch, newest first, each with the pulls its subject | |
| 4 | * names (`core/release_subject.ts` reads the subject). One bounded log read, | |
| 5 | * kept per head oid so a request between releases costs only a `rev-parse`. | |
| 6 | * Only a public `FORGE_REPO` is read. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { getRepo, type Repo } from "../core/repos.ts"; | |
| 11 | import { parseReleaseSubject, type ReleasePull } from "../core/release_subject.ts"; | |
| 12 | import { runGit } from "../git/env.ts"; | |
| 13 | import { commitLog } from "../git/read.ts"; | |
| 14 | import { repoDir } from "../git/repos.ts"; | |
| 15 | ||
| 16 | export const CHANGELOG_LIMIT = 30; | |
| 17 | ||
| 18 | export interface ReleaseEntry { | |
| 19 | readonly version: string; | |
| 20 | readonly date: string; | |
| 21 | readonly pulls: readonly ReleasePull[]; | |
| 22 | } | |
| 23 | ||
| 24 | const cache = new Map<string, { oid: string; entries: ReleaseEntry[] }>(); | |
| 25 | ||
| 26 | /** The newest releases on `branch`, read once per head oid. */ | |
| 27 | export async function readChangelog(dir: string, branch: string): Promise<ReleaseEntry[]> { | |
| 28 | const head = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${branch}`], { cwd: dir }); | |
| 29 | if (!head.ok) return []; | |
| 30 | const cached = cache.get(dir); | |
| 31 | if (cached?.oid === head.value) return cached.entries; | |
| 32 | const log = await commitLog(dir, head.value, { limit: CHANGELOG_LIMIT, grep: "^Release [0-9]+\\.[0-9]+\\.[0-9]+: " }); | |
| 33 | if (!log.ok) return []; | |
| 34 | const entries: ReleaseEntry[] = []; | |
| 35 | for (const commit of log.value) { | |
| 36 | const release = parseReleaseSubject(commit.subject); | |
| 37 | if (release !== undefined) entries.push({ version: release.version, date: commit.date, pulls: release.pulls }); | |
| 38 | } | |
| 39 | cache.set(dir, { oid: head.value, entries }); | |
| 40 | return entries; | |
| 41 | } | |
| 42 | ||
| 43 | /** The forge's own repository, when it is public: the only one the changelog reads. */ | |
| 44 | function publicForgeRepo(app: App): Repo | undefined { | |
| 45 | const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/"); | |
| 46 | const repo = owner === "" || name === "" ? undefined : getRepo(app.db, owner, name); | |
| 47 | return repo !== undefined && repo.visibility === "public" ? repo : undefined; | |
| 48 | } | |
| 49 | ||
| 50 | /** What the page and the terminal both show: the repository and its newest releases. */ | |
| 51 | export async function forgeChangelog( | |
| 52 | app: App, | |
| 53 | ): Promise<{ repo: Repo; entries: ReleaseEntry[] } | undefined> { | |
| 54 | const repo = publicForgeRepo(app); | |
| 55 | if (repo === undefined) return undefined; | |
| 56 | const entries = await readChangelog(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch); | |
| 57 | return { repo, entries }; | |
| 58 | } |
src/tui/forge.ts +8
| @@ -72,6 +72,7 @@ import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts"; | ||
| 72 | 72 | import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts"; |
| 73 | 73 | import { queuesFor, systemFor } from "../system/telemetry.ts"; |
| 74 | 74 | import { live as liveMachine } from "../system/live.ts"; |
| 75 | import { changelogScreen } from "./screens/changelog.ts"; | |
| 75 | 76 | import { releasesScreen } from "./screens/releases.ts"; |
| 76 | 77 | import { mayRead } from "../release/gate.ts"; |
| 77 | 78 | import { fileHoldsPrompt } from "./screens/fileholds.ts"; |
| @@ -345,6 +346,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 345 | 346 | hint: "each protection green, red or untested", |
| 346 | 347 | run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))), |
| 347 | 348 | }); |
| 349 | // The last releases with their pulls, as /changelog shows them: for anyone. | |
| 350 | commands.push({ | |
| 351 | id: "changelog", | |
| 352 | title: "Changelog", | |
| 353 | hint: "the last 30 releases and their pulls", | |
| 354 | run: () => ctx.push(changelogScreen(session, ctx)), | |
| 355 | }); | |
| 348 | 356 | // The live release and the kept ones, as /releases gates them: a site admin or a reader of FORGE_REPO. |
| 349 | 357 | if (mayRead(session.app, session.user)) |
| 350 | 358 | commands.push({ |
src/tui/screens/changelog.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * The changelog, the terminal's /changelog: the same `forgeChangelog` read the | |
| 3 | * page makes, for anyone, a guest included. Each release is a row: version, | |
| 4 | * date and the pulls it carried; Enter opens its first pull. | |
| 5 | */ | |
| 6 | ||
| 7 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; | |
| 8 | import { sourceFor } from "../../view/source.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { emptyState } from "../ui/widgets.ts"; | |
| 13 | import { pullScreen } from "./pull.ts"; | |
| 14 | import { ListState, ago, drawList, metaOf, type ListRow } from "./common.ts"; | |
| 15 | ||
| 16 | export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | |
| 17 | return entries.map((e) => ({ | |
| 18 | title: e.version, | |
| 19 | meta: metaOf( | |
| 20 | ago(e.date), | |
| 21 | `${e.pulls.length} ${e.pulls.length === 1 ? "pull" : "pulls"}`, | |
| 22 | e.pulls.map((p) => `#${p.number} ${p.title}`.trim()).join("; "), | |
| 23 | ), | |
| 24 | })); | |
| 25 | } | |
| 26 | ||
| 27 | export function changelogScreen(f: Session, ctx: Ctx): Screen { | |
| 28 | const state = new ListState(); | |
| 29 | let entries: ReleaseEntry[] = []; | |
| 30 | let loaded = false; | |
| 31 | let repoRef: { owner: string; name: string } | undefined; | |
| 32 | return { | |
| 33 | title: "Changelog", | |
| 34 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open first pull"), binding(["esc", "q"], "close")], | |
| 35 | async load() { | |
| 36 | const read = await forgeChangelog(f.app); | |
| 37 | entries = read?.entries ?? []; | |
| 38 | repoRef = read === undefined ? undefined : { owner: read.repo.ownerName, name: read.repo.name }; | |
| 39 | state.clamp(entries.length); | |
| 40 | loaded = true; | |
| 41 | ctx.invalidate(); | |
| 42 | }, | |
| 43 | render(area, s) { | |
| 44 | const c = ctx.theme.c; | |
| 45 | if (entries.length === 0) { | |
| 46 | emptyState(s, area, c, { | |
| 47 | title: loaded ? "No releases yet" : "Reading…", | |
| 48 | body: "Each release lands here with its pulls.", | |
| 49 | }); | |
| 50 | return; | |
| 51 | } | |
| 52 | drawList(s, area, c, state, changelogRows(entries)); | |
| 53 | }, | |
| 54 | key(e) { | |
| 55 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 56 | else if (e.key === "enter") { | |
| 57 | const first = entries[state.index]?.pulls[0]; | |
| 58 | const opened = first === undefined || repoRef === undefined ? undefined : sourceFor(f.app, f.user, repoRef); | |
| 59 | if (first !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, first.number)); | |
| 60 | } else state.key(e, entries.length); | |
| 61 | return true; | |
| 62 | }, | |
| 63 | }; | |
| 64 | } |
src/web/layout.ts +1
| @@ -518,6 +518,7 @@ function footer(ctx: Ctx): Html { | ||
| 518 | 518 | >${reduced}</span |
| 519 | 519 | >`} |
| 520 | 520 | <span class="spacer"></span> |
| 521 | <a href="/changelog">Changelog</a> | |
| 521 | 522 | ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org" |
| 522 | 523 | ? html`<a class="btn" data-variant="link" data-size="sm" href="/#composer">Tell the crew</a>` |
| 523 | 524 | : ""} |
src/web/pages/changelog.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The changelog page: the releases `release/changelog.ts` reads, as rows | |
| 3 | * (version, date, and the pulls each carried). Public and server-rendered. | |
| 4 | */ | |
| 5 | ||
| 6 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 7 | import type { Ctx } from "../../http/context.ts"; | |
| 8 | import { forgeChangelog } from "../../release/changelog.ts"; | |
| 9 | import { html } from "../html.ts"; | |
| 10 | import { page } from "../layout.ts"; | |
| 11 | import { empty, listCard, row, time } from "../ui.ts"; | |
| 12 | ||
| 13 | // GET /changelog | |
| 14 | export async function changelogPage(ctx: Ctx): Promise<Response> { | |
| 15 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 16 | const read = await timePhaseAsync(ctx.request, "releases", () => forgeChangelog(ctx.app)); | |
| 17 | const rendering = performance.now(); | |
| 18 | const repo = read?.repo; | |
| 19 | const entries = read?.entries ?? []; | |
| 20 | const rows = entries.map((e) => | |
| 21 | row({ | |
| 22 | id: e.version, | |
| 23 | href: `#${e.version}`, | |
| 24 | title: e.version, | |
| 25 | trail: time(e.date), | |
| 26 | meta: html`${e.pulls.length} ${e.pulls.length === 1 ? "pull" : "pulls"}`, | |
| 27 | children: e.pulls.map((p) => | |
| 28 | row({ | |
| 29 | href: `/${repo?.ownerName}/${repo?.name}/pulls/${p.number}`, | |
| 30 | title: p.title, | |
| 31 | trail: html`#${p.number}`, | |
| 32 | }), | |
| 33 | ), | |
| 34 | }), | |
| 35 | ); | |
| 36 | const body = html`<div class="page-head"><h1>Changelog</h1></div>${entries.length === 0 | |
| 37 | ? empty({ title: "No releases yet", note: "Each release lands here with its pulls." }) | |
| 38 | : listCard(rows)}`; | |
| 39 | const response = page({ ctx, title: "Changelog", body }); | |
| 40 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 41 | return response; | |
| 42 | } |
test/changelog.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { getRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 12 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 13 | return r.value; | |
| 14 | }; | |
| 15 | ||
| 16 | describe("changelog page", () => { | |
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let alice: User; | |
| 21 | ||
| 22 | const commit = async (repo: Awaited<ReturnType<typeof createRepo>> extends infer R ? (R extends { ok: true; value: infer V } ? V : never) : never, message: string, n: number) => | |
| 23 | must(await commitToBranch(app, alice, repo, { message, files: [{ path: `f${n}.txt`, content: `${n}\n` }] })); | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | alice = must( | |
| 30 | await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | username: "alice", | |
| 34 | email: "[email protected]", | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: true, | |
| 37 | }), | |
| 38 | ); | |
| 39 | const repo = must(await createRepo(app, alice, { name: "forge", visibility: "public" })); | |
| 40 | app.config.forgeRepo = "alice/forge"; | |
| 41 | await commit(repo, "Release 0.1.0: First <b>thing</b> (#1)", 1); | |
| 42 | await commit(repo, "unrelated work", 2); | |
| 43 | await commit(repo, "Release 0.2.0: Second (#2);Third (#3)", 3); | |
| 44 | await commit(repo, "Release 0.3.0: Fourth (#4)", 4); | |
| 45 | }, 60_000); | |
| 46 | ||
| 47 | afterAll(async () => { | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dataDir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | test("lists releases newest first with anchors, dates and escaped pull links, to anyone", async () => { | |
| 54 | const res = await fetch(`${servers.url}/changelog`); | |
| 55 | expect(res.status).toBe(200); | |
| 56 | expect(res.headers.get("server-timing")).toContain("releases"); | |
| 57 | const body = await res.text(); | |
| 58 | const at = (s: string) => body.indexOf(s); | |
| 59 | expect(at('id="0.3.0"')).toBeGreaterThan(-1); | |
| 60 | expect(at('id="0.3.0"')).toBeLessThan(at('id="0.2.0"')); | |
| 61 | expect(at('id="0.2.0"')).toBeLessThan(at('id="0.1.0"')); | |
| 62 | expect(body).toContain('href="/alice/forge/pulls/2"'); | |
| 63 | expect(body).toContain('href="/alice/forge/pulls/3"'); | |
| 64 | expect(body).toContain("<time"); | |
| 65 | expect(body).toContain("First <b>thing</b>"); | |
| 66 | expect(body).not.toContain("<b>thing</b>"); | |
| 67 | expect(body).not.toContain("unrelated work"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the footer links to it", async () => { | |
| 71 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 72 | expect(body).toContain('href="/changelog"'); | |
| 73 | }); | |
| 74 | ||
| 75 | test("with no forge repository it answers 200 and says there are no releases", async () => { | |
| 76 | const kept = app.config.forgeRepo; | |
| 77 | delete app.config.forgeRepo; | |
| 78 | const res = await fetch(`${servers.url}/changelog`); | |
| 79 | if (kept !== undefined) app.config.forgeRepo = kept; | |
| 80 | expect(res.status).toBe(200); | |
| 81 | expect(await res.text()).toContain("No releases yet"); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a new release shows after the head moves", async () => { | |
| 85 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 86 | await commit(repo, "Release 0.4.0: Fifth (#5)", 5); | |
| 87 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 88 | expect(body.indexOf('id="0.4.0"')).toBeGreaterThan(-1); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a private forge repository shows the empty state, with no version or pull link", async () => { | |
| 92 | app.db.run("UPDATE repo SET visibility = 'private' WHERE name = 'forge'"); | |
| 93 | try { | |
| 94 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 95 | expect(body).toContain("No releases yet"); | |
| 96 | expect(body).not.toContain('id="0.'); | |
| 97 | expect(body).not.toContain("/alice/forge/pulls/"); | |
| 98 | } finally { | |
| 99 | app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'"); | |
| 100 | } | |
| 101 | }); | |
| 102 | ||
| 103 | test("only well-formed release subjects count, and the page shows exactly 30", async () => { | |
| 104 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 105 | await commit(repo, "Release 9: not a version (#90)", 90); | |
| 106 | for (let n = 0; n < 31; n++) await commit(repo, `Release 1.0.${n}: Thing ${n} (#${100 + n})`, 100 + n); | |
| 107 | const body = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 108 | expect(body.match(/<[a-z]+ [^>]*id="\d+\.\d+\.\d+"/g)?.length).toBe(30); | |
| 109 | expect(body).toContain('id="1.0.30"'); | |
| 110 | expect(body).not.toContain('id="1.0.0"'); | |
| 111 | expect(body).not.toContain("not a version"); | |
| 112 | }, 60_000); | |
| 113 | }); |
test/tui-changelog.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** The changelog in the terminal: the palette offers it to a guest, and the screen lists the page's releases. */ | |
| 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 { openPull } from "../src/actions/pulls.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 11 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 12 | import { changelogScreen } from "../src/tui/screens/changelog.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { settleApp } from "./helpers/tui.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | let app: App; | |
| 21 | const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" }; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | dir = await mkdtemp(join(tmpdir(), "forge-tui-changelog-")); | |
| 25 | app = createApp({ dataDir: dir, port: 0 }); | |
| 26 | const made = await createUser(app.db, { | |
| 27 | origin: "seed", | |
| 28 | trusted: true, | |
| 29 | username: "alice", | |
| 30 | email: "[email protected]", | |
| 31 | password: "a-good-passphrase", | |
| 32 | adminIfFirst: true, | |
| 33 | }); | |
| 34 | if (!made.ok) throw new Error(made.error); | |
| 35 | const repo = await createRepo(app, made.value, { name: "forge", visibility: "public" }); | |
| 36 | if (!repo.ok) throw new Error(JSON.stringify(repo.error)); | |
| 37 | app.config.forgeRepo = "alice/forge"; | |
| 38 | for (const [n, message] of ["Release 0.1.0: Older (#1)", "Release 0.2.0: Newer thing (#2)"].entries()) { | |
| 39 | const done = await commitToBranch(app, made.value, repo.value, { | |
| 40 | message, | |
| 41 | files: [{ path: `f${n}.txt`, content: `${n}\n` }], | |
| 42 | }); | |
| 43 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 44 | } | |
| 45 | // The pulls the releases name, so Enter has a pull to open: #1 then #2. | |
| 46 | for (const [n, title] of ["Older", "Newer thing"].entries()) { | |
| 47 | const branch = `p${n + 1}`; | |
| 48 | const done = await commitToBranch(app, made.value, repo.value, { | |
| 49 | message: title, | |
| 50 | branch, | |
| 51 | files: [{ path: `p${n}.txt`, content: `${n}\n` }], | |
| 52 | }); | |
| 53 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 54 | const pull = await openPull(app, made.value, repo.value, { title, baseBranch: repo.value.defaultBranch, headBranch: branch }); | |
| 55 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 56 | } | |
| 57 | }, 60_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | app.close(); | |
| 61 | await rm(dir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a guest is offered the changelog and sees the releases newest first", async () => { | |
| 65 | expect(forgeCommands(new Session(app, guest))({} as Ctx).some((c) => c.id === "changelog")).toBe(true); | |
| 66 | const terminal = headlessTerminal(120, 30); | |
| 67 | const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false }); | |
| 68 | tui.start(); | |
| 69 | tui.ctx.push(changelogScreen(session, tui.ctx)); | |
| 70 | await settleApp(tui); | |
| 71 | const text = tui.screen.toText(); | |
| 72 | terminal.feed("\r"); | |
| 73 | await settleApp(tui); | |
| 74 | const opened = tui.screen.toText(); | |
| 75 | tui.stop?.(); | |
| 76 | // Enter opens the first pull of the newest release: pull #2's own screen. | |
| 77 | expect(opened).not.toBe(text); | |
| 78 | expect(opened).toContain("Changelog › #2"); | |
| 79 | expect(opened).not.toContain("0.1.0"); | |
| 80 | expect(text.indexOf("0.2.0")).toBeGreaterThan(-1); | |
| 81 | expect(text.indexOf("0.2.0")).toBeLessThan(text.indexOf("0.1.0")); | |
| 82 | expect(text).toContain("#2 Newer thing"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a private forge repository leaves the terminal's screen empty", async () => { | |
| 86 | app.db.run("UPDATE repo SET visibility = 'private' WHERE name = 'forge'"); | |
| 87 | const terminal = headlessTerminal(120, 30); | |
| 88 | const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false }); | |
| 89 | try { | |
| 90 | tui.start(); | |
| 91 | tui.ctx.push(changelogScreen(session, tui.ctx)); | |
| 92 | await settleApp(tui); | |
| 93 | const text = tui.screen.toText(); | |
| 94 | tui.stop?.(); | |
| 95 | expect(text).toContain("No releases yet"); | |
| 96 | expect(text).not.toContain("0.2.0"); | |
| 97 | expect(text).not.toContain("#2"); | |
| 98 | } finally { | |
| 99 | app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'"); | |
| 100 | } | |
| 101 | }); |
new above
offline
Low: this spawns a
git rev-parseon every public request, before the cache is checked. It is fine at today's scale. If this page gets hot, read the head from the forge's own ref state or keep the oid for a few seconds.