feat: scaffold CLI and minimal issue list (task 0001)

Tracer bullet for gitea-axi: runnable npm package on axi-sdk-js with
gitea-js as the sole HTTP layer, ESM on Node 20+.

- issue list with --state/--limit, default fields, count line from
  X-Total-Count, type=issues guard, explicit empty state, and next-step
  suggestions
- repo context detection from the git origin remote (SSH/scp/HTTPS),
  tea credential discovery with the three-way login-matching split, and
  -R/--repo and --login overrides (flag > env > auto)
- token retrieval via tea login helper get: tea's login list JSON
  carries no token (ADR 0001 amended)
- full AxiError classification table with path-based 404 split, TOON
  errors on stdout, exit codes 0/1/2
- test mode (GITEA_AXI_API_URL/TOKEN/REPO) suppressing subprocesses,
  fixture server, and vitest suites driving the CLI seam (50 tests)
This commit is contained in:
2026-07-11 07:11:07 -04:00
parent 21a075f8cd
commit 38026f963d
34 changed files with 3892 additions and 0 deletions

162
src/commands/issue.ts Normal file
View File

@@ -0,0 +1,162 @@
import type { Issue } from "gitea-js";
import { createClient } from "../client.js";
import { resolveRepoContext, type RepoContext } from "../context.js";
import type { CliDeps } from "../deps.js";
import { axiError, classifyHttpError } from "../errors.js";
import { extractRow, lowercased, pluck, relativeTimeField, type FieldDef } from "../fields.js";
import { parseFlags } from "../flags.js";
import { formatCountLine, renderList } from "../render.js";
import { suggestCommand } from "../suggestions.js";
export const ISSUE_HELP = `usage: gitea-axi issue <command> [flags]
commands:
list List issues in the current repository
Run \`gitea-axi issue list --help\` for the flags of a command.
`;
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
global flags:
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
--login <name> Select a tea login profile by name
`;
const ISSUE_LIST_FIELDS: FieldDef<Issue>[] = [
pluck("number"),
pluck("title"),
lowercased("state"),
pluck("author", "user.login"),
relativeTimeField("created", "created_at"),
];
const ISSUE_STATES = ["open", "closed", "all"] as const;
type IssueState = (typeof ISSUE_STATES)[number];
const DEFAULT_LIMIT = 30;
const ISSUE_LIST_HELP_SUGGESTION = [
"Run `gitea-axi issue list --help` to see available flags",
];
function parseState(value: string | true | undefined): IssueState {
if (value === undefined) {
return "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,
);
}
return value as IssueState;
}
function parseLimit(value: string | true | undefined): number {
if (value === undefined) {
return DEFAULT_LIMIT;
}
const limit = Number(value);
if (value === true || !Number.isInteger(limit) || limit < 1) {
throw axiError(
`Invalid --limit value: ${String(value)} (expected a positive integer)`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
return limit;
}
function issueListSuggestions(
context: RepoContext,
state: IssueState,
shown: number,
total: number | undefined,
): string[] {
const help: string[] = [];
if (state !== "all") {
help.push(suggestCommand(context, "issue list --state all", "to list issues in any state"));
}
if (total !== undefined && shown < total) {
help.push(
suggestCommand(context, "issue list --limit <n>", `to fetch more of the ${total} issues`),
);
}
if (help.length === 0) {
help.push(
suggestCommand(context, "issue list --help", "to see all issue list flags"),
);
}
return help;
}
async function issueList(deps: CliDeps, args: string[]): Promise<string> {
if (args.includes("--help")) {
return ISSUE_LIST_HELP;
}
const { flags, positionals } = parseFlags(
args,
{ "--state": { takesValue: true }, "--limit": { takesValue: true } },
"issue list",
);
if (positionals.length > 0) {
throw axiError(
`Unexpected argument: ${positionals[0]}`,
"VALIDATION_ERROR",
ISSUE_LIST_HELP_SUGGESTION,
);
}
const state = parseState(flags["--state"]);
const limit = parseLimit(flags["--limit"]);
const context = await resolveRepoContext(deps);
const api = createClient(context);
let response;
try {
response = await api.repos.issueListIssues(context.owner, context.name, {
state,
type: "issues",
limit,
page: 1,
});
} 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 }));
return renderList({
noun: "issues",
rows,
countLine: formatCountLine(rows.length, resolvedTotal, rows.length >= limit),
help: issueListSuggestions(context, state, rows.length, resolvedTotal),
});
}
export function issueCommand(deps: CliDeps) {
return async (args: string[]): Promise<string> => {
const [subcommand, ...rest] = args;
if (!subcommand || subcommand === "--help") {
return ISSUE_HELP;
}
if (subcommand === "list") {
return issueList(deps, rest);
}
throw axiError(`Unknown issue command: ${subcommand}`, "VALIDATION_ERROR", [
"Run `gitea-axi issue --help` to see available issue commands",
]);
};
}