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
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>] [--per-repo-wip <n>] [--json]",
" gittensory-miner queue done <owner/repo> <identifier> [--json]",
" gittensory-miner queue release <owner/repo> <identifier> [--json] Return a claimed item to the queue",
" gittensory-miner queue requeue <owner/repo> <identifier> [--json] Put a completed item back on the queue",
" gittensory-miner claim claim <owner/repo> <issue#> [--note <text>] [--json]",
" gittensory-miner claim release <owner/repo> <issue#> [--json]",
" gittensory-miner claim list [--repo <owner/repo>] [--status active|released|expired] [--json]",
Expand Down
14 changes: 14 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 },
Expand Down
85 changes: 81 additions & 4 deletions packages/gittensory-miner/lib/portfolio-queue-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { runPortfolioDashboard } from "./portfolio-dashboard.js";
const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo <owner/repo>] [--json]";
const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--json]";
const QUEUE_DONE_USAGE = "Usage: gittensory-miner queue done <owner/repo> <identifier> [--json]";
const QUEUE_RELEASE_USAGE = "Usage: gittensory-miner queue release <owner/repo> <identifier> [--json]";
const QUEUE_REQUEUE_USAGE = "Usage: gittensory-miner queue requeue <owner/repo> <identifier> [--json]";
const QUEUE_CLAIM_BATCH_USAGE =
"Usage: gittensory-miner queue claim-batch [--global-wip <n>] [--per-repo-wip <n>] [--json]";

Expand Down Expand Up @@ -79,19 +81,21 @@ export function parseQueueNextArgs(args) {
return { json: parsed.json };
}

export function parseQueueDoneArgs(args) {
/** Shared `<owner/repo> <identifier> [--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 {
Expand All @@ -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);
Expand Down Expand Up @@ -210,6 +226,65 @@ export function runQueueDone(args, options = {}) {
}
}

/** `release <owner/repo> <identifier>`: 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 <owner/repo> <identifier>`: 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) {
Expand Down Expand Up @@ -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}`);
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/portfolio-queue.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
19 changes: 19 additions & 0 deletions packages/gittensory-miner/lib/portfolio-queue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
140 changes: 140 additions & 0 deletions test/unit/miner-portfolio-queue-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<typeof initPortfolioQueueStore>;
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<typeof initPortfolioQueueStore>;
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");
});
});
});