Skip to content
Closed
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)

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.
Expand Down
32 changes: 32 additions & 0 deletions packages/gittensory-miner/lib/pr-outcome.d.ts
Original file line number Diff line number Diff line change
@@ -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<EventLedger, "appendEvent"> },
): { payload: PrOutcomePayload; event: LedgerEntry };

export function readPrOutcomes(
eventLedger: Pick<EventLedger, "readEvents">,
filter?: ReadEventsFilter,
): Map<string, PrOutcomeSnapshot>;
69 changes: 69 additions & 0 deletions packages/gittensory-miner/lib/pr-outcome.js
Original file line number Diff line number Diff line change
@@ -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;
}
87 changes: 87 additions & 0 deletions test/unit/miner-pr-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});