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

A fork takes an upstream change and offers its own back, proven end to end across two forges #12425

Merged opened this

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

Merged

scout/forge:scout/12401-a-fork-takes-an-upstream-change-and-offe → main 4 commits fork

  • Merged. scout/12401-a-fork-takes-an-upstream-change-and-offe was merged into main as f18c0e5.

Review

Sign in to review this change.
3 files changed +159 −3
src/http/mcp/tools/fork_tree.ts +1 −1
@@ -4,7 +4,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
44 /** The whole tree a repo sits in, as the caller may read it. */
55 export const tool: Tool = {
66 name: "fork_tree",
7 doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown; drawn as the `ui://forge/forks` card",
7 doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow, word }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates`, `offers` or `dont-follow`, `word` its plain word (follows, proposes, severed); `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown; drawn as the `ui://forge/forks` card",
88 description: "The fork tree around a repo: ancestors, siblings and descendants you may read.",
99 inputSchema: {
1010 type: "object",
src/http/mcp/tools/propose_to_parent_forge.ts +19 −2
@@ -1,10 +1,12 @@
11 import { FAMILY_WORDS } from "../../../actions/family_words.ts";
2 import { proposeToParentForge } from "../../../actions/upstream.ts";
2 import { proposeRefusalWords } from "../../../actions/follow_words.ts";
3 import { proposeToParentForge, proposeUpstream } from "../../../actions/upstream.ts";
4 import { forkParentFor } from "../../../core/repos.ts";
35 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
46
57 export const tool: Tool = {
68 name: "propose_to_parent_forge",
7 doc: "`{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull",
9 doc: "`{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; a fork whose parent is in this forge offers its default branch as a pull there; the same tip answers with that pull",
810 description:
911 "Offer this forge's landed change to its parent as a pull on `child/<name>/…`",
1012 inputSchema: {
@@ -16,6 +18,21 @@ export const tool: Tool = {
1618 async run(ctx, user, args) {
1719 const repo = repoArg(ctx, user, args);
1820 if (isAnswer(repo)) return repo;
21 const parent = forkParentFor(ctx.app.db, repo, user);
22 if (parent !== undefined) {
23 const name = `${parent.ownerName}/${parent.name}`;
24 const offered = await proposeUpstream(ctx.app, user, repo);
25 if (!offered.ok) {
26 const e = offered.error;
27 return bad(typeof e === "string" ? e : "refused", proposeRefusalWords(e as never, name));
28 }
29 const { pull, opened } = offered.value;
30 return good({
31 says: `${opened ? "Proposed to" : "Already proposed to"} ${name} as #${pull.number}.`,
32 opened,
33 proposal: { number: pull.number, title: pull.title, state: "open" },
34 });
35 }
1936 const made = await proposeToParentForge(ctx.app, user, repo);
2037 if (made.ok)
2138 return good({
test/fork-flow-journey.test.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The fork loop, end to end (#12401): a change lands upstream and flows down as
3 * an update the fork takes with one action; the fork's own commit goes back up
4 * as an offer the upstream owner lands. Read through the doors a person and an
5 * agent use: the following API, the page, the MCP, and the fork tree in the
6 * words of `follow_words.ts`. Both repositories are in this process; the
7 * cross-forge offer (`propose_to_parent_forge` between two forges) is walked in
8 * `upstream-forge.test.ts`.
9 */
10 import { afterAll, beforeAll, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { FOLLOW_WORD } from "../src/actions/follow_words.ts";
15 import { mergePull, openPull } from "../src/actions/pulls.ts";
16 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { getPull } from "../src/core/pulls.ts";
19 import type { Repo } from "../src/core/repos.ts";
20 import { createToken } from "../src/core/tokens.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { promote } from "./helpers/members.ts";
25 import { mcpTool } from "./helpers/mcp.ts";
26
27 quietLogs();
28
29 let dir = "";
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let bob: User;
34 let lib: Repo;
35 let fork: Repo;
36 let bobToken = "";
37
38 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
39 if (!r.ok) throw new Error(JSON.stringify(r.error));
40 return r.value;
41 };
42
43 const person = async (username: string, first: boolean): Promise<User> => {
44 const user = must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: first }));
45 promote(app.db, user);
46 return user;
47 };
48
49 const api = (path: string, token: string, method = "GET") =>
50 fetch(`${servers.url}${path}`, { method, headers: { authorization: `Bearer ${token}` } });
51
52 /** A change landing on `repo` the way a person lands one: a branch, a pull, a merge. */
53 async function land(owner: User, repo: Repo, branch: string, title: string): Promise<void> {
54 must(await commitToBranch(app, owner, repo, { branch, base: "main", message: title, files: [{ path: `${branch}.md`, content: `${title}\n` }] }));
55 const pull = must(await openPull(app, owner, repo, { title, baseBranch: "main", headBranch: branch }));
56 must(await mergePull(app, owner, repo, pull, "merge"));
57 }
58
59 /** The fork's node in the tree the MCP draws, and the word it says. */
60 async function treeWord(): Promise<{ follow: string; word: string }> {
61 const tree = await mcpTool(servers.url, bobToken, "fork_tree", { repo: "bob/lib" });
62 const node = tree.value.nodes.find((n: { repo: string }) => n.repo === "bob/lib");
63 return { follow: node.follow, word: node.word };
64 }
65
66 beforeAll(async () => {
67 dir = await mkdtemp(join(tmpdir(), "forge-fork-flow-"));
68 app = createApp({ dataDir: dir, port: 0 });
69 servers = await serve(app);
70 alice = await person("alice", true);
71 bob = await person("bob", false);
72 bobToken = createToken(app.db, bob.id, "t", "admin").token;
73 lib = must(await createRepo(app, alice, { name: "lib", visibility: "public" }));
74 must(await commitToBranch(app, alice, lib, { branch: "main", message: "Start", files: [{ path: "README.md", content: "hi\n" }] }));
75 fork = must(await forkRepo(app, bob, lib));
76 }, 60_000);
77
78 afterAll(async () => {
79 servers?.stop();
80 app?.close();
81 await rm(dir, { recursive: true, force: true });
82 });
83
84 test("a new fork proposes by default, and says so in the tree's words", async () => {
85 expect(await treeWord()).toEqual({ follow: "offers", word: FOLLOW_WORD["tell-me"] });
86 expect(FOLLOW_WORD["tell-me"]).toBe("proposes");
87 });
88
89 test("the fork has work of its own; a change lands upstream; the fork's update line says so and one action takes it", async () => {
90 // A fork with nothing of its own is moved silently as the push lands (core/following.ts), so
91 // the line below is for the fork that has its own work: both sides moved, git merges cleanly.
92 must(await commitToBranch(app, bob, fork, { branch: "main", message: "Fork improvement", files: [{ path: "fork.md", content: "mine\n" }] }));
93 await land(alice, lib, "upstream-feat", "Upstream feature");
94 const line = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { state: string; behind: number; ahead: number; clean: boolean; parent: string; words: string };
95 expect(line).toMatchObject({ state: "diverged", ahead: 1, clean: true, parent: "alice/lib" });
96 expect(line.behind).toBeGreaterThan(0);
97 expect(line.words).toContain("git merges the two cleanly");
98 // The fork page's tree names the fork in the same word as the MCP's.
99 const forks = await (await fetch(`${servers.url}/alice/lib/forks`, { headers: { cookie: "forge_mode=dev" } })).text();
100 expect(forks).toContain('href="/bob/lib"');
101 expect(forks).toContain(FOLLOW_WORD["tell-me"]);
102
103 const took = await mcpTool(servers.url, bobToken, "take_updates", { repo: "bob/lib" });
104 expect(took.error).toBeUndefined();
105 expect(took.value).toMatchObject({ updated: true, merged: true });
106 const after = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { behind: number; ahead: number };
107 expect(after.behind).toBe(0);
108 expect(after.ahead).toBeGreaterThan(0);
109 });
110
111 test("the fork's own work is offered up through the MCP, and the upstream owner lands it", async () => {
112 const offered = await mcpTool(servers.url, bobToken, "propose_to_parent_forge", { repo: "bob/lib" });
113 expect(offered.error).toBeUndefined();
114 expect(offered.value.opened).toBe(true);
115 const number = offered.value.proposal.number as number;
116
117 // Asking again is the same offer, not a second one.
118 const again = await mcpTool(servers.url, bobToken, "propose_to_parent_forge", { repo: "bob/lib" });
119 expect(again.value).toMatchObject({ opened: false });
120 expect(again.value.proposal.number).toBe(number);
121
122 const pull = getPull(app.db, lib.id, number)!;
123 expect(pull.headRepoId).toBe(fork.id);
124 must(await mergePull(app, alice, lib, pull, "merge"));
125 expect(getPull(app.db, lib.id, number)!.mergedAt).not.toBeNull();
126
127 // Landed upstream: the fork has nothing left to offer.
128 const left = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { ahead: number };
129 expect(left.ahead).toBe(0);
130 });
131
132 test("the tree says follows when the fork takes updates, then proposes again", async () => {
133 const set = (mode: string) => mcpTool(servers.url, bobToken, "follow_mode", { repo: "bob/lib", mode });
134 expect((await set("take-updates")).error).toBeUndefined();
135 expect(await treeWord()).toEqual({ follow: "take-updates", word: FOLLOW_WORD["take-updates"] });
136 expect(FOLLOW_WORD["take-updates"]).toBe("follows");
137 expect((await set("tell-me")).error).toBeUndefined();
138 expect(await treeWord()).toEqual({ follow: "offers", word: "proposes" });
139 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs