Claude CodeでGitHub Projectsの進捗を集計し残作業を報告
GitHub ProjectsのステータスとイテレーションをGraphQL APIで全件取得し、マイルストーンごとの残作業を集計して、顧客に送れる文面にする手順です。
GitHub Projectsの進捗は、画面を見れば分かります。ただ、顧客や上長に「v1.0まであと何が残っているか」を週ごとに報告するとなると、画面のスクリーンショットでは足りません。内部のIssue名や担当者名が写り込み、数字も毎回手で数え直すことになります。
この記事では、GraphQL APIでProjectsのアイテムを全件取り、マイルストーンごとの残作業をスクリプトで数えたうえで、Claude Codeに顧客向けの文面を書かせる流れを組みます。数える作業は決まった手順のスクリプトに、言い換えと抜け漏れの指摘はClaude Codeに分けるのが要点です。
全体像: 取得と集計はスクリプト、文面だけをClaudeに任せる
件数の集計までモデルに任せると、数え間違いの余地が残ります。そこで、数字が決まるところまでをghとjqで固定します。
週次報告までの5段
- 1
トークンを用意する
読み取り専用のスコープだけを持つトークンで認証します。
- 2
プロジェクトのIDを調べる
組織名とプロジェクト番号から、GraphQLのノードIDを引きます。
- 3
アイテムを全件取る
100件ずつページを送り、ステータス・イテレーション・マイルストーンの値を付けて受け取ります。
- 4
1行1アイテムの表に整える
jqでフィールド名をキーにした形にそろえます。 - 5
集計してからClaudeに渡す
マイルストーンごとの残件数を出し、集計結果だけを
claude -pに渡します。
取得用のトークンはread:projectに絞る
GitHubの手順では、クエリにはread:projectスコープ、ミューテーションにはprojectスコープが要ります。トークンは個人アクセストークン(classic)でも、GitHub Appのインストールアクセストークンでもかまいません。ghを使うなら、読み取りだけの場合は次のように認証します。
gh auth login --scopes "read:project"スコープが足りないままProjectsのフィールドを引くと、gh 2.90.0では次の形のエラーが返ります。メッセージは'number' field requires one of the following scopes: ['read:project']のように、どのフィールドがどのスコープを要求したかまで教えてくれます。repoやread:orgだけのトークンでは通らない点が、最初のつまずきどころです。
読み取り専用のトークンを使う利点は、もう一つあります。ここから先でClaude Codeにコマンドの実行を任せる場面があっても、アイテムの追加や削除といった書き込みのミューテーションはスコープの時点で通りません。
プロジェクトのIDとフィールドを調べる
更新系の操作ではフィールドのIDとオプションのIDが要りますが、今回のような読み取りでは、必要なのはプロジェクトのノードIDだけです。値の側にフィールド名が付いてくるので、名前で絞り込めます。
組織のプロジェクトは、組織名と番号で引きます。
gh api graphql -f query='
query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) { id title }
}
}' -f org=octo-org -F number=5 --jq '.data.organization.projectV2'ユーザーのプロジェクトならorganization(login: ...)をuser(login: ...)に替えます。番号はプロジェクトのURL末尾の数字です。
プロジェクトにどんなフィールドがあるかは、fields(first: 20)で一覧できます。ステータスは単一選択、イテレーションはイテレーション型で返り、選択肢の名前やイテレーションの開始日も一緒に取れます。報告の文面に使う「Done」などの名前が、プロジェクトごとに違うことが多いので、ここで実際の名前を控えておきます。
アイテムを100件ずつ全件取る
次のクエリをquery.graphqlとして保存します。公式の例ではアイテムを20件、フィールド値を8件までしか取っていません。Projectsは組み込みを含めて最大50フィールドを持てるので、フィールド値は50件まで取る設定にします。
query($project: ID!, $endCursor: String) {
node(id: $project) {
... on ProjectV2 {
items(first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes {
isArchived
fieldValues(first: 50) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name field { ... on ProjectV2FieldCommon { name } }
}
... on ProjectV2ItemFieldIterationValue {
title startDate duration
field { ... on ProjectV2FieldCommon { name } }
}
... on ProjectV2ItemFieldNumberValue {
number field { ... on ProjectV2FieldCommon { name } }
}
... on ProjectV2ItemFieldMilestoneValue {
milestone { title dueOn }
field { ... on ProjectV2FieldCommon { name } }
}
}
}
content {
... on Issue { title url state }
... on PullRequest { title url state }
... on DraftIssue { title }
}
}
}
}
}
}フィールドの種類ごとに、取り出す断片(フラグメント)が違います。
| フィールドの種類 | 型名 | 値の取り方 |
|---|---|---|
| 単一選択(Status) | 型名ProjectV2ItemFieldSingleSelectValue | 値の取り方name |
| イテレーション | 型名ProjectV2ItemFieldIterationValue | 値の取り方title、startDate、duration |
| 数値(見積もり) | 型名ProjectV2ItemFieldNumberValue | 値の取り方number |
| マイルストーン | 型名ProjectV2ItemFieldMilestoneValue | 値の取り方milestone { title dueOn } |
公式の例に載っているのはテキスト・日付・単一選択の断片です。イテレーション・数値・マイルストーンの型は、スキーマ照会の__type(name: "...")で持っているフィールドを引いて決めました。gh api graphql -f query='{__type(name:"ProjectV2ItemFieldIterationValue"){fields{name}}}'のように試せるので、手元の環境で型名を確かめたいときにも使えます。
全ページを一度に取るには、--paginateと--slurpを併用します。ghは、クエリが$endCursor: String変数を受け取り、pageInfo { hasNextPage endCursor }を取っていれば、次のページを自動でたどります。--slurpを付けると、全ページが1つの配列にまとまります。
PROJECT_ID=$(gh api graphql -f query='query { organization(login: "octo-org") {
projectV2(number: 5) { id } } }' --jq '.data.organization.projectV2.id')
gh api graphql --paginate --slurp \
-f query="$(cat query.graphql)" \
-f project="$PROJECT_ID" > out/pages.jsonGraphQLには、取得量の決まりがあります。firstとlastは1〜100の範囲で必ず指定し、1回の呼び出しで要求できるノードは合計50万までです。1ページ100件、フィールド値50件なら、1回に要求するノード数は5,000件ほどで、上限には遠く届きません。一方、レート制限はポイント制で、ユーザーのトークンなら1時間あたり5,000ポイントです。週1回の集計なら問題になりませんが、数百のプロジェクトを回すスクリプトは別です。
ステータス・イテレーション・マイルストーンを1行にそろえる
取得した結果は、フィールド値が配列で入れ子になっています。集計しやすいよう、フィールド名をキーにした1行1アイテムの形に変えます。rows.jqとして保存します。
[ .[].data.node.items.nodes[]
| select(.isArchived | not)
| { title: .content.title,
url: .content.url,
fields: ( [ .fieldValues.nodes[] | select(.field.name)
| { key: .field.name,
value: (.milestone.title // .title // .number // .name) } ]
| from_entries ) } ]jq -f rows.jq out/pages.json > out/rows.jsonこの変換を、2ページにまたがる擬似データ(進行中・完了・未着手・アーカイブ済みの4件)で動かすと、アーカイブ済みが落ち、各行が{"Status": "In Progress", "Iteration": "Sprint 4", "Estimate": 3, "Milestone": "v1.0"}のような形にそろいました。テキスト型のフィールドは断片を用意していないので、その値は行に入りません。報告に使いたいなら、ProjectV2ItemFieldTextValueの断片を足します。
フィールド名を変えたとき、たとえばStatusを「ステータス」に直したときは、rows.jqの後ろで使う名前も合わせます。
マイルストーンごとの残作業を数える
次のagg.jqは、マイルストーンごとの合計・完了・残り・残りの見積もり合計を出します。「完了」はStatusがDoneのものとし、それ以外とStatus未設定を残りに数えます。
group_by(.fields.Milestone // "(未設定)")
| map({ milestone: (.[0].fields.Milestone // "(未設定)"),
total: length,
done: (map(select(.fields.Status == "Done")) | length),
remaining: (map(select(.fields.Status != "Done")) | length),
estimate_left: (map(select(.fields.Status != "Done")
| .fields.Estimate // 0) | add) })jq -f agg.jq out/rows.json > out/summary.json
jq -r '.[] | select(.fields.Status != "Done")
| "- \(.title) [\(.fields.Status // "未設定")]"' out/rows.json > out/open.txt先ほどの擬似データでは、v1.0が合計1件・完了0件・残り1件(見積もり3)、マイルストーン未設定が1件と出ました。数字が決まるのはここまでです。summary.jsonとopen.txtが、Claudeに渡す材料になります。
Claude Codeで顧客向けの文面にする
材料をパイプで渡せば、Claudeに実行権限を与えなくても文面にできます。公式のヘッドレス実行の説明にも、差分をパイプで渡す例があり、「パイプで渡せばClaudeにBashの権限は要らない」と書かれています。GitHubへのアクセスはスクリプト側に閉じ、モデルにはトークンが渡りません。
顧客向けの変換ルールは、毎回プロンプトに書かずにファイルへ置きます。
# rules.md
- 読み手はプロジェクトの発注側の担当者。社内のIssue番号、担当者名、ブランチ名は書かない
- 件数は summary.json の値をそのまま使う。自分で数え直さず、合計が合わなければ指摘する
- 残作業は「何ができていないか」を顧客の言葉で1行にする。Statusの名前(In Progressなど)は使わない
- マイルストーン未設定の項目は、別枠で「期日未定の作業」として件数だけ書く
- 見積もりの合計は「目安」と明記する。工数の約束に見えないようにするcat rules.md out/summary.json out/open.txt | claude -p \
"上のルールに従い、マイルストーンごとの残作業の報告文を書いてください。\
ルールに反する材料があれば、本文の前に列挙してください"出力をそのまま顧客へ送らない運用にしておくと安全です。--output-format jsonを付けると、resultに文面、total_cost_usdに概算費用が入るので、スクリプトから拾えます。--json-schemaで{"report": ..., "problems": [...]}の形を指定すれば、問題点の一覧を機械的に取り出せます。その場合、検証に通らなかった実行はstructured_outputが付かないため、使う前に有無を調べます。
claude -pを週次で回すと使用量の面でも気になる点があるので、5時間枠への影響はclaude -pが5時間枠を対話の約1.8倍消費する報告で計測方法を整理しています。リリース時の顧客向け文面については、Claudeで顧客向けリリースノートを書くと共通する考え方が多く、社内用語の除去ルールはそちらのほうが詳しい内容です。
対話の中で頼む場合は、スクリプトだけを許可する
Claude Codeのセッションで「進捗を取ってきて」と頼む使い方もあります。このときBash(gh api graphql *)を許可すると、同じコマンドでミューテーションも通ってしまいます。許可規則は*より前の文字列で照合されるので、読み取り専用のクエリだけを許したことにはなりません。
そこで、取得部分をscripts/fetch-project.shにまとめ、そのスクリプトだけを許可します。
{
"permissions": {
"allow": ["Bash(./scripts/fetch-project.sh *)"],
"deny": ["Bash(gh api graphql *)"]
}
}公式の説明では、規則は拒否・確認・許可の順に評価され、広い拒否規則は狭い許可規則より優先されます。Claudeが直接打つgh api graphqlはこの拒否規則で止まり、許可されたスクリプトの実行だけが残ります。トークンのスコープをread:projectにしておけば、書き込みにはそもそもprojectスコープが要るので、規則をすり抜けた場合の備えにもなります。
数が合わないときの切り分け
集計結果が画面の数字と食い違ったら、次の順に疑います。
落とし穴
アーカイブしたアイテムを数えている、または落としている
rows.jqはselect(.isArchived | not)でアーカイブ済みを除いています。画面側のビューがアーカイブ済みを含む設定だと、数字が食い違います。顧客に見せる画面と同じ基準にそろえてから比べます。
落とし穴
アイテムの権限が足りず、中身が取れていない
プロジェクトには、閲覧権限のないアイテムが含まれることがあります。その場合のアイテム種別はREDACTEDになります。contentが空の行が目立つときは、トークンの持ち主が対象のリポジトリを読めるか確かめます。
落とし穴
リポジトリ側のマイルストーンの進捗と比べている
リポジトリのマイルストーンは、openIssueCountやprogressPercentageといった件数・進捗のフィールドを自前で持っています。Projectは複数のリポジトリや下書きIssueを含められるので、同じ名前のマイルストーンでも件数が一致しないことがあります。顧客に示す数字はどちらの基準かを先に決めます。
落とし穴
Statusの名前が「Done」ではない
Doneと書いたところを別名にしているプロジェクトでは、完了が全件残りに入ります。フィールド一覧で選択肢の名前を確かめ、agg.jqの比較を直します。
まとめ
進捗の集計で決めるのは、「完了」の基準と、顧客に見せる単位の2つです。この2つをスクリプトに固定すれば、数字は毎週同じ手順で出ます。Claude Codeに任せる範囲は、文面への言い換えと、ルールに反する材料の指摘までに絞ると、取りこぼしも誤送信も起きにくくなります。
Projectsの値を取る処理が組めたら、同じ型をほかの棚卸しにも使えます。GitHub組織のメンバー側の整理はClaude CodeでGitHub組織のメンバーを棚卸しするにあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- docs.github.com/en/issues/planning-and-tracking-with-projects/automating-y…
- docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-gr…
- docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api
- docs.github.com/en/issues/planning-and-tracking-with-projects/learning-abo…
- cli.github.com/manual/gh_api
- code.claude.com/docs/en/headless
- code.claude.com/docs/en/permissions