diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 8e38bfba8d..ce2c2b4a6e 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -28,6 +28,10 @@ The package also includes an append-only governor decision ledger: `initGovernor persist structured allow/deny/throttle/kill-switch outcomes in local SQLite for contributor audit. Insert-only — no enforcement wiring yet. (#2328) +The package also includes a manage-phase status renderer: `gittensory-miner manage status` reads the local portfolio +queue (`pr:` identifiers) plus `manage_pr_update` rows from the event ledger and prints CI/gate/outcome +columns. Pass `--json` for machine-readable output. Read-only — no network and no writes. (#2325) + ## Install From a local checkout: diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index cf09f37871..80771fcf88 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -68,6 +68,24 @@ if (cliArgs[0] === "state") { process.exit(exitCode); } +if (cliArgs[0] === "manage" && cliArgs[1] === "status") { + const { listQueue } = await import("../lib/portfolio-queue.js"); + const { readEvents } = await import("../lib/event-ledger.js"); + const { parseManageStatusArgs, runManageStatus } = await import("../lib/manage-status.js"); + try { + const options = parseManageStatusArgs(cliArgs.slice(2)); + const result = runManageStatus({ listQueue, readEvents }, options); + process.stdout.write(result.output); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(result.exitCode); + } catch (error) { + const message = error instanceof Error ? error.message : "manage_status_failed"; + console.error(`manage status failed: ${message}`); + await awaitOpportunisticUpdateCheck(updateCheck); + process.exit(1); + } +} + 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 4769680e8d..cff313c171 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 portfolio + CI/gate status", " 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..5bd0a76714 --- /dev/null +++ b/packages/gittensory-miner/lib/manage-status.d.ts @@ -0,0 +1,35 @@ +export declare const MANAGE_STATUS_EVENT_TYPE: "manage_pr_update"; + +export type ManageStatusRow = { + repoFullName: string; + pullNumber: number; + branch: string | null; + ciState: string | null; + gateVerdict: string | null; + outcome: string | null; + lastPolledAt: string | null; + portfolioStatus: string | null; +}; + +export type ManageStatusReaders = { + listQueue(): ReadonlyArray<{ + repoFullName: string; + identifier: string; + status: string; + }>; + readEvents(): ReadonlyArray<{ + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }>; +}; + +export function buildManageStatusSnapshot(readers: ManageStatusReaders): ManageStatusRow[]; +export function formatManageStatusJson(rows: ManageStatusRow[]): string; +export function formatManageStatusTable(rows: ManageStatusRow[]): string; +export function parseManageStatusArgs(cliArgs: string[]): { json: boolean }; +export function runManageStatus( + readers: ManageStatusReaders, + options?: { json?: boolean }, +): { rows: ManageStatusRow[]; output: string; exitCode: 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..57d0d9520a --- /dev/null +++ b/packages/gittensory-miner/lib/manage-status.js @@ -0,0 +1,150 @@ +/** Manage-phase event vocabulary — later phases append these to the local event ledger (#2325). */ +export const MANAGE_STATUS_EVENT_TYPE = "manage_pr_update"; + +const portfolioPrIdentifierPattern = /^pr:(\d+)$/; + +function parsePortfolioPullNumber(identifier) { + if (typeof identifier !== "string") return null; + const match = portfolioPrIdentifierPattern.exec(identifier.trim()); + if (!match) return null; + const pullNumber = Number(match[1]); + return Number.isInteger(pullNumber) && pullNumber > 0 ? pullNumber : null; +} + +function rowKey(repoFullName, pullNumber) { + return `${repoFullName}#${pullNumber}`; +} + +function normalizeOptionalString(value) { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizePullNumber(value) { + if (!Number.isInteger(value) || value <= 0) return null; + return value; +} + +function mergeManageFields(target, payload, eventCreatedAt) { + const pullNumber = normalizePullNumber(payload?.pullNumber); + if (pullNumber !== null) target.pullNumber = pullNumber; + const branch = normalizeOptionalString(payload?.branch); + if (branch !== null) target.branch = branch; + const ciState = normalizeOptionalString(payload?.ciState); + if (ciState !== null) target.ciState = ciState; + const gateVerdict = normalizeOptionalString(payload?.gateVerdict); + if (gateVerdict !== null) target.gateVerdict = gateVerdict; + const outcome = normalizeOptionalString(payload?.outcome); + if (outcome !== null) target.outcome = outcome; + const lastPolledAt = + normalizeOptionalString(payload?.lastPolledAt) ?? normalizeOptionalString(eventCreatedAt); + if (lastPolledAt !== null) target.lastPolledAt = lastPolledAt; +} + +/** + * Aggregate manage-phase rows from the portfolio queue and append-only event ledger. Pure read/render input — + * no network calls and no writes (#2325). + */ +export function buildManageStatusSnapshot(readers) { + const rows = new Map(); + + for (const item of readers.listQueue()) { + if (item.status === "done") continue; + const pullNumber = parsePortfolioPullNumber(item.identifier); + if (pullNumber === null) continue; + const key = rowKey(item.repoFullName, pullNumber); + rows.set(key, { + repoFullName: item.repoFullName, + pullNumber, + branch: null, + ciState: item.status === "in_progress" ? "unknown" : "unknown", + gateVerdict: null, + outcome: null, + lastPolledAt: null, + portfolioStatus: item.status, + }); + } + + for (const event of readers.readEvents()) { + if (event.type !== MANAGE_STATUS_EVENT_TYPE) continue; + if (!event.repoFullName) continue; + const payload = event.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) continue; + const pullNumber = normalizePullNumber(payload.pullNumber); + if (pullNumber === null) continue; + const key = rowKey(event.repoFullName, pullNumber); + const existing = rows.get(key); + if (!existing) continue; + mergeManageFields(existing, payload, event.createdAt); + } + + return [...rows.values()].sort((left, right) => { + const repoCompare = left.repoFullName + .toLowerCase() + .localeCompare(right.repoFullName.toLowerCase(), "en"); + if (repoCompare !== 0) return repoCompare; + return left.pullNumber - right.pullNumber; + }); +} + +export function formatManageStatusJson(rows) { + return `${JSON.stringify({ rows }, null, 2)}\n`; +} + +function pad(value, width) { + const text = String(value ?? ""); + return text.length >= width ? text : `${text}${" ".repeat(width - text.length)}`; +} + +export function formatManageStatusTable(rows) { + if (rows.length === 0) { + return "No managed pull requests in the local portfolio.\n"; + } + const headers = [ + "repo", + "pr", + "branch", + "ci", + "gate", + "outcome", + "last_polled_at", + ]; + const widths = [28, 6, 24, 10, 10, 10, 24]; + const lines = [ + headers.map((header, index) => pad(header, widths[index])).join(" "), + widths.map((width) => "-".repeat(width)).join(" "), + ]; + for (const row of rows) { + lines.push( + [ + pad(row.repoFullName, widths[0]), + pad(row.pullNumber, widths[1]), + pad(row.branch ?? "-", widths[2]), + pad(row.ciState ?? "unknown", widths[3]), + pad(row.gateVerdict ?? "-", widths[4]), + pad(row.outcome ?? "-", widths[5]), + pad(row.lastPolledAt ?? "-", widths[6]), + ].join(" "), + ); + } + return `${lines.join("\n")}\n`; +} + +const globalCliFlags = new Set(["--json", "--no-update-check"]); + +export function parseManageStatusArgs(cliArgs) { + const json = cliArgs.includes("--json"); + const positional = cliArgs.filter((arg) => !globalCliFlags.has(arg)); + if (positional.length > 0) { + throw new Error(`unexpected_arguments:${positional.join(",")}`); + } + return { json }; +} + +export function runManageStatus(readers, options = {}) { + const rows = buildManageStatusSnapshot(readers); + const output = options.json ? formatManageStatusJson(rows) : formatManageStatusTable(rows); + return { rows, output, exitCode: 0 }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index a4003e613d..9bddeee9b9 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/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/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 && node --check lib/manage-status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index d502bac0c3..aa9247eaf0 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 status"); expect(text).toContain("--no-update-check"); }); diff --git a/test/unit/miner-manage-status.test.ts b/test/unit/miner-manage-status.test.ts new file mode 100644 index 0000000000..c423ad051f --- /dev/null +++ b/test/unit/miner-manage-status.test.ts @@ -0,0 +1,197 @@ +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_STATUS_EVENT_TYPE, + buildManageStatusSnapshot, + formatManageStatusJson, + formatManageStatusTable, + parseManageStatusArgs, + runManageStatus, +} 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"; +import { runCapture } from "./support/miner-cli-harness"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempStores() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-manage-status-")); + roots.push(root); + const portfolio = initPortfolioQueueStore(join(root, "portfolio-queue.sqlite3")); + const ledger = initEventLedger(join(root, "event-ledger.sqlite3")); + stores.push(portfolio, ledger); + return { portfolio, ledger }; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPortfolioQueueStore(); + closeDefaultEventLedger(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("manage status snapshot (#2325)", () => { + it("exposes the manage event type constant", () => { + expect(MANAGE_STATUS_EVENT_TYPE).toBe("manage_pr_update"); + }); + + it("returns an empty snapshot when the portfolio has no PR rows", () => { + const { portfolio, ledger } = tempStores(); + expect( + buildManageStatusSnapshot({ + listQueue: () => portfolio.listQueue(), + readEvents: () => ledger.readEvents(), + }), + ).toEqual([]); + expect(formatManageStatusTable([])).toContain("No managed pull requests"); + }); + + it("renders portfolio PR rows and merges latest manage_pr_update fields", () => { + const { portfolio, ledger } = tempStores(); + portfolio.enqueue({ repoFullName: "acme/widgets", identifier: "pr:42", priority: 2 }); + portfolio.dequeueNext(); + portfolio.enqueue({ repoFullName: "acme/widgets", identifier: "issue:99", priority: 1 }); + ledger.appendEvent({ + type: MANAGE_STATUS_EVENT_TYPE, + repoFullName: "acme/widgets", + payload: { + pullNumber: 42, + branch: "feat/manage-status", + ciState: "pending", + gateVerdict: "hold", + outcome: "open", + lastPolledAt: "2026-07-03T12:00:00.000Z", + }, + }); + ledger.appendEvent({ + type: MANAGE_STATUS_EVENT_TYPE, + repoFullName: "acme/widgets", + payload: { + pullNumber: 42, + ciState: "success", + gateVerdict: "merge", + lastPolledAt: "2026-07-03T13:00:00.000Z", + }, + }); + ledger.appendEvent({ + type: MANAGE_STATUS_EVENT_TYPE, + repoFullName: "JSONbored/gittensory", + payload: { + pullNumber: 7, + branch: "feat/other", + ciState: "failure", + gateVerdict: "close", + outcome: "closed", + lastPolledAt: "2026-07-03T14:00:00.000Z", + }, + }); + + const rows = buildManageStatusSnapshot({ + listQueue: () => portfolio.listQueue(), + readEvents: () => ledger.readEvents(), + }); + expect(rows).toHaveLength(1); + expect(rows).toContainEqual({ + repoFullName: "acme/widgets", + pullNumber: 42, + branch: "feat/manage-status", + ciState: "success", + gateVerdict: "merge", + outcome: "open", + lastPolledAt: "2026-07-03T13:00:00.000Z", + portfolioStatus: "in_progress", + }); + + const table = formatManageStatusTable(rows); + expect(table).toContain("acme/widgets"); + expect(table).toContain("42"); + expect(table).toContain("feat/manage-status"); + expect(table).toContain("success"); + expect(table).toContain("merge"); + }); + + it("ignores manage_pr_update events for PRs no longer in the portfolio queue", () => { + const { portfolio, ledger } = tempStores(); + portfolio.enqueue({ repoFullName: "acme/widgets", identifier: "pr:42", priority: 1 }); + portfolio.markDone("acme/widgets", "pr:42"); + ledger.appendEvent({ + type: MANAGE_STATUS_EVENT_TYPE, + repoFullName: "acme/widgets", + payload: { + pullNumber: 42, + branch: "feat/done", + ciState: "success", + gateVerdict: "merge", + outcome: "merged", + lastPolledAt: "2026-07-03T15:00:00.000Z", + }, + }); + + expect( + buildManageStatusSnapshot({ + listQueue: () => portfolio.listQueue(), + readEvents: () => ledger.readEvents(), + }), + ).toEqual([]); + }); + + it("emits stable JSON output shape", () => { + const rows = [ + { + repoFullName: "acme/widgets", + pullNumber: 42, + branch: "feat/manage-status", + ciState: "success", + gateVerdict: "merge", + outcome: "open", + lastPolledAt: "2026-07-03T13:00:00.000Z", + portfolioStatus: "in_progress", + }, + ]; + expect(JSON.parse(formatManageStatusJson(rows))).toEqual({ rows }); + expect(runManageStatus({ listQueue: () => [], readEvents: () => [] }, { json: true }).output).toContain( + '"rows": []', + ); + }); + + it("rejects unexpected status arguments but ignores global CLI flags", () => { + expect(parseManageStatusArgs(["--json", "--no-update-check"])).toEqual({ json: true }); + expect(() => parseManageStatusArgs(["--json", "extra"])).toThrow(/unexpected_arguments/); + }); +}); + +describe("gittensory-miner manage status CLI (#2325)", () => { + it("prints the empty-portfolio message from the bin entrypoint", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-status-cli-")); + roots.push(root); + vi.stubEnv("GITTENSORY_MINER_CONFIG_DIR", root); + vi.stubEnv("GITTENSORY_MINER_NO_UPDATE_CHECK", "1"); + const output = runCapture(["manage", "status", "--no-update-check"]); + expect(output).toContain("No managed pull requests"); + }); + + it("serves --json from the bin entrypoint", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-status-cli-json-")); + roots.push(root); + vi.stubEnv("GITTENSORY_MINER_CONFIG_DIR", root); + vi.stubEnv("GITTENSORY_MINER_NO_UPDATE_CHECK", "1"); + const output = runCapture(["manage", "status", "--json", "--no-update-check"]); + expect(JSON.parse(output)).toEqual({ rows: [] }); + }); + + it("includes manage status in help output", () => { + const output = runCapture(["--help", "--no-update-check"]); + expect(output).toContain("gittensory-miner manage status"); + expect(output).toContain("--json"); + }); +});