feat(search): guide a 0-result search and fill the single-match number

The next-step suggestion on `search issues`/`search prs` is now conditioned
on the in-repo match count. On a miss it pointed the agent at `view <number>`,
which is nonsensical when nothing matched; it now suggests the non-indexed
`issue list --state all` / `pr list --state all` fallback, which recovers from
both an over-narrow query and issue-indexer lag without naming the cause. On
exactly one match it fills the real number (`issue view 2`), applying AXI
Principle 9's single-id fill. Two or more matches keep the parameterized
placeholder.

Search stays a locator — it never auto-loads the detail even on a single
match; ADR 0017 records that decision (a deliberate narrowing of Principle 4)
and the CONTEXT.md search term is updated to match.
This commit is contained in:
2026-07-19 10:17:14 -04:00
parent 0b0186674e
commit 5422f49f38
4 changed files with 196 additions and 1 deletions

View File

@@ -402,3 +402,149 @@ describe("search empty results", () => {
});
}
});
/**
* The `help[1]:` next-step line is emitted by `suggestCommand`, so it appears
* wrapped in a `Run \`gitea-axi …\`` line with `-R`/`--login` normalization.
* These tests pull that help block out of the output and assert on the
* count-conditional suggestion within it.
*/
function helpBlock(stdout: string): string {
const lines = stdout.split("\n");
const start = lines.findIndex((line) => /^help\[\d+\]:/.test(line));
expect(start).toBeGreaterThanOrEqual(0);
// The block runs from the help[N]: header to the next blank line / EOF.
const rest = lines.slice(start);
const end = rest.findIndex((line, i) => i > 0 && line.trim() === "");
return (end === -1 ? rest : rest.slice(0, end)).join("\n");
}
describe("search issues count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue list --state all");
expect(help).toContain("to list all issues instead");
expect(help).not.toContain("issue view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Fix login redirect loop" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("issue view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(42, { title: "Fix login redirect loop" }),
searchIssueOf(41, { title: "Login button unresponsive" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view <number>");
expect(help).toContain("to see a match in full");
});
});
describe("search prs count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr list --state all");
expect(help).toContain("to list all pull requests instead");
expect(help).not.toContain("pr view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Retry flaky CI jobs" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("pr view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(73, { title: "Retry flaky CI jobs" }),
searchIssueOf(72, { title: "Stabilize CI runners" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view <number>");
expect(help).toContain("to see a match in full");
});
});