1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

/code-review をバックグラウンドで回す — claude -p をCIに組み込む実装と、コストで死なないための3つのガード

1
Posted at

はじめに:レビューは「人が待つ工程」から外したい

当社(合同会社ジョインクラス)は、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 を呼びます。

lib/claude-run.sh
#!/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 です(抜粋)。

lib/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)で、上のラッパーをそのまま使います。

.github/workflows/ai-review.yml
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

呼び出し側のスクリプトです。

.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を前提にした開発プロセスの組み立て方を解説しています。

書籍一覧: https://zenn.dev/joinclass?tab=books

1
0
1

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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?