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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11432-mcp-forks-answers-one-fork-s-upstream-an → main 3 commits fork
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`; | ||
| 115 | 115 | | `forges` | — | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 336 | |
| 116 | 116 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 117 | 117 | | `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 | | |
| 119 | 119 | | `front_page` | — | The stranger's front page; admin sets `pinned`/`hidden`/`weights` | 457 | |
| 120 | 120 | | `health` | — | How this forge is: version, commit, draining or not, rollout, incidents | 190 | |
| 121 | 121 | | `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 | ||
| 296 | 296 | cannot read is not a row (`src/actions/fork_tree_rows.ts`); |
| 297 | 297 | `test/mcp-tree.test.ts` holds the words to the page. |
| 298 | 298 | |
| 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 | ||
| 299 | 308 | `system` with `{ kind: "csp" }` is another (#10906): the last 24 hours of |
| 300 | 309 | Content-Security-Policy violations browsers reported to `/csp-report`, |
| 301 | 310 | `{ 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 | ||
| 10 | 10 | export const DETACH_STOPS = "Updates from the parent and offers to it stop."; |
| 11 | 11 | /** The history row's verb; the row names the repository and carries when. */ |
| 12 | 12 | 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 @@ | ||
| 1 | 1 | import { driftOfForks, MAX_RANKED_FORKS, type RankedFork } from "../../../actions/forks.ts"; |
| 2 | 2 | import { forkParentFor, visibleForks, type Repo } from "../../../core/repos.ts"; |
| 3 | import { forkChain } from "../../../actions/fork_chain.ts"; | |
| 3 | 4 | import { pageRows } from "../page.ts"; |
| 4 | 5 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 6 | |
| 6 | 7 | export const tool: Tool = { |
| 7 | 8 | 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", | |
| 9 | 10 | description: "A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind.", |
| 10 | 11 | inputSchema: { |
| 11 | 12 | type: "object", |
| 12 | 13 | properties: { |
| 13 | 14 | repo: REPO_ARG, |
| 15 | kind: { type: "string", enum: ["chain"] }, | |
| 14 | 16 | limit: { type: "integer", minimum: 1, maximum: 200 }, |
| 15 | 17 | cursor: { type: "string" }, |
| 16 | 18 | }, |
| @@ -20,6 +22,7 @@ export const tool: Tool = { | ||
| 20 | 22 | async run(ctx, user, args) { |
| 21 | 23 | const repo = repoArg(ctx, user, args); |
| 22 | 24 | if (isAnswer(repo)) return repo; |
| 25 | if (args["kind"] === "chain") return good(await forkChain(ctx.app, repo, user)); | |
| 23 | 26 | const parent = forkParentFor(ctx.app.db, repo, user); |
| 24 | 27 | const paged = pageRows(visibleForks(ctx.app.db, repo.id, user), (f) => f.id, args); |
| 25 | 28 | 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 | }); |