diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 9e5251de8c..975a3ccd12 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -2,6 +2,7 @@ import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; import { runDenyCheck } from "../lib/deny-check.js"; +import { runManagePoll } from "../lib/manage-poll.js"; import { runManageStatus } from "../lib/manage-status.js"; import { runQueueCli } from "../lib/portfolio-queue-cli.js"; import { runStateCli } from "../lib/run-state-cli.js"; @@ -78,6 +79,12 @@ if (cliArgs[0] === "state") { process.exit(exitCode); } +if (cliArgs[0] === "manage" && cliArgs[1] === "poll") { + const exitCode = await runManagePoll(cliArgs.slice(2)); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(exitCode); +} + const exitCode = runCli(cliArgs, { packageName }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 0b1744bf52..b626c12754 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -17,6 +17,7 @@ export function printHelp(input) { " gittensory-miner status [--json] Show installed versions + local state paths", " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger", + " gittensory-miner manage poll [--branch ] [--json]", " gittensory-miner queue list [--repo ] [--json] List portfolio backlog rows", " gittensory-miner queue next [--json] Claim the highest-priority queued item", " gittensory-miner queue done [--json]", diff --git a/packages/gittensory-miner/lib/manage-poll.d.ts b/packages/gittensory-miner/lib/manage-poll.d.ts new file mode 100644 index 0000000000..027b37e24d --- /dev/null +++ b/packages/gittensory-miner/lib/manage-poll.d.ts @@ -0,0 +1,78 @@ +import type { PollCheckRunsOptions, PollCheckRunsResult } from "./ci-poller.js"; +import type { EventLedger, LedgerEntry } from "./event-ledger.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; + +export type ManagePollInput = { + repoFullName: string; + prNumber: number; + branch?: string | null; +}; + +export type ManagePollEventPayload = { + prNumber: number; + branch: string | null; + ciState: PollCheckRunsResult["conclusion"]; + gateVerdict: string; + outcome: string; + lastPolledAt: string; +}; + +export type ManagePollRecordResult = { + pollResult: PollCheckRunsResult; + payload: ManagePollEventPayload; + event: LedgerEntry; +}; + +export type ParsedManagePollArgs = + | { + repoFullName: string; + prNumber: number; + branch: string | null; + json: boolean; + } + | { error: string }; + +export function mapPollConclusionToGateVerdict( + conclusion: PollCheckRunsResult["conclusion"], +): string; + +export function mapPollConclusionToOutcome(conclusion: PollCheckRunsResult["conclusion"]): string; + +export function buildManagePollEventPayload( + prNumber: number, + pollResult: PollCheckRunsResult, + options?: { branch?: string | null; lastPolledAt?: string }, +): ManagePollEventPayload; + +export function parseManagePollArgs(args?: string[]): ParsedManagePollArgs; + +export function recordManagePollSnapshot( + input: ManagePollInput, + options: { + eventLedger: EventLedger; + portfolioQueue?: PortfolioQueueStore; + ensurePortfolioRow?: boolean; + pollCheckRuns?: ( + repoFullName: string, + prNumber: number, + options?: PollCheckRunsOptions, + ) => Promise; + lastPolledAt?: string; + } & PollCheckRunsOptions, +): Promise; + +export function runManagePoll( + args?: string[], + options?: { + initEventLedger?: () => EventLedger; + initPortfolioQueue?: () => PortfolioQueueStore; + ensurePortfolioRow?: boolean; + pollCheckRuns?: ( + repoFullName: string, + prNumber: number, + options?: PollCheckRunsOptions, + ) => Promise; + githubToken?: string; + lastPolledAt?: string; + } & PollCheckRunsOptions, +): Promise; diff --git a/packages/gittensory-miner/lib/manage-poll.js b/packages/gittensory-miner/lib/manage-poll.js new file mode 100644 index 0000000000..d44c086c8a --- /dev/null +++ b/packages/gittensory-miner/lib/manage-poll.js @@ -0,0 +1,210 @@ +import { pollCheckRuns } from "./ci-poller.js"; +import { initEventLedger } from "./event-ledger.js"; +import { + MANAGE_PR_UPDATE_EVENT, + formatManagedPrIdentifier, +} from "./manage-status.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; + +const MANAGE_POLL_USAGE = + "Usage: gittensory-miner manage poll [--branch ] [--json]"; + +function parseRepoArg(value, usage) { + if (!value) return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; +} + +export function mapPollConclusionToGateVerdict(conclusion) { + switch (conclusion) { + case "success": + return "pass"; + case "failure": + return "block"; + default: + return "advisory"; + } +} + +export function mapPollConclusionToOutcome(conclusion) { + switch (conclusion) { + case "success": + return "ready"; + case "failure": + return "needs-work"; + default: + return "open"; + } +} + +export function buildManagePollEventPayload(prNumber, pollResult, options = {}) { + if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("invalid_pr_number"); + if (!pollResult || typeof pollResult !== "object") throw new Error("invalid_poll_result"); + const branch = typeof options.branch === "string" && options.branch.trim() ? options.branch.trim() : null; + const lastPolledAt = + typeof options.lastPolledAt === "string" && options.lastPolledAt.trim() + ? options.lastPolledAt.trim() + : new Date().toISOString(); + return { + prNumber, + branch, + ciState: pollResult.conclusion, + gateVerdict: mapPollConclusionToGateVerdict(pollResult.conclusion), + outcome: mapPollConclusionToOutcome(pollResult.conclusion), + lastPolledAt, + }; +} + +export function parseManagePollArgs(args = []) { + const options = { json: false, branch: null }; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--branch") { + const branch = args[index + 1]; + if (!branch || branch.startsWith("-")) return { error: MANAGE_POLL_USAGE }; + options.branch = branch; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length !== 2) return { error: MANAGE_POLL_USAGE }; + + const repo = parseRepoArg(positional[0], MANAGE_POLL_USAGE); + if ("error" in repo) return repo; + + const prNumber = Number(positional[1]); + if (!Number.isInteger(prNumber) || prNumber <= 0) { + return { error: "Pull request number must be a positive integer." }; + } + + return { + repoFullName: repo.repoFullName, + prNumber, + ...options, + }; +} + +function ensureManagedPrRow(portfolioQueue, repoFullName, prNumber) { + const identifier = formatManagedPrIdentifier(prNumber); + const exists = portfolioQueue + .listQueue(repoFullName) + .some((entry) => entry.identifier === identifier); + if (!exists) { + portfolioQueue.enqueue({ repoFullName, identifier, priority: 0 }); + } +} + +/** + * Poll GitHub check runs for a managed PR and append a `manage_pr_update` snapshot to the local event ledger. + * Completes the manage-status data path introduced in #2325 / #3070 using the CI poller from #2323. + */ +export async function recordManagePollSnapshot(input, options = {}) { + if (!input || typeof input !== "object") throw new Error("invalid_manage_poll_input"); + const repoFullName = typeof input.repoFullName === "string" ? input.repoFullName.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + if (!Number.isInteger(input.prNumber) || input.prNumber <= 0) throw new Error("invalid_pr_number"); + + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") { + throw new Error("invalid_event_ledger"); + } + + const portfolioQueue = options.portfolioQueue; + if (options.portfolioQueue !== undefined) { + if (!portfolioQueue || typeof portfolioQueue.enqueue !== "function") { + throw new Error("invalid_portfolio_queue"); + } + } + + const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; + const pollResult = await pollCheckRunsFn(repoFullName, input.prNumber, { + apiBaseUrl: options.apiBaseUrl, + fetchFn: options.fetchFn, + githubToken: options.githubToken ?? "", + maxAttempts: options.maxAttempts, + minIntervalMs: options.minIntervalMs, + maxIntervalMs: options.maxIntervalMs, + sleepFn: options.sleepFn, + }); + + const payload = buildManagePollEventPayload(input.prNumber, pollResult, { + branch: input.branch, + lastPolledAt: options.lastPolledAt, + }); + + if ((options.ensurePortfolioRow ?? true) && portfolioQueue) { + ensureManagedPrRow(portfolioQueue, repoFullName, input.prNumber); + } + + const event = eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName, + payload, + }); + + return { pollResult, payload, event }; +} + +export async function runManagePoll(args = [], options = {}) { + const parsed = parseManagePollArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + const ownsEventLedger = options.initEventLedger === undefined; + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + + try { + const result = await recordManagePollSnapshot( + { + repoFullName: parsed.repoFullName, + prNumber: parsed.prNumber, + branch: parsed.branch, + }, + { + eventLedger, + portfolioQueue, + ensurePortfolioRow: options.ensurePortfolioRow ?? true, + 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, + }, + ); + + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`${result.payload.ciState} (${result.payload.gateVerdict}/${result.payload.outcome})`); + } + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 2; + } finally { + if (ownsEventLedger) eventLedger.close(); + if (ownsPortfolioQueue) portfolioQueue.close(); + } +} diff --git a/packages/gittensory-miner/lib/manage-status.js b/packages/gittensory-miner/lib/manage-status.js index 0e16e7da7d..e581f26067 100644 --- a/packages/gittensory-miner/lib/manage-status.js +++ b/packages/gittensory-miner/lib/manage-status.js @@ -1,7 +1,7 @@ import { initEventLedger } from "./event-ledger.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; -/** Event vocabulary for manage-phase PR snapshots written by future CI pollers. (#2325) */ +/** Event vocabulary for manage-phase PR snapshots written by manage poll. (#2325) */ export const MANAGE_PR_UPDATE_EVENT = "manage_pr_update"; export const MANAGED_PR_IDENTIFIER_PREFIX = "pr:"; diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 9244c695dc..537dc0e7d6 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/status.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-manage-poll.test.ts b/test/unit/miner-manage-poll.test.ts new file mode 100644 index 0000000000..7c7a27ef1d --- /dev/null +++ b/test/unit/miner-manage-poll.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeDefaultEventLedger, + initEventLedger, +} from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { + MANAGE_PR_UPDATE_EVENT, + collectManageStatus, +} from "../../packages/gittensory-miner/lib/manage-status.js"; +import { + buildManagePollEventPayload, + mapPollConclusionToGateVerdict, + mapPollConclusionToOutcome, + parseManagePollArgs, + recordManagePollSnapshot, + runManagePoll, +} from "../../packages/gittensory-miner/lib/manage-poll.js"; +import type { PollCheckRunsResult } from "../../packages/gittensory-miner/lib/ci-poller.d.ts"; +import { + closeDefaultPortfolioQueueStore, + initPortfolioQueueStore, +} from "../../packages/gittensory-miner/lib/portfolio-queue.js"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempStores() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-manage-poll-")); + roots.push(root); + const portfolioQueue = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + const eventLedger = initEventLedger(join(root, "event-ledger.sqlite3")); + stores.push(portfolioQueue, eventLedger); + return { portfolioQueue, eventLedger }; +} + +function pollResult(conclusion: PollCheckRunsResult["conclusion"]): PollCheckRunsResult { + return { + conclusion, + checks: [], + headSha: "abc123", + attempts: 1, + }; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPortfolioQueueStore(); + closeDefaultEventLedger(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner manage poll (#2323/#2325)", () => { + it("parseManagePollArgs validates argv", () => { + expect(parseManagePollArgs([])).toEqual({ + error: expect.stringContaining("Usage: gittensory-miner manage poll"), + }); + expect(parseManagePollArgs(["acme/widgets", "42", "--branch", "feat/x", "--json"])).toEqual({ + repoFullName: "acme/widgets", + prNumber: 42, + branch: "feat/x", + json: true, + }); + expect(parseManagePollArgs(["acme/widgets", "0"])).toEqual({ + error: "Pull request number must be a positive integer.", + }); + }); + + it("maps poll conclusions to manage snapshot fields", () => { + expect(mapPollConclusionToGateVerdict("success")).toBe("pass"); + expect(mapPollConclusionToGateVerdict("failure")).toBe("block"); + expect(mapPollConclusionToGateVerdict("pending")).toBe("advisory"); + expect(mapPollConclusionToOutcome("success")).toBe("ready"); + expect(mapPollConclusionToOutcome("failure")).toBe("needs-work"); + expect(mapPollConclusionToOutcome("neutral")).toBe("open"); + expect( + buildManagePollEventPayload(7, pollResult("success"), { + branch: "feat/x", + lastPolledAt: "2026-07-04T12:00:00.000Z", + }), + ).toEqual({ + prNumber: 7, + branch: "feat/x", + ciState: "success", + gateVerdict: "pass", + outcome: "ready", + lastPolledAt: "2026-07-04T12:00:00.000Z", + }); + }); + + it("recordManagePollSnapshot appends manage_pr_update and ensures a portfolio row", async () => { + const { portfolioQueue, eventLedger } = tempStores(); + const pollCheckRuns = vi.fn().mockResolvedValue(pollResult("failure")); + + const result = await recordManagePollSnapshot( + { repoFullName: "acme/widgets", prNumber: 12, branch: "fix/ci" }, + { + eventLedger, + portfolioQueue, + pollCheckRuns, + githubToken: "token", + lastPolledAt: "2026-07-04T12:05:00.000Z", + }, + ); + + expect(pollCheckRuns).toHaveBeenCalledWith("acme/widgets", 12, expect.objectContaining({ githubToken: "token" })); + expect(result.payload).toEqual({ + prNumber: 12, + branch: "fix/ci", + ciState: "failure", + gateVerdict: "block", + outcome: "needs-work", + lastPolledAt: "2026-07-04T12:05:00.000Z", + }); + expect(eventLedger.readEvents()).toEqual([ + expect.objectContaining({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: result.payload, + }), + ]); + expect(portfolioQueue.listQueue("acme/widgets")).toEqual([ + expect.objectContaining({ identifier: "pr:12", status: "queued", priority: 0 }), + ]); + expect(collectManageStatus({ portfolioQueue, eventLedger })).toEqual([ + expect.objectContaining({ + repoFullName: "acme/widgets", + prNumber: 12, + ciState: "failure", + gateVerdict: "block", + outcome: "needs-work", + queueStatus: "queued", + }), + ]); + }); + + it("runManagePoll prints summary and JSON output with injected stores", async () => { + const { portfolioQueue, eventLedger } = tempStores(); + const pollCheckRuns = vi.fn().mockResolvedValue(pollResult("success")); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + expect( + await runManagePoll(["acme/widgets", "4", "--branch", "feat/x"], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + pollCheckRuns, + lastPolledAt: "2026-07-04T12:10:00.000Z", + }), + ).toBe(0); + expect(log).toHaveBeenCalledWith("success (pass/ready)"); + + log.mockClear(); + expect( + await runManagePoll(["acme/widgets", "4", "--json"], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + pollCheckRuns, + lastPolledAt: "2026-07-04T12:10:00.000Z", + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ prNumber: 4, ciState: "success" }), + }), + ); + }); + + it("rejects invalid stores and poll failures", async () => { + const { eventLedger } = tempStores(); + await expect( + recordManagePollSnapshot( + { repoFullName: "acme/widgets", prNumber: 1 }, + { eventLedger: null as never, pollCheckRuns: vi.fn() }, + ), + ).rejects.toThrow("invalid_event_ledger"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { portfolioQueue } = tempStores(); + expect( + await runManagePoll(["acme/widgets", "9"], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + pollCheckRuns: vi.fn().mockRejectedValue(new Error("github_404: not found")), + }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("github_404: not found"); + }); +});