diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index 3d67c522f7..c11ab30f98 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) +On top of that ledger, the miner records the outcome of its OWN PRs: `recordPrOutcomeSnapshot` / `readPrOutcomes` +(`pr-outcome.js`) append and reduce `pr_outcome` events (a `merged`/`closed` decision, with an optional +`REJECTION_REASONS` bucket on a close). This is the miner's local bookkeeping only — DISTINCT from the +server-side `recordPrOutcome` (`src/review/outcomes-wire.ts`), which writes hosted-D1 ground truth from the +App's webhook stream; same concept name, different codebase layer, no shared code. (#4274) + ## 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. 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..7e5955f360 --- /dev/null +++ b/packages/gittensory-miner/lib/pr-outcome.d.ts @@ -0,0 +1,32 @@ +import type { EventLedger, LedgerEntry, ReadEventsFilter } from "./event-ledger.js"; + +export type PrOutcomeDecision = "merged" | "closed"; + +export type PrOutcomePayload = { + prNumber: number; + decision: PrOutcomeDecision; + reason: string | null; + closedAt: string | null; +}; + +export type PrOutcomeSnapshot = PrOutcomePayload & { repoFullName: string }; + +export const MINER_PR_OUTCOME_EVENT: "pr_outcome"; + +export function normalizePrOutcomePayload(payload: unknown): PrOutcomePayload | null; + +export function recordPrOutcomeSnapshot( + input: { + repoFullName: string; + prNumber: number; + decision: PrOutcomeDecision; + reason?: string | null; + closedAt?: string | null; + }, + options: { eventLedger: Pick }, +): { payload: PrOutcomePayload; event: LedgerEntry }; + +export function readPrOutcomes( + eventLedger: Pick, + filter?: ReadEventsFilter, +): 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..144494c51a --- /dev/null +++ b/packages/gittensory-miner/lib/pr-outcome.js @@ -0,0 +1,69 @@ +import { REJECTION_REASONS } from "./rejection-templates.js"; + +// The miner's OWN local record of the outcome of its OWN PRs (#4274). DELIBERATELY the same event-type string +// as — but a DIFFERENT codebase layer from — the server-side `recordPrOutcome` (src/review/outcomes-wire.ts), +// which writes hosted-D1 ground truth from the App's webhook stream. No shared code: a laptop-mode miner may +// have no webhook relay at all, so it records its own outcomes locally via event-ledger.js. +export const MINER_PR_OUTCOME_EVENT = "pr_outcome"; + +// A `closed`-not-merged PR may carry one reason bucket, reusing rejection-templates' REJECTION_REASONS so this +// writer and the rejection-note renderer share a single reason vocabulary. +const REASON_BUCKETS = new Set(REJECTION_REASONS); + +function optionalString(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +/** Normalize a PR-outcome payload into a plain, JSON-round-trippable record; returns null when the required + * fields are missing or invalid (mirrors normalizeManageUpdatePayload). `reason` is kept only for a `closed` + * decision and only when it names a known REJECTION_REASONS bucket — a merged PR, or an unknown reason, + * normalizes `reason` to null. */ +export function normalizePrOutcomePayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null; + if (payload.decision !== "merged" && payload.decision !== "closed") return null; + const reason = + payload.decision === "closed" && typeof payload.reason === "string" && REASON_BUCKETS.has(payload.reason) + ? payload.reason + : null; + return { + prNumber: payload.prNumber, + decision: payload.decision, + reason, + closedAt: optionalString(payload.closedAt), + }; +} + +/** Append one PR-outcome event to the local ledger — a thin writer with the same dependency-injection shape as + * recordManagePollSnapshot (manage-poll.js), so it's unit-testable without a real ledger file. Throws on an + * invalid repo, payload, or ledger. Returns the normalized payload and the appended ledger entry. */ +export function recordPrOutcomeSnapshot(input, options = {}) { + if (!input || typeof input !== "object") throw new Error("invalid_pr_outcome_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"); + const payload = normalizePrOutcomePayload(input); + if (!payload) throw new Error("invalid_pr_outcome_payload"); + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + const event = eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload }); + return { payload, event }; +} + +/** Reduce the append-only ledger to the current outcome per repo/PR (latest event wins) — the read-side mirror + * of indexLatestManageUpdates. Pure over whatever readEvents returns; `filter` (repo scope / `since` cursor) + * is passed straight through. */ +export function readPrOutcomes(eventLedger, filter = {}) { + if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger"); + const latest = new Map(); + for (const event of eventLedger.readEvents(filter)) { + 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..ec47fc8b29 --- /dev/null +++ b/test/unit/miner-pr-outcome.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { + MINER_PR_OUTCOME_EVENT, + normalizePrOutcomePayload, + readPrOutcomes, + recordPrOutcomeSnapshot, +} from "../../packages/gittensory-miner/lib/pr-outcome.js"; + +const ledgers: Array<{ close: () => void }> = []; +const roots: string[] = []; +function tempLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-proutcome-")); + roots.push(root); + const ledger = initEventLedger(join(root, "event-ledger.sqlite3")); + ledgers.push(ledger); + return ledger; +} +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("miner pr-outcome ledger (#4274)", () => { + it("normalizePrOutcomePayload validates required fields and scopes reason to closed + known buckets", () => { + expect(normalizePrOutcomePayload(null)).toBeNull(); + expect(normalizePrOutcomePayload({ prNumber: 0, decision: "merged" })).toBeNull(); + expect(normalizePrOutcomePayload({ prNumber: 5, decision: "abandoned" })).toBeNull(); + // merged: reason is dropped even if supplied, and closedAt is trimmed. + expect(normalizePrOutcomePayload({ prNumber: 5, decision: "merged", reason: "gate_close", closedAt: " 2026-01-01T00:00:00Z " })).toEqual({ + prNumber: 5, + decision: "merged", + reason: null, + closedAt: "2026-01-01T00:00:00Z", + }); + // closed + known bucket: reason kept; missing closedAt → null. + expect(normalizePrOutcomePayload({ prNumber: 5, decision: "closed", reason: "gate_close" })).toEqual({ + prNumber: 5, + decision: "closed", + reason: "gate_close", + closedAt: null, + }); + // closed + unknown bucket: reason drops to null. + expect(normalizePrOutcomePayload({ prNumber: 5, decision: "closed", reason: "because" })?.reason).toBeNull(); + }); + + it("recordPrOutcomeSnapshot appends a merged outcome that round-trips through the ledger", () => { + const ledger = tempLedger(); + const { payload, event } = recordPrOutcomeSnapshot( + { repoFullName: "owner/repo", prNumber: 7, decision: "merged", closedAt: "2026-01-02T00:00:00Z" }, + { eventLedger: ledger }, + ); + expect(event.type).toBe(MINER_PR_OUTCOME_EVENT); + expect(payload).toEqual({ prNumber: 7, decision: "merged", reason: null, closedAt: "2026-01-02T00:00:00Z" }); + expect(ledger.readEvents()).toEqual([expect.objectContaining({ type: "pr_outcome", repoFullName: "owner/repo", payload })]); + }); + + it("recordPrOutcomeSnapshot appends a closed outcome with a rejection-reason bucket", () => { + const ledger = tempLedger(); + const { payload } = recordPrOutcomeSnapshot( + { repoFullName: "owner/repo", prNumber: 8, decision: "closed", reason: "gate_close", closedAt: "2026-01-03T00:00:00Z" }, + { eventLedger: ledger }, + ); + expect(payload).toMatchObject({ decision: "closed", reason: "gate_close" }); + }); + + it("recordPrOutcomeSnapshot rejects an invalid repo or payload", () => { + const ledger = tempLedger(); + expect(() => recordPrOutcomeSnapshot({ repoFullName: "no-slash", prNumber: 1, decision: "merged" }, { eventLedger: ledger })).toThrow(/invalid_repo_full_name/); + expect(() => recordPrOutcomeSnapshot({ repoFullName: "o/r", prNumber: 0, decision: "merged" }, { eventLedger: ledger })).toThrow(/invalid_pr_outcome_payload/); + }); + + it("readPrOutcomes reduces the append-only stream to the latest outcome per repo/PR", () => { + const ledger = tempLedger(); + // PR 7 closed, then re-opened + merged; PR 8 closed. The latest event wins per PR. + recordPrOutcomeSnapshot({ repoFullName: "owner/repo", prNumber: 7, decision: "closed", reason: "maintainer_close_no_reason" }, { eventLedger: ledger }); + recordPrOutcomeSnapshot({ repoFullName: "owner/repo", prNumber: 7, decision: "merged", closedAt: "2026-01-05T00:00:00Z" }, { eventLedger: ledger }); + recordPrOutcomeSnapshot({ repoFullName: "owner/repo", prNumber: 8, decision: "closed", reason: "gate_close" }, { eventLedger: ledger }); + const latest = readPrOutcomes(ledger); + expect(latest.size).toBe(2); + expect(latest.get("owner/repo:7")).toMatchObject({ decision: "merged", reason: null }); + expect(latest.get("owner/repo:8")).toMatchObject({ decision: "closed", reason: "gate_close" }); + }); +});