Public: anyone can read this; the crew can change it.
A planned issue with `Relieves:` starts while its file is hot: #7299, #7424 and #7429 are seen through, or the map says why not #7496
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7489-a-planned-issue-with-relieves-starts-whi → main 1 commit fork
1 file
changed +129
−0
test/held-relieves-map.test.ts added +129
| @@ -0,0 +1,129 @@ | ||
| 1 | /** | |
| 2 | * GET …/map and the MCP `map` tool see a `Relieves:` issue through its own hot | |
| 3 | * file (#7489). The bodies are #7299's, #7424's and #7429's as the forge | |
| 4 | * stores them: `Relieves:` above a backticked, comma-separated `Files:` line. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createRun } from "../src/core/runs.ts"; | |
| 14 | import type { User } from "../src/core/users.ts"; | |
| 15 | import { HOT_PULLS, rememberPullFiles } from "../src/crew/hot_files.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 18 | ||
| 19 | const TUI = { | |
| 20 | title: "A terminal screen adds its own palette entry from its own module, so a new screen no longer edits src/tui/forge.ts", | |
| 21 | body: "For: the crew, which today cannot start five ready issues because `src/tui/forge.ts` is in 7 open pulls: every new terminal screen adds its palette entry there. Steering means work is not held behind one file.\n\nDone:\n- [ ] Each palette entry (id, title, hint, who it is offered to, what it opens) is declared next to its screen, e.g. an exported `entry` in `src/tui/screens/<name>.ts`, and one list in a new `src/tui/screens/entries.ts` names them; `src/tui/forge.ts` reads that list instead of pushing each entry by hand.\n- [ ] Every entry offered today is offered to exactly the same people, with the same id, title and hint, in the same order; a test compares the palette for a person admin, a member, an agent and a signed-out session before and after.\n- [ ] AGENTS.md or the terminal's doc says a new screen adds its entry in its own module and one line in `entries.ts`.\n\nRelieves: src/tui/forge.ts\nFiles: `src/tui/forge.ts`, a new `src/tui/screens/entries.ts`, the screen modules whose entries move, one test.\nRisk: medium; it conflicts with every open pull that adds a palette entry, so it lands when `src/tui/forge.ts` is no longer hot.", | |
| 22 | }; | |
| 23 | const CLI = { | |
| 24 | title: "Each `forge` command lives in its own module under src/cli/commands/, and main.ts dispatches from one table", | |
| 25 | body: "Intent: a new command stops being an edit to `src/cli/main.ts`, so command-line work no longer queues behind it. Today #7352, #7378, #7379 and #7380 all wait on that file.\n\nDone:\n- [ ] The commands already in `src/cli/main.ts` (`login`, `logout`, `whoami`, `sync`, `status`, `ls`, `pin`, `unpin`, `open`, `ui` and any that have landed since) each move to `src/cli/commands/<name>.ts`. Each exports `{ name, usage, run(out, args) }`.\n- [ ] `src/cli/main.ts` keeps the flags, `EXIT` and one table built from `src/cli/commands/index.ts`. `forge help` prints the same lines in the same order as before.\n- [ ] Behaviour is unchanged: the existing CLI tests pass untouched, and a new test checks that every module in `src/cli/commands/` is in the table and in `forge help`.\n- [ ] `AGENTS.md` (or `docs/CLI.md` if it exists) says in one line: a new command is a new file in `src/cli/commands/` plus one line in `index.ts`.\n\nRelieves: src/cli/main.ts\nFiles: `src/cli/main.ts`, `src/cli/commands/` (new), a new test.\n\nRisk: a pure move with no change in what any command does or prints.", | |
| 26 | }; | |
| 27 | const MCP = { | |
| 28 | title: "The MCP tool table is generated one file per tool under docs/mcp/, so two pulls adding tools never edit the same doc", | |
| 29 | body: "Intent: MCP work stops queueing behind `docs/MCP.md`. Every tool change today regenerates its one table, so the file stays hot (#5323 waits on it).\n\nDone:\n- [ ] `bun scripts/mcp-tools.ts` writes `docs/mcp/tools/<name>.md` (one line: the tool's `doc` and whether it writes) for each tool, and removes files for tools that are gone. `docs/MCP.md` keeps its prose and, between its `tools:begin`/`tools:end` markers, one sentence linking the directory instead of the table.\n- [ ] `test/mcp-tools.test.ts` and the test that every MCP tool in the index is in the docs (#7252) check the per-tool files instead of the table, with the same failure words naming the command to run.\n- [ ] `GET /api/v1/…` or the MCP `index` tool, if either reads the table today, reads the files.\n- [ ] Nothing else in `docs/MCP.md` changes.\n\nRelieves: docs/MCP.md\nFiles: `scripts/mcp-tools.ts`, `docs/MCP.md`, `docs/mcp/tools/` (new), `test/mcp-tools.test.ts`.\n\nRisk: a pure move of generated text. It lands once docs/MCP.md is relieved.", | |
| 30 | }; | |
| 31 | const HOT = ["src/tui/forge.ts", "src/cli/main.ts", "docs/MCP.md"]; | |
| 32 | ||
| 33 | let dataDir = ""; | |
| 34 | let app: App, servers: Servers, alice: User, bearer = ""; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-relieves-map-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 41 | if (!admin.ok) throw new Error(admin.error); | |
| 42 | alice = admin.value; | |
| 43 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 44 | method: "POST", | |
| 45 | headers: { "content-type": "application/json" }, | |
| 46 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 47 | }); | |
| 48 | bearer = ((await minted.json()) as { token: string }).token; | |
| 49 | }); | |
| 50 | ||
| 51 | afterAll(async () => { | |
| 52 | servers.stop?.(); | |
| 53 | app.close(); | |
| 54 | await rm(dataDir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | /** A repository whose remembered open pulls make every path in `HOT` hot, one more pull than the limit. */ | |
| 58 | async function hotRepo(name: string): Promise<string> { | |
| 59 | const made = await createRepo(app, alice, { name, visibility: "public", description: "" }); | |
| 60 | if (!made.ok) throw new Error(String(made.error)); | |
| 61 | rememberPullFiles( | |
| 62 | made.value.id, | |
| 63 | Array.from({ length: HOT_PULLS + 1 }, (_, i) => ({ number: 9000 + i, files: [...HOT, `src/own${i}.ts`] })), | |
| 64 | ); | |
| 65 | return made.value.id; | |
| 66 | } | |
| 67 | ||
| 68 | function open(repoId: string, issue: { title: string; body: string }) { | |
| 69 | const r = createIssue(app.db, { repoId, authorId: alice.id, ...issue }); | |
| 70 | if (!r.ok) throw new Error(r.error); | |
| 71 | return r.value; | |
| 72 | } | |
| 73 | ||
| 74 | type Held = { by: string; detail: string } | null; | |
| 75 | type MapBody = { ready: { number: number; held: Held }[]; startable: number[] }; | |
| 76 | ||
| 77 | async function apiMap(name: string): Promise<MapBody> { | |
| 78 | return (await (await fetch(`${servers.url}/api/v1/repos/alice/${name}/map`)).json()) as MapBody; | |
| 79 | } | |
| 80 | ||
| 81 | async function mcpMap(name: string): Promise<MapBody> { | |
| 82 | const res = await fetch(`${servers.url}/mcp`, { | |
| 83 | method: "POST", | |
| 84 | headers: { | |
| 85 | "content-type": "application/json", | |
| 86 | accept: "application/json, text/event-stream", | |
| 87 | authorization: `Bearer ${bearer}`, | |
| 88 | }, | |
| 89 | body: JSON.stringify({ | |
| 90 | jsonrpc: "2.0", | |
| 91 | id: 1, | |
| 92 | method: "tools/call", | |
| 93 | params: { name: "map", arguments: { app: `alice/${name}` } }, | |
| 94 | }), | |
| 95 | }); | |
| 96 | const body = (await res.json()) as { result: { content: { text: string }[] } }; | |
| 97 | return JSON.parse(body.result.content[0]!.text) as MapBody; | |
| 98 | } | |
| 99 | ||
| 100 | const heldOf = (map: MapBody, n: number): Held | undefined => map.ready.find((r) => r.number === n)?.held; | |
| 101 | ||
| 102 | test("the three planned bodies are held by nothing on their own hot files; a plain one on the same file is", async () => { | |
| 103 | const repoId = await hotRepo("relieved"); | |
| 104 | const tui = open(repoId, TUI); | |
| 105 | const cli = open(repoId, CLI); | |
| 106 | const mcp = open(repoId, MCP); | |
| 107 | const plain = open(repoId, { title: "Add a palette entry", body: "Files: `src/tui/forge.ts`, one test." }); | |
| 108 | for (const map of [await apiMap("relieved"), await mcpMap("relieved")]) { | |
| 109 | expect(heldOf(map, tui.number)).toBeNull(); | |
| 110 | expect(heldOf(map, cli.number)).toBeNull(); | |
| 111 | expect(heldOf(map, mcp.number)).toBeNull(); | |
| 112 | expect(heldOf(map, plain.number)).toEqual({ by: "hot_file", detail: `src/tui/forge.ts is in ${HOT_PULLS + 1} open pulls` }); | |
| 113 | expect(map.startable.sort((a, b) => a - b)).toEqual([tui.number, cli.number, mcp.number]); | |
| 114 | } | |
| 115 | }); | |
| 116 | ||
| 117 | test("a second issue relieving the same path waits on the one in flight, and the map says by whom", async () => { | |
| 118 | const repoId = await hotRepo("relieving"); | |
| 119 | const first = open(repoId, TUI); | |
| 120 | createRun(app.db, { repoId, kind: "agent", issueId: first.id }); | |
| 121 | const second = open(repoId, TUI); | |
| 122 | for (const map of [await apiMap("relieving"), await mcpMap("relieving")]) { | |
| 123 | expect(heldOf(map, first.number)?.by).toBe("running"); | |
| 124 | expect(heldOf(map, second.number)).toEqual({ | |
| 125 | by: "hot_file", | |
| 126 | detail: `waits: src/tui/forge.ts is already being relieved by #${first.number}`, | |
| 127 | }); | |
| 128 | } | |
| 129 | }); |
new above
offline