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

An agent lists a repository's build pins through an MCP builds tool, as GET /api/v1/repos/:owner/:name/builds does #9106

Merged opened this

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

Merged

torque/forge:torque/9091-an-agent-lists-a-repository-s-build-pins → main 4 commits fork

  • Merged. torque/9091-an-agent-lists-a-repository-s-build-pins was merged into main as 3b2a89d.

Review

Sign in to review this change.
5 files changed +103 −0
docs/features/build-pins.md +1
@@ -8,6 +8,7 @@ is trimmed to what main calls).
88
99 - Page: `/<owner>/<name>/builds` lists pins; `/builds/new` is the form (Branches has a Pin button per branch and a Builds link); Preview serves a pinned commit (`build_preview`); Delete is behind `/-/confirm`.
1010 - API: `GET/POST /api/v1/repos/:owner/:name/builds`, `PATCH`/`DELETE …/builds/:label` (rw scope, a person who writes; agents are refused).
11 - MCP: the read-only `builds` tool takes `{ repo }` and answers `{ items, next_cursor: null }`, the rows the API lists; a repository the caller cannot read is not found. Pinning, noting and deleting are not offered to agents.
1112 - Terminal: on a repository, the palette's `<owner>/<name> — Builds` (`src/tui/screens/builds.ts`) lists the pins to anyone who may read it; a person who writes (never an agent) presses `p` to pin (prefilled with the next label and the default branch's tip), `n` to note, `d` to delete.
1213 - Notes are an append-only list on `refs/forge/notes`. `FORGE_NOTES_INTENT=<file>` copies each person's note once into that file under `## Iterator notes`.
1314 - Left out: demos and displays, screenshots, the Iterations graph, the lineage on Branches.
src/http/mcp/tools/builds.ts added +20
@@ -0,0 +1,20 @@
1 import { listBuildPins } from "../../../git/builds.ts";
2 import { repoDir } from "../../../git/repos.ts";
3 import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
4
5 export const tool: Tool = {
6 name: "builds",
7 doc: "`{ repo }`: the repository's build pins, `{ items, next_cursor: null }`, as GET `/api/v1/repos/:owner/:name/builds` lists them (label, commit, who pinned it, note, parent, source); read-only, pinning stays with people on the page, the API and the terminal",
8 description: "A repo's build pins (`refs/builds/`): label, commit, pinned by, note.",
9 inputSchema: {
10 type: "object",
11 properties: { repo: REPO_ARG },
12 required: ["repo"],
13 },
14 writes: false,
15 async run(ctx, user, args) {
16 const repo = repoArg(ctx, user, args);
17 if (isAnswer(repo)) return repo;
18 return good({ items: await listBuildPins(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name)), next_cursor: null });
19 },
20 };
src/http/mcp/tools/index.ts +1
@@ -34,6 +34,7 @@ export { tool as board_pins } from "./board_pins.ts";
3434 export { tool as board_write } from "./board_write.ts";
3535 export { tool as boards } from "./boards.ts";
3636 export { tool as branches } from "./branches.ts";
37 export { tool as builds } from "./builds.ts";
3738 export { tool as capacity } from "./capacity.ts";
3839 export { tool as claim_link } from "./claim_link.ts";
3940 export { tool as close } from "./close.ts";
test/mcp-builds.test.ts added +80
@@ -0,0 +1,80 @@
1 /** MCP `builds`: the same pins as GET …/builds, read-only, and a private repository is not found to a stranger. */
2 import { afterAll, beforeAll, 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 { pinBuild } from "../src/actions/builds.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { getRepo } from "../src/core/repos.ts";
10 import { createToken } from "../src/core/tokens.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { promote } from "./helpers/members.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 const tok: Record<string, string> = {};
22
23 async function mcp(token: string, args: unknown): Promise<{ error?: string; value?: any }> {
24 const res = await fetch(`${servers.url}/mcp`, {
25 method: "POST",
26 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
27 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "builds", arguments: args } }),
28 });
29 const body = (await res.json()) as any;
30 const text: string = body.result.content[0].text;
31 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
32 }
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-builds-mcp-"));
36 app = createApp({ dataDir, port: 0 });
37 servers = await serve(app);
38 const make = async (username: string) => {
39 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: true });
40 if (!made.ok) throw new Error(made.error);
41 return made.value;
42 };
43 const ada = await make("ada");
44 const sam = await make("sam");
45 promote(app.db, sam);
46 for (const [name, visibility] of [["game", "public"], ["vault", "private"]] as const) {
47 const made = await createRepo(app, ada, { name, visibility });
48 if (!made.ok) throw new Error(String(made.error));
49 const c = await commitToBranch(app, ada, getRepo(app.db, "ada", name)!, { message: "one", files: [{ path: "a.txt", content: "a\n" }] });
50 if (!c.ok) throw new Error(JSON.stringify(c.error));
51 const pinned = await pinBuild(app, ada, getRepo(app.db, "ada", name)!, { label: "v1", oid: c.value.commitOid, note: "first", source: "branch main" });
52 if (!pinned.ok) throw new Error(String(pinned.error));
53 }
54 tok["ada"] = createToken(app.db, ada.id, "rw", "rw").token;
55 tok["sam"] = createToken(app.db, sam.id, "rw", "rw").token;
56 }, 30_000);
57
58 afterAll(async () => {
59 servers?.stop();
60 app?.close();
61 await rm(dataDir, { recursive: true, force: true });
62 });
63
64 test("the tool answers the rows the API route lists", async () => {
65 const api = await fetch(`${servers.url}/api/v1/repos/ada/game/builds`, { headers: { authorization: `Bearer ${tok["sam"]}` } });
66 const expected = (await api.json()) as any;
67 expect(expected.items).toHaveLength(1);
68 expect(expected.items[0].label).toBe("v1");
69 expect(expected.items[0].pinnedBy).toBe("ada");
70 const got = await mcp(tok["sam"]!, { repo: "ada/game" });
71 expect(got.value).toEqual(expected);
72 expect(got.value.next_cursor).toBeNull();
73 });
74
75 test("a private repository is not found to a caller who cannot read it, and names no pin", async () => {
76 const refused = await mcp(tok["sam"]!, { repo: "ada/vault" });
77 expect(refused.error).toContain("not_found");
78 expect(refused.error).not.toContain("v1");
79 expect((await mcp(tok["ada"]!, { repo: "ada/vault" })).value.items).toHaveLength(1);
80 });
test/mcp.test.ts +1
@@ -274,6 +274,7 @@ describe("the forge over MCP", () => {
274274 "events",
275275 "reports",
276276 "branches",
277 "builds",
277278 "commits",
278279 "waves",
279280 "probe",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs