diff --git a/packages/gittensory-miner/lib/gate-verdict-poller.d.ts b/packages/gittensory-miner/lib/gate-verdict-poller.d.ts new file mode 100644 index 0000000000..8de0be46d7 --- /dev/null +++ b/packages/gittensory-miner/lib/gate-verdict-poller.d.ts @@ -0,0 +1,28 @@ +export type GateVerdict = "merge" | "close" | "hold" | "pending"; + +export const GATE_VERDICTS: readonly GateVerdict[]; + +export function mapGateDisposition(disposition: unknown): GateVerdict; + +export function readGateDisposition(body: unknown): string | null; + +export type PollGateVerdictOptions = { + fetchFn?: (url: string, init: { headers: Record }) => Promise<{ ok: boolean; status?: number; json: () => Promise }>; + sleepFn?: (ms: number) => Promise; + headers?: Record; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; +}; + +export type GateVerdictPollResult = { + verdict: GateVerdict; + disposition: string | null; + attempts: number; + body: unknown; +}; + +export function pollGateVerdict( + url: string, + options?: PollGateVerdictOptions, +): Promise; diff --git a/packages/gittensory-miner/lib/gate-verdict-poller.js b/packages/gittensory-miner/lib/gate-verdict-poller.js new file mode 100644 index 0000000000..c31eb8a769 --- /dev/null +++ b/packages/gittensory-miner/lib/gate-verdict-poller.js @@ -0,0 +1,100 @@ +// Maintainer-gate verdict watcher (#4273): polls the gittensory API for the REAL gate disposition of one of +// the miner's own PRs and maps it to a small typed verdict. The authoritative gate verdict is server-internal +// `gate_decision` state — NOT a GitHub check-run — so `ci-poller.js`'s CI-check-run aggregate (which +// `manage-poll.js`'s `mapPollConclusionToGateVerdict` turns into a pass/block/advisory proxy) is only a +// heuristic. This reads the authoritative source instead. +// +// Read-only: targets the gittensory API (not api.github.com), so it needs NO GitHub token; any auth the polled +// endpoint requires for a contributor's OWN PR is passed via `options.headers` (none required for the public +// open-pr-monitor today). No writes. `ci-poller.js`'s CI-check-run polling is left untouched — CI state and +// gate verdict are two different signals a caller can record independently. +// +// Fully testable via injected `fetchFn`/`sleepFn` (mirrors `ci-poller.js`) — no real network in tests. + +/** The typed gate verdicts, decided ones first, `pending` (not-yet-decided) last. */ +export const GATE_VERDICTS = Object.freeze(["merge", "close", "hold", "pending"]); + +/** + * Map a raw gate disposition string to one of {@link GATE_VERDICTS}. Liberal on synonyms; an unknown, empty, or + * missing disposition maps to `pending` (not-yet-decided) — never a false decided verdict. Pure. + * @param {unknown} disposition + * @returns {"merge" | "close" | "hold" | "pending"} + */ +export function mapGateDisposition(disposition) { + const d = typeof disposition === "string" ? disposition.trim().toLowerCase() : ""; + switch (d) { + case "merge": + case "merged": + case "approved": + case "auto_merge": + return "merge"; + case "close": + case "closed": + case "rejected": + case "auto_close": + return "close"; + case "hold": + case "held": + case "manual_review": + case "action_required": + case "flagged": + return "hold"; + default: + return "pending"; // pending / open / unknown / missing — not yet decided + } +} + +/** + * Read the gate disposition field from an API response body, tolerant of it living at `disposition`, + * `gateDisposition`, or `verdict`. Returns the raw string, or null when absent/non-string. Pure. + * @param {unknown} body + * @returns {string | null} + */ +export function readGateDisposition(body) { + if (!body || typeof body !== "object") return null; + const raw = body.disposition ?? body.gateDisposition ?? body.verdict ?? null; + return typeof raw === "string" ? raw : null; +} + +function backoffDelayMs(attemptIndex, minIntervalMs, maxIntervalMs) { + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(maxIntervalMs, minIntervalMs * 2 ** exponent); +} + +function positiveInt(value, fallback, min, max) { + const n = Number.isInteger(value) ? value : fallback; + return Math.min(max, Math.max(min, n)); +} + +/** + * Poll a gittensory gate-verdict endpoint until it returns a DECIDED verdict (merge/close/hold) or `maxAttempts` + * is exhausted, backing off exponentially while `pending`. Dependencies are injected (`fetchFn`, `sleepFn`) so + * this is fully unit-testable with no real network. Throws on a missing URL or a non-OK HTTP response. + * @param {string} url + * @param {{ fetchFn?: Function, sleepFn?: Function, headers?: Record, + * maxAttempts?: number, minIntervalMs?: number, maxIntervalMs?: number }} [options] + * @returns {Promise<{ verdict: string, disposition: string | null, attempts: number, body: unknown }>} + */ +export async function pollGateVerdict(url, options = {}) { + if (typeof url !== "string" || !url) throw new Error("invalid_gate_verdict_url"); + const fetchFn = options.fetchFn ?? fetch; + const sleepFn = options.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const headers = options.headers ?? {}; + const maxAttempts = positiveInt(options.maxAttempts, 10, 1, 20); + const minIntervalMs = positiveInt(options.minIntervalMs, 2000, 1, 60 * 60_000); + const maxIntervalMs = positiveInt(options.maxIntervalMs, 60_000, 1, 60 * 60_000); + + let latest = { verdict: "pending", disposition: null, attempts: 0, body: null }; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const response = await fetchFn(url, { headers }); + if (!response || !response.ok) throw new Error(`gate_verdict_http_${response ? response.status : "error"}`); + const body = await response.json(); + const disposition = readGateDisposition(body); + const verdict = mapGateDisposition(disposition); + latest = { verdict, disposition, attempts: attempt + 1, body }; + if (verdict !== "pending") return latest; // decided — stop + if (attempt === maxAttempts - 1) return latest; // exhausted while still pending + await sleepFn(backoffDelayMs(attempt, minIntervalMs, maxIntervalMs)); + } + return latest; +} diff --git a/test/unit/miner-gate-verdict-poller.test.ts b/test/unit/miner-gate-verdict-poller.test.ts new file mode 100644 index 0000000000..ad7331959f --- /dev/null +++ b/test/unit/miner-gate-verdict-poller.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { + GATE_VERDICTS, + mapGateDisposition, + readGateDisposition, + pollGateVerdict, +} from "../../packages/gittensory-miner/lib/gate-verdict-poller.js"; + +const URL = "https://api.gittensory.test/v1/contributors/dhgoal/open-pr-monitor"; +const okResponse = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +describe("gittensory-miner gate-verdict poller (#4273)", () => { + it("exposes the frozen verdict vocabulary", () => { + expect(GATE_VERDICTS).toEqual(["merge", "close", "hold", "pending"]); + expect(Object.isFrozen(GATE_VERDICTS)).toBe(true); + }); + + it("maps each disposition family (with synonyms, case-insensitive) to a verdict", () => { + expect(mapGateDisposition("merged")).toBe("merge"); + expect(mapGateDisposition("Approved")).toBe("merge"); + expect(mapGateDisposition("closed")).toBe("close"); + expect(mapGateDisposition("REJECTED")).toBe("close"); + expect(mapGateDisposition(" action_required ")).toBe("hold"); + expect(mapGateDisposition("flagged")).toBe("hold"); + }); + + it("maps unknown/missing/non-string dispositions to pending, never a false decided verdict", () => { + expect(mapGateDisposition("open")).toBe("pending"); + expect(mapGateDisposition("")).toBe("pending"); + expect(mapGateDisposition(undefined)).toBe("pending"); + expect(mapGateDisposition(42)).toBe("pending"); + }); + + it("reads the disposition from any of the tolerated field names, else null", () => { + expect(readGateDisposition({ disposition: "merge" })).toBe("merge"); + expect(readGateDisposition({ gateDisposition: "hold" })).toBe("hold"); + expect(readGateDisposition({ verdict: "closed" })).toBe("closed"); + expect(readGateDisposition({ other: "x" })).toBeNull(); + expect(readGateDisposition({ disposition: 7 })).toBeNull(); // non-string + expect(readGateDisposition(null)).toBeNull(); + }); + + it("throws on a missing URL", async () => { + await expect(pollGateVerdict("")).rejects.toThrow(/invalid_gate_verdict_url/); + }); + + it("returns a decided verdict on the first attempt without sleeping", async () => { + const fetchFn = vi.fn().mockResolvedValue(okResponse({ disposition: "merge" })); + const sleepFn = vi.fn().mockResolvedValue(undefined); + const result = await pollGateVerdict(URL, { fetchFn, sleepFn }); + expect(result).toMatchObject({ verdict: "merge", disposition: "merge", attempts: 1 }); + expect(sleepFn).not.toHaveBeenCalled(); + }); + + it("backs off while pending, then returns the decided verdict", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(okResponse({ disposition: "pending" })) + .mockResolvedValueOnce(okResponse({ disposition: "pending" })) + .mockResolvedValueOnce(okResponse({ disposition: "close" })); + const sleepFn = vi.fn().mockResolvedValue(undefined); + const result = await pollGateVerdict(URL, { fetchFn, sleepFn, minIntervalMs: 100, maxIntervalMs: 1000 }); + expect(result.verdict).toBe("close"); + expect(result.attempts).toBe(3); + expect(sleepFn).toHaveBeenCalledTimes(2); + expect(sleepFn).toHaveBeenNthCalledWith(1, 100); // 100 * 2^0 + expect(sleepFn).toHaveBeenNthCalledWith(2, 200); // 100 * 2^1 + }); + + it("stops at maxAttempts and returns the last pending verdict when never decided", async () => { + const fetchFn = vi.fn().mockResolvedValue(okResponse({ disposition: "pending" })); + const sleepFn = vi.fn().mockResolvedValue(undefined); + const result = await pollGateVerdict(URL, { fetchFn, sleepFn, maxAttempts: 3 }); + expect(result.verdict).toBe("pending"); + expect(result.attempts).toBe(3); + expect(fetchFn).toHaveBeenCalledTimes(3); + expect(sleepFn).toHaveBeenCalledTimes(2); // no sleep after the final attempt + }); + + it("throws on a non-OK HTTP response", async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}) }); + await expect(pollGateVerdict(URL, { fetchFn, sleepFn: vi.fn() })).rejects.toThrow(/gate_verdict_http_503/); + }); +});