Public: anyone can read this; the crew can change it.

`forge asks` prints what waits on you, most important first, one line each, as /api/v1/asks answers #7381

Merged opened this

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

Merged

torque/forge:torque/7368-forge-asks-prints-what-waits-on-you-most → main 2 commits fork

  • Merged. torque/7368-forge-asks-prints-what-waits-on-you-most was merged into main as 0866982.

Review

Sign in to review this change.
5 files changed +385 −4
README.md +2
@@ -282,6 +282,8 @@ forge sync --ff # fast-forward clean repos, and only those
282282 forge pin owner/name # ask the workbench to keep this one here
283283 forge ls # what is here, and how stale
284284 forge open owner/name --web # the page, in a browser
285 forge asks # what waits on you, most important first
286 forge asks --repo owner/name # only that repository's; --json for the API's answer
285287 ```
286288
287289 `sync` never merges, rebases, resets or checks anything out. `--ff` is the one
src/cli/api.ts +71
@@ -36,6 +36,24 @@ export interface Whoami {
3636 readonly displayName: string;
3737 }
3838
39 /** One thing waiting on the user, as `forge asks` prints it. */
40 export interface Ask {
41 /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */
42 readonly kind: string;
43 /** `owner/name`, or "" for a need of the forge itself. */
44 readonly repo: string;
45 readonly title: string;
46 /** Absolute: where a person decides it on the workbench. */
47 readonly url: string;
48 }
49
50 export interface Asks {
51 /** The answer exactly as the forge gave it, for `--json`. */
52 readonly raw: unknown;
53 /** The rows in the order the forge ranks them, then the forge's own needs. */
54 readonly asks: Ask[];
55 }
56
3957 export class ForgeApi {
4058 /** True once `pins()` has had to fall back, so the CLI can say it once. */
4159 pinsUnavailable = false;
@@ -173,6 +191,47 @@ export class ForgeApi {
173191 );
174192 }
175193
194 /**
195 * What waits on the user, most consequential first (`GET /api/v1/asks`).
196 * A row of a kind this client has never heard of is kept, not dropped: it
197 * prints with its kind and whatever title it carries.
198 */
199 async asks(): Promise<ApiResult<Asks>> {
200 const out = await this.call<unknown>("/api/v1/asks");
201 if (!out.ok) return out;
202 const body = (out.value ?? {}) as { rows?: unknown; forge?: unknown };
203 const asks: Ask[] = [];
204 for (const item of Array.isArray(body.rows) ? body.rows : []) {
205 if (typeof item !== "object" || item === null) continue;
206 const r = item as Record<string, unknown>;
207 const kind = str(r["kind"]) ?? "ask";
208 const repo = str(r["repo"]) ?? "";
209 const proposal = r["proposal"] as Record<string, unknown> | undefined;
210 asks.push({
211 kind,
212 repo,
213 title: titleOf(
214 str(r["line"]) ?? str(proposal?.["title"]) ?? kind,
215 repo,
216 ),
217 url: this.absolute(str(r["link"]) ?? "/"),
218 });
219 }
220 for (const need of Array.isArray(body.forge) ? body.forge : []) {
221 const title = str(need);
222 if (title !== undefined)
223 asks.push({ kind: "forge", repo: "", title, url: this.absolute("/") });
224 }
225 return ok({ raw: out.value, asks });
226 }
227
228 /** A workbench path as a full address; anything not a path is the home. */
229 private absolute(link: string): string {
230 return link.startsWith("/") && !link.startsWith("//")
231 ? `${this.url}${link}`
232 : `${this.url}/`;
233 }
234
176235 /** The page a person would open in a browser. */
177236 webUrl(owner: string, name: string): string {
178237 return `${this.url}/${encode(owner)}/${encode(name)}`;
@@ -216,6 +275,18 @@ function toOrigin(value: unknown): Pin["origin"] {
216275 return { htmlUrl, fullName };
217276 }
218277
278 /**
279 * An ask's line without the repo name it leads with (`app: Pull #3 …`, or
280 * `app paused …`): the CLI prints `owner/name` in its own column.
281 */
282 function titleOf(line: string, repo: string): string {
283 const name = repo.slice(repo.indexOf("/") + 1);
284 if (name === "") return line;
285 if (line.startsWith(`${name}: `)) return line.slice(name.length + 2);
286 if (line.startsWith(`${name} `)) return line.slice(name.length + 1);
287 return line;
288 }
289
219290 function str(value: unknown): string | undefined {
220291 return typeof value === "string" && value !== "" ? value : undefined;
221292 }
src/cli/main.ts +94 −3
@@ -6,6 +6,7 @@
66 * forge login <url> remember a workbench and a token
77 * forge sync clone what is missing, fetch the rest, report
88 * forge status the same report without touching the network
9 * forge asks what waits on you, most important first
910 *
1011 * Everything here is a thin shell around three modules: `api.ts` talks to the
1112 * workbench, `sync.ts` decides what should be on disk, `report.ts` prints it.
@@ -14,7 +15,7 @@
1415 */
1516
1617 import { parseArgs, parseDuration, parseRepoRef, type Args } from "./args.ts";
17 import { ForgeApi } from "./api.ts";
18 import { ForgeApi, type Ask } from "./api.ts";
1819 import {
1920 clearConfig,
2021 configPath,
@@ -25,7 +26,7 @@ import {
2526 type Config,
2627 } from "./config.ts";
2728 import { systemNotifier } from "./notify.ts";
28 import { cell, createOut, type Out } from "./out.ts";
29 import { cell, createOut, inert, type Out } from "./out.ts";
2930 import { dirtyCount, printReport, settled, since } from "./report.ts";
3031 import {
3132 changesBetween,
@@ -35,6 +36,7 @@ import {
3536 } from "./sync.ts";
3637 import { G } from "../tui/term/screen.ts";
3738 import { trackText } from "../tui/local/common.ts";
39 import { displayWidth, pad, truncate } from "../tui/term/width.ts";
3840
3941 /**
4042 * The CLI's own number, kept by hand: it ships compiled, to machines no
@@ -51,7 +53,7 @@ export const EXIT = {
5153 partial: 4,
5254 } as const;
5355
54 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit"];
56 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"];
5557
5658 export async function main(argv: readonly string[]): Promise<number> {
5759 const args = parseArgs(argv, { values: VALUE_FLAGS });
@@ -95,6 +97,8 @@ export async function main(argv: readonly string[]): Promise<number> {
9597 return setPin(out, args, false);
9698 case "open":
9799 return openRepo(out, args);
100 case "asks":
101 return asks(out, args);
98102 case "ui":
99103 return openTui(out, args);
100104 default:
@@ -121,6 +125,9 @@ function usage(out: Out): void {
121125 out.line(b(" forge pin <owner/name>"));
122126 out.line(b(" forge unpin <owner/name>"));
123127 out.line(b(" forge open <owner/name>") + " [--web]");
128 out.line(
129 b(" forge asks") + " [--repo owner/name] [--json]",
130 );
124131 out.line();
125132 out.line(
126133 out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint),
@@ -394,6 +401,90 @@ async function openRepo(out: Out, args: Args): Promise<number> {
394401 return EXIT.ok;
395402 }
396403
404 // ── asks ─────────────────────────────────────────────────────────────────
405
406 /**
407 * What waits on you, one line each, in the order the forge ranks it: kind,
408 * repository, title cut to the terminal, and the address to decide it at.
409 */
410 async function asks(out: Out, args: Args): Promise<number> {
411 const only = args.value("repo");
412 const ref = only === undefined ? undefined : parseRepoRef(only);
413 if (only !== undefined && ref === undefined) {
414 out.err("forge asks: --repo expects owner/name");
415 return EXIT.usage;
416 }
417 const loaded = await requireConfig(out);
418 if (typeof loaded === "number") return loaded;
419 const answer = await new ForgeApi(loaded.url, loaded.token).asks();
420 if (!answer.ok) {
421 out.err(`forge asks: ${answer.error.message}`);
422 return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed;
423 }
424 const repo = ref === undefined ? undefined : `${ref.owner}/${ref.name}`;
425 if (args.flag("json")) {
426 const raw = answer.value.raw;
427 out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo)));
428 return EXIT.ok;
429 }
430 const rows = answer.value.asks.filter(
431 (a) => repo === undefined || a.repo === repo,
432 );
433 if (rows.length === 0) {
434 out.line("Nothing waits on you.");
435 return EXIT.ok;
436 }
437 for (const line of askLines(rows, out.width, out)) out.line(line);
438 return EXIT.ok;
439 }
440
441 /**
442 * The asks as lines no wider than `width - 1`, so none wraps on its own:
443 * kind and repository padded to their widest, the address whole, and the
444 * title cut to what those leave. Coloured only when `out` is given.
445 */
446 export function askLines(
447 rows: readonly Ask[],
448 width: number,
449 out?: Pick<Out, "paint" | "c">,
450 ): string[] {
451 const kindW = Math.max(...rows.map((a) => displayWidth(inert(a.kind))));
452 const repoW = Math.max(...rows.map((a) => displayWidth(inert(a.repo))));
453 return rows.map((a) => {
454 const url = inert(a.url);
455 // Three two-space gaps; the title gets what the other columns leave.
456 const room = Math.max(
457 8,
458 width - 1 - kindW - repoW - displayWidth(url) - 6,
459 );
460 const kind = pad(inert(a.kind), kindW);
461 return [
462 out === undefined ? kind : out.paint(kind, out.c.accent),
463 pad(inert(a.repo), repoW),
464 truncate(inert(a.title), room),
465 out === undefined ? url : out.paint(url, out.c.faint),
466 ].join(" ");
467 });
468 }
469
470 /**
471 * The API's answer with only one repository's rows; the forge's own needs
472 * belong to none.
473 */
474 function onlyRepo(raw: unknown, repo: string): unknown {
475 if (typeof raw !== "object" || raw === null) return raw;
476 const body = raw as { rows?: unknown };
477 return {
478 ...body,
479 rows: Array.isArray(body.rows)
480 ? body.rows.filter(
481 (r) => (r as { repo?: unknown } | null)?.repo === repo,
482 )
483 : body.rows,
484 forge: [],
485 };
486 }
487
397488 // ── the terminal UI ──────────────────────────────────────────────────────
398489
399490 async function openTui(out: Out, args: Args): Promise<number> {
src/cli/out.ts +1 −1
@@ -46,7 +46,7 @@ export function cell(text: string, fg?: Color, attr?: number): Cell {
4646 }
4747
4848 /** A cell is data, not instructions: control characters become U+FFFD. */
49 function inert(text: string): string {
49 export function inert(text: string): string {
5050 return text.replace(/[\x00-\x1f\x7f-\x9f]/g, "\ufffd");
5151 }
5252
test/cli-asks.test.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * `forge asks` against a fake `/api/v1/asks`: the CLI run as a shell runs
3 * it, a process reading only its environment, so the exit codes are real.
4 */
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 type { Ask } from "../src/cli/api.ts";
11 import { askLines, EXIT } from "../src/cli/main.ts";
12
13 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
14
15 const ANSWER = {
16 waiting: { count: 4, stale: 0 },
17 rows: [
18 {
19 id: "look:1",
20 kind: "look",
21 repo: "alice/shop",
22 line: "shop: Pull #3 is green — look and merge",
23 link: "/alice/shop/pulls/3",
24 items: ["look:1"],
25 },
26 {
27 id: "parked:2",
28 kind: "parked",
29 repo: "alice/notes",
30 line: `notes: Issue #9 waits on a decision ${"about the long tail of things ".repeat(4)}end`,
31 link: "/alice/notes/issues/9",
32 items: ["parked:2"],
33 },
34 // A kind this client has never heard of, with no line: it still prints.
35 { id: "x:3", kind: "brand-new", repo: "bob/lab", link: "/#ask-x-3", items: [] },
36 ],
37 forge: ["The check lane is stuck."],
38 measure: { days: 7, raised: 1 },
39 };
40
41 let server: ReturnType<typeof Bun.serve>;
42 let base = "";
43 let configDir = "";
44 let reply: () => Response = () => Response.json(ANSWER);
45 const seen: string[] = [];
46
47 beforeAll(async () => {
48 configDir = await mkdtemp(join(tmpdir(), "forge-asks-"));
49 server = Bun.serve({
50 port: 0,
51 fetch(req) {
52 const url = new URL(req.url);
53 seen.push(`${url.pathname} ${req.headers.get("authorization")}`);
54 return url.pathname === "/api/v1/asks"
55 ? reply()
56 : Response.json({ error: "not_found", message: "no" }, { status: 404 });
57 },
58 });
59 base = `http://127.0.0.1:${server.port}`;
60 });
61
62 afterAll(async () => {
63 server.stop(true);
64 await rm(configDir, { recursive: true, force: true });
65 });
66
67 async function forge(
68 args: string[],
69 extra: Record<string, string> = {},
70 ): Promise<{ code: number; out: string; err: string }> {
71 const proc = Bun.spawn(["bun", CLI, ...args], {
72 env: {
73 PATH: process.env["PATH"] ?? "",
74 HOME: process.env["HOME"] ?? "",
75 FORGE_URL: base,
76 FORGE_TOKEN: "tok",
77 FORGE_CONFIG_DIR: configDir,
78 FORGE_KEYCHAIN: "0",
79 NO_COLOR: "1",
80 ...extra,
81 },
82 stdout: "pipe",
83 stderr: "pipe",
84 });
85 const [out, err, code] = await Promise.all([
86 new Response(proc.stdout).text(),
87 new Response(proc.stderr).text(),
88 proc.exited,
89 ]);
90 return { code, out, err };
91 }
92
93 test("prints one line per ask, in the API's order, every kind kept", async () => {
94 reply = () => Response.json(ANSWER);
95 const run = await forge(["asks"]);
96 expect(run.code).toBe(EXIT.ok);
97 expect(seen.at(-1)).toBe("/api/v1/asks Bearer tok");
98 const lines = run.out.trimEnd().split("\n");
99 expect(lines).toHaveLength(4);
100 expect(lines[0]!.startsWith("look alice/shop Pull #3")).toBe(true);
101 expect(lines[0]!.endsWith(` ${base}/alice/shop/pulls/3`)).toBe(true);
102 expect(lines[1]!.startsWith("parked alice/notes Issue #9")).toBe(true);
103 expect(lines[1]!.endsWith(`… ${base}/alice/notes/issues/9`)).toBe(true);
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);
107 expect(lines[3]!.endsWith(` ${base}/`)).toBe(true);
108 // A pipe has no width; the CLI takes 80 and leaves the last column free.
109 for (const line of lines) expect(line.length).toBeLessThanOrEqual(79);
110 });
111
112 const ASKS: Ask[] = [
113 { kind: "look", repo: "alice/shop", title: "Pull #3 is green", url: "https://f.example/alice/shop/pulls/3" },
114 { kind: "parked", repo: "alice/notes", title: "x".repeat(200), url: "https://f.example/alice/notes/issues/9" },
115 { kind: "forge", repo: "", title: "The check lane is stuck.", url: "https://f.example/" },
116 ];
117
118 test("askLines pads kind and repository and keeps short titles whole", () => {
119 const lines = askLines(ASKS, 120);
120 expect(lines[0]).toBe(
121 "look alice/shop Pull #3 is green https://f.example/alice/shop/pulls/3",
122 );
123 expect(lines[2]).toBe(
124 "forge The check lane is stuck. https://f.example/",
125 );
126 });
127
128 test("askLines cuts a long title so the line fits the width", () => {
129 for (const width of [80, 100, 120]) {
130 const line = askLines(ASKS, width)[1]!;
131 expect(line.length).toBe(width - 1);
132 expect(line.endsWith("… https://f.example/alice/notes/issues/9")).toBe(true);
133 }
134 });
135
136 test("askLines neutralises control characters a title carries", () => {
137 const [line] = askLines(
138 [{ kind: "look", repo: "a/b", title: "evil\x1b[2Jtitle", url: "https://f.example/" }],
139 120,
140 );
141 expect(line).not.toContain("\x1b");
142 expect(line).toContain("evil\ufffd[2Jtitle");
143 });
144
145 test("--repo keeps only that repository's asks", async () => {
146 reply = () => Response.json(ANSWER);
147 const run = await forge(["asks", "--repo", "alice/notes"]);
148 expect(run.code).toBe(EXIT.ok);
149 const lines = run.out.trimEnd().split("\n");
150 expect(lines).toHaveLength(1);
151 expect(lines[0]!.startsWith("parked alice/notes Issue #9")).toBe(true);
152
153 const none = await forge(["asks", "--repo", "carol/elsewhere"]);
154 expect(none.out).toBe("Nothing waits on you.\n");
155 expect(none.code).toBe(EXIT.ok);
156
157 const bad = await forge(["asks", "--repo", "nope"]);
158 expect(bad.code).toBe(EXIT.usage);
159 expect(bad.err).toContain("owner/name");
160 });
161
162 test("--json prints the API's answer unchanged", async () => {
163 reply = () => Response.json(ANSWER);
164 const run = await forge(["asks", "--json"]);
165 expect(run.code).toBe(EXIT.ok);
166 expect(JSON.parse(run.out)).toEqual(ANSWER);
167 });
168
169 test("--json --repo prints the API's answer with only that repository's rows", async () => {
170 reply = () => Response.json(ANSWER);
171 const run = await forge(["asks", "--json", "--repo", "alice/notes"]);
172 expect(run.code).toBe(EXIT.ok);
173 const body = JSON.parse(run.out);
174 expect(body.rows).toEqual([ANSWER.rows[1]]);
175 expect(body.forge).toEqual([]);
176 expect(body.waiting).toEqual(ANSWER.waiting);
177 expect(body.measure).toEqual(ANSWER.measure);
178 });
179
180 test("nothing waiting prints one sentence and exits 0", async () => {
181 reply = () => Response.json({ waiting: { count: 0, stale: 0 }, rows: [], forge: [] });
182 const run = await forge(["asks"]);
183 expect(run.out).toBe("Nothing waits on you.\n");
184 expect(run.code).toBe(EXIT.ok);
185 });
186
187 test("a refusal prints the API's words and exits with the matching code", async () => {
188 reply = () =>
189 Response.json({ error: "unauthorized", message: "Sign in first." }, { status: 401 });
190 const signedOut = await forge(["asks"]);
191 expect(signedOut.code).toBe(EXIT.notConfigured);
192 expect(signedOut.err).toBe("forge asks: Sign in first.\n");
193
194 reply = () =>
195 Response.json({ error: "forbidden", message: "This token cannot read asks." }, { status: 403 });
196 const refused = await forge(["asks"]);
197 expect(refused.code).toBe(EXIT.failed);
198 expect(refused.err).toBe("forge asks: This token cannot read asks.\n");
199 });
200
201 test("not logged in exits notConfigured without calling the forge", async () => {
202 const before = seen.length;
203 const run = await forge(["asks"], {
204 FORGE_URL: "",
205 FORGE_TOKEN: "",
206 FORGE_CONFIG_DIR: join(configDir, "empty"),
207 });
208 expect(run.code).toBe(EXIT.notConfigured);
209 expect(run.err).toContain("forge login");
210 expect(seen.length).toBe(before);
211 });
212
213 test("forge help lists the command", async () => {
214 const run = await forge(["help"]);
215 expect(run.out).toContain("forge asks");
216 expect(run.out).toContain("--repo owner/name");
217 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs