AIエージェントでプルリクレビューを自動化してみた(GitHub App × Cloud Run × Gemini)

こんにちは!プラットフォームエンジニアリングチームのyuuです!
最近、AIエージェントを活用した開発支援ツールが増えてきましたね。
GitHub Copilotのレビュー機能などもありますが、「社内ルールや独自の観点を含めたレビューを自動化できないか?」という興味から、簡単なPoCとしてAIによるプルリクレビュー自動化ツールを作ってみました。
あくまでPoCなので、実装はシンプルにしていますが、「AIエージェントをどう開発ワークフローに組み込めるか」を試すには良い題材になりました。

どんなことをやりたかったのか

今回作ったものはシンプルで、

  • プルリクが作成される
  • GitHub Appがイベントを受信
  • Cloud Run上のサービスが差分を取得
  • Geminiにレビューを依頼
  • コメントとしてプルリクに投稿

という流れです。
完全に人間のレビューを置き換えるというより、

  • 明らかなミスの早期検知
  • セキュリティ観点の指摘
  • 安定した精度のレビュー実施
  • テスト観点の提案

といった「補助レビュー」を想定しています。

アーキテクチャ

今回の構成は以下のようになっています。

アーキテクチャ図

Cloud Runを中心に、Orchestratorは処理全般の制御をし、MCP ServerはAIに渡す前の整形処理やレビュー生成処理などをツールとして提供する、という形でサービスを分離しています。
※今回は処理の流れが固定なので敢えてMCPにする必要性はないのですが、AIエージェントが使うツール群を整理できるのが面白いポイントでした。

GitHub Appでイベントを受け取る

GitHub Actionsでも似たことはできますが、組織内での展開や機能の拡張性の点で今回はGitHub Appを使いました。
※作成の手順は省いてポイントだけ記載しています。

サブスクリプションの設定
プルリクイベントをトリガーにするので、「Pull Request」を指定します。

サブスクリプション設定

Webhookの設定
Orchestratorにリクエストを送信するように設定します。シークレットの値は任意で大丈夫ですが、呼び出し先の検証で使います。

Webhook設定

App権限の設定
Cloud Runの方でのdiff情報の取得や最終的なレビューコメントの投稿のためにプルリクエストの Read/Write 権限を設定します。

権限設定

シークレット情報
レビュー処理を行うCloud Run側には以下の情報が必要なので、別途Secret Managerに配置しておきます。

  • App ID…GitHub AppのID
  • App Secret…GitHub AppのSecret(App作成時に作られたpemファイル)
  • Webhook Secret…任意のランダム文字列など

Cloud Runでレビュー処理

レビュー処理自体はCloud Run上で動かしています。
Webhookを受け取ったら、

  • リクエストのシグネチャ検証
  • GitHub APIでプルリクのdiffを取得
  • AIに渡すプロンプトを生成
  • Geminiにレビュー依頼
  • レビュー結果をプルリクコメントに投稿

などの処理を行います。

シグネチャ検証

import requests
import hmac
import hashlib

def verify_github_signature(req) -> bool:
    if not GITHUB_SECRET:
        return False
    sig = req.headers.get("X-Hub-Signature-256")
    if not sig or "=" not in sig:
        return False
    algo, their = sig.split("=", 1)
    if algo != "sha256":
        return False
    mac = hmac.new(GITHUB_SECRET.encode("utf-8"), msg=req.data, digestmod=hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, their)

レビュー処理本体(diff取得、レビュー、コメント投稿)

from mcp_client import mcp_call
import json
import jwt
import requests

...

def generate_app_jwt():
    now = int(time.time())
    payload = {
        "iat": now - 60,
        "exp": now + (10 * 60),
        "iss": GITHUB_APP_ID,
    }
    return jwt.encode(payload, GITHUB_PRIVATE_KEY, algorithm="RS256")

def get_installation_token(installation_id: int):
    jwt_token = generate_app_jwt()

    headers = {
        "Authorization": f"Bearer {jwt_token}",
        "Accept": "application/vnd.github+json",
    }

    url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
    resp = requests.post(url, headers=headers, timeout=20)
    resp.raise_for_status()

    return resp.json()["token"]

def post_pr_review(token: str, repo: str, pr_number: int, body: str, comments: list[dict] | None = None) -> None:
    url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }

    def do_post(payload: dict) -> requests.Response:
        return requests.post(url, headers=headers, json=payload, timeout=20)

    payload: dict[str, Any] = {"body": body, "event": "COMMENT"}
    if comments:
        payload["comments"] = comments

    resp = do_post(payload)

...

def run_review_job_app(installation_id, repo, pr_number, head_sha):
...
    try:

        token = get_installation_token(installation_id)

        headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}

        pr_api = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
        pr_resp = requests.get(pr_api, headers=headers, timeout=20)
        pr_resp.raise_for_status()
        prj = pr_resp.json()

        title = prj.get("title", "")
        body = prj.get("body", "")
        diff_url = prj.get("diff_url")
        if not diff_url:
            raise RuntimeError("diff_url not found")

        diff_headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3.diff"}
        diff_resp = requests.get(diff_url, headers=diff_headers, timeout=60)
        diff_resp.raise_for_status()
        unified_diff = diff_resp.text

        # プロンプトの組み立て
        prompt = mcp_call("build_review_prompt_tool", {"title": title, "body": body, "unified_diff": unified_diff})

        # レビューの生成
        review_json = mcp_call("gemini_review_json_tool", {"prompt": prompt})
        if not isinstance(review_json, dict):
            raise ValueError(f"gemini_review_json_tool returned non-dict: {type(review_json)}")

        # インラインコメント作成のためにdiffの該当行抽出
        idx = mcp_call("parse_unified_diff", {"unified_diff": unified_diff})

        inline_comments, skipped = build_inline_comments_from_findings(
            review_json=review_json,
            idx=idx,
            max_inline=MAX_INLINE_COMMENTS,
        )

        body = build_review_body_with_skipped(review_json, head_sha, skipped)

        # レビュー結果の投稿
        post_pr_review(token, repo, pr_number, body, comments=inline_comments)

...

動かしてみる

プルリク作成
GitHub Appをインストール済のリポジトリでサンプルコードを作成してプルリクを作成します。

プルリク作成

レビュー生成
WebhookによってCloud RunのOrchestratorが起動しています。

Cloud Runログ

レビュー結果確認
プルリクにレビュー結果がコメントされました。
総合的な評価のサマリーと個別のインラインコメントを投稿するようになっています。

サマリー
インラインコメント

感想、気づき

PoCとして動かしてみて、いくつか気づきがありました。

良かった点

レビューの精度
レビュー指摘の抽出は予想以上にレベルが高く、エラーハンドリングや推奨アクションの提示など、かなり「それっぽい」印象。補助の位置づけなら十分使えるんじゃないかと思いました。

レビューの速さ
今回はテストコードをまとめて追加していましたが、概ね1~2分程度でレビューの投稿が完了していました。限られたスケジュールの中で早期に本質的な課題や品質向上に目を向けられるのは良いなと思いました。

課題

開発フローとのバランス
AIに生成してもらったレビューは確かに有用なのですが、厳しすぎる印象もあります。修正しても同じ箇所をさらに修正するよう推奨され、レビュー、修正、レビュー、修正…のループに陥る部分もありました。
レビューの粒度やルールはプロンプトで調整しつつ、エンドレスレビューにならないよう運用面も見直す必要がありそうです。

まとめ

今回はGitHub App + Cloud Run + Geminiを組み合わせてAIによるプルリクレビュー自動化を試してみました。
まだPoCレベルですが今回の構成は、実運用にも応用できそうな手応えがありました。
今後、課題を解消しつつ実運用への適用につなげていければと思います。