Claude Codeでgh api paginateの取りこぼしを防ぐ — レート制限と件数照合
組織の全リポジトリをgh apiで取るとき、Linkヘッダーのたどり方、403と429での待ち方、APIの件数との照合までを1本のスクリプトにまとめ、Claude Codeに任せる権限設定も添えます。
gh api --paginateは、次のページがなくなるまで順に取得するフラグです。ところが、途中で403が返ったときの待ち方や、取れた件数が正しいかの確認は担当範囲に入っていません。数百のリポジトリを棚卸しするスクリプトでは、そこが取りこぼしの入口になります。
この記事では、組織の全リポジトリを漏れなく取るスクリプトを1本書きます。Linkヘッダーをたどり、403と429で待ち、最後にAPIが返す件数と突き合わせます。Claude Codeには、このスクリプトだけを実行させる設定も付けます。
gh api --paginateだけで足りる範囲
最短の書き方は次の1行です。--slurpは--paginateと組み合わせて使い、全ページの配列を外側の配列に包みます。
gh api --paginate --slurp "orgs/ACME/repos?per_page=100" | jq 'add'数十件のリポジトリを一度だけ取るなら、これで足ります。足りなくなるのは、次の3つが重なったときです。
--paginateだけでは見えないもの
待ち方
gh apiのマニュアルは、次のページがなくなるまで順にリクエストすると説明しています。レート制限に当たったときの待機やリトライには触れていません。
件数の正しさ
取れた配列の長さが、組織の持つリポジトリ数と合っているかは別問題です。照合の元になる数を自分で取りにいく必要があります。
後続のリクエスト
一覧のあとにリポジトリごとの詳細を引くと、リクエスト数は一覧の約100倍に膨らみます。予算の計算は一覧の外で起きます。
もう1つ、書き方の罠があります。gh apiは-fや-Fでパラメータを足すと、メソッドをGETからPOSTに切り替えます。per_pageを-f per_page=100で渡すと、一覧取得のつもりがPOSTになります。クエリはURLに書きます。
全リポジトリ取得で上限に当たる場所
REST APIの上限は、1時間あたりの回数(primary)と、短時間の集中を防ぐ上限(secondary)の2層です。数字を先に並べます。
認証方法ごとのprimary上限(1時間あたり)
未認証
60
リクエスト
個人の認証
5,000
リクエスト
Actionsのトークン
1,000
リポジトリごと
Actionsで動かすなら、GITHUB_TOKENの上限が1リポジトリあたり1時間1,000リクエストです。ローカルで個人のトークンを使う場合の5,000より小さい点に注意が要ります。なお、Enterprise Cloudの組織が所有するアプリ経由などでは上限が15,000になる条件もあります。
secondaryのほうは、次のような起き方をします。
- 同時リクエストは100まで。REST APIとGraphQL APIで共有される
- 1つのエンドポイントへのリクエストは、REST APIで毎分900ポイントまで。ほとんどのGETは1ポイント
- 60秒の実時間あたり、CPU時間90秒まで
一覧の取得そのものは軽い処理です。100件ずつ取れば、2,300リポジトリでも23リクエストで済みます。重くなるのは一覧のあとです。2,300件それぞれに詳細を1回引くと2,300リクエストになり、一覧と合わせて2,323。1時間5,000の枠の半分近くを一度の棚卸しで使います。
同時実行は避けます。ベストプラクティスの説明には、secondaryを避けるにはリクエストを並列でなく直列に出す、とあります。1秒待つ指示があるのはPOSTなど書き込み系の連続で、読み取りのGETには書かれていません。直列で回せば、同時100の上限には構造上触れません。
取りこぼしを見つける件数照合
ページ送りで取りこぼす原因は、主に3つです。
per_pageに100より大きい値を渡すと、エラーにならずに100へ切り詰められる。想定より少ない件数が黙って返る- 取得の途中でリポジトリが増減し、ページの境目がずれて重複や欠落が起きうる
- 途中の失敗を握りつぶして、取れた分だけで先へ進む
1つめは設計で避けられます。per_pageは100を指定し、次ページのURLは自分でpage=Nを組み立てずにLinkヘッダーからそのまま使います。ベストプラクティスにも、ページ送りのクエリを手で組み立てず、Linkヘッダーで次に取れるページを判断するよう書かれています。ページが1つで収まるときはLinkヘッダー自体が付かないので、「Linkがない」は「終わり」と同じ意味になります。
2つめには並び順で備えます。組織のリポジトリ一覧はsortの既定がcreatedで、full_nameを選ぶとdirectionの既定はascです。名前の昇順に固定すると、ずれの原因は、取得済みの範囲に入る名前の増減に絞られます。完全には防げないので、最後に件数を照合します。
3つめの照合元は、組織本体のエンドポイントです。GET /orgs/{org}の応答例にはpublic_reposとtotal_private_reposが並んでいます。取得結果をfull_nameで重複除去した件数が、この2つの合計と一致すれば、少なくとも件数の上では取りこぼしがありません。
total_private_reposは、呼び出す人の権限によっては応答に含まれない場合があります。ない場合に備えて、スクリプトは公開リポジトリだけをpublic_reposと照合する側へ切り替えます。
Linkヘッダーをたどるスクリプト
ここまでをまとめたスクリプトです。読み取り専用で、叩くのはGETだけです。
#!/usr/bin/env bash
# 使い方: ./list-org-repos.sh <org> > repos.jsonl
# 読み取り専用(GET のみ)。ページ送りは Link ヘッダーの rel="next" をたどる。
set -euo pipefail
ORG="${1:?org を指定してください}"
MAX_RETRY=5
LOW_WATER=10 # x-ratelimit-remaining がこの値以下なら、リセットまで待つ
hdr() { # hdr <name> : 直近レスポンスのヘッダー値(小文字で照合)
printf '%s\n' "$HEADERS" | awk -v k="$(printf '%s' "$1" | tr 'A-Z' 'a-z')" \
'BEGIN{FS=": "} tolower($1)==k {sub(/^[^:]*: */,""); print; exit}'
}
fetch() { # fetch <path> : HEADERS / BODY を埋める。失敗したら非ゼロで終了
local path="$1" attempt=0 raw status wait
while :; do
raw=$(gh api -i "$path" 2>/dev/null | tr -d '\r' || true)
HEADERS=$(printf '%s\n' "$raw" | sed '/^$/q')
BODY=$(printf '%s\n' "$raw" | sed '1,/^$/d')
status=$(printf '%s\n' "$HEADERS" | head -n1 | awk '{print $2}')
case "$status" in
2*) return 0 ;;
403|429)
attempt=$((attempt + 1))
[ "$attempt" -gt "$MAX_RETRY" ] && { echo "リトライ上限: $path" >&2; return 1; }
if [ -n "$(hdr retry-after)" ]; then
wait=$(( $(hdr retry-after) + 1 ))
elif [ "$(hdr x-ratelimit-remaining)" = "0" ]; then
wait=$(( $(hdr x-ratelimit-reset) - $(date +%s) + 1 ))
[ "$wait" -lt 1 ] && wait=1
else
wait=$(( 60 * 2 ** (attempt - 1) )) # 60, 120, 240 ... 秒
fi
echo "status=$status ${wait}秒待ちます (試行 $attempt/$MAX_RETRY)" >&2
sleep "$wait"
;;
*) echo "status=${status:-なし}: $path" >&2; return 1 ;;
esac
done
}
# 1) 期待件数: 組織の public_repos + total_private_repos
fetch "orgs/$ORG"
EXPECTED=$(printf '%s' "$BODY" | jq '(.public_repos // 0) + (.total_private_repos // 0)')
HAS_PRIVATE=$(printf '%s' "$BODY" | jq 'has("total_private_repos")')
# 2) 全ページ取得。次ページは Link ヘッダーの URL をそのまま使う
TMP=$(mktemp); trap 'rm -f "$TMP"' EXIT
next="orgs/$ORG/repos?per_page=100&sort=full_name&direction=asc"
pages=0
while [ -n "$next" ]; do
fetch "$next"
pages=$((pages + 1))
printf '%s' "$BODY" | jq -c '.[]' >> "$TMP"
next=$(hdr link | tr ',' '\n' | sed -n 's/.*<\(.*\)>; rel="next".*/\1/p')
next="${next#https://api.github.com/}"
rem=$(hdr x-ratelimit-remaining)
if [ -n "$rem" ] && [ "$rem" -le "$LOW_WATER" ] && [ -n "$next" ]; then
sleep $(( $(hdr x-ratelimit-reset) - $(date +%s) + 1 ))
fi
done
# 3) 重複を除いて出力し、件数を照合する
# total_private_repos が応答に無いときは、公開リポジトリだけを public_repos と照合する
if [ "$HAS_PRIVATE" = "true" ]; then SEL='.'; else SEL='select(.private == false)'; fi
UNIQ=$(jq -s "unique_by(.full_name) | map($SEL) | length" "$TMP")
TOTAL=$(wc -l < "$TMP" | tr -d ' ')
jq -c -s 'unique_by(.full_name)[]' "$TMP"
echo "pages=$pages rows=$TOTAL unique=$UNIQ expected=$EXPECTED private_count_present=$HAS_PRIVATE" >&2
[ "$UNIQ" -eq "$EXPECTED" ] || { echo "件数が一致しません (取得 $UNIQ / API の合計 $EXPECTED)" >&2; exit 2; }作りは3段です。
fetchが1ページ取得し、2xx以外なら待つか中断する- メインのループが、Linkヘッダーの
rel="next"のURLをgh apiの引数に直して次を取る - 最後に重複を除いた件数を、組織の合計と比べる
gh api -iはステータス行とヘッダーを本文の前に出力します。ここからLink、Retry-After、X-Ratelimit-Remainingを読みます。次ページのURLはhttps://api.github.com/で始まる絶対URLなので、先頭を削ってパスにしてから渡します。
403と429での待ち方を決める
primaryとsecondaryのどちらに当たっても、応答は403か429です。見分けはヘッダーで行います。待ち方は次の順で決めます。
| 状況 | 見るヘッダー | 待つ時間 |
|---|---|---|
| Retry-Afterがある | 見るヘッダーretry-after | 待つ時間その秒数が過ぎるまで |
| 残りが0 | 見るヘッダーx-ratelimit-remaining、x-ratelimit-reset | 待つ時間resetのUTCエポック秒まで |
| どちらもない | 見るヘッダーなし | 待つ時間最低1分、続くなら指数的に延ばす |
3行目は、スクリプトでは60秒、120秒、240秒と倍にして、5回目で打ち切る実装にしています。回数と初期値は決まりではなく、自分で決める値です。待たずに叩き続けると、連携が停止される可能性があると説明されています。
成功した応答でも、x-ratelimit-remainingが小さければ先に待ちます。スクリプトのLOW_WATERが10なのは、手元の設計上の目安です。リージョンが違うと残り回数が前後することがあり、GET /rate_limitの値とヘッダーが食い違えばヘッダーを信じる、というのが仕様の説明です。正確な残り数に依存する作りは避けます。
GET /rate_limitはprimaryの消費に数えられませんが、secondaryには数えられることがあります。ループの中で毎回呼ぶ使い方は向きません。
ネットワークに出ずに分岐を確かめる
429や403を意図して起こすことはできません。そこで、gh api -iの応答を固定で返す代用品をPATHの先頭に置きます。代用品は2ページ目の1回目だけ403とRetry-After: 1を返し、3ページで5件を渡し切ります。
代用品のgh(ネットワークには出ません)
#!/usr/bin/env bash
# gh api -i <path> の簡易代用品(ネットワークには出ない)
D="$(dirname "$0")/../data"; path="$3"
[ "$1 $2" = "api -i" ] || exit 9
crlf() { sed 's/$/\r/'; }
case "$path" in
orgs/acme) printf 'HTTP/2.0 200 OK\nX-Ratelimit-Remaining: 4999\n\n' | crlf; cat "$D/org.json";;
orgs/acme/repos*|organizations/1/repos*)
p=$(printf '%s' "$path" | sed -n 's/.*[?&]page=\([0-9]*\).*/\1/p'); p=${p:-1}
if [ "$p" = 2 ] && [ ! -f "$D/.fail_done" ]; then
touch "$D/.fail_done"
printf 'HTTP/2.0 403 Forbidden\nRetry-After: 1\nX-Ratelimit-Remaining: 4990\n\n' | crlf
echo '{"message":"You have exceeded a secondary rate limit."}'; exit 1
fi
nxt=""; [ "$p" -lt 3 ] && nxt="Link: <https://api.github.com/organizations/1/repos?per_page=100&sort=full_name&direction=asc&page=$((p+1))>; rel=\"next\", <https://api.github.com/organizations/1/repos?per_page=100&page=3>; rel=\"last\""
{ printf 'HTTP/2.0 200 OK\n'; [ -n "$nxt" ] && printf '%s\n' "$nxt"; printf 'X-Ratelimit-Remaining: 4990\n\n'; } | crlf; cat "$D/p$p.json";;
*) printf 'HTTP/2.0 404 Not Found\n\n' | crlf; echo '{}'; exit 1;;
esac組織が合計5件を返す設定で実行すると、待機を挟んで完走します。
PATH="$PWD/bin:$PATH" ./list-org-repos.sh acme > repos.jsonlstatus=403 2秒待ちます (試行 1/5)
pages=3 rows=5 unique=5 expected=5 private_count_present=true組織の合計を6に変えると、同じ5件を取ったうえで終了コード2で止まります。
pages=3 rows=5 unique=5 expected=6 private_count_present=true
件数が一致しません (取得 5 / API の合計 6)これは代用品を相手にした出力で、実際のAPIの応答ではありません。分岐が意図どおり動くかを見るためのもので、実際のAPIでは、待ち時間の長さやRetry-Afterの有無は状況で変わります。
Claude Codeにはスクリプトだけを実行させる
スクリプトの安全性は、スクリプトの中身で決まります。Claude Codeの許可ルールは、Claudeが書いたコマンドの文字列に対して照合されます。Bash(./list-org-repos.sh *)を許可すると、許可されるのは「このスクリプトを実行する」という文字列です。中で何をするかは審査されません。許可の前に、中身がGETだけであることを読んで確かめます。
{
"permissions": {
"allow": [
"Bash(./list-org-repos.sh *)"
],
"deny": [
"Bash(gh api *-X*)",
"Bash(gh api *-f *)",
"Bash(gh api *-F *)",
"Bash(gh api *--input*)"
]
}
}組み込みの読み取り専用コマンドの集合はls、cat、grep、読み取り形のgitなどで、一覧にghは載っていません。許可がなければ毎回確認が出ます。また、> repos.jsonlのようなリダイレクトは、書き込み先が編集ルールと作業ディレクトリの範囲で検査されます。
denyルールは、Claudeが通常書く形の呼び出しを止めるもので、bash -c 'gh api -X PUT ...'のような別の書き方までは止まりません。ルールの限界と、書き込み系のフラグを塞ぐdenyの全体像は、ブランチ保護をgh apiで棚卸しする手順にまとめています。厳密に止めたいときは、そちらの記事のとおりトークンの権限を絞ります。
実行の指示はCLAUDE.mdに置くと安定します。
## GitHub APIの取得ルール
- リポジトリ一覧は ./list-org-repos.sh <org> だけで取る。gh api を直接ループしない
- 並列実行(& や xargs -P)を使わない。リクエストは直列にする
- 終了コードが0でなければ、標準エラーの最終行を読んで報告する。取れた分だけで集計しない
- 完了報告には pages / unique / expected の値をそのまま添える完了報告に件数を求めるのが要点です。Claudeが「全件取得しました」とだけ答える形では、取りこぼしの有無を人が確かめられません。uniqueとexpectedが並んでいれば、一致を目で確認できます。
GraphQLで取る場合とGITHUB_TOKENの使い方
REST APIでなく、GraphQLのgh api graphql --paginateでも一覧は取れます。マニュアルの例では、クエリが$endCursor: String変数を受け取り、pageInfo{ hasNextPage, endCursor }を返す形にします。GraphQL APIはRESTとは別にprimary上限を持ちます。件数の照合や待ち方の設計は同じ考え方で組めます。
Actionsで同じ処理を回すなら、GH_TOKENにGITHUB_TOKENを渡して実行します。上限が1リポジトリ1時間1,000リクエストなので、一覧のあとに各リポジトリへ詳細を引く構成は、先に件数を掛け算で見積もっておきます。
組織の全リポジトリに対する一括設定の注意は、Claude Code Reviewを組織の全リポジトリに有効化する手順で扱っています。別のAPIのレート制限を守る移行スクリプトは、Notion APIのレート制限をClaude Codeで守る移行スクリプトが参考になります。
まとめ
漏れのない取得は、待つ、たどる、数える、の3点で成り立ちます。--paginateは「たどる」だけを引き受けるので、待ち方と件数の照合は自分のスクリプトで補います。Claude Codeに任せるときは、実行するコマンドを1本に絞り、終了コードと件数を報告させる形にしておくと、取りこぼしが静かに混ざる経路が減ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- code.claude.com/docs/en/permissions
- docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
- docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api
- docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-re…
- cli.github.com/manual/gh_api
- docs.github.com/en/rest/repos/repos
- docs.github.com/en/rest/orgs/orgs