This is an automated email from the ASF dual-hosted git repository.
Yicong-Huang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 254faf8eac fix(ci): drop strict BEHIND filter in AutoQueue, iterate
candidates (#4678)
254faf8eac is described below
commit 254faf8eac4199b305f4beaf0385944bb6703b05
Author: Yicong Huang <[email protected]>
AuthorDate: Sat May 2 03:05:21 2026 -0700
fix(ci): drop strict BEHIND filter in AutoQueue, iterate candidates (#4678)
## What changes were proposed in this PR?
In \`.github/workflows/auto-queue.yml\`, replace the \`mergeStateStatus
=== 'BEHIND'\` filter with an iteration over eligible auto-merge PRs
(non-draft, non-conflicting). For each candidate, call \`updateBranch\`;
on failure (already up-to-date / merge conflict / etc.), warn and try
the next. Stop at the first PR that actually got updated.
## Any related issues, documentation, discussions?
Follow-up to #4672. Observed in [run
25248773692](https://github.com/apache/texera/actions/runs/25248773692/job/74037400879):
the workflow fired ~3s after a push to main, when GitHub had not yet
recomputed \`mergeStateStatus\` for open PRs. PR #4652
(\`mergeable=MERGEABLE\`, \`autoMergeRequest != null\`) was a real
candidate but its \`mergeStateStatus\` was \`UNKNOWN\` at query time, so
the strict \`=== 'BEHIND'\` filter dropped it and the run logged "No
auto-merge PRs need updating".
\`mergeStateStatus\` is documented as computed asynchronously, and
there's no reliable bound on how long it stays \`UNKNOWN\` after a
base-branch push. Letting \`updateBranch\` decide whether there's actual
work to do is more robust than depending on a flaky pre-flight signal.
## How was this PR tested?
Not yet — workflow runs only on push-to-main. Will observe behavior on
the next merge.
## Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.7 (Claude Code)
---
.github/workflows/auto-queue.yml | 158 ++++++++++++++++++++++++++++++++-------
1 file changed, 132 insertions(+), 26 deletions(-)
diff --git a/.github/workflows/auto-queue.yml b/.github/workflows/auto-queue.yml
index 74918cd2e3..6bdbde8cec 100644
--- a/.github/workflows/auto-queue.yml
+++ b/.github/workflows/auto-queue.yml
@@ -15,16 +15,36 @@
# limitations under the License.
# Temporary stand-in for GitHub Merge Queue.
-# After every push to main, picks the oldest auto-merge-enabled PR whose head
-# is behind main and merges main into it. If a PAT/App token with workflow
-# write is provided as AUTO_MERGE_TOKEN, the resulting push will retrigger the
-# PR's required checks and let auto-merge fire. With GITHUB_TOKEN only, the
-# branch is updated but downstream workflows on the PR are not retriggered.
-name: AutoQueue
+#
+# 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).
+# * workflow_dispatch: manual smoke test.
+#
+# Strategy: scan open PRs targeting main and pick the oldest eligible PR with
+# mergeStateStatus=BEHIND, then call updateBranch on it. A PR is eligible only
+# if it would actually merge once CI passes — auto-merge enabled, not a draft,
+# not conflicting, reviewDecision=APPROVED, and zero unresolved review threads.
+# This avoids burning CI on PRs blocked on review.
+#
+# 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
+# nothing is BEHIND, exit without retrying — there's no work.
+#
+# Token: needs AUTO_MERGE_TOKEN with contents:write + pull_requests:write so
+# the resulting push retriggers required CI on the PR. Falls back to
+# GITHUB_TOKEN, in which case auto-merge will not actually fire (GITHUB_TOKEN
+# pushes don't trigger downstream workflows).
+name: Auto Queue
on:
push:
branches: [main]
+ schedule:
+ - cron: '0 * * * *'
workflow_dispatch:
permissions:
@@ -45,6 +65,13 @@ jobs:
script: |
const { owner, repo } = context.repo;
+ 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
+ // mergeStateStatus settles within ~30s of a base-branch push;
+ // the tail keeps trying for the rare slow case.
+ const BACKOFFS_MS = [0, 10000, 20000, 30000, 30000, 30000];
+
const query = `
query($owner:String!, $name:String!) {
repository(owner:$owner, name:$name) {
@@ -60,33 +87,112 @@ jobs:
isDraft
mergeable
mergeStateStatus
+ reviewDecision
autoMergeRequest { enabledAt }
+ reviewThreads(first: 100) {
+ nodes { isResolved }
+ }
}
}
}
}`;
- const data = await github.graphql(query, { owner, name: repo });
- const candidates = data.repository.pullRequests.nodes.filter(p =>
- p.autoMergeRequest &&
- !p.isDraft &&
- p.mergeable !== 'CONFLICTING' &&
- p.mergeStateStatus === 'BEHIND'
- );
-
- if (candidates.length === 0) {
- core.info('No auto-merge PRs need updating.');
- return;
+ function classify(p) {
+ if (!p.autoMergeRequest) return 'skip: auto-merge not enabled';
+ if (p.isDraft) return 'skip: draft';
+ if (p.mergeable === 'CONFLICTING') return 'skip:
mergeable=CONFLICTING';
+ if (p.reviewDecision !== 'APPROVED') {
+ return `skip: reviewDecision=${p.reviewDecision || 'NONE'}`;
+ }
+ const threads = p.reviewThreads?.nodes ?? [];
+ const unresolved = threads.filter((t) => !t.isResolved).length;
+ if (unresolved > 0) {
+ return `skip: ${unresolved} unresolved review thread(s)`;
+ }
+ return `eligible: mergeable=${p.mergeable}
state=${p.mergeStateStatus}`;
}
- const pr = candidates[0];
- core.info(`Updating PR #${pr.number}: ${pr.title}`);
+ const start = Date.now();
+
+ for (let attempt = 0; attempt < BACKOFFS_MS.length; attempt++) {
+ if (BACKOFFS_MS[attempt] > 0) {
+ const elapsedS = Math.round((Date.now() - start) / 1000);
+ core.info(
+ `Waiting ${BACKOFFS_MS[attempt] / 1000}s before attempt ` +
+ `${attempt + 1}/${BACKOFFS_MS.length} (elapsed
${elapsedS}s).`
+ );
+ await sleep(BACKOFFS_MS[attempt]);
+ }
+
+ core.startGroup(`Attempt ${attempt + 1}/${BACKOFFS_MS.length}`);
+ const data = await github.graphql(query, { owner, name: repo });
+ const all = data.repository.pullRequests.nodes;
+ core.info(`Scanned ${all.length} open PR(s) targeting main.`);
- try {
- await github.rest.pulls.updateBranch({
- owner, repo, pull_number: pr.number,
- });
- core.info(`PR #${pr.number} update-branch dispatched.`);
- } catch (e) {
- core.setFailed(`updateBranch failed for #${pr.number}:
${e.message}`);
+ const behind = [];
+ const unknown = [];
+ 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);
+ }
+
+ core.info(
+ `Eligible: ${behind.length} BEHIND, ${unknown.length} UNKNOWN,
` +
+ `rest already up-to-date or otherwise blocked.`
+ );
+
+ if (behind.length > 0) {
+ let updated = null;
+ for (const pr of behind) {
+ core.info(`→ updateBranch #${pr.number}`);
+ try {
+ const res = await github.rest.pulls.updateBranch({
+ owner, repo, pull_number: pr.number,
+ });
+ core.info(
+ `✓ #${pr.number} updateBranch dispatched (HTTP
${res.status}).`
+ );
+ updated = pr.number;
+ break;
+ } catch (e) {
+ core.warning(
+ `✗ #${pr.number} updateBranch failed ` +
+ `(status ${e.status ?? '?'}): ${e.message}`
+ );
+ }
+ }
+ core.endGroup();
+ if (updated !== null) {
+ core.info(`Done: #${updated} updated on attempt ${attempt +
1}.`);
+ return;
+ }
+ core.info(
+ 'All BEHIND PRs failed updateBranch this attempt; retrying
after backoff.'
+ );
+ continue;
+ }
+
+ if (unknown.length > 0) {
+ core.info(
+ `No BEHIND PRs yet; ${unknown.length} eligible PR(s) ` +
+ 'still UNKNOWN — retrying after backoff to let GitHub
settle.'
+ );
+ core.endGroup();
+ continue;
+ }
+
+ core.info(
+ 'No BEHIND or UNKNOWN eligible PRs — nothing to do this run.'
+ );
+ core.endGroup();
+ return;
}
+
+ const totalS = Math.round((Date.now() - start) / 1000);
+ core.info(
+ `Exhausted ${BACKOFFS_MS.length} attempt(s) over ${totalS}s ` +
+ `without finding a BEHIND PR to update.`
+ );