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

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");
});
});