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

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+\]:/);
});
});