feat: add search commands (task 0016)
Some checks failed
CI / test (pull_request) Failing after 45s

Add `search issues <query>` and `search prs <query>`, the full-text escape
hatch the forbidden `--search` flag on the list commands redirects to.

Both hit Gitea's cross-repo issue-search endpoint with the query, a `type`
of issues or pulls, and the owner param, then filter results to the current
repository client-side via each result's `repository` field — the endpoint
has no repo-name filter. The count line reports `count: N of T total` with
`T` from the filtered set (ADR 0005), never the endpoint's cross-repo
`X-Total-Count`.

The positional query is required (VALIDATION_ERROR if missing). Flags:
`--state` (default open), `--label` (comma-separated names passed straight
through as the API `labels` param), `--limit` (default 30), and `--fields`.
Default output is the locator schema (`number`, `title`, `state`, `author`,
`created`) under `issues:` / `pull_requests:` blocks matching the list
commands — search finds the number, `issue view` / `pr view` load the detail.

Covered by fixture-server tests for both types, cross-repo filtering, the
count rule, each flag, the empty state, and missing-query validation, plus
end-to-end tests against a live Gitea instance.
This commit is contained in:
2026-07-14 09:55:20 -04:00
parent 6661239afe
commit 4c3dde26b4
5 changed files with 695 additions and 7 deletions

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);
});
}
});