Public: anyone can read this; the crew can change it.
`forge map` prints the plan of the folder's repository: ready now, being built, and what waits on what #7608
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7379-forge-map-prints-the-plan-of-the-folder → main 14 commits fork
8 files
changed +485
−0
README.md +13
| @@ -330,6 +330,7 @@ forge asks --repo owner/name # only that repository's; --json for the API' | ||
| 330 | 330 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] |
| 331 | 331 | forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY) |
| 332 | 332 | forge asks --undo <id> # take back a decision by the receipt id it printed |
| 333 | forge map # the plan of this clone: working, ready, waiting | |
| 333 | 334 | forge search retry budget # code, issues, pulls and repos you can read; exits 6 on "not now" |
| 334 | 335 | forge search x --kind pulls --limit 5 # one kind, five hits; --json marks truncated.issues when more matched |
| 335 | 336 | |
| @@ -352,6 +353,18 @@ shell command on purpose: the terminal UI of the forge (`ssh`, `src/tty.ts`) | ||
| 352 | 353 | already has its own pulls screen, and the local UI is about working trees, not |
| 353 | 354 | the workbench's conversation; the pulls API and MCP are the other doors. |
| 354 | 355 | |
| 356 | `forge map` reads the plan of the same repository (`--repo` or `origin`, as | |
| 357 | for `pulls`) from `GET /api/v1/repos/:owner/:name/map` and prints three | |
| 358 | groups, each headed with its count: Working (`#N title — assignee`), Ready — | |
| 359 | the map's `startable` subset of its ready issues, unassigned and not held, so | |
| 360 | its count matches the map page's ready count (`#N title — pillar`) — and | |
| 361 | Waiting (`#N title — waits on #A, #B`, open blockers only). Titles are cut to | |
| 362 | the terminal, and a group past 15 lines ends `… N more`. `--json` prints the | |
| 363 | API's answer unchanged; an unknown repository prints the forge's words and | |
| 364 | exits 1 (3 for a refused token, 5 for a forbidden one). | |
| 365 | The same plan is a screen in the forge's terminal UI: press `m` on a | |
| 366 | repository's issues list (`src/tui/screens/plan.ts`). | |
| 367 | ||
| 355 | 368 | `sync` never merges, rebases, resets or checks anything out. `--ff` is the one |
| 356 | 369 | exception and it fast-forwards a repo only when the tree is clean, nothing is |
| 357 | 370 | ahead, no rebase or merge is half-finished, and `merge-base --is-ancestor` has |
docs/CLI.md +37
| @@ -289,6 +289,43 @@ look alice/shop Pull #3 is green — look and merge https://forge.example. | ||
| 289 | 289 | parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9 |
| 290 | 290 | |
| 291 | 291 | |
| 292 | ## forge map | |
| 293 | ||
| 294 | The plan of the repository: three groups that do not overlap, each headed | |
| 295 | with its count. **Working** is what the crew is on (`#N title — assignee`), | |
| 296 | **Ready** is the map's `startable` issues: nothing open stands in front of | |
| 297 | them and nobody has started or held them, so the count matches the map | |
| 298 | page's (`#N title — pillar`), and **Waiting** is every open issue with an open | |
| 299 | blocker (`#N title — waits on #A, #B`). Titles are cut to the terminal and a | |
| 300 | group stops at 15 lines with `… N more`; an empty one says so (`nothing | |
| 301 | ready`). The repository comes from `--repo` | |
| 302 | or from the folder's `origin` remote on the configured workbench. | |
| 303 | ||
| 304 | Flags: | |
| 305 | ||
| 306 | - `--repo owner/name` — the repository to read (exit 2 if it is not | |
| 307 | `owner/name`, or if it is missing and the folder is not a clone). | |
| 308 | - `--json` — the forge's `/api/v1/repos/:owner/:name/map` answer as it came. | |
| 309 | ||
| 310 | console | |
| 311 | $ forge map --repo alice/shop | |
| 312 | Working (1) | |
| 313 | #12 Checkout keeps the cart — torque | |
| 314 | ||
| 315 | Ready (1) | |
| 316 | #14 Receipts by mail — Payments | |
| 317 | ||
| 318 | Waiting (1) | |
| 319 | #15 Refunds — waits on #14 | |
| 320 | ||
| 321 | ||
| 322 | The same plan is open to agents through MCP, as the `map` tool, and to | |
| 323 | people in the terminal UI (`m` on a repository's issues list): | |
| 324 | ||
| 325 | json | |
| 326 | { "name": "map", "arguments": { "app": "alice/shop" } } | |
| 327 | ||
| 328 | ||
| 292 | 329 | ## forge search |
| 293 | 330 | |
| 294 | 331 | Code, issues, pulls and repositories the token can read, from |
src/cli/api.ts +96
| @@ -91,6 +91,28 @@ export interface Bulk { | ||
| 91 | 91 | const show = (value: unknown): string => |
| 92 | 92 | value === null || value === undefined ? "" : String(value); |
| 93 | 93 | |
| 94 | /** One issue on the plan, as `forge map` prints it. */ | |
| 95 | export interface PlanIssue { | |
| 96 | readonly number: number; | |
| 97 | readonly title: string; | |
| 98 | readonly assignee: string | null; | |
| 99 | readonly pillar: string | null; | |
| 100 | /** Open issues this one waits on, lowest number first. */ | |
| 101 | readonly waitsOn: readonly number[]; | |
| 102 | } | |
| 103 | ||
| 104 | /** Where the plan stands: the three groups `forge map` prints. */ | |
| 105 | export interface Plan { | |
| 106 | /** The answer exactly as the forge gave it, for `--json`. */ | |
| 107 | readonly raw: unknown; | |
| 108 | /** What the crew is on right now. */ | |
| 109 | readonly working: PlanIssue[]; | |
| 110 | /** Ready now and nobody on it, in the forge's order. */ | |
| 111 | readonly ready: PlanIssue[]; | |
| 112 | /** Open, and waiting on at least one open issue. */ | |
| 113 | readonly waiting: PlanIssue[]; | |
| 114 | } | |
| 115 | ||
| 94 | 116 | export class ForgeApi { |
| 95 | 117 | /** True once `pins()` has had to fall back, so the CLI can say it once. */ |
| 96 | 118 | pinsUnavailable = false; |
| @@ -430,6 +452,20 @@ export class ForgeApi { | ||
| 430 | 452 | return ok(this.toBulk(out.value)); |
| 431 | 453 | } |
| 432 | 454 | |
| 455 | /** | |
| 456 | * The repository's plan (`GET /api/v1/repos/:owner/:name/map`), split into | |
| 457 | * what is being built, what is ready and what waits on what. The groups do | |
| 458 | * not overlap: an issue the crew is on is Working even when it is ready or | |
| 459 | * still has an open blocker. | |
| 460 | */ | |
| 461 | async map(owner: string, name: string): Promise<ApiResult<Plan>> { | |
| 462 | const out = await this.call<unknown>( | |
| 463 | `/api/v1/repos/${encode(owner)}/${encode(name)}/map`, | |
| 464 | ); | |
| 465 | if (!out.ok) return out; | |
| 466 | return ok(toPlan(out.value)); | |
| 467 | } | |
| 468 | ||
| 433 | 469 | /** A workbench path as a full address; anything not a path is the home. */ |
| 434 | 470 | private absolute(link: string): string { |
| 435 | 471 | return link.startsWith("/") && !link.startsWith("//") |
| @@ -492,6 +528,66 @@ function titleOf(line: string, repo: string): string { | ||
| 492 | 528 | return line; |
| 493 | 529 | } |
| 494 | 530 | |
| 531 | /** The map's JSON → `Plan`; rows without a number are dropped, not guessed. */ | |
| 532 | export function toPlan(raw: unknown): Plan { | |
| 533 | const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>; | |
| 534 | const list = (v: unknown): Record<string, unknown>[] => | |
| 535 | Array.isArray(v) | |
| 536 | ? v.filter((x): x is Record<string, unknown> => typeof x === "object" && x !== null) | |
| 537 | : []; | |
| 538 | const num = (v: unknown): number | undefined => | |
| 539 | typeof v === "number" && Number.isSafeInteger(v) ? v : undefined; | |
| 540 | const issues = new Map<number, Record<string, unknown>>(); | |
| 541 | for (const i of list(body["issues"])) { | |
| 542 | const n = num(i["number"]); | |
| 543 | if (n !== undefined && i["state"] !== "closed") issues.set(n, i); | |
| 544 | } | |
| 545 | const waitsOn = new Map<number, number[]>(); | |
| 546 | for (const e of list(body["edges"])) { | |
| 547 | const a = num(e["issue"]); | |
| 548 | const b = num(e["blockedBy"]); | |
| 549 | if (a === undefined || b === undefined || !issues.has(a) || !issues.has(b)) continue; | |
| 550 | const on = waitsOn.get(a) ?? []; | |
| 551 | if (!on.includes(b)) on.push(b); | |
| 552 | waitsOn.set(a, on); | |
| 553 | } | |
| 554 | // Every group reads its fields from `issues`: the ready rows agree with it. | |
| 555 | const issue = (n: number): PlanIssue => { | |
| 556 | const r = issues.get(n) ?? {}; | |
| 557 | return { | |
| 558 | number: n, | |
| 559 | title: str(r["title"]) ?? "", | |
| 560 | assignee: str(r["assignee"]) ?? null, | |
| 561 | pillar: str(r["pillar"]) ?? null, | |
| 562 | waitsOn: [...(waitsOn.get(n) ?? [])].sort((x, y) => x - y), | |
| 563 | }; | |
| 564 | }; | |
| 565 | const workingSet = new Set<number>(); | |
| 566 | const working: PlanIssue[] = []; | |
| 567 | for (const v of Array.isArray(body["working"]) ? body["working"] : []) { | |
| 568 | const n = num(v); | |
| 569 | // Only an open issue the map lists is being built; a closed one is done. | |
| 570 | if (n === undefined || workingSet.has(n) || !issues.has(n)) continue; | |
| 571 | workingSet.add(n); | |
| 572 | working.push(issue(n)); | |
| 573 | } | |
| 574 | const startable = Array.isArray(body["startable"]) | |
| 575 | ? new Set(body["startable"].map(num).filter((n) => n !== undefined)) | |
| 576 | : undefined; | |
| 577 | const ready: PlanIssue[] = []; | |
| 578 | for (const r of list(body["ready"])) { | |
| 579 | const n = num(r["number"]); | |
| 580 | if (n === undefined || workingSet.has(n)) continue; | |
| 581 | if (startable !== undefined ? !startable.has(n) : r["assignee"] != null) continue; | |
| 582 | ready.push(issue(n)); | |
| 583 | } | |
| 584 | const waiting = [...waitsOn.keys()] | |
| 585 | .filter((n) => !workingSet.has(n)) | |
| 586 | .sort((x, y) => x - y) | |
| 587 | .map((n) => issue(n)); | |
| 588 | return { raw, working, ready, waiting }; | |
| 589 | } | |
| 590 | ||
| 495 | 591 | function str(value: unknown): string | undefined { |
| 496 | 592 | return typeof value === "string" && value !== "" ? value : undefined; |
| 497 | 593 | } |
src/cli/commands/index.ts +2
| @@ -14,6 +14,7 @@ import { command as unpin } from "./unpin.ts"; | ||
| 14 | 14 | import { command as open } from "./open.ts"; |
| 15 | 15 | import { command as pulls } from "./pulls.ts"; |
| 16 | 16 | import { command as asks } from "./asks.ts"; |
| 17 | import { command as map } from "./map.ts"; | |
| 17 | 18 | import { command as search } from "./search.ts"; |
| 18 | 19 | import { command as ship } from "./ship.ts"; |
| 19 | 20 | import type { Command } from "./types.ts"; |
| @@ -31,6 +32,7 @@ export const commands: readonly Command[] = [ | ||
| 31 | 32 | open, |
| 32 | 33 | pulls, |
| 33 | 34 | asks, |
| 35 | map, | |
| 34 | 36 | search, |
| 35 | 37 | ship, |
| 36 | 38 | ]; |
src/cli/commands/map.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { parseRepoRef, type Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { folderRepo } from "../folder-repo.ts"; | |
| 6 | import { inert, type Out } from "../out.ts"; | |
| 7 | import { mapLines } from "../plan.ts"; | |
| 8 | import type { Command } from "./types.ts"; | |
| 9 | ||
| 10 | /** | |
| 11 | * `forge map`: where the repository's plan stands, in a screenful — what | |
| 12 | * the crew is building, what is ready, and what waits on what. | |
| 13 | */ | |
| 14 | async function map(out: Out, args: Args): Promise<number> { | |
| 15 | const loaded = await requireConfig(out); | |
| 16 | if (typeof loaded === "number") return loaded; | |
| 17 | const flagged = args.value("repo"); | |
| 18 | const ref = | |
| 19 | flagged !== undefined | |
| 20 | ? parseRepoRef(flagged) | |
| 21 | : await folderRepo(process.cwd(), loaded.url); | |
| 22 | if (ref === undefined) { | |
| 23 | out.err( | |
| 24 | flagged !== undefined | |
| 25 | ? "forge map: --repo expects owner/name" | |
| 26 | : `forge map: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`, | |
| 27 | ); | |
| 28 | return EXIT.usage; | |
| 29 | } | |
| 30 | const got = await new ForgeApi(loaded.url, loaded.token).map(ref.owner, ref.name); | |
| 31 | if (!got.ok) { | |
| 32 | out.err(`forge map: ${inert(got.error.message)}`); | |
| 33 | if (got.error.status === 401) return EXIT.notConfigured; | |
| 34 | if (got.error.status === 403) return EXIT.refused; | |
| 35 | return EXIT.failed; | |
| 36 | } | |
| 37 | if (args.flag("json")) { | |
| 38 | out.line(JSON.stringify(got.value.raw)); | |
| 39 | return EXIT.ok; | |
| 40 | } | |
| 41 | for (const line of mapLines(got.value, out.width, out)) out.line(line); | |
| 42 | return EXIT.ok; | |
| 43 | } | |
| 44 | ||
| 45 | export const command: Command = { | |
| 46 | name: "map", | |
| 47 | usage: { synopsis: "forge map", flags: "[--repo owner/name] [--json]" }, | |
| 48 | run: map, | |
| 49 | }; |
src/cli/main.ts +1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * forge search <words> code, issues, pulls and repos you can read |
| 11 | 11 | * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N |
| 12 | 12 | * forge asks --undo <id> take back what that decided |
| 13 | * forge map the plan: being built, ready now, what waits | |
| 13 | 14 | * forge ship <number> land a pull after a y/N, as its Merge button does |
| 14 | 15 | * |
| 15 | 16 | * Everything here is a thin shell around three modules: `api.ts` talks to the |
src/cli/plan.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * `forge map`'s screenful: the plan's three groups as lines for a terminal. | |
| 3 | * Plain data in (a `Plan` and a width), lines out; the command reads the | |
| 4 | * plan and prints, this module only lays it out. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Plan, PlanIssue } from "./api.ts"; | |
| 8 | import { inert, type Out } from "./out.ts"; | |
| 9 | import { displayWidth, pad, truncate } from "../tui/term/width.ts"; | |
| 10 | ||
| 11 | /** Lines a group prints before it says how many more it holds. */ | |
| 12 | export const MAP_GROUP_LINES = 15; | |
| 13 | ||
| 14 | /** | |
| 15 | * The plan as lines no wider than `width - 1`: three groups, each headed | |
| 16 | * with its count, at most `MAP_GROUP_LINES` rows each and `… N more` after. | |
| 17 | * Coloured only when `out` is given. | |
| 18 | */ | |
| 19 | export function mapLines( | |
| 20 | plan: Pick<Plan, "working" | "ready" | "waiting">, | |
| 21 | width: number, | |
| 22 | out?: Pick<Out, "paint" | "c">, | |
| 23 | ): string[] { | |
| 24 | const paint = (text: string, tone: "accent" | "faint"): string => | |
| 25 | out === undefined ? text : out.paint(text, out.c[tone]); | |
| 26 | const groups: [string, PlanIssue[], string, (i: PlanIssue) => string][] = [ | |
| 27 | ["Working", plan.working, "nothing being built", (i) => i.assignee ?? "the crew"], | |
| 28 | ["Ready", plan.ready, "nothing ready", (i) => i.pillar ?? "no pillar"], | |
| 29 | [ | |
| 30 | "Waiting", | |
| 31 | plan.waiting, | |
| 32 | "nothing waiting", | |
| 33 | (i) => `waits on ${i.waitsOn.map((n) => `#${n}`).join(", ")}`, | |
| 34 | ], | |
| 35 | ]; | |
| 36 | const lines: string[] = []; | |
| 37 | for (const [head, rows, none, tail] of groups) { | |
| 38 | if (lines.length > 0) lines.push(""); | |
| 39 | lines.push(paint(`${head} (${rows.length})`, "accent")); | |
| 40 | if (rows.length === 0) lines.push(paint(` ${none}`, "faint")); | |
| 41 | const shown = rows.slice(0, MAP_GROUP_LINES); | |
| 42 | const nW = Math.max(0, ...shown.map((i) => `#${i.number}`.length)); | |
| 43 | for (const i of shown) { | |
| 44 | const end = ` — ${inert(tail(i))}`; | |
| 45 | // Two of indent, the number, a space; the title gets what is left. | |
| 46 | const room = Math.max(8, width - 1 - 2 - nW - 1 - displayWidth(end)); | |
| 47 | lines.push( | |
| 48 | ` ${pad(`#${i.number}`, nW, "right")} ${truncate(inert(i.title), room)}${paint(end, "faint")}`, | |
| 49 | ); | |
| 50 | } | |
| 51 | if (rows.length > shown.length) | |
| 52 | lines.push(paint(` … ${rows.length - shown.length} more`, "faint")); | |
| 53 | } | |
| 54 | return lines; | |
| 55 | } |
test/cli-map.test.ts added +232
| @@ -0,0 +1,232 @@ | ||
| 1 | import { afterEach, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { toPlan } from "../src/cli/api.ts"; | |
| 6 | import { EXIT, main } from "../src/cli/main.ts"; | |
| 7 | import { MAP_GROUP_LINES, mapLines } from "../src/cli/plan.ts"; | |
| 8 | ||
| 9 | let server: ReturnType<typeof Bun.serve> | undefined; | |
| 10 | const env = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] }; | |
| 11 | afterEach(() => { | |
| 12 | server?.stop(true); | |
| 13 | for (const [k, v] of [["FORGE_URL", env.url], ["FORGE_TOKEN", env.token]] as const) | |
| 14 | if (v === undefined) delete process.env[k]; | |
| 15 | else process.env[k] = v; | |
| 16 | }); | |
| 17 | ||
| 18 | const seen: string[] = []; | |
| 19 | function fake(body: unknown): void { | |
| 20 | seen.length = 0; | |
| 21 | server = Bun.serve({ | |
| 22 | port: 0, | |
| 23 | fetch(req) { | |
| 24 | const url = new URL(req.url); | |
| 25 | seen.push(url.pathname); | |
| 26 | if (url.pathname === "/api/v1/repos/acme/app/map") return Response.json(body); | |
| 27 | if (url.pathname === "/api/v1/repos/acme/secret/map") | |
| 28 | return Response.json({ error: "forbidden", message: "you cannot read acme/secret" }, { status: 403 }); | |
| 29 | return Response.json({ error: "not_found", message: "no repository acme/gone" }, { status: 404 }); | |
| 30 | }, | |
| 31 | }); | |
| 32 | process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`; | |
| 33 | process.env["FORGE_TOKEN"] = "t"; | |
| 34 | } | |
| 35 | ||
| 36 | async function run(argv: string[], cwd?: string) { | |
| 37 | const lines: string[] = []; | |
| 38 | const errs: string[] = []; | |
| 39 | const write = process.stdout.write.bind(process.stdout); | |
| 40 | const werr = process.stderr.write.bind(process.stderr); | |
| 41 | const prev = process.cwd(); | |
| 42 | if (cwd) process.chdir(cwd); | |
| 43 | process.stdout.write = ((s: string) => (lines.push(String(s)), true)) as never; | |
| 44 | process.stderr.write = ((s: string) => (errs.push(String(s)), true)) as never; | |
| 45 | try { | |
| 46 | const code = await main(argv); | |
| 47 | return { code, out: lines.join("").split("\n"), err: errs.join("") }; | |
| 48 | } finally { | |
| 49 | process.stdout.write = write; | |
| 50 | process.stderr.write = werr; | |
| 51 | process.chdir(prev); | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | const issue = (number: number, title: string, extra: Record<string, unknown> = {}) => ({ | |
| 56 | number, | |
| 57 | title, | |
| 58 | state: "open", | |
| 59 | assignee: null, | |
| 60 | pillar: null, | |
| 61 | ...extra, | |
| 62 | }); | |
| 63 | ||
| 64 | /** | |
| 65 | * #1 is built by scout; #2 is ready under Reach; #3 is ready but taken, so | |
| 66 | * not startable; #4 waits on #1 and #2; #5 waits on #4; #6 is closed and | |
| 67 | * its edge to #5 does not count. | |
| 68 | */ | |
| 69 | const map = { | |
| 70 | repo: { owner: "acme", name: "app" }, | |
| 71 | ready: [ | |
| 72 | { ...issue(1, "Build the gauge", { assignee: "scout", pillar: "Reach" }), holdsUp: 1 }, | |
| 73 | { ...issue(2, "Draw the dial", { pillar: "Reach" }), holdsUp: 1 }, | |
| 74 | { ...issue(3, "Taken already", { assignee: "mason" }), holdsUp: 0 }, | |
| 75 | { ...issue(7, "No pillar here"), holdsUp: 0 }, | |
| 76 | ], | |
| 77 | startable: [2, 7], | |
| 78 | working: [1], | |
| 79 | issues: [ | |
| 80 | issue(1, "Build the gauge", { assignee: "scout", pillar: "Reach" }), | |
| 81 | issue(2, "Draw the dial", { pillar: "Reach" }), | |
| 82 | issue(3, "Taken already", { assignee: "mason" }), | |
| 83 | issue(4, "Wire the dial to the gauge"), | |
| 84 | issue(5, "Ship it"), | |
| 85 | { ...issue(6, "Old"), state: "closed" }, | |
| 86 | issue(7, "No pillar here"), | |
| 87 | ], | |
| 88 | edges: [ | |
| 89 | { issue: 4, blockedBy: 2 }, | |
| 90 | { issue: 4, blockedBy: 1 }, | |
| 91 | { issue: 5, blockedBy: 4 }, | |
| 92 | { issue: 5, blockedBy: 6 }, | |
| 93 | ], | |
| 94 | }; | |
| 95 | ||
| 96 | test("prints Working, Ready and Waiting, each headed with its count", async () => { | |
| 97 | fake(map); | |
| 98 | const r = await run(["map", "--repo", "acme/app"]); | |
| 99 | expect(r.code).toBe(EXIT.ok); | |
| 100 | expect(seen).toEqual(["/api/v1/repos/acme/app/map"]); | |
| 101 | expect(r.out.filter(Boolean)).toEqual([ | |
| 102 | "Working (1)", | |
| 103 | " #1 Build the gauge — scout", | |
| 104 | "Ready (2)", | |
| 105 | " #2 Draw the dial — Reach", | |
| 106 | " #7 No pillar here — no pillar", | |
| 107 | "Waiting (2)", | |
| 108 | " #4 Wire the dial to the gauge — waits on #1, #2", | |
| 109 | " #5 Ship it — waits on #4", | |
| 110 | ]); | |
| 111 | }); | |
| 112 | ||
| 113 | test("each group stops at 15 lines and says how many more", () => { | |
| 114 | const many = Array.from({ length: 20 }, (_, i) => ({ | |
| 115 | number: 100 + i, | |
| 116 | title: `Issue ${i}`, | |
| 117 | assignee: null, | |
| 118 | pillar: null, | |
| 119 | waitsOn: [1], | |
| 120 | })); | |
| 121 | const lines = mapLines({ working: [], ready: many, waiting: many.slice(0, 15) }, 80); | |
| 122 | const ready = lines.slice(lines.indexOf("Ready (20)") + 1, lines.indexOf("Waiting (15)") - 1); | |
| 123 | expect(ready).toHaveLength(MAP_GROUP_LINES + 1); | |
| 124 | expect(MAP_GROUP_LINES).toBe(15); | |
| 125 | expect(ready.at(-1)).toBe(" … 5 more"); | |
| 126 | expect(ready[14]).toBe(" #114 Issue 14 — no pillar"); | |
| 127 | // Exactly fifteen: no "more" line. | |
| 128 | expect(lines.slice(lines.indexOf("Waiting (15)") + 1)).toHaveLength(15); | |
| 129 | expect(lines.slice(0, 4)).toEqual(["Working (0)", " nothing being built", "", "Ready (20)"]); | |
| 130 | }); | |
| 131 | ||
| 132 | test("titles are cut so no line is wider than the terminal", () => { | |
| 133 | const long = { number: 9, title: "x".repeat(200), assignee: "scout", pillar: null, waitsOn: [] }; | |
| 134 | const [, line] = mapLines({ working: [long], ready: [], waiting: [] }, 40); | |
| 135 | expect(line!.length).toBeLessThanOrEqual(39); | |
| 136 | expect(line).toEndWith("… — scout"); | |
| 137 | }); | |
| 138 | ||
| 139 | test("--json prints the answer unchanged", async () => { | |
| 140 | fake(map); | |
| 141 | const r = await run(["map", "--repo", "acme/app", "--json"]); | |
| 142 | expect(r.code).toBe(EXIT.ok); | |
| 143 | expect(JSON.parse(r.out[0]!)).toEqual(map); | |
| 144 | }); | |
| 145 | ||
| 146 | test("an unknown repository prints the API's words and the matching exit", async () => { | |
| 147 | fake(map); | |
| 148 | const gone = await run(["map", "--repo", "acme/gone"]); | |
| 149 | expect(gone.code).toBe(EXIT.failed); | |
| 150 | expect(gone.err).toContain("no repository acme/gone"); | |
| 151 | const secret = await run(["map", "--repo", "acme/secret"]); | |
| 152 | expect(secret.code).toBe(EXIT.refused); | |
| 153 | expect(secret.err).toContain("you cannot read acme/secret"); | |
| 154 | }); | |
| 155 | ||
| 156 | test("a bad --repo, or a folder that is no clone, exits 2 before any request", async () => { | |
| 157 | fake(map); | |
| 158 | expect((await run(["map", "--repo", "nope"])).code).toBe(EXIT.usage); | |
| 159 | const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "map-")); | |
| 160 | expect((await run(["map"], dir)).code).toBe(EXIT.usage); | |
| 161 | expect(seen).toEqual([]); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a map with no startable list falls back to the unassigned ready", () => { | |
| 165 | const { startable: _, ...older } = map; | |
| 166 | expect(toPlan(older).ready.map((i) => i.number)).toEqual([2, 7]); | |
| 167 | expect(toPlan(null)).toEqual({ raw: null, working: [], ready: [], waiting: [] }); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a blocker that is closed, or only in edges, holds nothing; Working wins over Waiting", () => { | |
| 171 | const plan = toPlan({ | |
| 172 | issues: [ | |
| 173 | { number: 1, title: "Build it", state: "open", assignee: "torque" }, | |
| 174 | { number: 2, title: "Old blocker", state: "closed" }, | |
| 175 | { number: 3, title: "Waits on a gone one", state: "open" }, | |
| 176 | { number: 4, title: "Waits for real", state: "open", assignee: "torque" }, | |
| 177 | { number: 5, title: "Still open", state: "open" }, | |
| 178 | ], | |
| 179 | edges: [ | |
| 180 | { issue: 1, blockedBy: 2 }, | |
| 181 | { issue: 3, blockedBy: 99 }, | |
| 182 | { issue: 4, blockedBy: 5 }, | |
| 183 | ], | |
| 184 | working: [1, 4], | |
| 185 | ready: [], | |
| 186 | startable: [], | |
| 187 | }); | |
| 188 | expect(plan.working.map((i) => [i.number, i.waitsOn])).toEqual([[1, []], [4, [5]]]); | |
| 189 | expect(plan.waiting).toEqual([]); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a title, assignee or pillar with terminal escapes prints inert", () => { | |
| 193 | const esc = "\x1b]0;owned\x07\x1b[2J"; | |
| 194 | const row = { number: 1, title: `Fix ${esc}it`, assignee: `eve${esc}`, pillar: `P${esc}`, waitsOn: [2] }; | |
| 195 | const lines = mapLines({ working: [row], ready: [row], waiting: [row] }, 200); | |
| 196 | const text = lines.join("\n"); | |
| 197 | expect(text).not.toMatch(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/); | |
| 198 | expect(text).toContain("Fix �]0;owned��[2Jit"); | |
| 199 | expect(text).toContain("— eve�"); | |
| 200 | expect(text).toContain("— P�"); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a closed issue, or one the map does not list, is not Working", () => { | |
| 204 | const plan = toPlan({ | |
| 205 | issues: [ | |
| 206 | { number: 1, title: "Built", state: "closed", assignee: "torque" }, | |
| 207 | { number: 2, title: "Building", state: "open", assignee: "torque" }, | |
| 208 | ], | |
| 209 | edges: [], | |
| 210 | working: [1, 2, 3], | |
| 211 | ready: [], | |
| 212 | startable: [], | |
| 213 | }); | |
| 214 | expect(plan.working.map((i) => i.number)).toEqual([2]); | |
| 215 | }); | |
| 216 | ||
| 217 | test("an empty group says so under its heading", () => { | |
| 218 | expect(mapLines({ working: [], ready: [], waiting: [] }, 80)).toEqual([ | |
| 219 | "Working (0)", | |
| 220 | " nothing being built", | |
| 221 | "", | |
| 222 | "Ready (0)", | |
| 223 | " nothing ready", | |
| 224 | "", | |
| 225 | "Waiting (0)", | |
| 226 | " nothing waiting", | |
| 227 | ]); | |
| 228 | }); | |
| 229 | ||
| 230 | test("help lists map", async () => { | |
| 231 | expect((await run(["help"])).out.join("\n")).toContain("forge map"); | |
| 232 | }); |
new above
offline