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

API /map and MCP `map` name what a builder can start now: `startable`, unassigned and unheld with nothing open in front #7099

Merged opened this

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

Merged

scout/forge:scout/6177-api-map-and-mcp-map-name-what-a-builder → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6177-api-map-and-mcp-map-name-what-a-builder was merged into main as 247de9b.

Review

Sign in to review this change.
4 files changed +177 −4
docs/MCP.md +10
@@ -375,6 +375,16 @@ answers
375375 becomes the "Told from" link on the issue; a bad one is refused. It differs
376376 from `context.page`, which is any http(s) address appended as context, not
377377 linked as the origin.
378 - **`ready` and `startable`.** `map` (and `GET …/map`) carries both. `ready` is
379 every open issue with nothing open in front, held or working ones included,
380 and the page's Ready now section reads it. `startable` is the numbers from
381 `ready` that a builder may begin: no assignee, not working, held, parked or
382 deferred, and no filter holding them (`ready[].held`), in `ready`'s order.
383 `counts.ready` equals `startable.length`; read `startable`, not `ready`, to
384 count what can start. The page's counts line still counts every ready
385 issue, assigned ones too. `GET /api/v1/map` (the lanes) carries `startable`
386 too but unfiltered: it skips the filter holds, so a hot-file issue is
387 startable there and not in the repository's own map.
378388 - **Plugins.** `plugins/opencode` and `plugins/claude-code` register this
379389 server and add a `forge` command (tell, status, decide, plan, preview,
380390 pulls). They hold no logic.
src/contract/openapi/routes/getApiV1ReposOwnerNameMap.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false };
3 export const route: Route = { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars; `startable` lists the ready, unassigned, unheld numbers", auth: false };
src/web/pages/map.ts +15 −3
@@ -1487,6 +1487,15 @@ export function mapJson(
14871487 ): Record<string, unknown> {
14881488 const ready = readyNow(graph);
14891489 const readySet = new Set(ready.map((r) => r.issue.id));
1490 const free = held === undefined ? undefined : new Set(held.startable);

globalMapJson passes no held, so free is undefined and the filter is skipped. Lane startable then differs from the repo /map. Pass held there, or document the difference.

1491 const startable = ready
1492 .filter(
1493 (r) =>
1494 r.issue.assigneeName === null &&
1495 wordFor(r.issue, readySet, marks) === "ready" &&
1496 (free === undefined || free.has(r.issue.number)),
1497 )
1498 .map((r) => r.issue.number);
14901499 const brief = (i: Issue) => ({
14911500 number: i.number,
14921501 title: i.title,
@@ -1512,8 +1521,8 @@ export function mapJson(
15121521 holdsUp: r.holdsUp,
15131522 ...(held === undefined ? {} : { held: held.holds.get(r.issue.number) ?? null }),
15141523 })),
1515 // The ready numbers no filter holds: what a builder may start now.
1516 ...(held === undefined ? {} : { startable: held.startable }),
1524 // What a builder may start now: ready, unassigned, no filter holding it.
1525 startable,
15171526 blockedOn: blockedOn(graph).map((r) => ({
15181527 ...brief(r.issue),
15191528 holdsUp: r.holdsUp,
@@ -1530,7 +1539,10 @@ export function mapJson(
15301539 title: t.title,
15311540 reasons: t.reasons,
15321541 })),
1533 counts: countWords(graph.issues.map((i) => wordFor(i, readySet, marks))),
1542 counts: {

counts.ready now means startable, but global-map.ts sorts lanes and sets idle from it. Cover that with a test or document it.

1543 ...countWords(graph.issues.map((i) => wordFor(i, readySet, marks))),
1544 ready: startable.length,
1545 },
15341546 edges: graph.edges.map((e) => {
15351547 const a = graph.issues.find((i) => i.id === e.issueId);
15361548 const b = graph.issues.find((i) => i.id === e.blockedById);
test/map-startable.test.ts added +151
@@ -0,0 +1,151 @@
1 /** GET …/map `startable` (#6177): ready, unassigned, unheld; `counts.ready` agrees with it. */
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
8 import { assignIssue } from "../src/actions/issues.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { addDependency } from "../src/core/dependencies.ts";
11 import { addComment, createIssue } from "../src/core/issues.ts";
12 import { HOT_PULLS, rememberPullFiles } from "../src/crew/hot_files.ts";
13 import { claimRun, createRun } from "../src/core/runs.ts";
14 import { getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17
18 let dataDir = "";
19 let app: App, servers: Servers, repoId = "", bearer = "";
20 const nums: Record<string, number> = {};
21
22 beforeAll(async () => {
23 dataDir = await mkdtemp(join(tmpdir(), "forge-mapstart-"));
24 app = createApp({ dataDir, port: 0 });
25 servers = await serve(app);
26 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
27 if (!admin.ok) throw new Error(admin.error);
28 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
29 if (!made.ok) throw new Error(String(made.error));
30 const repo = made.value;
31 repoId = repo.id;
32 rememberPullFiles(
33 repoId,
34 Array.from({ length: HOT_PULLS }, (_, i) => ({ number: i + 1, files: ["src/hot.ts", `src/own${i}.ts`] })),
35 );
36 const add = (key: string, title: string) => {
37 const r = createIssue(app.db, { repoId: repo.id, authorId: admin.value.id, title, body: "" });
38 if (!r.ok) throw new Error(r.error);
39 nums[key] = r.value.number;
40 return r.value;
41 };
42 add("free", "Free");
43 const taken = add("taken", "Taken");
44 const front = add("front", "In front");
45 const waits = add("waits", "Waits");
46 add("hot", "Add a flag to src/hot.ts");
47 const parked = add("parked", "Parked");
48 for (let n = 0; n < 2; n++) {
49 const said = addComment(app.db, {
50 issueId: parked.id,
51 authorId: admin.value.id,
52 body: "Parked by the crew: the check stayed red past its tries.",
53 });
outdated

This test passes with the free and wordFor conditions deleted. Add held, parked, working and filter-held fixtures, plus an MCP map call.

54 if (!said.ok) throw new Error(said.error);
55 }
56 // Working but unassigned: only the word keeps it off the list.
57 const busy = add("working", "Crew is on it");
58 const agent = await createAgent(app, admin.value, { username: "scout" });
59 if (!agent.ok) throw new Error(String(agent.error));
60 if (!addToCrew(app, admin.value, repo, "scout").ok) throw new Error("crew");
61 const run = createRun(app.db, {
62 repoId: repo.id,
63 kind: "agent",
64 issueId: busy.id,
65 actorId: getUserByName(app.db, "scout")!.id,
66 reason: "assigned",
67 });
68 if (claimRun(app.db, run.id) === undefined) throw new Error("claim");
69 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
70 method: "POST",
71 headers: { "content-type": "application/json" },
72 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
73 });
74 bearer = ((await minted.json()) as { token: string }).token;
75 const a = assignIssue(app, admin.value, repo, taken, admin.value);
76 if (!a.ok) throw new Error(String(a.error));
77 const d = addDependency(app.db, waits, front, admin.value.id);
78 if (!d.ok) throw new Error(String(d.error));
79 });
80
81 afterAll(async () => {
82 rememberPullFiles(repoId, []);
83 servers.stop?.();
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 test("startable leaves out the assigned and the waiting, and counts.ready equals its length", async () => {
89 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/map`);
90 const body = (await res.json()) as {
91 ready: { number: number }[];
92 startable: number[];
93 counts: { ready: number };
94 };
95 expect(body.startable).toContain(nums.free!);
96 expect(body.startable).toContain(nums.front!);
97 expect(body.startable).not.toContain(nums.taken!);
98 expect(body.startable).not.toContain(nums.waits!);
99 expect(body.ready.map((r) => r.number)).not.toContain(nums.waits!);
100 expect(body.counts.ready).toBe(body.startable.length);
101 expect(body.startable.every((n) => body.ready.some((r) => r.number === n))).toBe(true);
102 });
103
104 test("a filter-held, parked or crew-working issue is ready or counted out, but never startable", async () => {
105 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/map`);
106 const body = (await res.json()) as {
107 ready: { number: number; held: { by: string } | null }[];
108 startable: number[];
109 };
110 const held = body.ready.find((r) => r.number === nums.hot!);
111 expect(held?.held?.by).toBe("hot_file");
112 expect(body.startable).not.toContain(nums.hot!);
113 expect(body.startable).not.toContain(nums.parked!);
114 expect(body.startable).not.toContain(nums.working!);
115 expect(body.startable).toContain(nums.free!);
116 });
117
118 test("the MCP map tool carries startable, and counts.ready agrees", async () => {
119 const res = await fetch(`${servers.url}/mcp`, {
120 method: "POST",
121 headers: {
122 "content-type": "application/json",
123 accept: "application/json, text/event-stream",
124 authorization: `Bearer ${bearer}`,
125 },
126 body: JSON.stringify({
127 jsonrpc: "2.0",
128 id: 1,
129 method: "tools/call",
130 params: { name: "map", arguments: { app: "alice/notes" } },
131 }),
132 });
133 const body: any = await res.json();
134 const map = JSON.parse(body.result.content[0].text) as { startable: number[]; counts: { ready: number } };
135 expect(map.startable).toContain(nums.free!);
136 expect(map.startable).not.toContain(nums.hot!);
137 expect(map.startable).not.toContain(nums.parked!);
138 expect(map.startable).not.toContain(nums.working!);
139 expect(map.counts.ready).toBe(map.startable.length);
140 });
141
142 test("the forge-wide map's lanes carry startable without the filters", async () => {
143 const res = await fetch(`${servers.url}/api/v1/map`, { headers: { authorization: `Bearer ${bearer}` } });
144 const body = (await res.json()) as { repos: { repo: { name: string }; startable: number[]; counts: { ready: number } }[] };
145 const lane = body.repos.find((r) => r.repo.name === "notes")!;
146 expect(lane.startable).toContain(nums.free!);
147 expect(lane.startable).toContain(nums.hot!);
148 expect(lane.startable).not.toContain(nums.parked!);
149 expect(lane.startable).not.toContain(nums.working!);
150 expect(lane.counts.ready).toBe(lane.startable.length);
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs