diff --git a/AGENTS.md b/AGENTS.md index a99f9b616..91a5a4abb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,51 @@ 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. + +## 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 new file mode 100755 index 000000000..9a3ca2370 --- /dev/null +++ b/scripts/ci-watch.sh @@ -0,0 +1,120 @@ +#!/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, 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: ... -- 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=120 +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} + 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 + 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") + 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 + + 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>"$errf") + if ! jq -e 'type == "array"' <<<"$checks" >/dev/null 2>&1; then + # 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") + 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 + 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 + [ "$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 + + # 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 ] \ + && { [ "$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 + fi + sleep "$interval" +done