Tech

Claude Code で PR を作ったら、Antigravity(Gemini)にクロスレビューさせる仕組みを作った

概要

個人開発のアプリ(Rails の API + React の SPA + iOS)で、実装はほぼ Claude Code に任せている。Issue を選んでもらって、実装・テスト・PR・セルフレビュー・マージまでを/loopで回すというのを最近はやってる。

で、レビューももうもはや見ないで作ってみていて、セルフレビューは Claude Code の /code-review でやっていたのだけど、Codexにもみてもらうということをやっていました。ただ最近Codexを解約して AntigravityのArgonを使うようにしたので、GitHub連携で動いていたCodexのレビュー部分ががら空きになってしまっていました。

それで、Antigravity Gemini Argon にレビューを自動でしてもらうにはどうするかね、というのに思いを馳せました。

作ったもの

  • Claude Code で gh pr create を実行すると、PostToolUse フックが動く
  • フックは裏で agy を起動して、すぐ返る。Claude Code の作業は止めない
  • agy は PR の HEAD を取り出した作業ディレクトリで、読むだけでレビューする
  • 結果は <!-- agy-review --> で始まるコメントとして、PR に付く
  • Claude Code は指摘に対応したら、--recheck で「直ったか」を確かめてもらう
  • 最新のレビューが今の HEAD を対象にしていて、「指摘なし」か「まとめ:マージしてよい」のときだけマージする

全体はひとつのシェルスクリプト(.claude/hooks/agy-pr-review.sh)と、.claude/settings.json のフックの設定でできてます。

フックの設定

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(gh pr create*)",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/agy-pr-review.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

matcher で Bash を、if で gh pr create に絞っている。ただ、後で書くとおり、if だけに頼らない作りにしている。

スクリプトの流れ

1. フックとして呼ばれたとき:PR の番号を取り出して、裏で起動する

フックには、ツールの入力と出力が JSON で渡される。そこからコマンドが本当に gh pr create かを確かめて、出力にある PR の URL から番号を取り出す。

INPUT="$(cat)"
printf '%s' "$INPUT" | jq -e '(.tool_input.command // "") | test("(^|[;&|[:space:]])gh pr create")' >/dev/null || exit 0
PR="$(printf '%s' "$INPUT" | jq -r --arg repo "$REPO" '
  [.tool_response.stdout?, .tool_response.output?, (.tool_response|strings)] | map(select(.)) | join("\n")
  | [scan("github\\.com/" + $repo + "/pull/([0-9]+)")] | last // empty | .[0]')"
[ -n "$PR" ] || exit 0
nohup "$SCRIPT" --run "$PR" >"$LOG_DIR/pr-$PR.log" 2>&1 &
echo "{\"systemMessage\": \"Antigravity で PR #$PR のクロスレビューを裏で始めたよ\"}"

最初Argonのmiddle、認証系などはhighでレビューを依頼していたのでレビューには5〜10分かかる感じでした。フックの中で待つと Claude Code が止まるので、nohup で切り離してすぐ返すなど工夫をしたようです。

2. 裏で:PR の HEAD を worktree に取り出す

HEAD_REF="$(gh pr view "$PR" -R "$REPO" --json headRefName -q .headRefName)"
git fetch -q origin "pull/$PR/head"
git fetch -q origin "$HEAD_REF"
HEAD_OID="$(git rev-parse FETCH_HEAD)"
WORK_DIR="$(mktemp -d)"
git worktree add -q --detach "$WORK_DIR" "$HEAD_OID"

手元の作業ツリーでレビューさせない理由は2つあって一つは手元のブランチや、作業中の変更に左右されない。PR のコードそのものを読ませられる。もうひとつは.env や master.key みたいな git 管理外の秘密情報が、worktree には入らないようにしていました。

差分・PR の本文・ファイルの一覧は、worktree の中の .agy-review/ にファイルで置く。差分は大きくなりうるから、プロンプトに埋め込まないでファイルで渡す。

3. agy を「読むだけ」で動かす

AGY_SETTINGS='{"permissions":{"deny":["command(*)","write_file(*)"]}}'
REVIEW="$(cd "$WORK_DIR" && "$AGY" -p "$PROMPT" --sandbox --model "$MODEL" --settings "$AGY_SETTINGS" --print-timeout 900s)"

シェルの実行とファイルの書き込みは deny にした。読み取りのツール(read_file など)だけで、レビューさせている。シェルが使えないとファイルを探しにくいから、git ls-files の一覧を渡している。

4. PR にコメントする

printf '%s\n## Antigravity(%s)の%s\n\n対象:%s\n\n%s\n' "$MARKER" "$MODEL" "$TITLE" "$HEAD_OID" "$REVIEW" >"$BODY_FILE"
gh pr comment "$PR" -R "$REPO" --body-file "$BODY_FILE"

先頭のマーカー(<!-- agy-review -->)でレビューのコメントを見分けて、「対象:」でどのコミットを見たかを残す。マージしていいかは、この2つで判断する。

再確認(–recheck)

最初は「レビューして終わり」だったんですが、/loopで回しているという背景があったので、自動でレビューしても問題がない状態としたかったため、Antigravityも「レビューOKだよ」といってもらえる体制が必要でした。そのための仕組みを作りました。

再確認では、次のものを渡す。

  • 前回の Antigravity のコメント(previous-review.md)
  • そのあとのコメント(Claude Code の「こう直した」「これは直さない、理由は〜」)
  • 前回の「対象:」のコミットから今の HEAD までの差分(since-review.diff)

プロンプトには「対応のコメントをうのみにせず、実際のコードで確かめて」と書いた。最後は必ず「まとめ:マージしてよい」か「まとめ:まだ直すところがある」のどちらかで終わらせる。

思考量の切り替え

Gemini の思考量(low・medium・high)は、PR の中身で変えている。

  • 再確認:low(見るところが決まっているから)
  • ふだん:medium
  • 認証・権限・招待・ルーティング・マイグレーション・.claude/ を触る PR:high
SENSITIVE_PATHS='^(server/app/controllers/(application|sessions|signups|...)_controller\.rb|...|server/db/migrate/|\.claude/)'
if [ "$MODE" = recheck ]; then
  MODEL="gemini-4-argon-low"
elif CHANGED_FILES="$(gh pr diff "$PR" -R "$REPO" --name-only)" && grep -qE "$SENSITIVE_PATHS" <<<"$CHANGED_FILES"; then
  MODEL="gemini-4-argon-high"
else
  MODEL="gemini-4-argon-medium"
fi

やはり何時間も/loopさせているとそこそこのトークンが消えるようで、その日のうちに、使える量(Argon の残り)が10%を切った。なので一旦今は SAVE_ARGON=1 で全部 low にしています。本当に慎重に行きたいところ以外は一旦 low でやりくりしたいなーという気持ちです。

つまずいたこと

--mode plan だと止まる

最初は、書き込みをさせないつもりで --mode plan を付けた。でも、人の承認を待つ状態のまま止まった。外して、--settings の deny で縛るやり方にした。

なお、許可を聞く設定(ask)にすると、人がいないモードでは何も出力されないまま終わる。deny にすると、モデルは「使えない」と理解して、読み取りだけで続けてくれた。

macOS の bash 3 と zsh

macOS の /bin/bash は 3.2 で、連想配列が使えない。思考量の表を連想配列で書いていたところで落ちた。

試しに zsh で動かしたときは、set -- $p が単語に分かれなかった。結局、全部を bash 3 で動く書き方にそろえた。

実行中のスクリプトを書き換えると壊れる

bash は、スクリプトを少しずつ読みながら実行する。裏でレビューが動いている最中にスクリプトを直したら、途中から別の行を読んで壊れた。

全体を { ... exit 0 } で囲んで、最初に全部読み込ませるようにした。

set -euo pipefail
{
  # ここに全部書く
  exit 0
}

フックの if が効かない環境がある

"if": "Bash(gh pr create*)" で絞っていたのに、ほかのコマンドでもスクリプトが呼ばれることがあった。そうなると、出力に偶然 PR の URL が入っていたときに、関係ない PR をレビューしてしまう。

スクリプトの中でも、jq でコマンドが gh pr create かを確かめるようにした。

pipefail と grep -q

gh pr diff --name-only | grep -qE "$SENSITIVE_PATHS" と書いていた。こうすると、grep -q が最初の一致で終わったあとに gh が SIGPIPE で落ちる。pipefail のせいでパイプ全体が失敗扱いになって、high のはずが medium になっていた。

いったん変数に入れてから、grep に渡すようにした。

全角の「:」で cut が使えない

「対象:<コミット>」から前回のコミットを取り出そうとして、`cut -d':'` と書いた。でも `cut` は全角の区切りを扱えない。

40桁の16進だけを grep -oE '[0-9a-f]{40}' で取り出すようにした。

push の直後は headRefOid が古い

直して push した直後に --recheck すると、gh pr view --json headRefOid が push 前のコミットを返すことがあった。古いコードを見て「直っていない」と言われてしまう。

PR のブランチを git fetch して、FETCH_HEAD を使うようにした。

PR を作ったのに、フックが動かなかった

あるとき、レビューが一度も付かない PR があった。ログもない。

調べたら、gh pr create のあとに続けた余計なコマンドが失敗していた。Bash ツールの結果が終了コード 1 になっていたのが原因。PostToolUse は成功したときだけ動くから、PR はできていたのにフックは呼ばれなかった。

それからは、gh pr create を単独で実行するようにしている。(PostToolUseFailure にもフックを置けば拾える)

Claude Code 側のルール

仕組みだけ作っても、待たずにマージされたら意味がない。だから、AGENTS.md と Issue を回すスキルに、次のルールを書きました。

  • gh pr create のあと、頼んだあとに増えた <!-- agy-review --> のコメントを待つ(最大15分)
  • 待つ前に、ログ(.claude/hooks/logs/pr-<番号>.log)があるか確かめる。ないか、エラーで終わっていたら、待たずに PR にコメントして進む
  • 指摘に対応したら、必ず --recheck する。自分の /code-review だけで済ませない
  • マージしていいのは、最新のレビューが今の HEAD を「対象:」にしていて、「指摘なし」か「まとめ:マージしてよい」のときだけ
  • 往復は2回まで。残ったら PR にコメントして止まる

やってみてどうだったか

1日回して、Claude Code のセルフレビューでは出なかった指摘を、何度も拾ってくれた。たとえばこういうもの。

  • Web のセッションの Cookie の期限が、DB の期限と合っていなかった
  • Google の ID トークンの iss を確かめていなかった
  • Google の credential がログに出ていた
  • アカウント作成の途中の Cookie が、別のパスでは消せていなかった。Rails の cookies.delete は、そのリクエストに付いていない Cookie には何もしない
  • 招待リンクのトークンが、URL のパスに入ったままログと APM に送られていた

反対に、Claude Code のセルフレビューのほうが先に見つけたものもあった。完全に任せるより、2つのモデルで見るほうがいい、というのが今のところの感想。

指摘をうのみにしないのも大事だった。「直さない、理由は〜」と PR に書いて、再確認で「直さない理由に納得」と返ってくることもある。理由を書かせると、Claude Code のほうも雑に流さなくなった。

残っていること

  • 思考量を全部 low にしているのは、使える量を節約するための一時的な設定。戻すときは SAVE_ARGON=0 にする
  • 失敗した Bash の結果にもフックを動かすか(PostToolUseFailure)は、まだ決めていない

Share this post

X Hatena Facebook