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

MCP `forks` answers one fork's upstream and downstream chain in plain words, with the link to each page #11450

Merged opened this

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

Merged

torque/forge:torque/11432-mcp-forks-answers-one-fork-s-upstream-an → main 3 commits fork

  • Merged. torque/11432-mcp-forks-answers-one-fork-s-upstream-an was merged into main as 2a73267.

Review

Sign in to review this change.
5 files changed +192 −2
docs/MCP.md +10 −1
@@ -115,7 +115,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
115115 | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 336 |
116116 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
117117 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 397 |
118 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
118 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 387 |
119119 | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 |
120120 | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 |
121121 | `held` | — | What the Later gate holds, and one proposed pass over it | 270 |
@@ -296,6 +296,15 @@ updates, so the last move of its default branch stands for it. A fork you
296296 cannot read is not a row (`src/actions/fork_tree_rows.ts`);
297297 `test/mcp-tree.test.ts` holds the words to the page.
298298
299 `forks` with `{ repo, kind: "chain" }` is another (#11432): one fork's own
300 lineage, `{ chain, severed, truncated }`, rows `{ role, repo, follows, page }` in
301 the order grandparent, parent, this fork, children (at most 20). `follows` is the
302 fork tree page's line (`followModeWords`); a fork cut loose by severing says
303 "Severed from its upstream." and the chain stops there (`severed: true`); a
304 repository you cannot read is left out, never named. `page` is the path of its
305 page. Not the whole tree (`fork_tree`, `explore` `kind: tree`);
306 `src/actions/fork_chain.ts`, `test/mcp-forks-chain.test.ts`.
307
299308 `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of
300309 Content-Security-Policy violations browsers reported to `/csp-report`,
301310 `{ hours: 24, since, directives: [{ directive, n, newest }] }`, most first,
src/actions/detach_words.ts +2
@@ -10,3 +10,5 @@ export const DETACH_STAYS = "The code and its history stay, with the branches, i
1010 export const DETACH_STOPS = "Updates from the parent and offers to it stop.";
1111 /** The history row's verb; the row names the repository and carries when. */
1212 export const DETACH_RECEIPT = "severed a fork from its upstream: code and history kept, updates and offers stopped";
13 /** A fork's line in a chain when its link was cut: the chain stops there. */
14 export const SEVERED_LINE = "Severed from its upstream.";
src/actions/fork_chain.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * One fork's own chain in plain words (#11432): the grandparent, the parent,
3 * the fork itself and its children, each with how it follows its parent (the
4 * fork tree page's `followModeWords`) and the path of its page. A link cut by
5 * "sever" says so and stops the chain; a repository the caller cannot read is
6 * left out, never named. Not the whole tree: only this fork's lineage.
7 */
8
9 import type { App } from "../app.ts";
10 import { forkParentFor, visibleForks, type Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { SEVERED_LINE } from "./detach_words.ts";
13 import { followModeOf } from "./follow.ts";
14 import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts";
15
16 export const MAX_CHAIN_CHILDREN = 20;
17
18 export interface ChainRow {
19 readonly role: "grandparent" | "parent" | "this" | "child";
20 readonly repo: string;
21 /** The Following card's line, "Severed from its upstream." or "This repository follows nothing.". */
22 readonly follows: string;
23 /** Path of its page. */
24 readonly page: string;
25 }
26
27 export interface ForkChain {
28 readonly chain: ChainRow[];
29 /** The chain above stopped at a severed link. */
30 readonly severed: boolean;
31 readonly truncated: boolean;
32 }
33
34 const nameOf = (r: Repo): string => `${r.ownerName}/${r.name}`;
35
36 /** Whether this repo was cut from its upstream: it names no parent and the ledger has the severing. */
37 function wasSevered(app: App, repo: Repo): boolean {
38 if (repo.forkParentId !== null) return false;
39 return (
40 app.db.query("SELECT 1 FROM event WHERE repo_id = ?1 AND kind = 'repo.detached' LIMIT 1").get(repo.id) !== null
41 );
42 }
43
44 async function rowOf(app: App, role: ChainRow["role"], repo: Repo, user: User | undefined): Promise<ChainRow> {
45 const parent = forkParentFor(app.db, repo, user);
46 let follows: string;
47 if (parent !== undefined) follows = followModeWords(await followModeOf(app, repo), nameOf(parent)).line;
48 else if (repo.forkParentId !== null) follows = FOLLOWS_NOTHING; // parent exists but is not the caller's to see
49 else follows = wasSevered(app, repo) ? SEVERED_LINE : FOLLOWS_NOTHING;
50 return { role, repo: nameOf(repo), follows, page: `/${repo.ownerName}/${repo.name}` };
51 }
52
53 /** The chain around `repo`; the caller has already shown they may read it. */
54 export async function forkChain(app: App, repo: Repo, user: User | undefined): Promise<ForkChain> {
55 const parent = forkParentFor(app.db, repo, user);
56 const grand = parent === undefined ? undefined : forkParentFor(app.db, parent, user);
57 const kids = visibleForks(app.db, repo.id, user);
58 const shown = kids.slice(0, MAX_CHAIN_CHILDREN);
59 const rows: ChainRow[] = [];
60 if (grand !== undefined) rows.push(await rowOf(app, "grandparent", grand, user));
61 if (parent !== undefined) rows.push(await rowOf(app, "parent", parent, user));
62 const self = await rowOf(app, "this", repo, user);
63 rows.push(self);
64 for (const k of shown) rows.push(await rowOf(app, "child", k, user));
65 return { chain: rows, severed: rows.some((r) => r.role !== "child" && r.follows === SEVERED_LINE), truncated: kids.length > shown.length };
66 }
src/http/mcp/tools/forks.ts +4 −1
@@ -1,16 +1,18 @@
11 import { driftOfForks, MAX_RANKED_FORKS, type RankedFork } from "../../../actions/forks.ts";
22 import { forkParentFor, visibleForks, type Repo } from "../../../core/repos.ts";
3 import { forkChain } from "../../../actions/fork_chain.ts";
34 import { pageRows } from "../page.ts";
45 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
56
67 export const tool: Tool = {
78 name: "forks",
8 doc: "`{ repo, limit?, cursor? }`: `{ parent, forks: [{ repo, ahead, behind, updatedAt }], next_cursor }`, the forks the caller may see in the order `/:owner/:name/forks` lists them, each with its commits ahead of and behind this one (`null` when git cannot say); `limit` 1-200 (default 50), `cursor` the last `next_cursor` (null on the last page); drift is read for the page only",
9 doc: "`{ repo, kind: chain }`: `{ chain: [{ role, repo, follows, page }], severed, truncated }`, its grandparent, parent, itself and children in the fork tree page's words, a severed link stops it, unreadable forks are left out; else `{ repo, limit?, cursor? }`: `{ parent, forks: [{ repo, ahead, behind, updatedAt }], next_cursor }`, the forks the caller may see in the order `/:owner/:name/forks` lists them, each with its commits ahead of and behind this one (`null` when git cannot say); `limit` 1-200 (default 50), `cursor` the last `next_cursor` (null on the last page); drift is read for the page only",
910 description: "A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind.",
1011 inputSchema: {
1112 type: "object",
1213 properties: {
1314 repo: REPO_ARG,
15 kind: { type: "string", enum: ["chain"] },
1416 limit: { type: "integer", minimum: 1, maximum: 200 },
1517 cursor: { type: "string" },
1618 },
@@ -20,6 +22,7 @@ export const tool: Tool = {
2022 async run(ctx, user, args) {
2123 const repo = repoArg(ctx, user, args);
2224 if (isAnswer(repo)) return repo;
25 if (args["kind"] === "chain") return good(await forkChain(ctx.app, repo, user));
2326 const parent = forkParentFor(ctx.app.db, repo, user);
2427 const paged = pageRows(visibleForks(ctx.app.db, repo.id, user), (f) => f.id, args);
2528 if (!paged.ok) return paged;
test/mcp-forks-chain.test.ts added +110
@@ -0,0 +1,110 @@
1 /** `forks` with `kind: chain`: one fork's lineage in the fork tree page's words (#11432). */
2 import { afterAll, beforeAll, describe, 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 { createApp, type App } from "../src/app.ts";
7 import { createRepo, forkRepo } from "../src/actions/repos.ts";
8 import { setFollowMode } from "../src/actions/follow.ts";
9 import { followModeWords } from "../src/actions/follow_words.ts";
10 import { SEVERED_LINE } from "../src/actions/detach_words.ts";
11 import { clearForkParent } from "../src/core/repos.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { proveAll } from "./helpers/verified.ts";
17 import { promote } from "./helpers/members.ts";
18
19 quietLogs();
20
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let aliceToken: string;
25 let carolToken: string;
26
27 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
28 if (!r.ok) throw new Error(JSON.stringify(r.error));
29 return r.value;
30 };
31 const mk = async (username: string, first: boolean): Promise<User> =>
32 must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }));
33
34 async function chain(token: string, repo: string): Promise<any> {
35 const res = await fetch(`${servers.url}/mcp`, {
36 method: "POST",
37 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
38 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forks", arguments: { repo, kind: "chain" } } }),
39 });
40 const body = (await res.json()) as any;
41 return JSON.parse(body.result.content[0].text);
42 }
43
44 beforeAll(async () => {
45 dir = await mkdtemp(join(tmpdir(), "forge-mcp-chain-"));
46 app = createApp({ dataDir: dir, port: 0 });
47 servers = await serve(app);
48 const alice = await mk("alice", true);
49 const bob = await mk("bob", false);
50 const carol = await mk("carol", false);
51 proveAll(app);
52 for (const u of [alice, bob, carol]) promote(app.db, u);
53 aliceToken = createToken(app.db, alice.id, "t", "admin").token;
54 carolToken = createToken(app.db, carol.id, "t", "admin").token;
55 const root = must(await createRepo(app, alice, { name: "lib", visibility: "public" }));
56 const mid = must(await forkRepo(app, alice, root, "lib-mid"));
57 const leaf = must(await forkRepo(app, alice, mid, "lib-leaf"));
58 must(await forkRepo(app, alice, leaf, "lib-tip"));
59 const secret = must(await forkRepo(app, bob, leaf, "lib-secret"));
60 app.db.run("UPDATE repo SET visibility = 'private' WHERE id = ?1", [secret.id]);
61 await setFollowMode(app, alice, leaf, "take-updates");
62 // A fork cut loose the way detach does it.
63 const cut = must(await forkRepo(app, bob, root, "lib-cut"));
64 clearForkParent(app.db, cut.id);
65 app.bus.append({ kind: "repo.detached", repoId: cut.id, actorId: bob.id, payload: { parentId: root.id } });
66 must(await forkRepo(app, alice, cut, "lib-after"));
67 }, 30_000);
68
69 afterAll(async () => {
70 servers.stop();
71 app.close();
72 await rm(dir, { recursive: true, force: true });
73 });
74
75 describe("forks kind: chain", () => {
76 test("names grandparent, parent, itself and children with the page's words and links", async () => {
77 const t = await chain(aliceToken, "alice/lib-leaf");
78 expect(t.chain.slice(0, 3).map((r: any) => `${r.role}:${r.repo}`)).toEqual(["grandparent:alice/lib", "parent:alice/lib-mid", "this:alice/lib-leaf"]);
79 expect(t.chain.slice(3).map((r: any) => r.repo).sort()).toEqual(["alice/lib-tip", "bob/lib-secret"]);
80 const me = t.chain.find((r: any) => r.role === "this");
81 expect(me.follows).toBe(followModeWords("take-updates", "alice/lib-mid").line);
82 expect(me.page).toBe("/alice/lib-leaf");
83 expect(t.severed).toBe(false);
84 });
85
86 test("a private fork the caller cannot see is omitted, not named", async () => {
87 const t = await chain(carolToken, "alice/lib-leaf");
88 expect(JSON.stringify(t)).not.toContain("lib-secret");
89 expect(t.chain.filter((r: any) => r.role === "child").map((r: any) => r.repo)).toEqual(["alice/lib-tip"]);
90 });
91
92 test("a severed link says severed and stops the chain", async () => {
93 const t = await chain(aliceToken, "alice/lib-after");
94 expect(t.chain.map((r: any) => r.role)).toEqual(["parent", "this"]);
95 expect(t.chain[0].repo).toBe("bob/lib-cut");
96 expect(t.chain[0].follows).toBe(SEVERED_LINE);
97 expect(t.severed).toBe(true);
98 });
99
100 test("without kind, forks still lists forks", async () => {
101 const res = await fetch(`${servers.url}/mcp`, {
102 method: "POST",
103 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${aliceToken}` },
104 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forks", arguments: { repo: "alice/lib-leaf" } } }),
105 });
106 const body = JSON.parse(((await res.json()) as any).result.content[0].text);
107 expect(body.chain).toBeUndefined();
108 expect(Array.isArray(body.forks)).toBe(true);
109 });
110 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs