Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/loopover-miner/lib/claim-ledger-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const CLAIM_RELEASE_USAGE =
"Usage: loopover-miner claim release <owner/repo> <issue#> [--api-base-url <url>] [--dry-run] [--json]";
const CLAIM_LIST_USAGE =
"Usage: loopover-miner claim list [--repo <owner/repo>] [--status active|released|expired] [--json]";
const CLAIM_RECLAIM_USAGE =
"Usage: loopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json]";

export type ParsedClaimClaimArgs =
| {
Expand Down Expand Up @@ -39,6 +41,14 @@ export type ParsedClaimListArgs =
}
| { error: string };

export type ParsedClaimReclaimArgs =
| {
maxAgeMs: number | undefined;
dryRun: boolean;
json: boolean;
}
| { error: string };

export type ClaimLedgerCliOptions = { openClaimLedger?: () => ClaimLedger };

type ParsedRepoArg = { repoFullName: string } | { error: string };
Expand Down Expand Up @@ -361,9 +371,90 @@ export function runClaimList(args: string[], options: ClaimLedgerCliOptions = {}
}
}

export function parseClaimReclaimArgs(args: string[]): ParsedClaimReclaimArgs {
const options: { json: boolean; dryRun: boolean; maxAgeMs: number | undefined } = {
json: false,
dryRun: false,
maxAgeMs: undefined,
};

for (let index = 0; index < args.length; index += 1) {
const token = args[index]!;
if (token === "--json") {
options.json = true;
continue;
}
if (token === "--dry-run") {
options.dryRun = true;
continue;
}
if (token === "--max-age-ms") {
const value = args[index + 1];
if (!value || value.startsWith("-")) {
return { error: CLAIM_RECLAIM_USAGE };
}
const parsed = Number(value);
// Only a finite integer >= 0 is a valid claim age; reject fractional, negative, and non-numeric input so
// a typo can never be silently coerced into an unbounded or nonsensical reclaim window.
if (!Number.isInteger(parsed) || parsed < 0) {
return { error: CLAIM_RECLAIM_USAGE };
}
options.maxAgeMs = parsed;
index += 1;
continue;
}
return { error: token.startsWith("-") ? `Unknown option: ${token}` : CLAIM_RECLAIM_USAGE };
}

return { maxAgeMs: options.maxAgeMs, dryRun: options.dryRun, json: options.json };
}

/** `reclaim [--max-age-ms <n>] [--dry-run] [--json]`: expire claims orphaned by a crashed/killed attempt,
* the manual counterpart to the automatic sweep claimIssue runs (mirrors `queue release` for the analogous
* portfolio-queue lease). Omitting --max-age-ms uses the ledger's own DEFAULT_MAX_CLAIM_AGE_MS. Exit 0 even
* when nothing is over-age -- reclaiming nothing is not a failure. */
export function runClaimReclaim(args: string[], options: ClaimLedgerCliOptions = {}): number {
const parsed = parseClaimReclaimArgs(args);
if ("error" in parsed) {
return reportCliFailure(argsWantJson(args), parsed.error);
}

if (parsed.dryRun) {
// Short-circuit BEFORE withClaimLedger so a dry run never opens the ledger at all, matching runQueueRelease.
const dryRunResult = { outcome: "dry_run", maxAgeMs: parsed.maxAgeMs ?? null };
if (parsed.json) {
console.log(JSON.stringify(dryRunResult, null, 2));
} else {
const window = parsed.maxAgeMs === undefined ? "the default max age" : `${parsed.maxAgeMs}ms`;
console.log(`DRY RUN: would reclaim claims older than ${window}. No claim-ledger write was made.`);
}
return 0;
}

try {
return withClaimLedger(options, (claimLedger) => {
const reclaimed = claimLedger.reclaimExpiredClaims(parsed.maxAgeMs);
if (parsed.json) {
console.log(JSON.stringify({ reclaimed }, null, 2));
} else if (reclaimed.length === 0) {
console.log("none");
} else {
for (const claim of reclaimed) {
console.log(`${claim.repoFullName}#${claim.issueNumber} ${claim.status}`);
}
console.log(`reclaimed ${reclaimed.length}`);
}
return 0;
});
} catch (error) {
return reportCliFailure(parsed.json, describeCliError(error));
}
}

export function runClaimCli(subcommand: string | undefined, args: string[], options: ClaimLedgerCliOptions = {}): number {
if (subcommand === "claim") return runClaimClaim(args, options);
if (subcommand === "release") return runClaimRelease(args, options);
if (subcommand === "list") return runClaimList(args, options);
if (subcommand === "reclaim") return runClaimReclaim(args, options);
return reportCliFailure(argsWantJson(args), `Unknown claim subcommand: ${subcommand ?? ""}. ${CLAIM_LIST_USAGE}`);
}
1 change: 1 addition & 0 deletions packages/loopover-miner/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function printHelp(input: { packageName: string }): void {
" loopover-miner claim claim <owner/repo> <issue#> [--note <text>] [--dry-run] [--json]",
" loopover-miner claim release <owner/repo> <issue#> [--dry-run] [--json]",
" loopover-miner claim list [--repo <owner/repo>] [--status active|released|expired] [--json]",
" loopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json] Expire claims orphaned by a killed attempt",
" loopover-miner ledger list [--repo <owner/repo>] [--since <seq>] [--type <eventType>] [--json]",
" loopover-miner ledger metrics Print event-ledger counters in Prometheus text format",
" loopover-miner plan list [--status pending|running|completed|failed] [--json]",
Expand Down
116 changes: 116 additions & 0 deletions test/unit/miner-claim-ledger-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import type { ClaimEntry } from "../../packages/loopover-miner/lib/claim-ledger.
import {
parseClaimClaimArgs,
parseClaimListArgs,
parseClaimReclaimArgs,
parseClaimReleaseArgs,
renderClaimsTable,
runClaimClaim,
runClaimCli,
runClaimList,
runClaimReclaim,
runClaimRelease,
} from "../../packages/loopover-miner/lib/claim-ledger-cli";
import { DEFAULT_MAX_CLAIM_AGE_MS } from "../../packages/loopover-miner/lib/claim-ledger-expiry";

const roots: string[] = [];
const ledgers: Array<{ close(): void }> = [];
Expand All @@ -33,6 +36,7 @@ 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 });
});

Expand Down Expand Up @@ -452,3 +456,115 @@ describe("loopover-miner claim ledger CLI (#4290)", () => {
}
});
});

describe("loopover-miner claim reclaim (#9686)", () => {
const CLAIM_RECLAIM_USAGE =
"Usage: loopover-miner claim reclaim [--max-age-ms <n>] [--dry-run] [--json]";

it("parseClaimReclaimArgs validates --max-age-ms, --dry-run, and --json", () => {
expect(parseClaimReclaimArgs([])).toEqual({ maxAgeMs: undefined, dryRun: false, json: false });
expect(parseClaimReclaimArgs(["--max-age-ms", "1000", "--dry-run", "--json"])).toEqual({
maxAgeMs: 1000,
dryRun: true,
json: true,
});
// 0 is a valid age (reclaim everything); the lower bound is inclusive.
expect(parseClaimReclaimArgs(["--max-age-ms", "0"])).toEqual({ maxAgeMs: 0, dryRun: false, json: false });
// Fractional, negative, non-numeric, and a missing value all return the usage string.
expect(parseClaimReclaimArgs(["--max-age-ms", "1.5"])).toEqual({ error: CLAIM_RECLAIM_USAGE });
expect(parseClaimReclaimArgs(["--max-age-ms", "-1"])).toEqual({ error: CLAIM_RECLAIM_USAGE });
expect(parseClaimReclaimArgs(["--max-age-ms", "soon"])).toEqual({ error: CLAIM_RECLAIM_USAGE });
expect(parseClaimReclaimArgs(["--max-age-ms"])).toEqual({ error: CLAIM_RECLAIM_USAGE });
expect(parseClaimReclaimArgs(["--nope"])).toEqual({ error: "Unknown option: --nope" });
expect(parseClaimReclaimArgs(["extra"])).toEqual({ error: CLAIM_RECLAIM_USAGE });
});

it("reclaims an over-age active claim, transitioning it to expired (default window, --max-age-ms omitted)", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const claimLedger = tempClaimLedger();
claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 });
// Move the clock one hour past the default window so the claim is over-age.
vi.setSystemTime(new Date(Date.parse("2026-01-01T00:00:00.000Z") + DEFAULT_MAX_CLAIM_AGE_MS + 60 * 60 * 1000));
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

expect(runClaimReclaim([], { openClaimLedger: () => claimLedger })).toBe(0);
expect(log).toHaveBeenCalledWith("acme/widgets#7 expired");
expect(log).toHaveBeenCalledWith("reclaimed 1");
expect(claimLedger.listClaims({ status: "expired" }).map((c) => c.issueNumber)).toEqual([7]);
});

it("prints 'none' and exits 0 when nothing is over-age", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const claimLedger = tempClaimLedger();
claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 });
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

// Still inside the default window -- reclaiming nothing is not a failure.
expect(runClaimReclaim([], { openClaimLedger: () => claimLedger })).toBe(0);
expect(log).toHaveBeenCalledWith("none");
expect(claimLedger.listClaims({ status: "expired" })).toEqual([]);
});

it("honours an explicit smaller --max-age-ms, reclaiming a claim inside the default window", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const claimLedger = tempClaimLedger();
claimLedger.recordClaim({ repoFullName: "acme/widgets", issueNumber: 7 });
// Two hours later: well inside the 14-day default window, but past an explicit 1-hour window.
vi.setSystemTime(new Date(Date.parse("2026-01-01T00:00:00.000Z") + 2 * 60 * 60 * 1000));
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

expect(runClaimReclaim(["--max-age-ms", String(60 * 60 * 1000), "--json"], { openClaimLedger: () => claimLedger })).toBe(0);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
reclaimed: [expect.objectContaining({ repoFullName: "acme/widgets", issueNumber: 7, status: "expired" })],
});
});

it("an invalid --max-age-ms returns the usage error without opening the ledger", () => {
const openClaimLedgerSpy = vi.fn();
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(runClaimReclaim(["--max-age-ms", "-5"], { openClaimLedger: openClaimLedgerSpy })).toBe(2);
expect(errorLog).toHaveBeenCalledWith(CLAIM_RECLAIM_USAGE);
expect(openClaimLedgerSpy).not.toHaveBeenCalled();
});

it("--dry-run returns 0 without opening the claim ledger (plain and --json)", () => {
const openClaimLedgerSpy = vi.fn();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);

expect(runClaimReclaim(["--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0);
expect(openClaimLedgerSpy).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than the default max age. No claim-ledger write was made.");

// Explicit --max-age-ms in the plain-text dry-run path names the exact window (the defined ternary arm).
log.mockClear();
expect(runClaimReclaim(["--dry-run", "--max-age-ms", "5000"], { openClaimLedger: openClaimLedgerSpy })).toBe(0);
expect(openClaimLedgerSpy).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than 5000ms. No claim-ledger write was made.");

log.mockClear();
expect(runClaimReclaim(["--dry-run", "--max-age-ms", "1000", "--json"], { openClaimLedger: openClaimLedgerSpy })).toBe(0);
expect(openClaimLedgerSpy).not.toHaveBeenCalled();
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ outcome: "dry_run", maxAgeMs: 1000 });
});

it("surfaces a ledger error through reportCliFailure (--json)", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const thrower = () => {
throw new Error("ledger boom");
};
expect(runClaimReclaim(["--json"], { openClaimLedger: thrower as never })).toBe(2);
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ ok: false });
});

it("runClaimCli dispatches the reclaim subcommand", () => {
const openClaimLedgerSpy = vi.fn();
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
// Reachable from the dispatcher: a dry-run proves the reclaim path ran (never opens the ledger).
expect(runClaimCli("reclaim", ["--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0);
expect(openClaimLedgerSpy).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("DRY RUN: would reclaim claims older than the default max age. No claim-ledger write was made.");
});
});