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:
@@ -13,10 +13,21 @@ Both commands use the locator schema (`number`, `title`, `state`, `author`, `cre
|
|||||||
|
|
||||||
## Acceptance criteria
|
## 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
|
- [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
|
||||||
- [ ] The count line reports `count: N of T total` with `T` from the client-side-filtered set
|
- [x] 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)
|
- [x] A missing query yields `VALIDATION_ERROR` (exit 2)
|
||||||
- [ ] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema
|
- [x] `--state`, `--label`, `--limit`, and `--fields` work; default fields are the locator schema
|
||||||
- [ ] Empty results emit the standard `<noun>[0]: (none)` empty state
|
- [x] 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
|
- [x] 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] 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`.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
|||||||
import { issueCommand } from "./commands/issue.js";
|
import { issueCommand } from "./commands/issue.js";
|
||||||
import { labelCommand } from "./commands/label.js";
|
import { labelCommand } from "./commands/label.js";
|
||||||
import { prCommand } from "./commands/pr.js";
|
import { prCommand } from "./commands/pr.js";
|
||||||
|
import { searchCommand } from "./commands/search.js";
|
||||||
import { resolveRepoContext } from "./context.js";
|
import { resolveRepoContext } from "./context.js";
|
||||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||||
import { consumeFlagValue, splitFlag } from "./flags.js";
|
import { consumeFlagValue, splitFlag } from "./flags.js";
|
||||||
@@ -22,6 +23,8 @@ commands:
|
|||||||
pr comment Post a comment on a pull request
|
pr comment Post a comment on a pull request
|
||||||
label list List labels in the current repository
|
label list List labels in the current repository
|
||||||
label create Create a label
|
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:
|
global flags:
|
||||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
-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),
|
issue: issueCommand(deps),
|
||||||
pr: prCommand(deps),
|
pr: prCommand(deps),
|
||||||
label: labelCommand(deps),
|
label: labelCommand(deps),
|
||||||
|
search: searchCommand(deps),
|
||||||
},
|
},
|
||||||
home: homeCommand(deps),
|
home: homeCommand(deps),
|
||||||
stdout: options.stdout,
|
stdout: options.stdout,
|
||||||
|
|||||||
237
src/commands/search.ts
Normal file
237
src/commands/search.ts
Normal 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
72
test/e2e/search.test.ts
Normal 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
364
test/search.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user