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

Who told and who shipped: a change shows the agent that wrote it but not the person whose words it answers, and lands under neither together #516

Merged opened this

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

Merged

scout/forge:scout/415-who-told-and-who-shipped-a-change-shows → main 8 commits fork

  • Merged. scout/415-who-told-and-who-shipped-a-change-shows was merged into main as 1ae4db1.

Review

Sign in to review this change.
9 files changed +269 −21
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 -- crew crew-wrapup crew-limits crew-parks crew-sandbox steer session-settle landing decide ledger checks dependencies autonomy studio studio-words forks fork-flow cross-repo-pull pulls apps apps-adversarial apps-busy apps-token web git-trust helpers shards migrate vitals guard asks feed feed-page offline share cloud run-secrets forge-cloudflare usage credentials standing board-windows spawn-plan spawn web-apps fleet-ops meter told-by",
1010 "timeout": 1800
1111 }
1212 }
src/actions/pulls.ts +35 −7
@@ -17,6 +17,8 @@ import {
1717 } from "../core/issues.ts";
1818 import {
1919 closesFromBody,
20 landingTrailers,
21 toldBy,
2022 createPull,
2123 getPull,
2224 mergePull as landPull,
@@ -47,7 +49,7 @@ import {
4749 type Side,
4850 type ThreadState,
4951 } from "../core/reviews.ts";
50 import type { User } from "../core/users.ts";
52 import { getUserById, getUserByName, type User } from "../core/users.ts";
5153 import type { MergeStrategy } from "../git/merge.ts";
5254 import { fetchInto, resolveOid, writeRef } from "../git/merge.ts";
5355 import { repoDir } from "../git/repos.ts";
@@ -294,6 +296,33 @@ export async function mergePull(
294296 // A fork may have pushed since the pull opened; merge what is being
295297 // proposed now, not the snapshot taken when it was.
296298 await refreshPullHead(app, repo, pull);
299 const lander = {
300 name: writer.value.displayName || writer.value.username,
301 email: writer.value.email,
302 };
303 // The agent that wrote the change is the commit's author; the person who
304 // asked is named in `Requested-by`, the person who landed it in
305 // `Co-authored-by`.
306 const writtenBy = getUserById(app.db, pull.authorId);
307 const agentAuthor =
308 writtenBy?.kind === "agent"
309 ? {
310 name: writtenBy.displayName || writtenBy.username,
311 email: writtenBy.email,
312 }
313 : undefined;
314 const asked = toldBy(app.db, pull);
315 const requester =
316 asked === null ? undefined : getUserByName(app.db, asked);
317 const trailers = landingTrailers(
318 requester
319 ? {
320 name: requester.displayName || requester.username,
321 email: requester.email,
322 }
323 : null,
324 lander,
325 );
297326 const merged = await landPull(
298327 app.db,
299328 repoDir(app.reposRoot, repo.ownerName, repo.name),
@@ -305,15 +334,14 @@ export async function mergePull(
305334 requireChecks: repo.requireChecks,
306335 },
307336 strategy,
308 message:
337 message: `${
309338 strategy === "squash"
310339 ? `${pull.title} (#${pull.number})`
311 : `Merge pull request #${pull.number} from ${pull.headBranch}\n\n${pull.title}`,
340 : `Merge pull request #${pull.number} from ${pull.headBranch}\n\n${pull.title}`
341 }\n\n${trailers}`,
312342 actorId: writer.value.id,
313 author: {
314 name: writer.value.displayName || writer.value.username,
315 email: writer.value.email,
316 },
343 author: agentAuthor ?? lander,
344 committer: lander,
317345 },
318346 );
319347 if (!merged.ok) return merged;
src/core/pulls.ts +39
@@ -598,6 +598,8 @@ export async function mergePull(
598598 message: string;
599599 actorId: string;
600600 author: { name: string; email: string };
601 /** Who commits it, when the author is the agent that wrote the change. */
602 committer?: { name: string; email: string };
601603 /** The review gate. Absent means no review policy applies. */
602604 gate?: ReviewGate;
603605 },
@@ -625,6 +627,7 @@ export async function mergePull(
625627 strategy: input.strategy,
626628 message: input.message,
627629 author: input.author,
630 ...(input.committer === undefined ? {} : { committer: input.committer }),
628631 });
629632 if (!landed.ok) return landed;
630633
@@ -679,3 +682,39 @@ export function openPullsClosing(
679682 if (!out.has(n)) out.set(n, pull);
680683 return out;
681684 }
685
686 /**
687 * The person whose words a pull answers: the author of the issue it closes
688 * (or continues), when that is a person. Derived from the body and the issue
689 * on every read — no column to drift. Null when it closes nothing, or only
690 * work an agent filed.
691 */
692 export function toldBy(db: Database, pull: Pull): string | null {
693 for (const n of closesFromBody(pull.body)) {
694 const issue = getIssue(db, pull.repoId, n);
695 if (issue && !issue.authorIsAgent && issue.originAuthor === null)
696 return issue.authorName;
697 }
698 return null;
699 }
700
701 /** A trailer value is one line: no control bytes, no angle brackets. */
702 function oneLine(s: string): string {
703 return s.replace(/[\u0000-\u001f\u007f<>]/g, "").trim();
704 }
705
706 /**
707 * The trailers a landed change carries: who asked (`Requested-by`) and who
708 * landed it (`Co-authored-by`), the agent being the commit's author.
709 */
710 export function landingTrailers(
711 requester: { name: string; email: string } | null,
712 lander: { name: string; email: string },
713 ): string {
714 const line = (key: string, who: { name: string; email: string }) =>
715 `${key}: ${oneLine(who.name)} <${oneLine(who.email)}>`;
716 return [
717 ...(requester ? [line("Requested-by", requester)] : []),
718 line("Co-authored-by", lander),
719 ].join("\n");
720 }
src/feed/items.ts +11 −5
@@ -10,7 +10,7 @@ import type { Database } from "bun:sqlite";
1010 import { listDecidedIn } from "../core/decisions.ts";
1111 import { audienceFilter } from "../core/event_audience.ts";
1212 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
13 import { getPull } from "../core/pulls.ts";
13 import { getPull, toldBy } from "../core/pulls.ts";
1414 import {
1515 canSeeOwnerFacts,
1616 getReposByIds,
@@ -148,8 +148,14 @@ function fromEvent(
148148 };
149149 const p = e.payload;
150150 const number = typeof p["number"] === "number" ? p["number"] : undefined;
151 const title =
152 number === undefined ? "" : (getPull(db, repo.id, number)?.title ?? "");
151 const pull = number === undefined ? undefined : getPull(db, repo.id, number);
152 const title = pull?.title ?? "";
153 const told = pull === undefined ? null : toldBy(db, pull);
154 const toldParts = [
155 ...(told === null ? [] : [`told by ${told}`]),
156 ...(pull?.authorIsAgent ? [`by ${pull.authorName}`] : []),
157 ].join(", ");
158 const toldWords = toldParts === "" ? "" : ` (${toldParts})`;
153159 const change = number === undefined ? base(repo) : `${base(repo)}/changes/${number}`;
154160 switch (e.kind) {
155161 case "run.finished": {
@@ -174,9 +180,9 @@ function fromEvent(
174180 };
175181 }
176182 case "pull.opened":
177 return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}` };
183 return { ...common, kind: "pull", link: change, what: `a change was opened${title ? `: ${clip(title)}` : ""}${toldWords}` };
178184 case "pull.merged":
179 return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}` };
185 return { ...common, kind: "pull", link: change, what: `a change shipped${title ? `: ${clip(title)}` : ""}${toldWords}` };
180186 case "pull.reviewed":
181187 return { ...common, kind: "pull", link: change, what: `a change was reviewed${title ? `: ${clip(title)}` : ""}` };
182188 case "look.taken":
src/git/merge.ts +4 −2
@@ -108,6 +108,8 @@ export async function merge(
108108 strategy: MergeStrategy;
109109 message: string;
110110 author: MergeIdentity;
111 /** Who commits it, when that is not the author. */
112 committer?: MergeIdentity;
111113 },
112114 ): Promise<Result<MergeResult, MergeError>> {
113115 if (
@@ -130,8 +132,8 @@ export async function merge(
130132 const env = {
131133 GIT_AUTHOR_NAME: input.author.name,
132134 GIT_AUTHOR_EMAIL: input.author.email,
133 GIT_COMMITTER_NAME: input.author.name,
134 GIT_COMMITTER_EMAIL: input.author.email,
135 GIT_COMMITTER_NAME: (input.committer ?? input.author).name,
136 GIT_COMMITTER_EMAIL: (input.committer ?? input.author).email,
135137 };
136138 const commit = await runGit(
137139 ["commit-tree", tree.value.treeOid, ...parents, "-m", input.message],
src/web/pages/board.ts +3 −2
@@ -35,7 +35,7 @@ import { forgeHealth, rolloutLine } from "../../kernel/guard.ts";
3535 import { vitals, type Vitals } from "../../kernel/vitals.ts";
3636 import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts";
3737 import { looksForApps } from "../../core/looks.ts";
38 import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts";
38 import { listPulls, mergedPullsForRepos, toldBy, type Pull } from "../../core/pulls.ts";
3939 import {
4040 canSeeOwnerFacts,
4141 listReposVisibleTo,
@@ -515,6 +515,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
515515 { name: pull.authorName, isAgent: pull.authorIsAgent },
516516 scope.ownerFacts.has(repo.id),
517517 );
518 const told = toldBy(db, pull);
518519 return html`<li>
519520 <a
520521 class="board-card board-landed"
@@ -536,7 +537,7 @@ function landed(ctx: Ctx, scope: BoardScope): Html {
536537 >
537538 <span class="board-landed-title">${pull.title}</span>
538539 <span class="board-when">${time(pull.mergedAt ?? pull.createdAt)}</span>
539 <span class="board-landed-meta">${repo.name} · ${author}</span>
540 <span class="board-landed-meta">${repo.name} · ${author}${told === null ? "" : html` · told by ${told}`}</span>
540541 </a>
541542 </li>`;
542543 });
src/web/pages/pull.ts +6 −2
@@ -24,7 +24,7 @@ import {
2424 type PullReviewStatus,
2525 type PullStatus,
2626 } from "../../core/pulls.ts";
27 import { getPull } from "../../core/pulls.ts";
27 import { getPull, toldBy } from "../../core/pulls.ts";
2828 import { checkSummary, type CheckSummary } from "../../core/runs.ts";
2929 import {
3030 anchorKey,
@@ -822,7 +822,11 @@ export async function pullPage(ctx: Ctx): Promise<Response> {
822822 ? originTag(pull.originAuthor, originGlyph(repo))
823823 : userLink(pull.authorName)}
824824 opened this ${time(pull.createdAt)}</span
825 >
825 >${when(
826 toldBy(ctx.app.db, pull),
827 (who) =>
828 html`<span class="told-by">in answer to ${userLink(who)}</span>`,
829 )}
826830 ${when(
827831 pull.originUrl,
828832 (url) =>
src/web/studio/change.ts +6 −2
@@ -9,7 +9,7 @@
99
1010 import { appsRuntime } from "../../apps/runtime.ts";
1111 import type { AppRecord } from "../../core/apps.ts";
12 import { pullStatus, revertOf, type Pull } from "../../core/pulls.ts";
12 import { pullStatus, revertOf, toldBy, type Pull } from "../../core/pulls.ts";
1313 import { canWrite, type Repo } from "../../core/repos.ts";
1414 import { checkSummary } from "../../core/runs.ts";
1515 import { repoDir } from "../../git/repos.ts";
@@ -153,7 +153,11 @@ export async function studioPull(
153153 ? originTag(pull.originAuthor, originGlyph(repo))
154154 : userLink(pull.authorName)}
155155 opened this ${time(pull.createdAt)}</span
156 >
156 >${when(
157 toldBy(ctx.app.db, pull),
158 (who) =>
159 html`<span class="told-by">in answer to ${userLink(who)}</span>`,
160 )}
157161 ${when(
158162 pull.originUrl,
159163 (url) =>
test/told-by.test.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * Who told and who shipped: a change names the person whose words it answers
3 * and the agent that wrote it, on its page, the board, the feed and the merge
4 * commit — derived from the issue it closes, stored nowhere.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { enableApp } from "../src/actions/apps.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { mergePull, openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { getPull, landingTrailers, toldBy } from "../src/core/pulls.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { readFeed } from "../src/feed/feed.ts";
20 import { repoDir } from "../src/git/repos.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22
23 describe("who told and who shipped", () => {
24 let dataDir: string;
25 let app: App;
26 let servers: Servers;
27 let alice: User;
28 let bob: User;
29 let repo: Repo;
30 let cookie: string;
31 let number: number;
32
33 const get = (path: string) =>
34 fetch(`${servers.url}${path}`, { headers: { cookie } });
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-toldby-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 const a = await createUser(app.db, {
41 username: "alice",
42 email: "[email protected]",
43 password: "a-good-passphrase",
44 adminIfFirst: true,
45 });
46 const b = await createUser(app.db, {
47 username: "bob",
48 email: "[email protected]",
49 password: "a-good-passphrase",
50 });
51 if (!a.ok || !b.ok) throw new Error("users");
52 alice = a.value;
53 bob = b.value;
54 cookie = (
55 (
56 await fetch(`${servers.url}/signin`, {
57 method: "POST",
58 body: new URLSearchParams({
59 username: "alice",
60 password: "a-good-passphrase",
61 }),
62 redirect: "manual",
63 })
64 ).headers.get("set-cookie") ?? ""
65 ).split(";")[0]!;
66 const made = await createRepo(app, alice, {
67 name: "notes",
68 visibility: "public",
69 });
70 if (!made.ok) throw new Error(String(made.error));
71 repo = made.value;
72 const seeded = await commitToBranch(app, alice, repo, {
73 message: "Start",
74 files: [
75 {
76 path: "package.json",
77 content: `{"name":"notes","scripts":{"start":"bun run server.ts"}}\n`,
78 },
79 {
80 path: "server.ts",
81 content:
82 "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n",
83 },
84 ],
85 });
86 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
87 const enabled = await enableApp(app, alice, repo, { slug: "notes" });
88 if (!enabled.ok) throw new Error(String(enabled.error));
89 const scout = await createAgent(app, alice, { username: "scout" });
90 if (!scout.ok) throw new Error(String(scout.error));
91 addToCrew(app, alice, repo, "scout");
92
93 const told = openIssue(app, bob, repo, { title: "Make it fast" });
94 if (!told.ok) throw new Error(String(told.error));
95 const branch = await commitToBranch(app, alice, repo, {
96 branch: "feature/fast",
97 base: "main",
98 message: "Faster",
99 files: [{ path: "FAST.md", content: "fast\n" }],
100 });
101 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
102 const opened = await openPull(app, getUserByName(app.db, "scout")!, repo, {
103 title: "Faster notes",
104 body: `Done.\n\nCloses #${told.value.number}`,
105 baseBranch: "main",
106 headBranch: "feature/fast",
107 });
108 if (!opened.ok) throw new Error(String(opened.error));
109 number = opened.value.number;
110 }, 30_000);
111
112 afterAll(async () => {
113 servers.stop();
114 app.close();
115 await rm(dataDir, { recursive: true, force: true });
116 });
117
118 test("toldBy is the person who wrote the issue it closes, nobody else", () => {
119 const pull = getPull(app.db, repo.id, number)!;
120 expect(toldBy(app.db, pull)).toBe("bob");
121 expect(toldBy(app.db, { ...pull, body: "no issue here" })).toBeNull();
122 expect(toldBy(app.db, { ...pull, body: "Closes #9999" })).toBeNull();
123 });
124
125 test("the pull's page names both, escaped", async () => {
126 const html = await (await get(`/alice/notes/pulls/${number}`)).text();
127 expect(html).toContain("in answer to");
128 expect(html).toMatch(/in answer to[\s\S]{0,200}bob/);
129 expect(html).toContain("scout");
130 });
131
132 test("the feed item says who told", () => {
133 const items = readFeed(app.db, alice).items;
134 expect(
135 items.some((i) => i.reason.includes("Faster notes") &&
136 i.reason.includes("(told by bob, by scout)")),
137 ).toBe(true);
138 });
139
140 test("the merge commit is the agent's, with both trailers; the board card says who told", async () => {
141 const pull = getPull(app.db, repo.id, number)!;
142 const merged = await mergePull(app, alice, repo, pull, "squash");
143 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
144 const log = Bun.spawnSync(
145 ["git", "log", "-1", "--format=%an|%cn%n%B", "main"],
146 { cwd: repoDir(app.reposRoot, "alice", "notes") },
147 ).stdout.toString();
148 expect(log).toContain("scout|alice");
149 expect(log).toContain("Requested-by: bob <[email protected]>");
150 expect(log).toContain("Co-authored-by: alice <[email protected]>");
151
152 const board = await (await get("/board")).text();
153 expect(board).toContain("told by bob");
154 expect(board).toContain("scout");
155 });
156
157 test("trailers are one line each, whatever a name holds", () => {
158 const t = landingTrailers(
159 { name: "evil\nCo-authored-by: x", email: "e@x" },
160 { name: "a", email: "a@x" },
161 );
162 expect(t.split("\n")).toHaveLength(2);
163 });
164 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs