🏎️

PRのレビューをClaudeで超楽にする

に公開

はじめに

コードレビューは開発プロセスにおいて重要な工程ですが、時間がかかる作業でもあります。特に大規模なPRや複雑な変更に対しては、レビューの準備だけでも相当な時間を要します。

最近は、GitHub上でPR作成時にDevinやClaudeでレビューさせる方法もあり、これもかなり有用です。
一方で、レビューをそこで完結させようとすると、prompt tuningや詳細なコンテキストの必要性があります。

そこで、Claude APIを活用してレビューを完結させるのではなく、補助してもらいレビュー効率を劇的に向上させるシステムを構築しました。

システムの概要

このシステムは以下の3つのコンポーネントで構成されています:

  1. Claude Codeのカスタムコマンド
  2. レビュー結果の保存・管理システム
  3. レビュー結果の表示システム

1. Claude Codeのカスタムコマンド

Claude Codeにはカスタムコマンドを定義できる機能があり、.claude/commands/ディレクトリなどにMarkdownファイルを配置することで、独自のコマンドを作成できます。

今回作成したpr-review.mdは、GitHubのPRを分析し、コード品質やパフォーマンス観点から構造化されたレビューコメントを生成します。

また、このコマンドでAIに全てをレビューさせるのではなく、人がレビューする時に手助けとなるコメントを生成するようにしました。

今回はこんな感じのMarkdownを用意しました。この指示自体も50%くらいはClaudeに書いてもらいました。

Review the PR: $ARGUMENTS

あなたはプルリクエストのレビューを支援するAIです。
このタスクは非常に高レベルなスキルが要求されるので、 ultrathink するようにしてください。

プルリクエストの内容を分析し、以下の観点からコメントを作成してください:

1. 品質
2. セキュリティ
3. パフォーマンス
4. テスト
5. ドキュメント

分析結果を以下のフォーマットでMarkdownコードブロック内に出力してください。

```markdown
## コードレビュー: [PR タイトル]

### 📋 PRの概要

[このPRの目的と実装内容を2-3文で簡潔に説明]

### このPRの背景知識として抑えておくべきコードの知識

[このPRを理解するために必要なコードの知識や背景情報]

### 人間が重点的にチェックする場所

- [人間がチェックすべきポイント1(ファイルのパスも含めて)]
- [人間がチェックすべきポイント2(ファイルのパスも含めて)]

### 分かりづらい箇所の解説

- [分かりづらい箇所1の説明(ファイルのパスも含めて)]
- [分かりづらい箇所2の説明(ファイルのパスも含めて)]

### その他のコメント

[全体的なフィードバック]

フィードバックのタイプ:
- POSITIVE: 良い点や成功している部分を強調する場合
- IMPROVEMENT: 具体的な改善が必要な点がある場合  
- SUGGESTION: 新しいアイデアや提案を提供する場合

### PRのリンク

[PRのリンク](PRのURL)

実はここに書いてある内容は、PRのdescriptionとして書くべき内容にも近しいです。
ただ、実装者以外の目線で出力されることが多く、やってみると意外と有用でした。

2. レビュー結果の保存・管理システム

上記の変更によって、claude "/pr-review <PRのURL>" を実行すると、標準出力にレビューがMarkdownとして表示されるようになりました。

しかし、このコマンドをレビュー依頼が来るたびに自分で打つのは面倒です。そこで、以下の機能を持つshell scriptを作りました

  1. 以下の条件に合致するPRのレビュー一覧を取得
    • 自分がレビュワーにアサインされている
    • まだ自分がレビューを行っていない
    • openされている(mergeやcloseされていない)
  2. それらを順次 claude "/pr-review <PRのURL> を通してレビューする
  3. 標準出力の内容から、Markdown部分だけを切り出して、特定のディレクトリに保存する
  4. 一度レビューしたPRはログに記述して再度実行してもレビューしないようにする

これを cron 経由で定期実行することで、いつの間にかClaude Codeによってレビューが完了しています

# 平日の8:00-21:55まで10分ごとに実行
*/10 8-21 * * 1-5 cd <このシェルスクリプトの場所> && ./check-pr-reviews.sh >> $HOME/.pr-review-check.log 2>&1

このスクリプトは、中で claude コマンドを呼び出しています。claude /pr-review では gh の実行が必要になるので、permissionsを適切に設定したディレクトリで実行する必要があります

シェルスクリプト(check-pr-reviews.sh)
#!/bin/bash

# PR Review 自動チェックスクリプト(汎用版)
# 使用方法: 
#   ./pr-review-check.sh

set -euo pipefail

# 設定
REPO="owner/repository"  # GitHubリポジトリを指定
REPO_URL="https://github.com/${REPO}"
HISTORY_FILE="$HOME/.pr-review-history.log"
LOCK_FILE="/tmp/pr-review-check.lock"
LOG_FILE="$HOME/.pr-review-check.log"

# ログ関数
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# 重複実行防止
acquire_lock() {
    if [[ -f "$LOCK_FILE" ]]; then
        local lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
        if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
            log "別のプロセスが実行中です (PID: $lock_pid)"
            exit 0
        else
            log "古いロックファイルを削除します"
            rm -f "$LOCK_FILE"
        fi
    fi
    
    echo $$ > "$LOCK_FILE"
    trap 'rm -f "$LOCK_FILE"' EXIT
}

# GitHub CLIの認証チェック
check_gh_auth() {
    if ! gh auth status &>/dev/null; then
        log "ERROR: GitHub CLIの認証が必要です: gh auth login"
        exit 1
    fi
}

# 履歴ファイルの初期化
init_history_file() {
    if [[ ! -f "$HISTORY_FILE" ]]; then
        touch "$HISTORY_FILE"
        log "履歴ファイルを作成しました: $HISTORY_FILE"
    fi
}

# Claude コマンドの存在チェック
check_claude_command() {
    if ! command -v claude &>/dev/null; then
        log "ERROR: claude コマンドが見つかりません"
        exit 1
    fi
}

# PRレビュー依頼をチェック
check_review_requests() {
    log "レビュー依頼をチェック中..."
    
    # 自分に対するレビュー依頼を取得
    local review_requests
    review_requests=$(gh pr list \
        --repo "$REPO" \
        --search "review-requested:@me" \
        --json number,url,title,author \
        --jq '.[] | "\(.number)|\(.url)|\(.title)|\(.author.login)"' 2>/dev/null) || {
        log "ERROR: PRリストの取得に失敗しました"
        return 1
    }
    
    if [[ -z "$review_requests" ]]; then
        log "新しいレビュー依頼はありません"
        return 0
    fi
    
    # 未処理のPRをフィルタリング
    local unprocessed_prs=()
    while IFS='|' read -r pr_number pr_url pr_title author; do
        [[ -z "$pr_number" ]] && continue
        
        # 既にチェック済みかどうかを確認
        if grep -q "${REPO_URL}/pull/${pr_number}" "$HISTORY_FILE" 2>/dev/null; then
            log "PR#$pr_number は処理済みのためスキップします"
            continue
        fi
        
        unprocessed_prs+=("$pr_number|$pr_url|$pr_title|$author")
    done <<< "$review_requests"
    
    if [[ ${#unprocessed_prs[@]} -eq 0 ]]; then
        log "新しいレビュー依頼はありません(全て処理済み)"
        return 0
    fi
    
    log "未処理のレビュー依頼: ${#unprocessed_prs[@]} 件"
    
    # 一度に処理するPR数を制限(1回に最大10件)
    local max_per_run=10
    local processed_count=0
    
    # 各PRを順次処理
    for pr_data in "${unprocessed_prs[@]}"; do
        if [[ $processed_count -ge $max_per_run ]]; then
            log "1回の実行で処理するPR数の上限($max_per_run件)に達しました"
            log "残りのPRは次回の実行で処理されます"
            break
        fi
        
        IFS='|' read -r pr_number pr_url pr_title author <<< "$pr_data"
        
        log "=== PR#$pr_number の処理を開始 ==="
        log "作成者: $author"
        log "タイトル: $pr_title"
        log "URL: $pr_url"
        
        # Claudeにレビューを依頼
        if execute_claude_review "$pr_url" "$pr_number"; then
            # 成功した場合のみ履歴に追加
            echo "$pr_url" >> "$HISTORY_FILE"
            ((processed_count++))
            log "✅ PR#$pr_number のレビューが完了しました"
            
            # 次のPRとの間隔を空ける(API負荷軽減)
            if [[ $processed_count -lt $max_per_run ]] && [[ $processed_count -lt ${#unprocessed_prs[@]} ]]; then
                log "次のPR処理まで10秒待機します..."
                sleep 10
            fi
        else
            log "❌ PR#$pr_number のレビューに失敗しました(次回再試行されます)"
        fi
        
        log "=== PR#$pr_number の処理終了 ==="
        echo
    done
    
    if [[ $processed_count -gt 0 ]]; then
        log "処理完了: $processed_count 件のレビューを実行しました"
    fi
    
    local remaining=$((${#unprocessed_prs[@]} - processed_count))
    if [[ $remaining -gt 0 ]]; then
        log "残り $remaining 件のPRは次回の実行で処理されます"
    fi
}

# GitHub上でレビュー済みまたはPRが完了しているかチェック
check_if_reviewed_on_github() {
    local pr_number="$1"
    
    # PRの状態を取得
    local pr_state
    pr_state=$(gh pr view "$pr_number" \
        --repo "$REPO" \
        --json state \
        --jq '.state' \
        2>/dev/null) || return 1
    
    # PRがマージまたはクローズされている場合
    if [[ "$pr_state" == "MERGED" ]] || [[ "$pr_state" == "CLOSED" ]]; then
        log "PR#${pr_number} は既に${pr_state}されています"
        return 0
    fi
    
    # 自分がレビューを送信したかチェック
    local review_state
    review_state=$(gh pr view "$pr_number" \
        --repo "$REPO" \
        --json reviews \
        --jq '.reviews[] | select(.author.login == "'$(gh api user --jq .login)'") | .state' \
        2>/dev/null | tail -1) || return 1
    
    # レビュー状態が存在する場合はレビュー済み
    if [[ -n "$review_state" ]]; then
        log "PR#${pr_number} はGitHub上でレビュー済みです (状態: $review_state)"
        return 0
    else
        return 1
    fi
}

# GitHub上でレビュー済みまたは完了したPRファイルを移動
move_reviewed_prs() {
    local review_dir="$HOME/.pr-reviews"
    local reviewed_dir="$review_dir/reviewed"
    
    log "GitHub上でレビュー済みまたは完了したPRファイルをチェック中..."
    
    # レビューディレクトリ内の全ファイルをチェック
    for file in "$review_dir"/*-pr-*-*.md; do
        [[ -f "$file" ]] || continue
        
        # ファイル名からPR番号を抽出
        if [[ $(basename "$file") =~ -pr-([0-9]+)- ]]; then
            local pr_number="${BASH_REMATCH[1]}"
            
            # GitHub上でレビュー済みかチェック
            if check_if_reviewed_on_github "$pr_number"; then
                local moved_name=$(basename "$file")
                mv "$file" "$reviewed_dir/$moved_name"
                log "レビュー済みファイルを移動: $file -> $reviewed_dir/$moved_name"
            fi
        fi
    done
}

# Claude レビューコマンドを実行
execute_claude_review() {
    local pr_url="$1"
    local pr_number="$2"
    
    # レビュー保存ディレクトリを作成
    local review_dir="$HOME/.pr-reviews"
    local reviewed_dir="$review_dir/reviewed"
    mkdir -p "$review_dir" "$reviewed_dir"
    
    # ファイル名を生成(リポジトリ名とPR番号から)
    local repo_name=$(echo "$REPO" | cut -d'/' -f2)
    local date=$(date +%Y-%m-%d)
    local review_file="$review_dir/${repo_name}-pr-${pr_number}-${date}.md"
    
    log "Claude レビューを開始します: $pr_url"
    
    # 一時ファイルにClaude出力を保存
    local temp_file=$(mktemp)
    local timeout_success=false
    local timeout_exit_code=0
    
    # macOS対応のタイムアウト実装
    if command -v timeout &>/dev/null; then
        # GNU timeout が利用可能な場合
        if timeout 300 claude "/pr-review $pr_url" > "$temp_file" 2>&1; then
            timeout_success=true
        else
            timeout_success=false
            timeout_exit_code=$?
        fi
    else
        # macOS の場合、バックグラウンドでタイムアウトを実装
        log "GNU timeout が見つかりません。macOS版のタイムアウトを使用します"
        
        # バックグラウンドでClaude実行(出力を一時ファイルへ)
        claude "/pr-review $pr_url" > "$temp_file" 2>&1 &
        local claude_pid=$!
        
        # 5分(300秒)待機
        local timeout_duration=300
        local elapsed=0
        local sleep_interval=5
        
        while [[ $elapsed -lt $timeout_duration ]]; do
            if ! kill -0 $claude_pid 2>/dev/null; then
                # プロセスが終了している
                wait $claude_pid
                timeout_exit_code=$?
                timeout_success=true
                break
            fi
            
            sleep $sleep_interval
            elapsed=$((elapsed + sleep_interval))
            
            # 進捗ログ(1分ごと)
            if [[ $((elapsed % 60)) -eq 0 ]]; then
                log "Claude レビュー実行中... ($((elapsed / 60))分経過)"
            fi
        done
        
        # タイムアウトした場合
        if [[ $elapsed -ge $timeout_duration ]]; then
            log "Claude レビューがタイムアウトしました(5分)"
            kill $claude_pid 2>/dev/null || true
            wait $claude_pid 2>/dev/null || true
            timeout_success=false
            timeout_exit_code=124
        fi
    fi
    
    # 結果の処理
    if [[ "$timeout_success" == "true" ]] && [[ $timeout_exit_code -eq 0 ]]; then
        # 一時ファイルからMarkdownブロックを抽出
        if grep -q '```markdown' "$temp_file"; then
            # sedでmarkdownブロックの内容を抽出
            sed -n '/```markdown/,/```/{//!p;}' "$temp_file" > "$review_file"
            
            if [[ -s "$review_file" ]]; then
                log "✅ レビューが成功しました: $pr_url"
                log "📝 レビューを保存しました: $review_file"
                rm -f "$temp_file"
                
                return 0
            else
                log "ERROR: Markdownコンテンツの抽出に失敗しました"
                rm -f "$temp_file" "$review_file"
                return 1
            fi
        else
            log "ERROR: Claude出力にMarkdownブロックが見つかりません"
            cat "$temp_file" >> "$LOG_FILE"  # デバッグ用に出力をログに保存
            rm -f "$temp_file"
            return 1
        fi
    else
        if [[ $timeout_exit_code -eq 124 ]]; then
            log "ERROR: Claude レビューコマンドがタイムアウトしました(5分)"
        else
            log "ERROR: Claude レビューコマンドが失敗しました (exit code: $timeout_exit_code)"
        fi
        rm -f "$temp_file"
        return 1
    fi
}

# 履歴ファイルのクリーンアップ(古いエントリを削除)
cleanup_history() {
    if [[ ! -f "$HISTORY_FILE" ]]; then
        return 0
    fi
    
    # 履歴ファイルが1000行を超えた場合、古いエントリを削除
    local line_count=$(wc -l < "$HISTORY_FILE")
    if [[ $line_count -gt 1000 ]]; then
        log "履歴ファイルをクリーンアップ中..."
        tail -500 "$HISTORY_FILE" > "${HISTORY_FILE}.tmp"
        mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
        log "履歴ファイルをクリーンアップしました ($line_count -> 500行)"
    fi
}

# メイン処理
main() {
    log "PR Review チェックを開始します"
    
    acquire_lock
    check_gh_auth
    check_claude_command
    init_history_file
    
    # レビュー依頼をチェック
    check_review_requests
    move_reviewed_prs
    cleanup_history
    
    log "PR Review チェックが完了しました"
}

# エラーハンドリング
handle_error() {
    local line_number=$1
    log "ERROR: スクリプトでエラーが発生しました (行: $line_number)"
    exit 1
}

trap 'handle_error $LINENO' ERR

# スクリプト実行
main "$@"

3. レビュー結果の表示システム

これは正直なんでも良かったのですが、

  • CLIから表示できる
  • 複数あるPRのレビューMarkdownからfuzzy searchできる
  • プレビュー表示できる

あたりをやりたかったので、現在使っていたfish shellのカスタム関数を使って、fzfbat を組み合わせて簡単な function をシェルで定義しました。

function pr-review --description "Fuzzy search and open PR review markdown files"
    set review_dir "$HOME/.pr-reviews"
    
    # ディレクトリが存在しない場合は警告
    if not test -d $review_dir
        echo "Review directory not found: $review_dir"
        return 1
    end
    
    # fzfが利用可能かチェック
    if not command -v fzf >/dev/null 2>&1
        echo "Error: fzf is required"
        echo "Install with: brew install fzf"
        return 1
    end
    
    # fzfでファイル選択
    set selected (
        find $review_dir -maxdepth 1 -name "*.md" -type f 2>/dev/null | \
        sed "s|$review_dir/||" | \
        fzf --preview="bat --style=numbers --color=always $review_dir/{}" \
            --preview-window=right:80% \
            --header="Select PR review to open" \
            --prompt="PR Review > "
    )
    
    if test -n "$selected"
        code "$review_dir/$selected"
    end
end

苦労したところ

これは僕が完全に悪いのですが、claudeのpermissionsの仕様をあまり理解しておらず、最初は適当なディレクトリでスクリプトを実行していて、「この操作を行うにはpermissionが必要です」と言われることが多かったです。

claude コマンドが実行される際に、どのディレクトリにいるか(permissionを設定しているィレクトリ)に注意を払うようにしたら解消されました。

スクリプト系は全てClaudeに書いてもらったのでその点はゼロコストでした。いい時代です。

導入効果

このシステムを導入してから、以下のような効果が得られました

  • MTG中など作業の手を止めているタイミングでレビューが送られてきても、裏側で勝手にレビューが進んでいる
  • レビューするか〜と思ったタイミングで、既にどこを重点的にレビューするかが分かっている
  • 自分が普段レビューする時に気をつけている部分をコマンドに入れることで、レビューしやすくなる
    • これはむしろチームで積極的に知見を共有してもいいかも
  • 「あれここなんだろう」と思った時に、レビューしたmarkdownをclaudeにもう一度読んでもらって、レポジトリのコードと合わせて質問することができる
  • GitHubを見に行かなくてもreviewできる

まとめ

Claude APIとシェルスクリプトを組み合わせることで、PRレビューのワークフローを個人的には効率化できました。

AIの発展で、実装速度が向上したり、並列に様々なものを開発できるようになったのは良いことですが、レビューはどうしてもまだボトルネックに感じる部分があるので、今後も積極的に楽にしていきたいです。

Progate Tech Blog

Discussion