diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index b702f2bdc8..8f29bf347c 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -47,6 +47,13 @@ The package also includes an append-only prediction ledger: `initPredictionLedge codes, plus the producing `ENGINE_VERSION`) in local SQLite, so a later self-improve pass can score predictions against realized outcomes. Insert-only. (#4263) +`gittensory-miner manage status` now also folds each tracked repo's current discover/plan/prepare run state +(`run-state.js`) alongside its managed PR rows into a "run portfolio" view — `collectRunPortfolio` / +`renderRunPortfolioTable` — so a repo actively being discovered or planned shows up even with zero PRs yet. +Additive only: the existing `rows` JSON key and PR table are unchanged; `runPortfolio` is a new key printed +after the existing table. A real GUI dashboard surface is out of scope here — `apps/gittensory-miner-ui/` is +Phase 6 of the same roadmap tracker and hasn't been scaffolded yet. (#4279) + ## Install See [`docs/miner-goal-spec.md`](docs/miner-goal-spec.md) for the `.gittensory-miner.yml` field reference and [`.gittensory-miner.yml.example`](../../.gittensory-miner.yml.example) at the repo root. @@ -88,6 +95,8 @@ gittensory-miner version gittensory-miner init [--json] gittensory-miner status [--json] gittensory-miner doctor [--json] +gittensory-miner manage status [--json] +gittensory-miner manage poll [--branch ] [--json] ``` ## Version check diff --git a/packages/gittensory-miner/lib/manage-status.d.ts b/packages/gittensory-miner/lib/manage-status.d.ts index 0b55e31343..1097d61d49 100644 --- a/packages/gittensory-miner/lib/manage-status.d.ts +++ b/packages/gittensory-miner/lib/manage-status.d.ts @@ -1,5 +1,6 @@ import type { EventLedger, LedgerEntry } from "./event-ledger.js"; import type { PortfolioQueueStore, QueueStatus } from "./portfolio-queue.js"; +import type { RunState, RunStateStore } from "./run-state.js"; export type ManageStatusRow = { repoFullName: string; @@ -18,6 +19,18 @@ export type ManageStatusSources = { eventLedger: EventLedger; }; +export type RunPortfolioSources = ManageStatusSources & { + runStateStore: RunStateStore; +}; + +export type RunPortfolioRow = { + repoFullName: string; + runState: RunState | null; + runStateUpdatedAt: string | null; + prCount: number; + prs: ManageStatusRow[]; +}; + export type ManageUpdateSnapshot = { repoFullName: string; prNumber: number; @@ -39,8 +52,12 @@ export function indexLatestManageUpdates(events: LedgerEntry[]): Map PortfolioQueueStore; initEventLedger?: () => EventLedger; + initRunStateStore?: () => RunStateStore; }, ): number; diff --git a/packages/gittensory-miner/lib/manage-status.js b/packages/gittensory-miner/lib/manage-status.js index e581f26067..d7be3fc0c8 100644 --- a/packages/gittensory-miner/lib/manage-status.js +++ b/packages/gittensory-miner/lib/manage-status.js @@ -1,5 +1,6 @@ import { initEventLedger } from "./event-ledger.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import { initRunStateStore } from "./run-state.js"; /** Event vocabulary for manage-phase PR snapshots written by manage poll. (#2325) */ export const MANAGE_PR_UPDATE_EVENT = "manage_pr_update"; @@ -106,6 +107,39 @@ export function collectManageStatus(sources) { }); } +/** + * Fold each tracked repo's current discover/plan/prepare run state alongside its managed PR rows into one + * "run portfolio" row per repo (#4279). `collectManageStatus` alone is PR-scoped only and never surfaces the + * run-state signal, so a repo actively discovering/planning with zero PRs yet is otherwise invisible. A repo + * appears here if it has EITHER a recorded run state OR at least one managed PR row. + */ +export function collectRunPortfolio(sources) { + const runStateStore = sources?.runStateStore; + if (!runStateStore || typeof runStateStore.listRunStates !== "function") { + throw new Error("invalid_run_state_store"); + } + const prsByRepo = new Map(); + for (const row of collectManageStatus(sources)) { + const list = prsByRepo.get(row.repoFullName) ?? []; + list.push(row); + prsByRepo.set(row.repoFullName, list); + } + const runStateByRepo = new Map(runStateStore.listRunStates().map((entry) => [entry.repoFullName, entry])); + + const repoFullNames = new Set([...prsByRepo.keys(), ...runStateByRepo.keys()]); + return [...repoFullNames].sort((left, right) => left.localeCompare(right)).map((repoFullName) => { + const prs = prsByRepo.get(repoFullName) ?? []; + const runState = runStateByRepo.get(repoFullName); + return { + repoFullName, + runState: runState?.state ?? null, + runStateUpdatedAt: runState?.updatedAt ?? null, + prCount: prs.length, + prs, + }; + }); +} + function display(value) { if (value === null || value === undefined) return "-"; return String(value); @@ -140,6 +174,27 @@ export function renderManageStatusTable(rows) { return [header, ...lines].join("\n"); } +/** One row per tracked repo (run state + PR count), the compact companion to {@link renderManageStatusTable}'s + * per-PR detail (#4279). */ +export function renderRunPortfolioTable(portfolio) { + if (!Array.isArray(portfolio) || portfolio.length === 0) return "no tracked repos"; + const header = [ + "repo".padEnd(24), + "run-state".padEnd(12), + "updated".padEnd(20), + "prs".padStart(4), + ].join(" "); + const lines = portfolio.map((entry) => + [ + entry.repoFullName.padEnd(24), + display(entry.runState).padEnd(12), + display(entry.runStateUpdatedAt).padEnd(20), + String(entry.prCount).padStart(4), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + export function parseManageStatusArgs(args = []) { for (const token of args) { if (token === "--json") continue; @@ -158,18 +213,24 @@ export function runManageStatus(args = [], options = {}) { const ownsPortfolioQueue = options.initPortfolioQueue === undefined; const ownsEventLedger = options.initEventLedger === undefined; + const ownsRunStateStore = options.initRunStateStore === undefined; const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const runStateStore = (options.initRunStateStore ?? initRunStateStore)(); try { const rows = collectManageStatus({ portfolioQueue, eventLedger }); + const runPortfolio = collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore }); if (parsed.json) { - console.log(JSON.stringify({ rows }, null, 2)); + // Additive only (#4279): `rows` keeps its existing shape unchanged; `runPortfolio` is a new key so an + // existing consumer parsing this JSON for `rows` alone sees byte-identical output. + console.log(JSON.stringify({ rows, runPortfolio }, null, 2)); } else { - console.log(renderManageStatusTable(rows)); + console.log(`${renderManageStatusTable(rows)}\n\n${renderRunPortfolioTable(runPortfolio)}`); } return 0; } finally { if (ownsPortfolioQueue) portfolioQueue.close(); if (ownsEventLedger) eventLedger.close(); + if (ownsRunStateStore) runStateStore.close(); } } diff --git a/packages/gittensory-miner/lib/run-state.d.ts b/packages/gittensory-miner/lib/run-state.d.ts index 7719f3e633..01827e69e8 100644 --- a/packages/gittensory-miner/lib/run-state.d.ts +++ b/packages/gittensory-miner/lib/run-state.d.ts @@ -6,10 +6,17 @@ export type RunStateWrite = { updatedAt: string; }; +export type RunStateRow = { + repoFullName: string; + state: RunState; + updatedAt: string; +}; + export type RunStateStore = { dbPath: string; getRunState(repoFullName: string): RunState | null; setRunState(repoFullName: string, state: RunState): RunStateWrite; + listRunStates(): RunStateRow[]; close(): void; }; @@ -23,4 +30,6 @@ export function getRunState(repoFullName: string): RunState | null; export function setRunState(repoFullName: string, state: RunState): RunStateWrite; +export function listRunStates(): RunStateRow[]; + export function closeDefaultRunStateStore(): void; diff --git a/packages/gittensory-miner/lib/run-state.js b/packages/gittensory-miner/lib/run-state.js index 3d2850423c..402aaea949 100644 --- a/packages/gittensory-miner/lib/run-state.js +++ b/packages/gittensory-miner/lib/run-state.js @@ -72,6 +72,9 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { state = excluded.state, updated_at = excluded.updated_at `); + const listStatement = db.prepare( + "SELECT repo_full_name, state, updated_at FROM miner_run_state ORDER BY repo_full_name", + ); return { dbPath: resolvedPath, @@ -86,6 +89,13 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { setStatement.run(normalizedRepo, normalizedState, updatedAt); return { repoFullName: normalizedRepo, state: normalizedState, updatedAt }; }, + /** Every repo with a recorded run state, across the whole store — the per-repo discover/plan/prepare + * signal a "run portfolio" view folds alongside managed PR rows (#4279). */ + listRunStates() { + return listStatement.all() + .filter((row) => runStateSet.has(row.state)) + .map((row) => ({ repoFullName: row.repo_full_name, state: row.state, updatedAt: row.updated_at })); + }, close() { db.close(); }, @@ -105,6 +115,10 @@ export function setRunState(repoFullName, state) { return getDefaultRunStateStore().setRunState(repoFullName, state); } +export function listRunStates() { + return getDefaultRunStateStore().listRunStates(); +} + export function closeDefaultRunStateStore() { if (!defaultRunStateStore) return; defaultRunStateStore.close(); diff --git a/test/unit/miner-manage-status.test.ts b/test/unit/miner-manage-status.test.ts index 79ec461295..3b2a958fb8 100644 --- a/test/unit/miner-manage-status.test.ts +++ b/test/unit/miner-manage-status.test.ts @@ -5,12 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MANAGE_PR_UPDATE_EVENT, collectManageStatus, + collectRunPortfolio, formatManagedPrIdentifier, indexLatestManageUpdates, parseManagedPrIdentifier, renderManageStatusTable, + renderRunPortfolioTable, runManageStatus, type ManageStatusRow, + type RunPortfolioRow, } from "../../packages/gittensory-miner/lib/manage-status.js"; import { closeDefaultEventLedger, @@ -20,6 +23,10 @@ import { closeDefaultPortfolioQueueStore, initPortfolioQueueStore, } from "../../packages/gittensory-miner/lib/portfolio-queue.js"; +import { + closeDefaultRunStateStore, + initRunStateStore, +} from "../../packages/gittensory-miner/lib/run-state.js"; const roots: string[] = []; const stores: Array<{ close(): void }> = []; @@ -29,14 +36,16 @@ function tempStores() { 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 }; + const runStateStore = initRunStateStore(join(root, "run-state.sqlite3")); + stores.push(portfolioQueue, eventLedger, runStateStore); + return { portfolioQueue, eventLedger, runStateStore }; } afterEach(() => { for (const store of stores.splice(0)) store.close(); closeDefaultPortfolioQueueStore(); closeDefaultEventLedger(); + closeDefaultRunStateStore(); vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -149,12 +158,76 @@ describe("gittensory-miner manage status (#2325)", () => { expect(renderManageStatusTable(rows)).toContain(" 2"); }); - it("runManageStatus prints table and JSON output", () => { + it("collectRunPortfolio: a repo with a run state but zero PRs still appears (#4279)", () => { + const { portfolioQueue, eventLedger, runStateStore } = tempStores(); + runStateStore.setRunState("acme/discovering-only", "discovering"); + + expect(collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore })).toEqual([ + { repoFullName: "acme/discovering-only", runState: "discovering", runStateUpdatedAt: expect.any(String), prCount: 0, prs: [] }, + ]); + }); + + it("collectRunPortfolio: a repo with PRs but no recorded run state reports runState: null (#4279)", () => { + const { portfolioQueue, eventLedger, runStateStore } = tempStores(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:4", priority: 1 }); + + const portfolio = collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore }); + expect(portfolio).toEqual([ + { repoFullName: "acme/widgets", runState: null, runStateUpdatedAt: null, prCount: 1, prs: [expect.objectContaining({ prNumber: 4 })] }, + ]); + }); + + it("collectRunPortfolio: folds a repo's run state alongside its multiple PR rows, sorted by repo", () => { + const { portfolioQueue, eventLedger, runStateStore } = tempStores(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:4", priority: 1 }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:5", priority: 2 }); + portfolioQueue.enqueue({ repoFullName: "acme/aaa", identifier: "pr:1", priority: 1 }); + runStateStore.setRunState("acme/widgets", "preparing"); + + const portfolio = collectRunPortfolio({ portfolioQueue, eventLedger, runStateStore }); + expect(portfolio.map((entry) => entry.repoFullName)).toEqual(["acme/aaa", "acme/widgets"]); + const widgets = portfolio.find((entry) => entry.repoFullName === "acme/widgets")!; + expect(widgets.runState).toBe("preparing"); + expect(widgets.prCount).toBe(2); + }); + + it("collectRunPortfolio rejects a missing/invalid run-state store", () => { + const { portfolioQueue, eventLedger } = tempStores(); + expect(() => + collectRunPortfolio({ + portfolioQueue, + eventLedger, + runStateStore: undefined, + } as unknown as Parameters[0]), + ).toThrow("invalid_run_state_store"); + }); + + it("renderRunPortfolioTable reports 'no tracked repos' for an empty portfolio, and renders repo/run-state/PR-count otherwise", () => { + expect(renderRunPortfolioTable([])).toBe("no tracked repos"); + const portfolio: RunPortfolioRow[] = [ + { repoFullName: "acme/widgets", runState: "planning", runStateUpdatedAt: "2026-07-04T12:00:00.000Z", prCount: 2, prs: [] }, + ]; + const rendered = renderRunPortfolioTable(portfolio); + expect(rendered).toContain("acme/widgets"); + expect(rendered).toContain("planning"); + expect(rendered).toContain("2026-07-04T12:00:00.000Z"); + expect(rendered).toMatch(/\s2$/); + }); + + it("renderRunPortfolioTable renders '-' for a repo with no recorded run state", () => { + const portfolio: RunPortfolioRow[] = [ + { repoFullName: "acme/widgets", runState: null, runStateUpdatedAt: null, prCount: 0, prs: [] }, + ]; + expect(renderRunPortfolioTable(portfolio)).toContain("-"); + }); + + it("runManageStatus prints the PR table + run portfolio table, and JSON output additive to the existing rows key (#4279)", () => { 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); + const runStateStore = initRunStateStore(join(root, "run-state.sqlite3")); + stores.push(portfolioQueue, eventLedger, runStateStore); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "pr:4", priority: 2 }); eventLedger.appendEvent({ type: MANAGE_PR_UPDATE_EVENT, @@ -168,34 +241,38 @@ describe("gittensory-miner manage status (#2325)", () => { lastPolledAt: "2026-07-04T12:00:00.000Z", }, }); + runStateStore.setRunState("acme/widgets", "planning"); + runStateStore.setRunState("acme/discovering-only", "discovering"); // no PRs yet -- must still appear 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"); + const initStores = { + initPortfolioQueue: () => portfolioQueue, + initEventLedger: () => eventLedger, + initRunStateStore: () => runStateStore, + }; + expect(runManageStatus([], initStores)).toBe(0); + const textOutput = String(log.mock.calls[0]?.[0]); + expect(textOutput).toContain("acme/widgets"); + expect(textOutput).toContain("success"); + expect(textOutput).toContain("planning"); // the run-portfolio section + expect(textOutput).toContain("acme/discovering-only"); + expect(textOutput).toContain("discovering"); log.mockClear(); - expect( - runManageStatus(["--json"], { - initPortfolioQueue: () => portfolioQueue, - initEventLedger: () => eventLedger, + expect(runManageStatus(["--json"], initStores)).toBe(0); + const parsed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(parsed.rows).toEqual([ + expect.objectContaining({ + repoFullName: "acme/widgets", + prNumber: 4, + ciState: "success", + queueStatus: "queued", }), - ).toBe(0); - expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ - rows: [ - expect.objectContaining({ - repoFullName: "acme/widgets", - prNumber: 4, - ciState: "success", - queueStatus: "queued", - }), - ], - }); + ]); + expect(parsed.runPortfolio).toEqual([ + expect.objectContaining({ repoFullName: "acme/discovering-only", runState: "discovering", prCount: 0 }), + expect.objectContaining({ repoFullName: "acme/widgets", runState: "planning", prCount: 1 }), + ]); }); it("rejects unknown CLI options", () => { diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index b57b0b9894..18f8e863ba 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -8,6 +8,7 @@ import { closeDefaultRunStateStore, getRunState, initRunStateStore, + listRunStates, resolveRunStateDbPath, setRunState, } from "../../packages/gittensory-miner/lib/run-state.js"; @@ -155,4 +156,60 @@ describe("gittensory-miner run-state store (#2289)", () => { store.close(); } }); + + it("listRunStates returns every recorded repo sorted by repoFullName (#4279)", () => { + const dbPath = join(tempRoot(), "run-state.sqlite3"); + const store = initRunStateStore(dbPath); + try { + expect(store.listRunStates()).toEqual([]); + + store.setRunState("acme/widgets", "planning"); + store.setRunState("acme/aaa", "idle"); + + const rows = store.listRunStates(); + expect(rows.map((row) => row.repoFullName)).toEqual(["acme/aaa", "acme/widgets"]); + expect(rows[0]).toMatchObject({ repoFullName: "acme/aaa", state: "idle" }); + expect(Date.parse(rows[0]!.updatedAt)).not.toBeNaN(); + } finally { + store.close(); + } + }); + + it("listRunStates fails closed by dropping a legacy row with an unknown state", () => { + const dbPath = join(tempRoot(), "legacy-list.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_run_state ( + repo_full_name TEXT PRIMARY KEY, + state TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + legacy + .prepare("INSERT INTO miner_run_state (repo_full_name, state, updated_at) VALUES (?, ?, ?)") + .run("acme/legacy", "paused", "2026-07-02T00:00:00.000Z"); + legacy + .prepare("INSERT INTO miner_run_state (repo_full_name, state, updated_at) VALUES (?, ?, ?)") + .run("acme/widgets", "planning", "2026-07-02T00:00:00.000Z"); + legacy.close(); + + const store = initRunStateStore(dbPath); + try { + expect(store.listRunStates()).toEqual([ + { repoFullName: "acme/widgets", state: "planning", updatedAt: "2026-07-02T00:00:00.000Z" }, + ]); + } finally { + store.close(); + } + }); + + it("exposes the module-level listRunStates helper backed by the default local DB path", () => { + vi.stubEnv("GITTENSORY_MINER_RUN_STATE_DB", join(tempRoot(), "default-list.sqlite3")); + + expect(listRunStates()).toEqual([]); + setRunState("acme/widgets", "preparing"); + expect(listRunStates()).toEqual([ + expect.objectContaining({ repoFullName: "acme/widgets", state: "preparing" }), + ]); + }); });