From 3cb50b6b8bbfd6efd7691c2e1786173afd7a958f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:42:19 -0700 Subject: [PATCH] fix(review): auto-merge reads LIVE mergeable_state (green+approved PRs were stuck OPEN) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With requireApprovals=0, a green+approved PR should auto-merge — but the planner read the STORED mergeableState, which lags GitHub's async recompute after gittensory[bot]'s own APPROVE flips blocked→clean. Result: PRs stuck OPEN at mergeState=CLEAN, never merged (e.g. awesome-claude#4206, metagraphed#1438/#1448). Fix: maybeRunAgentMaintenance fetches the LIVE mergeable_state (fetchLivePullRequestMergeState, GET /pulls/{n}) and the planner uses it (falls back to stored on a fetch error); 'unknown' is treated as not-yet-clean so a later trigger/sweep retries. --- src/github/backfill.ts | 13 +++++++++++++ src/queue/processors.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 89ee5ddc61..24d29a128e 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -1980,6 +1980,19 @@ export async function fetchLiveCiAggregate(env: Env, repoFullName: string, headS return { ciState, failingDetails }; } +/** + * Fetch a PR's LIVE `mergeable_state` (clean / dirty / blocked / unstable / behind / has_hooks / unknown). The + * STORED value lags GitHub's async recompute — e.g. right after gittensory[bot]'s own APPROVE flips a `blocked` + * PR to `clean`, the stored row is still `blocked`, which stops an otherwise-eligible PR from auto-merging + * (observed: green+approved PRs stuck OPEN at `mergeState=CLEAN`). The auto-maintain planner uses this so the + * merge decision sees the CURRENT state. `unknown` (GitHub still computing) ⇒ caller treats as not-yet-clean and + * a later trigger / the sweep retries. Best-effort: a fetch error returns undefined (caller falls back to stored). + */ +export async function fetchLivePullRequestMergeState(env: Env, repoFullName: string, prNumber: number, token: string | undefined): Promise { + const result = await githubJsonWithHeaders<{ mergeable_state?: string | null }>(env, repoFullName, `/pulls/${prNumber}`, token).catch(() => undefined); + return result?.data.mergeable_state ?? undefined; +} + async function fetchPullRequestDetailsFromGraphQl( env: Env, repoFullName: string, diff --git a/src/queue/processors.ts b/src/queue/processors.ts index b41383fdf3..f5b9374896 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -65,6 +65,7 @@ import { enqueueRepositoryOpenDataBackfill, fetchAndStorePullRequestFilesForReview, fetchLiveCiAggregate, + fetchLivePullRequestMergeState, refreshContributorActivity, refreshInstallationHealth, refreshPullRequestDetails, @@ -605,10 +606,13 @@ async function maybeRunAgentMaintenance( // planner uses this to NEVER approve/merge a PR whose CI isn't green, to CLOSE a red-CI non-owner PR (citing // the failing checks) / HOLD the owner's, and to DEFER entirely while CI is still pending. const ciToken = await createInstallationToken(env, installationId).catch(() => undefined); - const [changedFiles, hardGuardrailGlobs, ciAggregate] = await Promise.all([ + const [changedFiles, hardGuardrailGlobs, ciAggregate, liveMergeState] = await Promise.all([ resolvePullRequestFilesForReview(env, { installationId, repoFullName, pullNumber: pr.number }), loadHardGuardrailGlobs(env, repoFullName), fetchLiveCiAggregate(env, repoFullName, pr.headSha, ciToken ?? env.GITHUB_PUBLIC_TOKEN), + // Live mergeable_state — the stored one lags GitHub's async recompute after the bot's own approve, which + // otherwise leaves a green+approved PR stuck OPEN at mergeState=CLEAN (never auto-merged). + fetchLivePullRequestMergeState(env, repoFullName, pr.number, ciToken ?? env.GITHUB_PUBLIC_TOKEN), ]); const changedPaths = changedFiles.map((file) => file.path).filter((path) => path.length > 0); const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")) : ""; @@ -629,7 +633,7 @@ async function maybeRunAgentMaintenance( ciState: ciAggregate.ciState, failingCheckNames: ciAggregate.failingDetails.map((detail) => detail.name), pr: { - mergeableState: pr.mergeableState, + mergeableState: liveMergeState ?? pr.mergeableState, reviewDecision: pr.reviewDecision, slopRisk: pr.slopRisk, labels: pr.labels,