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

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

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

View File

@@ -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.

View 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
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1693
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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
View 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
View 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
View File

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

193
src/context.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

15
tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
},
});