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

`forge search <words>` finds code, issues and pulls across the repositories you can read, as /api/v1/search does #7463

Merged opened this

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

Merged

torque/forge:torque/7378-forge-search-words-finds-code-issues-and → main 8 commits fork 2 threads · 2 unresolved

  • Merged. torque/7378-forge-search-words-finds-code-issues-and was merged into main as 144c086.

Review

Sign in to review this change.
5 files changed +546 −1
README.md +2
@@ -330,6 +330,8 @@ forge asks --repo owner/name # only that repository's; --json for the API'
330330 forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N]
331331 forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY)
332332 forge asks --undo <id> # take back a decision by the receipt id it printed
333 forge search retry budget # code, issues, pulls and repos you can read; exits 6 on "not now"
334 forge search x --kind pulls --limit 5 # one kind, five hits; --json marks truncated.issues when more matched
333335 ```
334336
335337 `forge ship <number>` reads the repository from `--repo owner/name` or from
docs/CLI.md +28
@@ -47,6 +47,7 @@ command without a config file at all.
4747 | 3 | not signed in, or the stored token is unusable |
4848 | 4 | partial: the run finished, but at least one repo did not |
4949 | 5 | refused: the forge heard the request and said no (a rule, a conflict, a hold) |
50 | 6 | rate limited: the forge said "not now" and named when to try again |
5051
5152 A script can branch on these without parsing text.
5253
@@ -288,6 +289,33 @@ look alice/shop Pull #3 is green — look and merge https://forge.example.
288289 parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9
289290 ```
290291
292 ## forge search
293
294 Code, issues, pulls and repositories the token can read, from
295 `/api/v1/search`. Each kind is headed by its name and count, then one hit a
296 line: `owner/name:path:line text` for code, `owner/name#N title state`
297 for issues and pulls, `owner/name description` for repositories. With no
298 hit it prints `Nothing found for "…".` A 429 prints the forge's words with
299 the retry time and exits 6; a 401 exits 3.
300
301 Flags:
302
303 - `--kind code|issues|pulls|repos|all` — one kind (exit 2 for anything
304 else). `issues` and `pulls` ask the API for its whole window and cut to
305 `--limit` after keeping the one kind.
306 - `--limit N` — at most N hits per kind, a whole number above 0.
307 - `--json` — the forge's answer as it came; for `--kind issues|pulls`,
308 narrowed, with `truncated.issues` set when more matched.
309
310 ```console
311 $ forge search retry budget
312 code (1)
313 alice/shop:src/retry.ts:14 const budget = retry(attempts);
314
315 issues (1)
316 alice/shop#7 Retry budget runs out open
317 ```
318
291319 ## forge ship
292320
293321 Lands a pull, as its Merge button does: prints the pull with its check and
src/cli/api.ts +20
@@ -16,6 +16,8 @@ export interface ApiError {
1616 readonly code: string;
1717 readonly message: string;
1818 readonly status: number;
19 /** Seconds the forge asked us to wait (`retry-after`), when it said. */
20 readonly retryAfter?: number;
1921 }
2022
2123 export type ApiResult<T> = Result<T, ApiError>;
@@ -137,6 +139,7 @@ export class ForgeApi {
137139 }
138140 if (!response.ok) {
139141 const body = parsed as { error?: unknown; message?: unknown };
142 const retryAfter = Number(response.headers.get("retry-after") ?? "");
140143 // Some refusals are a bare code (`{ error: "checks_required" }`): the
141144 // code is then the forge's own word, and better than a status line.
142145 return err({
@@ -148,6 +151,7 @@ export class ForgeApi {
148151 ? body.error
149152 : `${response.status} from ${path}`,
150153 status: response.status,
154 ...(Number.isInteger(retryAfter) && retryAfter > 0 ? { retryAfter } : {}),
151155 });
152156 }
153157 return ok(parsed as T);
@@ -356,6 +360,22 @@ export class ForgeApi {
356360 return ok({ raw: out.value, asks });
357361 }
358362
363 /**
364 * `GET /api/v1/search`: code, issues, pulls and repositories the token can
365 * read. The answer is handed back as the forge gave it — the CLI prints
366 * from it and `--json` prints it unchanged. `kind` and `limit` are left to
367 * the forge's defaults when not given.
368 */
369 async search(
370 q: string,
371 options: { kind?: string; limit?: number } = {},
372 ): Promise<ApiResult<unknown>> {
373 const query = new URLSearchParams({ q });
374 if (options.kind !== undefined) query.set("kind", options.kind);
375 if (options.limit !== undefined) query.set("limit", String(options.limit));
376 return this.call<unknown>(`/api/v1/search?${query}`);
377 }
378
359379 /**
360380 * One action on many asks (`POST /api/v1/asks/bulk`): the plan while
361381 * `preview` is true, the deed and its receipt when false.
src/cli/main.ts +208 −1
@@ -7,6 +7,7 @@
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 search <words> code, issues, pulls and repos you can read
1011 * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N
1112 * forge asks --undo <id> take back what that decided
1213 * forge ship <number> land a pull after a y/N, as its Merge button does
@@ -61,9 +62,11 @@ export const EXIT = {
6162 partial: 4,
6263 /** The forge heard the request and said no: a rule, a conflict, a hold. */
6364 refused: 5,
65 /** The forge said "not now"; it named when to try again. */
66 rateLimited: 6,
6467 } as const;
6568
66 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "reason"];
69 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason"];
6770
6871 /** What the CLI needs from the person at the keyboard; a test fakes it. */
6972 export interface Io {
@@ -120,6 +123,8 @@ export async function main(
120123 return openRepo(out, args);
121124 case "asks":
122125 return asks(out, args, argv.slice(1));
126 case "search":
127 return search(out, args);
123128 case "ship":
124129 return ship(out, args, io);
125130 case "ui":
@@ -154,6 +159,10 @@ function usage(out: Out): void {
154159 out.line(
155160 b(" forge asks") + " [--repo owner/name] [--json]",
156161 );
162 out.line(
163 b(" forge search <words…>") +
164 " [--kind code|issues|pulls|repos|all] [--limit N] [--json]",
165 );
157166 out.line(
158167 b(" forge asks --ship|--close|--dismiss <n…>") +
159168 " [--reason words] [--yes]",
@@ -710,6 +719,204 @@ function onlyRepo(raw: unknown, repo: string): unknown {
710719 };
711720 }
712721
722 // ── search ───────────────────────────────────────────────────────────────
723
724 /** The forge's default and ceiling for `limit` (`src/actions/search.ts`). */
725 const SEARCH_API_LIMIT = 20;
726 const SEARCH_API_LIMIT_MAX = 100;
727
728 const SEARCH_KINDS = ["code", "issues", "pulls", "repos", "all"] as const;
729 export type SearchFor = (typeof SEARCH_KINDS)[number];
730
731 /**
732 * `GET /api/v1/search` from a shell: every hit one line, grouped by kind.
733 * The API has no `pulls` kind — its `issues` answers both — so `--kind
734 * issues` and `--kind pulls` ask for `issues` at the API's ceiling and
735 * keep N of the one kind (`narrowConversations`): asking for N and then
736 * filtering would print fewer than N, or none.
737 */
738 async function search(out: Out, args: Args): Promise<number> {
739 const words = args.positional.join(" ").trim();
740 if (words === "") {
741 out.err("forge search: what for? e.g. forge search retry budget");
742 usage(out);
743 return EXIT.usage;
744 }
745 const kindRaw = args.value("kind");
746 const kind = SEARCH_KINDS.find((k) => k === kindRaw);
747 if (kindRaw !== undefined && kind === undefined) {
748 out.err(`forge search: --kind is one of ${SEARCH_KINDS.join(", ")}`);
749 return EXIT.usage;
750 }
751 const limitRaw = args.value("limit");
752 const limit = limitRaw === undefined ? undefined : Number(limitRaw);
753 if (limit !== undefined && (!/^\d+$/.test(limitRaw!) || limit < 1)) {
754 out.err("forge search: --limit expects a whole number above 0");
755 return EXIT.usage;
756 }
757 const loaded = await requireConfig(out);
758 if (typeof loaded === "number") return loaded;
759 const oneConversation = kind === "issues" || kind === "pulls";
760 const answer = await new ForgeApi(loaded.url, loaded.token).search(words, {
761 ...(kind === undefined ? {} : { kind: oneConversation ? "issues" : kind }),
762 ...(oneConversation
763 ? { limit: SEARCH_API_LIMIT_MAX }
764 : limit === undefined ? {} : { limit }),
765 });
766 if (!answer.ok) {
767 const { message, retryAfter, status } = answer.error;
768 const when =
769 retryAfter === undefined || new RegExp(`\\b${retryAfter}s\\b`).test(message)
770 ? ""
771 : ` (retry in ${retryAfter}s)`;
772 out.err(`forge search: ${message}${when}`);
773 if (status === 429) return EXIT.rateLimited;
774 return status === 401 ? EXIT.notConfigured : EXIT.failed;
775 }
776 const found = oneConversation
777 ? narrowConversations(answer.value, kind, limit ?? SEARCH_API_LIMIT)
778 : answer.value;
779 if (args.flag("json")) {
780 out.line(JSON.stringify(found));
781 return EXIT.ok;
782 }
783 const lines = searchLines(found, kind ?? "all", out.width, out);
784 if (lines.length === 0) {
785 out.line(`Nothing found for "${inert(words)}".`);
786 return EXIT.ok;
787 }
788 for (const line of lines) out.line(line);
789 return EXIT.ok;
790 }
791
792 /**
793 * The answer with `issues` kept to one kind (`issue` or `pull`) and cut to
794 * `limit`; `truncated.issues` is set when the cut dropped any, or when the
795 * forge had already cut its own window, so `--json` and the group header
796 * both say the list is not whole. Everything else passes through.
797 */
798 export function narrowConversations(
799 raw: unknown,
800 kind: "issues" | "pulls",
801 limit: number,
802 ): unknown {
803 if (typeof raw !== "object" || raw === null) return raw;
804 const body = raw as Record<string, unknown>;
805 const want = kind === "pulls" ? "pull" : "issue";
806 const kept = rowsOf(body["issues"]).filter((r) => r["kind"] === want);
807 const truncated = (typeof body["truncated"] === "object" && body["truncated"] !== null
808 ? body["truncated"]
809 : {}) as Record<string, unknown>;
810 return {
811 ...body,
812 issues: kept.slice(0, limit),
813 truncated: { ...truncated, issues: truncated["issues"] === true || kept.length > limit },
814 };
815 }
816
817 /**
818 * The search answer as lines no wider than `width - 1` where the text can
819 * give way: each kind headed by its name and count, then one hit a line —
820 * `owner/name:path:line text` for code, `owner/name#N title state` for
821 * issues and pulls, `owner/name description` for repositories. Anything
822 * the answer carries that is not the expected shape is skipped, not fatal.
823 */
824 export function searchLines(
825 raw: unknown,
826 kind: SearchFor,
827 width: number,
828 out?: Pick<Out, "paint" | "c">,
829 ): string[] {
830 const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
831 const truncated = (body["truncated"] ?? {}) as Record<string, unknown>;
832 const conversations = rowsOf(body["issues"]).map(conversationRow);
833 const groups: { kind: SearchFor; rows: [string, string, string][]; more: boolean }[] = [
834 { kind: "code", rows: rowsOf(body["code"]).map(codeRow), more: truncated["code"] === true },
835 {
836 kind: "issues",
837 rows: conversations.filter((r) => r[0] === "issue").map((r) => r.slice(1) as [string, string, string]),
838 more: truncated["issues"] === true,
839 },
840 {
841 kind: "pulls",
842 rows: conversations.filter((r) => r[0] === "pull").map((r) => r.slice(1) as [string, string, string]),
843 more: truncated["issues"] === true,
844 },
845 { kind: "repos", rows: rowsOf(body["repos"]).map(repoRow), more: truncated["repos"] === true },
846 ];
847 const lines: string[] = [];
848 for (const group of groups) {
849 const rows = group.rows.filter((r) => r[0] !== "");
850 if (rows.length === 0 || (kind !== "all" && kind !== group.kind)) continue;
851 if (lines.length > 0) lines.push("");
852 const head = `${group.kind} (${rows.length}${group.more ? ", more not shown" : ""})`;
853 lines.push(out === undefined ? head : out.paint(head, out.c.accent));
854 // Code's place differs in width line to line; the others line up.
855 const leadW = group.kind === "code" ? 0 : Math.max(...rows.map((r) => displayWidth(r[0])));
856 const tails = rows.some((r) => r[2] !== "");
857 const tailW = tails ? Math.max(...rows.map((r) => displayWidth(r[2]))) + 2 : 0;
858 const textW = Math.max(...rows.map((r) => displayWidth(r[1])));
859 for (const [lead, text, tail] of rows) {
860 const first = leadW === 0 ? lead : pad(lead, leadW);
861 const room = Math.max(8, width - 1 - displayWidth(first) - 2 - tailW);
862 const cut = truncate(text, room);
863 const shown = tails ? pad(cut, Math.min(room, textW)) : cut;
864 lines.push(
865 [
866 first,
867 shown,
868 ...(tail === "" ? [] : [out === undefined ? tail : out.paint(tail, out.c.faint)]),
869 ].join(" ").trimEnd(),
870 );
871 }
872 if (group.kind === "code" && (body["moreCode"] === true || body["codeSkipped"] === true)) {
873 const note = " some repositories were not searched in time; --json says which kinds are partial";
874 lines.push(out === undefined ? note : out.paint(note, out.c.faint));
875 }
876 }
877 return lines;
878 }
879
880 function rowsOf(value: unknown): Record<string, unknown>[] {
881 return Array.isArray(value)
882 ? value.filter((v): v is Record<string, unknown> => typeof v === "object" && v !== null)
883 : [];
884 }
885
886 /** `owner/name` from a local hit's `repo`, or an origin hit's `fullName`. */
887 function repoName(r: Record<string, unknown>): string {
888 const repo = r["repo"] as Record<string, unknown> | undefined;
889 if (typeof repo?.["owner"] === "string" && typeof repo["name"] === "string")
890 return `${repo["owner"]}/${repo["name"]}`;
891 if (typeof r["owner"] === "string" && typeof r["name"] === "string")
892 return `${r["owner"]}/${r["name"]}`;
893 return typeof r["fullName"] === "string" ? r["fullName"] : "";
894 }
895
896 function textOf(value: unknown): string {
897 return typeof value === "string" ? inert(value) : "";
898 }
899
900 function codeRow(r: Record<string, unknown>): [string, string, string] {
901 const name = repoName(r);
902 const path = textOf(r["path"]);
903 const line = typeof r["line"] === "number" ? r["line"] : 0;
904 if (name === "" || path === "") return ["", "", ""];
905 return [`${inert(name)}:${path}:${line}`, textOf(r["text"]).trim(), ""];
906 }
907
908 function conversationRow(r: Record<string, unknown>): [string, string, string, string] {
909 const name = repoName(r);
910 const n = r["number"];
911 if (name === "" || typeof n !== "number") return ["", "", "", ""];
912 return [textOf(r["kind"]), `${inert(name)}#${n}`, textOf(r["title"]), textOf(r["state"])];
913 }
914
915 function repoRow(r: Record<string, unknown>): [string, string, string] {
916 const name = repoName(r);
917 return [inert(name), textOf(r["description"]), r["archived"] === true ? "archived" : ""];
918 }
919
713920 // ── landing a pull ───────────────────────────────────────────────────────
714921
715922 /**
test/cli-search.test.ts added +288
@@ -0,0 +1,288 @@
1 /**
2 * `forge search` against a fake `/api/v1/search`: the CLI run as a shell
3 * runs 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 { EXIT, searchLines } from "../src/cli/main.ts";
11
12 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
13
14 const ANSWER = {
15 repos: [
16 {
17 source: "local", id: "r1", owner: "alice", name: "shop", description: "A shop that retries",
18 visibility: "public", archived: false, openIssues: 1, openPulls: 1,
19 },
20 ],
21 issues: [
22 {
23 source: "local", repo: { owner: "alice", name: "shop" }, number: 7, kind: "issue",
24 title: "Retry budget runs out", state: "open", updatedAt: "2026-10-01T00:00:00Z",
25 },
26 {
27 source: "local", repo: { owner: "alice", name: "shop" }, number: 12, kind: "pull",
28 title: "Retry with a budget", state: "closed", updatedAt: "2026-10-02T00:00:00Z",
29 },
30 {
31 source: "origin", originId: "o1", fullName: "bob/upstream", number: 300, kind: "issue",
32 title: `Retry ${"and retry again ".repeat(8)}end`, state: "closed",
33 updatedAt: "2026-10-03T00:00:00Z", htmlUrl: "https://elsewhere.example/bob/upstream/issues/300",
34 },
35 ],
36 code: [
37 {
38 repo: { owner: "alice", name: "shop" }, archived: false, path: "src/retry.ts", line: 14,
39 text: " const budget = retry(attempts);",
40 },
41 {
42 repo: { owner: "alice", name: "shop" }, archived: false, path: "src/very/deep/path/client.ts",
43 line: 2031, text: `// retry ${"x".repeat(200)}`,
44 },
45 ],
46 truncated: { repos: false, issues: false, code: false },
47 moreCode: false,
48 codeSkipped: false,
49 };
50
51 const EMPTY = {
52 repos: [], issues: [], code: [],
53 truncated: { repos: false, issues: false, code: false },
54 moreCode: false, codeSkipped: false,
55 };
56
57 let server: ReturnType<typeof Bun.serve>;
58 let base = "";
59 let configDir = "";
60 let reply: () => Response = () => Response.json(ANSWER);
61 const seen: URL[] = [];
62
63 beforeAll(async () => {
64 configDir = await mkdtemp(join(tmpdir(), "forge-search-"));
65 server = Bun.serve({
66 port: 0,
67 fetch(req) {
68 const url = new URL(req.url);
69 seen.push(url);
70 return url.pathname === "/api/v1/search" && req.headers.get("authorization") === "Bearer tok"
71 ? reply()
72 : Response.json({ error: "not_found", message: "no" }, { status: 404 });
73 },
74 });
75 base = `http://127.0.0.1:${server.port}`;
76 });
77
78 afterAll(async () => {
79 server.stop(true);
80 await rm(configDir, { recursive: true, force: true });
81 });
82
83 async function forge(args: string[]): Promise<{ code: number; out: string; err: string }> {
84 const proc = Bun.spawn(["bun", CLI, ...args], {
85 env: {
86 PATH: process.env["PATH"] ?? "",
87 HOME: process.env["HOME"] ?? "",
88 FORGE_URL: base,
89 FORGE_TOKEN: "tok",
90 FORGE_CONFIG_DIR: configDir,
91 FORGE_KEYCHAIN: "0",
92 NO_COLOR: "1",
93 },
94 stdout: "pipe",
95 stderr: "pipe",
96 });
97 const [out, err, code] = await Promise.all([
98 new Response(proc.stdout).text(),
99 new Response(proc.stderr).text(),
100 proc.exited,
101 ]);
102 return { code, out, err };
103 }
104
105 test("the words become q; --kind and --limit pass through, and the forge's defaults stand otherwise", async () => {
106 reply = () => Response.json(ANSWER);
107 await forge(["search", "retry", "budget"]);
108 const plain = seen.at(-1)!;
109 expect(plain.searchParams.get("q")).toBe("retry budget");
110 expect(plain.searchParams.has("kind")).toBe(false);
111 expect(plain.searchParams.has("limit")).toBe(false);
112
113 await forge(["search", "retry", "--kind", "code", "--limit", "5"]);
114 const narrowed = seen.at(-1)!;
115 expect(narrowed.searchParams.get("q")).toBe("retry");
116 expect(narrowed.searchParams.get("kind")).toBe("code");
117 expect(narrowed.searchParams.get("limit")).toBe("5");
118
119 // The API answers pulls inside `issues`; the CLI asks for that, at the
120 // API's ceiling, and cuts to --limit itself after keeping the one kind.
121 await forge(["search", "retry", "--kind", "pulls", "--limit", "3"]);
122 expect(seen.at(-1)!.searchParams.get("kind")).toBe("issues");
123 expect(seen.at(-1)!.searchParams.get("limit")).toBe("100");
124 });
125
126 /** Hits as the forge orders them: issues first, so a window of 1 holds no pull. */
127 const MIXED = {
128 ...EMPTY,
129 issues: [1, 2, 3, 4].map((n) => ({
130 source: "local", repo: { owner: "alice", name: "shop" }, number: n,
131 kind: n <= 2 ? "issue" : "pull", title: `Retry ${n}`, state: "open",
132 updatedAt: "2026-10-01T00:00:00Z",
133 })),
134 };
135
136 /** A fake that honours `limit` as the forge does: the first N hits. */
137 function windowed(answer: typeof MIXED): () => Response {
138 return () => {
139 const limit = Number(seen.at(-1)!.searchParams.get("limit") ?? "20");
140 return Response.json({ ...answer, issues: answer.issues.slice(0, limit) });
141 };
142 }
143
144 test("--kind pulls --limit 1 prints a pull even when the forge ranks issues first", async () => {
145 reply = windowed(MIXED);
146 const pulls = await forge(["search", "retry", "--kind", "pulls", "--limit", "1"]);
147 expect(pulls.code).toBe(EXIT.ok);
148 expect(pulls.out).toBe("pulls (1, more not shown)\nalice/shop#3 Retry 3 open\n");
149
150 const issues = await forge(["search", "retry", "--kind", "issues", "--limit", "2"]);
151 expect(issues.out).toBe("issues (2)\nalice/shop#1 Retry 1 open\nalice/shop#2 Retry 2 open\n");
152
153 // --json is the same narrowed answer, and says the list was cut.
154 const json = await forge(["search", "retry", "--kind", "pulls", "--limit", "1", "--json"]);
155 const body = JSON.parse(json.out);
156 expect(body.issues.map((i: { number: number }) => i.number)).toEqual([3]);
157 expect(body.truncated).toEqual({ repos: false, issues: true, code: false });
158 const whole = await forge(["search", "retry", "--kind", "pulls", "--json"]);
159 expect(JSON.parse(whole.out).truncated.issues).toBe(false);
160 });
161
162 test("prints code as owner/name:path:line and conversations as owner/name#N, each group headed", async () => {
163 reply = () => Response.json(ANSWER);
164 const run = await forge(["search", "retry"]);
165 expect(run.code).toBe(EXIT.ok);
166 const lines = run.out.trimEnd().split("\n");
167 expect(lines[0]).toBe("code (2)");
168 expect(lines[1]).toBe("alice/shop:src/retry.ts:14 const budget = retry(attempts);");
169 expect(lines[2]!.startsWith("alice/shop:src/very/deep/path/client.ts:2031 // retry xxx")).toBe(true);
170 expect(lines[2]!.endsWith("…")).toBe(true);
171 expect(lines[3]).toBe("");
172 expect(lines[4]).toBe("issues (2)");
173 expect(lines[5]!.startsWith("alice/shop#7 Retry budget runs out ")).toBe(true);
174 expect(lines[5]!.endsWith(" open")).toBe(true);
175 expect(lines[6]!.startsWith("bob/upstream#300 Retry and retry again")).toBe(true);
176 expect(lines[6]!.endsWith("… closed")).toBe(true);
177 expect(lines[8]).toBe("pulls (1)");
178 expect(lines[9]).toBe("alice/shop#12 Retry with a budget closed");
179 expect(lines[11]).toBe("repos (1)");
180 expect(lines[12]).toBe("alice/shop A shop that retries");
181 // A pipe has no width; the CLI takes 80 and leaves the last column free.
182 for (const line of lines) expect(line.length).toBeLessThanOrEqual(79);
183 });
184
185 test("--kind keeps one group; --json prints the answer unchanged", async () => {
186 reply = () => Response.json(ANSWER);
187 const pulls = await forge(["search", "retry", "--kind", "pulls"]);
188 expect(pulls.out).toBe("pulls (1)\nalice/shop#12 Retry with a budget closed\n");
189
190 const json = await forge(["search", "retry", "--json"]);
191 expect(json.code).toBe(EXIT.ok);
192 expect(JSON.parse(json.out)).toEqual(ANSWER);
193 });
194
195 test("no hit prints one sentence and exits 0", async () => {
196 reply = () => Response.json(EMPTY);
197 const run = await forge(["search", "nothing", "here"]);
198 expect(run.code).toBe(EXIT.ok);
199 expect(run.out).toBe('Nothing found for "nothing here".\n');
200 });
201
202 test("a 429 prints the API's words with its retry time and exits rateLimited", async () => {
203 reply = () =>
204 Response.json(
205 { error: "rate_limited", message: "Too many code searches" },
206 { status: 429, headers: { "retry-after": "17" } },
207 );
208 const run = await forge(["search", "retry"]);
209 expect(run.code).toBe(EXIT.rateLimited);
210 expect(run.err).toBe("forge search: Too many code searches (retry in 17s)\n");
211
212 // When the words already name the time, it is not said twice.
213 reply = () =>
214 Response.json(
215 { error: "rate_limited", message: "busy; sign in, or retry in 9s" },
216 { status: 429, headers: { "retry-after": "9" } },
217 );
218 const named = await forge(["search", "retry"]);
219 expect(named.code).toBe(EXIT.rateLimited);
220 expect(named.err).toBe("forge search: busy; sign in, or retry in 9s\n");
221
222 // 117s is not 17s: the time is matched as a whole word.
223 reply = () =>
224 Response.json(
225 { error: "rate_limited", message: "busy; retry in 117s" },
226 { status: 429, headers: { "retry-after": "17" } },
227 );
228 const other = await forge(["search", "retry"]);
229 expect(other.err).toBe("forge search: busy; retry in 117s (retry in 17s)\n");
230 });
231
232 test("a refused token exits notConfigured; any other failure exits failed", async () => {
233 reply = () => Response.json({ error: "unauthorized", message: "token expired" }, { status: 401 });
234 const refused = await forge(["search", "retry"]);
235 expect(refused.code).toBe(EXIT.notConfigured);
236 expect(refused.err).toBe("forge search: token expired\n");
237
238 reply = () => Response.json({ error: "internal", message: "search broke" }, { status: 500 });
239 const broke = await forge(["search", "retry"]);
240 expect(broke.code).toBe(EXIT.failed);
241 expect(broke.err).toBe("forge search: search broke\n");
242 });
243
244 test("empty words and a bad flag are usage errors; help lists the command", async () => {
245 const before = seen.length;
246 const empty = await forge(["search"]);
247 expect(empty.code).toBe(EXIT.usage);
248 expect(empty.out).toContain("forge search <words…>");
249 const blank = await forge(["search", " "]);
250 expect(blank.code).toBe(EXIT.usage);
251 const kind = await forge(["search", "x", "--kind", "wiki"]);
252 expect(kind.code).toBe(EXIT.usage);
253 const limit = await forge(["search", "x", "--limit", "0"]);
254 expect(limit.code).toBe(EXIT.usage);
255 expect(seen.length).toBe(before);
256
257 const help = await forge(["help"]);
258 expect(help.out).toContain("forge search <words…>");
259 });
260
261 test("searchLines neutralises control characters and fits the width", () => {
262 const lines = searchLines(
263 {
264 code: [{ repo: { owner: "a", name: "b" }, path: "x\x1b[2J.ts", line: 1, text: "evil\x1b]0;t\x07" }],
265 issues: [],
266 repos: [],
267 },
268 "all",
269 40,
270 );
271 for (const line of lines) expect(line).not.toContain("\x1b");
272 expect(lines[1]).toBe("a/b:x�[2J.ts:1 evil�]0;t�");
273 const long = searchLines(ANSWER, "issues", 60);
274 for (const line of long) expect(line.length).toBeLessThanOrEqual(59);
275 });
276
277 test("searchLines says when code search was cut short", () => {
278 const lines = searchLines({ ...ANSWER, moreCode: true }, "code", 120);
279 expect(lines.at(-1)).toContain("not searched in time");
280 });
281
282 test("searchLines heads a group the forge cut with \"more not shown\"", () => {
283 const cut = { ...ANSWER, truncated: { repos: true, issues: false, code: true } };
284 const lines = searchLines(cut, "all", 120);
285 expect(lines).toContain("code (2, more not shown)");
286 expect(lines).toContain("issues (2)");
287 expect(lines).toContain("repos (1, more not shown)");
288 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs