Public: anyone can read this; the crew can change it.
The changelog credits who built every landing: the crew member and the model, read from the commit #10277
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10259-the-changelog-credits-who-built-every-la → main 3 commits fork 6 threads · 6 unresolved
docs/features/changelog.md +2 −2
| @@ -12,9 +12,9 @@ One bounded `git log` read, cached per head oid; Server-Timing names `releases` | ||
| 12 | 12 | |
| 13 | 13 | The list is a live region (`pull.merged`, `app.serving`): it swaps in place without a reload. MCP `releases` with `{shipped: true}` answers the same list to anyone. The "no release" alarm reads the same record (`release/landings.ts`), refreshed each minute, and is silent until a read has happened. The older `Release <version>: …` commit read (`release/changelog.ts`) now serves only the terminal's Changelog screen. |
| 14 | 14 | |
| 15 | ## Credit (#9181) | |
| 15 | ## Credit (#9181, #10259) | |
| 16 | 16 | |
| 17 | A squash keeps the people behind its commits: the landed message carries every distinct `Co-authored-by` its commits carried and each commit's author, except the crew's agents (`@agents.invalid`), once per address. `/changelog` then shows "· with Jay Moran" on a row for everyone credited on the landed commit beyond whoever landed it (`creditsOf` in `git/coauthors.ts`, one git read, kept per commit); a landing with no one else shows nothing. Only the forge's own repository is read. `GET /api/v1/changelog` carries the same names as `with` on a landing that has them. The terminal's Changelog screen is the release-commit view and does not list landings, so it shows no credit. | |
| 17 | A squash keeps the people behind its commits: the landed message carries every distinct `Co-authored-by` its commits carried and each commit's author, except the crew's agents (`@agents.invalid`), once per address. `/changelog` then shows "With Jay Moran" on a row for everyone credited on the landed commit beyond whoever landed it (`creditsOf` in `git/coauthors.ts`, one git read, kept per commit); a landing with no one else shows nothing. Only the forge's own repository is read. A row also says who built it: "By Scout" is the author of the landed commit, "With Claude Sonnet 5.5" the names in its trailers (less the committer and the author), both as plain facts under the title, from the same one git read (`buildersOf`). At most the newest 80 landings of the forge's own repository are asked of git, and at most 500 commits are remembered, oldest dropped first. `GET /api/v1/changelog` carries `by` and `with` on a landing that has them. In the terminal, Enter on a release lists its pulls, and a pull the forge landed shows "by Scout, with Claude Sonnet 5.5" beside it. | |
| 18 | 18 | |
| 19 | 19 | ## For agents |
| 20 | 20 |
src/contract/openapi/routes/getApiV1Changelog.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "get", |
| 5 | 5 | path: "/api/v1/changelog", |
| 6 | summary: "What went live and what is on its way, as /changelog shows it, for anyone: version, commit, live and onItsWay rows (repo, number, title, at) of public repositories; ?repo=all|owner/name, this forge's own by default (all when it has none); carries a weak ETag of the live version, the chosen repo and the rows, and a repeat sending If-None-Match gets 304 with no body and the same ETag and cache-control", | |
| 6 | summary: "What went live and what is on its way, as /changelog shows it, for anyone: version, commit, live and onItsWay rows (repo, number, title, at, and for the forge's own landings by: who built it, with: the models and people its commit credits) of public repositories; ?repo=all|owner/name, this forge's own by default (all when it has none); carries a weak ETag of the live version, the chosen repo and the rows, and a repeat sending If-None-Match gets 304 with no body and the same ETag and cache-control", | |
| 7 | 7 | alsoStatus: [304], |
| 8 | 8 | auth: false, |
| 9 | 9 | query: { repo: "string" }, |
src/git/coauthors.ts +33 −13
| @@ -54,16 +54,30 @@ export async function messagesBetween(dir: string, base: string, head: string): | ||
| 54 | 54 | return r.stdout.split("\0").filter((m) => m.trim() !== ""); |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | /** What a landed commit says of who else made it, per commit; a commit never changes, so it is asked once. */ | |
| 58 | const credited = new Map<string, readonly string[]>(); | |
| 57 | /** Who built a landed commit (its author) and who else it credits (the models and people in its trailers). */ | |
| 58 | export interface Builders { | |
| 59 | readonly by: string; | |
| 60 | readonly with: readonly string[]; | |
| 61 | } | |
| 62 | ||
| 63 | /** What a landed commit says of who made it, per commit; a commit never changes, so it is asked once. */ | |
| 64 | const credited = new Map<string, Builders>(); | |
| 65 | /** Commits remembered at most; the oldest remembered goes first. */ | |
| 66 | export const CREDITED_MAX = 500; | |
| 67 | ||
| 68 | function remember(key: string, b: Builders): void { | |
| 69 | credited.delete(key); | |
| 70 | credited.set(key, b); | |
| 71 | if (credited.size > CREDITED_MAX) credited.delete(credited.keys().next().value!); | |
| 72 | } | |
| 59 | 73 | |
| 60 | 74 | /** |
| 61 | * The names credited on each landed commit in `oids`, beyond the one who | |
| 62 | * committed it (its own `Co-authored-by` names the lander). Commits git | |
| 63 | * cannot read are left out. | |
| 75 | * Who built each landed commit in `oids`: its author as `by`, and as `with` | |
| 76 | * the names in its own `Co-authored-by` trailers, less the committer (the | |
| 77 | * lander) and the author. Commits git cannot read are left out. | |
| 64 | 78 | */ |
| 65 | export async function creditsOf(dir: string, oids: readonly string[]): Promise<Map<string, readonly string[]>> { | |
| 66 | const out = new Map<string, readonly string[]>(); | |
| 79 | export async function buildersOf(dir: string, oids: readonly string[]): Promise<Map<string, Builders>> { | |
Non-blocking: |
||
| 80 | const out = new Map<string, Builders>(); | |
| 67 | 81 | const todo: string[] = []; |
| 68 | 82 | for (const oid of oids) { |
| 69 | 83 | if (!isSafeOid(oid)) continue; |
| @@ -72,23 +86,29 @@ export async function creditsOf(dir: string, oids: readonly string[]): Promise<M | ||
| 72 | 86 | else out.set(oid, known); |
| 73 | 87 | } |
| 74 | 88 | if (todo.length > 0) { |
| 75 | const r = await runGitCapture(["log", "--no-walk=unsorted", "--format=%H%x01%ce%x01%B%x02", ...todo], { cwd: dir }); | |
| 89 | const r = await runGitCapture(["log", "--no-walk=unsorted", "--format=%H%x01%an%x01%ce%x01%B%x02", ...todo], { cwd: dir }); | |
| 76 | 90 | if (r.code === 0) |
| 77 | 91 | for (const block of r.stdout.split("\x02")) { |
| 78 | const [oid, committer, message] = block.trim().split("\x01"); | |
| 79 | if (!oid || committer === undefined || message === undefined) continue; | |
| 92 | const [oid, author, committer, message] = block.trim().split("\x01"); | |
| 93 | if (!oid || author === undefined || committer === undefined || message === undefined) continue; | |
| 80 | 94 | const names: string[] = []; |
| 81 | 95 | for (const line of coAuthorLines([message], [committer])) { |
| 82 | 96 | const name = /^Co-authored-by: (.*) <[^<>]*>$/.exec(line)?.[1]; |
| 83 | if (name !== undefined) names.push(name); | |
| 97 | if (name !== undefined && name.toLowerCase() !== author.toLowerCase()) names.push(name); | |
| 84 | 98 | } |
| 85 | credited.set(`${dir}\0${oid}`, names); | |
| 86 | out.set(oid, names); | |
| 99 | const builders = { by: author, with: names }; | |
| 100 | remember(`${dir}\0${oid}`, builders); | |
| 101 | out.set(oid, builders); | |
| 87 | 102 | } |
| 88 | 103 | } |
| 89 | 104 | return out; |
| 90 | 105 | } |
| 91 | 106 | |
| 107 | /** The names credited on each landed commit in `oids`, beyond its lander and author. */ | |
| 108 | export async function creditsOf(dir: string, oids: readonly string[]): Promise<Map<string, readonly string[]>> { | |
| 109 | return new Map([...(await buildersOf(dir, oids))].map(([oid, b]) => [oid, b.with] as const)); | |
| 110 | } | |
| 111 | ||
| 92 | 112 | /** |
| 93 | 113 | * The `Co-authored-by` lines a squash of `base..head` keeps: those its commits |
| 94 | 114 | * carry and its commits' human authors, once per address, less `skip`. |
src/release/landings.ts +17 −9
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | import type { Database } from "bun:sqlite"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | 17 | import { getRepo, type Repo } from "../core/repos.ts"; |
| 18 | import { creditsOf } from "../git/coauthors.ts"; | |
| 18 | import { buildersOf, type Builders } from "../git/coauthors.ts"; | |
| 19 | 19 | import { runGit } from "../git/env.ts"; |
| 20 | 20 | import { repoDir } from "../git/repos.ts"; |
| 21 | 21 | import { running } from "../kernel/version.ts"; |
| @@ -31,7 +31,9 @@ export interface Landing { | ||
| 31 | 31 | readonly bet?: string; |
| 32 | 32 | /** `account@host` when a trusted source's work folded in (#8937), set by the page that shows it. */ |
| 33 | 33 | readonly from?: string; |
| 34 | /** Others credited on the landed commit (`Co-authored-by`), beyond whoever landed it. */ | |
| 34 | /** Who built it: the author of the landed commit (a crew member or a person). */ | |
| 35 | readonly by?: string; | |
| 36 | /** Others credited on the landed commit (`Co-authored-by`: the models, people), beyond whoever landed or wrote it. */ | |
| 35 | 37 | readonly with?: readonly string[]; |
| 36 | 38 | } |
| 37 | 39 | |
| @@ -211,14 +213,19 @@ async function askLive(app: App, rows: readonly MergedRow[]): Promise<void> { | ||
| 211 | 213 | kept = { own: named, oids, misses, at, commit }; |
| 212 | 214 | } |
| 213 | 215 | |
| 214 | /** The names credited on the forge's own landed commits, by `repo#number`. */ | |
| 215 | async function creditsOfRepo(app: App, own: string, rows: readonly MergedRow[]): Promise<Map<string, readonly string[]>> { | |
| 216 | /** Who built the forge's own landed commits, by `repo#number`. */ | |
| 217 | async function buildersOfRepo(app: App, own: string, rows: readonly MergedRow[]): Promise<Map<string, Builders>> { | |
| 216 | 218 | const [owner = "", name = ""] = own.split("/"); |
| 217 | 219 | const repo = getRepo(app.db, owner, name); |
| 218 | 220 | if (repo === undefined) return new Map(); |
| 219 | 221 | const mine = rows.filter((r) => r.repo.toLowerCase() === own.toLowerCase() && r.oid !== "").slice(0, ASKED); |
outdated
High: the |
||
| 220 | const byOid = await creditsOf(repoDir(app.reposRoot, repo.ownerName, repo.name), mine.map((r) => r.oid)); | |
| 221 | return new Map(mine.map((r) => [`${r.repo}#${r.number}`, byOid.get(r.oid) ?? []] as const)); | |
| 222 | const byOid = await buildersOf(repoDir(app.reposRoot, repo.ownerName, repo.name), mine.map((r) => r.oid)); | |
| 223 | const out = new Map<string, Builders>(); | |
| 224 | for (const r of mine) { | |
| 225 | const b = byOid.get(r.oid); | |
| 226 | if (b !== undefined) out.set(`${r.repo}#${r.number}`, b); | |
| 227 | } | |
| 228 | return out; | |
| 222 | 229 | } |
| 223 | 230 | |
| 224 | 231 | /** Every landing, live or on its way, as of the running commit. */ |
| @@ -227,10 +234,11 @@ export async function readLandings(app: App): Promise<Landings> { | ||
| 227 | 234 | await askLive(app, rows).catch(() => undefined); |
| 228 | 235 | const { live, onItsWay } = split(rows, kept); |
| 229 | 236 | const own = kept?.own; |
| 230 | const credits = own === undefined ? new Map<string, readonly string[]>() : await creditsOfRepo(app, own, rows).catch(() => new Map<string, readonly string[]>()); | |
| 237 | const credits = own === undefined ? new Map<string, Builders>() : await buildersOfRepo(app, own, rows).catch(() => new Map<string, Builders>()); | |
| 231 | 238 | const named = (l: Landing): Landing => { |
| 232 | const names = credits.get(`${l.repo}#${l.number}`) ?? []; | |
| 233 | return names.length === 0 ? l : { ...l, with: names }; | |
| 239 | const b = credits.get(`${l.repo}#${l.number}`); | |
| 240 | if (b === undefined) return l; | |
| 241 | return { ...l, ...(b.by === "" ? {} : { by: b.by }), ...(b.with.length === 0 ? {} : { with: b.with }) }; | |
| 234 | 242 | }; |
| 235 | 243 | const { version, commit } = running(); |
| 236 | 244 | return { version, commit, own, live: live.map(named), onItsWay: onItsWay.map(named) }; |
src/tui/screens/changelog.ts +20 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; |
| 9 | import { readLandings } from "../../release/landings.ts"; | |
| 9 | 10 | import { sourceFor } from "../../view/source.ts"; |
| 10 | 11 | import type { Ctx, Screen } from "../app/app.ts"; |
| 11 | 12 | import { binding } from "../app/keys.ts"; |
| @@ -35,14 +36,20 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | ||
| 35 | 36 | |
| 36 | 37 | type Repo = { owner: string; name: string }; |
| 37 | 38 | |
| 39 | /** Who built a pull, in plain words: "by Scout, with Claude Sonnet 5.5"; empty when the commit says nothing. */ | |
| 40 | export function creditWords(by: string | undefined, withNames: readonly string[] | undefined): string { | |
| 41 | const parts = [by === undefined ? "" : `by ${by}`, withNames === undefined || withNames.length === 0 ? "" : `with ${withNames.join(", ")}`]; | |
| 42 | return parts.filter((p) => p !== "").join(", "); | |
| 43 | } | |
| 44 | ||
| 38 | 45 | /** One release's pulls, `#N title`; Enter opens the pull as the changelog's Enter once did. */ |
| 39 | function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry): Screen { | |
| 46 | function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry, credits: ReadonlyMap<number, string>): Screen { | |
| 40 | 47 | const state = new ListState(); |
| 41 | 48 | return { |
| 42 | 49 | title: entry.version, |
| 43 | 50 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open pull"), binding(["esc", "q"], "back")], |
| 44 | 51 | render(area, s) { |
| 45 | const rows = entry.pulls.map((p) => ({ title: `#${p.number} ${p.title}`.trim() })); | |
| 52 | const rows = entry.pulls.map((p) => ({ title: `#${p.number} ${p.title}`.trim(), meta: credits.get(p.number) ?? "" })); | |
| 46 | 53 | if (rows.length === 0) { |
| 47 | 54 | emptyState(s, area, ctx.theme.c, { title: "No pulls", body: "This release carried none." }); |
| 48 | 55 | return; |
| @@ -67,6 +74,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 67 | 74 | let entries: ReleaseEntry[] = []; |
| 68 | 75 | let loaded = false; |
| 69 | 76 | let repoRef: Repo | undefined; |
| 77 | let credits = new Map<number, string>(); | |
| 70 | 78 | const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e)); |
| 71 | 79 | return { |
| 72 | 80 | title: "Changelog", |
| @@ -80,6 +88,15 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 80 | 88 | const read = await forgeChangelog(f.app); |
| 81 | 89 | entries = read?.entries ?? []; |
| 82 | 90 | repoRef = read === undefined ? undefined : { owner: read.repo.ownerName, name: read.repo.name }; |
| 91 | if (read !== undefined) { | |
| 92 | const landings = await readLandings(f.app).catch(() => undefined); | |
| 93 | const repoName = `${read.repo.ownerName}/${read.repo.name}`.toLowerCase(); | |
| 94 | credits = new Map(); | |
| 95 | for (const l of [...(landings?.live ?? []), ...(landings?.onItsWay ?? [])]) { | |
| 96 | const words = creditWords(l.by, l.with); | |
| 97 | if (words !== "" && l.repo.toLowerCase() === repoName) credits.set(l.number, words); | |
| 98 | } | |
| 99 | } | |
| 83 | 100 | state.clamp(entries.length); |
| 84 | 101 | loaded = true; |
| 85 | 102 | ctx.invalidate(); |
| @@ -115,7 +132,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 115 | 132 | } else if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 116 | 133 | else if (e.key === "enter") { |
| 117 | 134 | const entry = shown()[state.index]; |
| 118 | if (entry !== undefined && repoRef !== undefined) ctx.push(releasePulls(f, ctx, repoRef, entry)); | |
| 135 | if (entry !== undefined && repoRef !== undefined) ctx.push(releasePulls(f, ctx, repoRef, entry, credits)); | |
| 119 | 136 | } else state.key(e, shown().length); |
| 120 | 137 | return true; |
| 121 | 138 | }, |
src/web/pages/changelog.ts +2 −1
| @@ -32,7 +32,8 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm | ||
| 32 | 32 | return row({ |
| 33 | 33 | href: `/${l.repo}/pulls/${l.number}`, |
| 34 | 34 | title: l.title, |
| 35 | meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}${l.from === undefined ? null : html` · folded in from ${l.from}`}${l.with === undefined ? null : html` · with ${l.with.join(", ")}`}`, | |
| 35 | meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}${l.from === undefined ? null : html` · folded in from ${l.from}`}`, | |
outdated
Both doors: a person can see who built a landing on the page but not in the terminal. Add the same |
||
| 36 | facts: [...(l.by === undefined ? [] : [{ label: "By", value: l.by }]), ...(l.with === undefined ? [] : [{ label: "With", value: l.with.join(", ") }])], | |
| 36 | 37 | trail: time(l.at), |
| 37 | 38 | }); |
| 38 | 39 | } |
test/coauthors.test.ts +54 −4
| @@ -13,7 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 13 | 13 | import { getPull } from "../src/core/pulls.ts"; |
| 14 | 14 | import type { Repo } from "../src/core/repos.ts"; |
| 15 | 15 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 16 | import { coAuthorLines, COAUTHORS_MAX, authorLinesBetween, creditsOf } from "../src/git/coauthors.ts"; | |
| 16 | import { coAuthorLines, COAUTHORS_MAX, authorLinesBetween, buildersOf, creditsOf, CREDITED_MAX } from "../src/git/coauthors.ts"; | |
| 17 | 17 | import { runGitCapture } from "../src/git/env.ts"; |
| 18 | 18 | import { serve } from "../src/http/server.ts"; |
| 19 | 19 | import { forgetLive, readLandings } from "../src/release/landings.ts"; |
| @@ -58,6 +58,54 @@ describe("authorLinesBetween", () => { | ||
| 58 | 58 | }); |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | describe("buildersOf", () => { | |
| 62 | test("reads the builder and the models from a squash commit whose trailers sit only in it", async () => { | |
| 63 | const dir = await mkdtemp(join(tmpdir(), "forge-builders-")); | |
| 64 | try { | |
| 65 | const git = (args: string[], msg?: string) => { | |
| 66 | const env = { ...process.env, GIT_AUTHOR_NAME: "Scout", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "trevato", GIT_COMMITTER_EMAIL: "[email protected]" }; | |
| 67 | return Bun.spawnSync(["git", ...args], { cwd: dir, env, stdin: msg === undefined ? undefined : new TextEncoder().encode(msg) }).stdout.toString().trim(); | |
| 68 | }; | |
| 69 | git(["init", "-q", "-b", "main"]); | |
| 70 | git(["commit", "-q", "--allow-empty", "-F", "-"], "Add it (#1)\n\nCo-authored-by: trevato <[email protected]>\nCo-authored-by: Claude Sonnet 5.5 <[email protected]>\nCo-authored-by: Scout <[email protected]>\n"); | |
| 71 | const oid = git(["rev-parse", "HEAD"]); | |
| 72 | git(["commit", "-q", "--allow-empty", "-m", "bare"]); | |
| 73 | const bare = git(["rev-parse", "HEAD"]); | |
| 74 | const got = await buildersOf(dir, [oid, bare]); | |
| 75 | expect(got.get(oid)).toEqual({ by: "Scout", with: ["Claude Sonnet 5.5"] }); | |
| 76 | expect(got.get(bare)).toEqual({ by: "Scout", with: [] }); | |
| 77 | } finally { | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | } | |
| 80 | }); | |
| 81 | }); | |
Add a test that would fail without the bound: more landings than the bound, with the credits still resolved for the rows that are shown. Also cover an author whose name matches a trailer case-insensitively, because the new filter does that. |
||
| 82 | ||
| 83 | describe("buildersOf bounds", () => { | |
| 84 | test("remembers at most CREDITED_MAX commits, and drops an author-named trailer in any case", async () => { | |
| 85 | const dir = await mkdtemp(join(tmpdir(), "forge-builders-max-")); | |
| 86 | try { | |
| 87 | const env = { ...process.env, GIT_AUTHOR_NAME: "Scout", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "[email protected]" }; | |
| 88 | const git = (args: string[]) => Bun.spawnSync(["git", ...args], { cwd: dir, env }).stdout.toString().trim(); | |
| 89 | git(["init", "-q", "-b", "main"]); | |
| 90 | git(["commit", "-q", "--allow-empty", "-m", "named\n\nCo-authored-by: SCOUT <[email protected]>\nCo-authored-by: Claude <[email protected]>"]); | |
| 91 | const first = git(["rev-parse", "HEAD"]); | |
| 92 | expect((await buildersOf(dir, [first])).get(first)).toEqual({ by: "Scout", with: ["Claude"] }); | |
| 93 | const oids = [first]; | |
| 94 | for (let i = 0; i < CREDITED_MAX + 5; i++) { | |
| 95 | git(["commit", "-q", "--allow-empty", "-m", `c${i}`]); | |
| 96 | oids.push(git(["rev-parse", "HEAD"])); | |
| 97 | } | |
| 98 | const got = await buildersOf(dir, oids); | |
| 99 | expect(got.size).toBe(oids.length); | |
| 100 | expect(got.get(oids.at(-1)!)).toEqual({ by: "Scout", with: [] }); | |
| 101 | // The newest are still remembered after the oldest were dropped: a repeat read still answers. | |
| 102 | expect((await buildersOf(dir, oids.slice(-3))).size).toBe(3); | |
| 103 | } finally { | |
| 104 | await rm(dir, { recursive: true, force: true }); | |
| 105 | } | |
| 106 | }, 60_000); | |
| 107 | }); | |
| 108 | ||
| 61 | 109 | describe("a squash landing", () => { |
| 62 | 110 | let dataDir: string; |
| 63 | 111 | let app: App; |
| @@ -149,17 +197,19 @@ describe("a squash landing", () => { | ||
| 149 | 197 | const byTitle = (t: string) => [...read.live, ...read.onItsWay].find((l) => l.title === t)!; |
| 150 | 198 | expect(byTitle("Do credited").with).toEqual(["Jay Moran"]); |
| 151 | 199 | expect(byTitle("Do uncredited").with).toBeUndefined(); |
| 200 | expect(byTitle("Do uncredited").by).toBe("scout"); | |
| 201 | expect(byTitle("Do credited").by).toBe("scout"); | |
| 152 | 202 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 153 | 203 | const oid = (await runGitCapture(["rev-parse", "main"], { cwd: dir })).stdout.trim(); |
| 154 | 204 | expect((await creditsOf(dir, [oid])).get(oid)).toEqual([]); |
| 155 | 205 | const server = await serve(app); |
| 156 | 206 | try { |
| 157 | 207 | const html = await (await fetch(`${server.url}/changelog`)).text(); |
| 158 | expect(html).toContain("with Jay Moran"); | |
| 208 | expect(html).toContain("<dt>With</dt><dd>Jay Moran</dd>"); | |
| 159 | 209 | // Three landed pulls carry Jay's credit (a trailer twice, an authorship once); the rest say nothing of it. |
| 160 | expect(html.match(/with Jay Moran/g)?.length).toBe(3); | |
| 210 | expect(html.match(/<dt>With<\/dt><dd>Jay Moran/g)?.length).toBe(3); | |
| 161 | 211 | const row = html.slice(html.indexOf("Do uncredited")); |
| 162 | expect(row.slice(0, row.indexOf("</li>"))).not.toContain(" with "); | |
| 212 | expect(row.slice(0, row.indexOf("</li>"))).not.toContain("<dt>With</dt>"); | |
| 163 | 213 | } finally { |
| 164 | 214 | server.stop(); |
| 165 | 215 | if (was === undefined) delete process.env["FORGE_COMMIT"]; |
test/tui-changelog.test.ts +8
| @@ -101,3 +101,11 @@ test("a private forge repository leaves the terminal's screen empty", async () = | ||
| 101 | 101 | app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'"); |
| 102 | 102 | } |
| 103 | 103 | }); |
| 104 | ||
| 105 | import { creditWords } from "../src/tui/screens/changelog.ts"; | |
Non-blocking: move this import to the top of the file and use the file indentation in the test below. |
||
| 106 | ||
| 107 | test("creditWords says who built a pull in plain words, and nothing when the commit says nothing", () => { | |
| 108 | expect(creditWords("Scout", ["Claude Sonnet 5.5", "Jay"])).toBe("by Scout, with Claude Sonnet 5.5, Jay"); | |
| 109 | expect(creditWords("Scout", [])).toBe("by Scout"); | |
| 110 | expect(creditWords(undefined, undefined)).toBe(""); | |
| 111 | }); |
The
creditedmap is keyed by dir and oid and never shrinks. With the cap gone it grows with the history. Bound it, for example with a maximum size that drops the oldest entry.