Skip to content
Merged
20 changes: 20 additions & 0 deletions tools/github/fixtures/blocked-by-threads.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"number": 915,
"state": "OPEN",
"mergeStateStatus": "BLOCKED",
"autoMergeRequest": null,
"mergeCommit": null,
"statusCheckRollup": [
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "build" },
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "lint" },
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "memory-index-integrity" }
],
"reviewThreads": {
"nodes": [
{ "isResolved": false },
{ "isResolved": false },
{ "isResolved": false },
{ "isResolved": true }
]
}
}
14 changes: 14 additions & 0 deletions tools/github/fixtures/clean-armed-auto-merge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"number": 917,
"state": "OPEN",
"mergeStateStatus": "BLOCKED",
Comment thread
AceHack marked this conversation as resolved.
Outdated
"autoMergeRequest": { "enabledAt": "2026-04-30T14:08:53Z" },
"mergeCommit": null,
"statusCheckRollup": [
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "build" },
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "lint" },
{ "status": "COMPLETED", "conclusion": "NEUTRAL", "name": "skipped-rule" },
{ "status": "COMPLETED", "conclusion": "SKIPPED", "name": "Analyze (csharp)" }
],
"reviewThreads": { "nodes": [{ "isResolved": true }, { "isResolved": true }] }
}
13 changes: 13 additions & 0 deletions tools/github/fixtures/status-context-error.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"number": 999,
"state": "OPEN",
"mergeStateStatus": "BLOCKED",
"autoMergeRequest": null,
"mergeCommit": null,
"statusCheckRollup": [
{ "status": "COMPLETED", "conclusion": "SUCCESS", "name": "build" },
{ "state": "ERROR", "context": "external/integration-check", "description": "upstream failed" },
{ "state": "EXPECTED", "context": "deploy/preview", "description": "queued" }
],
"reviewThreads": { "nodes": [] }
}
347 changes: 347 additions & 0 deletions tools/github/poll-pr-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
#!/usr/bin/env bun
// poll-pr-gate.ts — query GitHub PR gate state for the autonomous loop.
//
// TypeScript+Bun port replacing the inline `gh pr view --json` + jq
// snippets that the poll-the-gate memory file describes
// (memory/feedback_amara_poll_gate_not_ending_holding_is_not_status_2026_04_30.md).
//
// Origin: 5-AI convergence (Amara 2nd, Deepseek 4th, Alexia 5th, Ani 3rd,
// Gemini 4th — all 2026-04-30) on promoting prose-jq to executable.
// Amara's blade: "if the loop uses it every tick, it deserves tests."
Comment thread
AceHack marked this conversation as resolved.
Outdated
//
// This is **v0**: skeleton + minimal happy-path query. Fixtures and
// matrix tests follow in subsequent slices. The memory file should
// stop being the implementation; it should point to this file.
//
// Usage:
// bun tools/github/poll-pr-gate.ts <PR_NUMBER>
// bun tools/github/poll-pr-gate.ts <PR_NUMBER> --owner Lucent-Financial-Group --repo Zeta
// bun tools/github/poll-pr-gate.ts --fixture tools/github/fixtures/blocked-with-threads.json
Comment thread
AceHack marked this conversation as resolved.
Outdated
//
// Output: one JSON object on stdout, shape:
// {
// "number": 917,
// "state": "OPEN" | "MERGED" | "CLOSED",
// "gate": "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE" | "UNKNOWN",
// "checks": { "ok": 23, "inProgress": 0, "pending": 0, "failed": 0 },
// "unresolvedThreads": 0,
// "autoMerge": "armed" | "none",
// "mergeCommit": "0ec21ebe..." | null,
// "nextAction": "wait-ci" | "fix-failed-checks" | "resolve-threads" | "rebase" | "verify-merge" | "none"
// }
//
// Exit codes:
// 0 — query succeeded, JSON emitted
// 1 — invocation / dependency error
// 2 — gh CLI returned non-zero
//
// Required-check semantics (per Amara 2nd's GitHub-docs verification):
// SUCCESS / NEUTRAL / SKIPPED are merge-satisfying; FAILURE / CANCELLED
// / TIMED_OUT / STARTUP_FAILURE / ACTION_REQUIRED / STALE block.
Comment thread
AceHack marked this conversation as resolved.

import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";

type GateState = "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE" | "UNKNOWN";
type NextAction =
| "wait-ci"
| "fix-failed-checks"
| "resolve-threads"
| "rebase"
| "verify-merge"
| "none";
Comment thread
AceHack marked this conversation as resolved.

interface CheckRollupItem {
status?: string;
conclusion?: string;
name?: string;
}

interface ReviewThreadNode {
isResolved: boolean;
}

interface PullRequestData {
number: number;
state: string;
mergeStateStatus: string;
autoMergeRequest: { enabledAt?: string } | null;
mergeCommit: { oid: string } | null;
statusCheckRollup: CheckRollupItem[];
reviewThreads: { nodes: ReviewThreadNode[] };
}

interface GateReport {
number: number;
state: string;
gate: GateState;
checks: {
ok: number;
inProgress: number;
pending: number;
failed: number;
};
unresolvedThreads: number;
autoMerge: "armed" | "none";
mergeCommit: string | null;
nextAction: NextAction;
}

const OK_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
const BLOCKING_CONCLUSIONS = new Set([
"FAILURE",
"CANCELLED",
"TIMED_OUT",
"STARTUP_FAILURE",
"ACTION_REQUIRED",
"STALE",
// StatusContext-class blocking states (per Codex P1):
"ERROR",
]);
const PENDING_STATUSES = new Set([
"QUEUED",
"PENDING",
// StatusContext-class pending state (per Codex P1):
"EXPECTED",
]);
Comment thread
AceHack marked this conversation as resolved.

function classifyChecks(rollup: CheckRollupItem[]): GateReport["checks"] {
let ok = 0;
let inProgress = 0;
let pending = 0;
let failed = 0;
for (const c of rollup) {
if (c.status === "IN_PROGRESS") {
inProgress++;
continue;
}
if (c.status && PENDING_STATUSES.has(c.status)) {
pending++;
continue;
}
if (c.conclusion && OK_CONCLUSIONS.has(c.conclusion)) {
ok++;
continue;
}
if (c.conclusion && BLOCKING_CONCLUSIONS.has(c.conclusion)) {
failed++;
}
}
return { ok, inProgress, pending, failed };
}

function classifyGate(
mergeStateStatus: string,
state: string,
checks: GateReport["checks"],
unresolvedThreads: number,
Comment thread
AceHack marked this conversation as resolved.
): GateState {
if (state === "MERGED") return "CLEAN";
if (state === "CLOSED") return "CLEAN";
if (mergeStateStatus === "DIRTY") return "DIRTY";
if (mergeStateStatus === "UNSTABLE") return "UNSTABLE";
if (checks.failed > 0) return "BLOCKED";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prioritize CLEAN merge state over raw failed-check count

classifyGate marks any PR with checks.failed > 0 as BLOCKED before honoring mergeStateStatus === "CLEAN". GitHub’s MergeStateStatus.CLEAN explicitly means the PR is mergeable even when some commit statuses are non-passing, so this path misclassifies PRs that only have non-required failing checks and drives nextAction into fix-failed-checks unnecessarily. In those cases the loop can keep working a non-blocking failure while the PR is already mergeable.

Useful? React with 👍 / 👎.

if (mergeStateStatus === "BLOCKED") return "BLOCKED";

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: classifyGate can return CLEAN based solely on mergeStateStatus === "CLEAN" even when checks are still pending/in-progress (since checks.inProgress/checks.pending aren’t considered here). That can yield a contradictory report like gate=CLEAN but nextAction=wait-ci. Consider factoring pending/in-progress into the gate classification (or returning UNSTABLE/UNKNOWN until all required checks are terminal+OK).

Suggested change
if (mergeStateStatus === "BLOCKED") return "BLOCKED";
if (mergeStateStatus === "BLOCKED") return "BLOCKED";
// A PR is not clean while required checks are still non-terminal; otherwise
// we can report gate=CLEAN while nextAction=wait-ci, which is contradictory.
if (checks.inProgress > 0 || checks.pending > 0) return "UNKNOWN";

Copilot uses AI. Check for mistakes.
if (mergeStateStatus === "CLEAN" && unresolvedThreads === 0) return "CLEAN";
return "UNKNOWN";
Comment thread
AceHack marked this conversation as resolved.
}

function nextAction(report: Omit<GateReport, "nextAction">): NextAction {
if (report.state === "MERGED") return "verify-merge";
if (report.gate === "DIRTY") return "rebase";
if (report.checks.failed > 0) return "fix-failed-checks";
if (report.unresolvedThreads > 0) return "resolve-threads";
Comment thread
AceHack marked this conversation as resolved.
if (report.checks.inProgress > 0 || report.checks.pending > 0) {
return "wait-ci";
}
Comment thread
AceHack marked this conversation as resolved.
return "none";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Report actionable state when gate is BLOCKED

nextAction falls back to "none" whenever a PR is not merged/closed/dirty and has no failed checks, unresolved threads, or pending checks. That creates a dead-end for valid mergeStateStatus: "BLOCKED" cases caused by other protections (e.g., required Copilot review/ruleset gates), where the PR is still unmergeable but this tool reports no next step. In this repo’s documented protection model, that state exists, so returning none here can stall the autonomous loop on still-blocked PRs.

Useful? React with 👍 / 👎.

}

function buildReport(pr: PullRequestData): GateReport {
const checks = classifyChecks(pr.statusCheckRollup ?? []);
const unresolvedThreads = (pr.reviewThreads?.nodes ?? []).filter(
(t) => !t.isResolved,
).length;
const gate = classifyGate(
pr.mergeStateStatus,
pr.state,
checks,
unresolvedThreads,
);
const partial: Omit<GateReport, "nextAction"> = {
number: pr.number,
state: pr.state,
gate,
checks,
unresolvedThreads,
autoMerge: pr.autoMergeRequest ? "armed" : "none",
mergeCommit: pr.mergeCommit?.oid ?? null,
};
return { ...partial, nextAction: nextAction(partial) };
}

function fetchPR(
owner: string,
repo: string,
number: number,
): PullRequestData {
// Use `gh pr view --json` which flattens StatusCheckRollup into a uniform
// array (CheckRun + StatusContext both surfaced as items with status/
// conclusion/name fields). Pair with a separate `gh api graphql` call for
// reviewThreads since `gh pr view --json reviewThreads` is not supported.
const prResult = spawnSync(
"gh",
[
"pr",
"view",
String(number),
"--repo",
`${owner}/${repo}`,
"--json",
Comment thread
AceHack marked this conversation as resolved.
"number,state,mergeStateStatus,autoMergeRequest,mergeCommit,statusCheckRollup",
],
Comment thread
AceHack marked this conversation as resolved.
{ encoding: "utf8" },
);
if (prResult.status !== 0) {
process.stderr.write(`gh pr view failed: ${prResult.stderr}\n`);
process.exit(2);
}
const pr = JSON.parse(prResult.stdout);

// Paginate review threads — discussion-heavy PRs can have >50.
// gh's --paginate flag follows pageInfo for any cursor field named
// `endCursor`; we expose the cursor in our query so it works.
const threadsResult = spawnSync(
"gh",
[
"api",
"graphql",
"--paginate",
"-f",
`query=query($o:String!,$r:String!,$n:Int!,$endCursor:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$endCursor){pageInfo{hasNextPage endCursor}nodes{isResolved}}}}}`,
"-F",
`o=${owner}`,
"-F",
`r=${repo}`,
"-F",
`n=${number}`,
],
{ encoding: "utf8" },
);
if (threadsResult.status !== 0) {
process.stderr.write(`gh api graphql (threads) failed: ${threadsResult.stderr}\n`);
process.exit(2);
}
// gh --paginate emits one JSON object per page on stdout, separated by
// newlines (NDJSON-style for gh-graphql output). Aggregate the nodes.
Comment thread
AceHack marked this conversation as resolved.
Outdated
const allNodes: ReviewThreadNode[] = [];
for (const line of threadsResult.stdout.split("\n")) {
if (!line.trim()) continue;
const parsed = JSON.parse(line);
const nodes: ReviewThreadNode[] =
parsed.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
allNodes.push(...nodes);
}
const reviewThreads = { nodes: allNodes };

return {
...pr,
statusCheckRollup: normalizeRollup(pr.statusCheckRollup ?? []),
reviewThreads,
};
}

// StatusContext items (gh pr view --json output for non-CheckRun checks)
// expose .state instead of .status/.conclusion. Normalise to the CheckRun
// shape so classifyChecks's OK_CONCLUSIONS / BLOCKING_CONCLUSIONS sets
// pick them up. StatusContext states per GitHub schema: SUCCESS | FAILURE
// | PENDING | ERROR | EXPECTED. PENDING and EXPECTED both map to
// status=PENDING (CI still running); the rest map to status=COMPLETED
// with state forwarded as conclusion (per Codex P1).
const PENDING_STATE_LITERALS = new Set(["PENDING", "EXPECTED"]);
function normalizeRollup(rollup: unknown[]): CheckRollupItem[] {
return rollup.map((raw) => {
const c = raw as Record<string, unknown>;
Comment thread
AceHack marked this conversation as resolved.
if (typeof c.state === "string" && c.status === undefined) {
const state = c.state as string;
const isPendingState = PENDING_STATE_LITERALS.has(state);
const name =
(c.context as string | undefined) ?? (c.name as string | undefined);
const item: CheckRollupItem = {
status: isPendingState ? "PENDING" : "COMPLETED",
};
if (name !== undefined) item.name = name;
if (!isPendingState) item.conclusion = state;
Comment thread
AceHack marked this conversation as resolved.
return item;
}
return c as CheckRollupItem;
});
}

function loadFixture(path: string): PullRequestData {
const raw = JSON.parse(readFileSync(path, "utf8")) as PullRequestData;
// Apply the same StatusContext-state normalization as fetchPR so fixture
// mode and live mode classify identically (Codex P1).
return {
...raw,
statusCheckRollup: normalizeRollup(raw.statusCheckRollup ?? []),
};
Comment thread
AceHack marked this conversation as resolved.
Outdated
}

interface ParsedArgs {
fixture?: string;
owner: string;
repo: string;
number?: number;
Comment thread
AceHack marked this conversation as resolved.
}

function parseArgs(argv: string[]): ParsedArgs {
const out: ParsedArgs = {
owner: "Lucent-Financial-Group",
repo: "Zeta",
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
if (arg === "--fixture") {
const v = argv[++i];
if (v !== undefined) out.fixture = v;
} else if (arg === "--owner") {
const v = argv[++i];
if (v !== undefined) out.owner = v;
} else if (arg === "--repo") {
const v = argv[++i];
if (v !== undefined) out.repo = v;
} else if (/^\d+$/.test(arg)) {
out.number = Number.parseInt(arg, 10);
Comment thread
AceHack marked this conversation as resolved.
Outdated
} else if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: poll-pr-gate.ts <PR_NUMBER> [--owner X] [--repo Y]\n" +
" poll-pr-gate.ts --fixture path/to/fixture.json\n",
);
process.exit(0);
} else {
process.stderr.write(`unknown arg: ${arg}\n`);
process.exit(1);
}
}
return out;
}

function main(): void {
const argv = process.argv.slice(2);
const args = parseArgs(argv);
Comment on lines +359 to +402

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: parseArgs (and requireValue) call process.exit(...) directly. Since main is exported, this makes the module hard to test/compose (callers can’t recover from parse errors). Consider having parseArgs return a typed {kind:"ok"|"help"|"error"} result and letting main decide the final exit code/output, consistent with other tools/ scripts.

Suggested change
function parseArgs(argv: string[]): ParsedArgs {
const out: ParsedArgs = {
owner: "Lucent-Financial-Group",
repo: "Zeta",
};
const requireValue = (flag: string, v: string | undefined): string => {
if (v === undefined || v.startsWith("--")) {
process.stderr.write(`${flag} requires a value\n`);
process.exit(1);
}
return v;
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
if (arg === "--fixture") {
out.fixture = requireValue("--fixture", argv[++i]);
} else if (arg === "--owner") {
out.owner = requireValue("--owner", argv[++i]);
} else if (arg === "--repo") {
out.repo = requireValue("--repo", argv[++i]);
} else if (/^\d+$/.test(arg)) {
const parsed = Number.parseInt(arg, 10);
if (parsed <= 0) {
process.stderr.write("PR number must be a positive integer\n");
process.exit(1);
}
out.number = parsed;
} else if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: poll-pr-gate.ts <PR_NUMBER> [--owner X] [--repo Y]\n" +
" poll-pr-gate.ts --fixture path/to/fixture.json\n",
);
process.exit(0);
} else {
process.stderr.write(`unknown arg: ${arg}\n`);
process.exit(1);
}
}
return out;
}
export function main(argv: string[]): number {
const args = parseArgs(argv);
type ParseArgsResult =
| { kind: "ok"; args: ParsedArgs }
| { kind: "help"; message: string }
| { kind: "error"; message: string };
type RequireValueResult =
| { kind: "ok"; value: string }
| { kind: "error"; message: string };
function parseArgs(argv: string[]): ParseArgsResult {
const out: ParsedArgs = {
owner: "Lucent-Financial-Group",
repo: "Zeta",
};
const requireValue = (
flag: string,
v: string | undefined,
): RequireValueResult => {
if (v === undefined || v.startsWith("--")) {
return { kind: "error", message: `${flag} requires a value` };
}
return { kind: "ok", value: v };
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === undefined) continue;
if (arg === "--fixture") {
const fixture = requireValue("--fixture", argv[++i]);
if (fixture.kind === "error") return fixture;
out.fixture = fixture.value;
} else if (arg === "--owner") {
const owner = requireValue("--owner", argv[++i]);
if (owner.kind === "error") return owner;
out.owner = owner.value;
} else if (arg === "--repo") {
const repo = requireValue("--repo", argv[++i]);
if (repo.kind === "error") return repo;
out.repo = repo.value;
} else if (/^\d+$/.test(arg)) {
const parsed = Number.parseInt(arg, 10);
if (parsed <= 0) {
return { kind: "error", message: "PR number must be a positive integer" };
}
out.number = parsed;
} else if (arg === "--help" || arg === "-h") {
return {
kind: "help",
message:
"Usage: poll-pr-gate.ts <PR_NUMBER> [--owner X] [--repo Y]\n" +
" poll-pr-gate.ts --fixture path/to/fixture.json\n",
};
} else {
return { kind: "error", message: `unknown arg: ${arg}` };
}
}
return { kind: "ok", args: out };
}
export function main(argv: string[]): number {
const parsedArgs = parseArgs(argv);
if (parsedArgs.kind === "help") {
process.stdout.write(parsedArgs.message);
return 0;
}
if (parsedArgs.kind === "error") {
process.stderr.write(`${parsedArgs.message}\n`);
return 1;
}
const args = parsedArgs.args;

Copilot uses AI. Check for mistakes.
let pr: PullRequestData;
if (args.fixture) {
pr = loadFixture(args.fixture);
} else if (args.number !== undefined) {
pr = fetchPR(args.owner, args.repo, args.number);
} else {
process.stderr.write("must provide PR number or --fixture\n");
process.exit(1);
}
const report = buildReport(pr);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}

main();
Loading