diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index cf09f37871..cf674af677 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 { runManageStatus } from "../lib/manage-status.js"; import { runStateCli } from "../lib/run-state-cli.js"; import { runDoctor, runStatus } from "../lib/status.js"; import { @@ -23,6 +24,10 @@ if (cliArgs[0] === "doctor") { process.exit(runDoctor(cliArgs.slice(1))); } +if (cliArgs[0] === "manage" && cliArgs[1] === "status") { + process.exit(runManageStatus(cliArgs.slice(2))); +} + const require = createRequire(import.meta.url); const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 4769680e8d..e2fa23ae3c 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -16,6 +16,7 @@ export function printHelp(input) { " gittensory-miner version", " 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 hooks check --tool --input [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--json]", diff --git a/packages/gittensory-miner/lib/manage-status.d.ts b/packages/gittensory-miner/lib/manage-status.d.ts new file mode 100644 index 0000000000..0b55e31343 --- /dev/null +++ b/packages/gittensory-miner/lib/manage-status.d.ts @@ -0,0 +1,52 @@ +import type { EventLedger, LedgerEntry } from "./event-ledger.js"; +import type { PortfolioQueueStore, QueueStatus } from "./portfolio-queue.js"; + +export type ManageStatusRow = { + repoFullName: string; + prNumber: number; + branch: string | null; + ciState: string | null; + gateVerdict: string | null; + outcome: string | null; + lastPolledAt: string | null; + queueStatus: QueueStatus | null; + priority: number | null; +}; + +export type ManageStatusSources = { + portfolioQueue: PortfolioQueueStore; + eventLedger: EventLedger; +}; + +export type ManageUpdateSnapshot = { + repoFullName: string; + prNumber: number; + branch: string | null; + ciState: string | null; + gateVerdict: string | null; + outcome: string | null; + lastPolledAt: string | null; +}; + +export const MANAGE_PR_UPDATE_EVENT: "manage_pr_update"; +export const MANAGED_PR_IDENTIFIER_PREFIX: "pr:"; + +export function parseManagedPrIdentifier(identifier: string): number | null; + +export function formatManagedPrIdentifier(prNumber: number): string; + +export function indexLatestManageUpdates(events: LedgerEntry[]): Map; + +export function collectManageStatus(sources: ManageStatusSources): ManageStatusRow[]; + +export function renderManageStatusTable(rows: ManageStatusRow[]): string; + +export function parseManageStatusArgs(args?: string[]): { json: boolean } | { error: string }; + +export function runManageStatus( + args?: string[], + options?: { + initPortfolioQueue?: () => PortfolioQueueStore; + initEventLedger?: () => EventLedger; + }, +): number; diff --git a/packages/gittensory-miner/lib/manage-status.js b/packages/gittensory-miner/lib/manage-status.js new file mode 100644 index 0000000000..0e16e7da7d --- /dev/null +++ b/packages/gittensory-miner/lib/manage-status.js @@ -0,0 +1,175 @@ +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) */ +export const MANAGE_PR_UPDATE_EVENT = "manage_pr_update"; +export const MANAGED_PR_IDENTIFIER_PREFIX = "pr:"; + +export function parseManagedPrIdentifier(identifier) { + if (typeof identifier !== "string") return null; + const match = identifier.match(/^pr:(\d+)$/); + if (!match) return null; + const prNumber = Number(match[1]); + return Number.isInteger(prNumber) && prNumber > 0 ? prNumber : null; +} + +export function formatManagedPrIdentifier(prNumber) { + if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("invalid_pr_number"); + return `${MANAGED_PR_IDENTIFIER_PREFIX}${prNumber}`; +} + +function optionalString(value) { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function normalizeManageUpdatePayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null; + return { + prNumber: payload.prNumber, + branch: optionalString(payload.branch), + ciState: optionalString(payload.ciState), + gateVerdict: optionalString(payload.gateVerdict), + outcome: optionalString(payload.outcome), + lastPolledAt: optionalString(payload.lastPolledAt), + }; +} + +/** Index the latest manage snapshot per repo/PR from ascending ledger events. Pure. */ +export function indexLatestManageUpdates(events) { + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (event?.type !== MANAGE_PR_UPDATE_EVENT) continue; + if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue; + const normalized = normalizeManageUpdatePayload(event.payload); + if (!normalized) continue; + const key = `${event.repoFullName}:${normalized.prNumber}`; + latest.set(key, { ...normalized, repoFullName: event.repoFullName }); + } + return latest; +} + +/** + * Aggregate managed PR rows from the local portfolio queue and append-only event ledger. Read-only — never calls + * GitHub or mutates local stores. (#2325) + */ +export function collectManageStatus(sources) { + const portfolioQueue = sources?.portfolioQueue; + const eventLedger = sources?.eventLedger; + if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") { + throw new Error("invalid_portfolio_queue"); + } + if (!eventLedger || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + + const rowsByKey = new Map(); + for (const entry of portfolioQueue.listQueue(null)) { + const prNumber = parseManagedPrIdentifier(entry.identifier); + if (prNumber === null) continue; + const key = `${entry.repoFullName}:${prNumber}`; + rowsByKey.set(key, { + repoFullName: entry.repoFullName, + prNumber, + branch: null, + ciState: null, + gateVerdict: null, + outcome: null, + lastPolledAt: null, + queueStatus: entry.status, + priority: entry.priority, + }); + } + + for (const [key, update] of indexLatestManageUpdates(eventLedger.readEvents())) { + const existing = rowsByKey.get(key); + rowsByKey.set(key, { + repoFullName: update.repoFullName, + prNumber: update.prNumber, + branch: update.branch, + ciState: update.ciState, + gateVerdict: update.gateVerdict, + outcome: update.outcome, + lastPolledAt: update.lastPolledAt, + queueStatus: existing?.queueStatus ?? null, + priority: existing?.priority ?? null, + }); + } + + return [...rowsByKey.values()].sort((left, right) => { + const repoCmp = left.repoFullName.localeCompare(right.repoFullName); + if (repoCmp !== 0) return repoCmp; + return left.prNumber - right.prNumber; + }); +} + +function display(value) { + if (value === null || value === undefined) return "-"; + return String(value); +} + +export function renderManageStatusTable(rows) { + if (!Array.isArray(rows) || rows.length === 0) return "no managed pull requests"; + const header = [ + "repo".padEnd(24), + "pr".padStart(4), + "branch".padEnd(16), + "ci".padEnd(10), + "gate".padEnd(10), + "outcome".padEnd(10), + "last-polled".padEnd(20), + "queue".padEnd(12), + "pri".padStart(4), + ].join(" "); + const lines = rows.map((row) => + [ + row.repoFullName.padEnd(24), + String(row.prNumber).padStart(4), + display(row.branch).padEnd(16), + display(row.ciState).padEnd(10), + display(row.gateVerdict).padEnd(10), + display(row.outcome).padEnd(10), + display(row.lastPolledAt).padEnd(20), + display(row.queueStatus).padEnd(12), + display(row.priority).padStart(4), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + +export function parseManageStatusArgs(args = []) { + for (const token of args) { + if (token === "--json") continue; + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + return { error: "Usage: gittensory-miner manage status [--json]" }; + } + return { json: args.includes("--json") }; +} + +export function runManageStatus(args = [], options = {}) { + const parsed = parseManageStatusArgs(args); + if ("error" in parsed) { + console.error(parsed.error); + return 2; + } + + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + const ownsEventLedger = options.initEventLedger === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + try { + const rows = collectManageStatus({ portfolioQueue, eventLedger }); + if (parsed.json) { + console.log(JSON.stringify({ rows }, null, 2)); + } else { + console.log(renderManageStatusTable(rows)); + } + return 0; + } finally { + if (ownsPortfolioQueue) portfolioQueue.close(); + if (ownsEventLedger) eventLedger.close(); + } +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 2f5c90b3f8..f2aa9fe4d8 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/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.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/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" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-manage-status.test.ts b/test/unit/miner-manage-status.test.ts new file mode 100644 index 0000000000..79ec461295 --- /dev/null +++ b/test/unit/miner-manage-status.test.ts @@ -0,0 +1,206 @@ +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 { + MANAGE_PR_UPDATE_EVENT, + collectManageStatus, + formatManagedPrIdentifier, + indexLatestManageUpdates, + parseManagedPrIdentifier, + renderManageStatusTable, + runManageStatus, + type ManageStatusRow, +} from "../../packages/gittensory-miner/lib/manage-status.js"; +import { + closeDefaultEventLedger, + initEventLedger, +} from "../../packages/gittensory-miner/lib/event-ledger.js"; +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-status-")); + 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 }; +} + +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 status (#2325)", () => { + it("parses and formats managed PR identifiers", () => { + expect(parseManagedPrIdentifier("pr:42")).toBe(42); + expect(parseManagedPrIdentifier("issue:42")).toBeNull(); + expect(formatManagedPrIdentifier(42)).toBe("pr:42"); + expect(() => formatManagedPrIdentifier(0)).toThrow("invalid_pr_number"); + }); + + it("returns an empty snapshot for an empty portfolio and ledger", () => { + const { portfolioQueue, eventLedger } = tempStores(); + expect(collectManageStatus({ portfolioQueue, eventLedger })).toEqual([]); + expect(renderManageStatusTable([])).toBe("no managed pull requests"); + }); + + it("merges portfolio queue rows with the latest manage_pr_update event per PR", () => { + const { portfolioQueue, eventLedger } = tempStores(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:12", priority: 3 }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:99", priority: 1 }); + eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: { + prNumber: 12, + branch: "feat/a", + ciState: "pending", + gateVerdict: "advisory", + outcome: "open", + lastPolledAt: "2026-07-04T10:00:00.000Z", + }, + }); + eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: { + prNumber: 12, + branch: "feat/a", + ciState: "success", + gateVerdict: "pass", + outcome: "ready", + lastPolledAt: "2026-07-04T11:00:00.000Z", + }, + }); + eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/other", + payload: { + prNumber: 7, + branch: "fix/b", + ciState: "failure", + gateVerdict: "block", + outcome: "needs-work", + lastPolledAt: "2026-07-04T11:05:00.000Z", + }, + }); + + expect(collectManageStatus({ portfolioQueue, eventLedger })).toEqual([ + { + repoFullName: "acme/other", + prNumber: 7, + branch: "fix/b", + ciState: "failure", + gateVerdict: "block", + outcome: "needs-work", + lastPolledAt: "2026-07-04T11:05:00.000Z", + queueStatus: null, + priority: null, + }, + { + repoFullName: "acme/widgets", + prNumber: 12, + branch: "feat/a", + ciState: "success", + gateVerdict: "pass", + outcome: "ready", + lastPolledAt: "2026-07-04T11:00:00.000Z", + queueStatus: "queued", + priority: 3, + }, + ]); + }); + + it("ignores malformed manage_pr_update payloads when indexing events", () => { + const { eventLedger } = tempStores(); + eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: { prNumber: 0, branch: "bad" }, + }); + expect(indexLatestManageUpdates(eventLedger.readEvents()).size).toBe(0); + }); + + it("renders numeric queue priority in the table output", () => { + const rows: ManageStatusRow[] = [ + { + repoFullName: "acme/widgets", + prNumber: 4, + branch: "feat/x", + ciState: "success", + gateVerdict: "pass", + outcome: "ready", + lastPolledAt: "2026-07-04T12:00:00.000Z", + queueStatus: "queued", + priority: 2, + }, + ]; + expect(renderManageStatusTable(rows)).toContain(" 2"); + }); + + it("runManageStatus prints table and JSON output", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-manage-status-cli-")); + roots.push(root); + const portfolioQueue = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + const eventLedger = initEventLedger(join(root, "event-ledger.sqlite3")); + stores.push(portfolioQueue, eventLedger); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:4", priority: 2 }); + eventLedger.appendEvent({ + type: MANAGE_PR_UPDATE_EVENT, + repoFullName: "acme/widgets", + payload: { + prNumber: 4, + branch: "feat/x", + ciState: "success", + gateVerdict: "pass", + outcome: "ready", + lastPolledAt: "2026-07-04T12:00:00.000Z", + }, + }); + + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect( + runManageStatus([], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + }), + ).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("acme/widgets"); + expect(String(log.mock.calls[0]?.[0])).toContain("success"); + + log.mockClear(); + expect( + runManageStatus(["--json"], { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + rows: [ + expect.objectContaining({ + repoFullName: "acme/widgets", + prNumber: 4, + ciState: "success", + queueStatus: "queued", + }), + ], + }); + }); + + it("rejects unknown CLI options", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(runManageStatus(["--verbose"])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown option"); + }); +});