お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、GitHub Projectsを使ったタスク管理を自動化していたときのことです。ある日突然、開発中の終了時フックがAPIエラーを吐いて止まりました。調べてみると、裏側で叩かれていたGitHub GraphQL APIの利用枠を一瞬で食い潰していました。今回は、便利コマンドの裏に潜んでいたコストの罠と、走査起点を逆転させて解決した記録について書きます。
CLIコマンド1回で、300ポイント以上溶けていた
DevPickの開発では、Claude Codeの終了時に動くフック(check-issue-project.sh)や、Issue処理用のSkillを使って、作業中のIssueがGitHub Projectsにきちんと紐付いているかを自動で検査していました。登録漏れを防ぐためのガードレールです。
この検査で使っていたのが、GitHub CLIの gh project item-list コマンドでした。プロジェクト内のアイテム一覧をまとめて取得できる便利なコマンドです。
# 変更前の実装イメージ:プロジェクトの全アイテムを取得してローカルで突合
gh project item-list 2 --owner it-araiguma --limit 500 --format json
このコマンドでプロジェクト内のアイテムを全部取ってきて、その中に「いま作業しているIssue」が含まれているかを突合していました。シェルスクリプトとしてはごく自然な発想です。
しかし、ある日作業を進めていると、突然APIからエラーが返ってきてフックが落ちました。原因はGitHub GraphQL APIのレート制限(429 Too Many Requests)です。
調べてみて青ざめました。GitHubのREST APIは「1リクエスト = 1回」と単純にカウントされますが、GraphQL APIはクエリの複雑さや取得ノード数に応じた「ポイント制」で計算されます。そして上限は1時間あたり5,000ポイントです。
DevPickのプロジェクトには当時246件のアイテムが登録されていました。gh project item-list は内部でGraphQLを叩いており、1アイテムあたり約1ポイントを消費します。--limit 500 を指定して246件を取得すると、コマンドを1回叩くだけで約303ポイントを消費していたのです。
5,000ポイントの上限に対して、1回で303ポイント。単純計算で、たった16回コマンドが走っただけで1時間の枠を使い果たします。AIエージェントに指示を出してフックが数回動き、Issueの整理で何回かSkillを動かしたら、あっという間に上限に達して作業が完全にストップしてしまいました。
以前書いたどうせレビューで落ちるPRに、テストもE2Eも全部流していた話でもCIの実行リソースの無駄遣いに触れましたが、今回の相手は外部サービスのAPIクォータでした。
IT女子 アラ美「全件から探す」のをやめ、Issue側から逆引きした
なぜこれほどポイントを食っていたのか。理由は「探索の向き」にありました。
やりたかったことは「特定のIssueがプロジェクトに入っているか」の確認です。それなのに、「プロジェクトという巨大な箱の中身を全部引いてきて、その中から目当てのIssueを探す」という手順を踏んでいました。これでは、プロジェクトに登録されたアイテムが増えれば増えるほど、1回あたりの取得コストが青天井に膨らんでいきます。
そこで、探索の向きを完全に逆にしました。プロジェクトからIssueを探すのではなく、「検査したいIssueの側から、紐付いているプロジェクトを引く」設計への切り替えです。
幸いなことに、GitHub GraphQL APIの Issue オブジェクトには projectItems というフィールドが用意されています。これを使えば、そのIssueがどのプロジェクトのどのアイテムに対応しているかを直接たどることができます。
フック処理を gh project item-list 呼び出しから、gh api graphql による直接の単一クエリへと置き換えました。
# 変更後のクエリ構造(要点を単純化したもの)
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(last: 10, states: [OPEN]) {
nodes {
number
title
createdAt
projectItems(first: 5) {
nodes {
project {
number
}
}
}
}
}
}
}
この変更の効果は劇的でした。
全件リストアップしていたときは1回303ポイントだった消費が、終了時フックの実測で0〜1ポイントに激減しました。Issue処理Skill側で100件・ラベル情報込みの少し大きめのクエリを投げた場合でも、消費はわずか2ポイントです。
303ポイントから2ポイントへ、約99%の削減です。これなら1時間に100回、200回とツールが自動実行されても、5,000ポイントの上限に触れる心配はまったくなくなりました。



ついでに、重要度「未設定」の取りこぼしも塞いだ
クエリをGraphQLに一本化したことで、ポイントの節約以外にも大きな収穫がありました。検査自体の「抜け穴」を塞げたことです。
従来の gh project item-list を使った判定では、「そのIssueがプロジェクトのリストに含まれているか」という登録の有無しか見ていませんでした。しかし運用上は、プロジェクトに登録するだけでなく「重要度(Priority: 高・中・低)」まで設定して初めて完了となります。
実際のリポジトリの履歴を振り返ると、プロジェクトには入っているものの重要度が空のまま放置されているIssueが3件(#527〜#529)残っていました。旧実装のチェックをすべてパスしていたため、見落とされていたのです。
GraphQLで直接クエリを投げる形にしたことで、projectItems の配下からプロジェクト番号だけでなく、フィールド値(重要度)まで1回のリクエストで同時に引けるようになりました。
# テストコードで固定した検査パターン(単純化)
# 登録だけでなく「重要度が設定されているか」まで1回で検証する
IN_PROJECT_WITH_PRIORITY = [{"project": {"number": 2}, "priority": {"name": "中"}}]
IN_PROJECT_NO_PRIORITY = [{"project": {"number": 2}, "priority": None}]
これで、「未登録のIssue」と「登録されているが重要度が空のIssue」を同時に拾い上げ、フックの通知へ出せるようになりました。
もう1点、テストコードで慎重に固定したのが「APIが失敗したときの扱い(fail-openの防止)」です。
もしもGraphQL APIがエラーを返したり、権限不足で repository が null になったりしたときに、「通知するIssueがゼロ件だった」と解釈してマーカーを消してしまうと、検査がコケているのにガードレールをすり抜けてしまいます。
そのため、APIエラー時は「漏れがなかった」と判断せず、検査未完了としてマーカーを残し、次の操作時に再試行する設計にしました。判定できないときは安全側(未完了側)で倒す、という判断です。



まとめ
今回のGraphQLレート制限枯渇とクエリ改善を振り返ると、次の学びがありました。
- 高レベルなCLIコマンドも、裏側のAPI消費モデルを把握する:
gh project item-listのような便利なコマンドも、裏側ではノード数に応じたGraphQLポイントを消費している。手軽に呼べるコマンドほど、ループや自動化フックに入れた際の影響が大きい。 - 探索の起点を逆転させる:「全体の箱から中身を全件出して探す」のではなく、「対象のアイテムから所属先を逆引きする」構造に変えるだけで、取得コストは300ptから1〜2ptへと99%削減できた。
- GraphQLの強みを活かして検査の穴を塞ぐ:所属プロジェクトだけでなく、その中のカスタムフィールド(重要度)まで1クエリで引くことで、「登録はされたが中身が空」という見落としも解消できた。
- API障害時は安全側で止める:エラー応答やnullのときに「問題なし」と判定をすり抜けさせず、未完了マーカーを残して再試行を促すフェイルセーフが不可欠。
AIエージェントや開発自動化フックを組む際、GitHub CLIは非常に頼もしい存在です。しかし、裏側で叩かれているAPIがRESTなのかGraphQLなのか、どんなレート制限やポイント計算があるのかまで把握しておかないと、予期せぬ瞬間に作業を止められてしまいます。
コマンドの手軽さに甘えず、「今本当に必要なデータはどれで、どの向きから引くのが一番安いのか」を意識することの大切さを実感したトラブルでした。












