feat: add search commands (task 0016) #17

Merged
alexion merged 2 commits from task-0016-search-commands into main 2026-07-14 10:24:15 -04:00
5 changed files with 695 additions and 7 deletions
Showing only changes of commit 4c3dde26b4 - Show all commits

View File

@@ -13,10 +13,21 @@ Both commands use the locator schema (`number`, `title`, `state`, `author`, `cre
## Acceptance criteria
- [ ] `search issues "<query>"` and `search prs "<query>"` 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 `<noun>[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 "<query>"` and `search prs "<query>"` 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 `<noun>[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`.

View File

@@ -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 <OWNER/NAME> Override the repository detected from the git origin remote
@@ -119,6 +122,7 @@ export async function runCli(options: RunCliOptions): Promise<number> {
issue: issueCommand(deps),
pr: prCommand(deps),
label: labelCommand(deps),
search: searchCommand(deps),
},
home: homeCommand(deps),
stdout: options.stdout,

237
src/commands/search.ts Normal file
View File

@@ -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 <issues|prs> <query> [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 <command> --help\` for the flags of a command.
`;
export const SEARCH_ISSUES_HELP = `usage: gitea-axi search issues <query> [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 <open|closed|all> Filter by state (default: open)
--label <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
export const SEARCH_PRS_HELP = `usage: gitea-axi search prs <query> [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 <open|closed|all> Filter by state (default: open)
--label <a,b> Filter by label name (comma-separated)
--limit <n> Maximum number of matches to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--help Show this help
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> 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<Issue>[] = [
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<string, FieldDef<Issue>> = {
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<string> {
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} "<query>"\``,
]);
}
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<Issue>((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} <number>`, "to see a match in full")],
});
}
export function searchCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
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",
]);
};
}

72
test/e2e/search.test.ts Normal file
View File

@@ -0,0 +1,72 @@
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<string, string> = {}): Record<string, string> {
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 () => {
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 () => {
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");
});
});

364
test/search.test.ts Normal file
View File

@@ -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<string, unknown> {
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);
});
}
});