diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 414d619ca2..24fc8f4f95 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; +import { runCiWait } from "../lib/ci-wait.js"; import { awaitOpportunisticUpdateCheck, resolveUpgradeCommand, @@ -41,6 +42,15 @@ if ( process.exit(0); } +if (cliArgs[0] === "ci" && cliArgs[1] === "wait") { + const exitCode = await runCiWait(cliArgs.slice(2), { + packageName, + env: process.env, + }); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(exitCode); +} + const exitCode = runCli(cliArgs, { packageName }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/ci-wait.d.ts b/packages/gittensory-miner/lib/ci-wait.d.ts new file mode 100644 index 0000000000..372afb89d4 --- /dev/null +++ b/packages/gittensory-miner/lib/ci-wait.d.ts @@ -0,0 +1,17 @@ +export type ParsedCiWaitArgs = + | { + repoFullName: string; + prNumber: number; + json: boolean; + maxAttempts: number | undefined; + minIntervalMs: number | undefined; + maxIntervalMs: number | undefined; + } + | { error: string }; + +export function parseCiWaitArgs(args: string[]): ParsedCiWaitArgs; + +export function runCiWait( + args: string[], + input: { env?: Record }, +): Promise; diff --git a/packages/gittensory-miner/lib/ci-wait.js b/packages/gittensory-miner/lib/ci-wait.js new file mode 100644 index 0000000000..7369686e6d --- /dev/null +++ b/packages/gittensory-miner/lib/ci-wait.js @@ -0,0 +1,112 @@ +import { pollCheckRuns } from "./ci-poller.js"; + +const CI_WAIT_USAGE = + "Usage: gittensory-miner ci wait [--json] [--max-attempts N] [--min-interval-ms N] [--max-interval-ms N]"; + +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 parseCiWaitArgs(args) { + const positional = []; + const options = { + json: false, + maxAttempts: undefined, + minIntervalMs: undefined, + maxIntervalMs: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--max-attempts") { + const parsed = parsePositiveInt("--max-attempts", args[++index]); + if ("error" in parsed) return { error: parsed.error }; + options.maxAttempts = parsed.value; + continue; + } + if (token === "--min-interval-ms") { + const parsed = parsePositiveInt("--min-interval-ms", args[++index]); + if ("error" in parsed) return { error: parsed.error }; + options.minIntervalMs = parsed.value; + continue; + } + if (token === "--max-interval-ms") { + const parsed = parsePositiveInt("--max-interval-ms", args[++index]); + if ("error" in parsed) return { error: parsed.error }; + options.maxIntervalMs = parsed.value; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length !== 2) { + return { error: CI_WAIT_USAGE }; + } + + const prNumber = Number(positional[1]); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + return { error: "PR number must be a positive integer." }; + } + + return { + repoFullName: positional[0], + prNumber, + ...options, + }; +} + +export async function runCiWait(args, input) { + const parsed = parseCiWaitArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + const token = String( + input.env?.GITTENSOR_MINER_GITHUB_TOKEN ?? input.env?.GITHUB_TOKEN ?? "", + ).trim(); + if (!token) { + console.error("Missing GitHub token: set GITHUB_TOKEN or GITTENSOR_MINER_GITHUB_TOKEN."); + return 2; + } + + try { + const result = await pollCheckRuns(parsed.repoFullName, parsed.prNumber, { + githubToken: token, + ...(parsed.maxAttempts !== undefined ? { maxAttempts: parsed.maxAttempts } : {}), + ...(parsed.minIntervalMs !== undefined ? { minIntervalMs: parsed.minIntervalMs } : {}), + ...(parsed.maxIntervalMs !== undefined ? { maxIntervalMs: parsed.maxIntervalMs } : {}), + }); + + if (parsed.json) { + console.log(JSON.stringify(result)); + } else { + const shortSha = result.headSha ? result.headSha.slice(0, 7) : "unknown"; + console.error( + `CI ${result.conclusion} for ${parsed.repoFullName}#${parsed.prNumber} @ ${shortSha} (${result.attempts} attempt(s))`, + ); + for (const check of result.checks) { + console.error(` ${check.name}: ${check.conclusion}`); + } + } + + return result.conclusion === "success" ? 0 : 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 91ccc42dee..e6d281bf2c 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -14,6 +14,7 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", + " gittensory-miner ci wait [--json] [--max-attempts N] [--min-interval-ms N] [--max-interval-ms N]", "", "Options:", " --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)", diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 3630b0a0c8..a82e7b2c94 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/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/ci-wait.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" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-cli-ci-wait.test.ts b/test/unit/miner-cli-ci-wait.test.ts new file mode 100644 index 0000000000..2fc4fc5c9c --- /dev/null +++ b/test/unit/miner-cli-ci-wait.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { parseCiWaitArgs, runCiWait } from "../../packages/gittensory-miner/lib/ci-wait.js"; +import * as ciPoller from "../../packages/gittensory-miner/lib/ci-poller.js"; + +describe("gittensory-miner ci wait command", () => { + it("parseCiWaitArgs requires owner/repo and pr number", () => { + expect(parseCiWaitArgs([])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner ci wait"), + }); + expect(parseCiWaitArgs(["acme/widgets"])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner ci wait"), + }); + expect(parseCiWaitArgs(["acme/widgets", "0"])).toEqual({ + error: "PR number must be a positive integer.", + }); + expect(parseCiWaitArgs(["acme/widgets", "42", "--json"])).toEqual({ + repoFullName: "acme/widgets", + prNumber: 42, + json: true, + maxAttempts: undefined, + minIntervalMs: undefined, + maxIntervalMs: undefined, + }); + }); + + it("parseCiWaitArgs validates polling option flags", () => { + expect(parseCiWaitArgs(["acme/widgets", "42", "--max-attempts"])).toEqual({ + error: "Missing value for --max-attempts.", + }); + expect(parseCiWaitArgs(["acme/widgets", "42", "--max-attempts", "0"])).toEqual({ + error: "Invalid value for --max-attempts: must be a positive integer.", + }); + expect(parseCiWaitArgs(["acme/widgets", "42", "--min-interval-ms", "2500"])).toEqual({ + repoFullName: "acme/widgets", + prNumber: 42, + json: false, + maxAttempts: undefined, + minIntervalMs: 2500, + maxIntervalMs: undefined, + }); + }); + + it("parseCiWaitArgs rejects unknown flags", () => { + expect(parseCiWaitArgs(["acme/widgets", "42", "--wat"])).toEqual({ + error: "Unknown option: --wat", + }); + }); + + it("runCiWait returns exit code 2 when no GitHub token is configured", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect( + runCiWait(["acme/widgets", "42"], { env: {} }), + ).resolves.toBe(2); + expect(error).toHaveBeenCalledWith( + "Missing GitHub token: set GITHUB_TOKEN or GITTENSOR_MINER_GITHUB_TOKEN.", + ); + }); + + it("runCiWait prints JSON and exits 0 when CI succeeds", async () => { + vi.spyOn(ciPoller, "pollCheckRuns").mockResolvedValue({ + conclusion: "success", + checks: [{ name: "validate", status: "completed", conclusion: "success", detailsUrl: null, startedAt: null, completedAt: null }], + headSha: "abc1234567", + attempts: 1, + }); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + await expect( + runCiWait(["acme/widgets", "42", "--json", "--max-attempts", "3"], { + env: { GITHUB_TOKEN: "github-token" }, + }), + ).resolves.toBe(0); + expect(log).toHaveBeenCalledWith( + expect.stringContaining('"conclusion":"success"'), + ); + expect(ciPoller.pollCheckRuns).toHaveBeenCalledWith("acme/widgets", 42, { + githubToken: "github-token", + maxAttempts: 3, + }); + }); + + it("runCiWait returns exit code 2 for invalid polling flags", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect( + runCiWait(["acme/widgets", "42", "--max-attempts", "nope"], { + env: { GITHUB_TOKEN: "github-token" }, + }), + ).resolves.toBe(2); + expect(error).toHaveBeenCalledWith( + "Invalid value for --max-attempts: must be a positive integer.", + ); + }); + + it("runCiWait exits 1 when CI fails", async () => { + vi.spyOn(ciPoller, "pollCheckRuns").mockResolvedValue({ + conclusion: "failure", + checks: [], + headSha: "deadbeef", + attempts: 2, + }); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + await expect( + runCiWait(["acme/widgets", "7"], { + env: { GITTENSOR_MINER_GITHUB_TOKEN: "github-token" }, + }), + ).resolves.toBe(1); + expect(error).toHaveBeenCalledWith( + expect.stringContaining("CI failure for acme/widgets#7"), + ); + }); +}); diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index d502bac0c3..1739da9178 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -64,6 +64,7 @@ describe("gittensory-miner CLI helpers", () => { const text = log.mock.calls[0]?.[0]; expect(text).toContain("gittensory-miner --help"); expect(text).toContain("gittensory-miner version"); + expect(text).toContain("gittensory-miner ci wait"); expect(text).toContain("--no-update-check"); });