Public: anyone can read this; the crew can change it.
`forge asks --ship/--close/--dismiss <n…>` decides asks from the shell: planned first, applied after a y/N, as POST /api/v1/asks/bulk #7456
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7374-forge-asks-ship-close-dismiss-n-decides → main 3 commits fork 4 threads · 4 unresolved
6 files
changed +512
−12
README.md +3
| @@ -319,6 +319,9 @@ forge ship 12 # land pull #12 after a y/N, as Merge does | ||
| 319 | 319 | forge pulls # open pulls of this clone, newest first |
| 320 | 320 | forge asks # what waits on you, most important first |
| 321 | 321 | forge asks --repo owner/name # only that repository's; --json for the API's answer |
| 322 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] | |
| 323 | forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY) | |
| 324 | forge asks --undo <id> # take back a decision by the receipt id it printed | |
| 322 | 325 | |
| 323 | 326 | |
| 324 | 327 | `forge ship <number>` reads the repository from `--repo owner/name` or from |
src/cli/api.ts +77
| @@ -52,6 +52,8 @@ const PULL_PAGES = 20; | ||
| 52 | 52 | |
| 53 | 53 | /** One thing waiting on the user, as `forge asks` prints it. */ |
| 54 | 54 | export interface Ask { |
| 55 | /** The API's id for a row, to decide it by; absent on the forge's own needs. */ | |
| 56 | readonly id?: string; | |
| 55 | 57 | /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */ |
| 56 | 58 | readonly kind: string; |
| 57 | 59 | /** `owner/name`, or "" for a need of the forge itself. */ |
| @@ -68,6 +70,25 @@ export interface Asks { | ||
| 68 | 70 | readonly asks: Ask[]; |
| 69 | 71 | } |
| 70 | 72 | |
| 73 | export interface BulkRow { | |
| 74 | readonly label: string; | |
| 75 | /** `change`, `unchanged` or `refused` in a plan; what happened in a receipt. */ | |
| 76 | readonly outcome: string; | |
| 77 | readonly reason: string; | |
| 78 | readonly before: string; | |
| 79 | readonly after: string; | |
| 80 | } | |
| 81 | ||
| 82 | export interface Bulk { | |
| 83 | /** The operation's id, on a receipt only. */ | |
| 84 | readonly id: string | undefined; | |
| 85 | readonly rows: BulkRow[]; | |
| 86 | readonly skipped: { item: string; reason: string }[]; | |
| 87 | } | |
| 88 | ||
| 89 | const show = (value: unknown): string => | |
| 90 | value === null || value === undefined ? "" : String(value); | |
| 91 | ||
| 71 | 92 | export class ForgeApi { |
| 72 | 93 | /** True once `pins()` has had to fall back, so the CLI can say it once. */ |
| 73 | 94 | pinsUnavailable = false; |
| @@ -315,7 +336,9 @@ export class ForgeApi { | ||
| 315 | 336 | const kind = str(r["kind"]) ?? "ask"; |
| 316 | 337 | const repo = str(r["repo"]) ?? ""; |
| 317 | 338 | const proposal = r["proposal"] as Record<string, unknown> | undefined; |
| 339 | const id = str(r["id"]); | |
| 318 | 340 | asks.push({ |
| 341 | ...(id === undefined ? {} : { id }), | |
| 319 | 342 | kind, |
| 320 | 343 | repo, |
| 321 | 344 | title: titleOf( |
| @@ -333,6 +356,60 @@ export class ForgeApi { | ||
| 333 | 356 | return ok({ raw: out.value, asks }); |
| 334 | 357 | } |
| 335 | 358 | |
| 359 | /** | |
| 360 | * One action on many asks (`POST /api/v1/asks/bulk`): the plan while | |
| 361 | * `preview` is true, the deed and its receipt when false. | |
| 362 | */ | |
| 363 | async bulk( | |
| 364 | action: string, | |
| 365 | items: readonly string[], | |
| 366 | reason: string | undefined, | |
| 367 | preview: boolean, | |
| 368 | ): Promise<ApiResult<Bulk>> { | |
| 369 | const out = await this.call<Record<string, unknown>>("/api/v1/asks/bulk", { | |
| 370 | method: "POST", | |
| 371 | body: JSON.stringify({ | |
| 372 | action, | |
| 373 | items, | |
| 374 | ...(reason === undefined ? {} : { reason }), | |
| 375 | preview, | |
| 376 | }), | |
| 377 | }); | |
| 378 | if (!out.ok) return out; | |
| 379 | return ok(this.toBulk(out.value)); | |
| 380 | } | |
| 381 | ||
| 382 | private toBulk(value: Record<string, unknown>): Bulk { | |
| 383 | const out = { value }; | |
| 384 | const rows: BulkRow[] = []; | |
| 385 | for (const item of Array.isArray(out.value["rows"]) ? out.value["rows"] : []) { | |
| 386 | const r = (item ?? {}) as Record<string, unknown>; | |
| 387 | rows.push({ | |
| 388 | label: str(r["label"]) ?? "", | |
| 389 | outcome: str(r["outcome"]) ?? "", | |
| 390 | reason: str(r["reason"]) ?? "", | |
| 391 | before: show(r["before"]), | |
| 392 | after: show(r["after"]), | |
| 393 | }); | |
| 394 | } | |
| 395 | const skipped: { item: string; reason: string }[] = []; | |
| 396 | for (const item of Array.isArray(out.value["skipped"]) ? out.value["skipped"] : []) { | |
| 397 | const r = (item ?? {}) as Record<string, unknown>; | |
| 398 | skipped.push({ item: str(r["item"]) ?? "", reason: str(r["reason"]) ?? "" }); | |
| 399 | } | |
| 400 | return { id: str(out.value["id"]), rows, skipped }; | |
| 401 | } | |
| 402 | ||
| 403 | /** Reverse a bulk receipt (`POST /api/v1/asks/bulk/:id/undo`). */ | |
| 404 | async undo(id: string): Promise<ApiResult<Bulk>> { | |
| 405 | const out = await this.call<Record<string, unknown>>( | |
| 406 | `/api/v1/asks/bulk/${encode(id)}/undo`, | |
| 407 | { method: "POST" }, | |
| 408 | ); | |
| 409 | if (!out.ok) return out; | |
| 410 | return ok(this.toBulk(out.value)); | |
| 411 | } | |
| 412 | ||
| 336 | 413 | /** A workbench path as a full address; anything not a path is the home. */ |
| 337 | 414 | private absolute(link: string): string { |
| 338 | 415 | return link.startsWith("/") && !link.startsWith("//") |
src/cli/decide.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * `forge asks --ship 1 3 --close 2 --reason "words" --dismiss 4`: the part of | |
| 3 | * the command line that names a decision. The numbers are the ones `forge | |
| 4 | * asks` printed; this module only reads them and maps them to ask ids from | |
| 5 | * the list it is handed — the caller reads that list fresh, never from a cache. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Ask } from "./api.ts"; | |
| 9 | ||
| 10 | export const DECISIONS = ["ship", "close", "dismiss"] as const; | |
| 11 | export type Decision = (typeof DECISIONS)[number]; | |
| 12 | ||
| 13 | /** The most asks one decision takes: the API's own limit. */ | |
| 14 | export const MOST = 100; | |
| 15 | ||
| 16 | export interface Wish { | |
| 17 | readonly action: Decision | "undo"; | |
| 18 | /** The numbers as typed, in order, duplicates dropped; the receipt id for `undo`. */ | |
| 19 | readonly numbers: readonly string[]; | |
| 20 | readonly reason: string | undefined; | |
| 21 | } | |
| 22 | ||
| 23 | /** Flags of `forge asks` that take the next token as their value. */ | |
| 24 | const TAKES_VALUE = ["repo", "reason", "undo"]; | |
| 25 | ||
| 26 | /** | |
| 27 | * `Wish` for no decision at all (plain `forge asks`), or the words for a bad | |
| 28 | * line. `argv` is what follows `asks`. Numbers belong to the decision flag | |
| 29 | * they follow, up to the next flag; any other loose word is refused, never | |
| 30 | * silently dropped. | |
| 31 | */ | |
| 32 | export function parseWish(argv: readonly string[]): Wish | undefined | string { | |
| 33 | let action: Decision | undefined; | |
| 34 | const numbers: string[] = []; | |
| 35 | let reason: string | undefined; | |
| 36 | let undo: string | undefined; | |
| 37 | let into = false; | |
| 38 | for (let i = 0; i < argv.length; i++) { | |
| 39 | const arg = argv[i]!; | |
| 40 | if (!arg.startsWith("--")) { | |
| 41 | if (arg.startsWith("-") && arg !== "-") { | |
| 42 | into = false; | |
| 43 | continue; | |
| 44 | } | |
| 45 | if (!into) return `unexpected "${arg}" — numbers follow --ship, --close or --dismiss`; | |
| 46 | if (!numbers.includes(arg)) numbers.push(arg); | |
| 47 | continue; | |
| 48 | } | |
| 49 | const eq = arg.indexOf("="); | |
| 50 | const name = eq < 0 ? arg.slice(2) : arg.slice(2, eq); | |
| 51 | into = false; | |
| 52 | if (TAKES_VALUE.includes(name)) { | |
| 53 | let value: string | undefined; | |
outdated
No upper bound on numbers: the API caps at 100 items. Refuse early with a clear message and test it. |
||
| 54 | if (eq >= 0) value = arg.slice(eq + 1); | |
| 55 | else if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("-")) | |
| 56 | value = argv[++i]; | |
| 57 | if (name === "reason") { | |
| 58 | if (value === undefined || value.trim() === "") | |
| 59 | return "--reason needs words"; | |
| 60 | reason = value.trim(); | |
| 61 | } else if (name === "undo") { | |
| 62 | if (value === undefined || value.trim() === "") | |
| 63 | return "--undo needs the receipt id"; | |
| 64 | undo = value.trim(); | |
| 65 | } | |
| 66 | continue; | |
| 67 | } | |
| 68 | if ((DECISIONS as readonly string[]).includes(name)) { | |
| 69 | if (action !== undefined && action !== name) | |
| 70 | return `one decision at a time: --${action} or --${name}`; | |
| 71 | action = name as Decision; | |
| 72 | into = true; | |
| 73 | } | |
| 74 | } | |
| 75 | if (undo !== undefined) { | |
| 76 | if (action !== undefined || reason !== undefined) | |
| 77 | return "--undo stands alone"; | |
| 78 | return { action: "undo", numbers: [undo], reason: undefined }; | |
| 79 | } | |
| 80 | if (action === undefined) { | |
| 81 | return reason === undefined ? undefined : "--reason goes with --close"; | |
| 82 | } | |
| 83 | if (numbers.length === 0) return `--${action} needs the numbers of the asks`; | |
| 84 | if (numbers.length > MOST) | |
| 85 | return `at most ${MOST} asks at once, not ${numbers.length}`; | |
| 86 | if (reason !== undefined && action !== "close") | |
| 87 | return "--reason goes with --close"; | |
| 88 | return { action, numbers, reason }; | |
| 89 | } | |
| 90 | ||
| 91 | /** The ask ids the numbers name, or the words that refuse them. */ | |
| 92 | export function pickAsks( | |
| 93 | numbers: readonly string[], | |
| 94 | rows: readonly Ask[], | |
| 95 | ): string[] | { refused: string } { | |
| 96 | const ids: string[] = []; | |
| 97 | for (const n of numbers) { | |
| 98 | const at = /^[1-9]\d{0,5}$/.test(n) ? Number(n) : 0; | |
| 99 | const ask = rows[at - 1]; | |
| 100 | if (ask === undefined) | |
| 101 | return { | |
| 102 | refused: `no ask is numbered ${JSON.stringify(n).slice(1, -1)}${rows.length === 0 ? "" : ` (the list runs 1 to ${rows.length})`}`, | |
| 103 | }; | |
| 104 | if (ask.id === undefined) | |
| 105 | return { refused: `${at} is the forge's own need, not an ask to decide here` }; | |
| 106 | if (!ids.includes(ask.id)) ids.push(ask.id); | |
| 107 | } | |
| 108 | return ids; | |
| 109 | } |
src/cli/main.ts +123 −7
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | * forge sync clone what is missing, fetch the rest, report |
| 8 | 8 | * forge status the same report without touching the network |
| 9 | 9 | * forge asks what waits on you, most important first |
| 10 | * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N | |
| 11 | * forge asks --undo <id> take back what that decided | |
| 10 | 12 | * forge ship <number> land a pull after a y/N, as its Merge button does |
| 11 | 13 | * |
| 12 | 14 | * Everything here is a thin shell around three modules: `api.ts` talks to the |
| @@ -16,7 +18,8 @@ | ||
| 16 | 18 | */ |
| 17 | 19 | |
| 18 | 20 | import { parseArgs, parseDuration, parseRepoRef, type Args } from "./args.ts"; |
| 19 | import { ForgeApi, type ApiError, type Ask } from "./api.ts"; | |
| 21 | import { ForgeApi, type ApiError, type Ask, type Bulk } from "./api.ts"; | |
| 22 | import { parseWish, pickAsks, type Wish } from "./decide.ts"; | |
| 20 | 23 | import { |
| 21 | 24 | clearConfig, |
| 22 | 25 | configPath, |
| @@ -60,7 +63,7 @@ export const EXIT = { | ||
| 60 | 63 | refused: 5, |
| 61 | 64 | } as const; |
| 62 | 65 | |
| 63 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"]; | |
| 66 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "reason"]; | |
| 64 | 67 | |
| 65 | 68 | /** What the CLI needs from the person at the keyboard; a test fakes it. */ |
| 66 | 69 | export interface Io { |
| @@ -116,7 +119,7 @@ export async function main( | ||
| 116 | 119 | case "open": |
| 117 | 120 | return openRepo(out, args); |
| 118 | 121 | case "asks": |
| 119 | return asks(out, args); | |
| 122 | return asks(out, args, argv.slice(1)); | |
| 120 | 123 | case "ship": |
| 121 | 124 | return ship(out, args, io); |
| 122 | 125 | case "ui": |
| @@ -151,6 +154,10 @@ function usage(out: Out): void { | ||
| 151 | 154 | out.line( |
| 152 | 155 | b(" forge asks") + " [--repo owner/name] [--json]", |
| 153 | 156 | ); |
| 157 | out.line( | |
| 158 | b(" forge asks --ship|--close|--dismiss <n…>") + | |
| 159 | " [--reason words] [--yes]", | |
| 160 | ); | |
| 154 | 161 | out.line(b(" forge ship <number>") + " [--repo owner/name] [--yes]"); |
| 155 | 162 | out.line(); |
| 156 | 163 | out.line( |
| @@ -516,7 +523,17 @@ async function pulls(out: Out, args: Args): Promise<number> { | ||
| 516 | 523 | * What waits on you, one line each, in the order the forge ranks it: kind, |
| 517 | 524 | * repository, title cut to the terminal, and the address to decide it at. |
| 518 | 525 | */ |
| 519 | async function asks(out: Out, args: Args): Promise<number> { | |
| 526 | export async function asks( | |
| 527 | out: Out, | |
| 528 | args: Args, | |
| 529 | argv: readonly string[] = [], | |
| 530 | confirm: Confirm = confirmOnTty, | |
| 531 | ): Promise<number> { | |
| 532 | const wish = parseWish(argv); | |
| 533 | if (typeof wish === "string") { | |
| 534 | out.err(`forge asks: ${wish}`); | |
| 535 | return EXIT.usage; | |
outdated
The undo is printed as prose, not something runnable. Add |
||
| 536 | } | |
| 520 | 537 | const only = args.value("repo"); |
| 521 | 538 | const ref = only === undefined ? undefined : parseRepoRef(only); |
| 522 | 539 | if (only !== undefined && ref === undefined) { |
| @@ -525,12 +542,21 @@ async function asks(out: Out, args: Args): Promise<number> { | ||
| 525 | 542 | } |
| 526 | 543 | const loaded = await requireConfig(out); |
| 527 | 544 | if (typeof loaded === "number") return loaded; |
| 528 | const answer = await new ForgeApi(loaded.url, loaded.token).asks(); | |
| 545 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 546 | const answer = await api.asks(); | |
| 529 | 547 | if (!answer.ok) { |
| 530 | 548 | out.err(`forge asks: ${answer.error.message}`); |
| 531 | 549 | return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed; |
| 532 | 550 | } |
| 533 | 551 | const repo = ref === undefined ? undefined : `${ref.owner}/${ref.name}`; |
| 552 | if (wish !== undefined && wish.action === "undo") | |
| 553 | return decide(out, api, wish, [], true, confirm); | |
| 554 | if (wish !== undefined) { | |
| 555 | const rows = answer.value.asks.filter( | |
| 556 | (a) => repo === undefined || a.repo === repo, | |
| 557 | ); | |
| 558 | return decide(out, api, wish, rows, args.flag("yes"), confirm); | |
| 559 | } | |
| 534 | 560 | if (args.flag("json")) { |
| 535 | 561 | const raw = answer.value.raw; |
| 536 | 562 | out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo))); |
| @@ -543,10 +569,100 @@ async function asks(out: Out, args: Args): Promise<number> { | ||
| 543 | 569 | out.line("Nothing waits on you."); |
| 544 | 570 | return EXIT.ok; |
| 545 | 571 | } |
| 546 | for (const line of askLines(rows, out.width, out)) out.line(line); | |
| 572 | const numberW = String(rows.length).length; | |
| 573 | const lines = askLines(rows, out.width - numberW - 2, out); | |
| 574 | lines.forEach((line, i) => | |
| 575 | out.line(`${String(i + 1).padStart(numberW)} ${line}`), | |
| 576 | ); | |
| 547 | 577 | return EXIT.ok; |
| 548 | 578 | } |
| 549 | 579 | |
| 580 | /** Whether to go ahead: undefined when there is no one to ask. */ | |
| 581 | export type Confirm = (question: string) => Promise<boolean | undefined>; | |
| 582 | ||
| 583 | const confirmOnTty: Confirm = async (question) => { | |
| 584 | if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) | |
| 585 | return undefined; | |
| 586 | process.stdout.write(question); | |
| 587 | const line = await new Promise<string>((resolve) => { | |
| 588 | const stdin = process.stdin; | |
| 589 | stdin.setEncoding("utf8"); | |
| 590 | stdin.once("data", (chunk) => { | |
| 591 | stdin.pause(); | |
| 592 | resolve(String(chunk)); | |
| 593 | }); | |
| 594 | stdin.resume(); | |
| 595 | }); | |
| 596 | return /^\s*y(es)?\s*$/i.test(line); | |
| 597 | }; | |
| 598 | ||
| 599 | /** | |
| 600 | * Decide asks by the numbers of the list just read: the plan first, then, | |
| 601 | * after a yes (or `--yes`), the deed and the line that undoes it. | |
| 602 | */ | |
| 603 | async function decide( | |
| 604 | out: Out, | |
| 605 | api: ForgeApi, | |
| 606 | wish: Wish, | |
| 607 | rows: readonly Ask[], | |
| 608 | yes: boolean, | |
| 609 | confirm: Confirm, | |
| 610 | ): Promise<number> { | |
| 611 | const fail = (e: { message: string; status: number }): number => { | |
| 612 | out.err(`forge asks: ${e.message}`); | |
| 613 | return e.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 614 | }; | |
| 615 | if (wish.action === "undo") { | |
| 616 | const back = await api.undo(wish.numbers[0]!); | |
| 617 | if (!back.ok) return fail(back.error); | |
| 618 | printBulk(out, back.value, "undo", true); | |
| 619 | return EXIT.ok; | |
| 620 | } | |
| 621 | const ids = pickAsks(wish.numbers, rows); | |
| 622 | if (!Array.isArray(ids)) { | |
| 623 | out.err(`forge asks: ${ids.refused}`); | |
| 624 | return EXIT.usage; | |
| 625 | } | |
| 626 | const plan = await api.bulk(wish.action, ids, wish.reason, true); | |
| 627 | if (!plan.ok) return fail(plan.error); | |
| 628 | printBulk(out, plan.value, wish.action, false); | |
| 629 | const changes = plan.value.rows.filter((r) => r.outcome === "change").length; | |
| 630 | if (changes === 0) { | |
| 631 | out.line("Nothing would change."); | |
| 632 | return EXIT.ok; | |
| 633 | } | |
| 634 | if (!yes) { | |
| 635 | const said = await confirm("Apply? [y/N] "); | |
| 636 | if (said === undefined) { | |
| 637 | out.err("forge asks: not applied — no terminal to ask on; add --yes"); | |
| 638 | return EXIT.usage; | |
| 639 | } | |
| 640 | if (!said) { | |
| 641 | out.line("Nothing applied."); | |
| 642 | return EXIT.ok; | |
| 643 | } | |
| 644 | } | |
| 645 | const done = await api.bulk(wish.action, ids, wish.reason, false); | |
| 646 | if (!done.ok) return fail(done.error); | |
| 647 | printBulk(out, done.value, wish.action, true); | |
| 648 | if (done.value.id !== undefined) { | |
| 649 | out.line(`Receipt ${inert(done.value.id)}`); | |
| 650 | out.line(`Undo: forge asks --undo ${inert(done.value.id)}`); | |
| 651 | } | |
| 652 | return EXIT.ok; | |
| 653 | } | |
| 654 | ||
| 655 | function printBulk(out: Out, bulk: Bulk, action: string, done: boolean): void { | |
| 656 | out.line(done ? `Applied: ${action}` : `Would ${action}:`); | |
| 657 | for (const r of bulk.rows) { | |
| 658 | const move = r.before === r.after ? "" : ` ${r.before} → ${r.after}`; | |
| 659 | const why = r.reason === "" ? "" : ` (${r.reason})`; | |
| 660 | out.line(` ${pad(inert(r.outcome), 9)} ${inert(r.label)}${inert(move)}${inert(why)}`); | |
| 661 | } | |
| 662 | for (const s of bulk.skipped) | |
| 663 | out.line(` skipped ${inert(s.item)} (${inert(s.reason)})`); | |
| 664 | } | |
| 665 | ||
| 550 | 666 | /** |
| 551 | 667 | * The asks as lines no wider than `width - 1`, so none wraps on its own: |
| 552 | 668 | * kind and repository padded to their widest, the address whole, and the |
| @@ -563,7 +679,7 @@ export function askLines( | ||
| 563 | 679 | const url = inert(a.url); |
| 564 | 680 | // Three two-space gaps; the title gets what the other columns leave. |
| 565 | 681 | const room = Math.max( |
| 566 | 8, | |
| 682 | 1, | |
| 567 | 683 | width - 1 - kindW - repoW - displayWidth(url) - 6, |
| 568 | 684 | ); |
| 569 | 685 | const kind = pad(inert(a.kind), kindW); |
test/cli-asks-decide.test.ts added +195
| @@ -0,0 +1,195 @@ | ||
| 1 | /** | |
| 2 | * `forge asks --ship/--close/--dismiss <n…>` against a fake API: the numbers | |
| 3 | * map to the ids of the read just made, nothing is applied without a yes, | |
| 4 | * and a refusal prints the API's words with the right exit code. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; | |
| 8 | import { createOut } from "../src/cli/out.ts"; | |
| 9 | import { parseArgs } from "../src/cli/args.ts"; | |
| 10 | import { asks, EXIT, type Confirm } from "../src/cli/main.ts"; | |
| 11 | import { parseWish } from "../src/cli/decide.ts"; | |
| 12 | ||
| 13 | let ids = ["look:1", "parked:2", "pr:3"]; | |
| 14 | let server: ReturnType<typeof Bun.serve>; | |
| 15 | let base = ""; | |
| 16 | let bulkReply: (body: any) => Response; | |
| 17 | const posts: any[] = []; | |
| 18 | const undos: string[] = []; | |
| 19 | let reads = 0; | |
| 20 | ||
| 21 | const plan = (body: any) => | |
| 22 | Response.json({ | |
| 23 | preview: true, | |
| 24 | rows: body.items.map((i: string) => ({ | |
| 25 | label: `alice/shop#${i}`, | |
| 26 | outcome: "change", | |
| 27 | reason: "", | |
| 28 | before: "open", | |
| 29 | after: "closed", | |
| 30 | })), | |
| 31 | skipped: [], | |
| 32 | }); | |
| 33 | ||
| 34 | beforeAll(() => { | |
| 35 | server = Bun.serve({ | |
| 36 | port: 0, | |
| 37 | async fetch(req) { | |
| 38 | const url = new URL(req.url); | |
| 39 | if (url.pathname === "/api/v1/asks") { | |
| 40 | reads++; | |
| 41 | return Response.json({ | |
| 42 | rows: ids.map((id) => ({ id, kind: "look", repo: "alice/shop", line: `row ${id}`, link: "/x" })), | |
| 43 | forge: ["The check lane is stuck."], | |
| 44 | }); | |
| 45 | } | |
| 46 | if (url.pathname === "/api/v1/asks/bulk") { | |
| 47 | const body = await req.json(); | |
| 48 | posts.push(body); | |
| 49 | return bulkReply(body); | |
| 50 | } | |
| 51 | const undo = /^\/api\/v1\/asks\/bulk\/([^/]+)\/undo$/.exec(url.pathname); | |
| 52 | if (undo && req.method === "POST") { | |
| 53 | undos.push(undo[1]!); | |
| 54 | return undo[1] === "gone" | |
| 55 | ? Response.json({ error: "not_found", message: "no such operation" }, { status: 404 }) | |
| 56 | : Response.json({ id: "u1", rows: [{ label: "alice/shop#1", outcome: "ok", before: "closed", after: "open" }], skipped: [] }, { status: 201 }); | |
| 57 | } | |
| 58 | return Response.json({ error: "not_found", message: "no" }, { status: 404 }); | |
| 59 | }, | |
| 60 | }); | |
| 61 | base = `http://127.0.0.1:${server.port}`; | |
| 62 | process.env["FORGE_URL"] = base; | |
| 63 | process.env["FORGE_TOKEN"] = "tok"; | |
| 64 | process.env["FORGE_KEYCHAIN"] = "0"; | |
| 65 | process.env["NO_COLOR"] = "1"; | |
| 66 | }); | |
| 67 | afterAll(() => server.stop(true)); | |
| 68 | beforeEach(() => { | |
| 69 | posts.length = 0; | |
| 70 | undos.length = 0; | |
| 71 | reads = 0; | |
| 72 | ids = ["look:1", "parked:2", "pr:3"]; | |
| 73 | bulkReply = (body) => | |
| 74 | body.preview === false | |
| 75 | ? Response.json({ preview: false, id: "op9", rows: body.items.map((i: string) => ({ label: i, outcome: "ok", before: "open", after: "closed" })), skipped: [] }, { status: 201 }) | |
| 76 | : plan(body); | |
| 77 | }); | |
| 78 | ||
| 79 | async function run(argv: string[], confirm: Confirm) { | |
| 80 | let out = ""; | |
| 81 | let err = ""; | |
| 82 | const o = createOut((t) => (out += t), (t) => (err += t)); | |
| 83 | const code = await asks(o, parseArgs(["asks", ...argv], { values: ["repo", "reason"] }), argv, confirm); | |
| 84 | return { code, out, err }; | |
| 85 | } | |
| 86 | ||
| 87 | test("the list numbers its lines", async () => { | |
| 88 | const r = await run([], async () => undefined); | |
| 89 | expect(r.out.split("\n")[0]).toMatch(/^1 {2}look/); | |
| 90 | expect(r.out.split("\n")[3]).toMatch(/^4 {2}forge/); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the preview names the right items; no yes, no apply", async () => { | |
| 94 | const r = await run(["--ship", "1", "3"], async () => false); | |
| 95 | expect(posts).toEqual([{ action: "ship", items: ["look:1", "pr:3"], preview: true }]); | |
| 96 | expect(r.out).toContain("Would ship:"); | |
| 97 | expect(r.out).toContain("Nothing applied."); | |
| 98 | expect(r.code).toBe(EXIT.ok); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a yes applies once and prints the receipt and the undo line", async () => { | |
| 102 | let asked = ""; | |
| 103 | const r = await run(["--close", "2", "--reason", "not now"], async (q) => ((asked = q), true)); | |
| 104 | expect(asked).toBe("Apply? [y/N] "); | |
| 105 | expect(posts.map((p) => p.preview)).toEqual([true, false]); | |
| 106 | expect(posts[1]).toEqual({ action: "close", items: ["parked:2"], reason: "not now", preview: false }); | |
| 107 | expect(r.out).toContain("Receipt op9"); | |
| 108 | expect(r.out).toContain(`Undo: forge asks --undo op9`); | |
| 109 | }); | |
| 110 | ||
| 111 | test("--yes skips the question", async () => { | |
| 112 | const r = await run(["--dismiss", "1", "--yes"], async () => { | |
| 113 | throw new Error("asked"); | |
| 114 | }); | |
| 115 | expect(posts.map((p) => p.preview)).toEqual([true, false]); | |
| 116 | expect(r.code).toBe(EXIT.ok); | |
| 117 | }); | |
| 118 | ||
| 119 | test("no terminal and no --yes refuses with exit 2, applying nothing", async () => { | |
| 120 | const r = await run(["--dismiss", "1"], async () => undefined); | |
| 121 | expect(r.code).toBe(EXIT.usage); | |
| 122 | expect(r.err).toContain("--yes"); | |
| 123 | expect(posts.map((p) => p.preview)).toEqual([true]); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a number not on the list is refused before any request, named", async () => { | |
| 127 | for (const bad of ["9", "0", "x", "1.5"]) { | |
| 128 | const r = await run(["--ship", "1", bad], async () => true); | |
| 129 | expect(r.code).toBe(EXIT.usage); | |
| 130 | expect(r.err).toContain(`no ask is numbered ${bad}`); | |
| 131 | } | |
| 132 | const forgeNeed = await run(["--ship", "4"], async () => true); | |
| 133 | expect(forgeNeed.err).toContain("forge's own need"); | |
| 134 | expect(posts).toHaveLength(0); | |
| 135 | }); | |
| 136 | ||
| 137 | test("numbers come from a fresh read each run", async () => { | |
| 138 | await run(["--ship", "1", "--yes"], async () => true); | |
| 139 | ids = ["other:7", "look:1"]; | |
| 140 | await run(["--ship", "1", "--yes"], async () => true); | |
| 141 | expect(posts.map((p) => p.items[0])).toEqual(["look:1", "look:1", "other:7", "other:7"]); | |
| 142 | expect(reads).toBe(2); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a refusal prints the API's words and exits 1", async () => { | |
| 146 | bulkReply = () => Response.json({ error: "bad_action", message: "only close takes a reason" }, { status: 422 }); | |
| 147 | const r = await run(["--ship", "1"], async () => true); | |
| 148 | expect(r.code).toBe(EXIT.failed); | |
| 149 | expect(r.err).toContain("only close takes a reason"); | |
| 150 | bulkReply = () => Response.json({ error: "unauthorized", message: "sign in" }, { status: 401 }); | |
| 151 | expect((await run(["--ship", "1"], async () => true)).code).toBe(EXIT.notConfigured); | |
| 152 | }); | |
| 153 | ||
| 154 | test("parseWish reads groups and refuses mixed or bare lines", () => { | |
| 155 | expect(parseWish(["--ship", "1", "3", "--yes"])).toEqual({ action: "ship", numbers: ["1", "3"], reason: undefined }); | |
| 156 | expect(parseWish(["--repo", "a/b"])).toBeUndefined(); | |
| 157 | expect(parseWish(["--ship", "1", "--close", "2"])).toContain("one decision"); | |
| 158 | expect(parseWish(["--ship"])).toContain("numbers"); | |
| 159 | expect(parseWish(["--ship", "1", "--reason", "x"])).toContain("--close"); | |
| 160 | }); | |
| 161 | ||
| 162 | test("--undo posts the receipt's undo, once, with no question", async () => { | |
| 163 | const r = await run(["--undo", "op9"], async () => { | |
| 164 | throw new Error("asked"); | |
| 165 | }); | |
| 166 | expect(undos).toEqual(["op9"]); | |
| 167 | expect(posts).toHaveLength(0); | |
| 168 | expect(r.code).toBe(EXIT.ok); | |
| 169 | expect(r.out).toContain("alice/shop#1"); | |
| 170 | const gone = await run(["--undo", "gone"], async () => true); | |
| 171 | expect(gone.code).toBe(EXIT.failed); | |
| 172 | expect(gone.err).toContain("no such operation"); | |
| 173 | expect(parseWish(["--undo", "op9", "--ship", "1"])).toContain("stands alone"); | |
| 174 | expect(parseWish(["--undo"])).toContain("receipt"); | |
| 175 | }); | |
| 176 | ||
| 177 | test("more than 100 numbers are refused before any request", async () => { | |
| 178 | const many = Array.from({ length: 101 }, (_, i) => String(i + 1)); | |
| 179 | const r = await run(["--dismiss", ...many], async () => true); | |
| 180 | expect(r.code).toBe(EXIT.usage); | |
| 181 | expect(r.err).toContain("at most 100"); | |
| 182 | expect(posts).toHaveLength(0); | |
| 183 | expect(parseWish(["--dismiss", ...many.slice(0, 100)])).toMatchObject({ action: "dismiss" }); | |
| 184 | }); | |
| 185 | ||
| 186 | test("the parser pins loose words and a bare --reason", () => { | |
| 187 | expect(parseWish(["--ship", "1", "--repo", "a/b", "2"])).toContain('unexpected "2"'); | |
| 188 | expect(parseWish(["--ship", "1", "--yes", "2"])).toContain('unexpected "2"'); | |
| 189 | expect(parseWish(["--ship", "1", "--repo", "a/b"])).toMatchObject({ numbers: ["1"] }); | |
| 190 | expect(parseWish(["--close", "1", "--reason"])).toContain("needs words"); | |
| 191 | expect(parseWish(["--close", "1", "--reason", "--yes"])).toContain("needs words"); | |
| 192 | expect(parseWish(["--close", "1", "--reason", " "])).toContain("needs words"); | |
| 193 | expect(parseWish(["--close", "1", "--reason=because"])).toMatchObject({ reason: "because" }); | |
| 194 | expect(parseWish(["stray"])).toContain('unexpected "stray"'); | |
| 195 | }); |
test/cli-asks.test.ts +5 −5
| @@ -97,13 +97,13 @@ test("prints one line per ask, in the API's order, every kind kept", async () => | ||
| 97 | 97 | expect(seen.at(-1)).toBe("/api/v1/asks Bearer tok"); |
| 98 | 98 | const lines = run.out.trimEnd().split("\n"); |
| 99 | 99 | expect(lines).toHaveLength(4); |
| 100 | expect(lines[0]!.startsWith("look alice/shop Pull #3")).toBe(true); | |
| 100 | expect(lines[0]!.startsWith("1 look alice/shop Pull #3")).toBe(true); | |
| 101 | 101 | expect(lines[0]!.endsWith(` ${base}/alice/shop/pulls/3`)).toBe(true); |
| 102 | expect(lines[1]!.startsWith("parked alice/notes Issue #9")).toBe(true); | |
| 102 | expect(lines[1]!.startsWith("2 parked alice/notes ")).toBe(true); | |
| 103 | 103 | expect(lines[1]!.endsWith(`… ${base}/alice/notes/issues/9`)).toBe(true); |
| 104 | 104 | // An unknown kind with no line prints its kind as its title. |
| 105 | expect(lines[2]).toBe(`brand-new bob/lab brand-new ${base}/#ask-x-3`); | |
| 106 | expect(lines[3]!.startsWith("forge The check")).toBe(true); | |
| 105 | expect(lines[2]).toBe(`3 brand-new bob/lab brand-new ${base}/#ask-x-3`); | |
| 106 | expect(lines[3]!.startsWith("4 forge The check")).toBe(true); | |
| 107 | 107 | expect(lines[3]!.endsWith(` ${base}/`)).toBe(true); |
| 108 | 108 | // A pipe has no width; the CLI takes 80 and leaves the last column free. |
| 109 | 109 | for (const line of lines) expect(line.length).toBeLessThanOrEqual(79); |
| @@ -148,7 +148,7 @@ test("--repo keeps only that repository's asks", async () => { | ||
| 148 | 148 | expect(run.code).toBe(EXIT.ok); |
| 149 | 149 | const lines = run.out.trimEnd().split("\n"); |
| 150 | 150 | expect(lines).toHaveLength(1); |
| 151 | expect(lines[0]!.startsWith("parked alice/notes Issue #9")).toBe(true); | |
| 151 | expect(lines[0]!.startsWith("1 parked alice/notes Issue #9")).toBe(true); | |
| 152 | 152 | |
| 153 | 153 | const none = await forge(["asks", "--repo", "carol/elsewhere"]); |
| 154 | 154 | expect(none.out).toBe("Nothing waits on you.\n"); |
new above
offline
Non-blocking: an unknown
-xtoken is dropped here without a word. Return a usage error instead.