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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6177-api-map-and-mcp-map-name-what-a-builder → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +177
−4
docs/MCP.md +10
| @@ -375,6 +375,16 @@ answers | ||
| 375 | 375 | becomes the "Told from" link on the issue; a bad one is refused. It differs |
| 376 | 376 | from `context.page`, which is any http(s) address appended as context, not |
| 377 | 377 | 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. | |
| 378 | 388 | - **Plugins.** `plugins/opencode` and `plugins/claude-code` register this |
| 379 | 389 | server and add a `forge` command (tell, status, decide, plan, preview, |
| 380 | 390 | pulls). They hold no logic. |
src/contract/openapi/routes/getApiV1ReposOwnerNameMap.ts +1 −1
| @@ -1,3 +1,3 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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( | ||
| 1487 | 1487 | ): Record<string, unknown> { |
| 1488 | 1488 | const ready = readyNow(graph); |
| 1489 | 1489 | const readySet = new Set(ready.map((r) => r.issue.id)); |
| 1490 | const free = held === undefined ? undefined : new Set(held.startable); | |
| 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); | |
| 1490 | 1499 | const brief = (i: Issue) => ({ |
| 1491 | 1500 | number: i.number, |
| 1492 | 1501 | title: i.title, |
| @@ -1512,8 +1521,8 @@ export function mapJson( | ||
| 1512 | 1521 | holdsUp: r.holdsUp, |
| 1513 | 1522 | ...(held === undefined ? {} : { held: held.holds.get(r.issue.number) ?? null }), |
| 1514 | 1523 | })), |
| 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, | |
| 1517 | 1526 | blockedOn: blockedOn(graph).map((r) => ({ |
| 1518 | 1527 | ...brief(r.issue), |
| 1519 | 1528 | holdsUp: r.holdsUp, |
| @@ -1530,7 +1539,10 @@ export function mapJson( | ||
| 1530 | 1539 | title: t.title, |
| 1531 | 1540 | reasons: t.reasons, |
| 1532 | 1541 | })), |
| 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 | }, | |
| 1534 | 1546 | edges: graph.edges.map((e) => { |
| 1535 | 1547 | const a = graph.issues.find((i) => i.id === e.issueId); |
| 1536 | 1548 | 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 |
||
| 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 | }); |
new above
offline
globalMapJsonpasses noheld, sofreeis undefined and the filter is skipped. Lanestartablethen differs from the repo/map. Passheldthere, or document the difference.