Files
gitea-axi/src/flags.ts
alexion 348f28a54d 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.
2026-07-12 09:29:35 -04:00

186 lines
5.6 KiB
TypeScript

import { axiError } from "./errors.js";
export interface FlagSpec {
/** Flag names (e.g. "--state") mapped to how they are parsed. */
[name: string]: {
takesValue: boolean;
/** Accumulate every occurrence into `lists` instead of `flags` (e.g. `--label`). */
repeatable?: boolean;
};
}
export interface ParsedFlags {
/** Single-valued flags: the value, or `true` for a bare switch. */
flags: Record<string, string | true>;
/**
* Values of each repeatable flag in argv order. Every repeatable flag in the
* spec is present, holding an empty array when it was not passed.
*/
lists: Record<string, string[]>;
positionals: string[];
}
export interface SplitFlag {
name: string;
inlineValue?: string;
}
/**
* Read a value-taking flag. `parseFlags` rejects such a flag without a value,
* so anything present here is a string; the `true` case only arises for bare
* switches, which callers never read through this helper.
*/
export function flagValue(
flags: Record<string, string | true>,
name: string,
): string | undefined {
const value = flags[name];
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("=");
if (equals === -1) {
return { name: arg };
}
return { name: arg.slice(0, equals), inlineValue: arg.slice(equals + 1) };
}
/**
* Resolve a value-taking flag's value from its inline form or the next
* argument, returning the index of the last argument consumed.
*/
export function consumeFlagValue(
args: string[],
index: number,
flag: SplitFlag,
suggestions: string[] = [],
): { value: string; lastIndex: number } {
if (flag.inlineValue !== undefined) {
if (!flag.inlineValue) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: flag.inlineValue, lastIndex: index };
}
const next = args[index + 1];
if (next === undefined || next.startsWith("-")) {
throw axiError(`Flag ${flag.name} requires a value`, "VALIDATION_ERROR", suggestions);
}
return { value: next, lastIndex: index + 1 };
}
/** "issue" → "an issue"; "pull request" → "a pull request". */
function withArticle(noun: string): string {
return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;
}
/**
* Parse the single positional number of a `<command> <number>` invocation.
* `noun` names what the number identifies ("issue", "pull request") and appears
* in the errors; the parsing itself is identical for both.
*/
export function parsePositionalNumber(
positionals: string[],
command: string,
noun: string,
): number {
const helpSuggestion = [`Run \`gitea-axi ${command} --help\` to see available flags`];
if (positionals.length === 0) {
throw axiError(
`${command} requires ${withArticle(noun)} number`,
"VALIDATION_ERROR",
[`Run \`gitea-axi ${command} <number>\``],
);
}
if (positionals.length > 1) {
throw axiError(`Unexpected argument: ${positionals[1]}`, "VALIDATION_ERROR", helpSuggestion);
}
const raw = positionals[0]!;
const number = Number(raw);
if (!Number.isInteger(number) || number < 1) {
throw axiError(
`Invalid ${noun} number: ${raw} (expected a positive integer)`,
"VALIDATION_ERROR",
helpSuggestion,
);
}
return number;
}
export function parseFlags(
args: string[],
spec: FlagSpec,
helpCommand: string,
): ParsedFlags {
const flags: Record<string, string | true> = {};
const lists: Record<string, string[]> = {};
for (const [name, entry] of Object.entries(spec)) {
if (entry.repeatable) {
lists[name] = [];
}
}
const positionals: string[] = [];
const helpSuggestion = [`Run \`gitea-axi ${helpCommand} --help\` to see available flags`];
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (!arg.startsWith("-")) {
positionals.push(arg);
continue;
}
const flag = splitFlag(arg);
const entry = spec[flag.name];
if (!entry) {
throw axiError(`Unknown flag: ${flag.name}`, "VALIDATION_ERROR", helpSuggestion);
}
if (!entry.takesValue) {
if (flag.inlineValue !== undefined) {
throw axiError(`Flag ${flag.name} does not take a value`, "VALIDATION_ERROR", helpSuggestion);
}
flags[flag.name] = true;
continue;
}
const consumed = consumeFlagValue(args, i, flag, helpSuggestion);
if (entry.repeatable) {
lists[flag.name]!.push(consumed.value);
} else {
flags[flag.name] = consumed.value;
}
i = consumed.lastIndex;
}
return { flags, lists, positionals };
}