feat: complete issue list filters, sort, and fields (task 0002)
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:
@@ -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";
|
||||
@@ -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.
|
||||
|
||||
flags:
|
||||
--state <open|closed|all> Filter by state (default: open)
|
||||
--limit <n> Maximum number of issues to return (default: 30)
|
||||
--help Show this help
|
||||
--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:
|
||||
-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"),
|
||||
];
|
||||
|
||||
// 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";
|
||||
}
|
||||
|
||||
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)) {
|
||||
throw axiError(
|
||||
`Invalid --state value: ${String(value)} (expected open, closed, or all)`,
|
||||
"VALIDATION_ERROR",
|
||||
ISSUE_LIST_HELP_SUGGESTION,
|
||||
);
|
||||
const assignee = flagValue(flags, "--assignee");
|
||||
if (assignee !== undefined) {
|
||||
filters.assigned_by = assignee;
|
||||
}
|
||||
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 {
|
||||
@@ -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",
|
||||
limit,
|
||||
page: 1,
|
||||
});
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
33
src/flags.ts
33
src/flags.ts
@@ -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("=");
|
||||
|
||||
@@ -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 ?? [];
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
labels.push(...batch);
|
||||
if (batch.length < LABEL_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { items } = await fetchAllPages<Label>((page, limit) =>
|
||||
api.repos.issueListLabels(context.owner, context.name, { page, limit }),
|
||||
);
|
||||
return items;
|
||||
} catch (error) {
|
||||
throw classifyHttpError(error);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user