この記事の対象者
- 対象: AI コーディングツール導入済みで、PR の量に情報共有が追いつかなくなっているチーム
- 構成: GitHub Actions + Claude API + Slack Webhook
- コスト: 年間 ~$3.50 (Sonnet 4.6, 週1回実行)
- 所要時間: workflow 1 ファイルで完結、追加インフラなし
背景
AI コーディングツールを導入してから、開発チームのマージ速度が明らかに上がった。
これ自体は良いことなんだけど、副作用として 「今週なにがリリースされたのか、人間が把握できない」 という問題が出てきた。PR が週に 40 本マージされると、さすがに全部は追いきれない。
チームメンバーが「あれ、この機能いつの間に入ったんだっけ?」となる場面が増えてきたので、「じゃあレポートも AI に作らせればいいのでは」と思い、GitHub Actions + Claude API で週次プロダクトレポートを自動生成して Slack に投稿する仕組みを作った。
やったこと
毎週月曜 10:00 (JST) に GitHub Actions が起動し、以下を自動で行う。
- 過去 7 日間にマージされた PR を GitHub Search API で収集
- 各 PR の description と Story Points (GitHub Projects v2) を取得
- Claude API に PR 一覧を渡して、カテゴリ別の日本語レポートを生成
- Slack の Block Kit でリッチフォーマットにして webhook で投稿
実装
全体の workflow
name: Weekly Product Report
on:
schedule:
# Every Monday at 10:00 JST (01:00 UTC)
- cron: "0 1 * * 1"
workflow_dispatch: # 手動トリガーも可能にしておく
workflow_dispatch を入れておくとデバッグ時に手動実行できて便利。
Step 1: マージ済み PR の収集
- name: Collect merged PRs
env:
GH_TOKEN: ${{ github.token }}
run: |
SINCE=$(date -d "7 days ago" +%Y-%m-%d)
gh api search/issues \
--method GET \
--paginate \
-f q="repo:${{ github.repository }} is:pr is:merged merged:>=$SINCE" \
-f per_page=100 \
--jq '[.items[] | {number, title, labels: [.labels[].name]}]' \
| jq -s 'add' > /tmp/merged_prs.json
最初は gh pr list --state merged を使っていたが、日付フィルタの精度が微妙だったので GitHub Search API に切り替えた。--paginate で 100 件超にも対応。
Step 2: PR の詳細と Story Points を取得
- name: Fetch PR details and Story Points
env:
GH_TOKEN: ${{ github.token }}
GH_PAT_PROJECT: ${{ secrets.GH_PAT_PROJECT }}
run: |
while IFS=$'\t' read -r number title; do
# PR description を取得 (テストセクション以降は除去)
RAW_BODY=$(gh pr view "$number" --json body --jq '.body')
BODY=$(echo "$RAW_BODY" \
| sed '/^## Tests/,$d' \
| sed 's/!\[[^]]*\]([^)]*)//g' \
| sed 's/<img[^>]*>//g')
# Story Points を GitHub Projects v2 から取得
# GH_PAT_PROJECT は Classic PAT で repo + read:project scope が必要
SP_RAW=$(GH_TOKEN="$GH_PAT_PROJECT" gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
closingIssuesReferences(first: 5) {
nodes {
projectItems(first: 5) {
nodes {
fieldValues(first: 20) {
nodes {
... on ProjectV2ItemFieldNumberValue {
number
field { ... on ProjectV2Field { name } }
}
}
}
}
}
}
}
}
}
}' ... 2>&1) || true
# エラー時は警告を出して SP=0 にフォールバック
if echo "$SP_RAW" | jq -e '.data' >/dev/null 2>&1; then
SP=$(echo "$SP_RAW" | jq -r '...')
else
echo "::warning::PR #$number: SP fetch failed: $(echo "$SP_RAW" | head -1)"
SP=0
fi
done < <(jq -r '.[] | "\(.number)\t\(.title)"' /tmp/merged_prs.json)
ポイント:
- PR body から
## Tests以降をカット (テスト計画はレポートに不要) -
や<img>タグを除去 (画像 URL はトークンの無駄) - Story Points は PR に直接ではなく、PR が close する Issue の Project Items から取得する必要がある (GitHub Projects v2 の GraphQL API はネストが深い)
-
GH_PAT_PROJECTは Classic PAT でrepo+read:projectscope が必要。Fine-grained PAT は Projects v2 GraphQL API のサポートが限定的なため非推奨 - SP fetch は
|| trueで失敗を吸収し、::warningでログに残す。2>/dev/nullで握りつぶすと問題の発見が遅れる
Step 3: Claude API でレポート生成
- name: Generate weekly report
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# プロンプトを構築して Claude API を呼ぶ
RESPONSE=$(curl -s https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d "$(jq -n --arg prompt "$PROMPT" '{
model: "claude-sonnet-4-6",
max_tokens: 4096,
messages: [{role: "user", content: $prompt}]
}')")
プロンプト全文
You are a product manager writing a weekly update for both engineers and non-engineers.
Rules:
- Write in Japanese. Keep technical terms in English (e.g. GraphQL, API, Slack)
- Each item: 1-2 concise sentences describing WHAT changed for users, not HOW
- Prefix each item with a screen or area tag like [設定画面], [ダッシュボード画面], [一覧画面], etc.
- For bug fixes, describe what was fixed concisely: "{problem}を修正しました" (do NOT use before/after format)
- If a PR title contains (Xsp) or (SPなし), append it as-is to the item
- Do NOT include PR numbers, branch names, file paths, or code terms
- Exclude ONLY: pure infrastructure (Terraform, CI/CD pipelines),
internal tooling, test-only changes, doc-only changes
- DO NOT exclude PRs with feat:, fix:, or refactor: prefixes
— these are almost always product changes
- Include refactoring (internal restructuring) with brief technical summary
- Include ALL user-facing changes, even small ones
- When in doubt, INCLUDE the item rather than excluding it
- ONLY use information from the provided PR list. NEVER fabricate items
Output: Return ONLY a JSON array with no other text. Each object has:
- "category": one of "新機能", "改善", "不具合修正", "リファクタリング"
- "items": array of formatted item strings
Example:
[
{"category": "新機能", "items": ["[設定画面] ユーザーごとの通知設定ができるようになりました。(2sp)"]},
{"category": "改善", "items": ["[一覧画面] 絞り込み条件をより柔軟に扱えるよう改善しました。"]},
{"category": "不具合修正", "items": ["[詳細画面] モーダルで関連データが正しく表示されない問題を修正しました。"]},
{"category": "リファクタリング", "items": ["[バックエンド] 通知処理の内部構造を整理しました。"]}
]
Omit categories with no items.
PR list:
{ここに PR のタイトルと description が続く}
ポイント:
- 出力は JSON 固定。 Slack Block Kit 用に後段で加工するため、構造化データで受け取る
-
除外ルールは「迷ったら含める」方向に倒す。 最初は「Exclude: infrastructure, CI/CD, internal tooling...」とだけ書いたら、Haiku が全 PR を除外して空配列を返してきた。除外対象を厳密に列挙し、
feat:/fix:prefix は除外するなと明示することで解決 - 日本語で書けと指示しつつ、技術用語は英語のまま。 「GraphQL リゾルバー」みたいなカタカナ語を避ける
Step 4: Slack に投稿
- name: Post to Slack
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEEKLY_REPORT_WEBHOOK_URL }}
run: |
# カテゴリごとに emoji を付与
jq --argjson emap '{"新機能":":sparkles:","改善":":bulb:","不具合修正":":beetle:","リファクタリング":":wrench:"}' '
[.[] | select(.items | length > 0) |
.emoji = ($emap[.category] // ":pushpin:")
]' /tmp/categories.json > /tmp/categories_with_emoji.json
# Block Kit のブロック配列を構築 (同じ画面タグの items をグループ化、rich_text でネイティブリスト表示)
blocks=$(jq '
[.[] |
{"type": "divider"},
{"type": "section", "text": {"type": "mrkdwn", "text": (.emoji + " *" + .category + "*")}},
([.items[] | capture("^\\[(?<tag>[^\\]]+)\\]\\s*(?<body>.+)$") // {tag: "", body: .}]
| group_by(.tag)
| .[]
| {"type": "rich_text", "elements": (
[if .[0].tag != "" then
{"type": "rich_text_section", "elements": [
{"type": "text", "text": ("\n[\(.[0].tag)]\n"), "style": {"bold": true}}
]}
else empty end,
{"type": "rich_text_list", "style": "bullet", "elements": [
.[] | {"type": "rich_text_section", "elements": [
{"type": "text", "text": .body}
]}
]},
{"type": "rich_text_section", "elements": [
{"type": "text", "text": "\n"}
]}
]
)}
)
]' /tmp/categories_with_emoji.json)
# header + context + category blocks + footer を組み立てて POST
curl -s -X POST -H 'Content-type: application/json' \
--data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
Claude が返す各 item の [一覧画面] のようなタグを capture で抽出し、group_by(.tag) で同じ画面の items をまとめてから rich_text block に変換している。rich_text_list を使うことで Slack ネイティブの箇条書きリストが表示される。画面タグ間には空の rich_text_section で余白を確保。
Slack 出力例
┌──────────────────────────────────────────────┐
│ :rocket: プロダクトアップデート │
│ │
│ :calendar: 2025-03-15 〜 2025-03-22 │
│ :white_check_mark: マージ PR: 40 :bar_chart: 消化 SP: 28 │
│ │
│ ──────────────────────────────────────────── │
│ :sparkles: 新機能 │
│ │
│ *[設定画面]* │
│ │
│ • ユーザーごとの通知設定ができるように │
│ なりました。(2sp) │
│ │
│ *[一覧画面]* │
│ │
│ • スコアによるソートができるようになりました。 │
│ • 絞り込み条件をより柔軟に扱えるよう改善 │
│ しました。 │
│ │
│ ──────────────────────────────────────────── │
│ :bulb: 改善 │
│ │
│ *[ダッシュボード画面]* │
│ │
│ • 集計値の表示ラベルをよりわかりやすい │
│ 文言に変更しました。 │
│ │
│ ──────────────────────────────────────────── │
│ :beetle: 不具合修正 │
│ │
│ *[詳細画面]* │
│ │
│ • テーブルで長いテキストのデザインが崩れる │
│ 問題を修正しました。 │
│ │
│ ──────────────────────────────────────────── │
│ :robot_face: このレポートは AI によって │
│ 自動生成されています │
└──────────────────────────────────────────────┘
コスト
実測値 (42 PR の週、PR body 込み、Sonnet 4.6):
- Input: 14,459 tokens, Output: 1,572 tokens
- ~$0.067/回 (input $0.043 + output $0.024)
- 年間 ~$3.50 (週1回実行)
参考として他モデルで同じ入力量を処理した場合の推定:
- Haiku 4.5: ~$0.022/回, 年間 ~$1.15
- Opus 4.6: ~$0.112/回, 年間 ~$5.80
最初は Haiku で始めたが、精度の問題で Sonnet に切り替えた (詳しくは後述)。年間 $3.50 なので、コストは気にならない。
ハマったポイント
🤖 Haiku が全 PR を除外して空配列を返した
症状: Claude が [](空配列)を返し、Slack に空のレポートが投稿された
最初のプロンプトでは除外ルールを Exclude: infrastructure, CI/CD, internal tooling... とざっくり書いていた。すると Haiku は全 40 件を「除外対象」と判定し、[] を返してきた。
まずプロンプトの改善を試みた:
- 除外対象を厳密に列挙 (pure infrastructure, internal tooling のみ)
-
feat:/fix:/refactor:prefix の PR は除外するなと明示 - 「迷ったら含める」ルールを追加
- 空配列が返ってきたら workflow を fail させるガードも追加
しかしプロンプト改善だけでは解決しなかった。 除外ルールと包含ルールが競合する場面で、Haiku 4.5 は除外側に倒し続けた。軽量モデルの指示遵守力の限界だと判断し、最終的にモデルを claude-haiku-4-5 → claude-sonnet-4-6 に変更して解決した。
コスト差は年間 ~$1.15 → ~$3.50。この差で安定性が得られるなら安い。
🔑 GitHub token の権限まわりで 2 回ハマった
1 回目: GH_PAT_PROJECT で PR body が取れない
当初、Story Points 取得用の GH_PAT_PROJECT を GH_TOKEN に設定して全操作に使っていた。しかしこのトークンには PR の read 権限がなく、gh pr view が silent fail して全 PR の body が (unavailable) になっていた。
2>/dev/null でエラーを握りつぶしていたため気づけず、タイトルだけでレポートが生成されていた。per-PR のバイト数をログに出して初めて発覚:
PR #2857: raw=14B, stripped=14B, head=GraphQL: Resource not accessible by integration
修正: github.token で PR body を取得し、GH_PAT_PROJECT は Story Points の GraphQL 呼び出し時のみ使うように分離。private repo では workflow に permissions: pull-requests: read の明示も必要。
2 回目: GH_PAT_PROJECT に repo scope がなく SP が全件 0 に
SP fetch の GraphQL クエリが "Could not resolve to a Repository" を返していたが、これも 2>/dev/null で握りつぶされていたため、jq が 0 を返して正常終了。全 PR の SP が 0、合計 SP も 0 という状態が silent に発生していた。
Could not resolve to a Repository with the name 'synergeee/synergeee'.
エラーハンドリングを追加して 2>/dev/null を 2>&1 に変えたところ、今度は GitHub Actions の暗黙の set -e により、gh api graphql が非ゼロ終了した時点でスクリプト全体が即座に終了する問題も踏んだ。最終的に || true で吸収しつつ ::warning でログに残す形に落ち着いた。
原因は GH_PAT_PROJECT が Fine-grained PAT で、private repo へのアクセス権がなかったこと。Classic PAT に repo + read:project scope を付与して解決。
教訓:
-
2>/dev/nullは CI で使わない。エラーの発見が遅れる -
set -e環境でコマンド失敗を吸収するには|| trueが必要 - Fine-grained PAT は Projects v2 GraphQL API のサポートが限定的。Projects v2 を使うなら Classic PAT が確実
🖼️ PR body の画像がトークンを消費する
PR description にスクリーンショットを貼る運用をしていると、 のような長い URL がそのまま prompt に入る。Claude のテキスト API では画像は見えないので、純粋にトークンの無駄。sed で除去するようにした。
🪆 GitHub Projects v2 の Story Points 取得が深い
Story Points は PR 本体ではなく、PR → closing Issue → Project Item → Field Values とたどる必要がある。GraphQL のネストが 5 段くらいになる。
まとめ
- AI でマージ速度が上がると、人間の情報キャッチアップが追いつかなくなる
- 週次レポートも AI に任せれば、コストほぼゼロで解決できる
- GitHub Actions + Claude API だけで完結するので、追加のインフラは不要
- プロンプトの除外ルールは具体的に書くこと。曖昧だと軽量モデルは過剰に除外する
-
2>/dev/nullでエラーを握りつぶすと問題の発見が遅れる。CI では|| true+::warningでエラーを吸収しつつログに残すのが安全 - GitHub token の権限は最小限にしつつ、何に何の権限が必要かを明確にしておくこと。特に Projects v2 を使う場合は Classic PAT に
repo+read:projectscope が必要