From afa449e7d7d77e9d0b28a522d1ab4841f3406f83 Mon Sep 17 00:00:00 2001 From: carlh171112 Date: Sun, 12 Jul 2026 17:46:04 -0700 Subject: [PATCH] feat(miner): add queue release and requeue commands to CLI This update introduces two new commands to the gittensory-miner CLI: `queue release` and `queue requeue`. The `release` command allows users to return a claimed item back to the queue, while the `requeue` command enables users to put a completed item back on the queue, maintaining its original FIFO position. Additionally, the necessary parsing and handling functions have been implemented, along with corresponding usage messages and error handling. Tests have been added to ensure the correct functionality of these new commands. Closes #4828. --- packages/gittensory-miner/lib/cli.js | 2 + .../lib/portfolio-queue-cli.d.ts | 14 ++ .../lib/portfolio-queue-cli.js | 85 ++++++++++- .../gittensory-miner/lib/portfolio-queue.d.ts | 1 + .../gittensory-miner/lib/portfolio-queue.js | 19 +++ test/unit/miner-portfolio-queue-cli.test.ts | 140 ++++++++++++++++++ 6 files changed, 257 insertions(+), 4 deletions(-) diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index eb2bc1fdb1..059ac86194 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -30,6 +30,8 @@ export function printHelp(input) { " gittensory-miner queue next [--json] Claim the highest-priority queued item", " gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]", " gittensory-miner queue done [--json]", + " gittensory-miner queue release [--json] Return a claimed item to the queue", + " gittensory-miner queue requeue [--json] Put a completed item back on the queue", " gittensory-miner claim claim [--note ] [--json]", " gittensory-miner claim release [--json]", " gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]", diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts index 6bb5dafc09..d1721ffb5a 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts @@ -24,6 +24,10 @@ export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; +export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; + +export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; + export type ParsedQueueClaimBatchArgs = | { json: boolean; globalWipCap: number; perRepoWipCap: number } | { error: string }; @@ -47,6 +51,16 @@ export function runQueueDone( options?: { initPortfolioQueue?: () => PortfolioQueueStore }, ): number; +export function runQueueRelease( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + +export function runQueueRequeue( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + export function runQueueClaimBatch( args: string[], options?: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager }, diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js index 82c5e5c35a..2c0e3d5014 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -5,6 +5,8 @@ import { runPortfolioDashboard } from "./portfolio-dashboard.js"; const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo ] [--json]"; const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]"; const QUEUE_DONE_USAGE = "Usage: gittensory-miner queue done [--json]"; +const QUEUE_RELEASE_USAGE = "Usage: gittensory-miner queue release [--json]"; +const QUEUE_REQUEUE_USAGE = "Usage: gittensory-miner queue requeue [--json]"; const QUEUE_CLAIM_BATCH_USAGE = "Usage: gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]"; @@ -79,19 +81,21 @@ export function parseQueueNextArgs(args) { return { json: parsed.json }; } -export function parseQueueDoneArgs(args) { +/** Shared ` [--json]` parse for the item-targeting subcommands (done/release/requeue). + * `usage` is the command-specific message surfaced on a malformed argv. */ +function parseRepoIdentifierArgs(args, usage) { const parsed = parseJsonFlag(args); if ("error" in parsed) return parsed; if (parsed.positional.length !== 2) { - return { error: QUEUE_DONE_USAGE }; + return { error: usage }; } - const repo = parseRepoArg(parsed.positional[0], QUEUE_DONE_USAGE); + const repo = parseRepoArg(parsed.positional[0], usage); if ("error" in repo) return repo; const identifier = parsed.positional[1]?.trim(); if (!identifier) { - return { error: QUEUE_DONE_USAGE }; + return { error: usage }; } return { @@ -101,6 +105,18 @@ export function parseQueueDoneArgs(args) { }; } +export function parseQueueDoneArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); +} + +export function parseQueueReleaseArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); +} + +export function parseQueueRequeueArgs(args) { + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); +} + function display(value) { if (value === null || value === undefined) return "-"; return String(value); @@ -210,6 +226,65 @@ export function runQueueDone(args, options = {}) { } } +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export function runQueueRelease(args, options = {}) { + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier); + if (!entry) { + console.error("queue_entry_not_in_progress"); + return 2; + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export function runQueueRequeue(args, options = {}) { + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier); + if (!entry) { + console.error("queue_entry_not_requeuable"); + return 2; + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + export function parseQueueClaimBatchArgs(args) { const options = { json: false, globalWipCap: 1, perRepoWipCap: 1 }; for (let index = 0; index < args.length; index += 1) { @@ -269,6 +344,8 @@ export function runQueueCli(subcommand, args, options = {}) { if (subcommand === "list") return runQueueList(args, options); if (subcommand === "next") return runQueueNext(args, options); if (subcommand === "done") return runQueueDone(args, options); + if (subcommand === "release") return runQueueRelease(args, options); + if (subcommand === "requeue") return runQueueRequeue(args, options); if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); if (subcommand === "dashboard") return runPortfolioDashboard(args, options); console.error(`Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); diff --git a/packages/gittensory-miner/lib/portfolio-queue.d.ts b/packages/gittensory-miner/lib/portfolio-queue.d.ts index 54d6a827ba..7f623adf43 100644 --- a/packages/gittensory-miner/lib/portfolio-queue.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue.d.ts @@ -31,6 +31,7 @@ export type PortfolioQueueStore = { markDone(repoFullName: string, identifier: string): QueueEntry | null; markFailed(repoFullName: string, identifier: string): QueueEntry | null; reclaimStuckItem(repoFullName: string, identifier: string): QueueEntry | null; + requeueItem(repoFullName: string, identifier: string): QueueEntry | null; batchClaim( selectFn: (entries: QueueEntry[]) => Array<{ repoFullName: string; identifier: string }>, ): QueueEntry[]; diff --git a/packages/gittensory-miner/lib/portfolio-queue.js b/packages/gittensory-miner/lib/portfolio-queue.js index 31f993c58e..7521c51f89 100644 --- a/packages/gittensory-miner/lib/portfolio-queue.js +++ b/packages/gittensory-miner/lib/portfolio-queue.js @@ -156,6 +156,14 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) WHERE repo_full_name = ? AND identifier = ? AND status = 'in_progress' RETURNING * `); + // Requeue only ever targets a COMPLETED ('done') row — an in-flight item is released via reclaimStatement, and + // an already-'queued' item is a no-op — so a caller's manual requeue can never disturb an active claim. The + // row keeps its rowid/enqueued_at, so it re-enters the queue at its original FIFO position, not the back. + const requeueStatement = db.prepare(` + UPDATE miner_portfolio_queue SET status = 'queued', leased_at = NULL + WHERE repo_full_name = ? AND identifier = ? AND status = 'done' + RETURNING * + `); const claimTargetStatement = db.prepare(` UPDATE miner_portfolio_queue SET status = 'in_progress', leased_at = ? WHERE repo_full_name = ? AND identifier = ? AND status = 'queued' @@ -189,6 +197,17 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) ); return row ? rowToEntry(row) : null; }, + /** Requeue a COMPLETED ('done') item back to 'queued' so it is picked up again, keeping its FIFO position + * (rowid/enqueued_at unchanged). Returns the entry, or null when there is no 'done' item to requeue — i.e. + * it is already 'queued', is currently 'in_progress' (release it via {@link reclaimStuckItem} instead), or + * does not exist. The manual counterpart to {@link reclaimStuckItem} for the queue CLI's escape hatch (#4828). */ + requeueItem(repoFullName, identifier) { + const row = requeueStatement.get( + normalizeRepoFullName(repoFullName), + normalizeIdentifier(identifier), + ); + return row ? rowToEntry(row) : null; + }, listQueue(repoFullName) { const rows = repoFullName === undefined || repoFullName === null ? listAllStatement.all() diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index dd682b48c4..329947c865 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -10,11 +10,15 @@ import { parseQueueDoneArgs, parseQueueListArgs, parseQueueNextArgs, + parseQueueReleaseArgs, + parseQueueRequeueArgs, renderQueueTable, runQueueCli, runQueueDone, runQueueList, runQueueNext, + runQueueRelease, + runQueueRequeue, } from "../../packages/gittensory-miner/lib/portfolio-queue-cli.js"; import type { QueueEntry } from "../../packages/gittensory-miner/lib/portfolio-queue.d.ts"; @@ -164,4 +168,140 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(runQueueList(["--verbose"])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand"); }); + + describe("release / requeue escape hatch (#4828)", () => { + it("parseQueueReleaseArgs and parseQueueRequeueArgs validate argv with their own usage", () => { + expect(parseQueueReleaseArgs(["acme/widgets", "issue:1"])).toEqual({ + repoFullName: "acme/widgets", + identifier: "issue:1", + json: false, + }); + expect(parseQueueRequeueArgs(["acme/widgets", "issue:1", "--json"])).toEqual({ + repoFullName: "acme/widgets", + identifier: "issue:1", + json: true, + }); + // Wrong positional count surfaces the command-specific usage string. + expect(parseQueueReleaseArgs(["only-one"])).toEqual({ error: expect.stringContaining("queue release") }); + expect(parseQueueRequeueArgs([])).toEqual({ error: expect.stringContaining("queue requeue") }); + }); + + it("release returns a CLAIMED (in-progress) item to the queue", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7", priority: 1 }); + portfolioQueue.dequeueNext(); // claim it → in_progress + const options = { initPortfolioQueue: () => portfolioQueue }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runQueueRelease(["acme/widgets", "issue:7"], options)).toBe(0); + expect(log).toHaveBeenCalledWith("queued"); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + + it("release emits the full entry as JSON under --json", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7b", priority: 2 }); + portfolioQueue.dequeueNext(); // → in_progress + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runQueueRelease(["acme/widgets", "issue:7b", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.entry).toMatchObject({ identifier: "issue:7b", status: "queued" }); + }); + + it("release exits 2 when the item is not in-progress (nothing to release)", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:8", priority: 1 }); // still 'queued' + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(runQueueRelease(["acme/widgets", "issue:8"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + expect(error).toHaveBeenCalledWith("queue_entry_not_in_progress"); + }); + + it("requeue puts a COMPLETED (done) item back on the queue, keeping its position", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:9", priority: 5 }); + portfolioQueue.markDone("acme/widgets", "issue:9"); // → done + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + runQueueRequeue(["acme/widgets", "issue:9", "--json"], { initPortfolioQueue: () => portfolioQueue }), + ).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.entry).toMatchObject({ repoFullName: "acme/widgets", identifier: "issue:9", status: "queued", priority: 5 }); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + + it("requeue exits 2 when the item is not a completed entry (already queued / in-flight / absent)", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:10", priority: 1 }); // 'queued' + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(runQueueRequeue(["acme/widgets", "issue:10"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + expect(error).toHaveBeenCalledWith("queue_entry_not_requeuable"); + // Absent item too. + expect(runQueueRequeue(["acme/widgets", "issue:404"], { initPortfolioQueue: () => portfolioQueue })).toBe(2); + }); + + it("the shared parser rejects a bad option, a malformed repo, and an empty identifier", () => { + expect(parseQueueReleaseArgs(["--bad"])).toEqual({ error: expect.stringContaining("Unknown option") }); + expect(parseQueueRequeueArgs(["notarepo", "issue:1"])).toEqual({ + error: "Repository must be in owner/repo form.", + }); + expect(parseQueueReleaseArgs(["acme/widgets", " "])).toEqual({ error: expect.stringContaining("queue release") }); + }); + + it("release and requeue each surface a parse error before touching the store", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runQueueRelease(["only-one"], {})).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("queue release"); + expect(runQueueRequeue(["only-one"], {})).toBe(2); + expect(String(error.mock.calls[1]?.[0])).toContain("queue requeue"); + }); + + it("release and requeue fail-safe (exit 2) when the store throws", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const throwingStore = { + reclaimStuckItem() { + throw new Error("db_locked"); + }, + requeueItem() { + throw new Error("db_locked"); + }, + } as unknown as ReturnType; + expect(runQueueRelease(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(runQueueRequeue(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(error).toHaveBeenCalledWith("db_locked"); + + // A thrown non-Error is stringified rather than crashing (the String(error) fallback branch). + const throwingNonError = { + reclaimStuckItem() { + throw "raw_string_fault"; + }, + requeueItem() { + throw "raw_string_fault"; + }, + } as unknown as ReturnType; + expect(runQueueRelease(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingNonError })).toBe(2); + expect(runQueueRequeue(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingNonError })).toBe(2); + expect(error).toHaveBeenCalledWith("raw_string_fault"); + }); + + it("runQueueCli dispatches release and requeue", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:11", priority: 1 }); + portfolioQueue.dequeueNext(); // in_progress + const options = { initPortfolioQueue: () => portfolioQueue }; + vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runQueueCli("release", ["acme/widgets", "issue:11"], options)).toBe(0); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + + portfolioQueue.markDone("acme/widgets", "issue:11"); + expect(runQueueCli("requeue", ["acme/widgets", "issue:11"], options)).toBe(0); + expect(portfolioQueue.listQueue("acme/widgets")[0]?.status).toBe("queued"); + }); + }); });