はじめに:レビューは「人が待つ工程」から外したい
当社(合同会社ジョインクラス)は、launchd の定期ジョブ17本で記事公開・SNS配信・営業パイプラインまで回している小さな会社です。自動化率は98%まで来ましたが、最後まで人が待つ工程として残っていたのがコードレビューでした。
Claude Code の /code-review は手元で打てば便利です。ただ「打ち忘れる」「結果を待つあいだ手が止まる」の2点で、結局ローカルでしか使わなくなっていました。そこで claude -p で無人実行し、PR作成時にバックグラウンドで回してコメントで返す 形に組み直しました。
先に結論を書きます。仕組みそのものは20行程度で作れます。大事なのは コストのガード でした。この記事では、当社で実際に動かしている無人実行ラッパーを土台に、CIへの組み込み手順と、ハマった落とし穴を順に書きます。
前提:無人実行は1回 $0.27 からかかる
最初にやったのは実測です。claude -p "2+2は?" を --output-format json で叩き、total_cost_usd を見ました。結果は 1回 $0.27〜0.47 でした。
内訳はキャッシュ作成の約41,000トークンです。そのうち自社ファイル(CLAUDE.md・エージェント定義・スキル)は5,800トークンしかなく、残りは Claude Code 側の固定オーバーヘッドでした。つまり プロンプトを削っても安くならず、削れるのは呼び出し回数だけ です。
もう一つ痛い発見がありました。社内のコストトラッカーは各ジョブが自己申告した概算値(固定で 0.02)を足しているだけでした。そのため「月$160で警告、$200で自動停止」のガードは 架空の数字で判定していて、一度も作動しない状態 でした。
CIでレビューを回すと、PRの数だけこの固定費がかかります。だから、ガードを先に作ってからCIに載せる順番にしました。
実装1:無人実行の共通ラッパー
当社の .company/scripts/lib/claude-run.sh です。全ての定期ジョブがこれを経由して claude を呼びます。
#!/bin/bash
# claude を無人実行するときの共通ラッパー。実コストを記録する。
#
# 使い方:
# source .company/scripts/lib/claude-run.sh
# RESULT=$(claude_run dept-dev --effort low -p "…")
#
# 標準出力には応答テキストだけを出すので、従来の `claude -p` と差し替えられる。
# 注意: --bare は認証を読まないため "Not logged in" で失敗する。使わない。
_CLAUDE_RUN_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
claude_run() {
local label="$1"; shift
claude --output-format json \
--fallback-model claude-sonnet-5 \
--max-budget-usd 3 \
"$@" 2>/dev/null \
| python3 "$_CLAUDE_RUN_LIB/record-cost.py" "$label"
}
ポイントは3つです。
| フラグ | 役割 |
|---|---|
--output-format json |
応答と一緒に total_cost_usd が返る。これを実測コストとして積む |
--max-budget-usd 3 |
1回の実行の上限。レビューが暴走して巨大diffを読み続けても$3で止まる |
--fallback-model |
メインモデルが過負荷のときに落ちずに続行する。CIで「たまに落ちる」を減らす |
label は集計用の名前です。CIでは code-review を渡します。
実装2:JSONから応答を取り出し、実コストを積む
パイプの後ろ側の record-cost.py です(抜粋)。
#!/usr/bin/env python3
"""`claude --output-format json` の出力を受け取り、応答テキストを標準出力に流しつつ実コストを記録する。"""
import json, os, pathlib, subprocess, sys
DEFAULT_COST_FILE = pathlib.Path(__file__).resolve().parents[1] / "cost-tracker.json"
WARN_USD = 160.0
STOP_USD = 200.0
def notify_slack(text):
url = os.environ.get("SLACK_WEBHOOK_URL")
if not url:
return
subprocess.run(
["curl", "-sf", "-X", "POST", "-H", "Content-Type: application/json",
"-d", json.dumps({"text": text}), url],
capture_output=True, timeout=20,
)
def main():
label = sys.argv[1]
raw = sys.stdin.read()
try:
d = json.loads(raw)
except json.JSONDecodeError:
sys.stdout.write(raw) # JSONでなければ素通しする
return 0
sys.stdout.write(d.get("result", ""))
# 以降: total_cost_usd を月別・label別に cost-tracker.json へ加算し、
# 月合計が WARN_USD / STOP_USD を超えたら Slack に通知する
ここで効いているのは「JSONでなければ素通し」の1行です。認証切れなどで claude がエラー文字列を返したとき、パースに失敗して空文字になると、CIは「指摘なし=合格」と誤判定します。素通しにしておけば、エラー文がそのままPRコメントに出るので気づけます。
実装3:PRごとにバックグラウンドでレビューする
ここからがCI本体です。GitHub Actions の self-hosted runner(当社は launchd が動いている Mac mini)で、上のラッパーをそのまま使います。
name: ai-review
on:
pull_request:
types: [opened, synchronize]
concurrency:
group: ai-review-${{ github.event.pull_request.number }}
cancel-in-progress: true # 連続pushで古いレビューを捨てる=呼び出し回数を削る
jobs:
review:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-ai-review') }}
runs-on: self-hosted
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run review
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
BASE: ${{ github.event.pull_request.base.sha }}
PR: ${{ github.event.pull_request.number }}
run: bash .github/scripts/ai-review.sh
呼び出し側のスクリプトです。
#!/bin/bash
set -euo pipefail
source .company/scripts/lib/claude-run.sh
# 1. 小さすぎる・大きすぎるdiffは呼ばない(固定費$0.27を払う価値があるかで判断)
CHANGED=$(git diff --shortstat "$BASE"...HEAD | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0)
if [ "$CHANGED" -lt 5 ]; then
echo "diffが小さいのでスキップ ($CHANGED行)"; exit 0
fi
if [ "$CHANGED" -gt 1500 ]; then
gh pr comment "$PR" --body "AIレビュー: diffが${CHANGED}行あるため対象外です。PRを分割してください。"
exit 0
fi
# 2. 読み取り専用で実行する。Edit/Write/Bash の書き込み系は渡さない
REVIEW=$(claude_run code-review \
--effort low \
--allowedTools "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*)" \
-p "/code-review low ${BASE}...HEAD の差分をレビューし、指摘を Markdown の箇条書きで返して。指摘がなければ「指摘なし」とだけ書いて。")
# 3. PRに返す
gh pr comment "$PR" --body "## 🤖 AIレビュー
${REVIEW}"
設計で意識したのは次の3点です。
-
concurrency+cancel-in-progress: 5回連続でpushしても、走るのは最後の1回だけです。前提で書いた「削れるのは呼び出し回数だけ」を、CIの機能で実現しています。 -
diff行数のゲート: typo修正に$0.27払うのは割に合いません。逆に巨大diffは
--max-budget-usdに当たって途中で切れ、中途半端な結果になります。両端を切りました。 -
--allowedToolsで読み取り専用: 当社は過去に、AIがgit push --forceを自動実行してブランチを消したことがあります。レビュージョブに書き込み権限を渡す理由はないので、git diffとgit log以外のBashは許可しません。
落とし穴
実際に踏んだもの、踏みかけたものです。
1. --bare で "Not logged in"
起動を速くしようとして --bare を付けると、認証情報を読まずに失敗します。ラッパーのコメントに残しているのはこのためです。
2. 2>/dev/null でエラーが消える
ラッパーは標準エラーを捨てています。原因を追いたいときは、一時的に外して手で叩いてください。
claude --output-format json -p "ping" | jq '{result, total_cost_usd, is_error}'
is_error: true が返っていれば、認証かモデル側の問題です。
3. /code-review のレベル指定は前回値を引き継ぐ
レベルを省略すると、前回打ったレベルが使われます。CIでは毎回明示してください(上の例では low)。high 以上にすると不確かな指摘まで増え、PRコメントがノイズで埋まりました。
4. フォークからのPR
secrets が渡らないので、self-hosted runner で外部PRを回すのは危険です。当社は社内リポジトリ限定にしています。
検証方法
導入した日に、この3つを確認してください。
# 1) ラッパー単体: 応答だけが出て、コストが積まれるか
source .company/scripts/lib/claude-run.sh
claude_run smoke-test -p "OKとだけ返して"
jq '.' .company/scripts/cost-tracker.json | tail -20
# 2) 素通し: JSONでない入力がそのまま出るか
echo "Not logged in" | python3 .company/scripts/lib/record-cost.py smoke-test
# 3) CI: 4行だけのPRでスキップされ、10行のPRでコメントが付くか
特に 2) は必ず確認してください。ここが壊れていると、認証切れの日に全PRが「指摘なし」で通ります。
結び:レビューを「待つ」から「届く」へ
人の手を止めずにレビュー結果が届くようになると、レビューは開発プロセスの一工程として固定化できます。ただし、AIの出力を無条件に信じる運用は危険です。当社は以前、AIが書いた営業メールを確認なしで送り、敬語の崩れたメールを取引先に届けてしまいました。それ以来、AIは指摘まで、マージ判断は人 という線を守っています。
レビューを開発ライフサイクルのどこに置き、AIと人の責任をどう分けるか。その設計は書籍にまとめました。
📘 『AI駆動開発ライフサイクル(AI-DLC)の教科書』
要件定義からレビュー・CIまで、AIを前提にした開発プロセスの組み立て方を解説しています。