From 0dfa60abe28bc337f55a45c87bfa22f7d3492a8a Mon Sep 17 00:00:00 2001 From: jony376 Date: Sat, 4 Jul 2026 18:42:54 -0700 Subject: [PATCH] feat(miner): add local claims ledger CLI Expose list, claim, release, and sweep subcommands for the existing local claim ledger so operators can manage soft claims without importing store modules directly. Co-authored-by: Cursor --- .../gittensory-miner/bin/gittensory-miner.js | 5 + .../lib/claim-ledger-cli.d.ts | 69 ++++ .../gittensory-miner/lib/claim-ledger-cli.js | 331 ++++++++++++++++++ packages/gittensory-miner/lib/cli.js | 4 + packages/gittensory-miner/package.json | 2 +- test/unit/miner-claim-ledger-cli.test.ts | 201 +++++++++++ 6 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/claim-ledger-cli.d.ts create mode 100644 packages/gittensory-miner/lib/claim-ledger-cli.js create mode 100644 test/unit/miner-claim-ledger-cli.test.ts diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 74a0096dde..9893e7fb46 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; +import { runClaimsCli } from "../lib/claim-ledger-cli.js"; import { runDenyCheck } from "../lib/deny-check.js"; import { runGovernorCli } from "../lib/governor-ledger-cli.js"; import { runLedgerCli } from "../lib/event-ledger-cli.js"; @@ -37,6 +38,10 @@ if (cliArgs[0] === "queue") { process.exit(runQueueCli(cliArgs[1], cliArgs.slice(2))); } +if (cliArgs[0] === "claims") { + process.exit(runClaimsCli(cliArgs[1], cliArgs.slice(2))); +} + if (cliArgs[0] === "ledger") { process.exit(runLedgerCli(cliArgs[1], cliArgs.slice(2))); } diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.d.ts b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts new file mode 100644 index 0000000000..0caddc27bd --- /dev/null +++ b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts @@ -0,0 +1,69 @@ +import type { ClaimEntry, ClaimLedger, ClaimStatus } from "./claim-ledger.js"; + +export type ParsedClaimsListArgs = + | { + json: boolean; + repoFullName: string | null; + status: ClaimStatus | null; + } + | { error: string }; + +export type ParsedClaimsClaimArgs = + | { + repoFullName: string; + issueNumber: number; + note: string | null; + json: boolean; + } + | { error: string }; + +export type ParsedClaimsReleaseArgs = + | { + repoFullName: string; + issueNumber: number; + json: boolean; + } + | { error: string }; + +export type ParsedClaimsSweepArgs = + | { + json: boolean; + maxAgeDays: number; + } + | { error: string }; + +export function parseClaimsListArgs(args: string[]): ParsedClaimsListArgs; + +export function parseClaimsClaimArgs(args: string[]): ParsedClaimsClaimArgs; + +export function parseClaimsReleaseArgs(args: string[]): ParsedClaimsReleaseArgs; + +export function parseClaimsSweepArgs(args: string[]): ParsedClaimsSweepArgs; + +export function renderClaimsTable(claims: ClaimEntry[]): string; + +export function runClaimsList( + args: string[], + options?: { openClaimLedger?: () => ClaimLedger }, +): number; + +export function runClaimsClaim( + args: string[], + options?: { openClaimLedger?: () => ClaimLedger }, +): number; + +export function runClaimsRelease( + args: string[], + options?: { openClaimLedger?: () => ClaimLedger }, +): number; + +export function runClaimsSweep( + args: string[], + options?: { openClaimLedger?: () => ClaimLedger; nowMs?: number }, +): number; + +export function runClaimsCli( + subcommand: string | undefined, + args: string[], + options?: { openClaimLedger?: () => ClaimLedger; nowMs?: number }, +): number; diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.js b/packages/gittensory-miner/lib/claim-ledger-cli.js new file mode 100644 index 0000000000..79cca6f60d --- /dev/null +++ b/packages/gittensory-miner/lib/claim-ledger-cli.js @@ -0,0 +1,331 @@ +import { CLAIM_STATUSES, openClaimLedger } from "./claim-ledger.js"; +import { DEFAULT_MAX_CLAIM_AGE_MS, sweepExpiredClaims } from "./claim-ledger-expiry.js"; + +const CLAIMS_USAGE = + "Usage: gittensory-miner claims list|claim|release|sweep (run --help for details)"; +const CLAIMS_LIST_USAGE = + "Usage: gittensory-miner claims list [--repo ] [--status active|released|expired] [--json]"; +const CLAIMS_CLAIM_USAGE = + "Usage: gittensory-miner claims claim [--note ] [--json]"; +const CLAIMS_RELEASE_USAGE = "Usage: gittensory-miner claims release [--json]"; +const CLAIMS_SWEEP_USAGE = "Usage: gittensory-miner claims sweep [--max-age-days ] [--json]"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function parseRepoArg(value, usage) { + if (!value) return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; +} + +function parseIssueNumber(value) { + const issueNumber = Number(value); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + return { error: "Issue number must be a positive integer." }; + } + return { issueNumber }; +} + +function parseJsonFlag(args) { + const options = { json: false }; + const positional = []; + + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + if (token.startsWith("--")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + return { positional, ...options }; +} + +export function parseClaimsListArgs(args) { + const options = { json: false, repoFullName: null, status: null }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--repo") { + const repoArg = args[index + 1]; + if (!repoArg || repoArg.startsWith("--")) return { error: CLAIMS_LIST_USAGE }; + const repo = parseRepoArg(repoArg, CLAIMS_LIST_USAGE); + if ("error" in repo) return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token === "--status") { + const status = args[index + 1]; + if (!status || status.startsWith("--")) return { error: CLAIMS_LIST_USAGE }; + if (!CLAIM_STATUSES.includes(status)) { + return { error: `Invalid status: ${status}. Expected one of ${CLAIM_STATUSES.join(", ")}.` }; + } + options.status = status; + index += 1; + continue; + } + if (token.startsWith("--")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length > 0) return { error: CLAIMS_LIST_USAGE }; + return options; +} + +export function parseClaimsClaimArgs(args) { + const options = { json: false, note: null }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--note") { + const note = args[index + 1]; + if (note === undefined) return { error: CLAIMS_CLAIM_USAGE }; + options.note = note; + index += 1; + continue; + } + if (token.startsWith("--")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length !== 2) return { error: CLAIMS_CLAIM_USAGE }; + + const repo = parseRepoArg(positional[0], CLAIMS_CLAIM_USAGE); + if ("error" in repo) return repo; + const issue = parseIssueNumber(positional[1]); + if ("error" in issue) return issue; + + return { + repoFullName: repo.repoFullName, + issueNumber: issue.issueNumber, + note: options.note, + json: options.json, + }; +} + +export function parseClaimsReleaseArgs(args) { + const parsed = parseJsonFlag(args); + if ("error" in parsed) return parsed; + if (parsed.positional.length !== 2) return { error: CLAIMS_RELEASE_USAGE }; + + const repo = parseRepoArg(parsed.positional[0], CLAIMS_RELEASE_USAGE); + if ("error" in repo) return repo; + const issue = parseIssueNumber(parsed.positional[1]); + if ("error" in issue) return issue; + + return { + repoFullName: repo.repoFullName, + issueNumber: issue.issueNumber, + json: parsed.json, + }; +} + +export function parseClaimsSweepArgs(args) { + const options = { json: false, maxAgeDays: DEFAULT_MAX_CLAIM_AGE_MS / MS_PER_DAY }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--max-age-days") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) return { error: CLAIMS_SWEEP_USAGE }; + const maxAgeDays = Number(value); + if (!Number.isInteger(maxAgeDays) || maxAgeDays <= 0) { + return { error: "max-age-days must be a positive integer." }; + } + options.maxAgeDays = maxAgeDays; + index += 1; + continue; + } + if (token.startsWith("--")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length > 0) return { error: CLAIMS_SWEEP_USAGE }; + return options; +} + +function display(value) { + if (value === null || value === undefined) return "-"; + return String(value); +} + +export function renderClaimsTable(claims) { + if (!Array.isArray(claims) || claims.length === 0) return "no claim ledger entries"; + const header = [ + "repo".padEnd(24), + "issue".padStart(5), + "status".padEnd(10), + "claimed-at".padEnd(24), + "note".padEnd(16), + ].join(" "); + const lines = claims.map((entry) => + [ + entry.repoFullName.padEnd(24), + String(entry.issueNumber).padStart(5), + entry.status.padEnd(10), + display(entry.claimedAt).padEnd(24), + display(entry.note).padEnd(16), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + +function withClaimLedger(options, run) { + const ownsLedger = options.openClaimLedger === undefined; + const claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + try { + return run(claimLedger); + } finally { + if (ownsLedger) claimLedger.close(); + } +} + +function findClaim(claimLedger, repoFullName, issueNumber) { + return claimLedger + .listClaims({ repoFullName }) + .find((entry) => entry.issueNumber === issueNumber); +} + +function emitClaimResult(claim, json) { + if (json) { + console.log(JSON.stringify({ claim }, null, 2)); + } else { + console.log(claim.status); + } +} + +export function runClaimsList(args, options = {}) { + const parsed = parseClaimsListArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withClaimLedger(options, (claimLedger) => { + const claims = claimLedger.listClaims({ + repoFullName: parsed.repoFullName, + status: parsed.status, + }); + if (parsed.json) { + console.log(JSON.stringify({ claims }, null, 2)); + } else { + console.log(renderClaimsTable(claims)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runClaimsClaim(args, options = {}) { + const parsed = parseClaimsClaimArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withClaimLedger(options, (claimLedger) => { + const claim = claimLedger.recordClaim({ + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + note: parsed.note ?? undefined, + }); + emitClaimResult(claim, parsed.json); + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runClaimsRelease(args, options = {}) { + const parsed = parseClaimsReleaseArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withClaimLedger(options, (claimLedger) => { + let claim = claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + if (!claim) { + const existing = findClaim(claimLedger, parsed.repoFullName, parsed.issueNumber); + if (existing?.status === "released") { + claim = existing; + } else { + console.error("claim_not_found_or_not_active"); + return 2; + } + } + emitClaimResult(claim, parsed.json); + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runClaimsSweep(args, options = {}) { + const parsed = parseClaimsSweepArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withClaimLedger(options, (claimLedger) => { + const nowMs = options.nowMs ?? Date.now(); + const maxAgeMs = parsed.maxAgeDays * MS_PER_DAY; + const expired = sweepExpiredClaims(claimLedger, nowMs, maxAgeMs); + if (parsed.json) { + console.log(JSON.stringify({ expired, maxAgeDays: parsed.maxAgeDays }, null, 2)); + } else if (expired.length === 0) { + console.log("no expired claims"); + } else { + console.log(renderClaimsTable(expired)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runClaimsCli(subcommand, args, options = {}) { + if (subcommand === "list") return runClaimsList(args, options); + if (subcommand === "claim") return runClaimsClaim(args, options); + if (subcommand === "release") return runClaimsRelease(args, options); + if (subcommand === "sweep") return runClaimsSweep(args, options); + console.error(`Unknown claims subcommand: ${subcommand ?? ""}. ${CLAIMS_USAGE}`); + return 2; +} diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 6f999371b5..963f780ef4 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -21,6 +21,10 @@ export function printHelp(input) { " gittensory-miner queue list [--repo ] [--json] List portfolio backlog rows", " gittensory-miner queue next [--json] Claim the highest-priority queued item", " gittensory-miner queue done [--json]", + " gittensory-miner claims list [--repo ] [--status active|released|expired] [--json]", + " gittensory-miner claims claim [--note ] [--json]", + " gittensory-miner claims release [--json]", + " gittensory-miner claims sweep [--max-age-days ] [--json]", " gittensory-miner ledger list [--repo ] [--since ] [--type ] [--json]", " gittensory-miner plan list [--status pending|running|completed|failed] [--json]", " gittensory-miner plan show [--json]", diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 51b4353a02..fed32cfa9b 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-claim-ledger-cli.test.ts b/test/unit/miner-claim-ledger-cli.test.ts new file mode 100644 index 0000000000..5527146407 --- /dev/null +++ b/test/unit/miner-claim-ledger-cli.test.ts @@ -0,0 +1,201 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeDefaultClaimLedger, + openClaimLedger, +} from "../../packages/gittensory-miner/lib/claim-ledger.js"; +import { + parseClaimsClaimArgs, + parseClaimsListArgs, + parseClaimsReleaseArgs, + parseClaimsSweepArgs, + renderClaimsTable, + runClaimsClaim, + runClaimsCli, + runClaimsList, + runClaimsRelease, + runClaimsSweep, +} from "../../packages/gittensory-miner/lib/claim-ledger-cli.js"; +import type { ClaimEntry } from "../../packages/gittensory-miner/lib/claim-ledger.d.ts"; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +function tempLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-claim-cli-")); + roots.push(root); + const ledger = openClaimLedger(join(root, "claim-ledger.sqlite3")); + ledgers.push(ledger); + return ledger; +} + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + closeDefaultClaimLedger(); + vi.restoreAllMocks(); + vi.useRealTimers(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner claims CLI (#2314/#2316)", () => { + it("parseClaimsListArgs, parseClaimsClaimArgs, parseClaimsReleaseArgs, and parseClaimsSweepArgs validate argv", () => { + expect(parseClaimsListArgs(["--repo", "acme/widgets", "--status", "active", "--json"])).toEqual({ + json: true, + repoFullName: "acme/widgets", + status: "active", + }); + expect(parseClaimsClaimArgs(["acme/widgets", "42", "--note", "mine"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 42, + note: "mine", + json: false, + }); + expect(parseClaimsClaimArgs(["acme/widgets", "42", "--note", "-blocked"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 42, + note: "-blocked", + json: false, + }); + expect(parseClaimsReleaseArgs(["acme/widgets", "7", "--json"])).toEqual({ + repoFullName: "acme/widgets", + issueNumber: 7, + json: true, + }); + expect(parseClaimsSweepArgs(["--max-age-days", "3", "--json"])).toEqual({ + json: true, + maxAgeDays: 3, + }); + expect(parseClaimsClaimArgs(["acme/widgets"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner claims claim"), + }); + }); + + it("renderClaimsTable formats claim rows and empty output", () => { + const claims: ClaimEntry[] = [ + { + id: 1, + repoFullName: "acme/widgets", + issueNumber: 9, + status: "active", + claimedAt: "2026-07-04T12:00:00.000Z", + note: "working", + }, + ]; + expect(renderClaimsTable([])).toBe("no claim ledger entries"); + expect(renderClaimsTable(claims)).toContain("working"); + expect(renderClaimsTable(claims)).toContain(" 9"); + }); + + it("runClaimsList prints table and JSON output", () => { + const claimLedger = tempLedger(); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1, note: "a" }); + claimLedger.recordClaim({ repoFullName: "acme/other", issueNumber: 2 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runClaimsList([], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("acme/widgets"); + + log.mockClear(); + expect( + runClaimsList(["--repo", "acme/other", "--json"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + claims: [expect.objectContaining({ issueNumber: 2, repoFullName: "acme/other" })], + }); + }); + + it("runClaimsClaim and runClaimsRelease mutate the ledger", () => { + const claimLedger = tempLedger(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runClaimsClaim(["acme/widgets", "12", "--note", "phase-2"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("active"); + expect(claimLedger.listClaims({ status: "active" })).toHaveLength(1); + + log.mockClear(); + expect( + runClaimsRelease(["acme/widgets", "12"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("released"); + }); + + it("runClaimsRelease is idempotent for already-released claims", () => { + const claimLedger = tempLedger(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const options = { openClaimLedger: () => claimLedger }; + + expect(runClaimsClaim(["acme/widgets", "12"], options)).toBe(0); + expect(runClaimsRelease(["acme/widgets", "12"], options)).toBe(0); + + log.mockClear(); + expect(runClaimsRelease(["acme/widgets", "12"], options)).toBe(0); + expect(log).toHaveBeenCalledWith("released"); + }); + + it("runClaimsRelease fails closed when the claim is missing or not active", () => { + const claimLedger = tempLedger(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + runClaimsRelease(["acme/widgets", "404"], { + openClaimLedger: () => claimLedger, + }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("claim_not_found_or_not_active"); + }); + + it("runClaimsSweep expires stale active claims", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-20T00:00:00.000Z")); + const claimLedger = tempLedger(); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 1 }); + vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z")); + claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 2 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runClaimsSweep(["--max-age-days", "7", "--json"], { + openClaimLedger: () => claimLedger, + nowMs: Date.parse("2026-07-03T00:00:00.000Z"), + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + expired: [expect.objectContaining({ issueNumber: 1, status: "expired" })], + maxAgeDays: 7, + }); + expect(claimLedger.listClaims({ status: "active" }).map((entry) => entry.issueNumber)).toEqual([2]); + }); + + it("runClaimsCli dispatches list, claim, release, and sweep subcommands", () => { + const claimLedger = tempLedger(); + const options = { openClaimLedger: () => claimLedger, nowMs: Date.parse("2026-07-03T00:00:00.000Z") }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runClaimsCli("list", ["--json"], options)).toBe(0); + expect(runClaimsCli("claim", ["acme/widgets", "3"], options)).toBe(0); + expect(runClaimsCli("release", ["acme/widgets", "3"], options)).toBe(0); + expect(runClaimsCli("sweep", [], options)).toBe(0); + expect(log).toHaveBeenCalled(); + }); + + it("rejects unknown claims subcommands and options", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runClaimsCli("peek", [])).toBe(2); + expect(runClaimsList(["--verbose"])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown claims subcommand"); + expect(String(error.mock.calls[0]?.[0])).toContain("list|claim|release|sweep"); + }); +});