diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 74a0096dde..cf2ea836ee 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -8,6 +8,7 @@ import { runManagePoll } from "../lib/manage-poll.js"; import { runManageStatus } from "../lib/manage-status.js"; import { runPlanCli } from "../lib/plan-store-cli.js"; import { runQueueCli } from "../lib/portfolio-queue-cli.js"; +import { runRejectCli } from "../lib/rejection-render.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runDoctor, runStatus } from "../lib/status.js"; import { @@ -88,6 +89,12 @@ if (cliArgs[0] === "hooks" && cliArgs[1] === "check") { process.exit(exitCode); } +if (cliArgs[0] === "reject") { + const exitCode = runRejectCli(cliArgs[1], cliArgs.slice(2)); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(exitCode); +} + if (cliArgs[0] === "state") { const exitCode = runStateCli(cliArgs[1], cliArgs.slice(2)); await awaitOpportunisticUpdateCheck(updateCheck); diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 6f999371b5..2a3803b10e 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -26,6 +26,8 @@ export function printHelp(input) { " gittensory-miner plan show [--json]", " gittensory-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", " gittensory-miner hooks check --tool --input [--json]", + " gittensory-miner reject render --reason --repo --pr [--json]", + " gittensory-miner reject reasons [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--json]", "", diff --git a/packages/gittensory-miner/lib/rejection-render.d.ts b/packages/gittensory-miner/lib/rejection-render.d.ts new file mode 100644 index 0000000000..75330d6a0a --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-render.d.ts @@ -0,0 +1,20 @@ +export type ParsedRejectRenderArgs = + | { + reason: string; + repo: string; + prNumber: number; + json: boolean; + } + | { error: string }; + +export type ParsedRejectReasonsArgs = { json: boolean } | { error: string }; + +export function parseRejectRenderArgs(args: string[]): ParsedRejectRenderArgs; + +export function parseRejectReasonsArgs(args: string[]): ParsedRejectReasonsArgs; + +export function runRejectRender(args: string[]): number; + +export function runRejectReasons(args: string[]): number; + +export function runRejectCli(subcommand: string | undefined, args: string[]): number; diff --git a/packages/gittensory-miner/lib/rejection-render.js b/packages/gittensory-miner/lib/rejection-render.js new file mode 100644 index 0000000000..3386c731d4 --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-render.js @@ -0,0 +1,128 @@ +import { REJECTION_REASONS, renderRejectionMessage } from "./rejection-templates.js"; + +const REJECT_RENDER_USAGE = + "Usage: gittensory-miner reject render --reason --repo --pr [--json]"; + +function parsePositiveInt(flag, value) { + if (value === undefined) { + return { error: `Missing value for ${flag}.` }; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return { error: `Invalid value for ${flag}: must be a positive integer.` }; + } + return { value: parsed }; +} + +export function parseRejectRenderArgs(args) { + const options = { + json: false, + reason: undefined, + repo: undefined, + prNumber: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--reason") { + const reason = args[++index]; + if (!reason) return { error: "Missing value for --reason." }; + options.reason = reason; + continue; + } + if (token === "--repo") { + const repo = args[++index]; + if (!repo) return { error: "Missing value for --repo." }; + options.repo = repo; + continue; + } + if (token === "--pr") { + const parsed = parsePositiveInt("--pr", args[++index]); + if ("error" in parsed) return { error: parsed.error }; + options.prNumber = parsed.value; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + return { error: REJECT_RENDER_USAGE }; + } + + if (!options.reason || !options.repo || options.prNumber === undefined) { + return { error: REJECT_RENDER_USAGE }; + } + + return options; +} + +export function parseRejectReasonsArgs(args) { + if (args.length === 1 && args[0] === "--json") { + return { json: true }; + } + if (args.length === 0) { + return { json: false }; + } + if (args.length === 1 && args[0].startsWith("-")) { + return { error: `Unknown option: ${args[0]}` }; + } + return { error: "Usage: gittensory-miner reject reasons [--json]" }; +} + +export function runRejectRender(args) { + const parsed = parseRejectRenderArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + try { + const message = renderRejectionMessage(parsed.reason, { + repoFullName: parsed.repo, + prNumber: parsed.prNumber, + }); + if (parsed.json) { + console.log( + JSON.stringify({ + reason: parsed.reason, + repoFullName: parsed.repo, + prNumber: parsed.prNumber, + message, + }), + ); + } else { + console.log(message); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } +} + +export function runRejectReasons(args) { + const parsed = parseRejectReasonsArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + if (parsed.json) { + console.log(JSON.stringify({ reasons: [...REJECTION_REASONS] })); + } else { + for (const reason of REJECTION_REASONS) { + console.log(reason); + } + } + return 0; +} + +export function runRejectCli(subcommand, args) { + if (subcommand === "render") return runRejectRender(args); + if (subcommand === "reasons") return runRejectReasons(args); + console.error(`Unknown reject subcommand: ${subcommand ?? ""}. ${REJECT_RENDER_USAGE}`); + return 2; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 51b4353a02..e5105da918 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/event-ledger-cli.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/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/rejection-render.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/event-ledger-cli.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/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-cli-rejection-render.test.ts b/test/unit/miner-cli-rejection-render.test.ts new file mode 100644 index 0000000000..4dd834fefa --- /dev/null +++ b/test/unit/miner-cli-rejection-render.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseRejectReasonsArgs, + parseRejectRenderArgs, + runRejectCli, + runRejectReasons, + runRejectRender, +} from "../../packages/gittensory-miner/lib/rejection-render.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("gittensory-miner reject render command", () => { + it("parseRejectRenderArgs validates required flags", () => { + expect(parseRejectRenderArgs([])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner reject render"), + }); + expect(parseRejectRenderArgs(["--reason", "gate_close"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner reject render"), + }); + expect(parseRejectRenderArgs(["--reason", "gate_close", "--repo", "acme/widgets", "--pr", "0"])).toEqual({ + error: "Invalid value for --pr: must be a positive integer.", + }); + expect( + parseRejectRenderArgs([ + "--reason", + "gate_close", + "--repo", + "acme/widgets", + "--pr", + "42", + "--json", + ]), + ).toEqual({ + reason: "gate_close", + repo: "acme/widgets", + prNumber: 42, + json: true, + }); + }); + + it("parseRejectReasonsArgs accepts optional --json", () => { + expect(parseRejectReasonsArgs([])).toEqual({ json: false }); + expect(parseRejectReasonsArgs(["--json"])).toEqual({ json: true }); + expect(parseRejectReasonsArgs(["--wat"])).toEqual({ error: "Unknown option: --wat" }); + }); + + it("runRejectRender prints a courtesy note and exits 0", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runRejectRender([ + "--reason", + "gate_close", + "--repo", + "JSONbored/gittensory", + "--pr", + "42", + ]), + ).toBe(0); + expect(log).toHaveBeenCalledWith(expect.stringContaining("#42")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("JSONbored/gittensory")); + }); + + it("runRejectRender prints JSON when requested", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runRejectRender([ + "--reason", + "superseded_by_duplicate", + "--repo", + "JSONbored/gittensory", + "--pr", + "7", + "--json", + ]), + ).toBe(0); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"reason":"superseded_by_duplicate"')); + }); + + it("runRejectRender returns exit code 2 for invalid reasons or flags", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runRejectRender(["--reason", "bogus", "--repo", "acme/widgets", "--pr", "1"])).toBe(2); + expect(error).toHaveBeenCalledWith("invalid_rejection_reason"); + expect(runRejectRender(["--reason", "gate_close", "--repo", "bad", "--pr", "1"])).toBe(2); + expect(runRejectRender(["--reason", "gate_close", "--repo", "acme/widgets", "--pr", "nope"])).toBe(2); + }); + + it("runRejectReasons lists reason buckets", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runRejectReasons([])).toBe(0); + expect(log).toHaveBeenCalledWith("gate_close"); + expect(runRejectReasons(["--json"])).toBe(0); + expect(log).toHaveBeenCalledWith(expect.stringContaining('"gate_close"')); + }); + + it("runRejectCli routes render and reasons subcommands", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect( + runRejectCli("render", ["--reason", "gate_close", "--repo", "JSONbored/gittensory", "--pr", "1"]), + ).toBe(0); + expect(runRejectCli("reasons", [])).toBe(0); + expect(log).toHaveBeenCalled(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(runRejectCli("wat", [])).toBe(2); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Unknown reject subcommand")); + }); +});