diff --git a/.claude/tasks/0016-search-commands.md b/.claude/tasks/0016-search-commands.md index d3fdfe0..251427f 100644 --- a/.claude/tasks/0016-search-commands.md +++ b/.claude/tasks/0016-search-commands.md @@ -13,10 +13,21 @@ Both commands use the locator schema (`number`, `title`, `state`, `author`, `cre ## Acceptance criteria -- [ ] `search issues ""` and `search prs ""` query the search endpoint with the right `type` and owner, then filter to the current repo client-side -- [ ] The count line reports `count: N of T total` with `T` from the client-side-filtered set -- [ ] A missing query yields `VALIDATION_ERROR` (exit 2) -- [ ] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema -- [ ] Empty results emit the standard `[0]: (none)` empty state -- [ ] Fixture-server tests cover both types, cross-repo results being filtered out, and the missing-query validation -- [ ] End-to-end tests run `search issues` and `search prs` against a live Gitea instance and assert real matches are returned with the locator schema, confirming the live search-endpoint response shape and the `type`/`owner`/`q` query behavior the fixture server cannot attest to +- [x] `search issues ""` and `search prs ""` query the search endpoint with the right `type` and owner, then filter to the current repo client-side +- [x] The count line reports `count: N of T total` with `T` from the client-side-filtered set +- [x] A missing query yields `VALIDATION_ERROR` (exit 2) +- [x] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema +- [x] Empty results emit the standard `[0]: (none)` empty state +- [x] Fixture-server tests cover both types, cross-repo results being filtered out, and the missing-query validation +- [x] End-to-end tests run `search issues` and `search prs` against a live Gitea instance and assert real matches are returned with the locator schema, confirming the live search-endpoint response shape and the `type`/`owner`/`q` query behavior the fixture server cannot attest to + +## Implementation Notes + +- Both variants live in one `src/commands/search.ts`, parameterised by a `SearchKind` config (`type`, output `noun`, the `view` command a match feeds into, and `--help` text) — the same config-object dispatch used elsewhere (`pr.ts`'s `DependencyGroup`). + `search issues` and `search prs` share the endpoint call, the client-side repo filter, and the render, differing only in that config. +- The repo filter is *always* client-side (the endpoint has no repo-name param), so every call fully paginates via `fetchAllPages` and then filters to the current repo by matching each result's `repository.owner`/`repository.name` case-insensitively. + The count-line total `T` is the filtered set's own size, so the endpoint's cross-repo `X-Total-Count` is never used — the ADR 0005 client-side-filtering rule. +- `--limit` caps the shown rows *after* filtering while `T` keeps the full filtered total, matching `pr list`'s client-filter behaviour. +- `--label` is passed straight through as the endpoint's `labels` param (comma-separated names): the search endpoint takes names directly, so there is no name→id lookup, unlike `pr list --label`. +- The `--fields` extra-field vocabulary (`body`, `closedAt`, `labels`, `milestone`, `updatedAt`, `url`) mirrors `issue list`'s, since search results are Issue-shaped for both types. +- Added, beyond the bare acceptance criteria, ordinary CLI hygiene consistent with the sibling commands: a `search` group help, per-variant `--help` text, an unknown-subcommand `VALIDATION_ERROR`, a too-many-positionals rejection, and top-level-help entries in `cli.ts`. diff --git a/CLAUDE.md b/CLAUDE.md index 7a779eb..7ee913c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,7 @@ Fall back to `tea pr create --login alexion --base main --head ` only fo Task branches are merged into `main` on the remote, so the local `main` goes stale. Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at. + +Gitea's issue/PR search endpoint (`GET /repos/issues/search`, behind `search issues`/`search prs`) is backed by an **asynchronous, eventually-consistent issue indexer** (bleve by default). +Content created moments earlier may not be searchable yet, so end-to-end assertions that create an issue/PR and then search for it must poll (e.g. `expect.poll`) until it is indexed rather than searching once. +The fixture tier is unaffected — it stubs the endpoint — so this bites only the live `test/e2e` tier. diff --git a/src/cli.ts b/src/cli.ts index 0223f70..5c5e098 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,6 +3,7 @@ import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js"; import { issueCommand } from "./commands/issue.js"; import { labelCommand } from "./commands/label.js"; import { prCommand } from "./commands/pr.js"; +import { searchCommand } from "./commands/search.js"; import { resolveRepoContext } from "./context.js"; import type { CliDeps, GlobalFlags } from "./deps.js"; import { consumeFlagValue, splitFlag } from "./flags.js"; @@ -22,6 +23,8 @@ commands: pr comment Post a comment on a pull request label list List labels in the current repository label create Create a label + search issues Full-text search for issues in the current repository + search prs Full-text search for pull requests in the current repository global flags: -R, --repo Override the repository detected from the git origin remote @@ -119,6 +122,7 @@ export async function runCli(options: RunCliOptions): Promise { issue: issueCommand(deps), pr: prCommand(deps), label: labelCommand(deps), + search: searchCommand(deps), }, home: homeCommand(deps), stdout: options.stdout, diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..a442c29 --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,237 @@ +import type { Issue } from "gitea-js"; +import { createClient } from "../client.js"; +import { resolveRepoContext, type RepoContext } from "../context.js"; +import type { CliDeps } from "../deps.js"; +import { axiError, classifyHttpError } from "../errors.js"; +import { + extractRow, + joined, + lowercased, + pluck, + relativeTimeField, + selectExtraFields, + type FieldDef, +} from "../fields.js"; +import { flagValue, parseEnumFlag, parseFlags, parsePositiveInt } from "../flags.js"; +import { fetchAllPages } from "../paginate.js"; +import { formatCountLine, renderList } from "../render.js"; +import { suggestCommand } from "../suggestions.js"; + +export const SEARCH_HELP = `usage: gitea-axi search [flags] + +Full-text search within the current repository. The positional query is +required. + +commands: + issues Search issues in the current repository + prs Search pull requests in the current repository + +Run \`gitea-axi search --help\` for the flags of a command. +`; + +export const SEARCH_ISSUES_HELP = `usage: gitea-axi search issues [flags] + +Full-text search for issues in the current repository. Results are found across +the repositories the owner can access and filtered to the current one, so the +count reflects the current repository's matches. Use the number with +\`issue view\` to load a match in full. + +flags: + --state Filter by state (default: open) + --label Filter by label name (comma-separated) + --limit Maximum number of matches to return (default: 30) + --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +export const SEARCH_PRS_HELP = `usage: gitea-axi search prs [flags] + +Full-text search for pull requests in the current repository. Results are found +across the repositories the owner can access and filtered to the current one, so +the count reflects the current repository's matches. Use the number with +\`pr view\` to load a match in full. + +flags: + --state Filter by state (default: open) + --label Filter by label name (comma-separated) + --limit Maximum number of matches to return (default: 30) + --fields Append extra fields: body, closedAt, labels, milestone, updatedAt, url + --help Show this help + +global flags: + -R, --repo Override the repository detected from the git origin remote + --login Select a tea login profile by name +`; + +// The locator schema (spec): enough to recognise a match and feed its number +// into `issue view` / `pr view`, which load the full detail. `draft`/`review` +// parity with the list commands would cost extra fetches per result for a +// command whose job is only to find the number. +const SEARCH_FIELDS: FieldDef[] = [ + pluck("number"), + pluck("title"), + lowercased("state"), + pluck("author", "user.login"), + relativeTimeField("created", "created_at"), +]; + +// Appended to the locator schema on request via `--fields`, never replacing it. +// Results are Issue-shaped, so this mirrors `issue list`'s extra-field vocabulary. +const SEARCH_EXTRA_FIELDS: Record> = { + body: pluck("body"), + closedAt: relativeTimeField("closedAt", "closed_at"), + labels: joined("labels", "labels", "name"), + milestone: pluck("milestone", "milestone.title"), + updatedAt: relativeTimeField("updatedAt", "updated_at"), + url: pluck("url", "html_url"), +}; + +/** + * The two search variants. `search issues` and `search prs` are the same + * endpoint call, repo filter, and render, differing only in the `type` param, + * the output block noun, and the `view` command a match feeds into. + */ +interface SearchKind { + /** Subcommand as typed, for help and error text. */ + command: string; + /** Gitea search `type` param: issues or pull requests. */ + type: "issues" | "pulls"; + /** Output block noun, matching the list commands. */ + noun: string; + /** The command a matched number feeds into. */ + viewCommand: string; + /** The `--help` text for this variant. */ + help: string; +} + +const SEARCH_ISSUES: SearchKind = { + command: "search issues", + type: "issues", + noun: "issues", + viewCommand: "issue view", + help: SEARCH_ISSUES_HELP, +}; + +const SEARCH_PRS: SearchKind = { + command: "search prs", + type: "pulls", + noun: "pull_requests", + viewCommand: "pr view", + help: SEARCH_PRS_HELP, +}; + +const SEARCH_STATES = ["open", "closed", "all"] as const; +type SearchState = (typeof SEARCH_STATES)[number]; + +const DEFAULT_LIMIT = 30; + +/** + * Whether a search result belongs to the current repository. The search + * endpoint spans every repo the owner has, so results are filtered here via + * each result's `repository` field (owner and name matched case-insensitively, + * as Gitea treats them). + */ +function inCurrentRepo(issue: Issue, context: RepoContext): boolean { + const repo = issue.repository; + return ( + repo?.owner?.toLowerCase() === context.owner.toLowerCase() && + repo?.name?.toLowerCase() === context.name.toLowerCase() + ); +} + +async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promise { + if (args.includes("--help")) { + return kind.help; + } + const helpSuggestion = [`Run \`gitea-axi ${kind.command} --help\` to see available flags`]; + const { flags, positionals } = parseFlags( + args, + { + "--state": { takesValue: true }, + "--label": { takesValue: true }, + "--limit": { takesValue: true }, + "--fields": { takesValue: true }, + }, + kind.command, + ); + if (positionals.length === 0) { + throw axiError(`${kind.command} requires a query`, "VALIDATION_ERROR", [ + `Run \`gitea-axi ${kind.command} ""\``, + ]); + } + if (positionals.length > 1) { + throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion); + } + const query = positionals[0]!; + const state: SearchState = + parseEnumFlag(flags["--state"], "--state", SEARCH_STATES, helpSuggestion) ?? "open"; + // The search endpoint's `labels` param takes comma-separated names directly, so + // `--label` passes straight through — no name→id lookup, unlike `pr list`. + const labels = flagValue(flags, "--label"); + const limitFlag = flags["--limit"]; + const limit = + limitFlag === undefined ? DEFAULT_LIMIT : parsePositiveInt(limitFlag, "--limit", helpSuggestion); + const extraFields = selectExtraFields( + flagValue(flags, "--fields"), + SEARCH_EXTRA_FIELDS, + kind.command, + ); + + const context = await resolveRepoContext(deps); + const api = createClient(context); + + // The repo filter has no API param, so the whole result set is paged in and + // filtered here; the filtered set's own size is the count-line total, since + // the endpoint's total spans every repo (ADR 0005 client-side policy). + let matches: Issue[]; + try { + const result = await fetchAllPages((page, pageLimit) => + api.repos.issueSearchIssues({ + q: query, + type: kind.type, + owner: context.owner, + state, + ...(labels !== undefined ? { labels } : {}), + page, + limit: pageLimit, + }), + ); + matches = result.items.filter((issue) => inCurrentRepo(issue, context)); + } catch (error) { + throw classifyHttpError(error); + } + + const total = matches.length; + const shown = matches.slice(0, limit); + const now = new Date(); + const rows = shown.map((issue) => extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now })); + + return renderList({ + noun: kind.noun, + rows, + countLine: formatCountLine(rows.length, total, false), + help: [suggestCommand(context, `${kind.viewCommand} `, "to see a match in full")], + }); +} + +export function searchCommand(deps: CliDeps) { + return async (args: string[]): Promise => { + const [subcommand, ...rest] = args; + if (!subcommand || subcommand === "--help") { + return SEARCH_HELP; + } + if (subcommand === "issues") { + return runSearch(deps, rest, SEARCH_ISSUES); + } + if (subcommand === "prs") { + return runSearch(deps, rest, SEARCH_PRS); + } + throw axiError(`Unknown search command: ${subcommand}`, "VALIDATION_ERROR", [ + "Run `gitea-axi search --help` to see available search commands", + ]); + }; +} diff --git a/test/e2e/search.test.ts b/test/e2e/search.test.ts new file mode 100644 index 0000000..929a426 --- /dev/null +++ b/test/e2e/search.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { runCliTest } from "../harness.js"; +import { provisionInstance, seedBranch, type E2EInstance } from "./provision.js"; + +/** + * The end-to-end tier for the search commands. The `/repos/issues/search` + * endpoint spans repositories and reports an unfiltered cross-repo total, so the + * live response shape and the `type`/`owner`/`q` query behavior are precisely + * what the fixture server can only simulate. Here both variants run against a + * live, disposable Gitea instance and are asserted to return real matches under + * the locator schema. + */ +const E2E_URL = process.env.GITEA_AXI_E2E_URL; + +const RELATIVE_TIME = /(just now|\d+(m|h|d|mo|y) ago)/; + +describe.skipIf(!E2E_URL)("end-to-end: search commands", () => { + let instance: E2EInstance; + const branch = "e2e-search-branch"; + + function env(overrides: Record = {}): Record { + return { + GITEA_AXI_API_URL: instance.baseUrl, + GITEA_AXI_TOKEN: instance.token, + GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`, + ...overrides, + }; + } + + beforeAll(async () => { + instance = await provisionInstance(E2E_URL!); + // A fresh repo has no pull requests; seed a branch with a diff and open one + // through the CLI dogfood path so `search prs` has a real match to find. + await seedBranch(instance, branch); + const created = await runCliTest( + [ + "pr", "create", + "--title", "E2E search pull request", + "--head", branch, + "--base", "main", + ], + { env: env() }, + ); + expect(created.exitCode).toBe(0); + }, 150_000); + + it("returns live issue matches under the locator schema", async () => { + // Gitea's search endpoint is backed by an eventually-consistent issue + // indexer, so poll until the seeded issue has been indexed and surfaces + // under the locator-schema header before asserting on the exact output. + await expect + .poll( + async () => (await runCliTest(["search", "issues", "issue"], { env: env() })).stdout, + { timeout: 20_000, interval: 500 }, + ) + .toMatch(/^issues\[\d+\]\{number,title,state,author,created\}:$/m); + + const { stdout, exitCode } = await runCliTest(["search", "issues", "issue"], { + env: env(), + }); + + expect(exitCode).toBe(0); + // The block header is the default locator schema: the live search response + // is Issue-shaped and rendered by the same field set as `issue list`. + expect(stdout).toMatch(/^issues\[\d+\]\{number,title,state,author,created\}:$/m); + // Real matches from the current repo: a seeded open issue title, the author + // column (the instance owner), and a rendered relative-time `created`. + expect(stdout).toContain(instance.openTitles[0]!); + expect(stdout).toContain(instance.owner); + expect(stdout).toMatch(RELATIVE_TIME); + }); + + it("returns live pull-request matches under the locator schema", async () => { + // The PR was opened moments ago in beforeAll; the indexer needs a beat to + // catch up, so poll the search until the PR surfaces before asserting. + await expect + .poll( + async () => (await runCliTest(["search", "prs", "search"], { env: env() })).stdout, + { timeout: 20_000, interval: 500 }, + ) + .toMatch(/^pull_requests\[\d+\]\{number,title,state,author,created\}:$/m); + + const { stdout, exitCode } = await runCliTest(["search", "prs", "search"], { + env: env(), + }); + + expect(exitCode).toBe(0); + expect(stdout).toMatch(/^pull_requests\[\d+\]\{number,title,state,author,created\}:$/m); + expect(stdout).toContain("E2E search pull request"); + }); +}); diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 0000000..b8131ee --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,364 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; +import { runCliTest, testModeEnv } from "./harness.js"; + +const SEARCH_PATH = "/api/v1/repos/issues/search"; + +/** The current repo the test env is pinned to; results must carry it to survive the client-side filter. */ +const CURRENT_REPO = { + id: 1, + name: "testrepo", + owner: "testowner", + full_name: "testowner/testrepo", +}; + +let server: FixtureServer; + +afterEach(async () => { + await server.close(); +}); + +/** + * A cross-repo search result, Issue-shaped like `issue list` results. The + * `repository` field defaults to the current repo so the result passes the + * client-side repo filter; `number` doubles as the identity asserted on. + */ +function searchIssueOf( + number: number, + options: { + title?: string; + author?: string; + repository?: unknown; + body?: string; + labels?: { id: number; name: string }[]; + milestone?: { title: string } | null; + closed_at?: string | null; + } = {}, +): Record { + return { + id: 1000 + number, + number, + title: options.title ?? `Issue ${number}`, + body: options.body ?? "", + state: "open", + comments: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + closed_at: options.closed_at ?? null, + html_url: `http://gitea.example/testowner/testrepo/issues/${number}`, + user: { id: 7, login: options.author ?? "alexion" }, + labels: options.labels ?? [], + milestone: options.milestone ?? null, + assignees: null, + pull_request: null, + repository: options.repository ?? CURRENT_REPO, + }; +} + +describe("search issues", () => { + it("queries the search endpoint with type=issues and owner, then renders repo results", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + query: { q: "login bug", type: "issues", owner: "testowner" }, + headers: { "X-Total-Count": "2" }, + body: [ + searchIssueOf(42, { title: "Fix login redirect loop", author: "alexion" }), + searchIssueOf(41, { title: "Login button unresponsive", author: "contributor" }), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest(["search", "issues", "login bug"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + + const lines = stdout.split("\n"); + expect(lines).toContain("count: 2 of 2 total"); + expect(lines).toContain("issues[2]{number,title,state,author,created}:"); + expect(stdout).toMatch(/^ {2}42,Fix login redirect loop,open,alexion,\d+(mo|[smhdy]) ago$/m); + expect(stdout).toMatch(/^ {2}41,Login button unresponsive,open,contributor,\d+(mo|[smhdy]) ago$/m); + expect(stdout).toMatch(/^help\[\d+\]:/m); + + expect(server.requests[0]!.query.type).toBe("issues"); + expect(server.requests[0]!.query.owner).toBe("testowner"); + expect(server.requests[0]!.query.q).toBe("login bug"); + }); +}); + +describe("search prs", () => { + it("queries the search endpoint with type=pulls and owner, then renders repo results", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + query: { q: "flaky ci", type: "pulls", owner: "testowner" }, + headers: { "X-Total-Count": "1" }, + body: [searchIssueOf(73, { title: "Retry flaky CI jobs", author: "contributor" })], + }, + ]); + + const { stdout, exitCode } = await runCliTest(["search", "prs", "flaky ci"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + + const lines = stdout.split("\n"); + expect(lines).toContain("count: 1 of 1 total"); + expect(lines).toContain("pull_requests[1]{number,title,state,author,created}:"); + expect(stdout).toMatch(/^ {2}73,Retry flaky CI jobs,open,contributor,\d+(mo|[smhdy]) ago$/m); + expect(stdout).toMatch(/^help\[\d+\]:/m); + + expect(server.requests[0]!.query.type).toBe("pulls"); + expect(server.requests[0]!.query.owner).toBe("testowner"); + expect(server.requests[0]!.query.q).toBe("flaky ci"); + }); +}); + +describe("search issues cross-repo filtering", () => { + it("drops results from other repos and counts only the filtered set", async () => { + const OTHER_REPO = { + id: 9, + name: "otherrepo", + owner: "otherowner", + full_name: "otherowner/otherrepo", + }; + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + query: { q: "login bug", type: "issues", owner: "testowner" }, + // The unfiltered, cross-repo total the endpoint reports — misleading once + // the client-side repo filter runs, so the command must ignore it. + headers: { "X-Total-Count": "50" }, + body: [ + searchIssueOf(42, { title: "Fix login redirect loop" }), + searchIssueOf(88, { title: "Login bug in other repo", repository: OTHER_REPO }), + searchIssueOf(41, { title: "Login button unresponsive" }), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest(["search", "issues", "login bug"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + + const renderedNumbers = [...stdout.matchAll(/^ {2}(\d+),/gm)].map((match) => Number(match[1])); + expect(renderedNumbers).toEqual([42, 41]); + + const lines = stdout.split("\n"); + expect(lines).toContain("count: 2 of 2 total"); + expect(stdout).not.toContain("of 50 total"); + }); +}); + +describe("search missing query", () => { + it("rejects a missing query with VALIDATION_ERROR and no network call", async () => { + server = await startFixtureServer([]); + + const issues = await runCliTest(["search", "issues"], { + env: testModeEnv(server.url), + }); + const prs = await runCliTest(["search", "prs"], { + env: testModeEnv(server.url), + }); + + for (const result of [issues, prs]) { + expect(result.exitCode).toBe(2); + expect(result.stdout).toContain("code: VALIDATION_ERROR"); + } + expect(server.requests).toHaveLength(0); + }); +}); + +describe("search issues --state", () => { + it("forwards --state to the state query param", async () => { + server = await startFixtureServer([ + { method: "GET", path: SEARCH_PATH, body: [] }, + ]); + + const { exitCode } = await runCliTest( + ["search", "issues", "login bug", "--state", "closed"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(server.requests[0]!.query.state).toBe("closed"); + }); + + it("defaults the state param to open when no flag is given", async () => { + server = await startFixtureServer([ + { method: "GET", path: SEARCH_PATH, body: [] }, + ]); + + await runCliTest(["search", "issues", "login bug"], { + env: testModeEnv(server.url), + }); + + expect(server.requests[0]!.query.state).toBe("open"); + }); + + it("rejects an out-of-set --state value with VALIDATION_ERROR and no request", async () => { + server = await startFixtureServer([]); + + const { stdout, exitCode } = await runCliTest( + ["search", "issues", "login bug", "--state", "banana"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); +}); + +describe("search issues --label", () => { + it("passes comma-separated label names straight through, with no label lookup", async () => { + server = await startFixtureServer([ + { method: "GET", path: SEARCH_PATH, body: [] }, + ]); + + const { exitCode } = await runCliTest( + ["search", "issues", "login bug", "--label", "bug,urgent"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + // Names go straight to the endpoint's `labels` param — no name→id resolution. + expect(server.requests[0]!.query.labels).toBe("bug,urgent"); + // The endpoint takes names directly, so there is no /labels lookup call. + expect(server.requests.every((request) => request.path === SEARCH_PATH)).toBe(true); + }); + + it("omits the labels param when no --label flag is given", async () => { + server = await startFixtureServer([ + { method: "GET", path: SEARCH_PATH, body: [] }, + ]); + + await runCliTest(["search", "issues", "login bug"], { + env: testModeEnv(server.url), + }); + + expect(server.requests[0]!.query.labels).toBeUndefined(); + }); +}); + +describe("search issues --limit", () => { + it("caps the shown rows at --limit while the count total keeps the full filtered size", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + body: [ + searchIssueOf(42), + searchIssueOf(41), + searchIssueOf(40), + searchIssueOf(39), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest( + ["search", "issues", "login bug", "--limit", "2"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + + const renderedNumbers = [...stdout.matchAll(/^ {2}(\d+),/gm)].map((match) => Number(match[1])); + expect(renderedNumbers).toHaveLength(2); + + // Shown 2, filtered total 4 — the cap trims N, not T (ADR 0005). + expect(stdout.split("\n")).toContain("count: 2 of 4 total"); + }); + + it("rejects a non-numeric --limit with VALIDATION_ERROR and no request", async () => { + server = await startFixtureServer([]); + + const { stdout, exitCode } = await runCliTest( + ["search", "issues", "login bug", "--limit", "abc"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(server.requests).toHaveLength(0); + }); +}); + +describe("search issues --fields", () => { + it("appends the requested extras onto the default locator schema", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: SEARCH_PATH, + body: [ + searchIssueOf(42, { + title: "Fix login redirect loop", + labels: [{ id: 1, name: "bug" }], + milestone: { title: "v1.0" }, + }), + ], + }, + ]); + + const { stdout, exitCode } = await runCliTest( + ["search", "issues", "login bug", "--fields", "labels,url"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout.split("\n")).toContain( + "issues[1]{number,title,state,author,created,labels,url}:", + ); + + const row = stdout.split("\n").find((line) => line.startsWith(" 42,"))!; + expect(row).toContain("bug"); + expect(row).toContain("http://gitea.example/testowner/testrepo/issues/42"); + }); + + it("rejects an unknown --fields name with VALIDATION_ERROR", async () => { + server = await startFixtureServer([]); + + const { stdout, exitCode } = await runCliTest( + ["search", "issues", "login bug", "--fields", "bogus"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("bogus"); + expect(server.requests).toHaveLength(0); + }); +}); + +describe("search empty results", () => { + const cases: { subcommand: string; noun: string }[] = [ + { subcommand: "issues", noun: "issues" }, + { subcommand: "prs", noun: "pull_requests" }, + ]; + + for (const { subcommand, noun } of cases) { + it(`emits the standard ${noun}[0]: (none) empty state`, async () => { + server = await startFixtureServer([ + { method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] }, + ]); + + const { stdout, exitCode } = await runCliTest( + ["search", subcommand, "login bug"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain(`${noun}[0]: (none)`); + expect(stdout).toContain("count: 0 of 0 total"); + expect(stdout).toMatch(/^help\[\d+\]:/m); + }); + } +});