diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index cf674af677..9e5251de8c 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; import { runDenyCheck } from "../lib/deny-check.js"; import { runManageStatus } from "../lib/manage-status.js"; +import { runQueueCli } from "../lib/portfolio-queue-cli.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runDoctor, runStatus } from "../lib/status.js"; import { @@ -28,6 +29,10 @@ if (cliArgs[0] === "manage" && cliArgs[1] === "status") { process.exit(runManageStatus(cliArgs.slice(2))); } +if (cliArgs[0] === "queue") { + process.exit(runQueueCli(cliArgs[1], cliArgs.slice(2))); +} + const require = createRequire(import.meta.url); const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index e2fa23ae3c..0b1744bf52 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -17,6 +17,9 @@ export function printHelp(input) { " gittensory-miner status [--json] Show installed versions + local state paths", " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger", + " 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 hooks check --tool --input [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--json]", diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts new file mode 100644 index 0000000000..11ab2be435 --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts @@ -0,0 +1,47 @@ +import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; + +export type ParsedQueueListArgs = + | { + json: boolean; + repoFullName: string | null; + } + | { error: string }; + +export type ParsedQueueNextArgs = { json: boolean } | { error: string }; + +export type ParsedQueueDoneArgs = + | { + repoFullName: string; + identifier: string; + json: boolean; + } + | { error: string }; + +export function parseQueueListArgs(args: string[]): ParsedQueueListArgs; + +export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; + +export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; + +export function renderQueueTable(entries: QueueEntry[]): string; + +export function runQueueList( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + +export function runQueueNext( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + +export function runQueueDone( + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; + +export function runQueueCli( + subcommand: string | undefined, + args: string[], + options?: { initPortfolioQueue?: () => PortfolioQueueStore }, +): number; diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js new file mode 100644 index 0000000000..3214d5869f --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -0,0 +1,215 @@ +import { initPortfolioQueueStore } from "./portfolio-queue.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]"; + +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 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 parseQueueListArgs(args) { + const options = { json: false, repoFullName: 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: QUEUE_LIST_USAGE }; + } + const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); + if ("error" in repo) return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length > 0) { + return { error: QUEUE_LIST_USAGE }; + } + + return options; +} + +export function parseQueueNextArgs(args) { + const parsed = parseJsonFlag(args); + if ("error" in parsed) return parsed; + if (parsed.positional.length > 0) { + return { error: QUEUE_NEXT_USAGE }; + } + return { json: parsed.json }; +} + +export function parseQueueDoneArgs(args) { + const parsed = parseJsonFlag(args); + if ("error" in parsed) return parsed; + if (parsed.positional.length !== 2) { + return { error: QUEUE_DONE_USAGE }; + } + + const repo = parseRepoArg(parsed.positional[0], QUEUE_DONE_USAGE); + if ("error" in repo) return repo; + + const identifier = parsed.positional[1]?.trim(); + if (!identifier) { + return { error: QUEUE_DONE_USAGE }; + } + + return { + repoFullName: repo.repoFullName, + identifier, + json: parsed.json, + }; +} + +function display(value) { + if (value === null || value === undefined) return "-"; + return String(value); +} + +export function renderQueueTable(entries) { + if (!Array.isArray(entries) || entries.length === 0) return "no portfolio queue entries"; + const header = [ + "repo".padEnd(24), + "identifier".padEnd(16), + "status".padEnd(12), + "pri".padStart(4), + "enqueued-at".padEnd(24), + ].join(" "); + const lines = entries.map((entry) => + [ + entry.repoFullName.padEnd(24), + entry.identifier.padEnd(16), + entry.status.padEnd(12), + display(entry.priority).padStart(4), + display(entry.enqueuedAt).padEnd(24), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + +function withPortfolioQueue(options, run) { + const ownsStore = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return run(portfolioQueue); + } finally { + if (ownsStore) portfolioQueue.close(); + } +} + +export function runQueueList(args, options = {}) { + const parsed = parseQueueListArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entries = portfolioQueue.listQueue(parsed.repoFullName); + if (parsed.json) { + console.log(JSON.stringify({ entries }, null, 2)); + } else { + console.log(renderQueueTable(entries)); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runQueueNext(args, options = {}) { + const parsed = parseQueueNextArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.dequeueNext(); + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry ? entry.identifier : "none"); + } + return 0; + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runQueueDone(args, options = {}) { + const parsed = parseQueueDoneArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier); + if (!entry) { + console.error("queue_entry_not_found"); + 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 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); + console.error(`Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); + return 2; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 922b022f79..9244c695dc 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/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/status.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/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/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts new file mode 100644 index 0000000000..dd682b48c4 --- /dev/null +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -0,0 +1,167 @@ +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 { + closeDefaultPortfolioQueueStore, + initPortfolioQueueStore, +} from "../../packages/gittensory-miner/lib/portfolio-queue.js"; +import { + parseQueueDoneArgs, + parseQueueListArgs, + parseQueueNextArgs, + renderQueueTable, + runQueueCli, + runQueueDone, + runQueueList, + runQueueNext, +} from "../../packages/gittensory-miner/lib/portfolio-queue-cli.js"; +import type { QueueEntry } from "../../packages/gittensory-miner/lib/portfolio-queue.d.ts"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempQueueStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-queue-cli-")); + roots.push(root); + const store = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + stores.push(store); + return store; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPortfolioQueueStore(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner portfolio queue CLI (#2292)", () => { + it("parseQueueListArgs, parseQueueNextArgs, and parseQueueDoneArgs validate argv", () => { + expect(parseQueueListArgs([])).toEqual({ json: false, repoFullName: null }); + expect(parseQueueListArgs(["--repo", "acme/widgets", "--json"])).toEqual({ + json: true, + repoFullName: "acme/widgets", + }); + expect(parseQueueNextArgs(["--json"])).toEqual({ json: true }); + expect(parseQueueDoneArgs(["acme/widgets", "issue:42", "--json"])).toEqual({ + repoFullName: "acme/widgets", + identifier: "issue:42", + json: true, + }); + expect(parseQueueDoneArgs(["acme/widgets"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner queue done"), + }); + }); + + it("renderQueueTable formats numeric priority and empty output", () => { + const entries: QueueEntry[] = [ + { + repoFullName: "acme/widgets", + identifier: "issue:7", + status: "queued", + priority: 42, + enqueuedAt: "2026-07-04T12:00:00.000Z", + }, + ]; + expect(renderQueueTable([])).toBe("no portfolio queue entries"); + expect(renderQueueTable(entries)).toContain(" 42"); + expect(renderQueueTable(entries)).toContain("issue:7"); + }); + + it("runQueueList prints table and JSON output", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:1", priority: 10 }); + portfolioQueue.enqueue({ repoFullName: "acme/other", identifier: "issue:2", priority: 5 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueList([], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("acme/widgets"); + + log.mockClear(); + expect( + runQueueList(["--repo", "acme/other", "--json"], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + entries: [expect.objectContaining({ identifier: "issue:2", repoFullName: "acme/other" })], + }); + }); + + it("runQueueNext claims the highest-priority queued item", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:1", priority: 10 }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:2", priority: 90 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueNext([], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("issue:2"); + + log.mockClear(); + expect( + runQueueNext(["--json"], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + entry: expect.objectContaining({ identifier: "issue:1", status: "in_progress" }), + }); + + log.mockClear(); + expect( + runQueueNext([], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("none"); + }); + + it("runQueueDone marks an item done and rejects missing entries", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:9", priority: 1 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueDone(["acme/widgets", "issue:9"], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("done"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect( + runQueueDone(["acme/widgets", "issue:404"], { + initPortfolioQueue: () => portfolioQueue, + }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("queue_entry_not_found"); + }); + + it("runQueueCli dispatches list, next, and done subcommands", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:3", priority: 1 }); + const options = { initPortfolioQueue: () => portfolioQueue }; + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runQueueCli("list", ["--json"], options)).toBe(0); + expect(runQueueCli("next", [], options)).toBe(0); + expect(runQueueCli("done", ["acme/widgets", "issue:3"], options)).toBe(0); + expect(log).toHaveBeenCalled(); + }); + + it("rejects unknown queue subcommands and options", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runQueueCli("peek", [])).toBe(2); + expect(runQueueList(["--verbose"])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand"); + }); +});