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:
116
src/cli.ts
Normal file
116
src/cli.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { exitCodeForError, runAxiCli, AxiError } from "axi-sdk-js";
|
||||
import { issueCommand } from "./commands/issue.js";
|
||||
import { resolveRepoContext } from "./context.js";
|
||||
import type { CliDeps, GlobalFlags } from "./deps.js";
|
||||
import { consumeFlagValue, splitFlag } from "./flags.js";
|
||||
import { renderErrorOutput } from "./render.js";
|
||||
import { suggestCommand } from "./suggestions.js";
|
||||
|
||||
const DESCRIPTION = "Agent-ergonomic CLI for Gitea issues and pull requests";
|
||||
|
||||
const TOP_LEVEL_HELP = `usage: gitea-axi <command> [flags]
|
||||
|
||||
commands:
|
||||
issue list List issues in the current repository
|
||||
|
||||
global flags:
|
||||
-R, --repo <OWNER/NAME> Override the repository detected from the git origin remote
|
||||
--login <name> Select a tea login profile by name
|
||||
--help Show help (also available on every command)
|
||||
-v, --version Show version
|
||||
|
||||
environment:
|
||||
GITEA_AXI_REPO Repository override, as OWNER/NAME
|
||||
GITEA_AXI_LOGIN Login profile override
|
||||
`;
|
||||
|
||||
const GLOBAL_FLAG_NAMES: Record<string, keyof GlobalFlags> = {
|
||||
"-R": "repo",
|
||||
"--repo": "repo",
|
||||
"--login": "login",
|
||||
};
|
||||
|
||||
interface ExtractedArgv {
|
||||
argv: string[];
|
||||
globals: GlobalFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the context override flags out of argv before the SDK sees it: they are
|
||||
* accepted anywhere on the command line, while the SDK rejects any flag placed
|
||||
* before the command.
|
||||
*/
|
||||
export function extractGlobalFlags(argv: string[]): ExtractedArgv {
|
||||
const globals: GlobalFlags = {};
|
||||
const rest: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const flag = splitFlag(argv[i]!);
|
||||
const target = GLOBAL_FLAG_NAMES[flag.name];
|
||||
if (!target) {
|
||||
rest.push(argv[i]!);
|
||||
continue;
|
||||
}
|
||||
const consumed = consumeFlagValue(argv, i, flag);
|
||||
globals[target] = consumed.value;
|
||||
i = consumed.lastIndex;
|
||||
}
|
||||
return { argv: rest, globals };
|
||||
}
|
||||
|
||||
function readVersion(): string {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
||||
) as { version: string };
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function homeCommand(deps: CliDeps) {
|
||||
return async (): Promise<Record<string, unknown>> => {
|
||||
const context = await resolveRepoContext(deps);
|
||||
return {
|
||||
repo: `${context.owner}/${context.name}`,
|
||||
help: [
|
||||
suggestCommand(context, "issue list", "to list open issues"),
|
||||
"Run `gitea-axi --help` to see available commands",
|
||||
],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunCliOptions {
|
||||
argv: string[];
|
||||
env: Record<string, string | undefined>;
|
||||
cwd: string;
|
||||
stdout: { write: (chunk: string) => unknown };
|
||||
}
|
||||
|
||||
export async function runCli(options: RunCliOptions): Promise<number> {
|
||||
process.exitCode = 0;
|
||||
let extracted: ExtractedArgv;
|
||||
try {
|
||||
extracted = extractGlobalFlags(options.argv);
|
||||
} catch (error) {
|
||||
const axi = error as AxiError;
|
||||
options.stdout.write(`${renderErrorOutput(axi.message, axi.code, axi.suggestions)}\n`);
|
||||
process.exitCode = exitCodeForError(error);
|
||||
return process.exitCode;
|
||||
}
|
||||
const deps: CliDeps = {
|
||||
env: options.env,
|
||||
cwd: options.cwd,
|
||||
globals: extracted.globals,
|
||||
};
|
||||
await runAxiCli({
|
||||
description: DESCRIPTION,
|
||||
version: readVersion(),
|
||||
argv: extracted.argv,
|
||||
topLevelHelp: TOP_LEVEL_HELP,
|
||||
commands: {
|
||||
issue: issueCommand(deps),
|
||||
},
|
||||
home: homeCommand(deps),
|
||||
stdout: options.stdout,
|
||||
});
|
||||
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
||||
}
|
||||
10
src/client.ts
Normal file
10
src/client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { giteaApi, type Api } from "gitea-js";
|
||||
import type { RepoContext } from "./context.js";
|
||||
|
||||
export type GiteaClient = Api<unknown>;
|
||||
|
||||
export function createClient(context: RepoContext): GiteaClient {
|
||||
return giteaApi(context.apiUrl, {
|
||||
token: context.token || undefined,
|
||||
});
|
||||
}
|
||||
162
src/commands/issue.ts
Normal file
162
src/commands/issue.ts
Normal 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",
|
||||
]);
|
||||
};
|
||||
}
|
||||
193
src/context.ts
Normal file
193
src/context.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { axiError } from "./errors.js";
|
||||
import { detectRemote } from "./git.js";
|
||||
import { getToken, listLogins, type TeaLogin } from "./tea.js";
|
||||
|
||||
export type ContextSource = "flag" | "env" | "auto";
|
||||
|
||||
export interface RepoContext {
|
||||
owner: string;
|
||||
name: string;
|
||||
host: string;
|
||||
/** Gitea instance base URL, without the /api/v1 suffix. */
|
||||
apiUrl: string;
|
||||
token: string;
|
||||
repoSource: ContextSource;
|
||||
loginSource: ContextSource;
|
||||
loginName?: string;
|
||||
}
|
||||
|
||||
/** The repo/login overrides resolved from flags and environment, pre-context. */
|
||||
interface ContextOverrides {
|
||||
repoSpec?: string;
|
||||
repoSource: ContextSource;
|
||||
/** Human label for where the repo spec came from, for error messages. */
|
||||
repoOrigin: string;
|
||||
loginName?: string;
|
||||
loginSource: ContextSource;
|
||||
}
|
||||
|
||||
function resolveOverrides(deps: CliDeps): ContextOverrides {
|
||||
const repoFromFlag = deps.globals.repo;
|
||||
const repoFromEnv = deps.env.GITEA_AXI_REPO;
|
||||
const loginFromFlag = deps.globals.login;
|
||||
const loginFromEnv = deps.env.GITEA_AXI_LOGIN;
|
||||
return {
|
||||
repoSpec: repoFromFlag ?? repoFromEnv,
|
||||
repoSource: repoFromFlag ? "flag" : repoFromEnv ? "env" : "auto",
|
||||
repoOrigin: repoFromFlag ? "`-R`" : "`GITEA_AXI_REPO`",
|
||||
loginName: loginFromFlag ?? loginFromEnv,
|
||||
loginSource: loginFromFlag ? "flag" : loginFromEnv ? "env" : "auto",
|
||||
};
|
||||
}
|
||||
|
||||
function parseRepoSpec(spec: string, origin: string): { owner: string; name: string } {
|
||||
const segments = spec.split("/");
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
throw axiError(
|
||||
`Invalid repository "${spec}" from ${origin}: expected OWNER/NAME`,
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
}
|
||||
return { owner: segments[0], name: segments[1] };
|
||||
}
|
||||
|
||||
function hostnameOf(url: string, origin: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
throw axiError(`Invalid URL "${url}" from ${origin}`, "VALIDATION_ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTestModeContext(
|
||||
deps: CliDeps,
|
||||
apiUrl: string,
|
||||
overrides: ContextOverrides,
|
||||
): RepoContext {
|
||||
if (!overrides.repoSpec) {
|
||||
throw axiError(
|
||||
"Repository context is required when GITEA_AXI_API_URL is set",
|
||||
"REPO_NOT_FOUND",
|
||||
["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"],
|
||||
);
|
||||
}
|
||||
return {
|
||||
...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin),
|
||||
host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"),
|
||||
apiUrl: apiUrl.replace(/\/+$/, ""),
|
||||
token: deps.env.GITEA_AXI_TOKEN ?? "",
|
||||
repoSource: overrides.repoSource,
|
||||
loginSource: overrides.loginSource,
|
||||
loginName: overrides.loginName,
|
||||
};
|
||||
}
|
||||
|
||||
function matchLoginsByHost(logins: TeaLogin[], host: string): TeaLogin[] {
|
||||
return logins.filter((login) => {
|
||||
if (login.sshHost && login.sshHost === host) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return new URL(login.url).hostname === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectLogin(
|
||||
logins: TeaLogin[],
|
||||
loginName: string | undefined,
|
||||
remoteHost: string | undefined,
|
||||
): TeaLogin {
|
||||
if (logins.length === 0) {
|
||||
throw axiError("No tea logins are configured", "AUTH_REQUIRED", [
|
||||
"Run `tea login add` to configure a login",
|
||||
]);
|
||||
}
|
||||
if (loginName) {
|
||||
const login = logins.find((entry) => entry.name === loginName);
|
||||
if (!login) {
|
||||
throw axiError(
|
||||
`Login profile "${loginName}" not found (available: ${logins.map((l) => l.name).join(", ")})`,
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--login <name>` with one of the listed profiles"],
|
||||
);
|
||||
}
|
||||
return login;
|
||||
}
|
||||
if (!remoteHost) {
|
||||
throw axiError(
|
||||
"Cannot select a tea login without a git remote hostname",
|
||||
"REPO_NOT_FOUND",
|
||||
["Pass `--login <name>` together with `-R OWNER/NAME`"],
|
||||
);
|
||||
}
|
||||
const matches = matchLoginsByHost(logins, remoteHost);
|
||||
if (matches.length === 0) {
|
||||
throw axiError(
|
||||
`No tea login matches host "${remoteHost}" — this may not be a Gitea repository`,
|
||||
"REPO_NOT_FOUND",
|
||||
[
|
||||
`Run \`tea login add --url ${remoteHost}\` if this is a Gitea instance`,
|
||||
"Or pass `-R OWNER/NAME` together with `--login <name>`",
|
||||
],
|
||||
);
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return matches[0]!;
|
||||
}
|
||||
const fallback = matches.find((login) => login.isDefault);
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
throw axiError(
|
||||
`Multiple tea logins match host "${remoteHost}": ${matches.map((l) => l.name).join(", ")}`,
|
||||
"VALIDATION_ERROR",
|
||||
["Pass `--login <name>` to select one"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
|
||||
const overrides = resolveOverrides(deps);
|
||||
|
||||
const testApiUrl = deps.env.GITEA_AXI_API_URL;
|
||||
if (testApiUrl) {
|
||||
return resolveTestModeContext(deps, testApiUrl, overrides);
|
||||
}
|
||||
|
||||
const remote = await detectRemote(deps);
|
||||
let owner: string;
|
||||
let name: string;
|
||||
if (overrides.repoSpec) {
|
||||
({ owner, name } = parseRepoSpec(overrides.repoSpec, overrides.repoOrigin));
|
||||
} else if (remote) {
|
||||
({ owner, name } = remote);
|
||||
} else {
|
||||
throw axiError(
|
||||
"Could not detect a Gitea repository from the git `origin` remote",
|
||||
"REPO_NOT_FOUND",
|
||||
[
|
||||
"Run inside a git repository whose `origin` remote points at a Gitea instance",
|
||||
"Or pass `-R OWNER/NAME` together with `--login <name>`",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const logins = await listLogins(deps);
|
||||
const login = selectLogin(logins, overrides.loginName, remote?.host);
|
||||
const host = hostnameOf(login.url, `tea login "${login.name}"`);
|
||||
const token = await getToken(deps, login, host);
|
||||
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
host,
|
||||
apiUrl: login.url.replace(/\/+$/, ""),
|
||||
token,
|
||||
repoSource: overrides.repoSource,
|
||||
loginSource: overrides.loginSource,
|
||||
loginName: login.name,
|
||||
};
|
||||
}
|
||||
10
src/deps.ts
Normal file
10
src/deps.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface GlobalFlags {
|
||||
repo?: string;
|
||||
login?: string;
|
||||
}
|
||||
|
||||
export interface CliDeps {
|
||||
env: Record<string, string | undefined>;
|
||||
cwd: string;
|
||||
globals: GlobalFlags;
|
||||
}
|
||||
133
src/errors.ts
Normal file
133
src/errors.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { AxiError } from "axi-sdk-js";
|
||||
|
||||
export type AxiErrorCode =
|
||||
| "REPO_NOT_FOUND"
|
||||
| "ISSUE_NOT_FOUND"
|
||||
| "PR_NOT_FOUND"
|
||||
| "AUTH_REQUIRED"
|
||||
| "FORBIDDEN"
|
||||
| "RATE_LIMITED"
|
||||
| "TEA_NOT_INSTALLED"
|
||||
| "VALIDATION_ERROR"
|
||||
| "GIT_ERROR"
|
||||
| "UNKNOWN";
|
||||
|
||||
export function axiError(
|
||||
message: string,
|
||||
code: AxiErrorCode,
|
||||
suggestions: string[] = [],
|
||||
): AxiError {
|
||||
return new AxiError(message, code, suggestions);
|
||||
}
|
||||
|
||||
interface HttpResponseLike {
|
||||
status: number;
|
||||
url: string;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
function isHttpResponseLike(value: unknown): value is HttpResponseLike {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as HttpResponseLike).status === "number" &&
|
||||
typeof (value as HttpResponseLike).url === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function bodyMessage(response: HttpResponseLike): string | undefined {
|
||||
const error = response.error;
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pathname(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const ISSUE_PATH = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(?:\/|$)/;
|
||||
const PULL_PATH = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)(?:\/|$)/;
|
||||
const REPO_PATH = /\/repos\/([^/]+)\/([^/]+)(?:\/|$)/;
|
||||
|
||||
function classify404(response: HttpResponseLike): AxiError {
|
||||
const path = pathname(response.url);
|
||||
const issue = ISSUE_PATH.exec(path);
|
||||
if (issue) {
|
||||
return axiError(`Issue #${issue[1]} not found`, "ISSUE_NOT_FOUND", [
|
||||
"Run `gitea-axi issue list` to see existing issues",
|
||||
]);
|
||||
}
|
||||
const pull = PULL_PATH.exec(path);
|
||||
if (pull) {
|
||||
return axiError(`Pull request #${pull[1]} not found`, "PR_NOT_FOUND");
|
||||
}
|
||||
// Gitea returns 404 for every path under a nonexistent repository, so any
|
||||
// repo-subtree 404 that is not an indexed issue/pull lookup means the
|
||||
// repository itself was not found.
|
||||
const repo = REPO_PATH.exec(path);
|
||||
if (repo) {
|
||||
return axiError(
|
||||
`Repository ${repo[1]}/${repo[2]} not found`,
|
||||
"REPO_NOT_FOUND",
|
||||
[
|
||||
"Check the repository owner and name",
|
||||
"Pass `-R OWNER/NAME` to target a different repository",
|
||||
],
|
||||
);
|
||||
}
|
||||
return axiError(`Not found: ${path}`, "UNKNOWN");
|
||||
}
|
||||
|
||||
export function classifyHttpError(error: unknown): AxiError {
|
||||
if (error instanceof AxiError) {
|
||||
return error;
|
||||
}
|
||||
if (!isHttpResponseLike(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const cause =
|
||||
error instanceof Error && error.cause instanceof Error
|
||||
? ` (${error.cause.message})`
|
||||
: "";
|
||||
return axiError(`Request failed: ${message}${cause}`, "UNKNOWN");
|
||||
}
|
||||
const detail = bodyMessage(error);
|
||||
switch (error.status) {
|
||||
case 401:
|
||||
return axiError(detail ?? "Authentication required", "AUTH_REQUIRED", [
|
||||
"Run `tea login add` to configure credentials, or verify the token is still valid",
|
||||
]);
|
||||
case 403:
|
||||
return axiError(detail ?? "Access forbidden", "FORBIDDEN", [
|
||||
"Verify the token has permission to access this repository",
|
||||
]);
|
||||
case 404:
|
||||
return classify404(error);
|
||||
case 405:
|
||||
case 409:
|
||||
case 422:
|
||||
return axiError(
|
||||
detail ?? `Validation failed (HTTP ${error.status})`,
|
||||
"VALIDATION_ERROR",
|
||||
);
|
||||
case 429:
|
||||
return axiError(detail ?? "Rate limited", "RATE_LIMITED", [
|
||||
"Wait and retry, or reduce `--limit` to make smaller requests",
|
||||
]);
|
||||
default:
|
||||
return axiError(
|
||||
detail
|
||||
? `Gitea API error (HTTP ${error.status}): ${detail}`
|
||||
: `Gitea API error (HTTP ${error.status})`,
|
||||
"UNKNOWN",
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/fields.ts
Normal file
54
src/fields.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { relativeTime } from "./time.js";
|
||||
|
||||
export interface ExtractContext {
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export interface FieldDef<T> {
|
||||
name: string;
|
||||
extract: (raw: T, context: ExtractContext) => unknown;
|
||||
}
|
||||
|
||||
function pluckPath(raw: unknown, path: string): unknown {
|
||||
let value: unknown = raw;
|
||||
for (const key of path.split(".")) {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
value = (value as Record<string, unknown>)[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function pluck<T>(name: string, path: string = name): FieldDef<T> {
|
||||
return { name, extract: (raw) => pluckPath(raw, path) ?? "" };
|
||||
}
|
||||
|
||||
export function lowercased<T>(name: string, path: string = name): FieldDef<T> {
|
||||
return {
|
||||
name,
|
||||
extract: (raw) => String(pluckPath(raw, path) ?? "").toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function relativeTimeField<T>(name: string, path: string): FieldDef<T> {
|
||||
return {
|
||||
name,
|
||||
extract: (raw, context) => {
|
||||
const value = pluckPath(raw, path);
|
||||
return relativeTime(typeof value === "string" ? value : undefined, context.now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractRow<T>(
|
||||
raw: T,
|
||||
fields: FieldDef<T>[],
|
||||
context: ExtractContext,
|
||||
): Record<string, unknown> {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
row[field.name] = field.extract(raw, context);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
81
src/flags.ts
Normal file
81
src/flags.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { axiError } from "./errors.js";
|
||||
|
||||
export interface FlagSpec {
|
||||
/** Flag names (e.g. "--state") mapped to whether they take a value. */
|
||||
[name: string]: { takesValue: boolean };
|
||||
}
|
||||
|
||||
export interface ParsedFlags {
|
||||
flags: Record<string, string | true>;
|
||||
positionals: string[];
|
||||
}
|
||||
|
||||
export interface SplitFlag {
|
||||
name: string;
|
||||
inlineValue?: string;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
export function parseFlags(
|
||||
args: string[],
|
||||
spec: FlagSpec,
|
||||
helpCommand: string,
|
||||
): ParsedFlags {
|
||||
const flags: Record<string, string | true> = {};
|
||||
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);
|
||||
flags[flag.name] = consumed.value;
|
||||
i = consumed.lastIndex;
|
||||
}
|
||||
return { flags, positionals };
|
||||
}
|
||||
55
src/git.ts
Normal file
55
src/git.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { runSubprocess } from "./subprocess.js";
|
||||
|
||||
export interface RemoteRepo {
|
||||
host: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function parseRepoPath(rawPath: string): { owner: string; name: string } | null {
|
||||
const path = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
|
||||
const segments = path.split("/");
|
||||
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
||||
return null;
|
||||
}
|
||||
return { owner: segments[0], name: segments[1] };
|
||||
}
|
||||
|
||||
export function parseRemoteUrl(url: string): RemoteRepo | null {
|
||||
const trimmed = url.trim();
|
||||
if (/^(https?|ssh):\/\//.test(trimmed)) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const repo = parseRepoPath(parsed.pathname);
|
||||
if (!repo || !parsed.hostname) {
|
||||
return null;
|
||||
}
|
||||
return { host: parsed.hostname, ...repo };
|
||||
}
|
||||
// scp-like SSH form: [user@]host:owner/name[.git]
|
||||
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.exec(trimmed);
|
||||
if (scp) {
|
||||
const repo = parseRepoPath(scp[2]!);
|
||||
if (!repo) {
|
||||
return null;
|
||||
}
|
||||
return { host: scp[1]!, ...repo };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectRemote(deps: CliDeps): Promise<RemoteRepo | null> {
|
||||
const result = await runSubprocess("git", ["remote", "get-url", "origin"], {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
});
|
||||
if (result.enoent || result.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
return parseRemoteUrl(result.stdout);
|
||||
}
|
||||
17
src/main.ts
Normal file
17
src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCli } from "./cli.js";
|
||||
|
||||
// Exit quietly when the consumer closes the pipe early (e.g. `gitea-axi | head`).
|
||||
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "EPIPE") {
|
||||
process.exit(0);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
await runCli({
|
||||
argv: process.argv.slice(2),
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
stdout: process.stdout,
|
||||
});
|
||||
44
src/render.ts
Normal file
44
src/render.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { encode } from "@toon-format/toon";
|
||||
|
||||
// Same shape the SDK's own error formatter produces; its renderError helper
|
||||
// exists in axi-sdk-js but is not exported from the package index.
|
||||
export function renderErrorOutput(
|
||||
message: string,
|
||||
code: string,
|
||||
suggestions: string[] = [],
|
||||
): string {
|
||||
const output: Record<string, unknown> = { error: message, code };
|
||||
if (suggestions.length > 0) {
|
||||
output.help = suggestions;
|
||||
}
|
||||
return encode(output);
|
||||
}
|
||||
|
||||
export function formatCountLine(
|
||||
shown: number,
|
||||
total: number | undefined,
|
||||
atLimit: boolean,
|
||||
): string {
|
||||
if (total === undefined) {
|
||||
if (atLimit) {
|
||||
return `count: ${shown} (showing first ${shown})`;
|
||||
}
|
||||
return `count: ${shown} of ${shown} total`;
|
||||
}
|
||||
return `count: ${shown} of ${total} total`;
|
||||
}
|
||||
|
||||
export interface RenderListOptions {
|
||||
noun: string;
|
||||
rows: Record<string, unknown>[];
|
||||
countLine: string;
|
||||
help: string[];
|
||||
}
|
||||
|
||||
export function renderList(options: RenderListOptions): string {
|
||||
const body =
|
||||
options.rows.length > 0
|
||||
? encode({ [options.noun]: options.rows })
|
||||
: `${options.noun}[0]: (none)`;
|
||||
return [options.countLine, body, encode({ help: options.help })].join("\n");
|
||||
}
|
||||
55
src/subprocess.ts
Normal file
55
src/subprocess.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface SubprocessResult {
|
||||
enoent: boolean;
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface SubprocessOptions {
|
||||
cwd?: string;
|
||||
env: Record<string, string | undefined>;
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
export function runSubprocess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SubprocessOptions,
|
||||
): Promise<SubprocessResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env as NodeJS.ProcessEnv,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") {
|
||||
resolve({ enoent: true, code: null, stdout, stderr });
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({ enoent: false, code, stdout, stderr });
|
||||
});
|
||||
if (options.stdin !== undefined) {
|
||||
// The child may exit without reading stdin; a late write then raises
|
||||
// EPIPE, which must not crash the parent.
|
||||
child.stdin.on("error", () => {});
|
||||
child.stdin.write(options.stdin);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
21
src/suggestions.ts
Normal file
21
src/suggestions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { RepoContext } from "./context.js";
|
||||
|
||||
/**
|
||||
* Build a next-step suggestion line, appending `-R`/`--login` overrides when
|
||||
* the context did not come from the git remote (an agent's next call in the
|
||||
* same working directory would auto-detect the same context otherwise).
|
||||
*/
|
||||
export function suggestCommand(
|
||||
context: RepoContext,
|
||||
commandLine: string,
|
||||
note: string,
|
||||
): string {
|
||||
let command = `gitea-axi ${commandLine}`;
|
||||
if (context.repoSource !== "auto") {
|
||||
command += ` -R ${context.owner}/${context.name}`;
|
||||
}
|
||||
if (context.loginSource !== "auto" && context.loginName) {
|
||||
command += ` --login ${context.loginName}`;
|
||||
}
|
||||
return `Run \`${command}\` ${note}`;
|
||||
}
|
||||
102
src/tea.ts
Normal file
102
src/tea.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { CliDeps } from "./deps.js";
|
||||
import { axiError } from "./errors.js";
|
||||
import { runSubprocess } from "./subprocess.js";
|
||||
|
||||
export interface TeaLogin {
|
||||
name: string;
|
||||
url: string;
|
||||
sshHost: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
interface RawTeaLogin {
|
||||
name?: string;
|
||||
url?: string;
|
||||
ssh_host?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
function teaNotInstalled(): never {
|
||||
throw axiError("tea is not installed", "TEA_NOT_INSTALLED", [
|
||||
"Install tea (https://gitea.com/gitea/tea) — gitea-axi discovers credentials from tea's login store",
|
||||
"Then run `tea login add` to configure a login",
|
||||
]);
|
||||
}
|
||||
|
||||
function failureDetail(result: { stderr: string; code: number | null }): string {
|
||||
return firstLine(result.stderr) || `exit code ${result.code}`;
|
||||
}
|
||||
|
||||
export async function listLogins(deps: CliDeps): Promise<TeaLogin[]> {
|
||||
const result = await runSubprocess("tea", ["login", "list", "--output", "json"], {
|
||||
env: deps.env,
|
||||
});
|
||||
if (result.enoent) {
|
||||
teaNotInstalled();
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw axiError(`\`tea login list\` failed: ${failureDetail(result)}`, "UNKNOWN");
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw axiError("`tea login list --output json` returned invalid JSON", "UNKNOWN");
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
throw axiError("`tea login list --output json` returned unexpected output", "UNKNOWN");
|
||||
}
|
||||
return (raw as RawTeaLogin[]).map((entry) => ({
|
||||
name: entry.name ?? "",
|
||||
url: entry.url ?? "",
|
||||
sshHost: entry.ssh_host ?? "",
|
||||
isDefault: entry.default === "true",
|
||||
}));
|
||||
}
|
||||
|
||||
// The list output carries no token (its columns are name, url, ssh_host, user,
|
||||
// default), so the token comes from tea's git-credential interface, which also
|
||||
// refreshes OAuth tokens transparently.
|
||||
export async function getToken(deps: CliDeps, login: TeaLogin, host: string): Promise<string> {
|
||||
let protocol = "https";
|
||||
try {
|
||||
protocol = new URL(login.url).protocol.replace(/:$/, "");
|
||||
} catch {
|
||||
// Fall back to https; the helper only requires the host.
|
||||
}
|
||||
const result = await runSubprocess(
|
||||
"tea",
|
||||
["login", "helper", "get", "--login", login.name],
|
||||
{
|
||||
env: deps.env,
|
||||
stdin: `protocol=${protocol}\nhost=${host}\n\n`,
|
||||
},
|
||||
);
|
||||
if (result.enoent) {
|
||||
teaNotInstalled();
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw axiError(
|
||||
`tea could not provide a token for login "${login.name}": ${failureDetail(result)}`,
|
||||
"AUTH_REQUIRED",
|
||||
[`Run \`tea login edit ${login.name}\` to repair the login`],
|
||||
);
|
||||
}
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (line.startsWith("password=")) {
|
||||
const token = line.slice("password=".length).trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw axiError(
|
||||
`tea returned no token for login "${login.name}"`,
|
||||
"AUTH_REQUIRED",
|
||||
[`Run \`tea login edit ${login.name}\` to repair the login`],
|
||||
);
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return text.split("\n", 1)[0]?.trim() ?? "";
|
||||
}
|
||||
32
src/time.ts
Normal file
32
src/time.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export function relativeTime(iso: string | undefined, now: Date): string {
|
||||
if (!iso) {
|
||||
return "unknown";
|
||||
}
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) {
|
||||
return "unknown";
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
|
||||
if (seconds < MINUTE) {
|
||||
return "just now";
|
||||
}
|
||||
if (seconds < HOUR) {
|
||||
return `${Math.floor(seconds / MINUTE)}m ago`;
|
||||
}
|
||||
if (seconds < DAY) {
|
||||
return `${Math.floor(seconds / HOUR)}h ago`;
|
||||
}
|
||||
if (seconds < MONTH) {
|
||||
return `${Math.floor(seconds / DAY)}d ago`;
|
||||
}
|
||||
if (seconds < YEAR) {
|
||||
return `${Math.floor(seconds / MONTH)}mo ago`;
|
||||
}
|
||||
return `${Math.floor(seconds / YEAR)}y ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user