Skip to content

Commit f361e8d

Browse files
Make ci-watch.sh work in GraphQL-less cloud sessions (#1620)
* Fall back to REST in ci-watch.sh Claude Code cloud sessions get a 403 for every GraphQL call, and gh pr view, gh pr checks and gh repo view all use GraphQL, so the CI watcher AGENTS.md tells agents to run exited at startup there with no output. Read the repo from GH_REPO or the origin URL, find the branch's PR over REST when gh pr view fails, and on the first "GraphQL is not available" 403 switch to REST polling: the PR, its check runs and commit statuses, with the base branch's required checks standing in for the rollup. With --merge, queue lines follow the PR's merge_group runs. The GraphQL path is unchanged where GraphQL works. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqtsyAYsehu1NAZhKbg3o5 * Don't call a passed queue entry dequeued Over REST, a merge group whose runs all passed leaves the PR open for a moment before it merges; wait for the merge (up to 10 minutes) instead of counting those polls as misses. And keep the rollup pending while the base branch's required checks can't be read, so an unreadable rule set can't let DONE through before ci-ok reports. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqtsyAYsehu1NAZhKbg3o5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b959a8 commit f361e8d

1 file changed

Lines changed: 104 additions & 13 deletions

File tree

‎scripts/ci-watch.sh‎

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
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
@@ -17,6 +18,13 @@
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.
2129
set -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
3341
done
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}")
3747
fi
38-
repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) || exit 2
48+
[ -n "$repo" ] || repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) || exit 2
3949
owner=${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
4257
query='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+
46111
errf=$(mktemp) || exit 2
47112
trap 'rm -f "$errf"' EXIT
48113
seen="" 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=""
50115
while 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

Comments
 (0)