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
28 changes: 28 additions & 0 deletions packages/gittensory-miner/lib/gate-verdict-poller.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }) => Promise<{ ok: boolean; status?: number; json: () => Promise<unknown> }>;
sleepFn?: (ms: number) => Promise<void>;
headers?: Record<string, string>;
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<GateVerdictPollResult>;
100 changes: 100 additions & 0 deletions packages/gittensory-miner/lib/gate-verdict-poller.js
Original file line number Diff line number Diff line change
@@ -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<string,string>,
* 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;
}
84 changes: 84 additions & 0 deletions test/unit/miner-gate-verdict-poller.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});