Skip to content

Commit 84331ac

Browse files
committed
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
1 parent 4daceaa commit 84331ac

4 files changed

Lines changed: 240 additions & 0 deletions

File tree

packages/gittensory-miner/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ The package also includes an append-only event ledger: `initEventLedger` / `appe
3636
immutable miner-loop events in local SQLite for contributor audit. Insert-only — rows are never updated or
3737
deleted. (#2322)
3838

39+
The package also records local PR outcomes: `recordPrOutcomeSnapshot` / `readPrOutcomes` write and reduce the
40+
miner's OWN record of the outcomes of its OWN PRs (merged / closed, with an optional rejection-reason bucket) over
41+
the append-only event ledger above. This is DISTINCT from the gittensory server's `recordPrOutcome`
42+
(`src/review/outcomes-wire.ts`), which writes hosted-backend audit rows from the GitHub App's webhook stream — same
43+
concept name, different codebase layer, no shared code (a laptop-mode miner may have no webhook relay at all). (#4274)
44+
3945
The package also includes an append-only prediction ledger: `initPredictionLedger` / `appendPrediction` /
4046
`readPredictions` persist each predicted-gate verdict (conclusion / pack / readiness score + blocker/warning
4147
codes, plus the producing `ENGINE_VERSION`) in local SQLite, so a later self-improve pass can score predictions
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const MINER_PR_OUTCOME_EVENT: "pr_outcome";
2+
export const MINER_PR_OUTCOME_DECISIONS: readonly ["merged", "closed"];
3+
4+
export type MinerPrOutcomeDecision = "merged" | "closed";
5+
6+
export interface NormalizedPrOutcomePayload {
7+
prNumber: number;
8+
decision: MinerPrOutcomeDecision;
9+
closedAt: string | null;
10+
reason: string | null;
11+
}
12+
13+
export interface PrOutcomeInput {
14+
repoFullName?: unknown;
15+
prNumber?: unknown;
16+
decision?: unknown;
17+
closedAt?: unknown;
18+
reason?: unknown;
19+
}
20+
21+
export interface RecordPrOutcomeOptions {
22+
/** Optional at the type level so a caller can pass an unusable ledger to exercise the fail-closed guard; the
23+
* writer throws `invalid_event_ledger` at runtime when this is absent or lacks `appendEvent`. */
24+
eventLedger?: { appendEvent(event: { type: string; repoFullName: string; payload: unknown }): unknown };
25+
}
26+
27+
export interface PrOutcomeLedgerReader {
28+
readEvents(filter?: { since?: number; repoFullName?: string }): unknown[];
29+
}
30+
31+
export function normalizePrOutcomePayload(payload: unknown): NormalizedPrOutcomePayload | null;
32+
33+
export function recordPrOutcomeSnapshot(input: PrOutcomeInput, options?: RecordPrOutcomeOptions): unknown;
34+
35+
export function readPrOutcomes(
36+
eventLedger: PrOutcomeLedgerReader,
37+
filter?: { since?: number; repoFullName?: string },
38+
): Map<string, NormalizedPrOutcomePayload & { repoFullName: string }>;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Miner-local PR-outcome record (#4274). The miner's OWN local record of the outcomes of its OWN PRs — merged or
2+
// closed — written to the miner's local SQLite via the generic append-only event-ledger.js, mirroring how
3+
// manage-status.js layers a specific typed event (MANAGE_PR_UPDATE_EVENT + a payload normalizer + a thin writer)
4+
// on top of that same ledger.
5+
//
6+
// DISTINCT from the server-side `pr_outcome` concept: src/review/outcomes-wire.ts's `recordPrOutcome` writes
7+
// `pr_outcome` rows to the HOSTED backend's D1 audit tables from the GitHub App's webhook stream — that is the
8+
// gittensory SERVER recording ground truth for every contributor. THIS is a laptop-mode miner's local record of
9+
// its own PRs (it may have no webhook relay at all): same concept name, different codebase layer, no shared code.
10+
// The distinct `MINER_PR_OUTCOME_EVENT` local constant keeps the two from being conflated.
11+
12+
import { REJECTION_REASONS } from "./rejection-templates.js";
13+
14+
/** Event-ledger vocabulary for a miner-local PR outcome. */
15+
export const MINER_PR_OUTCOME_EVENT = "pr_outcome";
16+
17+
/** The terminal decisions a miner records for one of its own PRs. */
18+
export const MINER_PR_OUTCOME_DECISIONS = Object.freeze(["merged", "closed"]);
19+
20+
const decisionSet = new Set(MINER_PR_OUTCOME_DECISIONS);
21+
const reasonSet = new Set(REJECTION_REASONS);
22+
23+
function optionalString(value) {
24+
if (value === undefined || value === null) return null;
25+
if (typeof value !== "string") return null;
26+
const trimmed = value.trim();
27+
return trimmed || null;
28+
}
29+
30+
/**
31+
* Validate + normalize a PR-outcome payload; returns `null` on any malformed shape (mirrors manage-status.js's
32+
* `normalizeManageUpdatePayload`, so a bad row can neither be written nor read back). A `closed` decision may carry
33+
* a reason bucket drawn from {@link REJECTION_REASONS} (shared with the rejection-state-machine sibling); a `merged`
34+
* decision — or an unrecognized reason — normalizes the reason to `null` (a merged PR has no rejection reason).
35+
*/
36+
export function normalizePrOutcomePayload(payload) {
37+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
38+
if (!Number.isInteger(payload.prNumber) || payload.prNumber <= 0) return null;
39+
const decision = optionalString(payload.decision);
40+
if (!decision || !decisionSet.has(decision)) return null;
41+
const reasonRaw = optionalString(payload.reason);
42+
const reason = decision === "closed" && reasonRaw !== null && reasonSet.has(reasonRaw) ? reasonRaw : null;
43+
return {
44+
prNumber: payload.prNumber,
45+
decision,
46+
closedAt: optionalString(payload.closedAt),
47+
reason,
48+
};
49+
}
50+
51+
/**
52+
* Thin writer over an INJECTED event ledger (same dependency-injection shape as manage-poll.js's
53+
* `recordManagePollSnapshot`, so it's unit-testable without a real ledger file). Appends one
54+
* {@link MINER_PR_OUTCOME_EVENT} scoped to the repo and returns the appended entry. Fail-soft on a malformed
55+
* snapshot: a missing repo or an invalid payload returns `null` rather than throwing (an unusable ledger is the
56+
* only hard error, since that is a programmer wiring mistake).
57+
*/
58+
export function recordPrOutcomeSnapshot(input, options = {}) {
59+
const eventLedger = options.eventLedger;
60+
if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger");
61+
const repoFullName = typeof input?.repoFullName === "string" ? input.repoFullName.trim() : "";
62+
if (!repoFullName) return null;
63+
const payload = normalizePrOutcomePayload({
64+
prNumber: input?.prNumber,
65+
decision: input?.decision,
66+
closedAt: input?.closedAt,
67+
reason: input?.reason,
68+
});
69+
if (!payload) return null;
70+
return eventLedger.appendEvent({ type: MINER_PR_OUTCOME_EVENT, repoFullName, payload });
71+
}
72+
73+
/**
74+
* Reconstruct the latest outcome per repo/PR from the ledger's ascending append-only event stream (mirrors
75+
* manage-status.js's `indexLatestManageUpdates`). Reads via the injected ledger's `readEvents(filter)` and reduces
76+
* the pure result — a later event for the same repo/PR supersedes an earlier one. Returns a `Map` keyed by
77+
* `repoFullName:prNumber`.
78+
*/
79+
export function readPrOutcomes(eventLedger, filter = {}) {
80+
const events = eventLedger && typeof eventLedger.readEvents === "function" ? eventLedger.readEvents(filter) : [];
81+
const latest = new Map();
82+
for (const event of Array.isArray(events) ? events : []) {
83+
if (event?.type !== MINER_PR_OUTCOME_EVENT) continue;
84+
if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue;
85+
const normalized = normalizePrOutcomePayload(event.payload);
86+
if (!normalized) continue;
87+
latest.set(`${event.repoFullName}:${normalized.prNumber}`, { ...normalized, repoFullName: event.repoFullName });
88+
}
89+
return latest;
90+
}

test/unit/miner-pr-outcome.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
MINER_PR_OUTCOME_DECISIONS,
4+
MINER_PR_OUTCOME_EVENT,
5+
normalizePrOutcomePayload,
6+
readPrOutcomes,
7+
recordPrOutcomeSnapshot,
8+
} from "../../packages/gittensory-miner/lib/pr-outcome.js";
9+
10+
// A minimal injected event ledger (the DI shape the writer/reader accept), so these stay pure unit tests with no
11+
// SQLite file. `_events` is exposed so a test can inject crafted rows for the reader's defensive skip branches.
12+
function mockLedger(): { appendEvent: (e: unknown) => unknown; readEvents: (filter?: { repoFullName?: string }) => unknown[]; _events: Array<Record<string, unknown>> } {
13+
const events: Array<Record<string, unknown>> = [];
14+
let seq = 0;
15+
return {
16+
appendEvent: (e) => {
17+
const entry = { ...(e as object), seq: ++seq } as Record<string, unknown>;
18+
events.push(entry);
19+
return entry;
20+
},
21+
readEvents: (filter = {}) => events.filter((e) => filter.repoFullName === undefined || e.repoFullName === filter.repoFullName),
22+
_events: events,
23+
};
24+
}
25+
26+
describe("normalizePrOutcomePayload (#4274)", () => {
27+
it("rejects a non-object, a bad prNumber, or an unknown decision", () => {
28+
for (const bad of [null, "x", [1], {}, { prNumber: 0, decision: "merged" }, { prNumber: 1.5, decision: "merged" }, { prNumber: 3, decision: "abandoned" }, { prNumber: 3 }]) {
29+
expect(normalizePrOutcomePayload(bad)).toBeNull();
30+
}
31+
});
32+
33+
it("keeps a closed decision's reason only when it is a recognized rejection bucket", () => {
34+
expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed", reason: "gate_close", closedAt: "2026-07-09T00:00:00Z" })).toEqual({
35+
prNumber: 7,
36+
decision: "closed",
37+
closedAt: "2026-07-09T00:00:00Z",
38+
reason: "gate_close",
39+
});
40+
// unrecognized reason → dropped
41+
expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed", reason: "because" })?.reason).toBeNull();
42+
// no reason → null
43+
expect(normalizePrOutcomePayload({ prNumber: 7, decision: "closed" })?.reason).toBeNull();
44+
});
45+
46+
it("drops any reason on a merged decision (a merged PR has no rejection reason)", () => {
47+
const merged = normalizePrOutcomePayload({ prNumber: 9, decision: "merged", reason: "gate_close" });
48+
expect(merged).toEqual({ prNumber: 9, decision: "merged", closedAt: null, reason: null });
49+
});
50+
51+
it("coerces a null / non-string / whitespace closedAt to null", () => {
52+
expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: null })?.closedAt).toBeNull();
53+
expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: 42 })?.closedAt).toBeNull();
54+
expect(normalizePrOutcomePayload({ prNumber: 1, decision: "merged", closedAt: " " })?.closedAt).toBeNull();
55+
});
56+
});
57+
58+
describe("recordPrOutcomeSnapshot (#4274)", () => {
59+
it("throws only when the injected ledger is unusable", () => {
60+
expect(() => recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 1, decision: "merged" }, {})).toThrow("invalid_event_ledger");
61+
expect(() => recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 1, decision: "merged" }, { eventLedger: {} } as never)).toThrow("invalid_event_ledger");
62+
});
63+
64+
it("fail-soft returns null for a missing repo or a malformed payload, without appending", () => {
65+
const ledger = mockLedger();
66+
expect(recordPrOutcomeSnapshot({ prNumber: 1, decision: "merged" }, { eventLedger: ledger })).toBeNull();
67+
expect(recordPrOutcomeSnapshot({ repoFullName: " ", prNumber: 1, decision: "merged" }, { eventLedger: ledger })).toBeNull();
68+
expect(recordPrOutcomeSnapshot({ repoFullName: "a/b", prNumber: 0, decision: "merged" }, { eventLedger: ledger })).toBeNull();
69+
expect(ledger._events).toHaveLength(0);
70+
});
71+
72+
it("appends one repo-scoped pr_outcome event for a valid snapshot", () => {
73+
const ledger = mockLedger();
74+
const entry = recordPrOutcomeSnapshot({ repoFullName: " acme/widgets ", prNumber: 12, decision: "closed", reason: "superseded_by_duplicate", closedAt: "t" }, { eventLedger: ledger }) as Record<string, unknown>;
75+
expect(entry.type).toBe(MINER_PR_OUTCOME_EVENT);
76+
expect(entry.repoFullName).toBe("acme/widgets");
77+
expect(entry.payload).toEqual({ prNumber: 12, decision: "closed", closedAt: "t", reason: "superseded_by_duplicate" });
78+
expect(MINER_PR_OUTCOME_DECISIONS).toEqual(["merged", "closed"]);
79+
});
80+
});
81+
82+
describe("readPrOutcomes (#4274)", () => {
83+
it("reduces the append-only stream to the latest outcome per repo/PR", () => {
84+
const ledger = mockLedger();
85+
recordPrOutcomeSnapshot({ repoFullName: "acme/widgets", prNumber: 1, decision: "closed", reason: "gate_close" }, { eventLedger: ledger });
86+
recordPrOutcomeSnapshot({ repoFullName: "acme/widgets", prNumber: 1, decision: "merged" }, { eventLedger: ledger }); // supersedes
87+
recordPrOutcomeSnapshot({ repoFullName: "acme/other", prNumber: 2, decision: "closed" }, { eventLedger: ledger });
88+
const latest = readPrOutcomes(ledger, { repoFullName: "acme/widgets" });
89+
expect(latest.get("acme/widgets:1")).toEqual({ repoFullName: "acme/widgets", prNumber: 1, decision: "merged", closedAt: null, reason: null });
90+
expect(latest.has("acme/other:2")).toBe(false); // filtered out by the repo filter
91+
});
92+
93+
it("skips foreign event types, missing repos, and malformed payloads; empty when the ledger can't read", () => {
94+
const ledger = mockLedger();
95+
ledger._events.push(
96+
{ type: "manage_pr_update", repoFullName: "acme/widgets", payload: { prNumber: 1 } }, // foreign type
97+
{ type: MINER_PR_OUTCOME_EVENT, repoFullName: " ", payload: { prNumber: 1, decision: "merged" } }, // blank repo
98+
{ type: MINER_PR_OUTCOME_EVENT, repoFullName: "acme/widgets", payload: { prNumber: 0, decision: "merged" } }, // bad payload
99+
{ type: MINER_PR_OUTCOME_EVENT, repoFullName: "acme/widgets", payload: { prNumber: 5, decision: "merged" } }, // kept
100+
);
101+
expect([...readPrOutcomes(ledger).keys()]).toEqual(["acme/widgets:5"]);
102+
// a ledger without readEvents, and one whose readEvents returns a non-array, both reduce to an empty map
103+
expect(readPrOutcomes({} as never).size).toBe(0);
104+
expect(readPrOutcomes({ readEvents: () => null } as never).size).toBe(0);
105+
});
106+
});

0 commit comments

Comments
 (0)