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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7378-forge-search-words-finds-code-issues-and → main 8 commits fork 2 threads · 2 unresolved
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' | ||
| 330 | 330 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] |
| 331 | 331 | forge asks --close 2 --reason "words" # also --dismiss 4; --yes skips the question (needed without a TTY) |
| 332 | 332 | forge asks --undo <id> # take back a decision by the receipt id it printed |
| 333 | forge 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 | |
| 333 | 335 | |
| 334 | 336 | |
| 335 | 337 | `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. | ||
| 47 | 47 | | 3 | not signed in, or the stored token is unusable | |
| 48 | 48 | | 4 | partial: the run finished, but at least one repo did not | |
| 49 | 49 | | 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 | | |
| 50 | 51 | |
| 51 | 52 | A script can branch on these without parsing text. |
| 52 | 53 | |
| @@ -288,6 +289,33 @@ look alice/shop Pull #3 is green — look and merge https://forge.example. | ||
| 288 | 289 | parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9 |
| 289 | 290 | |
| 290 | 291 | |
| 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 | ||
| 291 | 319 | ## forge ship |
| 292 | 320 | |
| 293 | 321 | 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 { | ||
| 16 | 16 | readonly code: string; |
| 17 | 17 | readonly message: string; |
| 18 | 18 | readonly status: number; |
| 19 | /** Seconds the forge asked us to wait (`retry-after`), when it said. */ | |
| 20 | readonly retryAfter?: number; | |
| 19 | 21 | } |
| 20 | 22 | |
| 21 | 23 | export type ApiResult<T> = Result<T, ApiError>; |
| @@ -137,6 +139,7 @@ export class ForgeApi { | ||
| 137 | 139 | } |
| 138 | 140 | if (!response.ok) { |
| 139 | 141 | const body = parsed as { error?: unknown; message?: unknown }; |
| 142 | const retryAfter = Number(response.headers.get("retry-after") ?? ""); | |
| 140 | 143 | // Some refusals are a bare code (`{ error: "checks_required" }`): the |
| 141 | 144 | // code is then the forge's own word, and better than a status line. |
| 142 | 145 | return err({ |
| @@ -148,6 +151,7 @@ export class ForgeApi { | ||
| 148 | 151 | ? body.error |
| 149 | 152 | : `${response.status} from ${path}`, |
| 150 | 153 | status: response.status, |
| 154 | ...(Number.isInteger(retryAfter) && retryAfter > 0 ? { retryAfter } : {}), | |
| 151 | 155 | }); |
| 152 | 156 | } |
| 153 | 157 | return ok(parsed as T); |
| @@ -356,6 +360,22 @@ export class ForgeApi { | ||
| 356 | 360 | return ok({ raw: out.value, asks }); |
| 357 | 361 | } |
| 358 | 362 | |
| 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 | ||
| 359 | 379 | /** |
| 360 | 380 | * One action on many asks (`POST /api/v1/asks/bulk`): the plan while |
| 361 | 381 | * `preview` is true, the deed and its receipt when false. |
src/cli/main.ts +208 −1
| @@ -7,6 +7,7 @@ | ||
| 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 search <words> code, issues, pulls and repos you can read | |
| 10 | 11 | * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N |
| 11 | 12 | * forge asks --undo <id> take back what that decided |
| 12 | 13 | * forge ship <number> land a pull after a y/N, as its Merge button does |
| @@ -61,9 +62,11 @@ export const EXIT = { | ||
| 61 | 62 | partial: 4, |
| 62 | 63 | /** The forge heard the request and said no: a rule, a conflict, a hold. */ |
| 63 | 64 | refused: 5, |
| 65 | /** The forge said "not now"; it named when to try again. */ | |
| 66 | rateLimited: 6, | |
| 64 | 67 | } as const; |
| 65 | 68 | |
| 66 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "reason"]; | |
| 69 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason"]; | |
| 67 | 70 | |
| 68 | 71 | /** What the CLI needs from the person at the keyboard; a test fakes it. */ |
| 69 | 72 | export interface Io { |
| @@ -120,6 +123,8 @@ export async function main( | ||
| 120 | 123 | return openRepo(out, args); |
| 121 | 124 | case "asks": |
| 122 | 125 | return asks(out, args, argv.slice(1)); |
| 126 | case "search": | |
| 127 | return search(out, args); | |
| 123 | 128 | case "ship": |
| 124 | 129 | return ship(out, args, io); |
| 125 | 130 | case "ui": |
| @@ -154,6 +159,10 @@ function usage(out: Out): void { | ||
| 154 | 159 | out.line( |
| 155 | 160 | b(" forge asks") + " [--repo owner/name] [--json]", |
| 156 | 161 | ); |
| 162 | out.line( | |
| 163 | b(" forge search <words…>") + | |
| 164 | " [--kind code|issues|pulls|repos|all] [--limit N] [--json]", | |
| 165 | ); | |
| 157 | 166 | out.line( |
| 158 | 167 | b(" forge asks --ship|--close|--dismiss <n…>") + |
| 159 | 168 | " [--reason words] [--yes]", |
| @@ -710,6 +719,204 @@ function onlyRepo(raw: unknown, repo: string): unknown { | ||
| 710 | 719 | }; |
| 711 | 720 | } |
| 712 | 721 | |
| 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 | ||
| 713 | 920 | // ── landing a pull ─────────────────────────────────────────────────────── |
| 714 | 921 | |
| 715 | 922 | /** |
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 | }); |
new above
offline