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

Merged opened this

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

Merged

scout/forge:scout/12319-the-fork-tree-of-a-repository-is-one-jso → main 3 commits fork

  • Merged. scout/12319-the-fork-tree-of-a-repository-is-one-jso was merged into main as 98b4b17.

Review

Sign in to review this change.
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
378378
379379 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`).
380380
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
381383 ## Develop
382384
383385 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
149149 export { route as getApiV1ReposOwnerNameDecisions } from "./getApiV1ReposOwnerNameDecisions.ts";
150150 export { route as getApiV1ReposOwnerNameEgressGrants } from "./getApiV1ReposOwnerNameEgressGrants.ts";
151151 export { route as getApiV1ReposOwnerNameEvents } from "./getApiV1ReposOwnerNameEvents.ts";
152 export { route as getApiV1ReposOwnerNameFamily } from "./getApiV1ReposOwnerNameFamily.ts";
152153 export { route as getApiV1ReposOwnerNameFlow } from "./getApiV1ReposOwnerNameFlow.ts";
153154 export { route as getApiV1ReposOwnerNameFollowing } from "./getApiV1ReposOwnerNameFollowing.ts";
154155 export { route as getApiV1ReposOwnerNameFollowingProposals } from "./getApiV1ReposOwnerNameFollowingProposals.ts";
src/http/api.ts +2
@@ -374,6 +374,7 @@ import { subscriptionRoutes } from "./api/subscriptions.ts";
374374 import { boardRoutes } from "./api/boards.ts";
375375 import { displayRoutes } from "./api/displays.ts";
376376 import { followingRoutes } from "./api/following.ts";
377 import { familyRoutes } from "./api/family.ts";
377378 import { labelsRoutes } from "./api/labels.ts";
378379 import { dataRoutes } from "./api/data.ts";
379380 import { statementRoutes } from "./api/statement.ts";
@@ -1820,6 +1821,7 @@ export function apiRoutes(router: Router<Handler>): void {
18201821 });
18211822
18221823 followingRoutes(router);
1824 familyRoutes(router);
18231825
18241826 // ── git reads. The agent arena showed the write path was complete but the
18251827 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs