99# fail: <check name> -- one line per failed check
1010# progress: <settled>/<total> settled, <f> fail -- at most every 5th poll
1111# queue: entered at <n> | position <n> <state> | left the queue
12+ # -- "building" for <n> over REST
1213# DONE checks pass|fail (<p> pass, <f> fail) -- exits here without --merge
1314# DONE merged|closed|dequeued -- exits here with --merge
1415# warn: ... -- a failed poll; keeps going
1718# is one small GraphQL query; the full check list (several pages for a PR
1819# here) is fetched only when the rollup state changes or every 5th poll.
1920# A rate-limited poll pauses for 10 minutes instead of retrying.
21+ #
22+ # Claude Code cloud sessions can't reach GraphQL (gh pr view, gh pr checks
23+ # and gh api graphql all get a 403 there), so the first such 403 switches
24+ # the script to REST for good: each poll then reads the PR, its check runs
25+ # and commit statuses, and the base branch's required checks stand in for
26+ # the rollup. REST has no merge-queue entry, so with --merge the queue
27+ # lines track the PR's merge_group runs instead and carry no position.
2028# Exit status: 0 on pass/merged, 1 on fail/closed/dequeued, 2 on bad usage.
2129set -uo pipefail
2230
@@ -25,48 +33,124 @@ while [ $# -gt 0 ]; do
2533 case " $1 " in
2634 --merge) merge=1 ;;
2735 --interval) interval=" $2 " ; shift ;;
28- -h|--help) sed -n ' 2,15p ' " $0 " ; exit 0 ;;
36+ -h|--help) sed -n ' 2,28p ' " $0 " ; exit 0 ;;
2937 [0-9]* ) pr=" $1 " ;;
3038 * ) echo " usage: $0 [PR] [--merge] [--interval SECONDS]" >&2 ; exit 2 ;;
3139 esac
3240 shift
3341done
3442
35- if [ -z " $pr " ]; then
36- pr=$( gh pr view --json number -q .number 2> /dev/null) || { echo " no PR for the current branch" >&2 ; exit 2; }
43+ repo=${GH_REPO:- }
44+ if [ -z " $repo " ]; then
45+ url=$( git remote get-url origin 2> /dev/null) || url=" "
46+ repo=$( sed -nE ' s#^.*[:/]([^/:]+/[^/:]+)$#\1#p' <<< " ${url%.git}" )
3747fi
38- repo=$( gh repo view --json nameWithOwner -q .nameWithOwner) || exit 2
48+ [ -n " $repo " ] || repo=$( gh repo view --json nameWithOwner -q .nameWithOwner) || exit 2
3949owner=${repo%/* } name=${repo#*/ }
50+ if [ -z " $pr " ]; then
51+ pr=$( gh pr view --json number -q .number 2> /dev/null) \
52+ || pr=$( gh api " repos/$repo /pulls?head=$owner :$( git branch --show-current) &state=open" -q ' .[0].number // empty' 2> /dev/null)
53+ [ -n " $pr " ] || { echo " no PR for the current branch" >&2 ; exit 2; }
54+ fi
4055
4156# shellcheck disable=SC2016 # GraphQL variables, not shell
4257query=' query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){
4358 state headRefOid mergeQueueEntry{position state}
4459 commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}'
4560
61+ # REST stand-ins for the GraphQL poll and for gh pr checks. Both leave
62+ # the gh error text in $json / $errf on failure, like the calls they replace.
63+ rest_poll () {
64+ json=$( gh api " repos/$repo /pulls/$pr " 2>&1 ) || return 1
65+ state=$( jq -r ' if .merged then "MERGED" elif .state == "closed" then "CLOSED" else "OPEN" end' <<< " $json" )
66+ sha=$( jq -r ' .head.sha' <<< " $json" )
67+ base=$( jq -r ' .base.ref' <<< " $json" )
68+ # Until the required checks are known the rollup stays pending, so an
69+ # unreadable rule set never lets DONE through before ci-ok reports.
70+ if [ -z " $required " ]; then
71+ required=$( gh api " repos/$repo /rules/branches/$base " \
72+ -q ' [.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context]' 2> /dev/null) \
73+ || { required=" " ; [ -n " $rules_warned " ] || echo " warn: can't read $base 's required checks; retrying" ; rules_warned=1; }
74+ fi
75+ entry=" "
76+ [ " $merge " = 1 ] || return 0
77+ # A queue entry being built has unfinished merge_group runs on
78+ # gh-readonly-queue/<base>/pr-<n>-<sha>. Once they all pass the PR merges
79+ # moments later, so that is not a dequeue unless it is still open 10
80+ # minutes on; a rebuild starts new runs at once, so any other gap counts
81+ # as leaving the queue only on the second miss in a row.
82+ local group
83+ group=$( gh api " repos/$repo /actions/runs?event=merge_group&per_page=30" -q " [.workflow_runs[]
84+ | select(.head_branch | startswith(\" gh-readonly-queue/$base /pr-$pr -\" ))]
85+ | (.[0].head_branch // \"\" ) as \$ latest | map(select(.head_branch == \$ latest))
86+ | if any(.status != \" completed\" ) then \" building\"
87+ elif length > 0 and all(.conclusion | IN(\" success\" , \" neutral\" , \" skipped\" )) then \" passed\"
88+ else \" idle\" end" 2> /dev/null) || group=" "
89+ case " $group " in
90+ building) entry=" building" misses=0 passed_at=" " ;;
91+ passed) [ " $was_in_queue " = 1 ] || return 0
92+ entry=$queued misses=0 passed_at=${passed_at:- $SECONDS }
93+ [ $(( SECONDS - passed_at)) -lt 600 ] || entry=" " ;;
94+ * ) [ " $was_in_queue " = 1 ] && { [ -z " $group " ] || [ " $(( misses += 1 )) " -lt 2 ]; } \
95+ && entry=$queued ;;
96+ esac
97+ return 0
98+ }
99+ rest_checks () {
100+ local runs statuses
101+ runs=$( gh api --paginate " repos/$repo /commits/$sha /check-runs?per_page=100" -q ' .check_runs[]
102+ | {name, bucket: (if .status != "completed" then "pending"
103+ elif (.conclusion | IN("success", "neutral", "skipped")) then "pass"
104+ elif .conclusion == "cancelled" then "cancel" else "fail" end)}' 2> " $errf " ) || return 0
105+ statuses=$( gh api " repos/$repo /commits/$sha /status?per_page=100" -q ' .statuses[]
106+ | {name: .context, bucket: (if .state == "pending" then "pending"
107+ elif .state == "success" then "pass" else "fail" end)}' 2> " $errf " ) || return 0
108+ jq -s ' .' <<< " $runs$statuses"
109+ }
110+
46111errf=$( mktemp) || exit 2
47112trap ' rm -f "$errf"' EXIT
48113seen=" " head=" " queued=" " was_in_queue=0 settled_before=-1 rollup_before=" " polls=0
49- total=0 pending=1 fail=0
114+ total=0 pending=1 fail=0 rest=0 required= " " misses=0 passed_at= " " rules_warned= " " base= " "
50115while true ; do
51- if ! json=$( gh api graphql -f query=" $query " -F o=" $owner " -F n=" $name " -F p=" $pr " 2>&1 ) ; then
116+ if [ " $rest " = 1 ]; then
117+ rest_poll
118+ else
119+ json=$( gh api graphql -f query=" $query " -F o=" $owner " -F n=" $name " -F p=" $pr " 2>&1 )
120+ fi
121+ # shellcheck disable=SC2181 # both branches above set the status
122+ if [ $? != 0 ]; then
123+ if [ " $rest " = 0 ] && grep -q ' GraphQL is not available' <<< " $json" ; then
124+ echo " warn: GraphQL unavailable here; polling over REST" ; rest=1; continue
125+ fi
52126 if grep -qiE ' rate limit|HTTP 403|HTTP 429' <<< " $json" ; then
53127 echo " warn: GitHub rate limit hit; pausing 10 minutes" ; sleep 600; continue
54128 fi
55129 echo " warn: gh ${json%% $' \n ' * } " ; sleep " $interval " ; continue
56130 fi
57- state=$( jq -r ' .data.repository.pullRequest.state' <<< " $json" )
58- sha=$( jq -r ' .data.repository.pullRequest.headRefOid' <<< " $json" )
131+ if [ " $rest " = 0 ]; then
132+ state=$( jq -r ' .data.repository.pullRequest.state' <<< " $json" )
133+ sha=$( jq -r ' .data.repository.pullRequest.headRefOid' <<< " $json" )
134+ fi
59135 if [ " $sha " != " $head " ]; then
60136 [ -n " $head " ] && echo " push: head now ${sha: 0: 9} , checks restart"
61137 head=$sha seen=" " settled_before=-1
62138 fi
63139
64- rollup=$( jq -r ' .data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.state // "NONE"' <<< " $json" )
140+ if [ " $rest " = 1 ]; then
141+ rollup=REST # the check list is the only signal, so it is read every poll
142+ else
143+ rollup=$( jq -r ' .data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.state // "NONE"' <<< " $json" )
144+ fi
65145 polls=$(( polls + 1 ))
66- if [ " $settled_before " = -1 ] || [ " $rollup " != " $rollup_before " ] || [ $(( polls % 5 )) = 0 ]; then
146+ if [ " $rest " = 1 ] || [ " $ settled_before" = -1 ] || [ " $rollup " != " $rollup_before " ] || [ $(( polls % 5 )) = 0 ]; then
67147 # gh pr checks pages through every check; its exit status is 8 while any
68148 # is pending and 1 when one failed, so only its output is trusted.
69- checks=$( gh pr checks " $pr " -R " $repo " --json name,bucket 2> " $errf " )
149+ if [ " $rest " = 1 ]; then
150+ checks=$( rest_checks)
151+ else
152+ checks=$( gh pr checks " $pr " -R " $repo " --json name,bucket 2> " $errf " )
153+ fi
70154 if ! jq -e ' type == "array"' <<< " $checks" > /dev/null 2>&1 ; then
71155 # Keep the last counts and retry this fetch on the next poll; the
72156 # paginated call is the expensive one, so back off on a rate limit.
@@ -92,8 +176,15 @@ while true; do
92176 seen=$failed settled_before=$settled_now
93177 fi
94178 rollup_before=$rollup
95-
96- entry=$( jq -r ' .data.repository.pullRequest.mergeQueueEntry | if . then "\(.position) \(.state | ascii_downcase)" else "" end' <<< " $json" )
179+ if [ " $rest " = 1 ]; then
180+ # Settled once every required check has reported; REST lists only
181+ # check runs that exist, like gh pr checks.
182+ rollup=PENDING
183+ [ -n " $required " ] && jq -e --argjson req " $required " ' ($req - map(.name)) == []' <<< " $checks" > /dev/null 2>&1 \
184+ && rollup=SUCCESS
185+ else
186+ entry=$( jq -r ' .data.repository.pullRequest.mergeQueueEntry | if . then "\(.position) \(.state | ascii_downcase)" else "" end' <<< " $json" )
187+ fi
97188 if [ -n " $entry " ] && [ " $entry " != " $queued " ]; then
98189 [ " $was_in_queue " = 1 ] && echo " queue: position $entry " || echo " queue: entered at $entry "
99190 queued=$entry was_in_queue=1
0 commit comments