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:
119
test/context.test.ts
Normal file
119
test/context.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest, testModeEnv } from "./harness.js";
|
||||
|
||||
let server: FixtureServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
describe("context overrides", () => {
|
||||
it("prefers the -R flag over GITEA_AXI_REPO", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
["issue", "list", "-R", "flagowner/flagrepo"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||
});
|
||||
|
||||
it("accepts -R before the command", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
["-R", "flagowner/flagrepo", "issue", "list"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||
});
|
||||
|
||||
it("accepts the --repo=OWNER/NAME form", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(
|
||||
["issue", "list", "--repo=flagowner/flagrepo"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server.requests[0]!.path).toBe("/api/v1/repos/flagowner/flagrepo/issues");
|
||||
});
|
||||
|
||||
it("includes the -R override in next-step suggestions when context came from a flag", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/flagowner/flagrepo/issues", body: [] },
|
||||
]);
|
||||
const { stdout } = await runCliTest(
|
||||
["issue", "list", "-R", "flagowner/flagrepo"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(stdout).toContain("-R flagowner/flagrepo");
|
||||
});
|
||||
|
||||
it("includes the -R override in suggestions when context came from the environment", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] },
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(stdout).toContain("-R testowner/testrepo");
|
||||
});
|
||||
|
||||
it("fails with REPO_NOT_FOUND when test mode has no repository context", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { GITEA_AXI_API_URL: server.url, GITEA_AXI_TOKEN: "test-token" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
expect(stdout).toContain("GITEA_AXI_REPO");
|
||||
});
|
||||
|
||||
it("rejects a malformed -R value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "-R", "not-a-repo"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("OWNER/NAME");
|
||||
});
|
||||
|
||||
it("rejects -R without a value with exit code 2", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "-R"], {
|
||||
env: { GITEA_AXI_API_URL: "http://127.0.0.1:1" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("test mode never spawns git or tea", async () => {
|
||||
// An empty PATH makes any subprocess spawn fail loudly; success here
|
||||
// proves the git and tea subprocesses were suppressed.
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: "/api/v1/repos/testowner/testrepo/issues", body: [] },
|
||||
]);
|
||||
const { exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { ...testModeEnv(server.url), PATH: "" },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
257
test/detection.test.ts
Normal file
257
test/detection.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest } from "./harness.js";
|
||||
|
||||
interface FakeLogin {
|
||||
name: string;
|
||||
url: string;
|
||||
ssh_host?: string;
|
||||
user?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "gitea-axi-detect-"));
|
||||
const gitPath = execFileSync("which", ["git"], { encoding: "utf8" }).trim();
|
||||
// The fake tea script needs cat on the sandbox PATH for its heredoc branches.
|
||||
const catPath = execFileSync("which", ["cat"], { encoding: "utf8" }).trim();
|
||||
let sandboxCounter = 0;
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
|
||||
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
|
||||
const bin = join(root, `bin-${sandboxCounter++}`);
|
||||
mkdirSync(bin);
|
||||
symlinkSync(gitPath, join(bin, "git"));
|
||||
symlinkSync(catPath, join(bin, "cat"));
|
||||
if (options.tea !== false) {
|
||||
const script = `#!/bin/sh
|
||||
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
|
||||
cat <<'JSON'
|
||||
${JSON.stringify(options.logins ?? [])}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
|
||||
cat > /dev/null
|
||||
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
|
||||
exit 0
|
||||
fi
|
||||
echo "unexpected tea invocation: $*" >&2
|
||||
exit 1
|
||||
`;
|
||||
writeFileSync(join(bin, "tea"), script, { mode: 0o755 });
|
||||
}
|
||||
return bin;
|
||||
}
|
||||
|
||||
function makeRepo(remoteUrl: string | undefined): string {
|
||||
const dir = join(root, `repo-${sandboxCounter++}`);
|
||||
mkdirSync(dir);
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: dir });
|
||||
if (remoteUrl) {
|
||||
execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||
|
||||
let server: FixtureServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
async function startIssuesServer(): Promise<FixtureServer> {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "3" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
return server;
|
||||
}
|
||||
|
||||
describe("repository context detection", () => {
|
||||
it("detects the repo from an HTTPS origin remote and authenticates via tea", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||
token: "detected-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 3 of 3 total");
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer detected-token");
|
||||
// Auto-detected context: suggestions must not carry override flags.
|
||||
expect(stdout).not.toContain("-R testowner/testrepo");
|
||||
expect(stdout).not.toContain("--login");
|
||||
});
|
||||
|
||||
it("detects the repo from an SSH (scp-form) origin remote", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo("git@127.0.0.1:testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url, user: "u", default: "true" }],
|
||||
token: "detected-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 3 of 3 total");
|
||||
});
|
||||
|
||||
it("fails with REPO_NOT_FOUND when there is no recognizable origin remote", async () => {
|
||||
const cwd = makeRepo(undefined);
|
||||
const bin = makeSandbox({ logins: [] });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("fails with TEA_NOT_INSTALLED when the tea binary is missing", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ tea: false });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: TEA_NOT_INSTALLED");
|
||||
});
|
||||
|
||||
it("fails with AUTH_REQUIRED when tea has zero logins", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({ logins: [] });
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||
expect(stdout).toContain("tea login add");
|
||||
});
|
||||
|
||||
it("fails with REPO_NOT_FOUND when no login matches the remote hostname", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "other", url: "https://other.example.net", default: "true" }],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
expect(stdout).toContain("gitea.example.com");
|
||||
});
|
||||
|
||||
it("fails with VALIDATION_ERROR listing profiles on an ambiguous multi-match", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url: "https://gitea.example.com" },
|
||||
{ name: "personal", url: "https://gitea.example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("work");
|
||||
expect(stdout).toContain("personal");
|
||||
});
|
||||
|
||||
it("uses tea's default login when several match the hostname", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo(`${url}/testowner/testrepo.git`);
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url },
|
||||
{ name: "personal", url, default: "true" },
|
||||
],
|
||||
token: "default-token",
|
||||
});
|
||||
|
||||
const { exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: { PATH: bin },
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer default-token");
|
||||
});
|
||||
|
||||
it("fails with VALIDATION_ERROR listing available profiles for a nonexistent --login", async () => {
|
||||
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [
|
||||
{ name: "work", url: "https://gitea.example.com", default: "true" },
|
||||
{ name: "personal", url: "https://gitea.example.com" },
|
||||
],
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--login", "missing"],
|
||||
{ env: { PATH: bin }, cwd },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("missing");
|
||||
expect(stdout).toContain("available: work, personal");
|
||||
});
|
||||
|
||||
it("selects a login by name with --login and adds it to suggestions", async () => {
|
||||
const { url } = await startIssuesServer();
|
||||
const cwd = makeRepo("https://unrelated.example.org/testowner/testrepo.git");
|
||||
const bin = makeSandbox({
|
||||
logins: [{ name: "fixture", url }],
|
||||
token: "named-token",
|
||||
});
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["--login", "fixture", "issue", "list"],
|
||||
{ env: { PATH: bin }, cwd },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
|
||||
expect(stdout).toContain("--login fixture");
|
||||
});
|
||||
});
|
||||
71
test/errors.test.ts
Normal file
71
test/errors.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest, testModeEnv } from "./harness.js";
|
||||
|
||||
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||
|
||||
let server: FixtureServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function listWithStatus(status: number, body: unknown = { message: "boom" }) {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, status, body },
|
||||
]);
|
||||
return runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
}
|
||||
|
||||
describe("error classification", () => {
|
||||
it("maps 401 to AUTH_REQUIRED with exit code 1", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(401, { message: "token is required" });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: AUTH_REQUIRED");
|
||||
expect(stdout).toContain("error: token is required");
|
||||
});
|
||||
|
||||
it("maps 403 to FORBIDDEN with exit code 1", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(403);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: FORBIDDEN");
|
||||
});
|
||||
|
||||
it("maps a 404 on the repo subtree to REPO_NOT_FOUND", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(404, {});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: REPO_NOT_FOUND");
|
||||
expect(stdout).toContain("testowner/testrepo");
|
||||
});
|
||||
|
||||
it("maps 422 to VALIDATION_ERROR with the body message and exit code 2", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(422, {
|
||||
message: "state must be one of open, closed, all",
|
||||
});
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("state must be one of");
|
||||
});
|
||||
|
||||
it("maps 429 to RATE_LIMITED with exit code 1", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(429, {});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: RATE_LIMITED");
|
||||
expect(stdout).toMatch(/help\[\d+\]:.*retry/i);
|
||||
});
|
||||
|
||||
it("maps unexpected statuses to UNKNOWN with exit code 1", async () => {
|
||||
const { stdout, exitCode } = await listWithStatus(500, { message: "internal error" });
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stdout).toContain("code: UNKNOWN");
|
||||
expect(stdout).toContain("internal error");
|
||||
});
|
||||
|
||||
it("errors are TOON error blocks on stdout", async () => {
|
||||
const { stdout } = await listWithStatus(403, { message: "no access" });
|
||||
const lines = stdout.trimEnd().split("\n");
|
||||
expect(lines[0]).toBe("error: no access");
|
||||
expect(lines[1]).toBe("code: FORBIDDEN");
|
||||
expect(lines[2]).toMatch(/^help\[\d+\]:/);
|
||||
});
|
||||
});
|
||||
96
test/fixture-server.ts
Normal file
96
test/fixture-server.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface FixtureRoute {
|
||||
method: string;
|
||||
/** Exact pathname to match, e.g. "/api/v1/repos/o/r/issues". */
|
||||
path: string;
|
||||
/** Query params that must all be present with these exact values. */
|
||||
query?: Record<string, string>;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
/** Inline JSON body; mutually exclusive with `fixture`. */
|
||||
body?: unknown;
|
||||
/** Name of a JSON file in test/fixtures to serve as the body. */
|
||||
fixture?: string;
|
||||
}
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FixtureServer {
|
||||
url: string;
|
||||
requests: RecordedRequest[];
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function loadBody(route: FixtureRoute): unknown {
|
||||
if (route.fixture !== undefined) {
|
||||
return JSON.parse(
|
||||
readFileSync(new URL(`./fixtures/${route.fixture}`, import.meta.url), "utf8"),
|
||||
);
|
||||
}
|
||||
return route.body ?? {};
|
||||
}
|
||||
|
||||
function matches(route: FixtureRoute, request: RecordedRequest): boolean {
|
||||
if (route.method !== request.method || route.path !== request.path) {
|
||||
return false;
|
||||
}
|
||||
for (const [key, value] of Object.entries(route.query ?? {})) {
|
||||
if (request.query[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function startFixtureServer(routes: FixtureRoute[]): Promise<FixtureServer> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fixture");
|
||||
const recorded: RecordedRequest = {
|
||||
method: req.method ?? "GET",
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : (v ?? "")]),
|
||||
),
|
||||
};
|
||||
requests.push(recorded);
|
||||
const route = routes.find((candidate) => matches(candidate, recorded));
|
||||
if (!route) {
|
||||
res.writeHead(599, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: `no fixture route matched ${recorded.method} ${recorded.path} ${JSON.stringify(recorded.query)}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(route.status ?? 200, {
|
||||
"content-type": "application/json",
|
||||
...route.headers,
|
||||
});
|
||||
res.end(JSON.stringify(loadBody(route)));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("fixture server has no address");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
27
test/fixtures/issues-closed.json
vendored
Normal file
27
test/fixtures/issues-closed.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"id": 300,
|
||||
"number": 37,
|
||||
"title": "Crash on empty config",
|
||||
"body": "Fixed in 1.2.1",
|
||||
"state": "closed",
|
||||
"is_locked": false,
|
||||
"comments": 1,
|
||||
"created_at": "2026-04-10T11:00:00Z",
|
||||
"updated_at": "2026-04-12T11:00:00Z",
|
||||
"closed_at": "2026-04-12T11:00:00Z",
|
||||
"html_url": "http://gitea.example/testowner/testrepo/issues/37",
|
||||
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/37",
|
||||
"user": {
|
||||
"id": 9,
|
||||
"login": "contributor",
|
||||
"full_name": "A Contributor",
|
||||
"email": "contributor@example.com"
|
||||
},
|
||||
"labels": [],
|
||||
"milestone": null,
|
||||
"assignee": null,
|
||||
"assignees": null,
|
||||
"pull_request": null
|
||||
}
|
||||
]
|
||||
78
test/fixtures/issues-open.json
vendored
Normal file
78
test/fixtures/issues-open.json
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"id": 301,
|
||||
"number": 42,
|
||||
"title": "Fix login redirect loop, please",
|
||||
"body": "Steps to reproduce: log in twice.",
|
||||
"state": "open",
|
||||
"is_locked": false,
|
||||
"comments": 2,
|
||||
"created_at": "2026-07-01T10:00:00Z",
|
||||
"updated_at": "2026-07-08T09:30:00Z",
|
||||
"html_url": "http://gitea.example/testowner/testrepo/issues/42",
|
||||
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/42",
|
||||
"user": {
|
||||
"id": 7,
|
||||
"login": "alexion",
|
||||
"full_name": "Alexion",
|
||||
"email": "alexion@example.com"
|
||||
},
|
||||
"labels": [
|
||||
{ "id": 1, "name": "bug", "color": "ee0701" }
|
||||
],
|
||||
"milestone": null,
|
||||
"assignee": null,
|
||||
"assignees": null,
|
||||
"pull_request": null
|
||||
},
|
||||
{
|
||||
"id": 302,
|
||||
"number": 41,
|
||||
"title": "Add dark mode",
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"is_locked": false,
|
||||
"comments": 0,
|
||||
"created_at": "2026-06-20T15:45:00Z",
|
||||
"updated_at": "2026-06-20T15:45:00Z",
|
||||
"html_url": "http://gitea.example/testowner/testrepo/issues/41",
|
||||
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/41",
|
||||
"user": {
|
||||
"id": 9,
|
||||
"login": "contributor",
|
||||
"full_name": "A Contributor",
|
||||
"email": "contributor@example.com"
|
||||
},
|
||||
"labels": [],
|
||||
"milestone": null,
|
||||
"assignee": null,
|
||||
"assignees": null,
|
||||
"pull_request": null
|
||||
},
|
||||
{
|
||||
"id": 303,
|
||||
"number": 38,
|
||||
"title": "Docs: document the release process",
|
||||
"body": "The release process lives only in someone's head.",
|
||||
"state": "open",
|
||||
"is_locked": false,
|
||||
"comments": 5,
|
||||
"created_at": "2026-05-02T08:00:00Z",
|
||||
"updated_at": "2026-07-01T12:00:00Z",
|
||||
"html_url": "http://gitea.example/testowner/testrepo/issues/38",
|
||||
"url": "http://gitea.example/api/v1/repos/testowner/testrepo/issues/38",
|
||||
"user": {
|
||||
"id": 7,
|
||||
"login": "alexion",
|
||||
"full_name": "Alexion",
|
||||
"email": "alexion@example.com"
|
||||
},
|
||||
"labels": [
|
||||
{ "id": 2, "name": "documentation", "color": "0075ca" }
|
||||
],
|
||||
"milestone": null,
|
||||
"assignee": null,
|
||||
"assignees": null,
|
||||
"pull_request": null
|
||||
}
|
||||
]
|
||||
53
test/git.test.ts
Normal file
53
test/git.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseRemoteUrl } from "../src/git.js";
|
||||
|
||||
describe("parseRemoteUrl", () => {
|
||||
it("parses HTTPS remotes with and without .git", () => {
|
||||
expect(parseRemoteUrl("https://git.example.com/owner/repo.git")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
expect(parseRemoteUrl("https://git.example.com/owner/repo")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses HTTP remotes with a port", () => {
|
||||
expect(parseRemoteUrl("http://git.example.com:3000/owner/repo.git")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses scp-form SSH remotes", () => {
|
||||
expect(parseRemoteUrl("git@git.example.com:owner/repo.git")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
expect(parseRemoteUrl("git.example.com:owner/repo")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses ssh:// remotes with a port", () => {
|
||||
expect(parseRemoteUrl("ssh://git@git.example.com:2222/owner/repo.git")).toEqual({
|
||||
host: "git.example.com",
|
||||
owner: "owner",
|
||||
name: "repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects URLs without an owner/name path", () => {
|
||||
expect(parseRemoteUrl("https://git.example.com/owner")).toBeNull();
|
||||
expect(parseRemoteUrl("https://git.example.com/a/b/c")).toBeNull();
|
||||
expect(parseRemoteUrl("not a url")).toBeNull();
|
||||
expect(parseRemoteUrl("/local/path/repo.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
42
test/harness.ts
Normal file
42
test/harness.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { runCli } from "../src/cli.js";
|
||||
|
||||
export interface CliResult {
|
||||
stdout: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface CliTestOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the CLI seam: argv in, stdout and exit code out. The environment is
|
||||
* fully explicit — nothing leaks in from the test process's own env.
|
||||
*/
|
||||
export async function runCliTest(
|
||||
argv: string[],
|
||||
options: CliTestOptions = {},
|
||||
): Promise<CliResult> {
|
||||
let stdout = "";
|
||||
const exitCode = await runCli({
|
||||
argv,
|
||||
env: options.env ?? {},
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
stdout: {
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
});
|
||||
process.exitCode = 0;
|
||||
return { stdout, exitCode };
|
||||
}
|
||||
|
||||
export function testModeEnv(apiUrl: string): Record<string, string> {
|
||||
return {
|
||||
GITEA_AXI_API_URL: apiUrl,
|
||||
GITEA_AXI_TOKEN: "test-token",
|
||||
GITEA_AXI_REPO: "testowner/testrepo",
|
||||
};
|
||||
}
|
||||
46
test/help.test.ts
Normal file
46
test/help.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runCliTest } from "./harness.js";
|
||||
|
||||
describe("--help", () => {
|
||||
it("prints a top-level flag reference and exits 0", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["--help"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi");
|
||||
expect(stdout).toContain("issue list");
|
||||
expect(stdout).toContain("-R, --repo");
|
||||
expect(stdout).toContain("--login");
|
||||
});
|
||||
|
||||
it("prints the issue list flag reference and exits 0", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list", "--help"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi issue list");
|
||||
expect(stdout).toContain("--state");
|
||||
expect(stdout).toContain("--limit");
|
||||
});
|
||||
|
||||
it("prints the issue group help and exits 0", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "--help"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi issue");
|
||||
expect(stdout).toContain("list");
|
||||
});
|
||||
|
||||
it("prints the version for --version", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["--version"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
it("rejects unknown commands with exit code 2", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["frobnicate"]);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("frobnicate");
|
||||
});
|
||||
});
|
||||
167
test/issue-list.test.ts
Normal file
167
test/issue-list.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
|
||||
import { runCliTest, testModeEnv } from "./harness.js";
|
||||
|
||||
const ISSUES_PATH = "/api/v1/repos/testowner/testrepo/issues";
|
||||
|
||||
let server: FixtureServer;
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("issue list", () => {
|
||||
it("lists open issues with default fields and a count line", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { state: "open", type: "issues", limit: "30", page: "1" },
|
||||
headers: { "X-Total-Count": "17" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const lines = stdout.split("\n");
|
||||
expect(lines[0]).toBe("count: 3 of 17 total");
|
||||
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
|
||||
expect(lines[2]).toMatch(/^ {2}42,"Fix login redirect loop, please",open,alexion,\d+(mo|[smhdy]) ago$/);
|
||||
expect(lines[3]).toMatch(/^ {2}41,Add dark mode,open,contributor,\d+(mo|[smhdy]) ago$/);
|
||||
expect(lines[4]).toMatch(/^ {2}38,"Docs: document the release process",open,alexion,\d+(mo|[smhdy]) ago$/);
|
||||
expect(stdout).toMatch(/^help\[\d+\]:/m);
|
||||
});
|
||||
|
||||
it("passes type=issues on every issues-list API call", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, query: { type: "issues" }, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(server.requests).toHaveLength(1);
|
||||
expect(server.requests[0]!.query.type).toBe("issues");
|
||||
});
|
||||
|
||||
it("sends the token as a bearer Authorization header", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(server.requests[0]!.headers.authorization).toBe("Bearer test-token");
|
||||
});
|
||||
|
||||
it("passes --state and --limit through to the API", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { state: "closed", limit: "5" },
|
||||
headers: { "X-Total-Count": "1" },
|
||||
fixture: "issues-closed.json",
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--state", "closed", "--limit", "5"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 1 of 1 total");
|
||||
expect(stdout).toContain("37,Crash on empty config,closed,contributor");
|
||||
});
|
||||
|
||||
it("defaults to state=open and limit=30", async () => {
|
||||
server = await startFixtureServer([
|
||||
{ method: "GET", path: ISSUES_PATH, body: [] },
|
||||
]);
|
||||
await runCliTest(["issue", "list"], { env: testModeEnv(server.url) });
|
||||
|
||||
expect(server.requests[0]!.query.state).toBe("open");
|
||||
expect(server.requests[0]!.query.limit).toBe("30");
|
||||
});
|
||||
|
||||
it("emits an explicit empty state with a next-step suggestion", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
headers: { "X-Total-Count": "0" },
|
||||
body: [],
|
||||
},
|
||||
]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("count: 0 of 0 total");
|
||||
expect(stdout).toContain("issues[0]: (none)");
|
||||
expect(stdout).toMatch(/^help\[\d+\]:/m);
|
||||
});
|
||||
|
||||
it("suggests raising --limit when more issues exist than were shown", async () => {
|
||||
server = await startFixtureServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: ISSUES_PATH,
|
||||
query: { limit: "3" },
|
||||
headers: { "X-Total-Count": "17" },
|
||||
fixture: "issues-open.json",
|
||||
},
|
||||
]);
|
||||
const { stdout } = await runCliTest(["issue", "list", "--limit", "3"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(stdout).toContain("issue list --limit <n>");
|
||||
});
|
||||
|
||||
it("rejects an invalid --state value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--state", "banana"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(server.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects an invalid --limit value with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--limit", "0"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
});
|
||||
|
||||
it("rejects unknown flags with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(
|
||||
["issue", "list", "--frobnicate"],
|
||||
{ env: testModeEnv(server.url) },
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("--frobnicate");
|
||||
});
|
||||
|
||||
it("rejects unknown issue subcommands with exit code 2", async () => {
|
||||
server = await startFixtureServer([]);
|
||||
const { stdout, exitCode } = await runCliTest(["issue", "destroy"], {
|
||||
env: testModeEnv(server.url),
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
});
|
||||
});
|
||||
24
test/time.test.ts
Normal file
24
test/time.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { relativeTime } from "../src/time.js";
|
||||
|
||||
const now = new Date("2026-07-10T12:00:00Z");
|
||||
|
||||
describe("relativeTime", () => {
|
||||
it("formats each magnitude bucket", () => {
|
||||
expect(relativeTime("2026-07-10T11:59:30Z", now)).toBe("just now");
|
||||
expect(relativeTime("2026-07-10T11:45:00Z", now)).toBe("15m ago");
|
||||
expect(relativeTime("2026-07-10T07:00:00Z", now)).toBe("5h ago");
|
||||
expect(relativeTime("2026-07-03T12:00:00Z", now)).toBe("7d ago");
|
||||
expect(relativeTime("2026-05-10T12:00:00Z", now)).toBe("2mo ago");
|
||||
expect(relativeTime("2024-07-10T12:00:00Z", now)).toBe("2y ago");
|
||||
});
|
||||
|
||||
it("clamps future timestamps to just now", () => {
|
||||
expect(relativeTime("2026-07-10T13:00:00Z", now)).toBe("just now");
|
||||
});
|
||||
|
||||
it("returns unknown for missing or invalid input, matching gh-axi", () => {
|
||||
expect(relativeTime(undefined, now)).toBe("unknown");
|
||||
expect(relativeTime("garbage", now)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user