diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.d.ts b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts index b029b8e4b..ea89f7b2a 100644 --- a/packages/gittensory-miner/lib/claim-ledger-cli.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger-cli.d.ts @@ -5,6 +5,7 @@ export type ParsedClaimClaimArgs = repoFullName: string; issueNumber: number; note: string | undefined; + dryRun: boolean; json: boolean; } | { error: string }; @@ -13,6 +14,7 @@ export type ParsedClaimReleaseArgs = | { repoFullName: string; issueNumber: number; + dryRun: boolean; json: boolean; } | { error: string }; diff --git a/packages/gittensory-miner/lib/claim-ledger-cli.js b/packages/gittensory-miner/lib/claim-ledger-cli.js index 1dab93db7..ce16350a0 100644 --- a/packages/gittensory-miner/lib/claim-ledger-cli.js +++ b/packages/gittensory-miner/lib/claim-ledger-cli.js @@ -2,8 +2,8 @@ import { CLAIM_STATUSES, openClaimLedger } from "./claim-ledger.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const CLAIM_CLAIM_USAGE = - "Usage: gittensory-miner claim claim [--note ] [--json]"; -const CLAIM_RELEASE_USAGE = "Usage: gittensory-miner claim release [--json]"; + "Usage: gittensory-miner claim claim [--note ] [--dry-run] [--json]"; +const CLAIM_RELEASE_USAGE = "Usage: gittensory-miner claim release [--dry-run] [--json]"; const CLAIM_LIST_USAGE = "Usage: gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]"; @@ -27,7 +27,7 @@ function parseIssueNumberArg(value, usage) { } export function parseClaimClaimArgs(args) { - const options = { json: false, note: undefined }; + const options = { json: false, note: undefined, dryRun: false }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -36,6 +36,11 @@ export function parseClaimClaimArgs(args) { options.json = true; continue; } + // #4847: reports what a real claim would do and returns before opening the claim ledger at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--note") { const note = args[index + 1]; if (!note || note.startsWith("-")) { @@ -64,12 +69,13 @@ export function parseClaimClaimArgs(args) { repoFullName: repo.repoFullName, issueNumber: issue.issueNumber, note: options.note, + dryRun: options.dryRun, json: options.json, }; } export function parseClaimReleaseArgs(args) { - const options = { json: false }; + const options = { json: false, dryRun: false }; const positional = []; for (const token of args) { @@ -77,6 +83,10 @@ export function parseClaimReleaseArgs(args) { options.json = true; continue; } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -95,6 +105,7 @@ export function parseClaimReleaseArgs(args) { return { repoFullName: repo.repoFullName, issueNumber: issue.issueNumber, + dryRun: options.dryRun, json: options.json, }; } @@ -187,6 +198,18 @@ export function runClaimClaim(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, note: parsed.note ?? null }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would claim ${parsed.repoFullName}#${parsed.issueNumber}${parsed.note ? ` (note: ${parsed.note})` : ""}. No claim-ledger write was made.`, + ); + } + return 0; + } + try { return withClaimLedger(options, (claimLedger) => { const claim = claimLedger.claimIssue( @@ -212,6 +235,16 @@ export function runClaimRelease(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would release the claim on ${parsed.repoFullName}#${parsed.issueNumber}. No claim-ledger write was made.`); + } + return 0; + } + try { return withClaimLedger(options, (claimLedger) => { const claim = claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 53a2ad1a5..459e8833a 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -22,21 +22,21 @@ export function printHelp(input) { " gittensory-miner migrate [--json] Apply pending schema migrations to existing local stores", " gittensory-miner metrics Print prediction-calibration counters in Prometheus text format", " gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger", - " gittensory-miner manage poll [--branch ] [--json]", - " gittensory-miner discover [...] [--json]", - " gittensory-miner discover --search [--json] Fan out, rank, and enqueue candidates", + " gittensory-miner manage poll [--branch ] [--dry-run] [--json]", + " gittensory-miner discover [...] [--dry-run] [--json]", + " gittensory-miner discover --search [--dry-run] [--json] Fan out, rank, and enqueue candidates", " gittensory-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]", " gittensory-miner loop [...] --miner-login [--base ] [--live] [--dry-run]", " 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 [--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 queue next [--dry-run] [--json] Claim the highest-priority queued item", + " 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", + " gittensory-miner queue requeue [--dry-run] [--json] Put a completed item back on the queue", + " gittensory-miner claim claim [--note ] [--dry-run] [--json]", + " gittensory-miner claim release [--dry-run] [--json]", " gittensory-miner claim list [--repo ] [--status active|released|expired] [--json]", " gittensory-miner ledger list [--repo ] [--since ] [--type ] [--json]", " gittensory-miner ledger metrics Print event-ledger counters in Prometheus text format", @@ -47,8 +47,8 @@ export function printHelp(input) { " gittensory-miner feasibility [--not-found] [--json]", " gittensory-miner hooks check --tool --input [--json]", " gittensory-miner state get [--json]", - " gittensory-miner state set [--json]", - " gittensory-miner orb export [--enable] [--json] Build the opt-in anonymized telemetry batch", + " gittensory-miner state set [--dry-run] [--json]", + " gittensory-miner orb export [--enable] [--dry-run] [--json] Build the opt-in anonymized telemetry batch", "", "Options:", " --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)", diff --git a/packages/gittensory-miner/lib/discover-cli.d.ts b/packages/gittensory-miner/lib/discover-cli.d.ts index 238634dce..45f5f8013 100644 --- a/packages/gittensory-miner/lib/discover-cli.d.ts +++ b/packages/gittensory-miner/lib/discover-cli.d.ts @@ -19,6 +19,7 @@ export type ParsedDiscoverArgs = | { targets: FanoutTarget[]; search: string | null; + dryRun: boolean; json: boolean; /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ apiBaseUrl?: string; diff --git a/packages/gittensory-miner/lib/discover-cli.js b/packages/gittensory-miner/lib/discover-cli.js index d2f9e0334..0d6364d20 100644 --- a/packages/gittensory-miner/lib/discover-cli.js +++ b/packages/gittensory-miner/lib/discover-cli.js @@ -13,7 +13,7 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const DISCOVER_USAGE = - "Usage: gittensory-miner discover [...] | --search [--json] [--api-base-url ] [--token-env ]"; + "Usage: gittensory-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240; const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g; @@ -43,7 +43,7 @@ export function parseDiscoverArgs(args) { // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact // pre-#4784 `{ targets, search, json }` shape. - const options = { json: false, search: null, apiBaseUrl: null, tokenEnv: null }; + const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; const targets = []; for (let index = 0; index < args.length; index += 1) { @@ -52,6 +52,11 @@ export function parseDiscoverArgs(args) { options.json = true; continue; } + // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--search") { const query = args[index + 1]; if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE }; @@ -91,6 +96,7 @@ export function parseDiscoverArgs(args) { return { targets, search: options.search, + dryRun: options.dryRun, json: options.json, ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), @@ -156,6 +162,48 @@ export async function runDiscover(args, options = {}) { const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for + // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio + // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself + // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification + // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. + if (parsed.dryRun) { + const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache: null, policyVerdictCache: null }; + try { + const fanOut = + parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + const rankedSummary = rankIssues(fanOut.issues, { + nowMs: options.nowMs, + goalSpecsByRepo: options.goalSpecsByRepo, + goalSpecContentByRepo: options.goalSpecContentByRepo, + }); + const noopQueueStore = { enqueue: () => {} }; + const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); + const result = { + outcome: "dry_run", + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderDiscoverSummary(result)); + console.log("\nDRY RUN: no portfolio-queue write was made."); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } + } + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; let portfolioQueue; try { diff --git a/packages/gittensory-miner/lib/manage-poll.d.ts b/packages/gittensory-miner/lib/manage-poll.d.ts index 027b37e24..299bda4c2 100644 --- a/packages/gittensory-miner/lib/manage-poll.d.ts +++ b/packages/gittensory-miner/lib/manage-poll.d.ts @@ -28,6 +28,7 @@ export type ParsedManagePollArgs = repoFullName: string; prNumber: number; branch: string | null; + dryRun: boolean; json: boolean; } | { error: string }; diff --git a/packages/gittensory-miner/lib/manage-poll.js b/packages/gittensory-miner/lib/manage-poll.js index 6cff3d4a7..792b98f74 100644 --- a/packages/gittensory-miner/lib/manage-poll.js +++ b/packages/gittensory-miner/lib/manage-poll.js @@ -8,7 +8,7 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; const MANAGE_POLL_USAGE = - "Usage: gittensory-miner manage poll [--branch ] [--json]"; + "Usage: gittensory-miner manage poll [--branch ] [--dry-run] [--json]"; function parseRepoArg(value, usage) { if (!value) return { error: usage }; @@ -61,7 +61,7 @@ export function buildManagePollEventPayload(prNumber, pollResult, options = {}) } export function parseManagePollArgs(args = []) { - const options = { json: false, branch: null }; + const options = { json: false, branch: null, dryRun: false }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -70,6 +70,12 @@ export function parseManagePollArgs(args = []) { options.json = true; continue; } + // #4847: still runs the real (read-only) CI-check-run poll, but skips the event-ledger append and + // portfolio-queue enqueue. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token === "--branch") { const branch = args[index + 1]; if (!branch || branch.startsWith("-")) return { error: MANAGE_POLL_USAGE }; @@ -166,6 +172,45 @@ export async function runManagePoll(args = [], options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + // #4847: the CI-check-run poll itself is a real, read-only GitHub signal -- the useful "what would this + // record?" output -- so a dry run still performs it for real. It never opens the event ledger or portfolio + // queue, though: a no-op event ledger is fed through recordManagePollSnapshot so its own real payload-building + // logic still runs, just without ever writing to local storage (ensurePortfolioRow: false skips the queue + // enqueue the same way). + if (parsed.dryRun) { + const noopEventLedger = { appendEvent: () => null }; + try { + const result = await recordManagePollSnapshot( + { repoFullName: parsed.repoFullName, prNumber: parsed.prNumber, branch: parsed.branch }, + { + eventLedger: noopEventLedger, + ensurePortfolioRow: false, + pollCheckRuns: options.pollCheckRuns, + fetchFn: options.fetchFn, + githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "", + apiBaseUrl: options.apiBaseUrl, + maxAttempts: options.maxAttempts, + minIntervalMs: options.minIntervalMs, + maxIntervalMs: options.maxIntervalMs, + sleepFn: options.sleepFn, + lastPolledAt: options.lastPolledAt, + }, + ); + const dryRunResult = { outcome: "dry_run", pollResult: result.pollResult, payload: result.payload }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: ${result.payload.ciState} (${result.payload.gateVerdict}/${result.payload.outcome}). No event-ledger or portfolio-queue write was made.`, + ); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } + } + const ownsEventLedger = options.initEventLedger === undefined; const ownsPortfolioQueue = options.initPortfolioQueue === undefined; const eventLedger = (options.initEventLedger ?? initEventLedger)(); diff --git a/packages/gittensory-miner/lib/orb-export.d.ts b/packages/gittensory-miner/lib/orb-export.d.ts index 78231d384..c7b749909 100644 --- a/packages/gittensory-miner/lib/orb-export.d.ts +++ b/packages/gittensory-miner/lib/orb-export.d.ts @@ -41,7 +41,7 @@ export function collectOrbExportBatch(options?: { enabled?: boolean; }): OrbExportRow[] | null; -export type ParsedOrbExportArgs = { json: boolean; enable: boolean } | { error: string }; +export type ParsedOrbExportArgs = { json: boolean; enable: boolean; dryRun: boolean } | { error: string }; export function parseOrbExportArgs(args: string[]): ParsedOrbExportArgs; diff --git a/packages/gittensory-miner/lib/orb-export.js b/packages/gittensory-miner/lib/orb-export.js index 4554c5098..19b5fc9f2 100644 --- a/packages/gittensory-miner/lib/orb-export.js +++ b/packages/gittensory-miner/lib/orb-export.js @@ -134,10 +134,10 @@ export function collectOrbExportBatch({ store, eventLedger, enabled = ORB_EXPORT return buildAnonymizedOrbBatch(outcomes, store.getOrCreateAnonSecret()); } -const ORB_EXPORT_USAGE = "Usage: gittensory-miner orb export [--enable] [--json]"; +const ORB_EXPORT_USAGE = "Usage: gittensory-miner orb export [--enable] [--dry-run] [--json]"; export function parseOrbExportArgs(args) { - const options = { json: false, enable: false }; + const options = { json: false, enable: false, dryRun: false }; for (const token of args) { if (token === "--json") { options.json = true; @@ -147,6 +147,12 @@ export function parseOrbExportArgs(args) { options.enable = true; continue; } + // #4847: openOrbExportStore() itself creates the local SQLite file (a real write) even before any secret is + // generated, so a dry run reports what would happen and returns before opening any store at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } return { error: ORB_EXPORT_USAGE }; } return options; @@ -161,6 +167,18 @@ export function runOrbExportCli(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", enabled: parsed.enable }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else if (parsed.enable) { + console.log("DRY RUN: would build and report an anonymized Orb export batch. No local writes were made."); + } else { + console.log("DRY RUN: orb export is opt-in and disabled — pass --enable to build an anonymized batch. No local writes were made."); + } + return 0; + } + // Open the stores INSIDE the try so a bad config path / SQLite open failure returns 2 instead of crashing the // process; the finally guards each close with `?.` since either initializer may have thrown before assigning. const ownsStore = options.openOrbExportStore === undefined; diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts index d1721ffb5..276403a81 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.d.ts @@ -8,12 +8,13 @@ export type ParsedQueueListArgs = } | { error: string }; -export type ParsedQueueNextArgs = { json: boolean } | { error: string }; +export type ParsedQueueNextArgs = { json: boolean; dryRun: boolean } | { error: string }; export type ParsedQueueDoneArgs = | { repoFullName: string; identifier: string; + dryRun: boolean; json: boolean; } | { error: string }; @@ -29,7 +30,7 @@ export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; export type ParsedQueueClaimBatchArgs = - | { json: boolean; globalWipCap: number; perRepoWipCap: number } + | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } | { error: string }; export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; diff --git a/packages/gittensory-miner/lib/portfolio-queue-cli.js b/packages/gittensory-miner/lib/portfolio-queue-cli.js index 3b5a1468a..ee7a44d93 100644 --- a/packages/gittensory-miner/lib/portfolio-queue-cli.js +++ b/packages/gittensory-miner/lib/portfolio-queue-cli.js @@ -4,12 +4,12 @@ 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 [--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_NEXT_USAGE = "Usage: gittensory-miner queue next [--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]"; const QUEUE_CLAIM_BATCH_USAGE = - "Usage: gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--json]"; + "Usage: gittensory-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; function parseRepoArg(value, usage) { if (!value) return { error: usage }; @@ -22,7 +22,7 @@ function parseRepoArg(value, usage) { } function parseJsonFlag(args) { - const options = { json: false }; + const options = { json: false, dryRun: false }; const positional = []; for (const token of args) { @@ -30,6 +30,11 @@ function parseJsonFlag(args) { options.json = true; continue; } + // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -79,7 +84,7 @@ export function parseQueueNextArgs(args) { if (parsed.positional.length > 0) { return { error: QUEUE_NEXT_USAGE }; } - return { json: parsed.json }; + return { json: parsed.json, dryRun: parsed.dryRun }; } /** Shared ` [--json]` parse for the item-targeting subcommands (done/release/requeue). @@ -102,6 +107,7 @@ function parseRepoIdentifierArgs(args, usage) { return { repoFullName: repo.repoFullName, identifier, + dryRun: parsed.dryRun, json: parsed.json, }; } @@ -181,6 +187,16 @@ export function runQueueNext(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run" }; + 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."); + } + return 0; + } + try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.dequeueNext(); @@ -202,6 +218,16 @@ export function runQueueDone(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); + } + return 0; + } + try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier); @@ -228,6 +254,16 @@ export function runQueueRelease(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); + } + return 0; + } + try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier); @@ -255,6 +291,16 @@ export function runQueueRequeue(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); + } + return 0; + } + try { return withPortfolioQueue(options, (portfolioQueue) => { const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier); @@ -274,13 +320,17 @@ export function runQueueRequeue(args, options = {}) { } export function parseQueueClaimBatchArgs(args) { - const options = { json: false, globalWipCap: 1, perRepoWipCap: 1 }; + 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) { @@ -304,6 +354,18 @@ export function runQueueClaimBatch(args, options = {}) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + 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.`, + ); + } + return 0; + } + // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the // close with `?.` since the initializer may have thrown before assigning. const ownsManager = options.initPortfolioQueueManager === undefined; diff --git a/packages/gittensory-miner/lib/run-state-cli.d.ts b/packages/gittensory-miner/lib/run-state-cli.d.ts index be57877fc..0b4ce2b78 100644 --- a/packages/gittensory-miner/lib/run-state-cli.d.ts +++ b/packages/gittensory-miner/lib/run-state-cli.d.ts @@ -9,6 +9,7 @@ export type ParsedStateSetArgs = | { repoFullName: string; state: "idle" | "discovering" | "planning" | "preparing"; + dryRun: boolean; json: boolean; } | { error: string }; diff --git a/packages/gittensory-miner/lib/run-state-cli.js b/packages/gittensory-miner/lib/run-state-cli.js index 4ed2b036c..fa2d8c3ee 100644 --- a/packages/gittensory-miner/lib/run-state-cli.js +++ b/packages/gittensory-miner/lib/run-state-cli.js @@ -3,7 +3,7 @@ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js const STATE_GET_USAGE = "Usage: gittensory-miner state get [--json]"; const STATE_SET_USAGE = - "Usage: gittensory-miner state set [--json]"; + "Usage: gittensory-miner state set [--dry-run] [--json]"; const allowedRunStates = new Set(RUN_STATES); @@ -44,7 +44,7 @@ export function parseStateGetArgs(args) { } export function parseStateSetArgs(args) { - const options = { json: false }; + const options = { json: false, dryRun: false }; const positional = []; for (let index = 0; index < args.length; index += 1) { @@ -53,6 +53,11 @@ export function parseStateSetArgs(args) { options.json = true; continue; } + // #4847: reports what a real state set would do and returns before writing to the run-state store. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } if (token.startsWith("-")) { return { error: `Unknown option: ${token}` }; } @@ -99,6 +104,16 @@ export function runStateSet(args) { return reportCliFailure(argsWantJson(args), parsed.error); } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, state: parsed.state }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult)); + } else { + console.log(`DRY RUN: would set ${parsed.repoFullName}'s run state to "${parsed.state}". No run-state write was made.`); + } + return 0; + } + try { const write = setRunState(parsed.repoFullName, parsed.state); if (parsed.json) { diff --git a/test/unit/miner-claim-ledger-cli.test.ts b/test/unit/miner-claim-ledger-cli.test.ts index 7dc6ccd65..024c5eb41 100644 --- a/test/unit/miner-claim-ledger-cli.test.ts +++ b/test/unit/miner-claim-ledger-cli.test.ts @@ -38,16 +38,18 @@ afterEach(() => { describe("gittensory-miner claim ledger CLI (#4290)", () => { it("parseClaimClaimArgs, parseClaimReleaseArgs, and parseClaimListArgs validate argv", () => { - expect(parseClaimClaimArgs(["acme/widgets", "42", "--note", "wip", "--json"])).toEqual({ + expect(parseClaimClaimArgs(["acme/widgets", "42", "--note", "wip", "--dry-run", "--json"])).toEqual({ repoFullName: "acme/widgets", issueNumber: 42, note: "wip", + dryRun: true, json: true, }); expect(parseClaimClaimArgs(["acme/widgets", "42"])).toEqual({ repoFullName: "acme/widgets", issueNumber: 42, note: undefined, + dryRun: false, json: false, }); expect(parseClaimClaimArgs(["acme/widgets"])).toEqual({ @@ -69,6 +71,7 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { expect(parseClaimReleaseArgs(["acme/widgets", "7", "--json"])).toEqual({ repoFullName: "acme/widgets", issueNumber: 7, + dryRun: false, json: true, }); expect(parseClaimReleaseArgs(["acme/widgets"])).toEqual({ @@ -154,6 +157,53 @@ describe("gittensory-miner claim ledger CLI (#4290)", () => { }); }); + it("#4847: --dry-run reports what would happen and returns 0 without opening the claim ledger", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const openClaimLedgerSpy = vi.fn(); + + expect( + runClaimClaim(["acme/widgets", "42", "--note", "on it", "--dry-run", "--json"], { + openClaimLedger: openClaimLedgerSpy, + }), + ).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + issueNumber: 42, + note: "on it", + }); + + log.mockClear(); + expect(runClaimClaim(["acme/widgets", "42", "--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would claim acme/widgets#42"); + expect(String(log.mock.calls[0]?.[0])).not.toContain("note:"); + + log.mockClear(); + expect( + runClaimClaim(["acme/widgets", "42", "--note", "on it", "--dry-run"], { openClaimLedger: openClaimLedgerSpy }), + ).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would claim acme/widgets#42 (note: on it)"); + + log.mockClear(); + expect(runClaimRelease(["acme/widgets", "42", "--dry-run"], { openClaimLedger: openClaimLedgerSpy })).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would release the claim on acme/widgets#42"); + + log.mockClear(); + expect( + runClaimRelease(["acme/widgets", "42", "--dry-run", "--json"], { openClaimLedger: openClaimLedgerSpy }), + ).toBe(0); + expect(openClaimLedgerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + issueNumber: 42, + }); + }); + it("runClaimRelease releases a claim and rejects missing entries", () => { const claimLedger = tempClaimLedger(); claimLedger.claimIssue("acme/widgets", 9); diff --git a/test/unit/miner-cli-run-state.test.ts b/test/unit/miner-cli-run-state.test.ts index b00570664..05ffad8b4 100644 --- a/test/unit/miner-cli-run-state.test.ts +++ b/test/unit/miner-cli-run-state.test.ts @@ -33,6 +33,7 @@ describe("gittensory-miner state CLI", () => { expect(parseStateSetArgs(["acme/widgets", "planning"])).toEqual({ repoFullName: "acme/widgets", state: "planning", + dryRun: false, json: false, }); expect(parseStateSetArgs(["acme/widgets", "bogus"])).toEqual({ @@ -63,6 +64,23 @@ describe("gittensory-miner state CLI", () => { ); }); + it("#4847: --dry-run reports what a state set would do and returns 0 without writing the run-state store", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect(runStateSet(["acme/widgets", "planning", "--dry-run", "--json"])).toBe(0); + expect(setRunState).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + state: "planning", + }); + + log.mockClear(); + expect(runStateSet(["acme/widgets", "planning", "--dry-run"])).toBe(0); + expect(setRunState).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain('DRY RUN: would set acme/widgets\'s run state to "planning"'); + }); + it("runStateSet returns exit code 2 for malformed repositories", () => { const error = vi.spyOn(console, "error").mockImplementation(() => undefined); expect(runStateSet(["not-a-repo", "idle"])).toBe(2); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 106203e2d..3b24c23d6 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -88,6 +88,7 @@ describe("parseDiscoverArgs (#4247)", () => { { owner: "acme", repo: "gadgets" }, ], search: null, + dryRun: false, json: true, }); }); @@ -96,6 +97,7 @@ describe("parseDiscoverArgs (#4247)", () => { expect(parseDiscoverArgs(["--search", "label:bug"])).toEqual({ targets: [], search: "label:bug", + dryRun: false, json: false, }); }); @@ -136,6 +138,7 @@ describe("parseDiscoverArgs (#4247)", () => { ).toEqual({ targets: [{ owner: "acme", repo: "widgets" }], search: null, + dryRun: false, json: false, apiBaseUrl: "https://ghe.example.com/api/v3", tokenEnv: "FORGE_PAT", @@ -146,10 +149,20 @@ describe("parseDiscoverArgs (#4247)", () => { expect(parseDiscoverArgs(["acme/widgets"])).toEqual({ targets: [{ owner: "acme", repo: "widgets" }], search: null, + dryRun: false, json: false, }); }); + it("parses --dry-run (#4847)", () => { + expect(parseDiscoverArgs(["acme/widgets", "--dry-run", "--json"])).toEqual({ + targets: [{ owner: "acme", repo: "widgets" }], + search: null, + dryRun: true, + json: true, + }); + }); + it("rejects --api-base-url / --token-env missing their value (#4784)", () => { expect(parseDiscoverArgs(["acme/widgets", "--api-base-url"])).toEqual({ error: expect.stringContaining("Usage: gittensory-miner discover"), @@ -336,6 +349,109 @@ describe("runDiscover (#4247)", () => { expect(queued.map((entry) => entry.identifier).sort()).toEqual(["issue:1", "issue:2"]); }); + it("#4847: --dry-run performs the real fan-out/rank but never opens any local store", async () => { + const initPortfolioQueue = vi.fn(); + const initPolicyDocCache = vi.fn(); + const initPolicyVerdictCache = vi.fn(); + const fetchCandidateIssuesWithSummary = vi.fn(async (targets, token, fanOutOptions) => { + expect(fanOutOptions).toMatchObject({ policyDocCache: null, policyVerdictCache: null }); + return { + issues: [fanOutIssue({ issueNumber: 1, title: "Add retry helper" })], + warnings: [], + rateLimitRemaining: 4990, + rateLimitResetAt: "2026-07-09T13:00:00.000Z", + }; + }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { + nowMs: NOW, + initPortfolioQueue, + initPolicyDocCache, + initPolicyVerdictCache, + fetchCandidateIssuesWithSummary, + }); + + expect(exitCode).toBe(0); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(initPolicyDocCache).not.toHaveBeenCalled(); + expect(initPolicyVerdictCache).not.toHaveBeenCalled(); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.outcome).toBe("dry_run"); + expect(payload.fanOutCount).toBe(1); + expect(payload.enqueueSummary.enqueued).toBe(1); + expect(payload.ranked.map((entry: { issueNumber: number }) => entry.issueNumber)).toEqual([1]); + + log.mockClear(); + const textExitCode = await runDiscover(["acme/widgets", "--dry-run"], { + nowMs: NOW, + initPortfolioQueue, + initPolicyDocCache, + initPolicyVerdictCache, + fetchCandidateIssuesWithSummary, + }); + expect(textExitCode).toBe(0); + expect(String(log.mock.calls[1]?.[0])).toContain("DRY RUN: no portfolio-queue write was made."); + }); + + it("#4847: --dry-run reports fan-out failures and exits non-zero without opening any local store", async () => { + const initPortfolioQueue = vi.fn(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => { + throw new Error("github_unreachable"); + }); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { + nowMs: NOW, + initPortfolioQueue, + fetchCandidateIssuesWithSummary, + }); + + expect(exitCode).toBe(2); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("github_unreachable"); + }); + + it("#4847: --dry-run stringifies a thrown non-Error value instead of crashing", async () => { + const fetchCandidateIssuesWithSummary = vi.fn(async () => { + throw "raw_string_fault"; + }); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run"], { nowMs: NOW, fetchCandidateIssuesWithSummary }); + + expect(exitCode).toBe(2); + expect(error).toHaveBeenCalledWith("raw_string_fault"); + }); + + it("#4847: --dry-run works in --search mode too, never calling the repo fan-out", async () => { + const initPortfolioQueue = vi.fn(); + const searchCandidateIssuesWithSummary = vi.fn(async (query: string) => ({ + issues: [fanOutIssue({ issueNumber: 9, title: `Result for ${query}` })], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + const fetchCandidateIssuesWithSummary = vi.fn(async () => { + throw new Error("must not be called in --search mode"); + }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runDiscover(["--search", "label:bug", "--dry-run", "--json"], { + nowMs: NOW, + initPortfolioQueue, + searchCandidateIssuesWithSummary, + fetchCandidateIssuesWithSummary, + }); + + expect(exitCode).toBe(0); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(fetchCandidateIssuesWithSummary).not.toHaveBeenCalled(); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.outcome).toBe("dry_run"); + expect(payload.ranked[0]?.title).toContain("Result for label:bug"); + }); + it("uses --search instead of repo targets and never calls the repo fan-out", async () => { const portfolioQueue = tempQueueStore(); const fetchCandidateIssuesWithSummary = vi.fn(async () => { diff --git a/test/unit/miner-manage-poll.test.ts b/test/unit/miner-manage-poll.test.ts index 6d1b428ad..2e5dab7c7 100644 --- a/test/unit/miner-manage-poll.test.ts +++ b/test/unit/miner-manage-poll.test.ts @@ -62,6 +62,7 @@ describe("gittensory-miner manage poll (#2323/#2325)", () => { repoFullName: "acme/widgets", prNumber: 42, branch: "feat/x", + dryRun: false, json: true, }); expect(parseManagePollArgs(["acme/widgets", "0"])).toEqual({ @@ -168,6 +169,69 @@ describe("gittensory-miner manage poll (#2323/#2325)", () => { ); }); + it("#4847: --dry-run performs the real CI poll but never opens the event ledger or portfolio queue", async () => { + const initPortfolioQueue = vi.fn(); + const initEventLedger = vi.fn(); + const pollCheckRuns = vi.fn().mockResolvedValue(pollResult("success")); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + await runManagePoll(["acme/widgets", "4", "--branch", "feat/x", "--dry-run", "--json"], { + initPortfolioQueue, + initEventLedger, + pollCheckRuns, + lastPolledAt: "2026-07-04T12:10:00.000Z", + }), + ).toBe(0); + expect(pollCheckRuns).toHaveBeenCalledWith("acme/widgets", 4, expect.any(Object)); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(initEventLedger).not.toHaveBeenCalled(); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.outcome).toBe("dry_run"); + expect(payload.payload).toEqual( + expect.objectContaining({ prNumber: 4, ciState: "success", gateVerdict: "pass", outcome: "ready" }), + ); + + log.mockClear(); + expect( + await runManagePoll(["acme/widgets", "4", "--dry-run"], { + initPortfolioQueue, + initEventLedger, + pollCheckRuns, + lastPolledAt: "2026-07-04T12:10:00.000Z", + }), + ).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain( + "DRY RUN: success (pass/ready). No event-ledger or portfolio-queue write was made.", + ); + }); + + it("#4847: --dry-run reports poll failures and exits non-zero without opening any local store", async () => { + const initPortfolioQueue = vi.fn(); + const initEventLedger = vi.fn(); + const pollCheckRuns = vi.fn().mockRejectedValue(new Error("github_404: not found")); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect( + await runManagePoll(["acme/widgets", "4", "--dry-run"], { + initPortfolioQueue, + initEventLedger, + pollCheckRuns, + }), + ).toBe(2); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(initEventLedger).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith("github_404: not found"); + }); + + it("#4847: --dry-run stringifies a thrown non-Error value instead of crashing", async () => { + const pollCheckRuns = vi.fn().mockRejectedValue("raw_string_fault"); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(await runManagePoll(["acme/widgets", "4", "--dry-run"], { pollCheckRuns })).toBe(2); + expect(error).toHaveBeenCalledWith("raw_string_fault"); + }); + it("rejects invalid stores and poll failures", async () => { const { eventLedger } = tempStores(); await expect( diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index 72171f6e7..bc1ab2a90 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -47,10 +47,11 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { json: true, repoFullName: "acme/widgets", }); - expect(parseQueueNextArgs(["--json"])).toEqual({ json: true }); + expect(parseQueueNextArgs(["--json"])).toEqual({ json: true, dryRun: false }); expect(parseQueueDoneArgs(["acme/widgets", "issue:42", "--json"])).toEqual({ repoFullName: "acme/widgets", identifier: "issue:42", + dryRun: false, json: true, }); expect(parseQueueDoneArgs(["acme/widgets"])).toEqual({ @@ -129,6 +130,36 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { 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(); + + 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" }); + + 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"); + + log.mockClear(); + expect( + runQueueDone(["acme/widgets", "issue:9", "--dry-run", "--json"], { initPortfolioQueue: initPortfolioQueueSpy }), + ).toBe(0); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + identifier: "issue:9", + }); + + log.mockClear(); + expect(runQueueDone(["acme/widgets", "issue:9", "--dry-run"], { initPortfolioQueue: initPortfolioQueueSpy })).toBe( + 0, + ); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would mark acme/widgets issue:9 done"); + }); + it("runQueueDone marks an item done and rejects missing entries", () => { const portfolioQueue = tempQueueStore(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:9", priority: 1 }); @@ -194,11 +225,13 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(parseQueueReleaseArgs(["acme/widgets", "issue:1"])).toEqual({ repoFullName: "acme/widgets", identifier: "issue:1", + dryRun: false, json: false, }); expect(parseQueueRequeueArgs(["acme/widgets", "issue:1", "--json"])).toEqual({ repoFullName: "acme/widgets", identifier: "issue:1", + dryRun: false, json: true, }); // Wrong positional count surfaces the command-specific usage string. @@ -206,6 +239,52 @@ describe("gittensory-miner portfolio queue CLI (#2292)", () => { expect(parseQueueRequeueArgs([])).toEqual({ error: expect.stringContaining("queue requeue") }); }); + it("#4847: --dry-run reports what release/requeue would do and returns 0 without opening the portfolio queue", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const initPortfolioQueueSpy = vi.fn(); + + expect( + runQueueRelease(["acme/widgets", "issue:7", "--dry-run", "--json"], { + initPortfolioQueue: initPortfolioQueueSpy, + }), + ).toBe(0); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + identifier: "issue:7", + }); + + log.mockClear(); + expect( + runQueueRelease(["acme/widgets", "issue:7", "--dry-run"], { initPortfolioQueue: initPortfolioQueueSpy }), + ).toBe(0); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain( + "DRY RUN: would release acme/widgets issue:7 back to the queue", + ); + + log.mockClear(); + expect( + runQueueRequeue(["acme/widgets", "issue:9", "--dry-run"], { initPortfolioQueue: initPortfolioQueueSpy }), + ).toBe(0); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would requeue acme/widgets issue:9"); + + log.mockClear(); + expect( + runQueueRequeue(["acme/widgets", "issue:9", "--dry-run", "--json"], { + initPortfolioQueue: initPortfolioQueueSpy, + }), + ).toBe(0); + expect(initPortfolioQueueSpy).not.toHaveBeenCalled(); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + repoFullName: "acme/widgets", + identifier: "issue:9", + }); + }); + it("release returns a CLAIMED (in-progress) item to the queue", () => { const portfolioQueue = tempQueueStore(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7", priority: 1 }); diff --git a/test/unit/miner-wire-cli-modules.test.ts b/test/unit/miner-wire-cli-modules.test.ts index 0506fc4e2..155ecbed0 100644 --- a/test/unit/miner-wire-cli-modules.test.ts +++ b/test/unit/miner-wire-cli-modules.test.ts @@ -42,6 +42,7 @@ describe("queue claim-batch — wires the WIP-cap-aware batch claimer (#4833)", it("parses the wip flags, rejecting a non-numeric/negative value", () => { expect(parseQueueClaimBatchArgs(["--global-wip", "3", "--per-repo-wip", "1", "--json"])).toEqual({ json: true, + dryRun: false, globalWipCap: 3, perRepoWipCap: 1, }); @@ -50,6 +51,24 @@ describe("queue claim-batch — wires the WIP-cap-aware batch claimer (#4833)", expect(parseQueueClaimBatchArgs(["--bogus"])).toHaveProperty("error"); }); + it("#4847: --dry-run reports what a claim would do and returns 0 without opening the manager", () => { + const initPortfolioQueueManagerSpy = vi.fn(); + const spy = captureLog(); + + const jsonCode = runQueueClaimBatch(["--global-wip", "3", "--per-repo-wip", "2", "--dry-run", "--json"], { + initPortfolioQueueManager: initPortfolioQueueManagerSpy, + }); + expect(jsonCode).toBe(0); + expect(initPortfolioQueueManagerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(logs.join(""))).toEqual({ outcome: "dry_run", globalWipCap: 3, perRepoWipCap: 2 }); + + logs = []; + const textCode = runQueueClaimBatch(["--dry-run"], { initPortfolioQueueManager: initPortfolioQueueManagerSpy }); + expect(textCode).toBe(0); + expect(logs.join("")).toContain("DRY RUN: would claim a batch (global-wip: 1, per-repo-wip: 1)"); + spy.mockRestore(); + }); + it("claims a diversified batch across repos via the manager", () => { const store = initPortfolioQueueStore(join(tempDir(), "q.sqlite3")); closeables.push(store); @@ -100,6 +119,40 @@ describe("orb export — wires the anonymized telemetry batch-builder (#4833)", return { openOrbExportStore: () => store, initEventLedger: () => ledger }; }; + it("#4847: --dry-run reports what an export would do and returns 0 without opening any store", () => { + const openOrbExportStoreSpy = vi.fn(); + const initEventLedgerSpy = vi.fn(); + const spy = captureLog(); + + const disabledCode = runOrbExportCli(["--dry-run", "--json"], { + openOrbExportStore: openOrbExportStoreSpy, + initEventLedger: initEventLedgerSpy, + }); + expect(disabledCode).toBe(0); + expect(openOrbExportStoreSpy).not.toHaveBeenCalled(); + expect(initEventLedgerSpy).not.toHaveBeenCalled(); + expect(JSON.parse(logs.join(""))).toEqual({ outcome: "dry_run", enabled: false }); + + logs = []; + const enabledCode = runOrbExportCli(["--enable", "--dry-run"], { + openOrbExportStore: openOrbExportStoreSpy, + initEventLedger: initEventLedgerSpy, + }); + expect(enabledCode).toBe(0); + expect(openOrbExportStoreSpy).not.toHaveBeenCalled(); + expect(logs.join("")).toContain("DRY RUN: would build and report an anonymized Orb export batch"); + + logs = []; + const disabledTextCode = runOrbExportCli(["--dry-run"], { + openOrbExportStore: openOrbExportStoreSpy, + initEventLedger: initEventLedgerSpy, + }); + expect(disabledTextCode).toBe(0); + expect(openOrbExportStoreSpy).not.toHaveBeenCalled(); + expect(logs.join("")).toContain("DRY RUN: orb export is opt-in and disabled — pass --enable"); + spy.mockRestore(); + }); + it("is opt-in: exports nothing (null batch) without --enable", () => { const spy = captureLog(); const code = runOrbExportCli(["--json"], stores());