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

The MCP answers whether the work is growing: open count, net per day, days queued and the pause suggestion #11760

Merged opened this

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

Merged

mason/forge:mason/11751-the-mcp-answers-whether-the-work-is-grow → main 3 commits fork

  • Merged. mason/11751-the-mcp-answers-whether-the-work-is-grow was merged into main as 872a068.

Review

Sign in to review this change.
3 files changed +99 −5
docs/MCP.md +1 −1
@@ -107,7 +107,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
107107 | `flag_set` | — | Set or clear a flag (admin) | 209 |
108108 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
109109 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
110 | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 473 |
110 | `flow` | `bugs`, `drift`, `growth` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 482 |
111111 | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 |
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
src/http/mcp/tools/flow.ts +14 −4

Diff not shown — a line 2,043 characters wide (limit 2,000). +14 −4

View file
test/mcp-growth.test.ts added +84
@@ -0,0 +1,84 @@
1 /** `flow` with kind "growth" (#11751): the queue page's growth numbers and pause words, for a repository the caller can read. */
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 { growth } from "../src/core/growth.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 let dir: string;
13 let app: App;
14 let servers: Servers;
15 let adminTok: string;
16 let guestTok: string;
17 let id = 1;
18
19 const call = async (token: string, args: unknown) => {
20 const res = await fetch(`${servers.url}/mcp`, {
21 method: "POST",
22 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
23 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
24 });
25 const body: any = await res.json();
26 const text: string = body.result.content[0].text;
27 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
28 };
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-mcp-growth-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 servers = await serve(app);
34 const mk = async (username: string, first = false) => {
35 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
36 if (!u.ok) throw new Error(u.error);
37 return u.value;
38 };
39 const alice = await mk("alice", true);
40 const carol = await mk("carol");
41 adminTok = createToken(app.db, alice.id, "rw", "rw").token;
42 guestTok = createToken(app.db, carol.id, "rw", "rw").token;
43 for (const [r, vis] of [["pub", "public"], ["priv", "private"]] as const)
44 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?1, ?3)", [r, alice.id, vis]);
45 // Three days running (today and the two before) with issues opened and none closed.
46 let n = 0;
47 for (const back of [0, 1, 2])
48 for (let k = 0; k < 2; k++) {
49 n++;
50 const at = new Date(Date.now() - back * 86_400_000).toISOString();
51 for (const r of ["pub", "priv"])
52 app.db.run(
53 "INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, ?2, ?3, 'issue', 't', ?4, 'open', ?5)",
54 [`${r}${n}`, r, n, alice.id, at],
55 );
56 }
57 }, 30_000);
58
59 afterAll(async () => {
60 servers.stop();
61 app.close();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 test("a list that grew three days running answers the page's own numbers and sentence", async () => {
66 const r = await call(guestTok, { kind: "growth", repo: "alice/pub" });
67 expect(r.value.growingRun).toBe(3);
68 expect(r.value.open.issues).toBe(6);
69 expect(r.value.daysQueued).toBeNull();
70 const page = growth(app.db, { id: "pub", ownerId: "x" }, 14, new Date(), {});
71 expect(r.value.suggest).not.toBeNull();
72 expect(r.value.suggest).toBe(page.suggest);
73 expect(r.value.sentences).toEqual(page.sentences);
74 expect(r.value.perDay).toBe(page.perDay);
75 });
76
77 test("a private repository is refused to an outsider and answered to its owner", async () => {
78 expect((await call(guestTok, { kind: "growth", repo: "alice/priv" })).error).toContain("no such");
79 expect((await call(adminTok, { kind: "growth", repo: "alice/priv" })).value.growingRun).toBe(3);
80 });
81
82 test("growth needs a repository", async () => {
83 expect((await call(adminTok, { kind: "growth" })).error).toBeDefined();
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs