From 48fa9e5c7810661116734e28330c7fa07f09edf8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 16:16:19 -0400 Subject: [PATCH 1/3] Add scripts/ci-watch.sh and agent notes for CI monitors and /code-review scripts/ci-watch.sh turns a PR's CI into a quiet event stream meant for Claude Code's Monitor tool: a start summary, one line per failed check, progress counts, merge-queue position, and a final DONE line with an exit status (0 pass/merged, 1 fail/closed/dequeued). It reads checks through `gh pr checks`, which pages past the ~400-550 checks a PR here runs, and reports passes as counts so a monitor isn't flooded. AGENTS.md now tells agents to wait on CI with that script under Monitor rather than sleep/gh polling loops, and to run /code-review high (with --fix) before a PR leaves draft or enters the merge queue. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 29 ++++++++++++++ scripts/ci-watch.sh | 92 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100755 scripts/ci-watch.sh diff --git a/AGENTS.md b/AGENTS.md index a99f9b616..9a2e8b557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,32 @@ details of a change in its commit messages and PR description instead. If a PR you work on already changes `CHANGELOG.md`, restore that file to its merge-base version. The only exceptions are release PRs: a `release/v*` branch, or the release train's `release-sync` PR. + +## Wait on CI with a monitor, not a polling loop + +To wait on a PR's checks or its trip through the merge queue, run +`scripts/ci-watch.sh` under the Monitor tool instead of looping on `sleep` and +`gh pr checks`, or polling with `/loop`. The script stays silent while +nothing changes and prints one line per event: a start summary, each failed +check by name, progress, queue position, and a final `DONE ...` line when it +exits. + +``` +scripts/ci-watch.sh [PR] # until every check settles (exit 0 pass, 1 fail) +scripts/ci-watch.sh [PR] --merge # through the merge queue (exit 0 merged, 1 closed/dequeued) +``` + +PR defaults to the current branch's. Give Monitor the maximum `timeout_ms` +and re-arm it when it expires before `DONE`. Act on a `fail:` line as soon as +it arrives; there's no need to wait for the remaining checks. If a session +has no Monitor tool, run the script with Bash `run_in_background` and read +its output when it exits. + +## Run /code-review before a PR leaves draft + +Before you mark a PR ready for review or add it to the merge queue, run +`/code-review high` on its diff. Fix every confirmed finding +(`/code-review high --fix` applies them) and push the fixes before going on. +For a finding you decide not to fix, say why in the PR description. When you +review someone else's PR, use `/code-review --comment` to post the +findings as inline comments. diff --git a/scripts/ci-watch.sh b/scripts/ci-watch.sh new file mode 100755 index 000000000..ee4a0a927 --- /dev/null +++ b/scripts/ci-watch.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Event stream of a PR's CI, for Claude Code's Monitor tool (or a terminal). +# +# scripts/ci-watch.sh [PR] [--merge] [--interval SECONDS] +# +# PR defaults to the current branch's PR. Prints one line per event and +# nothing while nothing changes: +# check : pass|fail|cancelled|skipped +# queue: entered at | position | left the queue +# DONE checks pass|fail (

pass, fail) -- exits here without --merge +# DONE merged|closed|dequeued -- exits here with --merge +# warn: gh ... -- a failed poll; keeps going +# Exit status: 0 on pass/merged, 1 on fail/closed/dequeued, 2 on bad usage. +set -uo pipefail + +pr="" merge=0 interval=60 +while [ $# -gt 0 ]; do + case "$1" in + --merge) merge=1 ;; + --interval) interval="$2"; shift ;; + -h|--help) sed -n '2,15p' "$0"; exit 0 ;; + [0-9]*) pr="$1" ;; + *) echo "usage: $0 [PR] [--merge] [--interval SECONDS]" >&2; exit 2 ;; + esac + shift +done + +if [ -z "$pr" ]; then + pr=$(gh pr view --json number -q .number 2>/dev/null) || { echo "no PR for the current branch" >&2; exit 2; } +fi +repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) || exit 2 +owner=${repo%/*} name=${repo#*/} + +# shellcheck disable=SC2016 # GraphQL variables, not shell +query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){ + state headRefOid mergeQueueEntry{position state}}}}' + +seen="" head="" queued="" was_in_queue=0 settled_before=-1 +while true; do + if ! json=$(gh api graphql -f query="$query" -F o="$owner" -F n="$name" -F p="$pr" 2>&1); then + echo "warn: gh ${json%%$'\n'*}"; sleep "$interval"; continue + fi + state=$(jq -r '.data.repository.pullRequest.state' <<<"$json") + sha=$(jq -r '.data.repository.pullRequest.headRefOid' <<<"$json") + if [ "$sha" != "$head" ]; then + [ -n "$head" ] && echo "push: head now ${sha:0:9}, checks restart" + head=$sha seen="" settled_before=-1 + fi + + # gh pr checks pages through every check; its exit status is 8 while any + # is pending and 1 when one failed, so only its output is trusted. + checks=$(gh pr checks "$pr" -R "$repo" --json name,bucket 2>/dev/null) + if ! jq -e 'type == "array"' <<<"$checks" >/dev/null 2>&1; then + echo "warn: gh pr checks returned no JSON"; checks='[]' + fi + total=$(jq 'length' <<<"$checks") + pending=$(jq '[.[] | select(.bucket == "pending")] | length' <<<"$checks") + fail=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' <<<"$checks") + failed=$(jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | .name' <<<"$checks" | sort -u) + settled_now=$((total - pending)) + if [ "$settled_before" = -1 ]; then + echo "start: #$pr ${sha:0:9}, $((settled_now - fail)) pass $fail fail $pending pending" + printf '%s\n' "$failed" | grep -v '^$' | sed 's/^/fail: /' + else + comm -13 <(printf '%s\n' "$seen") <(printf '%s\n' "$failed") | grep -v '^$' | sed 's/^/fail: /' + [ "$settled_now" != "$settled_before" ] && [ "$pending" != 0 ] \ + && echo "progress: $settled_now/$total settled, $fail fail" + fi + seen=$failed settled_before=$settled_now + + entry=$(jq -r '.data.repository.pullRequest.mergeQueueEntry | if . then "\(.position) \(.state | ascii_downcase)" else "" end' <<<"$json") + if [ -n "$entry" ] && [ "$entry" != "$queued" ]; then + [ "$was_in_queue" = 1 ] && echo "queue: position $entry" || echo "queue: entered at $entry" + queued=$entry was_in_queue=1 + elif [ -z "$entry" ] && [ "$was_in_queue" = 1 ] && [ "$state" = OPEN ]; then + echo "queue: left the queue"; queued="" was_in_queue=0 + [ "$merge" = 1 ] && { echo "DONE dequeued"; exit 1; } + fi + + case "$state" in + MERGED) echo "DONE merged"; exit 0 ;; + CLOSED) echo "DONE closed"; exit 1 ;; + esac + + if [ "$merge" = 0 ]; then + if [ "$total" -gt 0 ] && [ "$pending" = 0 ]; then + if [ "$fail" = 0 ]; then echo "DONE checks pass ($total checks)"; exit 0; fi + echo "DONE checks fail ($fail of $total failed)"; exit 1 + fi + fi + sleep "$interval" +done From fd1987d30613f30761eec1e589a7d658b8058d85 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 8 Oct 2026 17:11:17 -0400 Subject: [PATCH 2/3] Poll CI cheaply and add a shared API budget rule ci-watch.sh now reads only the PR's rollup state each poll (one small GraphQL query, every 2 minutes) and fetches the full check list, which is several pages for a PR here, only when that state changes or every 5th poll. A rate-limited poll pauses 10 minutes instead of retrying. AGENTS.md gains a "Share the GitHub API budget" section. Every agent and routine calls GitHub as one account, and on 2026-10-08 a bulk Actions fetch plus ~18 concurrent sessions ran the shared 5,000/hour quota to zero twice in one evening. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 19 +++++++++++++ scripts/ci-watch.sh | 67 ++++++++++++++++++++++++++++----------------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a2e8b557..91a5a4abb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,22 @@ Before you mark a PR ready for review or add it to the merge queue, run For a finding you decide not to fix, say why in the PR description. When you review someone else's PR, use `/code-review --comment` to post the findings as inline comments. + +## Share the GitHub API budget + +Every agent and routine here calls GitHub as the same account, so all of +them share one budget of 5,000 REST requests and 5,000 GraphQL points +per hour. When it runs out, every agent stalls until the hourly reset. + +- Before a read of more than ~20 requests, check what is left with + `gh api -i repos/SocketDev/socket-patch | grep -i '^x-ratelimit-remaining'`. + Trust that header over `gh api rate_limit`. Below 1,000, do only your + most important write and finish early. +- Never `--paginate` over Actions runs, jobs or issues without a date + filter and a page cap, and never run more than 2 GitHub requests in + parallel. +- On a 403 or 429 that mentions a rate limit, stop calling GitHub and + report what you have. Don't retry in a loop, and don't schedule a + reminder just to retry after the reset. +- Keep at most one pending re-check reminder per PR, at least 30 minutes + out. diff --git a/scripts/ci-watch.sh b/scripts/ci-watch.sh index ee4a0a927..d70d7fe55 100755 --- a/scripts/ci-watch.sh +++ b/scripts/ci-watch.sh @@ -4,16 +4,23 @@ # scripts/ci-watch.sh [PR] [--merge] [--interval SECONDS] # # PR defaults to the current branch's PR. Prints one line per event and -# nothing while nothing changes: -# check : pass|fail|cancelled|skipped +# nothing while nothing changes, so it stays quiet enough for Monitor: +# start: # ,

pass fail pending +# fail: -- one line per failed check +# progress: / settled, fail -- at most every 5th poll # queue: entered at | position | left the queue # DONE checks pass|fail (

pass, fail) -- exits here without --merge # DONE merged|closed|dequeued -- exits here with --merge -# warn: gh ... -- a failed poll; keeps going +# warn: ... -- a failed poll; keeps going +# +# Every agent on this account shares one GitHub API budget, so each poll +# is one small GraphQL query; the full check list (several pages for a PR +# here) is fetched only when the rollup state changes or every 5th poll. +# A rate-limited poll pauses for 10 minutes instead of retrying. # Exit status: 0 on pass/merged, 1 on fail/closed/dequeued, 2 on bad usage. set -uo pipefail -pr="" merge=0 interval=60 +pr="" merge=0 interval=120 while [ $# -gt 0 ]; do case "$1" in --merge) merge=1 ;; @@ -33,11 +40,16 @@ owner=${repo%/*} name=${repo#*/} # shellcheck disable=SC2016 # GraphQL variables, not shell query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){ - state headRefOid mergeQueueEntry{position state}}}}' + state headRefOid mergeQueueEntry{position state} + commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}' -seen="" head="" queued="" was_in_queue=0 settled_before=-1 +seen="" head="" queued="" was_in_queue=0 settled_before=-1 rollup_before="" polls=0 +total=0 pending=1 fail=0 while true; do if ! json=$(gh api graphql -f query="$query" -F o="$owner" -F n="$name" -F p="$pr" 2>&1); then + if grep -qiE 'rate limit|HTTP 403|HTTP 429' <<<"$json"; then + echo "warn: GitHub rate limit hit; pausing 10 minutes"; sleep 600; continue + fi echo "warn: gh ${json%%$'\n'*}"; sleep "$interval"; continue fi state=$(jq -r '.data.repository.pullRequest.state' <<<"$json") @@ -47,26 +59,31 @@ while true; do head=$sha seen="" settled_before=-1 fi - # gh pr checks pages through every check; its exit status is 8 while any - # is pending and 1 when one failed, so only its output is trusted. - checks=$(gh pr checks "$pr" -R "$repo" --json name,bucket 2>/dev/null) - if ! jq -e 'type == "array"' <<<"$checks" >/dev/null 2>&1; then - echo "warn: gh pr checks returned no JSON"; checks='[]' - fi - total=$(jq 'length' <<<"$checks") - pending=$(jq '[.[] | select(.bucket == "pending")] | length' <<<"$checks") - fail=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' <<<"$checks") - failed=$(jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | .name' <<<"$checks" | sort -u) - settled_now=$((total - pending)) - if [ "$settled_before" = -1 ]; then - echo "start: #$pr ${sha:0:9}, $((settled_now - fail)) pass $fail fail $pending pending" - printf '%s\n' "$failed" | grep -v '^$' | sed 's/^/fail: /' - else - comm -13 <(printf '%s\n' "$seen") <(printf '%s\n' "$failed") | grep -v '^$' | sed 's/^/fail: /' - [ "$settled_now" != "$settled_before" ] && [ "$pending" != 0 ] \ - && echo "progress: $settled_now/$total settled, $fail fail" + rollup=$(jq -r '.data.repository.pullRequest.commits.nodes[0].commit.statusCheckRollup.state // "NONE"' <<<"$json") + polls=$((polls + 1)) + if [ "$settled_before" = -1 ] || [ "$rollup" != "$rollup_before" ] || [ $((polls % 5)) = 0 ]; then + # gh pr checks pages through every check; its exit status is 8 while any + # is pending and 1 when one failed, so only its output is trusted. + checks=$(gh pr checks "$pr" -R "$repo" --json name,bucket 2>/dev/null) + if ! jq -e 'type == "array"' <<<"$checks" >/dev/null 2>&1; then + echo "warn: gh pr checks returned no JSON"; checks='[]' + fi + total=$(jq 'length' <<<"$checks") + pending=$(jq '[.[] | select(.bucket == "pending")] | length' <<<"$checks") + fail=$(jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' <<<"$checks") + failed=$(jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | .name' <<<"$checks" | sort -u) + settled_now=$((total - pending)) + if [ "$settled_before" = -1 ]; then + echo "start: #$pr ${sha:0:9}, $((settled_now - fail)) pass $fail fail $pending pending" + printf '%s\n' "$failed" | grep -v '^$' | sed 's/^/fail: /' + else + comm -13 <(printf '%s\n' "$seen") <(printf '%s\n' "$failed") | grep -v '^$' | sed 's/^/fail: /' + [ "$settled_now" != "$settled_before" ] && [ "$pending" != 0 ] \ + && echo "progress: $settled_now/$total settled, $fail fail" + fi + seen=$failed settled_before=$settled_now fi - seen=$failed settled_before=$settled_now + rollup_before=$rollup entry=$(jq -r '.data.repository.pullRequest.mergeQueueEntry | if . then "\(.position) \(.state | ascii_downcase)" else "" end' <<<"$json") if [ -n "$entry" ] && [ "$entry" != "$queued" ]; then From 7308f20a9141e134be93cd30158ef2264066a6d6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Sat, 10 Oct 2026 10:07:27 -0400 Subject: [PATCH 3/3] ci-watch: wait for a terminal rollup and back off on checks rate limits Only report DONE when the statusCheckRollup is SUCCESS/FAILURE/ERROR as well as the gh pr checks list having no pending entries, so expected or not-yet-created checks keep the watch running. A failed gh pr checks call now keeps the previous counts, retries on the next poll, and pauses 10 minutes when its stderr shows a rate limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci-watch.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/ci-watch.sh b/scripts/ci-watch.sh index d70d7fe55..9a3ca2370 100755 --- a/scripts/ci-watch.sh +++ b/scripts/ci-watch.sh @@ -43,6 +43,8 @@ query='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullReq state headRefOid mergeQueueEntry{position state} commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}}' +errf=$(mktemp) || exit 2 +trap 'rm -f "$errf"' EXIT seen="" head="" queued="" was_in_queue=0 settled_before=-1 rollup_before="" polls=0 total=0 pending=1 fail=0 while true; do @@ -64,9 +66,15 @@ while true; do if [ "$settled_before" = -1 ] || [ "$rollup" != "$rollup_before" ] || [ $((polls % 5)) = 0 ]; then # gh pr checks pages through every check; its exit status is 8 while any # is pending and 1 when one failed, so only its output is trusted. - checks=$(gh pr checks "$pr" -R "$repo" --json name,bucket 2>/dev/null) + checks=$(gh pr checks "$pr" -R "$repo" --json name,bucket 2>"$errf") if ! jq -e 'type == "array"' <<<"$checks" >/dev/null 2>&1; then - echo "warn: gh pr checks returned no JSON"; checks='[]' + # Keep the last counts and retry this fetch on the next poll; the + # paginated call is the expensive one, so back off on a rate limit. + rollup_before="" + if grep -qiE 'rate limit|HTTP 403|HTTP 429' "$errf"; then + echo "warn: GitHub rate limit hit; pausing 10 minutes"; sleep 600; continue + fi + echo "warn: gh pr checks returned no JSON"; sleep "$interval"; continue fi total=$(jq 'length' <<<"$checks") pending=$(jq '[.[] | select(.bucket == "pending")] | length' <<<"$checks") @@ -99,8 +107,11 @@ while true; do CLOSED) echo "DONE closed"; exit 1 ;; esac + # gh pr checks lists only check runs that exist; the rollup also counts + # expected and re-queued ones, so both must agree before calling it done. if [ "$merge" = 0 ]; then - if [ "$total" -gt 0 ] && [ "$pending" = 0 ]; then + if [ "$total" -gt 0 ] && [ "$pending" = 0 ] \ + && { [ "$rollup" = SUCCESS ] || [ "$rollup" = FAILURE ] || [ "$rollup" = ERROR ]; }; then if [ "$fail" = 0 ]; then echo "DONE checks pass ($total checks)"; exit 0; fi echo "DONE checks fail ($fail of $total failed)"; exit 1 fi