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

Merged opened this

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

Merged

scout/forge:scout/7374-forge-asks-ship-close-dismiss-n-decides → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/7374-forge-asks-ship-close-dismiss-n-decides was merged into main as 6463964.

Review

Sign in to review this change.
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
319319 forge pulls # open pulls of this clone, newest first
320320 forge asks # what waits on you, most important first
321321 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
322325 ```
323326
324327 `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;
5252
5353 /** One thing waiting on the user, as `forge asks` prints it. */
5454 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;
5557 /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */
5658 readonly kind: string;
5759 /** `owner/name`, or "" for a need of the forge itself. */
@@ -68,6 +70,25 @@ export interface Asks {
6870 readonly asks: Ask[];
6971 }
7072
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
7192 export class ForgeApi {
7293 /** True once `pins()` has had to fall back, so the CLI can say it once. */
7394 pinsUnavailable = false;
@@ -315,7 +336,9 @@ export class ForgeApi {
315336 const kind = str(r["kind"]) ?? "ask";
316337 const repo = str(r["repo"]) ?? "";
317338 const proposal = r["proposal"] as Record<string, unknown> | undefined;
339 const id = str(r["id"]);
318340 asks.push({
341 ...(id === undefined ? {} : { id }),
319342 kind,
320343 repo,
321344 title: titleOf(
@@ -333,6 +356,60 @@ export class ForgeApi {
333356 return ok({ raw: out.value, asks });
334357 }
335358
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
336413 /** A workbench path as a full address; anything not a path is the home. */
337414 private absolute(link: string): string {
338415 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);

Non-blocking: an unknown -x token is dropped here without a word. Return a usage error instead.

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 @@
77 * forge sync clone what is missing, fetch the rest, report
88 * forge status the same report without touching the network
99 * 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
1012 * forge ship <number> land a pull after a y/N, as its Merge button does
1113 *
1214 * Everything here is a thin shell around three modules: `api.ts` talks to the
@@ -16,7 +18,8 @@
1618 */
1719
1820 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";
2023 import {
2124 clearConfig,
2225 configPath,
@@ -60,7 +63,7 @@ export const EXIT = {
6063 refused: 5,
6164 } as const;
6265
63 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"];
66 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "reason"];
6467
6568 /** What the CLI needs from the person at the keyboard; a test fakes it. */
6669 export interface Io {
@@ -116,7 +119,7 @@ export async function main(
116119 case "open":
117120 return openRepo(out, args);
118121 case "asks":
119 return asks(out, args);
122 return asks(out, args, argv.slice(1));
120123 case "ship":
121124 return ship(out, args, io);
122125 case "ui":
@@ -151,6 +154,10 @@ function usage(out: Out): void {
151154 out.line(
152155 b(" forge asks") + " [--repo owner/name] [--json]",
153156 );
157 out.line(
158 b(" forge asks --ship|--close|--dismiss <n…>") +
159 " [--reason words] [--yes]",
160 );
154161 out.line(b(" forge ship <number>") + " [--repo owner/name] [--yes]");
155162 out.line();
156163 out.line(
@@ -516,7 +523,17 @@ async function pulls(out: Out, args: Args): Promise<number> {
516523 * What waits on you, one line each, in the order the forge ranks it: kind,
517524 * repository, title cut to the terminal, and the address to decide it at.
518525 */
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 forge asks --undo <id>, which calls POST /api/v1/asks/bulk/:id/undo, with a test.

536 }
520537 const only = args.value("repo");
521538 const ref = only === undefined ? undefined : parseRepoRef(only);
522539 if (only !== undefined && ref === undefined) {
@@ -525,12 +542,21 @@ async function asks(out: Out, args: Args): Promise<number> {
525542 }
526543 const loaded = await requireConfig(out);
527544 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();
529547 if (!answer.ok) {
530548 out.err(`forge asks: ${answer.error.message}`);
531549 return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed;
532550 }
533551 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 }
534560 if (args.flag("json")) {
535561 const raw = answer.value.raw;
536562 out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo)));
@@ -543,10 +569,100 @@ async function asks(out: Out, args: Args): Promise<number> {
543569 out.line("Nothing waits on you.");
544570 return EXIT.ok;
545571 }
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 );
547577 return EXIT.ok;
548578 }
549579
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
550666 /**
551667 * The asks as lines no wider than `width - 1`, so none wraps on its own:
552668 * kind and repository padded to their widest, the address whole, and the
@@ -563,7 +679,7 @@ export function askLines(
563679 const url = inert(a.url);
564680 // Three two-space gaps; the title gets what the other columns leave.
565681 const room = Math.max(
566 8,
682 1,
567683 width - 1 - kindW - repoW - displayWidth(url) - 6,
568684 );
569685 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 () =>
9797 expect(seen.at(-1)).toBe("/api/v1/asks Bearer tok");
9898 const lines = run.out.trimEnd().split("\n");
9999 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);
101101 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);
103103 expect(lines[1]!.endsWith(`… ${base}/alice/notes/issues/9`)).toBe(true);
104104 // 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);
107107 expect(lines[3]!.endsWith(` ${base}/`)).toBe(true);
108108 // A pipe has no width; the CLI takes 80 and leaves the last column free.
109109 for (const line of lines) expect(line.length).toBeLessThanOrEqual(79);
@@ -148,7 +148,7 @@ test("--repo keeps only that repository's asks", async () => {
148148 expect(run.code).toBe(EXIT.ok);
149149 const lines = run.out.trimEnd().split("\n");
150150 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);
152152
153153 const none = await forge(["asks", "--repo", "carol/elsewhere"]);
154154 expect(none.out).toBe("Nothing waits on you.\n");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs