2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeのhooks機能で『AIが書いたコードを自動テスト→自動修正』するCI/CDパイプラインを3時間で構築した話

2
Posted at

AIエージェントが生成したコード、レビューなしでマージしていませんか?

結論から言います。Claude Codeのhooks機能とGitHub Actionsを組み合わせれば、「コード生成→lint/型チェック→テスト→失敗時の自動修正→再テスト」の品質ゲートを完全自動化できます。 構築にかかった時間は約3時間。手動レビュー工数は体感で60%減りました。

本記事では、設計思想から設定ファイル全文、そして最初にハマった「無限ループ地獄」の失敗談まで、すべて公開します。

問題提起: AIエージェントの出力品質をどう担保するか

Claude Codeは強力です。指示を出せば数百行のコードを一気に生成してくれます。しかし、ここで厄介な問題が生まれます。

  • 「動くけど正しくない」コードが混入する: 型の不整合、エッジケースの未処理、既存テストとの矛盾
  • 人力レビューがボトルネックになる: AI生成コードのdiffは大きくなりがちで、レビュアーの認知負荷が高い
  • 「AIが書いたから大丈夫だろう」というバイアス: 特に急いでいるとき、レビューが形骸化しやすい

つまり、AIの出力速度に人間のレビュー速度が追いつかないのが根本課題です。

ならば、品質担保もAIとCIに任せてしまおう——そう考えて構築したのが今回のパイプラインです。

Claude Code hooks機能とは

hooks機能は、Claude Codeのエージェントループの特定タイミングでシェルコマンドを自動実行できる仕組みです。.claude/hooks.json に定義します。

3つのフックポイント

フック 発火タイミング 主な用途
PreToolUse ツール実行 危険な操作のブロック、入力バリデーション
PostToolUse ツール実行 lint、型チェック、フォーマッタの自動実行
Notification ユーザー入力待ち時 Slack通知、ログ記録

重要なポイントは、hookスクリプトの終了コードと標準出力がClaude Codeにフィードバックされることです。

  • 終了コード 0: 成功。処理続行
  • 終了コード 2: ブロック。ツール実行をキャンセルし、stdoutの内容がClaude Codeへのフィードバックになる
  • それ以外の終了コード: エラー表示のみ(処理は続行)

この仕組みにより、Claude Codeが書いたコードに対して即座にlintや型チェックを走らせ、失敗したらClaude Code自身に修正させることが可能になります。

設計: hooks × GitHub Actions × pytest で作る自動品質ゲートの全体像

今回構築したパイプラインの全体像です。

2段階の品質ゲートがポイントです。

  1. ローカルゲート(hooks): コミット前にlintと型チェックで明らかなミスを弾く
  2. CIゲート(GitHub Actions): テスト実行で機能的な正しさを検証し、失敗時はClaude Codeにフィードバック

実装Step1: PostToolUse hookでコミット前にlint+型チェックを強制する

まず、Claude Codeがファイルを書き込むたびにruffとmypyを走らせるhookを設定します。

.claude/hooks.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": {
          "tool_name": "write_file|edit_file|create_file",
          "file_pattern": "**/*.py"
        },
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/scripts/post-write-check.sh \"$CLAUDE_FILE_PATH\"",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

.claude/scripts/post-write-check.sh

#!/bin/bash
set -euo pipefail

FILE_PATH="$1"

# Pythonファイル以外はスキップ
if [[ "$FILE_PATH" != *.py ]]; then
  exit 0
fi

ERRORS=""

# ruff lint
if ! ruff check "$FILE_PATH" 2>&1; then
  ERRORS+="[ruff] lintエラーが見つかりました。上記のエラーを修正してください。\n"
fi

# ruff format チェック(書き換えはしない)
if ! ruff format --check "$FILE_PATH" 2>&1; then
  ERRORS+="[ruff] フォーマットが不正です。ruff formatの規則に従って修正してください。\n"
fi

# mypy 型チェック
if ! mypy "$FILE_PATH" --ignore-missing-imports 2>&1; then
  ERRORS+="[mypy] 型エラーが見つかりました。上記のエラーを修正してください。\n"
fi

if [ -n "$ERRORS" ]; then
  echo -e "$ERRORS" >&2
  exit 2  # exit 2 でClaude Codeにフィードバック&ツール実行ブロック
fi

echo "✅ lint・型チェック通過"
exit 0

このhookにより、Claude Codeがファイルを書き込むたびに自動でチェックが走り、エラーがあればClaude Code自身が修正を試みます。 人間が介入する必要はありません。

動作の様子

実際に動かすと、Claude Codeのログにこのように表示されます。

[Hook: PostToolUse] Running post-write-check.sh for src/service.py
[ruff] src/service.py:42:5 F841 local variable 'result' is assigned but never used
[Hook] Exit code 2 — feeding back to Claude Code

Claude Codeはこのフィードバックを受け取り、42行目の未使用変数を自動的に削除して再度書き込みます。再びhookが走り、今度はパスする——というループです。

実装Step2: GitHub Actionsでテスト実行→失敗時にClaude Codeへ自動フィードバック

ローカルのlint/型チェックを通過したコードがpushされたら、CIでテストを実行します。

.github/workflows/claude-code-ci.yml

name: Claude Code CI Pipeline
on:
  pull_request:
    branches: [main, develop]

permissions:
  contents: write
  pull-requests: write
  issues: write

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: |
          pip install -e ".[dev]"

      - name: Run pytest
        id: pytest
        continue-on-error: true
        run: |
          pytest tests/ \
            --tb=short \
            --no-header \
            -q \
            2>&1 | tee pytest-output.txt
          echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT

      - name: Post failure feedback to PR
        if: steps.pytest.outputs.exit_code != '0'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const output = fs.readFileSync('pytest-output.txt', 'utf8');
            const body = `## ❌ テスト失敗 — Claude Code自動修正リクエスト

            以下のテストが失敗しました。修正をお願いします。

            \`\`\`
            ${output.slice(-3000)}
            \`\`\`

            <!-- CLAUDE_AUTO_FIX_REQUESTED -->
            `;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: body
            });

      - name: Fail the job if tests failed
        if: steps.pytest.outputs.exit_code != '0'
        run: exit 1

  auto-fix:
    needs: test
    if: failure()
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref }}
          fetch-depth: 0

      - name: Check retry count
        id: retry
        run: |
          COUNT=$(git log --oneline --grep="auto-fix" HEAD~5..HEAD | wc -l)
          echo "count=$COUNT" >> $GITHUB_OUTPUT
          if [ "$COUNT" -ge 3 ]; then
            echo "🛑 自動修正の上限(3回)に達しました。人間のレビューが必要です。"
            exit 1
          fi

      - name: Run Claude Code auto-fix
        if: steps.retry.outputs.count < 3
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          claude -p "
          以下のテスト失敗を修正してください。
          修正は最小限にし、既存の機能を壊さないようにしてください。

          $(cat pytest-output.txt)
          " --allowedTools "edit_file,read_file,list_files,bash(pytest:*)"

      - name: Commit and push fix
        if: steps.retry.outputs.count < 3
        run: |
          git config user.name "claude-auto-fix[bot]"
          git config user.email "claude-auto-fix@bot"
          git add -A
          DIFF=$(git diff --cached --stat)
          if [ -n "$DIFF" ]; then
            git commit -m "fix(auto-fix): Claude Codeによるテスト失敗の自動修正 [retry: ${{ steps.retry.outputs.count }}]"
            git push
          else
            echo "変更なし。修正できませんでした。"
            exit 1
          fi

実装Step3: 失敗テスト解析→修正コミット→再テストのループ

Step2のワークフローがpushすると再びCIが起動し、テストが再実行されます。これが自動修正ループです。

ポイントは以下の3つです。

  1. pushトリガーで自然にループする: 修正コミットのpushがCIを再起動するので、特別なループ機構は不要
  2. リトライ上限はgit logで判定: コミットメッセージにauto-fixを含むコミットを直近5件からカウント
  3. --allowedToolsで権限を絞る: Claude Codeに渡すツールを最小限にし、意図しない変更を防止

最初の失敗: 無限ループ地獄とその対策

正直に書きます。最初の実装では無限ループにハマりました。

何が起きたか

テストが失敗 → Claude Codeが修正 → 修正が別のテストを壊す → 再修正 → また別のテストが壊れる → 以下無限……

課金額を見て冷や汗をかきました。

対策として入れた3つのセーフガード

{
  "safeguards": {
    "max_retries": 3,
    "diff_size_limit_lines": 50,
    "timeout_minutes": 10
  }
}

① リトライ上限(max 3回)

前述のとおり、git logベースでカウントします。3回修正しても直らないなら、それは人間が見るべき問題です。

② diff行数の制限(max 50行)

Claude Codeの修正が50行を超えた場合、コミットせずにエスカレートします。大きな修正はたいてい間違った方向に進んでいるサインです。

# auto-fixジョブ内に追加
DIFF_LINES=$(git diff --cached --numstat | awk '{s+=$1+$2} END {print s}')
if [ "$DIFF_LINES" -gt 50 ]; then
  echo "🛑 修正が大きすぎます(${DIFF_LINES}行)。人間のレビューが必要です。"
  git reset HEAD
  exit 1
fi

③ ジョブタイムアウト(10分)

GitHub Actionsのtimeout-minutes: 10で、どんな状況でも10分で強制終了します。

この3つを入れてからは安定して動いています。

成果: 手動レビュー工数60%削減、テストカバレッジ自動維持

2週間運用した結果です(チーム4名、Pythonプロジェクト)。

指標 導入前 導入後 変化
PR当たりの手動レビュー時間 平均25分 平均10分 -60%
lint/型エラーの指摘 PR当たり3.2件 0件(hookで自動解決) -100%
テストカバレッジ 72% → 徐々に低下 78%で安定維持 +6pt
CIでの自動修正成功率 68%(3回以内で解決)
マージまでのリードタイム 平均4.2時間 平均1.8時間 -57%

レビュアーがlintエラーや型エラーを指摘する作業がゼロになったのが最大の効果です。人間は「設計意図の妥当性」「ビジネスロジックの正しさ」という本質的なレビューに集中できるようになりました。

設定ファイル全文

.claude/hooks.json(完全版)

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": {
          "tool_name": "bash"
        },
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/scripts/block-dangerous-commands.sh",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": {
          "tool_name": "write_file|edit_file|create_file",
          "file_pattern": "**/*.py"
        },
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/scripts/post-write-check.sh \"$CLAUDE_FILE_PATH\"",
            "timeout": 30
          }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/scripts/notify-slack.sh",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

.claude/scripts/block-dangerous-commands.sh

#!/bin/bash
# rm -rf / や本番DBへの接続などをブロック
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

BLOCKED_PATTERNS=(
  "rm -rf /"
  "DROP DATABASE"
  "DROP TABLE"
  "prod"
  "production"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qi "$pattern"; then
    echo "🚫 危険なコマンドをブロックしました: $pattern を含むコマンドは実行できません" >&2
    exit 2
  fi
done

exit 0

まとめ

  • Claude Codeのhooks機能(PostToolUse)でlint/型チェックを自動実行し、エラーをClaude Code自身にフィードバックして即時修正させることで、コミット前の品質を担保できる
  • GitHub Actionsと組み合わせてテスト失敗→自動修正→再テストのループを構築すれば、CI/CDパイプライン全体でAI生成コードの品質ゲートを自動化できる
  • 無限ループ対策(リトライ上限3回・diff行数制限・タイムアウト)は必須。これを入れ忘れると課金とCIリソースが爆発する

参考リンク

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?