|
| 1 | +# Flywheel: after a merge to the base branch, find one open PR that is |
| 2 | +# out-of-date (behind base) but otherwise passing + conflict-free, and press |
| 3 | +# its "Update branch" button. Updating retriggers the PR's CI; if it goes green |
| 4 | +# and the PR has auto-merge enabled, it merges -> another push to base -> this |
| 5 | +# workflow runs again -> the next stale PR gets updated, and so on. |
| 6 | +# |
| 7 | +# Only ONE PR is updated per run, on purpose: updating every stale PR at once |
| 8 | +# makes them all up-to-date simultaneously, the first merges, the rest are |
| 9 | +# instantly stale again, and a full CI run is burned on each for nothing. |
| 10 | +# |
| 11 | +# Uses PROSOPONATOR_PAT (not the default GITHUB_TOKEN) so the branch update is |
| 12 | +# attributed to a real user and therefore retriggers the PR's CI workflows. |
| 13 | + |
| 14 | +name: auto-update-pr |
| 15 | + |
| 16 | +on: |
| 17 | + push: |
| 18 | + branches: [main] |
| 19 | + # backstop: catches the case where a stale PR's CI failed and stalled the |
| 20 | + # flywheel, then the base later moved for some other reason. |
| 21 | + schedule: |
| 22 | + - cron: "*/30 * * * *" |
| 23 | + workflow_dispatch: |
| 24 | + |
| 25 | +permissions: |
| 26 | + contents: read |
| 27 | + pull-requests: read |
| 28 | + |
| 29 | +# Single-flight: never let two runs race to update PRs and double-spend CI. |
| 30 | +concurrency: |
| 31 | + group: auto-update-pr |
| 32 | + cancel-in-progress: false |
| 33 | + |
| 34 | +defaults: |
| 35 | + run: |
| 36 | + shell: bash |
| 37 | + |
| 38 | +jobs: |
| 39 | + update-stale-pr: |
| 40 | + runs-on: ubuntu-latest |
| 41 | + env: |
| 42 | + GH_TOKEN: ${{ secrets.PROSOPONATOR_PAT }} |
| 43 | + REPO: ${{ github.repository }} |
| 44 | + BASE: main |
| 45 | + # how long to wait for GitHub to finish computing mergeability per PR |
| 46 | + POLL_ATTEMPTS: "6" |
| 47 | + POLL_SLEEP_SECONDS: "10" |
| 48 | + steps: |
| 49 | + - name: Update one stale-but-passing PR |
| 50 | + run: | |
| 51 | + set -euo pipefail |
| 52 | +
|
| 53 | + # Open, non-draft PRs targeting BASE that have auto-merge enabled, |
| 54 | + # oldest first (lowest number) for FIFO fairness. |
| 55 | + # Drop the `.autoMergeRequest != null` filter to also flywheel PRs |
| 56 | + # that don't yet have auto-merge enabled. |
| 57 | + mapfile -t prs < <( |
| 58 | + gh pr list --repo "$REPO" --state open --base "$BASE" --limit 100 \ |
| 59 | + --json number,isDraft,autoMergeRequest \ |
| 60 | + --jq '[ .[] |
| 61 | + | select(.isDraft == false) |
| 62 | + | select(.autoMergeRequest != null) |
| 63 | + | .number ] |
| 64 | + | sort |
| 65 | + | .[]' |
| 66 | + ) |
| 67 | +
|
| 68 | + if [ "${#prs[@]}" -eq 0 ]; then |
| 69 | + echo "No open auto-merge PRs targeting $BASE. Nothing to do." |
| 70 | + exit 0 |
| 71 | + fi |
| 72 | +
|
| 73 | + echo "Candidate PRs (auto-merge, targeting $BASE): ${prs[*]}" |
| 74 | +
|
| 75 | + for n in "${prs[@]}"; do |
| 76 | + echo "::group::PR #$n" |
| 77 | +
|
| 78 | + mergeable="" |
| 79 | + state="" |
| 80 | + # Poll until GitHub finishes computing the merge state (it is lazy |
| 81 | + # and returns UNKNOWN right after a base change). |
| 82 | + for attempt in $(seq 1 "$POLL_ATTEMPTS"); do |
| 83 | + read -r mergeable state < <( |
| 84 | + gh pr view "$n" --repo "$REPO" \ |
| 85 | + --json mergeable,mergeStateStatus \ |
| 86 | + --jq '"\(.mergeable) \(.mergeStateStatus)"' |
| 87 | + ) |
| 88 | + echo "attempt $attempt: mergeable=$mergeable mergeStateStatus=$state" |
| 89 | + if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then |
| 90 | + break |
| 91 | + fi |
| 92 | + sleep "$POLL_SLEEP_SECONDS" |
| 93 | + done |
| 94 | +
|
| 95 | + if [ "$mergeable" = "UNKNOWN" ] || [ "$state" = "UNKNOWN" ]; then |
| 96 | + echo "Merge state still computing after polling; skipping PR #$n." |
| 97 | + echo "::endgroup::" |
| 98 | + continue |
| 99 | + fi |
| 100 | +
|
| 101 | + # CONFLICTING => real conflicts, author must resolve. Skip. |
| 102 | + if [ "$mergeable" != "MERGEABLE" ]; then |
| 103 | + echo "PR #$n is not mergeable ($mergeable); skipping." |
| 104 | + echo "::endgroup::" |
| 105 | + continue |
| 106 | + fi |
| 107 | +
|
| 108 | + # mergeStateStatus meanings we care about: |
| 109 | + # BEHIND -> conflict-free, checks/reviews otherwise satisfied, |
| 110 | + # only blocker is being out of date. THIS is the target. |
| 111 | + # CLEAN -> already up to date and ready (auto-merge handles it). |
| 112 | + # BLOCKED/UNSTABLE/DIRTY -> failing/pending checks, missing review, |
| 113 | + # or conflicts: not something a branch update fixes. |
| 114 | + if [ "$state" != "BEHIND" ]; then |
| 115 | + echo "PR #$n mergeStateStatus=$state (not BEHIND); skipping." |
| 116 | + echo "::endgroup::" |
| 117 | + continue |
| 118 | + fi |
| 119 | +
|
| 120 | + echo "PR #$n is behind but passing. Updating its branch..." |
| 121 | + gh api \ |
| 122 | + --method PUT \ |
| 123 | + -H "Accept: application/vnd.github+json" \ |
| 124 | + "/repos/$REPO/pulls/$n/update-branch" |
| 125 | +
|
| 126 | + echo "Updated PR #$n. Done (one per run)." |
| 127 | + echo "::endgroup::" |
| 128 | + exit 0 |
| 129 | + done |
| 130 | +
|
| 131 | + echo "No behind-but-passing PR found this run." |
0 commit comments