feat: complete issue list filters, sort, and fields (task 0002)
All checks were successful
CI / test (pull_request) Successful in 28s
All checks were successful
CI / test (pull_request) Successful in 28s
Finish the `issue list` flag surface left minimal by the tracer slice: - Server-side filters `--label`, `--assignee`, `--author`, `--milestone`, mapped to Gitea's `labels`, `assigned_by`, `created_by`, `milestones`. - Client-side `--sort <created|updated|comments>`, always descending, over the fully paginated set (ADR 0005); `--limit` caps the sorted result. - `--fields` exposing body, closedAt, labels, milestone, updatedAt, url. - `--search` refused with a VALIDATION_ERROR redirecting to `search issues`. Exhaustive pagination lands as a shared `paginate.ts`, since ADR 0005 makes it a policy later slices reuse; `lookup.ts` drops its hand-rolled copy of the same loop. The helper carries the 20-page cap that copy encoded, matching the 1000-item ceiling Principle 8 sets.
This commit is contained in:
@@ -10,6 +10,41 @@ afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* A synthetic issue, for the sort and pagination cases where what matters is the
|
||||
* ordering keys rather than realistic content. `number` doubles as the identity
|
||||
* asserted on in the rendered output.
|
||||
*/
|
||||
function issueOf(
|
||||
number: number,
|
||||
keys: { created?: string; updated?: string; comments?: number } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: 1000 + number,
|
||||
number,
|
||||
title: `Issue ${number}`,
|
||||
body: "",
|
||||
state: "open",
|
||||
comments: keys.comments ?? 0,
|
||||
created_at: keys.created ?? "2026-01-01T00:00:00Z",
|
||||
updated_at: keys.updated ?? "2026-01-01T00:00:00Z",
|
||||
html_url: `http://gitea.example/testowner/testrepo/issues/${number}`,
|
||||
user: { id: 7, login: "alexion" },
|
||||
labels: [],
|
||||
milestone: null,
|
||||
assignees: null,
|
||||
pull_request: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The `number` column of every rendered row, in output order. */
|
||||
function renderedNumbers(stdout: string): number[] {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter((line) => /^ {2}\d+,/.test(line))
|
||||
.map((line) => Number(line.trim().split(",")[0]));
|
||||
}
|
||||
|
||||
describe("issue list", () => {
|
||||
it("lists open issues with default fields and a count line", async () => {
|
||||
server = await startFixtureServer([
|
||||
@@ -164,4 +199,319 @@ describe("issue list", () => {
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("emits no type field, since Gitea has no issue types", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, fixture: "issues-open.json" },
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(stdout).toContain("issues[3]{number,title,state,author,created}:");
|
||||
expect(stdout).not.toContain("type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list filters", () => {
|
||||
it("maps --label to the labels query param, passing names through", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--label", "bug,documentation"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.labels).toBe("bug,documentation");
|
||||
});
|
||||
|
||||
it("maps --assignee to assigned_by", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--assignee", "alexion"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.assigned_by).toBe("alexion");
|
||||
});
|
||||
|
||||
it("maps --author to created_by", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--author", "contributor"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.created_by).toBe("contributor");
|
||||
});
|
||||
|
||||
it("maps --milestone to milestones", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list", "--milestone", "v1.0"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(server.requests[0]!.query.milestones).toBe("v1.0");
|
||||
});
|
||||
|
||||
it("filters server-side: every filter travels in one request alongside type=issues", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: {
|
||||
labels: "bug",
|
||||
assigned_by: "alexion",
|
||||
created_by: "contributor",
|
||||
milestones: "v1.0",
|
||||
type: "issues",
|
||||
},
|
||||
headers: { "X-Total-Count": "1" },
|
||||
body: [],
|
||||
},
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
[
|
||||
"issue", "list",
|
||||
"--label", "bug",
|
||||
"--assignee", "alexion",
|
||||
"--author", "contributor",
|
||||
"--milestone", "v1.0",
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list --sort", () => {
|
||||
it("reorders by updated descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "updated"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
// Fixture order is 42, 41, 38; by updated_at it is 42 (Jul 8), 38 (Jul 1), 41 (Jun 20).
|
||||
expect(renderedNumbers(stdout)).toEqual([42, 38, 41]);
|
||||
});
|
||||
|
||||
it("reorders by comments descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "comments"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
// Comment counts: 42 has 2, 41 has 0, 38 has 5.
|
||||
expect(renderedNumbers(stdout)).toEqual([38, 42, 41]);
|
||||
});
|
||||
|
||||
it("reorders by created descending, client-side", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "created"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([42, 41, 38]);
|
||||
});
|
||||
|
||||
it("paginates fully before sorting, so a later page can outrank the first", async () => {
|
||||
// Page 1 is a full page of stale issues; the freshest issue of all sits on
|
||||
// page 2, so it can only lead the output if pagination completed first.
|
||||
const page1 = Array.from({ length: 50 }, (_, i) =>
|
||||
issueOf(100 + i, { updated: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
const page2 = [
|
||||
issueOf(7, { updated: "2026-07-09T00:00:00Z" }),
|
||||
issueOf(8, { updated: "2026-03-01T00:00:00Z" }),
|
||||
];
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { page: "1", limit: "50" },
|
||||
headers: { "X-Total-Count": "52" },
|
||||
body: page1,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { page: "2", limit: "50" },
|
||||
headers: { "X-Total-Count": "52" },
|
||||
body: page2,
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "updated"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(2);
|
||||
expect(renderedNumbers(stdout)[0]).toBe(7);
|
||||
// The count line keeps T from X-Total-Count: sorting reorders without
|
||||
// changing membership, so the unfiltered total stays accurate (ADR 0005).
|
||||
expect(stdout).toContain("count: 30 of 52 total");
|
||||
});
|
||||
|
||||
it("applies --limit to the sorted order, not to the fetched pages", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "17" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(
|
||||
["issue", "list", "--sort", "comments", "--limit", "2"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(renderedNumbers(stdout)).toEqual([38, 42]);
|
||||
expect(stdout).toContain("count: 2 of 17 total");
|
||||
// Pagination reads full pages regardless of --limit; the cap is applied after sorting.
|
||||
expect(server.requests[0]!.query.limit).toBe("50");
|
||||
});
|
||||
|
||||
it("reports the paginated set's own size when the instance omits X-Total-Count", async () => {
|
||||
// Everything was fetched to sort it, so the total is known even with no
|
||||
// header — the bare `count: N` form must never appear (Principle 4).
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, fixture: "issues-open.json" },
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--sort", "updated", "--limit", "2"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(stdout).toContain("count: 2 of 3 total");
|
||||
});
|
||||
|
||||
it("stops at the page cap when a server keeps returning full pages", async () => {
|
||||
// A server that ignores paging would otherwise loop forever.
|
||||
const fullPage = Array.from({ length: 50 }, (_, i) => issueOf(100 + i));
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, headers: { "X-Total-Count": "9999" }, body: fullPage },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["issue", "list", "--sort", "created"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("rejects an invalid --sort value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--sort", "banana"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue list --fields", () => {
|
||||
it("appends the selected extra fields, each via its extractor", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "1" },
|
||||
fixture: "issues-fields.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
[
|
||||
"issue", "list",
|
||||
"--state", "closed",
|
||||
"--fields", "body,closedAt,labels,milestone,updatedAt,url",
|
||||
],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain(
|
||||
"issues[1]{number,title,state,author,created,body,closedAt,labels,milestone,updatedAt,url}:",
|
||||
);
|
||||
const row = stdout.split("\n").find((line) => line.startsWith(" 50,"))!;
|
||||
expect(row).toContain("Raw body text, kept whole by the body field.");
|
||||
expect(row).toContain('"bug, priority: high"');
|
||||
expect(row).toContain("v1.0");
|
||||
expect(row).toContain("http://gitea.example/testowner/testrepo/issues/50");
|
||||
// closedAt and updatedAt render as relative times, like `created`.
|
||||
expect(row).toMatch(/\d+(mo|[smhdy]) ago/);
|
||||
});
|
||||
|
||||
it("rejects an unknown --fields name with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--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("issue list --search", () => {
|
||||
it("forbids --search, redirecting to search issues", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--search", "login bug"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
// TOON escapes the quotes around <query> inside the help string.
|
||||
expect(stdout).toContain("gitea-axi search issues");
|
||||
expect(stdout).toContain("<query>");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("forbids --search in its inline and bare forms too", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const inline = await runCliTest(["issue", "list", "--search=login"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
const bare = await runCliTest(["issue", "list", "--search"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
for (const result of [inline, bare]) {
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stdout).toContain("gitea-axi search issues");
|
||||
}
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user