Compare commits

...

4 Commits

Author SHA1 Message Date
19018772d7 Merge branch 'main' into task-0002-issue-list-filters-and-fields
All checks were successful
CI / test (pull_request) Successful in 26s
2026-07-12 09:28:46 -04:00
13883fa6e1 docs: capture the --fields body truncation conflict as task 0021
All checks were successful
CI / test (pull_request) Successful in 28s
2026-07-12 09:25:57 -04:00
9a902d2d37 docs: prefer gitea-axi over tea for opening pull requests
All checks were successful
CI / test (pull_request) Successful in 27s
2026-07-11 23:06:30 -04:00
605c46f73a feat: complete issue list filters, sort, and fields (task 0002)
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.
2026-07-11 23:05:47 -04:00
8 changed files with 655 additions and 56 deletions

View File

@@ -13,9 +13,25 @@ Field selection: `--fields <a,b,c>` exposing the extra fields `body` (raw), `clo
## Acceptance criteria
- [ ] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
- [ ] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
- [ ] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
- [ ] Output contains no `type` field
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
- [ ] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
- [x] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
- [x] `--sort <created|updated|comments>` reorders descending client-side after full pagination; the count line still reports `T` from `X-Total-Count`
- [x] `--fields` selects among the documented extra fields, each rendered via its FieldDef extractor (relative times, joined label names, milestone title)
- [x] Output contains no `type` field
- [x] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
- [x] Fixture-server tests cover each filter, client-side sort with pagination, `--fields` extraction, and the forbidden `--search`
## Implementation Notes
Exhaustive pagination landed as a shared `src/paginate.ts` (`fetchAllPages`, `readTotalCount`), since ADR 0005 makes it a policy that later slices (`pr list`, the dashboard) reuse rather than a detail of this command.
`lookup.ts`'s `listAllLabels` hand-rolled the same loop and now calls the shared helper, which removed its local `LABEL_PAGE_SIZE`/`LABEL_PAGE_LIMIT` constants.
The helper carries the 20-page cap those constants encoded, matching the 1000-item ceiling Principle 8 sets on exhaustive pagination — without it a server that ignores paging would loop forever.
Two count-line details the acceptance criteria did not spell out.
Under `--sort`, `--limit` caps the *sorted* result rather than the fetch, so pages are always read at the full page size of 50 and the top `N` by the sort key is what the limit selects.
Also under `--sort`, when an instance omits `X-Total-Count`, the total falls back to the size of the fully paginated set instead of degrading to `count: N (showing first N)` — everything was fetched to sort it, so the total is known, and Principle 4 says a total is always reported.
`--sort` and `--state` shared an enum-parsing shape, now extracted as `parseEnumFlag` in `flags.ts`.
**Open question for the spec, deliberately not resolved here:** `--fields body` renders the body raw and untruncated, exactly as this task and the spec's command surface specify ("`body` (raw)"), matching the already-merged `issue create --fields body`.
This contradicts Principle 3 ("Body text is truncated at **500 characters** in all contexts (list and detail alike)"): a 30-row list with `--fields body` can now emit 30 full bodies, which is the cost Principle 3 exists to prevent.
Truncating here alone would make `issue list` disagree with `issue create`, so the conflict wants one ruling applied to both commands rather than a silent divergence in this slice.

View File

@@ -7,7 +7,12 @@ Any commit message you write must follow the Conventional Commits specification
## Gotchas
The `origin` remote is a self-hosted **Gitea** instance (`git.alexion.dev`), not GitHub.
The `gh` CLI does not work here — open pull requests with `tea pr create --login axi --base main --head <branch>`, and list them with `tea pr`.
The `gh` CLI does not work here.
Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path:
`npm run build && node dist/main.js pr create --login axi --base main --head <branch> --title <text> --body-file <path>`.
It reuses the `tea` login profiles, so it needs no separate credentials.
Fall back to `tea pr create --login axi --base main --head <branch>` only for what gitea-axi cannot do yet; `tea pr` still lists PRs until `pr list` lands (task 0008).
Task branches are merged into `main` on the remote, so the local `main` goes stale.
Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at.

View File

@@ -15,8 +15,15 @@ import {
selectExtraFields,
type FieldDef,
} from "../fields.js";
import { flagValue, parseFlags, parsePositionalNumber } from "../flags.js";
import {
flagValue,
parseEnumFlag,
parseFlags,
parsePositionalNumber,
splitFlag,
} from "../flags.js";
import { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
import { fetchAllPages, readTotalCount } from "../paginate.js";
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
import { relativeTime } from "../time.js";
import { suggestCommand } from "../suggestions.js";
@@ -87,7 +94,13 @@ List issues in the current repository. Pull requests are never included.
flags:
--state <open|closed|all> Filter by state (default: open)
--label <a,b> Filter by label name (comma-separated)
--assignee <login> Filter by assignee
--author <login> Filter by author
--milestone <name> Filter by milestone name
--sort <created|updated|comments> Sort descending (client-side)
--limit <n> Maximum number of issues to return (default: 30)
--fields <a,b,c> Append extra fields: body, closedAt, labels, milestone, updatedAt, url
--help Show this help
global flags:
@@ -103,27 +116,97 @@ const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
relativeTimeField("created", "created_at"),
];
// Appended to the defaults on request via `--fields`, never replacing them.
const ISSUE_LIST_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"),
};
const ISSUE_STATES = ["open", "closed", "all"] as const;
type IssueState = (typeof ISSUE_STATES)[number];
const ISSUE_SORTS = ["created", "updated", "comments"] as const;
type IssueSort = (typeof ISSUE_SORTS)[number];
/** Sort keys, read descending. A missing or unparseable value sorts last. */
const ISSUE_SORT_KEYS: Record<IssueSort, (issue: Issue) => number> = {
created: (issue) => timestamp(issue.created_at),
updated: (issue) => timestamp(issue.updated_at),
comments: (issue) => issue.comments ?? 0,
};
const DEFAULT_LIMIT = 30;
const ISSUE_LIST_HELP_SUGGESTION = [
"Run `gitea-axi issue list --help` to see available flags",
];
function timestamp(iso: string | undefined): number {
const value = Date.parse(iso ?? "");
return Number.isNaN(value) ? 0 : value;
}
function parseState(value: string | true | undefined): IssueState {
if (value === undefined) {
return "open";
return parseEnumFlag(value, "--state", ISSUE_STATES, ISSUE_LIST_HELP_SUGGESTION) ?? "open";
}
if (value === true || !ISSUE_STATES.includes(value as IssueState)) {
throw axiError(
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
function parseSort(value: string | true | undefined): IssueSort | undefined {
return parseEnumFlag(value, "--sort", ISSUE_SORTS, ISSUE_LIST_HELP_SUGGESTION);
}
return value as IssueState;
/**
* Gitea's issue list has no sort parameter, so ordering happens here, over the
* fully paginated set (see ADR 0005). `sort` is stable, so equal keys keep the
* order the API returned them in.
*/
function sortIssues(issues: Issue[], sort: IssueSort): Issue[] {
const key = ISSUE_SORT_KEYS[sort];
return [...issues].sort((a, b) => key(b) - key(a));
}
/**
* The filters Gitea's issue list accepts as query params, under its own names.
* All four filter server-side; none of them needs the client-side policy.
*/
function issueListFilters(flags: Record<string, string | true>): Record<string, string> {
const filters: Record<string, string> = {};
const label = flagValue(flags, "--label");
if (label !== undefined) {
filters.labels = label;
}
const assignee = flagValue(flags, "--assignee");
if (assignee !== undefined) {
filters.assigned_by = assignee;
}
const author = flagValue(flags, "--author");
if (author !== undefined) {
filters.created_by = author;
}
const milestone = flagValue(flags, "--milestone");
if (milestone !== undefined) {
filters.milestones = milestone;
}
return filters;
}
/**
* `--search` is refused rather than quietly forwarded to the API's `q` param:
* full-text search is `search issues`, and a flag that half-worked here would be
* the wrong thing to learn. Checked ahead of `parseFlags` so every form of the
* flag — valued, inline, bare — lands on the redirect instead of a generic
* unknown-flag or missing-value error.
*/
function refuseSearchFlag(args: string[]): void {
if (!args.some((arg) => splitFlag(arg).name === "--search")) {
return;
}
throw axiError("issue list does not support --search", "VALIDATION_ERROR", [
'Use `gitea-axi search issues "<query>"` for full-text search',
]);
}
function parseLimit(value: string | true | undefined): number {
@@ -168,9 +251,19 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return ISSUE_LIST_HELP;
}
refuseSearchFlag(args);
const { flags, positionals } = parseFlags(
args,
{ "--state": { takesValue: true }, "--limit": { takesValue: true } },
{
"--state": { takesValue: true },
"--label": { takesValue: true },
"--assignee": { takesValue: true },
"--author": { takesValue: true },
"--milestone": { takesValue: true },
"--sort": { takesValue: true },
"--limit": { takesValue: true },
"--fields": { takesValue: true },
},
"issue list",
);
if (positionals.length > 0) {
@@ -181,33 +274,58 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
);
}
const state = parseState(flags["--state"]);
const sort = parseSort(flags["--sort"]);
const limit = parseLimit(flags["--limit"]);
const extraFields = selectExtraFields(
flagValue(flags, "--fields"),
ISSUE_LIST_EXTRA_FIELDS,
"issue list",
);
const query = { state, type: "issues" as const, ...issueListFilters(flags) };
const context = await resolveRepoContext(deps);
const api = createClient(context);
let response;
let issues: Issue[];
let total: number | undefined;
try {
response = await api.repos.issueListIssues(context.owner, context.name, {
state,
type: "issues",
if (sort === undefined) {
const response = await api.repos.issueListIssues(context.owner, context.name, {
...query,
limit,
page: 1,
});
issues = response.data ?? [];
total = readTotalCount(response.headers);
} else {
// Sorting client-side means holding the whole set first: the top `limit`
// by the sort key is only knowable once every page is in (see ADR 0005).
const result = await fetchAllPages<Issue>((page, pageLimit) =>
api.repos.issueListIssues(context.owner, context.name, {
...query,
page,
limit: pageLimit,
}),
);
issues = sortIssues(result.items, sort).slice(0, limit);
// Sorting reorders without changing membership, so the API's own total
// still describes this result set and the count line keeps reporting it.
// Having paginated everything, the set's own size is the fallback when an
// instance omits the header — a total is always reported (Principle 4).
total = result.total ?? result.items.length;
}
} catch (error) {
throw classifyHttpError(error);
}
const issues = response.data ?? [];
const totalHeader = response.headers.get("x-total-count");
const total = totalHeader !== null ? Number(totalHeader) : undefined;
const resolvedTotal = total !== undefined && Number.isFinite(total) ? total : undefined;
const now = new Date();
const rows = issues.map((issue) => extractRow(issue, ISSUE_LIST_FIELDS, { now }));
const rows = issues.map((issue) =>
extractRow(issue, [...ISSUE_LIST_FIELDS, ...extraFields], { now }),
);
return renderList({
noun: "issues",
rows,
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
countLine: formatCountLine(rows.length, total, rows.length >= limit),
help: issueListSuggestions(context, state, rows.length, total),
});
}

View File

@@ -38,6 +38,39 @@ export function flagValue(
return typeof value === "string" ? value : undefined;
}
/** ["open", "closed", "all"] → "open, closed, or all". */
function orList(values: readonly string[]): string {
if (values.length < 2) {
return values[0] ?? "";
}
return `${values.slice(0, -1).join(", ")}, or ${values[values.length - 1]}`;
}
/**
* Read a flag whose value must be one of a fixed set. Returns undefined when the
* flag was absent, leaving the default to the caller — a flag with no default
* (`--sort`) and one with a default (`--state`) then differ only in what they do
* with that undefined.
*/
export function parseEnumFlag<T extends string>(
value: string | true | undefined,
name: string,
allowed: readonly T[],
suggestions: string[],
): T | undefined {
if (value === undefined) {
return undefined;
}
if (value === true || !allowed.includes(value as T)) {
throw axiError(
`Invalid ${name} value: ${String(value)} (expected ${orList(allowed)})`,
"VALIDATION_ERROR",
suggestions,
);
}
return value as T;
}
/** Split "--flag=value" into name and inline value; "--flag" has none. */
export function splitFlag(arg: string): SplitFlag {
const equals = arg.indexOf("=");

View File

@@ -2,36 +2,23 @@ import type { Label } from "gitea-js";
import type { GiteaClient } from "./client.js";
import type { RepoContext } from "./context.js";
import { axiError, classifyHttpError } from "./errors.js";
import { fetchAllPages } from "./paginate.js";
/**
* Name→ID resolution for the Gitea endpoints that only accept numeric ids.
* Shared by every command that takes a `--label` or `--milestone` name.
*/
const LABEL_PAGE_SIZE = 50;
/** Guard against an unbounded loop if a server ignores paging and always returns a full page. */
const LABEL_PAGE_LIMIT = 20;
/** Fetch every label in the repository, paging until the API runs out. */
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
const labels: Label[] = [];
for (let page = 1; page <= LABEL_PAGE_LIMIT; page++) {
let batch: Label[];
try {
const response = await api.repos.issueListLabels(context.owner, context.name, {
page,
limit: LABEL_PAGE_SIZE,
});
batch = response.data ?? [];
const { items } = await fetchAllPages<Label>((page, limit) =>
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
);
return items;
} catch (error) {
throw classifyHttpError(error);
}
labels.push(...batch);
if (batch.length < LABEL_PAGE_SIZE) {
break;
}
}
return labels;
}
/**

56
src/paginate.ts Normal file
View File

@@ -0,0 +1,56 @@
const PAGE_SIZE = 50;
/**
* Guard against an unbounded loop if a server ignores paging and always returns
* a full page. 20 pages of 50 is the 1000-item cap the spec sets on exhaustive
* pagination (Principle 8).
*/
const PAGE_LIMIT = 20;
interface PageResponse<T> {
data?: T[];
headers: Headers;
}
export interface PaginatedResult<T> {
items: T[];
/** `X-Total-Count`, absent when the header is missing or not a number. */
total: number | undefined;
}
export function readTotalCount(headers: Headers): number | undefined {
const raw = headers.get("x-total-count");
if (raw === null) {
return undefined;
}
const total = Number(raw);
return Number.isFinite(total) ? total : undefined;
}
/**
* Read every page, stopping at the first short one or at the page cap. Needed by
* the client-side policies (see ADR 0005): a command sorting or filtering
* in-process cannot do either correctly until it holds the whole set.
*
* The total comes from the first page and describes the set the API returned, so
* it stays accurate under sorting (which only reorders) but not under
* client-side filtering, whose caller counts the filtered set itself.
*/
export async function fetchAllPages<T>(
fetchPage: (page: number, limit: number) => Promise<PageResponse<T>>,
): Promise<PaginatedResult<T>> {
const items: T[] = [];
let total: number | undefined;
for (let page = 1; page <= PAGE_LIMIT; page++) {
const response = await fetchPage(page, PAGE_SIZE);
if (page === 1) {
total = readTotalCount(response.headers);
}
const batch = response.data ?? [];
items.push(...batch);
if (batch.length < PAGE_SIZE) {
break;
}
}
return { items, total };
}

34
test/fixtures/issues-fields.json vendored Normal file
View File

@@ -0,0 +1,34 @@
[
{
"id": 310,
"number": 50,
"title": "Ship the field extractors",
"body": "Raw body text, kept whole by the body field.",
"state": "closed",
"is_locked": false,
"comments": 1,
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-07-05T10:00:00Z",
"closed_at": "2026-07-06T10:00:00Z",
"html_url": "http://gitea.example/testowner/testrepo/issues/50",
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/50",
"user": {
"id": 9,
"login": "contributor",
"full_name": "A Contributor",
"email": "contributor@example.com"
},
"labels": [
{ "id": 1, "name": "bug", "color": "ee0701" },
{ "id": 3, "name": "priority: high", "color": "b60205" }
],
"milestone": {
"id": 4,
"title": "v1.0",
"state": "open"
},
"assignee": null,
"assignees": null,
"pull_request": null
}
]

View File

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