Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions packages/gittensory-miner/lib/pr-outcome.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, NormalizedPrOutcomePayload & { repoFullName: string }>;
90 changes: 90 additions & 0 deletions packages/gittensory-miner/lib/pr-outcome.js
Original file line number Diff line number Diff line change
@@ -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;
}
106 changes: 106 additions & 0 deletions test/unit/miner-pr-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> } {
const events: Array<Record<string, unknown>> = [];
let seq = 0;
return {
appendEvent: (e) => {
const entry = { ...(e as object), seq: ++seq } as Record<string, unknown>;
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<string, unknown>;
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);
});
});