From 22b21ac66dbf0d21d093cf033c937638358bcee5 Mon Sep 17 00:00:00 2001 From: realDiligent Date: Mon, 13 Jul 2026 12:08:31 +0800 Subject: [PATCH 1/4] feat(miner): route queue next through WIP-cap-aware claimer (#4850) Wire queue next and claim-batch caps from .gittensory-miner.yml and env, so repeated queue next stops once global/per-repo WIP limits are reached. Co-authored-by: Cursor --- packages/gittensory-miner/lib/cli.js | 2 +- .../lib/portfolio-queue-caps.d.ts | 4 + .../lib/portfolio-queue-caps.js | 64 ++++++++++ .../lib/portfolio-queue-cli.d.ts | 30 ++++- .../lib/portfolio-queue-cli.js | 117 +++++++++++------- .../lib/portfolio-queue-manager.js | 2 +- test/unit/miner-portfolio-queue-caps.test.ts | 67 ++++++++++ test/unit/miner-portfolio-queue-cli.test.ts | 96 ++++++++++---- test/unit/miner-wire-cli-modules.test.ts | 6 + 9 files changed, 314 insertions(+), 74 deletions(-) create mode 100644 packages/gittensory-miner/lib/portfolio-queue-caps.d.ts create mode 100644 packages/gittensory-miner/lib/portfolio-queue-caps.js create mode 100644 test/unit/miner-portfolio-queue-caps.test.ts diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index eea03a1843..b38ebf2c34 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -30,7 +30,7 @@ export function printHelp(input) { " gittensory-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--dry-run] [--json]", " Autonomous discover->claim->attempt->reenter loop", " gittensory-miner queue list [--repo ] [--json] List portfolio backlog rows", - " gittensory-miner queue next [--dry-run] [--json] Claim the highest-priority queued item", + " gittensory-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json] Claim next item under WIP caps", " gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", " gittensory-miner queue done [--dry-run] [--json]", " gittensory-miner queue release [--dry-run] [--json] Return a claimed item to the queue", diff --git a/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts b/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts new file mode 100644 index 0000000000..1cb8b72d8c --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts @@ -0,0 +1,4 @@ +export function resolvePortfolioQueueCaps(options?: { + env?: NodeJS.ProcessEnv; + cliCaps?: { globalWipCap?: number; perRepoWipCap?: number }; +}): { globalWipCap: number; perRepoWipCap: number }; diff --git a/packages/gittensory-miner/lib/portfolio-queue-caps.js b/packages/gittensory-miner/lib/portfolio-queue-caps.js new file mode 100644 index 0000000000..7dfa0f7c82 --- /dev/null +++ b/packages/gittensory-miner/lib/portfolio-queue-caps.js @@ -0,0 +1,64 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { normalizePortfolioCaps } from "./portfolio-queue-manager.js"; +import { resolveMinerStateDir } from "./status.js"; + +const CONFIG_FILE_CANDIDATES = Object.freeze([ + ".gittensory-miner.yml", + ".github/gittensory-miner.yml", + ".gittensory-miner.json", + ".github/gittensory-miner.json", +]); + +function discoverConfigFile(cwd) { + for (const candidate of CONFIG_FILE_CANDIDATES) { + const path = join(cwd, candidate); + if (existsSync(path)) return path; + } + return null; +} + +function readConfigCaps(stateDir) { + const configPath = discoverConfigFile(stateDir); + if (!configPath) return null; + try { + const raw = configPath.endsWith(".json") ? JSON.parse(readFileSync(configPath, "utf8")) : parseYaml(readFileSync(configPath, "utf8")); + const portfolioQueue = raw?.portfolioQueue; + if (!portfolioQueue || typeof portfolioQueue !== "object" || Array.isArray(portfolioQueue)) return null; + return normalizePortfolioCaps(portfolioQueue); + } catch { + return null; + } +} + +function readEnvCaps(env) { + const caps = {}; + if (typeof env.GITTENSORY_MINER_GLOBAL_WIP_CAP === "string" && env.GITTENSORY_MINER_GLOBAL_WIP_CAP.trim()) { + caps.globalWipCap = Number(env.GITTENSORY_MINER_GLOBAL_WIP_CAP); + } + if (typeof env.GITTENSORY_MINER_PER_REPO_WIP_CAP === "string" && env.GITTENSORY_MINER_PER_REPO_WIP_CAP.trim()) { + caps.perRepoWipCap = Number(env.GITTENSORY_MINER_PER_REPO_WIP_CAP); + } + return Object.keys(caps).length > 0 ? normalizePortfolioCaps(caps) : null; +} + +/** + * Resolve WIP caps for portfolio claiming: operator `.gittensory-miner.yml` in the state dir, then env, then CLI + * flags (when provided), defaulting to `{ globalWipCap: 1, perRepoWipCap: 1 }`. + * @param {{ env?: NodeJS.ProcessEnv, cliCaps?: { globalWipCap?: number, perRepoWipCap?: number } }} [options] + */ +export function resolvePortfolioQueueCaps(options = {}) { + const env = options.env ?? process.env; + let caps = readConfigCaps(resolveMinerStateDir(env)) ?? { globalWipCap: 1, perRepoWipCap: 1 }; + const envCaps = readEnvCaps(env); + if (envCaps) caps = envCaps; + const cliCaps = options.cliCaps ?? {}; + if (cliCaps.globalWipCap !== undefined || cliCaps.perRepoWipCap !== undefined) { + caps = normalizePortfolioCaps({ + globalWipCap: cliCaps.globalWipCap ?? caps.globalWipCap, + perRepoWipCap: cliCaps.perRepoWipCap ?? caps.perRepoWipCap, + }); + } + return caps; +} diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts index 276403a815..a44c1d1620 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts @@ -8,7 +8,14 @@ export type ParsedQueueListArgs = } | { error: string }; -export type ParsedQueueNextArgs = { json: boolean; dryRun: boolean } | { error: string }; +export type ParsedQueueNextArgs = + | { + json: boolean; + dryRun: boolean; + globalWipCap?: number; + perRepoWipCap?: number; + } + | { error: string }; export type ParsedQueueDoneArgs = | { @@ -30,7 +37,12 @@ export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; export type ParsedQueueClaimBatchArgs = - | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } + | { + json: boolean; + dryRun: boolean; + globalWipCap?: number; + perRepoWipCap?: number; + } | { error: string }; export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; @@ -44,7 +56,11 @@ export function runQueueList( export function runQueueNext( args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, + options?: { + env?: Record; + dbPath?: string; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + }, ): number; export function runQueueDone( @@ -64,13 +80,19 @@ export function runQueueRequeue( export function runQueueClaimBatch( args: string[], - options?: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager }, + options?: { + env?: Record; + dbPath?: string; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + }, ): number; export function runQueueCli( subcommand: string | undefined, args: string[], options?: { + env?: Record; + dbPath?: string; initPortfolioQueue?: () => PortfolioQueueStore; 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 ee7a44d932..7d7b294348 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -1,10 +1,12 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; +import { resolvePortfolioQueueCaps } from "./portfolio-queue-caps.js"; import { runPortfolioDashboard } from "./portfolio-dashboard.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const QUEUE_LIST_USAGE = "Usage: gittensory-miner queue list [--repo ] [--json]"; -const QUEUE_NEXT_USAGE = "Usage: gittensory-miner queue next [--dry-run] [--json]"; +const QUEUE_NEXT_USAGE = + "Usage: gittensory-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; const QUEUE_DONE_USAGE = "Usage: gittensory-miner queue done [--dry-run] [--json]"; const QUEUE_RELEASE_USAGE = "Usage: gittensory-miner queue release [--dry-run] [--json]"; const QUEUE_REQUEUE_USAGE = "Usage: gittensory-miner queue requeue [--dry-run] [--json]"; @@ -44,6 +46,38 @@ function parseJsonFlag(args) { return { positional, ...options }; } +function parsePortfolioQueueCapArgs(args, usage) { + const options = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: 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 === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: usage }; + } + if (token === "--global-wip") options.globalWipCap = value; + else options.perRepoWipCap = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + return { error: usage }; + } + + return options; +} + export function parseQueueListArgs(args) { const options = { json: false, repoFullName: null }; const positional = []; @@ -79,12 +113,7 @@ export function parseQueueListArgs(args) { } 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, dryRun: parsed.dryRun }; + return parsePortfolioQueueCapArgs(args, QUEUE_NEXT_USAGE); } /** Shared ` [--json]` parse for the item-targeting subcommands (done/release/requeue). @@ -188,27 +217,44 @@ export function runQueueNext(args, options = {}) { } if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run" }; + const caps = resolvePortfolioQueueCaps({ + env: options.env ?? process.env, + cliCaps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + const dryRunResult = { outcome: "dry_run", ...caps }; if (parsed.json) { console.log(JSON.stringify(dryRunResult, null, 2)); } else { - console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); + console.log( + `DRY RUN: would claim the next queued item (global-wip: ${caps.globalWipCap}, per-repo-wip: ${caps.perRepoWipCap}). No portfolio-queue write was made.`, + ); } return 0; } + const caps = resolvePortfolioQueueCaps({ + env: options.env ?? process.env, + cliCaps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + + const ownsManager = options.initPortfolioQueueManager === undefined; + let manager; 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; + manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ + caps, + dbPath: options.dbPath, }); + const entry = manager.claimNextBatch()[0] ?? null; + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry ? entry.identifier : "none"); + } + return 0; } catch (error) { return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + if (ownsManager) manager?.close(); } } @@ -320,30 +366,7 @@ export function runQueueRequeue(args, options = {}) { } export function parseQueueClaimBatchArgs(args) { - const options = { json: false, dryRun: false, globalWipCap: 1, perRepoWipCap: 1 }; - 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 === "--global-wip" || token === "--per-repo-wip") { - const value = Number(args[index + 1]); - if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { - return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - if (token === "--global-wip") options.globalWipCap = value; - else options.perRepoWipCap = value; - index += 1; - continue; - } - return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - return options; + return parsePortfolioQueueCapArgs(args, QUEUE_CLAIM_BATCH_USAGE); } /** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also @@ -354,13 +377,18 @@ export function runQueueClaimBatch(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + const caps = resolvePortfolioQueueCaps({ + env: options.env ?? process.env, + cliCaps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + const dryRunResult = { outcome: "dry_run", ...caps }; if (parsed.json) { console.log(JSON.stringify(dryRunResult, null, 2)); } else { console.log( - `DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`, + `DRY RUN: would claim a batch (global-wip: ${caps.globalWipCap}, per-repo-wip: ${caps.perRepoWipCap}). No portfolio-queue write was made.`, ); } return 0; @@ -372,7 +400,8 @@ export function runQueueClaimBatch(args, options = {}) { let manager; try { manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ - caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + caps, + dbPath: options.dbPath, }); const claimed = manager.claimNextBatch(); if (parsed.json) { diff --git a/packages/gittensory-miner/lib/portfolio-queue-manager.js b/packages/gittensory-miner/lib/portfolio-queue-manager.js index 06cd2fdec9..0717afe989 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-manager.js +++ b/packages/gittensory-miner/lib/portfolio-queue-manager.js @@ -1,7 +1,7 @@ // Stateful PortfolioQueueManager (#4285): compose the persisted SQLite portfolio/queue store // (portfolio-queue.js, #2292) with the pure engine selector (nextEligibleItems, queue.ts, #2326) so batch // claiming respects global/per-repo WIP caps and cross-repo diversification instead of a naive priority-only -// single-row dequeue. Caps are plain constructor arguments — not wired to .gittensory-miner.yml here. +// single-row dequeue. Caps resolve via resolvePortfolioQueueCaps() for queue next / claim-batch CLI paths. import { nextEligibleItems } from "@jsonbored/gittensory-engine"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { DEFAULT_MAX_LEASE_MS, sweepStuckItems } from "./portfolio-queue-expiry.js"; diff --git a/test/unit/miner-portfolio-queue-caps.test.ts b/test/unit/miner-portfolio-queue-caps.test.ts new file mode 100644 index 0000000000..d86b806cd0 --- /dev/null +++ b/test/unit/miner-portfolio-queue-caps.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePortfolioQueueCaps } from "../../packages/gittensory-miner/lib/portfolio-queue-caps.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("resolvePortfolioQueueCaps (#4850)", () => { + it("defaults to global/per-repo cap of 1", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-queue-caps-")); + roots.push(root); + expect( + resolvePortfolioQueueCaps({ + env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + }), + ).toEqual({ globalWipCap: 1, perRepoWipCap: 1 }); + }); + + it("reads portfolioQueue caps from .gittensory-miner.yml in the state dir", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-queue-caps-")); + roots.push(root); + writeFileSync( + join(root, ".gittensory-miner.yml"), + "portfolioQueue:\n globalWipCap: 4\n perRepoWipCap: 2\n", + "utf8", + ); + expect( + resolvePortfolioQueueCaps({ + env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + }), + ).toEqual({ globalWipCap: 4, perRepoWipCap: 2 }); + }); + + it("env vars override config file and CLI flags override env", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-queue-caps-")); + roots.push(root); + writeFileSync(join(root, ".gittensory-miner.yml"), "portfolioQueue:\n globalWipCap: 4\n", "utf8"); + const env = { + GITTENSORY_MINER_CONFIG_DIR: root, + GITTENSORY_MINER_GLOBAL_WIP_CAP: "3", + GITTENSORY_MINER_PER_REPO_WIP_CAP: "2", + } as NodeJS.ProcessEnv; + expect(resolvePortfolioQueueCaps({ env })).toEqual({ globalWipCap: 3, perRepoWipCap: 2 }); + expect( + resolvePortfolioQueueCaps({ + env, + cliCaps: { globalWipCap: 5 }, + }), + ).toEqual({ globalWipCap: 5, perRepoWipCap: 2 }); + }); + + it("ignores invalid config content and falls back to defaults", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-portfolio-queue-caps-")); + roots.push(root); + writeFileSync(join(root, ".gittensory-miner.yml"), "not: [valid", "utf8"); + expect( + resolvePortfolioQueueCaps({ + env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + }), + ).toEqual({ globalWipCap: 1, perRepoWipCap: 1 }); + }); +}); diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index bc1ab2a906..ee8dd9227a 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -6,6 +6,7 @@ import { closeDefaultPortfolioQueueStore, initPortfolioQueueStore, } from "../../packages/gittensory-miner/lib/portfolio-queue.js"; +import { initPortfolioQueueManager } from "../../packages/gittensory-miner/lib/portfolio-queue-manager.js"; import { parseQueueDoneArgs, parseQueueListArgs, @@ -33,6 +34,15 @@ function tempQueueStore() { return store; } +function managerOptions(store: ReturnType, caps = { globalWipCap: 1, perRepoWipCap: 1 }) { + return { + initPortfolioQueueManager: (opts: unknown) => { + const parsed = opts as { caps?: { globalWipCap: number; perRepoWipCap: number } }; + return initPortfolioQueueManager({ store, caps: parsed.caps ?? caps }); + }, + }; +} + afterEach(() => { for (const store of stores.splice(0)) store.close(); closeDefaultPortfolioQueueStore(); @@ -47,7 +57,18 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { json: true, repoFullName: "acme/widgets", }); - expect(parseQueueNextArgs(["--json"])).toEqual({ json: true, dryRun: false }); + expect(parseQueueNextArgs(["--json"])).toEqual({ + json: true, + dryRun: false, + globalWipCap: undefined, + perRepoWipCap: undefined, + }); + expect(parseQueueNextArgs(["--global-wip", "2", "--json"])).toEqual({ + json: true, + dryRun: false, + globalWipCap: 2, + perRepoWipCap: undefined, + }); expect(parseQueueDoneArgs(["acme/widgets", "issue:42", "--json"])).toEqual({ repoFullName: "acme/widgets", identifier: "issue:42", @@ -98,51 +119,58 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { }); }); - it("runQueueNext claims the highest-priority queued item", () => { + it("runQueueNext claims the highest-priority queued item under WIP caps (#4850)", () => { 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(runQueueNext([], managerOptions(portfolioQueue))).toBe(0); expect(log).toHaveBeenCalledWith("issue:2"); log.mockClear(); - expect( - runQueueNext(["--json"], { - initPortfolioQueue: () => portfolioQueue, - }), - ).toBe(0); + expect(runQueueNext([], managerOptions(portfolioQueue))).toBe(0); + expect(log).toHaveBeenCalledWith("none"); + + log.mockClear(); + expect(runQueueNext(["--global-wip", "2", "--per-repo-wip", "2", "--json"], managerOptions(portfolioQueue))).toBe(0); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ entry: expect.objectContaining({ identifier: "issue:1", status: "in_progress" }), }); + }); + + it("runQueueNext honors global WIP cap from CLI flags (#4850)", () => { + const portfolioQueue = tempQueueStore(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:1", priority: 10 }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:2", priority: 20 }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runQueueNext(["--global-wip", "1"], managerOptions(portfolioQueue))).toBe(0); + expect(log).toHaveBeenCalledWith("issue:2"); log.mockClear(); - expect( - runQueueNext([], { - initPortfolioQueue: () => portfolioQueue, - }), - ).toBe(0); + expect(runQueueNext(["--global-wip", "1"], managerOptions(portfolioQueue))).toBe(0); expect(log).toHaveBeenCalledWith("none"); }); it("#4847: --dry-run reports what next/done would do and returns 0 without opening the portfolio queue", () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); - const initPortfolioQueueSpy = vi.fn(); + const initPortfolioQueueManagerSpy = vi.fn(); - expect(runQueueNext(["--dry-run", "--json"], { initPortfolioQueue: initPortfolioQueueSpy })).toBe(0); - expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); - expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ outcome: "dry_run" }); + expect(runQueueNext(["--dry-run", "--json"], { initPortfolioQueueManager: initPortfolioQueueManagerSpy })).toBe(0); + expect(initPortfolioQueueManagerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + globalWipCap: 1, + perRepoWipCap: 1, + }); log.mockClear(); - expect(runQueueNext(["--dry-run"], { initPortfolioQueue: initPortfolioQueueSpy })).toBe(0); - expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would dequeue the highest-priority queued item"); + expect(runQueueNext(["--dry-run"], { initPortfolioQueueManager: initPortfolioQueueManagerSpy })).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would claim the next queued item"); log.mockClear(); + const initPortfolioQueueSpy = vi.fn(); expect( runQueueDone(["acme/widgets", "issue:9", "--dry-run", "--json"], { initPortfolioQueue: initPortfolioQueueSpy }), ).toBe(0); @@ -160,6 +188,26 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would mark acme/widgets issue:9 done"); }); + it("runQueueNext surfaces JSON errors when the manager fails to open (#4850)", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runQueueNext(["--json"], { + initPortfolioQueueManager: () => { + throw new Error("bad_store_path"); + }, + }), + ).toBe(2); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + ok: false, + error: "bad_store_path", + }); + }); + + it("parseQueueNextArgs rejects unknown flags and positional args", () => { + expect(parseQueueNextArgs(["--bogus"])).toEqual({ error: expect.stringContaining("Unknown option") }); + expect(parseQueueNextArgs(["acme/widgets"])).toEqual({ error: expect.stringContaining("queue next") }); + }); + it("runQueueDone marks an item done and rejects missing entries", () => { const portfolioQueue = tempQueueStore(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:9", priority: 1 }); @@ -196,7 +244,7 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { 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 options = { ...managerOptions(portfolioQueue), initPortfolioQueue: () => portfolioQueue }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runQueueCli("list", ["--json"], options)).toBe(0); diff --git a/test/unit/miner-wire-cli-modules.test.ts b/test/unit/miner-wire-cli-modules.test.ts index 155ecbed0a..a11bd71e8b 100644 --- a/test/unit/miner-wire-cli-modules.test.ts +++ b/test/unit/miner-wire-cli-modules.test.ts @@ -46,6 +46,12 @@ describe("queue claim-batch — wires the WIP-cap-aware batch claimer (#4833)", globalWipCap: 3, perRepoWipCap: 1, }); + expect(parseQueueClaimBatchArgs(["--json"])).toEqual({ + json: true, + dryRun: false, + globalWipCap: undefined, + perRepoWipCap: undefined, + }); expect(parseQueueClaimBatchArgs(["--global-wip", "x"])).toHaveProperty("error"); expect(parseQueueClaimBatchArgs(["--per-repo-wip", "-1"])).toHaveProperty("error"); expect(parseQueueClaimBatchArgs(["--bogus"])).toHaveProperty("error"); From 1fab20e2768496c94dcf1f9b20aeeec0e698e620 Mon Sep 17 00:00:00 2001 From: realDiligent Date: Mon, 13 Jul 2026 12:13:02 +0800 Subject: [PATCH 2/4] fix(miner): typecheck for queue WIP cap tests (#4850) Co-authored-by: Cursor --- packages/gittensory-miner/lib/portfolio-queue-caps.d.ts | 2 +- test/unit/miner-portfolio-queue-caps.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts b/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts index 1cb8b72d8c..fe508d747d 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue-caps.d.ts @@ -1,4 +1,4 @@ export function resolvePortfolioQueueCaps(options?: { - env?: NodeJS.ProcessEnv; + env?: Record; cliCaps?: { globalWipCap?: number; perRepoWipCap?: number }; }): { globalWipCap: number; perRepoWipCap: number }; diff --git a/test/unit/miner-portfolio-queue-caps.test.ts b/test/unit/miner-portfolio-queue-caps.test.ts index d86b806cd0..e005f4740f 100644 --- a/test/unit/miner-portfolio-queue-caps.test.ts +++ b/test/unit/miner-portfolio-queue-caps.test.ts @@ -16,7 +16,7 @@ describe("resolvePortfolioQueueCaps (#4850)", () => { roots.push(root); expect( resolvePortfolioQueueCaps({ - env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + env: { GITTENSORY_MINER_CONFIG_DIR: root }, }), ).toEqual({ globalWipCap: 1, perRepoWipCap: 1 }); }); @@ -31,7 +31,7 @@ describe("resolvePortfolioQueueCaps (#4850)", () => { ); expect( resolvePortfolioQueueCaps({ - env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + env: { GITTENSORY_MINER_CONFIG_DIR: root }, }), ).toEqual({ globalWipCap: 4, perRepoWipCap: 2 }); }); @@ -44,7 +44,7 @@ describe("resolvePortfolioQueueCaps (#4850)", () => { GITTENSORY_MINER_CONFIG_DIR: root, GITTENSORY_MINER_GLOBAL_WIP_CAP: "3", GITTENSORY_MINER_PER_REPO_WIP_CAP: "2", - } as NodeJS.ProcessEnv; + }; expect(resolvePortfolioQueueCaps({ env })).toEqual({ globalWipCap: 3, perRepoWipCap: 2 }); expect( resolvePortfolioQueueCaps({ @@ -60,7 +60,7 @@ describe("resolvePortfolioQueueCaps (#4850)", () => { writeFileSync(join(root, ".gittensory-miner.yml"), "not: [valid", "utf8"); expect( resolvePortfolioQueueCaps({ - env: { GITTENSORY_MINER_CONFIG_DIR: root } as NodeJS.ProcessEnv, + env: { GITTENSORY_MINER_CONFIG_DIR: root }, }), ).toEqual({ globalWipCap: 1, perRepoWipCap: 1 }); }); From 7c74c9fb7cd48872fef961820a11db4bab40ddec Mon Sep 17 00:00:00 2001 From: realDiligent Date: Mon, 13 Jul 2026 12:18:34 +0800 Subject: [PATCH 3/4] ci: retrigger checks for #4850 Co-authored-by: Cursor From 0cd7004889a563f24ad5869390a1b78d5dac2f69 Mon Sep 17 00:00:00 2001 From: realDiligent Date: Mon, 13 Jul 2026 13:07:56 +0800 Subject: [PATCH 4/4] fix(miner): rebase queue WIP caps onto merged CLI json/dry-run (#4850) Co-authored-by: Cursor --- test/unit/miner-cli-json-error-coverage.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/miner-cli-json-error-coverage.test.ts b/test/unit/miner-cli-json-error-coverage.test.ts index b66780f6b4..17e13d89db 100644 --- a/test/unit/miner-cli-json-error-coverage.test.ts +++ b/test/unit/miner-cli-json-error-coverage.test.ts @@ -83,8 +83,8 @@ describe("miner CLI --json error coverage (#4836)", () => { expectJsonError( () => runQueueNext(["--json"], { - initPortfolioQueue: () => - ({ dequeueNext: () => { throw new Error("next_db"); }, close: () => {} }) as never, + initPortfolioQueueManager: () => + ({ claimNextBatch: () => { throw new Error("next_db"); }, close: () => {} }) as never, }), "next_db", );