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:
@@ -13,9 +13,25 @@ Field selection: `--fields <a,b,c>` exposing the extra fields `body` (raw), `clo
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `--label`, `--assignee`, `--author`, and `--milestone` map to their Gitea API query params and filter server-side
|
- [x] `--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`
|
- [x] `--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)
|
- [x] `--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
|
- [x] Output contains no `type` field
|
||||||
- [ ] `--search` fails with `VALIDATION_ERROR` (exit 2) and a help line pointing at `gitea-axi search issues "<query>"`
|
- [x] `--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] 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.
|
||||||
|
|||||||
@@ -15,8 +15,15 @@ import {
|
|||||||
selectExtraFields,
|
selectExtraFields,
|
||||||
type FieldDef,
|
type FieldDef,
|
||||||
} from "../fields.js";
|
} 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 { resolveLabelIds, resolveMilestoneId } from "../lookup.js";
|
||||||
|
import { fetchAllPages, readTotalCount } from "../paginate.js";
|
||||||
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
import { formatCountLine, renderDetail, renderList, type DetailBlock } from "../render.js";
|
||||||
import { relativeTime } from "../time.js";
|
import { relativeTime } from "../time.js";
|
||||||
import { suggestCommand } from "../suggestions.js";
|
import { suggestCommand } from "../suggestions.js";
|
||||||
@@ -86,9 +93,15 @@ export const ISSUE_LIST_HELP = `usage: gitea-axi issue list [flags]
|
|||||||
List issues in the current repository. Pull requests are never included.
|
List issues in the current repository. Pull requests are never included.
|
||||||
|
|
||||||
flags:
|
flags:
|
||||||
--state <open|closed|all> Filter by state (default: open)
|
--state <open|closed|all> Filter by state (default: open)
|
||||||
--limit <n> Maximum number of issues to return (default: 30)
|
--label <a,b> Filter by label name (comma-separated)
|
||||||
--help Show this help
|
--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:
|
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
|
||||||
@@ -103,27 +116,97 @@ const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
|
|||||||
relativeTimeField("created", "created_at"),
|
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;
|
const ISSUE_STATES = ["open", "closed", "all"] as const;
|
||||||
type IssueState = (typeof ISSUE_STATES)[number];
|
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 DEFAULT_LIMIT = 30;
|
||||||
|
|
||||||
const ISSUE_LIST_HELP_SUGGESTION = [
|
const ISSUE_LIST_HELP_SUGGESTION = [
|
||||||
"Run `gitea-axi issue list --help` to see available flags",
|
"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 {
|
function parseState(value: string | true | undefined): IssueState {
|
||||||
if (value === undefined) {
|
return parseEnumFlag(value, "--state", ISSUE_STATES, ISSUE_LIST_HELP_SUGGESTION) ?? "open";
|
||||||
return "open";
|
}
|
||||||
|
|
||||||
|
function parseSort(value: string | true | undefined): IssueSort | undefined {
|
||||||
|
return parseEnumFlag(value, "--sort", ISSUE_SORTS, ISSUE_LIST_HELP_SUGGESTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
if (value === true || !ISSUE_STATES.includes(value as IssueState)) {
|
const assignee = flagValue(flags, "--assignee");
|
||||||
throw axiError(
|
if (assignee !== undefined) {
|
||||||
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
|
filters.assigned_by = assignee;
|
||||||
"VALIDATION_ERROR",
|
|
||||||
ISSUE_LIST_HELP_SUGGESTION,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return value as IssueState;
|
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 {
|
function parseLimit(value: string | true | undefined): number {
|
||||||
@@ -168,9 +251,19 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
if (args.includes("--help")) {
|
if (args.includes("--help")) {
|
||||||
return ISSUE_LIST_HELP;
|
return ISSUE_LIST_HELP;
|
||||||
}
|
}
|
||||||
|
refuseSearchFlag(args);
|
||||||
const { flags, positionals } = parseFlags(
|
const { flags, positionals } = parseFlags(
|
||||||
args,
|
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",
|
"issue list",
|
||||||
);
|
);
|
||||||
if (positionals.length > 0) {
|
if (positionals.length > 0) {
|
||||||
@@ -181,33 +274,58 @@ async function issueList(deps: CliDeps, args: string[]): Promise<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const state = parseState(flags["--state"]);
|
const state = parseState(flags["--state"]);
|
||||||
|
const sort = parseSort(flags["--sort"]);
|
||||||
const limit = parseLimit(flags["--limit"]);
|
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 context = await resolveRepoContext(deps);
|
||||||
const api = createClient(context);
|
const api = createClient(context);
|
||||||
let response;
|
let issues: Issue[];
|
||||||
|
let total: number | undefined;
|
||||||
try {
|
try {
|
||||||
response = await api.repos.issueListIssues(context.owner, context.name, {
|
if (sort === undefined) {
|
||||||
state,
|
const response = await api.repos.issueListIssues(context.owner, context.name, {
|
||||||
type: "issues",
|
...query,
|
||||||
limit,
|
limit,
|
||||||
page: 1,
|
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) {
|
} catch (error) {
|
||||||
throw classifyHttpError(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 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({
|
return renderList({
|
||||||
noun: "issues",
|
noun: "issues",
|
||||||
rows,
|
rows,
|
||||||
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
|
countLine: formatCountLine(rows.length, total, rows.length >= limit),
|
||||||
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
|
help: issueListSuggestions(context, state, rows.length, total),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
src/flags.ts
33
src/flags.ts
@@ -38,6 +38,39 @@ export function flagValue(
|
|||||||
return typeof value === "string" ? value : undefined;
|
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. */
|
/** Split "--flag=value" into name and inline value; "--flag" has none. */
|
||||||
export function splitFlag(arg: string): SplitFlag {
|
export function splitFlag(arg: string): SplitFlag {
|
||||||
const equals = arg.indexOf("=");
|
const equals = arg.indexOf("=");
|
||||||
|
|||||||
@@ -2,36 +2,23 @@ import type { Label } from "gitea-js";
|
|||||||
import type { GiteaClient } from "./client.js";
|
import type { GiteaClient } from "./client.js";
|
||||||
import type { RepoContext } from "./context.js";
|
import type { RepoContext } from "./context.js";
|
||||||
import { axiError, classifyHttpError } from "./errors.js";
|
import { axiError, classifyHttpError } from "./errors.js";
|
||||||
|
import { fetchAllPages } from "./paginate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name→ID resolution for the Gitea endpoints that only accept numeric ids.
|
* Name→ID resolution for the Gitea endpoints that only accept numeric ids.
|
||||||
* Shared by every command that takes a `--label` or `--milestone` name.
|
* 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. */
|
/** Fetch every label in the repository, paging until the API runs out. */
|
||||||
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
async function listAllLabels(api: GiteaClient, context: RepoContext): Promise<Label[]> {
|
||||||
const labels: Label[] = [];
|
try {
|
||||||
for (let page = 1; page <= LABEL_PAGE_LIMIT; page++) {
|
const { items } = await fetchAllPages<Label>((page, limit) =>
|
||||||
let batch: Label[];
|
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
|
||||||
try {
|
);
|
||||||
const response = await api.repos.issueListLabels(context.owner, context.name, {
|
return items;
|
||||||
page,
|
} catch (error) {
|
||||||
limit: LABEL_PAGE_SIZE,
|
throw classifyHttpError(error);
|
||||||
});
|
|
||||||
batch = response.data ?? [];
|
|
||||||
} catch (error) {
|
|
||||||
throw classifyHttpError(error);
|
|
||||||
}
|
|
||||||
labels.push(...batch);
|
|
||||||
if (batch.length < LABEL_PAGE_SIZE) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return labels;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
src/paginate.ts
Normal file
56
src/paginate.ts
Normal 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
34
test/fixtures/issues-fields.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -10,6 +10,41 @@ afterEach(async () => {
|
|||||||
await server.close();
|
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", () => {
|
describe("issue list", () => {
|
||||||
it("lists open issues with default fields and a count line", async () => {
|
it("lists open issues with default fields and a count line", async () => {
|
||||||
server = await startFixtureServer([
|
server = await startFixtureServer([
|
||||||
@@ -164,4 +199,319 @@ describe("issue list", () => {
|
|||||||
expect(exitCode).toBe(2);
|
expect(exitCode).toBe(2);
|
||||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
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