Skip to content
Merged
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
112 changes: 104 additions & 8 deletions .github/workflows/auto-queue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@
#
# Triggers:
# * push to main: advance the queue right after a merge.
# * hourly cron: catch PRs that became BEHIND while no merge happened
# (e.g. a force-push to base, or a PR enabling auto-merge after the
# last main push).
# * pull_request {auto_merge_enabled, ready_for_review}: a PR just
# became eligible — kick the queue without waiting for cron.
# * pull_request_review {submitted}: an approval may have just made
# a PR eligible (script filters non-approval review states).
# * workflow_run {Required Checks, completed}: the head PR's CI
# just finished. On success, auto-merge fires and the next push to
# main triggers us; on failure, the head PR's CI moves from PENDING
# to FAILURE so the in-flight guard releases — this trigger gives
# us a same-second kick instead of waiting on cron.
# * 5-minute cron: bounded safety net for any missed event delivery
# and for PRs that became BEHIND without producing any of the above.
# * workflow_dispatch: manual smoke test.
#
# Strategy: scan open PRs targeting main and pick the oldest eligible PR with
Expand All @@ -29,6 +37,15 @@
# not conflicting, reviewDecision=APPROVED, and zero unresolved review threads.
# This avoids burning CI on PRs blocked on review.
#
# In-flight guard: if any eligible PR is already past the BEHIND state and
# its required CI is still running (mergeStateStatus != BEHIND and
# statusCheckRollup state is PENDING/EXPECTED), the run exits without
# bumping anyone else. That PR is the queue head; bumping a different PR
# while it is in flight would just preempt CI capacity for a PR that
# would still need re-bumping after the head merges. PRs that are
# BEHIND with PENDING checks do NOT count as in-flight — that CI is on
# pre-update code and would need to re-run after updateBranch anyway.
#
# mergeStateStatus is computed asynchronously and is UNKNOWN for a window
# after a base-branch push. If at least one eligible PR is UNKNOWN, retry
# with backoff up to ~2min to let it settle. If everything is settled and
Expand All @@ -43,8 +60,15 @@ name: Auto Queue
on:
push:
branches: [main]
pull_request:
types: [auto_merge_enabled, ready_for_review]
pull_request_review:
types: [submitted]
workflow_run:
workflows: [Required Checks]
types: [completed]
schedule:
- cron: '0 * * * *'
- cron: '*/5 * * * *'
workflow_dispatch:

permissions:
Expand All @@ -65,6 +89,22 @@ jobs:
script: |
const { owner, repo } = context.repo;

// pull_request_review fires for any submitted review (Comment /
// Approve / Request changes). Only Approve can newly satisfy the
// reviewDecision=APPROVED gate, so other states are pure no-ops
// worth short-circuiting before the GraphQL call.
if (
context.eventName === 'pull_request_review' &&
context.payload.review?.state !== 'approved'
) {
core.info(
`Skip: pull_request_review state=` +
`${context.payload.review?.state} (only "approved" can ` +
`change queue eligibility).`
);
return;
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 0, 10, 20, 30, 30, 30 = 120s total wall-clock budget across
// attempts. Short ramp catches the common case where
Expand Down Expand Up @@ -92,6 +132,13 @@ jobs:
reviewThreads(first: 100) {
nodes { isResolved }
}
commits(last: 1) {
nodes {
commit {
statusCheckRollup { state }
}
}
}
}
}
}
Expand Down Expand Up @@ -125,25 +172,74 @@ jobs:
}

core.startGroup(`Attempt ${attempt + 1}/${BACKOFFS_MS.length}`);
const data = await github.graphql(query, { owner, name: repo });
let data;
try {
data = await github.graphql(query, { owner, name: repo });
} catch (e) {
// Transient GitHub API failures (5xx, "terminated", etc.)
// shouldn't kill the whole run — the backoff loop is exactly
// the right place to absorb them. Try again next attempt.
core.warning(
`GraphQL query failed (status ${e.status ?? '?'}): ` +
`${e.message}. Retrying after backoff.`
);
core.endGroup();
continue;
}
const all = data.repository.pullRequests.nodes;
core.info(`Scanned ${all.length} open PR(s) targeting main.`);

const behind = [];
const unknown = [];
const inFlight = [];
for (const p of all) {
const verdict = classify(p);
core.info(` #${p.number} ${verdict} — ${p.title}`);
if (!verdict.startsWith('eligible')) continue;
if (p.mergeStateStatus === 'BEHIND') behind.push(p);
else if (p.mergeStateStatus === 'UNKNOWN') unknown.push(p);
if (p.mergeStateStatus === 'BEHIND') {
behind.push(p);
continue;
}
if (p.mergeStateStatus === 'UNKNOWN') {
unknown.push(p);
continue;
}
// Eligible AND not BEHIND/UNKNOWN: this PR is ahead of any
// BEHIND PR in the queue. Treat it as in-flight only if its
// current required CI is actually working toward a merge.
// PENDING/EXPECTED (CI still running on the with-main code)
// means "wait for it"; SUCCESS (about to auto-merge) means
// "wait for it"; FAILURE/ERROR (CI failed) is NOT in-flight
// — auto-merge will not fire, queue can advance past it.
const ciState =
p.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state;
if (
ciState === 'PENDING' ||
ciState === 'EXPECTED' ||
ciState === 'SUCCESS'
) {
inFlight.push({ pr: p, ciState });
}
}

core.info(
`Eligible: ${behind.length} BEHIND, ${unknown.length} UNKNOWN, ` +
`rest already up-to-date or otherwise blocked.`
`${inFlight.length} in-flight (queue head still merging), ` +
`rest blocked on failed CI or non-CI gates.`
);

if (inFlight.length > 0) {
const head = inFlight[0];
core.info(
`Skip: PR #${head.pr.number} is in flight ` +
`(state=${head.pr.mergeStateStatus}, ci=${head.ciState}). ` +
`Letting it finish to avoid preempting CI on a PR we may ` +
`need to re-bump.`
);
core.endGroup();
return;
}

if (behind.length > 0) {
let updated = null;
for (const pr of behind) {
Expand Down
Loading