From 84331aca51a3decb422a062200dad2cfb87e4120 Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:09:28 -0500 Subject: [PATCH] feat(miner-manage): local pr_outcome record writer (#4274) Add packages/gittensory-miner/lib/pr-outcome.js: the miner's OWN local record of the outcomes of its OWN PRs (merged / closed), layered on the generic append-only event-ledger.js exactly like manage-status.js layers MANAGE_PR_UPDATE_EVENT. - MINER_PR_OUTCOME_EVENT + normalizePrOutcomePayload: a distinct local event type and a tolerant payload normalizer (returns null on a malformed shape). A closed decision may carry a reason bucket reused from rejection-templates.js's REJECTION_REASONS (shared vocabulary with the rejection-state-machine sibling); a merged decision or an unrecognized reason normalizes the reason to null. - recordPrOutcomeSnapshot(input, { eventLedger }): a thin writer over an injected ledger (same DI shape as recordManagePollSnapshot, unit-testable without a real file). Fail-soft: a missing repo or invalid payload returns null; only an unusable ledger throws. - readPrOutcomes(eventLedger, filter): reduces the append-only stream to the latest outcome per repo/PR, mirroring indexLatestManageUpdates. Distinct from the SERVER-side pr_outcome path (recordPrOutcome, src/review/ outcomes-wire.ts): same concept name, different codebase layer, no shared code (a laptop-mode miner may have no webhook relay). Adds the hand-written .d.ts and a README note recording that distinction. Closes #4274 --- packages/gittensory-miner/README.md | 6 + packages/gittensory-miner/lib/pr-outcome.d.ts | 38 +++++++ packages/gittensory-miner/lib/pr-outcome.js | 90 +++++++++++++++ test/unit/miner-pr-outcome.test.ts | 106 ++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 packages/gittensory-miner/lib/pr-outcome.d.ts create mode 100644 packages/gittensory-miner/lib/pr-outcome.js create mode 100644 test/unit/miner-pr-outcome.test.ts diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 0796b49214..b702f2bdc8 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -36,6 +36,12 @@ The package also includes an append-only event ledger: `initEventLedger` / `appe immutable miner-loop events in local SQLite for contributor audit. Insert-only — rows are never updated or deleted. (#2322) +The package also records local PR outcomes: `recordPrOutcomeSnapshot` / `readPrOutcomes` write and reduce the +miner's OWN record of the outcomes of its OWN PRs (merged / closed, with an optional rejection-reason bucket) over +the append-only event ledger above. This is DISTINCT from the gittensory server's `recordPrOutcome` +(`src/review/outcomes-wire.ts`), which writes hosted-backend audit rows from the GitHub App's webhook stream — same +concept name, different codebase layer, no shared code (a laptop-mode miner may have no webhook relay at all). (#4274) + The package also includes an append-only prediction ledger: `initPredictionLedger` / `appendPrediction` / `readPredictions` persist each predicted-gate verdict (conclusion / pack / readiness score + blocker/warning codes, plus the producing `ENGINE_VERSION`) in local SQLite, so a later self-improve pass can score predictions diff --git a/packages/gittensory-miner/lib/pr-outcome.d.ts b/packages/gittensory-miner/lib/pr-outcome.d.ts new file mode 100644 index 0000000000..551a3abd86 --- /dev/null +++ b/packages/gittensory-miner/lib/pr-outcome.d.ts @@ -0,0 +1,38 @@ +export const MINER_PR_OUTCOME_EVENT: "pr_outcome"; +export const MINER_PR_OUTCOME_DECISIONS: readonly ["merged", "closed"]; + +export type MinerPrOutcomeDecision = "merged" | "closed"; + +export interface NormalizedPrOutcomePayload { + prNumber: number; + decision: MinerPrOutcomeDecision; + closedAt: string | null; + reason: string | null; +} + +export interface PrOutcomeInput { + repoFullName?: unknown; + prNumber?: unknown; + decision?: unknown; + closedAt?: unknown; + reason?: unknown; +} + +export interface RecordPrOutcomeOptions { + /** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the + * writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. */ + eventLedger?: { appendEvent(event: { type: string; repoFullName: string; payload: unknown }): unknown }; +} + +export interface PrOutcomeLedgerReader { + readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; +} + +export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null; + +export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): unknown; + +export function readPrOutcomes( + eventLedger: PrOutcomeLedgerReader, + filter?: { since?: number; repoFullName?: string }, +): Map; diff --git a/packages/gittensory-miner/lib/pr-outcome.js b/packages/gittensory-miner/lib/pr-outcome.js new file mode 100644 index 0000000000..b4baedd3f0 --- /dev/null +++ b/packages/gittensory-miner/lib/pr-outcome.js @@ -0,0 +1,90 @@ +// Miner-local PR-outcome record (#4274). The miner's OWN local record of the outcomes of its OWN PRs — merged or +// closed — written to the miner's local SQLite via the generic append-only event-ledger.js, mirroring how +// manage-status.js layers a specific typed event (MANAGE_PR_UPDATE_EVENT + a payload normalizer + a thin writer) +// on top of that same ledger. +// +// DISTINCT from the server-side `pr_outcome` concept: src/review/outcomes-wire.ts's `recordPrOutcome` writes +// `pr_outcome` rows to the HOSTED backend's D1 audit tables from the GitHub App's webhook stream — that is the +// gittensory SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of +// its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code. +// The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated. + +import { REJECTION_REASONS } from "./rejection-templates.js"; + +/** Event-ledger vocabulary for a miner-local PR outcome. */ +export const MINER_PR_OUTCOME_EVENT = "pr_outcome"; + +/** The terminal decisions a miner records for one of its own PRs. */ +export const MINER_PR_OUTCOME_DECISIONS = Object.freeze(["merged", "closed"]); + +const decisionSet = new Set(MINER_PR_OUTCOME_DECISIONS); +const reasonSet = new Set(REJECTION_REASONS); + +function optionalString(value) { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +/** + * Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's + * `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry + * a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged` + * decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason). + */ +export function normalizePrOutcomePayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null; + const decision = optionalString(payload.decision); + if (!decision || !decisionSet.has(decision)) return null; + const reasonRaw = optionalString(payload.reason); + const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null; + return { + prNumber: payload.prNumber, + decision, + closedAt: optionalString(payload.closedAt), + reason, + }; +} + +/** + * Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's + * `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one + * {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed + * snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the + * only hard error, since that is a programmer wiring mistake). + */ +export function recordPrOutcomeSnapshot(input, options = {}) { + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : ""; + if (!repoFullName) return null; + const payload = normalizePrOutcomePayload({ + prNumber: input?.prNumber, + decision: input?.decision, + closedAt: input?.closedAt, + reason: input?.reason, + }); + if (!payload) return null; + return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); +} + +/** + * Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors + * manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces + * the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by + * `repoFullName:prNumber`. + */ +export function readPrOutcomes(eventLedger, filter = {}) { + const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : []; + const latest = new Map(); + for (const event of Array.isArray(events) ? events : []) { + if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; + if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue; + const normalized = normalizePrOutcomePayload(event.payload); + if (!normalized) continue; + latest.set(`${event.repoFullName}:${normalized.prNumber}`, { ...normalized, repoFullName: event.repoFullName }); + } + return latest; +} diff --git a/test/unit/miner-pr-outcome.test.ts b/test/unit/miner-pr-outcome.test.ts new file mode 100644 index 0000000000..848c19decc --- /dev/null +++ b/test/unit/miner-pr-outcome.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + MINER_PR_OUTCOME_DECISIONS, + MINER_PR_OUTCOME_EVENT, + normalizePrOutcomePayload, + readPrOutcomes, + recordPrOutcomeSnapshot, +} from "../../packages/gittensory-miner/lib/pr-outcome.js"; + +// A minimal injected event ledger (the DI shape the writer/reader accept), so these stay pure unit tests with no +// SQLite file. `_events` is exposed so a test can inject crafted rows for the reader's defensive skip branches. +function mockLedger(): { appendEvent: (e: unknown) => unknown; readEvents: (filter?: { repoFullName?: string }) => unknown[]; _events: Array> } { + const events: Array> = []; + let seq = 0; + return { + appendEvent: (e) => { + const entry = { ...(e as object), seq: ++seq } as Record; + events.push(entry); + return entry; + }, + readEvents: (filter = {}) => events.filter((e) => filter.repoFullName === undefined || e.repoFullName === filter.repoFullName), + _events: events, + }; +} + +describe("normalizePrOutcomePayload (#4274)", () => { + it("rejects a non-object, a bad prNumber, or an unknown decision", () => { + for (const bad of [null, "x", [1], {}, { prNumber: 0, decision: "merged" }, { prNumber: 1.5, decision: "merged" }, { prNumber: 3, decision: "abandoned" }, { prNumber: 3 }]) { + expect(normalizePrOutcomePayload(bad)).toBeNull(); + } + }); + + it("keeps a closed decision's reason only when it is a recognized rejection bucket", () => { + expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed", reason: "gate_close", closedAt: "2026-07-09T00:00:00Z" })).toEqual({ + prNumber: 7, + decision: "closed", + closedAt: "2026-07-09T00:00:00Z", + reason: "gate_close", + }); + // unrecognized reason → dropped + expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed", reason: "because" })?.reason).toBeNull(); + // no reason → null + expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed" })?.reason).toBeNull(); + }); + + it("drops any reason on a merged decision (a merged PR has no rejection reason)", () => { + const merged = normalizePrOutcomePayload({ prNumber: 9, decision: "merged", reason: "gate_close" }); + expect(merged).toEqual({ prNumber: 9, decision: "merged", closedAt: null, reason: null }); + }); + + it("coerces a null / non-string / whitespace closedAt to null", () => { + expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: null })?.closedAt).toBeNull(); + expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: 42 })?.closedAt).toBeNull(); + expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: " " })?.closedAt).toBeNull(); + }); +}); + +describe("recordPrOutcomeSnapshot (#4274)", () => { + it("throws only when the injected ledger is unusable", () => { + expect(() => recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 1, decision: "merged" }, {})).toThrow("invalid_event_ledger"); + expect(() => recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 1, decision: "merged" }, { eventLedger: {} } as never)).toThrow("invalid_event_ledger"); + }); + + it("fail-soft returns null for a missing repo or a malformed payload, without appending", () => { + const ledger = mockLedger(); + expect(recordPrOutcomeSnapshot({ prNumber: 1, decision: "merged" }, { eventLedger: ledger })).toBeNull(); + expect(recordPrOutcomeSnapshot({ repoFullName: " ", prNumber: 1, decision: "merged" }, { eventLedger: ledger })).toBeNull(); + expect(recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 0, decision: "merged" }, { eventLedger: ledger })).toBeNull(); + expect(ledger._events).toHaveLength(0); + }); + + it("appends one repo-scoped pr_outcome event for a valid snapshot", () => { + const ledger = mockLedger(); + const entry = recordPrOutcomeSnapshot({ repoFullName: " acme/widgets ", prNumber: 12, decision: "closed", reason: "superseded_by_duplicate", closedAt: "t" }, { eventLedger: ledger }) as Record; + expect(entry.type).toBe(MINER_PR_OUTCOME_EVENT); + expect(entry.repoFullName).toBe("acme/widgets"); + expect(entry.payload).toEqual({ prNumber: 12, decision: "closed", closedAt: "t", reason: "superseded_by_duplicate" }); + expect(MINER_PR_OUTCOME_DECISIONS).toEqual(["merged", "closed"]); + }); +}); + +describe("readPrOutcomes (#4274)", () => { + it("reduces the append-only stream to the latest outcome per repo/PR", () => { + const ledger = mockLedger(); + recordPrOutcomeSnapshot({ repoFullName: "acme/widgets", prNumber: 1, decision: "closed", reason: "gate_close" }, { eventLedger: ledger }); + recordPrOutcomeSnapshot({ repoFullName: "acme/widgets", prNumber: 1, decision: "merged" }, { eventLedger: ledger }); // supersedes + recordPrOutcomeSnapshot({ repoFullName: "acme/other", prNumber: 2, decision: "closed" }, { eventLedger: ledger }); + const latest = readPrOutcomes(ledger, { repoFullName: "acme/widgets" }); + expect(latest.get("acme/widgets:1")).toEqual({ repoFullName: "acme/widgets", prNumber: 1, decision: "merged", closedAt: null, reason: null }); + expect(latest.has("acme/other:2")).toBe(false); // filtered out by the repo filter + }); + + it("skips foreign event types, missing repos, and malformed payloads; empty when the ledger can't read", () => { + const ledger = mockLedger(); + ledger._events.push( + { type: "manage_pr_update", repoFullName: "acme/widgets", payload: { prNumber: 1 } }, // foreign type + { type: MINER_PR_OUTCOME_EVENT, repoFullName: " ", payload: { prNumber: 1, decision: "merged" } }, // blank repo + { type: MINER_PR_OUTCOME_EVENT, repoFullName: "acme/widgets", payload: { prNumber: 0, decision: "merged" } }, // bad payload + { type: MINER_PR_OUTCOME_EVENT, repoFullName: "acme/widgets", payload: { prNumber: 5, decision: "merged" } }, // kept + ); + expect([...readPrOutcomes(ledger).keys()]).toEqual(["acme/widgets:5"]); + // a ledger without readEvents, and one whose readEvents returns a non-array, both reduce to an empty map + expect(readPrOutcomes({} as never).size).toBe(0); + expect(readPrOutcomes({ readEvents: () => null } as never).size).toBe(0); + }); +});