The GitHub commands behind the pull-request skill's
monitor phase. The phase owns the loop, its
cadence, and every rule and gate; this file holds the commands and the GitHub and gh facts behind
them.
PR_NUMBER=$(gh pr view --json number -q '.number' | tr -d '\r')
OWNER=$(gh repo view --json owner -q .owner.login)
REPO=$(gh repo view --json name -q .name)With multiple PRs open against this checkout, name the branch:
gh pr view "$(git branch --show-current)" --json number -q '.number'.
gh pr view <pr_number> --json state -q '.state'The Monitor watch's poll script, passed inline in the command parameter. <scripts-dir> is the
placeholder the pull-request skill resolves. BASELINE_FILE stays empty on the first arming; a
re-arm sets it to the snapshot file this same script wrote in a snapshot run (SNAPSHOT_FILE
set: one state check and checks read, the cur_checks lines written under a marker line, then
exit 0, or exit 1 with nothing written when the read failed). A baseline file without that
marker line is refused with BASELINE-UNREADABLE, so only this script's own serializer ever
produces a baseline.
PR_NUMBER=<N>
INTERVAL=60 # raise with several watchers (../README.md#api-budget)
OWNER=$(gh repo view --json owner -q .owner.login)
REPO=$(gh repo view --json name -q .name)
BASELINE_FILE= # re-arm only: the file a snapshot run of this script wrote
SNAPSHOT_FILE= # snapshot run only: write the checks here and exit
SNAPSHOT_MARK='# monitor-poll checks snapshot'
prev_checks=""
baselined=0 # set by a snapshot BASELINE_FILE or the first successful check read
if [ -n "$BASELINE_FILE" ] && [ "$(head -n 1 "$BASELINE_FILE" 2>/dev/null | tr -d '\r')" = "$SNAPSHOT_MARK" ]; then
prev_checks=$(tail -n +2 "$BASELINE_FILE" | tr -d '\r' | grep -v '^$' | sort || true)
baselined=1
elif [ -n "$BASELINE_FILE" ]; then
echo "BASELINE-UNREADABLE $BASELINE_FILE — missing or not a snapshot this script wrote; first checks poll only records; run a full scan"
fi
last_comment_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
prev_threads=""
poll=0
err=$(mktemp) # stderr of every later gh call, checked for a rate limit each poll
while true; do
# Terminal state check — exit watch if PR closed/merged. REST, like the
# CI read below (§3.1 "Polling CI from more than one worker"). A rate
# limit stops the watch: polling on would probe for recovery
# (../README.md#api-budget).
if out=$(gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER" \
--jq '"\(if .merged then "MERGED" elif .state == "closed" then "CLOSED" else "OPEN" end) \(.head.sha)"' 2>&1); then
read -r state head_sha <<<"$(printf '%s' "$out" | tr -d '\r')"
elif grep -qiE 'rate limit|HTTP 429' <<<"$out"; then
echo "RATE-LIMITED — watch stopped: $out"
exit 2
fi
if [ "$state" = "MERGED" ] || [ "$state" = "CLOSED" ]; then
echo "PR #$PR_NUMBER $state — watch complete"
exit 0
fi
# CI changes: one "name: bucket" line per check, the same rows and
# buckets `gh pr checks` reports, read over REST. Check runs are
# deduplicated as gh does (latest start per name/workflow/event);
# commit statuses are always read, since check-runs omit them.
b='def b: ascii_upcase | if . == "SUCCESS" then "pass" elif . == "SKIPPED" or . == "NEUTRAL" then "skipping" elif . == "CANCELLED" then "cancel" elif . == "ERROR" or . == "FAILURE" or . == "TIMED_OUT" or . == "ACTION_REQUIRED" then "fail" else "pending" end;'
# A failed read of any call below leaves the baseline alone and emits
# nothing: an empty list from a failed read would otherwise be stored as
# the baseline, and the next good poll would emit every finished check.
checks_ok=1
[ -n "${head_sha:-}" ] || checks_ok=0
if [ "$checks_ok" -eq 1 ]; then
if wf_raw=$(gh api --paginate "repos/$OWNER/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
--jq '.workflow_runs[] | {(.check_suite_id | tostring): "\(.name)/\(.event)"}' 2>>"$err"); then
wf=$(printf '%s\n' "$wf_raw" | jq -s -c 'add // {}') || checks_ok=0
else checks_ok=0; fi
fi
if [ "$checks_ok" -eq 1 ]; then
runs_raw=$(gh api --paginate "repos/$OWNER/$REPO/commits/$head_sha/check-runs?per_page=100" 2>>"$err") || checks_ok=0
stat_raw=$(gh api --paginate "repos/$OWNER/$REPO/commits/$head_sha/status?per_page=100" \
--jq "$b"' .statuses[] | "\(.context): \(.state | b)"' 2>>"$err") || checks_ok=0
fi
if [ "$checks_ok" -eq 1 ]; then
run_rows=$(printf '%s\n' "$runs_raw" | jq -s -r --argjson wf "$wf" "$b"' [.[].check_runs[] | {k: "\(.name)/\($wf[.check_suite.id | tostring] // "")", name, t: (.started_at // ""), s: (if .status == "completed" then .conclusion else .status end)}] | group_by(.k) | map(max_by(.t))[] | "\(.name): \(.s | b)"') || checks_ok=0
cur_checks=$({ printf '%s\n' "$run_rows"; printf '%s\n' "$stat_raw"; } \
| tr -d '\r' | grep -v -e '^$' -e ': pending$' | sort || true)
fi
if [ -n "$SNAPSHOT_FILE" ]; then
if [ "$checks_ok" -ne 1 ]; then
echo "SNAPSHOT-FAILED — checks read failed; no snapshot written"
exit 1
fi
printf '%s\n%s\n' "$SNAPSHOT_MARK" "$cur_checks" >"$SNAPSHOT_FILE.tmp" &&
mv "$SNAPSHOT_FILE.tmp" "$SNAPSHOT_FILE"
exit $?
fi
if [ "$checks_ok" -eq 1 ]; then
# Bucket values mirror gh pr checks: pass|fail|pending|skipping|cancel.
# With no baseline, the first poll whose check read succeeds only records
# the checks. A re-arm starts from the full iteration's snapshot instead,
# so a check that finished since that read is emitted and the rest are not
# re-emitted.
if [ "$baselined" -eq 1 ] && [ "$cur_checks" != "$prev_checks" ]; then
comm -13 <(echo "$prev_checks") <(echo "$cur_checks") | \
grep --line-buffered -E ': (pass|fail|skipping|cancel)$' \
|| true
fi
prev_checks="$cur_checks"
baselined=1
fi
# New comments — ALL THREE review surfaces (issue-level, inline
# review comments, review bodies). Watching only issues/comments
# misses inline findings posted with no CI state change.
# Advance the watermark ONLY when every fetch succeeded — a transient
# gh failure would otherwise skip past comments that arrived during
# the failed poll window and never emit them.
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
fetch_ok=1
if out=$(gh api --paginate "repos/$OWNER/$REPO/issues/$PR_NUMBER/comments?since=$last_comment_ts&per_page=100" \
--jq '.[] | "COMMENT \(.user.login): \(.body[:80])"' 2>>"$err"); then
printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true
else fetch_ok=0; fi
if out=$(gh api --paginate "repos/$OWNER/$REPO/pulls/$PR_NUMBER/comments?since=$last_comment_ts&per_page=100" \
--jq '.[] | "INLINE-COMMENT \(.user.login): \(.body[:80])"' 2>>"$err"); then
printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true
else fetch_ok=0; fi
# Reviews API has no `since` param — filter client-side on submitted_at.
# Client-side filtering makes pagination required: an unpaginated read
# returns the 30 OLDEST reviews, so on a PR past that count the new ones
# this poll exists to catch are exactly the ones never fetched.
if out=$(gh api --paginate "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews?per_page=100" \
--jq ".[] | select(.submitted_at > \"$last_comment_ts\") | \"REVIEW \(.user.login) [\(.state)]: \(.body[:80])\"" 2>>"$err"); then
printf '%s\n' "$out" | tr -d '\r' | grep --line-buffered . || true
else fetch_ok=0; fi
[ "$fetch_ok" -eq 1 ] && last_comment_ts="$now"
# Review threads: resolving one moves no check and posts no comment, so
# the reads above never see it. The Gate 7 read (readiness.md) costs a
# GraphQL call, so it runs every 4th poll (about 4 minutes) and emits
# when the unresolved count, or THREADS_UNPROVEN, changes.
if [ $((poll % 4)) -eq 0 ]; then
cur_threads=$(bash "<scripts-dir>/source-control-review-threads" "$OWNER/$REPO#$PR_NUMBER" 2>>"$err" \
| awk 'NR == 1 && /^THREADS_(OK|BLOCKED) unresolved=/ { sub(/.*unresolved=/, ""); sub(/[^0-9].*/, ""); print }')
cur_threads=${cur_threads:-THREADS_UNPROVEN}
if [ "$cur_threads" != "$prev_threads" ]; then
[ "$poll" -gt 0 ] && echo "REVIEW-THREADS unresolved=$cur_threads"
prev_threads="$cur_threads"
fi
fi
if grep -qiE 'rate limit|HTTP 429' "$err"; then
echo "RATE-LIMITED — watch stopped: $(grep -iE -m1 'rate limit|HTTP 429' "$err")"
exit 2
fi
: >"$err"
poll=$((poll + 1))
sleep "$INTERVAL"
doneIts state check and CI block (the head_sha read through cur_checks) also run standalone, with
OWNER, REPO, PR_NUMBER, and err set, as the REST read of a PR's checks; cur_checks is valid only when checks_ok is 1. The bucket mapping and the
latest-per-name/workflow/event dedupe follow gh's pkg/cmd/pr/checks/aggregate.go.
gh pr checks (including --watch) and gh pr view --json query GraphQL (GH_DEBUG=api shows
POST /graphql for gh pr checks and for gh pr view --json state), and
concurrent workers under one token have hit GraphQL secondary rate limits. GraphQL and REST draw on
separate per-minute point budgets (2,000 and 900 points per minute) and separate primary limits
(5,000 points per hour for GraphQL, 5,000 requests per hour for REST), and a REST GET costs 1
point, so moving the polling to REST leaves the GraphQL budget for the calls that have no REST
equivalent, such as review-thread resolution. The 100-concurrent-request ceiling is shared by
both APIs, so REST polling does not raise it. Verified 2026-09-25, and 2026-09-26 with gh 2.97.0 for the gh behavior, against
List check runs for a Git reference,
Get the combined status for a specific reference,
Rate limits for the REST API,
and Rate limits for the GraphQL API.
Recheck when either rate-limits page changes its secondary-limit figures, or when a gh release
note says pr checks or pr view moved off GraphQL or changes the pr checks bucket mapping.
gh pr list --state open --json number,title,headRefName,statusCheckRollup \
--jq '.[] | "\(.number) \(.headRefName) \(.title)"'gh pr view <N> --json mergeable,mergeStateStatusgh pr checks --watch re-runs its GraphQL query every --interval seconds (default 10) until every
check settles.
gh api --paginate "repos/$OWNER/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
--jq '.workflow_runs[] | select(.status != "completed") | .id' | tr -d '\r' |
while read -r run_id; do
gh api --paginate "repos/$OWNER/$REPO/actions/runs/$run_id/jobs?per_page=100" \
--jq '.jobs[] | select(.status != "completed") | "\(.name) | \(.status) | \(.labels | join(",")) | created \(.created_at)"'
done | tr -d '\r'Claim: a long CI wait must poll REST on a fixed schedule and must not use gh pr checks --watch; a pending check is reported as queued or running before the wait starts; a queued job
is routed as stuck only when no online runner matches its label, or when occupancy is unreadable
or shows a free matching runner past the threshold, never on age alone while the pool is busy.
Basis: gh pr checks --help (--interval default 10) on gh 2.99.0; Workflow
jobs (status, labels, runner_name);
Self-hosted runners (busy,
status, labels; admin access); Check runs
(six status values); Best practices for using the REST
API
("poll only as often as you need to, on a fixed schedule"). Probe on this marketplace's repo the
same day: jobs read succeeded; both runner-list endpoints returned 403 to a non-admin token.
Issue #3955 evidence: --watch exhausted GraphQL while a self-hosted test job was still queued.
As of: 2026-09-28. Recheck: when a gh release changes --watch's transport or default
interval, or when either runners endpoint changes its permission requirement.
gh run view --log-failed is the LAST resort because it truncates at the CLI display layer (~4MB
cap, cli/cli #11059, #10551, #7771, #7642). The REST API path the bundled scripts use returns
complete logs every time. The last-resort eyeball:
gh run view <run-id> --log-failed 2>&1 | grep '##\[error\]'gh api repos/{owner}/{repo}/pulls/<pr_number>/comments/{comment_id}/replies -f body='...'Writing the body to a file first is safer; see the provider reference.
gh api "repos/<owner>/<repo>/commits/<head-sha>/check-runs"gh pr view --json comments,reviews MISSES inline review comments, so the bundled
fetch-all-pr-comments.sh reads all three surfaces instead.