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

Merged opened this in answer to

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

Merged

mason/forge:mason/10259-the-changelog-credits-who-built-every-la → main 3 commits fork 6 threads · 6 unresolved

  • Merged. mason/10259-the-changelog-credits-who-built-every-la was merged into main as cb081fe.

Review

Sign in to review this change.
8 files changed +137 −33
docs/features/changelog.md +2 −2
@@ -12,9 +12,9 @@ One bounded `git log` read, cached per head oid; Server-Timing names `releases`
1212
1313 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.
1414
15 ## Credit (#9181)
15 ## Credit (#9181, #10259)
1616
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.
1818
1919 ## For agents
2020
src/contract/openapi/routes/getApiV1Changelog.ts +1 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "get",
55 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",
77 alsoStatus: [304],
88 auth: false,
99 query: { repo: "string" },
src/git/coauthors.ts +33 −13
@@ -54,16 +54,30 @@ export async function messagesBetween(dir: string, base: string, head: string):
5454 return r.stdout.split("\0").filter((m) => m.trim() !== "");
5555 }
5656
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). */

The credited map 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.

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 }
5973
6074 /**
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.
6478 */
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: creditsOf has no caller in src after this change. Remove it, or say why it stays.

80 const out = new Map<string, Builders>();
6781 const todo: string[] = [];
6882 for (const oid of oids) {
6983 if (!isSafeOid(oid)) continue;
@@ -72,23 +86,29 @@ export async function creditsOf(dir: string, oids: readonly string[]): Promise<M
7286 else out.set(oid, known);
7387 }
7488 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 });
7690 if (r.code === 0)
7791 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;
8094 const names: string[] = [];
8195 for (const line of coAuthorLines([message], [committer])) {
8296 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);
8498 }
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);
87102 }
88103 }
89104 return out;
90105 }
91106
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
92112 /**
93113 * The `Co-authored-by` lines a squash of `base..head` keeps: those its commits
94114 * carry and its commits' human authors, once per address, less `skip`.
src/release/landings.ts +17 −9
@@ -15,7 +15,7 @@
1515 import type { Database } from "bun:sqlite";
1616 import type { App } from "../app.ts";
1717 import { getRepo, type Repo } from "../core/repos.ts";
18 import { creditsOf } from "../git/coauthors.ts";
18 import { buildersOf, type Builders } from "../git/coauthors.ts";
1919 import { runGit } from "../git/env.ts";
2020 import { repoDir } from "../git/repos.ts";
2121 import { running } from "../kernel/version.ts";
@@ -31,7 +31,9 @@ export interface Landing {
3131 readonly bet?: string;
3232 /** `account@host` when a trusted source's work folded in (#8937), set by the page that shows it. */
3333 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. */
3537 readonly with?: readonly string[];
3638 }
3739
@@ -211,14 +213,19 @@ async function askLive(app: App, rows: readonly MergedRow[]): Promise<void> {
211213 kept = { own: named, oids, misses, at, commit };
212214 }
213215
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>> {
216218 const [owner = "", name = ""] = own.split("/");
217219 const repo = getRepo(app.db, owner, name);
218220 if (repo === undefined) return new Map();
219221 const mine = rows.filter((r) => r.repo.toLowerCase() === own.toLowerCase() && r.oid !== "").slice(0, ASKED);
outdated

High: the .slice(0, ASKED) is removed, so buildersOf is called with every merged row's oid. Those oids are passed as argv to one git log, which can exceed the argument limit on a long history and fail. Each oid is also stored in the unbounded credited map. The .catch hides the failure, and then no landing gets credits at all. Fix: keep the bound, for example a constant for the rows shown, or batch the oids in chunks. Leaving the cap off is not the fix for empty credits. Find why the rows were empty, and test with more landings than the bound.

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;
222229 }
223230
224231 /** Every landing, live or on its way, as of the running commit. */
@@ -227,10 +234,11 @@ export async function readLandings(app: App): Promise<Landings> {
227234 await askLive(app, rows).catch(() => undefined);
228235 const { live, onItsWay } = split(rows, kept);
229236 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>());
231238 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 }) };
234242 };
235243 const { version, commit } = running();
236244 return { version, commit, own, live: live.map(named), onItsWay: onItsWay.map(named) };
src/tui/screens/changelog.ts +20 −3
@@ -6,6 +6,7 @@
66 */
77
88 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
9 import { readLandings } from "../../release/landings.ts";
910 import { sourceFor } from "../../view/source.ts";
1011 import type { Ctx, Screen } from "../app/app.ts";
1112 import { binding } from "../app/keys.ts";
@@ -35,14 +36,20 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
3536
3637 type Repo = { owner: string; name: string };
3738
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
3845 /** 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 {
4047 const state = new ListState();
4148 return {
4249 title: entry.version,
4350 keys: [binding(["j", "k"], "move"), binding(["enter"], "open pull"), binding(["esc", "q"], "back")],
4451 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) ?? "" }));
4653 if (rows.length === 0) {
4754 emptyState(s, area, ctx.theme.c, { title: "No pulls", body: "This release carried none." });
4855 return;
@@ -67,6 +74,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
6774 let entries: ReleaseEntry[] = [];
6875 let loaded = false;
6976 let repoRef: Repo | undefined;
77 let credits = new Map<number, string>();
7078 const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e));
7179 return {
7280 title: "Changelog",
@@ -80,6 +88,15 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
8088 const read = await forgeChangelog(f.app);
8189 entries = read?.entries ?? [];
8290 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 }
83100 state.clamp(entries.length);
84101 loaded = true;
85102 ctx.invalidate();
@@ -115,7 +132,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
115132 } else if (e.key === "esc" || e.key === "q") ctx.pop();
116133 else if (e.key === "enter") {
117134 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));
119136 } else state.key(e, shown().length);
120137 return true;
121138 },
src/web/pages/changelog.ts +2 −1
@@ -32,7 +32,8 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
3232 return row({
3333 href: `/${l.repo}/pulls/${l.number}`,
3434 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 by and with credits to the changelog screen in src/tui/, and update docs/features/changelog.md to describe them. Neither is in this diff. (Principles 1 and 8: the meta line holds up to five dot-separated facts, so say it in plain words and keep it short.)

36 facts: [...(l.by === undefined ? [] : [{ label: "By", value: l.by }]), ...(l.with === undefined ? [] : [{ label: "With", value: l.with.join(", ") }])],
3637 trail: time(l.at),
3738 });
3839 }
test/coauthors.test.ts +54 −4
@@ -13,7 +13,7 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1313 import { getPull } from "../src/core/pulls.ts";
1414 import type { Repo } from "../src/core/repos.ts";
1515 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";
1717 import { runGitCapture } from "../src/git/env.ts";
1818 import { serve } from "../src/http/server.ts";
1919 import { forgetLive, readLandings } from "../src/release/landings.ts";
@@ -58,6 +58,54 @@ describe("authorLinesBetween", () => {
5858 });
5959 });
6060
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
61109 describe("a squash landing", () => {
62110 let dataDir: string;
63111 let app: App;
@@ -149,17 +197,19 @@ describe("a squash landing", () => {
149197 const byTitle = (t: string) => [...read.live, ...read.onItsWay].find((l) => l.title === t)!;
150198 expect(byTitle("Do credited").with).toEqual(["Jay Moran"]);
151199 expect(byTitle("Do uncredited").with).toBeUndefined();
200 expect(byTitle("Do uncredited").by).toBe("scout");
201 expect(byTitle("Do credited").by).toBe("scout");
152202 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
153203 const oid = (await runGitCapture(["rev-parse", "main"], { cwd: dir })).stdout.trim();
154204 expect((await creditsOf(dir, [oid])).get(oid)).toEqual([]);
155205 const server = await serve(app);
156206 try {
157207 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>");
159209 // 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);
161211 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>");
163213 } finally {
164214 server.stop();
165215 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 () =
101101 app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'");
102102 }
103103 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs