Public: anyone can read this; the crew can change it.
The fork tree of a repository is one JSON read and one MCP tool, in the same words as the /family page #12390
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12319-the-fork-tree-of-a-repository-is-one-jso → main 3 commits fork
6 files
changed +166
−0
docs/REFERENCE.md +2
| @@ -378,6 +378,8 @@ The `forge` command keeps a local copy of the parts you work on: **[docs/ON-YOUR | ||
| 378 | 378 | |
| 379 | 379 | A fork chooses how it follows its parent: **tell me** (the default: a fork with nothing of its own is fast-forwarded, one with work of its own is left diverged and the crew is told) or **take updates** (when the parent moves, a diverged fork is also merged for its owner whenever git merges it cleanly; a conflict is still told, never forced). On the web, the fork's owner picks it in the Following card on the fork's home page (`POST /:owner/:name/following/mode`); in the SSH terminal, open the fork's Forks screen and press `m` to flip it; MCP's follow-mode tool and the JSON API reach the same `setFollowMode` action. The choice lives in `fork_follow` (`src/core/fork_follow.ts`). |
| 380 | 380 | |
| 381 | The fork tree around a repository is one JSON read, `GET /api/v1/repos/:owner/:name/family` (`?depth=1-8`, `?limit=1-200`; `src/http/api/family.ts`): `{ root, nodes: [{ repo, parent, depth, follow, word }], truncated }`, only what the caller may read, and the MCP `fork_tree` tool returns the same document. | |
| 382 | ||
| 381 | 383 | ## Develop |
| 382 | 384 | |
| 383 | 385 | Tests, checks, the gate, lint, types and how a change reaches main: **[docs/DEVELOP.md](DEVELOP.md)**. |
src/contract/openapi/routes/getApiV1ReposOwnerNameFamily.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import { REPO, limitTo, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/family`, | |
| 6 | summary: | |
| 7 | "The fork tree around a repository you can read, as the MCP fork_tree tool returns it: { root, nodes: [{ repo, parent, depth, follow, word }], truncated }, from the topmost ancestor you may read down, only forks you may read; follow is autonomous (the root), take-updates, offers or dont-follow; depth 1 to 8 (default 4), limit 1 to 200 nodes; truncated is true when more exists than is shown", | |
| 8 | auth: false, | |
| 9 | role: "anonymous", | |
| 10 | query: { depth: { type: "integer", min: 1, max: 8 }, limit: limitTo(200) }, | |
| 11 | refusals: { | |
| 12 | invalid_depth: "depth is not a whole number from 1 to 8 (422)", | |
| 13 | invalid_limit: "limit is not a whole number from 1 to 200 (422)", | |
| 14 | }, | |
| 15 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -149,6 +149,7 @@ export { route as getApiV1ReposOwnerNameDataEnvFileTables } from "./getApiV1Repo | ||
| 149 | 149 | export { route as getApiV1ReposOwnerNameDecisions } from "./getApiV1ReposOwnerNameDecisions.ts"; |
| 150 | 150 | export { route as getApiV1ReposOwnerNameEgressGrants } from "./getApiV1ReposOwnerNameEgressGrants.ts"; |
| 151 | 151 | export { route as getApiV1ReposOwnerNameEvents } from "./getApiV1ReposOwnerNameEvents.ts"; |
| 152 | export { route as getApiV1ReposOwnerNameFamily } from "./getApiV1ReposOwnerNameFamily.ts"; | |
| 152 | 153 | export { route as getApiV1ReposOwnerNameFlow } from "./getApiV1ReposOwnerNameFlow.ts"; |
| 153 | 154 | export { route as getApiV1ReposOwnerNameFollowing } from "./getApiV1ReposOwnerNameFollowing.ts"; |
| 154 | 155 | export { route as getApiV1ReposOwnerNameFollowingProposals } from "./getApiV1ReposOwnerNameFollowingProposals.ts"; |
src/http/api.ts +2
| @@ -374,6 +374,7 @@ import { subscriptionRoutes } from "./api/subscriptions.ts"; | ||
| 374 | 374 | import { boardRoutes } from "./api/boards.ts"; |
| 375 | 375 | import { displayRoutes } from "./api/displays.ts"; |
| 376 | 376 | import { followingRoutes } from "./api/following.ts"; |
| 377 | import { familyRoutes } from "./api/family.ts"; | |
| 377 | 378 | import { labelsRoutes } from "./api/labels.ts"; |
| 378 | 379 | import { dataRoutes } from "./api/data.ts"; |
| 379 | 380 | import { statementRoutes } from "./api/statement.ts"; |
| @@ -1820,6 +1821,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1820 | 1821 | }); |
| 1821 | 1822 | |
| 1822 | 1823 | followingRoutes(router); |
| 1824 | familyRoutes(router); | |
| 1823 | 1825 | |
| 1824 | 1826 | // ── git reads. The agent arena showed the write path was complete but the |
| 1825 | 1827 | // read path was missing, so automation couldn't inspect before acting. |
src/http/api/family.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * `GET /api/v1/repos/:owner/:name/family`: the fork tree around a repository | |
| 3 | * as one JSON document, the same one the MCP `fork_tree` tool returns | |
| 4 | * (`actions/fork_tree.ts`): ancestors, siblings and descendants the caller may | |
| 5 | * read, each with how it follows its parent. `?depth=` (1-8) and `?limit=` | |
| 6 | * (1-200 nodes) narrow it; `truncated` says when more exists than is shown. | |
| 7 | */ | |
| 8 | ||
| 9 | import { forkTree } from "../../actions/fork_tree.ts"; | |
| 10 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 11 | import { loadRepo } from "../api.ts"; | |
| 12 | import type { Handler } from "../context.ts"; | |
| 13 | import { apiError, json, notFound } from "../respond.ts"; | |
| 14 | import type { Router } from "../router.ts"; | |
| 15 | ||
| 16 | /** A query value as a whole number in range; undefined when absent, null when not one. */ | |
| 17 | function whole(raw: string | null, max: number): number | undefined | null { | |
| 18 | if (raw === null) return undefined; | |
| 19 | if (!/^\d{1,4}$/.test(raw)) return null; | |
| 20 | const n = Number(raw); | |
| 21 | return n >= 1 && n <= max ? n : null; | |
| 22 | } | |
| 23 | ||
| 24 | export function familyRoutes(router: Router<Handler>): void { | |
| 25 | router.add("GET", "/api/v1/repos/:owner/:name/family", (ctx) => { | |
| 26 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 27 | const repo = loadRepo(ctx); | |
| 28 | if (!repo) return notFound(); | |
| 29 | const q = new URL(ctx.request.url).searchParams; | |
| 30 | const depth = whole(q.get("depth"), 8); | |
| 31 | const limit = whole(q.get("limit"), 200); | |
| 32 | if (depth === null) return apiError("invalid_depth", 422, "depth is a whole number from 1 to 8"); | |
| 33 | if (limit === null) return apiError("invalid_limit", 422, "limit is a whole number from 1 to 200"); | |
| 34 | const opts: { depth?: number; limit?: number } = {}; | |
| 35 | if (depth !== undefined) opts.depth = depth; | |
| 36 | if (limit !== undefined) opts.limit = limit; | |
| 37 | const tree = timePhase(ctx.request, "tree", () => forkTree(ctx.app.db, repo, ctx.auth.user, opts)); | |
| 38 | return json(tree ?? { root: null, nodes: [], truncated: false }); | |
| 39 | }); | |
| 40 | } |
test/family-api.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * GET /repos/:o/:n/family and the MCP `fork_tree` tool answer one document; | |
| 3 | * a hidden fork is absent; depth is capped. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createToken } from "../src/core/tokens.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { promote } from "./helpers/members.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 20 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 21 | return r.value; | |
| 22 | }; | |
| 23 | ||
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let adminToken = ""; | |
| 28 | let bobToken = ""; | |
| 29 | ||
| 30 | const api = (path: string, token = adminToken) => | |
| 31 | fetch(`${servers.url}/api/v1/repos/alice/app/family${path}`, { headers: { authorization: `Bearer ${token}` } }); | |
| 32 | ||
| 33 | const tool = async (args: unknown, token = adminToken) => { | |
| 34 | const res = await fetch(`${servers.url}/mcp`, { | |
| 35 | method: "POST", | |
| 36 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 37 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "fork_tree", arguments: args } }), | |
| 38 | }); | |
| 39 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 40 | }; | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-family-api-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | servers = await serve(app); | |
| 46 | const users: Record<string, User> = {}; | |
| 47 | for (const name of ["alice", "bob", "dan", "fay", "gus"]) | |
| 48 | users[name] = must(await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@f.dev`, password: "a-good-passphrase", adminIfFirst: name === "alice" })); | |
| 49 | for (const u of Object.values(users)) promote(app.db, u); | |
| 50 | adminToken = createToken(app.db, users["alice"]!.id, "admin", "admin").token; | |
| 51 | bobToken = createToken(app.db, users["bob"]!.id, "bob", "ro").token; | |
| 52 | const upstream = must(await createRepo(app, users["alice"]!, { name: "app", visibility: "public" })); | |
| 53 | const bob = must(await forkRepo(app, users["bob"]!, upstream)); | |
| 54 | const dan = must(await forkRepo(app, users["dan"]!, bob)); | |
| 55 | must(await forkRepo(app, users["gus"]!, dan)); | |
| 56 | must(await forkRepo(app, users["fay"]!, upstream, "secret", { visibility: "private" })); | |
| 57 | }, 60_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers.stop(); | |
| 61 | app.close(); | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | describe("the family JSON read", () => { | |
| 66 | test("answers the chain, each node's follow mode and truncated", async () => { | |
| 67 | const res = await api(""); | |
| 68 | expect(res.status).toBe(200); | |
| 69 | const doc = (await res.json()) as any; | |
| 70 | expect(doc.root).toBe("alice/app"); | |
| 71 | expect(doc.truncated).toBe(false); | |
| 72 | const dan = doc.nodes.find((n: any) => n.repo === "dan/app"); | |
| 73 | expect(dan).toMatchObject({ parent: "bob/app", depth: 2, follow: "offers" }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a private fork is hidden from an outsider and shown to one who may read it", async () => { | |
| 77 | expect(JSON.stringify(await (await api("", bobToken)).json())).not.toContain("fay/"); | |
| 78 | expect(JSON.stringify(await (await api("")).json())).toContain("fay/secret"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("depth is capped and says so with truncated: true", async () => { | |
| 82 | const doc = (await (await api("?depth=1")).json()) as any; | |
| 83 | expect(doc.nodes.every((n: any) => n.depth <= 1)).toBe(true); | |
| 84 | expect(doc.nodes.some((n: any) => n.repo === "dan/app")).toBe(false); | |
| 85 | expect(doc.truncated).toBe(true); | |
| 86 | const limited = (await (await api("?limit=2")).json()) as any; | |
| 87 | expect(limited.nodes.length).toBe(2); | |
| 88 | expect(limited.truncated).toBe(true); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a bad depth or limit is refused; an unreadable repository is not found", async () => { | |
| 92 | expect((await api("?depth=9")).status).toBe(422); | |
| 93 | expect((await api("?limit=x")).status).toBe(422); | |
| 94 | const hidden = await fetch(`${servers.url}/api/v1/repos/fay/secret/family`, { headers: { authorization: `Bearer ${bobToken}` } }); | |
| 95 | expect(hidden.status).toBe(404); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the MCP tool returns the same document", async () => { | |
| 99 | for (const [query, args] of [["", {}], ["?depth=1", { depth: 1 }]] as const) { | |
| 100 | const viaApi = await (await api(query)).json(); | |
| 101 | expect(await tool({ repo: "alice/app", ...args })).toEqual(viaApi as any); | |
| 102 | const asBob = await (await api(query, bobToken)).json(); | |
| 103 | expect(await tool({ repo: "alice/app", ...args }, bobToken)).toEqual(asBob as any); | |
| 104 | } | |
| 105 | }); | |
| 106 | }); |
new above
offline