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:
@@ -8,3 +8,12 @@ To get the auth token for that request, gitea-axi calls `tea login list --output
|
|||||||
**Read `~/.config/tea/config.yml` directly** — one fewer subprocess call, but couples gitea-axi to tea's internal storage format rather than its stable JSON output interface.
|
**Read `~/.config/tea/config.yml` directly** — one fewer subprocess call, but couples gitea-axi to tea's internal storage format rather than its stable JSON output interface.
|
||||||
|
|
||||||
**Shell out to `tea login list --output json`** (chosen) — consistent with the rest of the architecture, which goes through tea's JSON interface for everything; decoupled from tea's file format internals.
|
**Shell out to `tea login list --output json`** (chosen) — consistent with the rest of the architecture, which goes through tea's JSON interface for everything; decoupled from tea's file format internals.
|
||||||
|
|
||||||
|
## Amendment (2026-07-10): token comes from `tea login helper get`, not the list output
|
||||||
|
|
||||||
|
The original decision assumed `tea login list --output json` includes the token.
|
||||||
|
It does not: the list output carries only `name`, `url`, `ssh_host`, `user`, and `default` (verified against tea 0.14.2 and current tea main).
|
||||||
|
|
||||||
|
`tea login list --output json` remains the discovery interface (profile names, URLs, default flag, hostname matching).
|
||||||
|
The token for the selected login is fetched via `tea login helper get --login <name>`, tea's git-credential-protocol interface (`host=` on stdin, `password=` on stdout).
|
||||||
|
This stays on tea's stable machine interfaces rather than its YAML internals, and additionally gets OAuth token refresh for free — `helper get` refreshes near-expiry OAuth tokens in place, which reading `config.yml` directly could never do.
|
||||||
|
|||||||
59
.claude/tasks/0001-scaffold-and-issue-list-core.md
Normal file
59
.claude/tasks/0001-scaffold-and-issue-list-core.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
spec: gitea-axi
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The tracer bullet: a runnable `gitea-axi` npm package whose first command, a minimal `issue list`, works end-to-end — argv in, TOON out — against both the fixture server and a real Gitea instance.
|
||||||
|
Scaffold the project on axi-sdk-js (`runAxiCli`, `AxiError`, `exitCodeForError`, output helpers) with gitea-js as the sole HTTP layer, ESM on Node 20+.
|
||||||
|
Implement repository context detection from the git `origin` remote (SSH and HTTPS forms), tea credential discovery via `tea login list --output json` with the three-way login-matching split, and the `-R`/`--repo` and `--login` context override flags with their env equivalents (priority: flag > env > auto-detection).
|
||||||
|
Implement the full error classification table (ten AxiError codes, HTTP status + path-based 404 mapping, TOON error output to stdout, exit codes 0/1/2).
|
||||||
|
Implement test mode (`GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO`) and the fixture server, with Vitest as the runner.
|
||||||
|
`issue list` itself stays minimal in this slice: `--state` and `--limit` only, default fields (`number`, `title`, `state`, `author`, `created`), count line from `X-Total-Count`, explicit empty state, `type=issues` guard, and a next-step suggestion block.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `gitea-axi issue list` returns a TOON list with default fields `number`, `title`, `state` (lowercased), `author`, `created` (relative time), preceded by a `count: N of T total` line
|
||||||
|
- [x] `--state <open|closed|all>` (default open) and `--limit <n>` (default 30) work
|
||||||
|
- [x] Every issues-list API call passes `type=issues`, so PRs never appear in issue lists
|
||||||
|
- [x] Empty result emits `issues[0]: (none)` plus a next-step suggestion, never silent output
|
||||||
|
- [x] Every command run appends at least one `help[N]:` next-step suggestion
|
||||||
|
- [x] Repo owner, name, and hostname are detected from the git `origin` remote in both SSH and HTTPS forms; no recognizable remote yields `REPO_NOT_FOUND`
|
||||||
|
- [x] Credentials come from `tea login list --output json`; missing tea binary yields `TEA_NOT_INSTALLED`, zero logins yields `AUTH_REQUIRED`, no hostname match yields `REPO_NOT_FOUND`, ambiguous multi-match without a default yields `VALIDATION_ERROR` listing profile names
|
||||||
|
- [x] `-R`/`--repo` and `--login` flags (accepted anywhere on the command line) and `GITEA_AXI_REPO`/`GITEA_AXI_LOGIN` env vars override auto-detection with flag > env > auto priority; suggestions include the override flags only when context did not come from the git remote
|
||||||
|
- [x] A nonexistent `--login` profile name yields `VALIDATION_ERROR` listing available profiles
|
||||||
|
- [x] Errors are TOON-encoded to stdout as `error:` + `code:` + optional `help[N]:`, classified per the spec's status table (401→`AUTH_REQUIRED`, 403→`FORBIDDEN`, path-based 404 split, 405/409/422→`VALIDATION_ERROR`, 429→`RATE_LIMITED`, other→`UNKNOWN`)
|
||||||
|
- [x] Exit codes: 0 on success, 1 on error, 2 on `VALIDATION_ERROR` including unknown flags
|
||||||
|
- [x] `--help` on the root and on `issue list` prints a concise flag reference and exits 0; no command ever prompts interactively
|
||||||
|
- [x] Setting `GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO` suppresses the git and tea subprocesses and routes all HTTP to the fixture server
|
||||||
|
- [x] Vitest tests drive the CLI seam (argv in, stdout/exit-code out) against the fixture server for the happy path, empty state, and at least one error classification per category exercised here
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**Token discovery deviates from the letter of the spec and original ADR 0001.**
|
||||||
|
`tea login list --output json` factually carries no token — its columns are only `name`, `url`, `ssh_host`, `user`, `default` (verified against tea 0.14.2 and tea main).
|
||||||
|
The list output is still used for login discovery and the three-way matching split; the token for the selected login comes from `tea login helper get --login <name>` (tea's git-credential interface, which also refreshes OAuth tokens in place).
|
||||||
|
ADR 0001 was amended in this change; the spec's Auth paragraph ("extracts the token") still reflects the old assumption and should be updated — left untouched here because the spec file carries unrelated pending edits.
|
||||||
|
|
||||||
|
**404 classification is slightly broader than the spec table.**
|
||||||
|
The spec maps 404 on `/repos/{owner}/{repo}` itself to `REPO_NOT_FOUND` and "other paths" to `UNKNOWN`.
|
||||||
|
Gitea returns 404 for every path under a nonexistent repository, so a 404 on a repo-subtree path that is not an indexed `/issues/{n}` or `/pulls/{n}` lookup (e.g. the issue-list endpoint itself) is classified `REPO_NOT_FOUND` rather than `UNKNOWN`.
|
||||||
|
Without this, `issue list -R bad/repo` would report `UNKNOWN`, which defeats the code's self-correction purpose.
|
||||||
|
|
||||||
|
**Login hostname matching compares hostnames, ignoring ports.**
|
||||||
|
A login matches when its URL hostname or its `ssh_host` equals the remote's hostname.
|
||||||
|
SSH remotes (like this repo's own `ssh://gitea@git.alexion.dev:2022/...`) use a different port than the login's HTTPS URL, so port-inclusive matching would never match.
|
||||||
|
|
||||||
|
**A minimal home view exists as a placeholder.**
|
||||||
|
`runAxiCli` requires a `home` handler; bare `gitea-axi` currently prints the SDK header, a `repo:` line, and a `help:` block.
|
||||||
|
Task 0017 replaces it with the real two-tier dashboard.
|
||||||
|
Similarly, the SDK's built-in `update` command remains unshadowed until task 0018.
|
||||||
|
|
||||||
|
**Global flags are extracted before the SDK parses argv.**
|
||||||
|
`runAxiCli` rejects any flag placed before the command, so `-R`/`--repo`/`--login` are pulled out of argv first (satisfying "accepted anywhere"); all other leading flags still exit 2 via the SDK's own error.
|
||||||
|
|
||||||
|
**Other notes.**
|
||||||
|
`GITEA_AXI_API_URL` is the instance base URL without `/api/v1` (gitea-js appends it); the fixture server serves `/api/v1/...` paths.
|
||||||
|
The SDK's `renderError` helper is not exported from the axi-sdk-js package index (contrary to ADR 0004's summary), so `src/render.ts` builds the identical error TOON locally.
|
||||||
|
The bin entry guards against EPIPE so `gitea-axi | head` exits quietly instead of crashing.
|
||||||
|
Tests drive the CLI seam in-process via `runCli({ argv, env, cwd, stdout })` — the same function the binary calls — with a fully explicit environment; context-detection tests use real `git` repos plus a fake `tea` script on a sandboxed `PATH`.
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
1693
package-lock.json
generated
Normal file
1693
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-axi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Agent-ergonomic CLI for Gitea issues and pull requests",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gitea-axi": "dist/main.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"prepublishOnly": "npm run build",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@toon-format/toon": "^2.3.0",
|
||||||
|
"axi-sdk-js": "^0.1.8",
|
||||||
|
"gitea-js": "^1.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^3.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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`;
|
||||||
|
}
|
||||||
119
test/context.test.ts
Normal file
119
test/context.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
let server: FixtureServer | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server?.close();
|
||||||
|
server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("context overrides", () => {
|
||||||
|
it("prefers the -R flag over GITEA_AXI_REPO", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "-R", "flagowner/flagrepo"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts -R before the command", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { exitCode } = await runCliTest(
|
||||||
|
["-R", "flagowner/flagrepo", "issue", "list"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the --repo=OWNER/NAME form", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--repo=flagowner/flagrepo"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the -R override in next-step suggestions when context came from a flag", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { stdout } = await runCliTest(
|
||||||
|
["issue", "list", "-R", "flagowner/flagrepo"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stdout).toContain("-R flagowner/flagrepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the -R override in suggestions when context came from the environment", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { stdout } = await runCliTest(["issue", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("-R testowner/testrepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with REPO_NOT_FOUND when test mode has no repository context", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { GITEA_AXI_API_URL: server.url, GITEA_AXI_TOKEN: "test-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||||
|
expect(stdout).toContain("GITEA_AXI_REPO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed -R value with exit code 2", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "-R", "not-a-repo"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("OWNER/NAME");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects -R without a value with exit code 2", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list", "-R"], {
|
||||||
|
env: { GITEA_AXI_API_URL: "http://127.0.0.1:1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test mode never spawns git or tea", async () => {
|
||||||
|
// An empty PATH makes any subprocess spawn fail loudly; success here
|
||||||
|
// proves the git and tea subprocesses were suppressed.
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] },
|
||||||
|
]);
|
||||||
|
const { exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { ...testModeEnv(server.url), PATH: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
test/detection.test.ts
Normal file
257
test/detection.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest } from "./harness.js";
|
||||||
|
|
||||||
|
interface FakeLogin {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
ssh_host?: string;
|
||||||
|
user?: string;
|
||||||
|
default?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "gitea-axi-detect-"));
|
||||||
|
const gitPath = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
||||||
|
// The fake tea script needs cat on the sandbox PATH for its heredoc branches.
|
||||||
|
const catPath = execFileSync("which", ["cat"], { encoding: "utf8" }).trim();
|
||||||
|
let sandboxCounter = 0;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
|
||||||
|
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
|
||||||
|
const bin = join(root, `bin-${sandboxCounter++}`);
|
||||||
|
mkdirSync(bin);
|
||||||
|
symlinkSync(gitPath, join(bin, "git"));
|
||||||
|
symlinkSync(catPath, join(bin, "cat"));
|
||||||
|
if (options.tea !== false) {
|
||||||
|
const script = `#!/bin/sh
|
||||||
|
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${JSON.stringify(options.logins ?? [])}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
||||||
|
cat > /dev/null
|
||||||
|
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "unexpected tea invocation: $*" >&2
|
||||||
|
exit 1
|
||||||
|
`;
|
||||||
|
writeFileSync(join(bin, "tea"), script, { mode: 0o755 });
|
||||||
|
}
|
||||||
|
return bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRepo(remoteUrl: string | undefined): string {
|
||||||
|
const dir = join(root, `repo-${sandboxCounter++}`);
|
||||||
|
mkdirSync(dir);
|
||||||
|
execFileSync("git", ["init", "--quiet"], { cwd: dir });
|
||||||
|
if (remoteUrl) {
|
||||||
|
execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||||
|
|
||||||
|
let server: FixtureServer | undefined;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server?.close();
|
||||||
|
server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startIssuesServer(): Promise<FixtureServer> {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: ISSUES_PATH,
|
||||||
|
headers: { "X-Total-Count": "3" },
|
||||||
|
fixture: "issues-open.json",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("repository context detection", () => {
|
||||||
|
it("detects the repo from an HTTPS origin remote and authenticates via tea", async () => {
|
||||||
|
const { url } = await startIssuesServer();
|
||||||
|
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||||
|
token: "detected-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 3 of 3 total");
|
||||||
|
expect(server!.requests[0]!.headers.authorization).toBe("Bearer detected-token");
|
||||||
|
// Auto-detected context: suggestions must not carry override flags.
|
||||||
|
expect(stdout).not.toContain("-R testowner/testrepo");
|
||||||
|
expect(stdout).not.toContain("--login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects the repo from an SSH (scp-form) origin remote", async () => {
|
||||||
|
const { url } = await startIssuesServer();
|
||||||
|
const cwd = makeRepo("git@127.0.0.1:testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||||
|
token: "detected-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 3 of 3 total");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with REPO_NOT_FOUND when there is no recognizable origin remote", async () => {
|
||||||
|
const cwd = makeRepo(undefined);
|
||||||
|
const bin = makeSandbox({ logins: [] });
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => {
|
||||||
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({ tea: false });
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: TEA_NOT_INSTALLED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with AUTH_REQUIRED when tea has zero logins", async () => {
|
||||||
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({ logins: [] });
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||||
|
expect(stdout).toContain("tea login add");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with REPO_NOT_FOUND when no login matches the remote hostname", async () => {
|
||||||
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [{ name: "other", url: "https://other.example.net", default: "true" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||||
|
expect(stdout).toContain("gitea.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with VALIDATION_ERROR listing profiles on an ambiguous multi-match", async () => {
|
||||||
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [
|
||||||
|
{ name: "work", url: "https://gitea.example.com" },
|
||||||
|
{ name: "personal", url: "https://gitea.example.com" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("work");
|
||||||
|
expect(stdout).toContain("personal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses tea's default login when several match the hostname", async () => {
|
||||||
|
const { url } = await startIssuesServer();
|
||||||
|
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [
|
||||||
|
{ name: "work", url },
|
||||||
|
{ name: "personal", url, default: "true" },
|
||||||
|
],
|
||||||
|
token: "default-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: { PATH: bin },
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(server!.requests[0]!.headers.authorization).toBe("Bearer default-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with VALIDATION_ERROR listing available profiles for a nonexistent --login", async () => {
|
||||||
|
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [
|
||||||
|
{ name: "work", url: "https://gitea.example.com", default: "true" },
|
||||||
|
{ name: "personal", url: "https://gitea.example.com" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--login", "missing"],
|
||||||
|
{ env: { PATH: bin }, cwd },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("missing");
|
||||||
|
expect(stdout).toContain("available: work, personal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a login by name with --login and adds it to suggestions", async () => {
|
||||||
|
const { url } = await startIssuesServer();
|
||||||
|
const cwd = makeRepo("https://unrelated.example.org/testowner/testrepo.git");
|
||||||
|
const bin = makeSandbox({
|
||||||
|
logins: [{ name: "fixture", url }],
|
||||||
|
token: "named-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["--login", "fixture", "issue", "list"],
|
||||||
|
{ env: { PATH: bin }, cwd },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
||||||
|
expect(stdout).toContain("--login fixture");
|
||||||
|
});
|
||||||
|
});
|
||||||
71
test/errors.test.ts
Normal file
71
test/errors.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function listWithStatus(status: number, body: unknown = { message: "boom" }) {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: ISSUES_PATH, status, body },
|
||||||
|
]);
|
||||||
|
return runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("error classification", () => {
|
||||||
|
it("maps 401 to AUTH_REQUIRED with exit code 1", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(401, { message: "token is required" });
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||||
|
expect(stdout).toContain("error: token is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 403 to FORBIDDEN with exit code 1", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(403);
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: FORBIDDEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a 404 on the repo subtree to REPO_NOT_FOUND", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(404, {});
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||||
|
expect(stdout).toContain("testowner/testrepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 422 to VALIDATION_ERROR with the body message and exit code 2", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(422, {
|
||||||
|
message: "state must be one of open, closed, all",
|
||||||
|
});
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("state must be one of");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 429 to RATE_LIMITED with exit code 1", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(429, {});
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: RATE_LIMITED");
|
||||||
|
expect(stdout).toMatch(/help\[\d+\]:.*retry/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps unexpected statuses to UNKNOWN with exit code 1", async () => {
|
||||||
|
const { stdout, exitCode } = await listWithStatus(500, { message: "internal error" });
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stdout).toContain("code: UNKNOWN");
|
||||||
|
expect(stdout).toContain("internal error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors are TOON error blocks on stdout", async () => {
|
||||||
|
const { stdout } = await listWithStatus(403, { message: "no access" });
|
||||||
|
const lines = stdout.trimEnd().split("\n");
|
||||||
|
expect(lines[0]).toBe("error: no access");
|
||||||
|
expect(lines[1]).toBe("code: FORBIDDEN");
|
||||||
|
expect(lines[2]).toMatch(/^help\[\d+\]:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
test/fixture-server.ts
Normal file
96
test/fixture-server.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createServer, type Server } from "node:http";
|
||||||
|
|
||||||
|
export interface FixtureRoute {
|
||||||
|
method: string;
|
||||||
|
/** Exact pathname to match, e.g. "/api/v1/repos/o/r/issues". */
|
||||||
|
path: string;
|
||||||
|
/** Query params that must all be present with these exact values. */
|
||||||
|
query?: Record<string, string>;
|
||||||
|
status?: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/** Inline JSON body; mutually exclusive with `fixture`. */
|
||||||
|
body?: unknown;
|
||||||
|
/** Name of a JSON file in test/fixtures to serve as the body. */
|
||||||
|
fixture?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedRequest {
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
query: Record<string, string>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixtureServer {
|
||||||
|
url: string;
|
||||||
|
requests: RecordedRequest[];
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBody(route: FixtureRoute): unknown {
|
||||||
|
if (route.fixture !== undefined) {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(new URL(`./fixtures/${route.fixture}`, import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return route.body ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(route: FixtureRoute, request: RecordedRequest): boolean {
|
||||||
|
if (route.method !== request.method || route.path !== request.path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(route.query ?? {})) {
|
||||||
|
if (request.query[key] !== value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFixtureServer(routes: FixtureRoute[]): Promise<FixtureServer> {
|
||||||
|
const requests: RecordedRequest[] = [];
|
||||||
|
const server: Server = createServer((req, res) => {
|
||||||
|
const url = new URL(req.url ?? "/", "http://fixture");
|
||||||
|
const recorded: RecordedRequest = {
|
||||||
|
method: req.method ?? "GET",
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams),
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : (v ?? "")]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
requests.push(recorded);
|
||||||
|
const route = routes.find((candidate) => matches(candidate, recorded));
|
||||||
|
if (!route) {
|
||||||
|
res.writeHead(599, { "content-type": "application/json" });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
message: `no fixture route matched ${recorded.method} ${recorded.path} ${JSON.stringify(recorded.query)}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(route.status ?? 200, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...route.headers,
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(loadBody(route)));
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
if (address === null || typeof address === "string") {
|
||||||
|
throw new Error("fixture server has no address");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${address.port}`,
|
||||||
|
requests,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
27
test/fixtures/issues-closed.json
vendored
Normal file
27
test/fixtures/issues-closed.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 300,
|
||||||
|
"number": 37,
|
||||||
|
"title": "Crash on empty config",
|
||||||
|
"body": "Fixed in 1.2.1",
|
||||||
|
"state": "closed",
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 1,
|
||||||
|
"created_at": "2026-04-10T11:00:00Z",
|
||||||
|
"updated_at": "2026-04-12T11:00:00Z",
|
||||||
|
"closed_at": "2026-04-12T11:00:00Z",
|
||||||
|
"html_url": "http://gitea.example/testowner/testrepo/issues/37",
|
||||||
|
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/37",
|
||||||
|
"user": {
|
||||||
|
"id": 9,
|
||||||
|
"login": "contributor",
|
||||||
|
"full_name": "A Contributor",
|
||||||
|
"email": "contributor@example.com"
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": null,
|
||||||
|
"pull_request": null
|
||||||
|
}
|
||||||
|
]
|
||||||
78
test/fixtures/issues-open.json
vendored
Normal file
78
test/fixtures/issues-open.json
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 301,
|
||||||
|
"number": 42,
|
||||||
|
"title": "Fix login redirect loop, please",
|
||||||
|
"body": "Steps to reproduce: log in twice.",
|
||||||
|
"state": "open",
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 2,
|
||||||
|
"created_at": "2026-07-01T10:00:00Z",
|
||||||
|
"updated_at": "2026-07-08T09:30:00Z",
|
||||||
|
"html_url": "http://gitea.example/testowner/testrepo/issues/42",
|
||||||
|
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/42",
|
||||||
|
"user": {
|
||||||
|
"id": 7,
|
||||||
|
"login": "alexion",
|
||||||
|
"full_name": "Alexion",
|
||||||
|
"email": "alexion@example.com"
|
||||||
|
},
|
||||||
|
"labels": [
|
||||||
|
{ "id": 1, "name": "bug", "color": "ee0701" }
|
||||||
|
],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": null,
|
||||||
|
"pull_request": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 302,
|
||||||
|
"number": 41,
|
||||||
|
"title": "Add dark mode",
|
||||||
|
"body": "",
|
||||||
|
"state": "open",
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 0,
|
||||||
|
"created_at": "2026-06-20T15:45:00Z",
|
||||||
|
"updated_at": "2026-06-20T15:45:00Z",
|
||||||
|
"html_url": "http://gitea.example/testowner/testrepo/issues/41",
|
||||||
|
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/41",
|
||||||
|
"user": {
|
||||||
|
"id": 9,
|
||||||
|
"login": "contributor",
|
||||||
|
"full_name": "A Contributor",
|
||||||
|
"email": "contributor@example.com"
|
||||||
|
},
|
||||||
|
"labels": [],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": null,
|
||||||
|
"pull_request": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 303,
|
||||||
|
"number": 38,
|
||||||
|
"title": "Docs: document the release process",
|
||||||
|
"body": "The release process lives only in someone's head.",
|
||||||
|
"state": "open",
|
||||||
|
"is_locked": false,
|
||||||
|
"comments": 5,
|
||||||
|
"created_at": "2026-05-02T08:00:00Z",
|
||||||
|
"updated_at": "2026-07-01T12:00:00Z",
|
||||||
|
"html_url": "http://gitea.example/testowner/testrepo/issues/38",
|
||||||
|
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/38",
|
||||||
|
"user": {
|
||||||
|
"id": 7,
|
||||||
|
"login": "alexion",
|
||||||
|
"full_name": "Alexion",
|
||||||
|
"email": "alexion@example.com"
|
||||||
|
},
|
||||||
|
"labels": [
|
||||||
|
{ "id": 2, "name": "documentation", "color": "0075ca" }
|
||||||
|
],
|
||||||
|
"milestone": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": null,
|
||||||
|
"pull_request": null
|
||||||
|
}
|
||||||
|
]
|
||||||
53
test/git.test.ts
Normal file
53
test/git.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseRemoteUrl } from "../src/git.js";
|
||||||
|
|
||||||
|
describe("parseRemoteUrl", () => {
|
||||||
|
it("parses HTTPS remotes with and without .git", () => {
|
||||||
|
expect(parseRemoteUrl("https://git.example.com/owner/repo.git")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
expect(parseRemoteUrl("https://git.example.com/owner/repo")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses HTTP remotes with a port", () => {
|
||||||
|
expect(parseRemoteUrl("http://git.example.com:3000/owner/repo.git")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses scp-form SSH remotes", () => {
|
||||||
|
expect(parseRemoteUrl("git@git.example.com:owner/repo.git")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
expect(parseRemoteUrl("git.example.com:owner/repo")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses ssh:// remotes with a port", () => {
|
||||||
|
expect(parseRemoteUrl("ssh://git@git.example.com:2222/owner/repo.git")).toEqual({
|
||||||
|
host: "git.example.com",
|
||||||
|
owner: "owner",
|
||||||
|
name: "repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects URLs without an owner/name path", () => {
|
||||||
|
expect(parseRemoteUrl("https://git.example.com/owner")).toBeNull();
|
||||||
|
expect(parseRemoteUrl("https://git.example.com/a/b/c")).toBeNull();
|
||||||
|
expect(parseRemoteUrl("not a url")).toBeNull();
|
||||||
|
expect(parseRemoteUrl("/local/path/repo.git")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
test/harness.ts
Normal file
42
test/harness.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { runCli } from "../src/cli.js";
|
||||||
|
|
||||||
|
export interface CliResult {
|
||||||
|
stdout: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliTestOptions {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the CLI seam: argv in, stdout and exit code out. The environment is
|
||||||
|
* fully explicit — nothing leaks in from the test process's own env.
|
||||||
|
*/
|
||||||
|
export async function runCliTest(
|
||||||
|
argv: string[],
|
||||||
|
options: CliTestOptions = {},
|
||||||
|
): Promise<CliResult> {
|
||||||
|
let stdout = "";
|
||||||
|
const exitCode = await runCli({
|
||||||
|
argv,
|
||||||
|
env: options.env ?? {},
|
||||||
|
cwd: options.cwd ?? process.cwd(),
|
||||||
|
stdout: {
|
||||||
|
write: (chunk: string) => {
|
||||||
|
stdout += chunk;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.exitCode = 0;
|
||||||
|
return { stdout, exitCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testModeEnv(apiUrl: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
GITEA_AXI_API_URL: apiUrl,
|
||||||
|
GITEA_AXI_TOKEN: "test-token",
|
||||||
|
GITEA_AXI_REPO: "testowner/testrepo",
|
||||||
|
};
|
||||||
|
}
|
||||||
46
test/help.test.ts
Normal file
46
test/help.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runCliTest } from "./harness.js";
|
||||||
|
|
||||||
|
describe("--help", () => {
|
||||||
|
it("prints a top-level flag reference and exits 0", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["--help"]);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi");
|
||||||
|
expect(stdout).toContain("issue list");
|
||||||
|
expect(stdout).toContain("-R, --repo");
|
||||||
|
expect(stdout).toContain("--login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the issue list flag reference and exits 0", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list", "--help"]);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi issue list");
|
||||||
|
expect(stdout).toContain("--state");
|
||||||
|
expect(stdout).toContain("--limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the issue group help and exits 0", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "--help"]);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("usage: gitea-axi issue");
|
||||||
|
expect(stdout).toContain("list");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints the version for --version", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["--version"]);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown commands with exit code 2", async () => {
|
||||||
|
const { stdout, exitCode } = await runCliTest(["frobnicate"]);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("frobnicate");
|
||||||
|
});
|
||||||
|
});
|
||||||
167
test/issue-list.test.ts
Normal file
167
test/issue-list.test.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||||
|
import { runCliTest, testModeEnv } from "./harness.js";
|
||||||
|
|
||||||
|
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||||
|
|
||||||
|
let server: FixtureServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("issue list", () => {
|
||||||
|
it("lists open issues with default fields and a count line", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: ISSUES_PATH,
|
||||||
|
query: { state: "open", type: "issues", limit: "30", page: "1" },
|
||||||
|
headers: { "X-Total-Count": "17" },
|
||||||
|
fixture: "issues-open.json",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
const lines = stdout.split("\n");
|
||||||
|
expect(lines[0]).toBe("count: 3 of 17 total");
|
||||||
|
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
|
||||||
|
expect(lines[2]).toMatch(/^ {2}42,"Fix login redirect loop, please",open,alexion,\d+(mo|[smhdy]) ago$/);
|
||||||
|
expect(lines[3]).toMatch(/^ {2}41,Add dark mode,open,contributor,\d+(mo|[smhdy]) ago$/);
|
||||||
|
expect(lines[4]).toMatch(/^ {2}38,"Docs: document the release process",open,alexion,\d+(mo|[smhdy]) ago$/);
|
||||||
|
expect(stdout).toMatch(/^help\[\d+\]:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes type=issues on every issues-list API call", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: ISSUES_PATH, query: { type: "issues" }, body: [] },
|
||||||
|
]);
|
||||||
|
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||||
|
|
||||||
|
expect(server.requests).toHaveLength(1);
|
||||||
|
expect(server.requests[0]!.query.type).toBe("issues");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the token as a bearer Authorization header", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||||
|
]);
|
||||||
|
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||||
|
|
||||||
|
expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes --state and --limit through to the API", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: ISSUES_PATH,
|
||||||
|
query: { state: "closed", limit: "5" },
|
||||||
|
headers: { "X-Total-Count": "1" },
|
||||||
|
fixture: "issues-closed.json",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--state", "closed", "--limit", "5"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 1 of 1 total");
|
||||||
|
expect(stdout).toContain("37,Crash on empty config,closed,contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to state=open and limit=30", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||||
|
]);
|
||||||
|
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||||
|
|
||||||
|
expect(server.requests[0]!.query.state).toBe("open");
|
||||||
|
expect(server.requests[0]!.query.limit).toBe("30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits an explicit empty state with a next-step suggestion", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: ISSUES_PATH,
|
||||||
|
headers: { "X-Total-Count": "0" },
|
||||||
|
body: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("count: 0 of 0 total");
|
||||||
|
expect(stdout).toContain("issues[0]: (none)");
|
||||||
|
expect(stdout).toMatch(/^help\[\d+\]:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests raising --limit when more issues exist than were shown", async () => {
|
||||||
|
server = await startFixtureServer([
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
path: ISSUES_PATH,
|
||||||
|
query: { limit: "3" },
|
||||||
|
headers: { "X-Total-Count": "17" },
|
||||||
|
fixture: "issues-open.json",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { stdout } = await runCliTest(["issue", "list", "--limit", "3"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("issue list --limit <n>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid --state value with exit code 2", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--state", "banana"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(server.requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid --limit value with exit code 2", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--limit", "0"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown flags with exit code 2", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(
|
||||||
|
["issue", "list", "--frobnicate"],
|
||||||
|
{ env: testModeEnv(server.url) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
expect(stdout).toContain("--frobnicate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown issue subcommands with exit code 2", async () => {
|
||||||
|
server = await startFixtureServer([]);
|
||||||
|
const { stdout, exitCode } = await runCliTest(["issue", "destroy"], {
|
||||||
|
env: testModeEnv(server.url),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exitCode).toBe(2);
|
||||||
|
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
test/time.test.ts
Normal file
24
test/time.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { relativeTime } from "../src/time.js";
|
||||||
|
|
||||||
|
const now = new Date("2026-07-10T12:00:00Z");
|
||||||
|
|
||||||
|
describe("relativeTime", () => {
|
||||||
|
it("formats each magnitude bucket", () => {
|
||||||
|
expect(relativeTime("2026-07-10T11:59:30Z", now)).toBe("just now");
|
||||||
|
expect(relativeTime("2026-07-10T11:45:00Z", now)).toBe("15m ago");
|
||||||
|
expect(relativeTime("2026-07-10T07:00:00Z", now)).toBe("5h ago");
|
||||||
|
expect(relativeTime("2026-07-03T12:00:00Z", now)).toBe("7d ago");
|
||||||
|
expect(relativeTime("2026-05-10T12:00:00Z", now)).toBe("2mo ago");
|
||||||
|
expect(relativeTime("2024-07-10T12:00:00Z", now)).toBe("2y ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps future timestamps to just now", () => {
|
||||||
|
expect(relativeTime("2026-07-10T13:00:00Z", now)).toBe("just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unknown for missing or invalid input, matching gh-axi", () => {
|
||||||
|
expect(relativeTime(undefined, now)).toBe("unknown");
|
||||||
|
expect(relativeTime("garbage", now)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
9
tsconfig.build.json
Normal file
9
tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "vitest.config.ts"]
|
||||||
|
}
|
||||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user