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

The MCP `flow` tool answers kind "bugs": open bugs with their clocks, gated as the terminal is #9007

Merged opened this

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

Merged

scout/forge:scout/8973-the-mcp-flow-tool-answers-kind-bugs-open → main 2 commits fork

  • Merged. scout/8973-the-mcp-flow-tool-answers-kind-bugs-open was merged into main as 29aeaa7.

Review

Sign in to review this change.
3 files changed +110 −2
docs/MCP.md +5
@@ -61,6 +61,11 @@ a failure names the bytes over and the longest descriptions.
6161 `instructions` say once that a tool with `dryRun` plans unless it is `false`.
6262 - The ceiling is the owner's: never raise it in a feature pull.
6363
64 `flow` with `{ kind: "bugs" }` is such a door (#8973): `{ open, overdue, bugs }`,
65 each bug `{ repo, number, priority, dueAt }`, soonest due first, at most 100,
66 from `src/flow/bugs.ts`. An admin sees every repository; anyone else only
67 public ones, as the terminal's bugs screen does.
68
6469 Answers are JSON in a text block; most carry a `says` sentence short enough
6570 to relay as it is.
6671
src/http/mcp/tools/flow.ts +16 −2
@@ -5,6 +5,7 @@ import { canWrite } from "../../../core/repos.ts";
55 import { mayView } from "../../../core/sealed.ts";
66 import { whyIssue, whyPull, liveHead, whyRepo, type Why } from "../../../flow/why.ts";
77 import { countRead } from "../../../system/telemetry_reads.ts";
8 import { openBugs } from "../../../flow/bugs.ts";
89 import { flowCached } from "../../../system/flow_cache.ts";
910 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1011
@@ -22,12 +23,13 @@ const said = (subject: string, why: Why) =>
2223
2324 export const tool: Tool = {
2425 name: "flow",
25 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
26 doc: "with no arguments, the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing`; with `{ kind: bugs }`, `{ open, overdue, bugs: [{ repo, number, priority, dueAt }] }` soonest due first, at most 100 (an admin sees all, anyone else public repositories); with `{ repo, issue? or pull? }` (a writer of the repository), why it is not moving: `{ subject, moving, state?, cause, says, clears, next? }`, the first of wall (a person decides new access), ceiling, hot_file, blocked_by, later_gate, no_builder, no_headroom, red_check, waiting_review that holds it (`flow/why.ts`); `repo` alone asks about the repository's builders",
2627 description:
27 "Pipeline (admin); with `repo` and `issue` or `pull`: why it is not moving.",
28 "Pipeline (admin); `kind: \"bugs\"`: open bugs on their clocks; with `repo` and `issue` or `pull`: why it is not moving.",
2829 inputSchema: {
2930 type: "object",
3031 properties: {
32 kind: { type: "string", enum: ["bugs"] },
3133 repo: REPO_ARG,
3234 issue: { type: "integer", minimum: 1 },
3335 pull: { type: "integer", minimum: 1 },
@@ -35,6 +37,18 @@ export const tool: Tool = {
3537 },
3638 writes: false,
3739 async run(ctx, user, args) {
40 if (args["kind"] !== undefined) {
41 if (args["kind"] !== "bugs") return bad("invalid", "kind is bugs");
42 // As the terminal's bugs screen: an admin sees every repository, anyone else public ones.
43 const all = openBugs(ctx.app.db, user.isAdmin && user.kind !== "agent" ? {} : { publicOnly: true });
44 const now = Date.now();
45 const bugs = all.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
46 return good({
47 open: bugs.length,
48 overdue: bugs.filter((b) => Date.parse(b.dueAt) <= now).length,
49 bugs: bugs.slice(0, 100).map((b) => ({ repo: b.repo, number: b.number, priority: b.high ? "high" : "low", dueAt: b.dueAt })),
50 });
51 }
3852 if (args["repo"] === undefined) {
3953 const reader = telemetryReader(ctx.app, user);
4054 if (!reader)
test/mcp-flow-bugs.test.ts added +89
@@ -0,0 +1,89 @@
1 /** `flow` with kind "bugs" (#8973): open bugs on their clocks, gated as the terminal's bugs screen. */
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 { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 let dir: string;
12 let app: App;
13 let servers: Servers;
14 let adminTok: string;
15 let guestTok: string;
16 let id = 1;
17 let n = 0;
18
19 const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
20
21 function bug(repo: string, hoursOld: number, high: boolean) {
22 n++;
23 const iid = `i${n}`;
24 app.db.run(
25 "INSERT INTO issue (id, repo_id, number, kind, title, author_id, state, created_at) VALUES (?1, ?2, ?3, 'issue', 't', (SELECT id FROM user LIMIT 1), 'open', ?4)",
26 [iid, repo, n, hoursAgo(hoursOld)],
27 );
28 app.db.run("INSERT INTO issue_label (issue_id, label_id) VALUES (?1, ?2)", [iid, `${repo}-bug`]);
29 if (high) app.db.run("INSERT INTO issue_label (issue_id, label_id) VALUES (?1, ?2)", [iid, `${repo}-high`]);
30 return n;
31 }
32
33 const call = async (token: string, args: unknown) => {
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: id++, method: "tools/call", params: { name: "flow", arguments: args } }),
38 });
39 const body: any = await res.json();
40 const text: string = body.result.content[0].text;
41 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
42 };
43
44 beforeAll(async () => {
45 dir = await mkdtemp(join(tmpdir(), "forge-mcp-flow-bugs-"));
46 app = createApp({ dataDir: dir, port: 0 });
47 servers = await serve(app);
48 const mk = async (username: string, first = false) => {
49 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
50 if (!u.ok) throw new Error(u.error);
51 return u.value;
52 };
53 const alice = await mk("alice", true);
54 const carol = await mk("carol");
55 adminTok = createToken(app.db, alice.id, "rw", "rw").token;
56 guestTok = createToken(app.db, carol.id, "rw", "rw").token;
57 for (const [r, vis] of [["pub", "public"], ["priv", "private"]] as const) {
58 app.db.run("INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?1, ?3)", [r, alice.id, vis]);
59 app.db.run("INSERT INTO label (id, repo_id, name) VALUES (?1, ?2, 'bug')", [`${r}-bug`, r]);
60 app.db.run("INSERT INTO label (id, repo_id, name) VALUES (?1, ?2, 'high')", [`${r}-high`, r]);
61 }
62 }, 30_000);
63
64 afterAll(async () => {
65 servers.stop();
66 app.close();
67 await rm(dir, { recursive: true, force: true });
68 });
69
70 test("an overdue high bug is named first; a private repository's bug is hidden from a guest", async () => {
71 const late = bug("pub", 5, true);
72 bug("pub", 1, false);
73 const secret = bug("priv", 10, true);
74 const admin = await call(adminTok, { kind: "bugs" });
75 expect(admin.value.open).toBe(3);
76 expect(admin.value.overdue).toBe(2);
77 expect(admin.value.bugs[0]).toMatchObject({ repo: "alice/priv", number: secret, priority: "high" });
78 expect(admin.value.bugs.map((b: any) => b.repo)).toContain("alice/priv");
79
80 const guest = await call(guestTok, { kind: "bugs" });
81 expect(guest.value.open).toBe(2);
82 expect(guest.value.overdue).toBe(1);
83 expect(guest.value.bugs[0]).toMatchObject({ repo: "alice/pub", number: late, priority: "high" });
84 expect(JSON.stringify(guest.value)).not.toContain("priv");
85 });
86
87 test("an unknown kind is refused", async () => {
88 expect((await call(adminTok, { kind: "nope" })).error).toContain("kind");
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs