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 }] }
}
295 changes: 295 additions & 0 deletions tools/github/poll-pr-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
#!/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" | "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"
| "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",
]);
Comment thread
AceHack marked this conversation as resolved.
const PENDING_STATUSES = new Set(["QUEUED", "PENDING"]);

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 "resolve-threads";
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);

const threadsResult = spawnSync(
"gh",
[
"api",
"graphql",
"-f",
`query=query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:50){nodes{isResolved}}}}}`,
Comment thread
AceHack marked this conversation as resolved.
Outdated
"-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);
}
const parsed = JSON.parse(threadsResult.stdout);
const reviewThreads =
parsed.data?.repository?.pullRequest?.reviewThreads ?? { nodes: [] };

// gh pr view returns StatusContext items with .state instead of
// .status/.conclusion; normalise to the CheckRun shape.
const rollup = (pr.statusCheckRollup ?? []).map(
(c: Record<string, unknown>) => {
if (typeof c.state === "string" && c.status === undefined) {
const state = c.state as string;
return {
name: (c.context as string | undefined) ?? (c.name as string | undefined),
status: state === "PENDING" ? "PENDING" : "COMPLETED",
conclusion: state === "PENDING" ? undefined : state,
Comment thread
AceHack marked this conversation as resolved.
Outdated
};
}
return c;
},
);
return { ...pr, statusCheckRollup: rollup, reviewThreads };
}

function loadFixture(path: string): PullRequestData {
return JSON.parse(readFileSync(path, "utf8")) as PullRequestData;
}

function parseArgs(argv: string[]): {
fixture?: string;
owner: string;
repo: string;
number?: number;
Comment thread
AceHack marked this conversation as resolved.
} {
let fixture: string | undefined;
let owner = "Lucent-Financial-Group";
let repo = "Zeta";
let number: number | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--fixture") fixture = argv[++i];
else if (arg === "--owner") owner = argv[++i];
else if (arg === "--repo") repo = argv[++i];
else if (/^\d+$/.test(arg)) number = Number.parseInt(arg, 10);
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 { fixture, owner, repo, number };
}

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