チームの規模が5人を超えたころから、コードレビューの「ムラ」が気になり始めました。同じようなバグパターンを、あるメンバーは毎回指摘するのに別のメンバーはスルーしてしまう。疲れているときはレビューが甘くなります。そして何より、全員がレビューの「基準」を共有できていません。
Claude API を使えば、このムラを大きく減らせます。ただし「コードを渡してレビューして」と素朴にお願いするだけでは、返ってくる内容がいつも違って運用に耐えません。重要なのは、Tool Use を使って構造化されたレビュー結果を得ること と、GitHub Webhook から一気通貫のパイプラインを設計すること です。
ここではGitHub に PR が作成されたときに自動でレビューコメントを投稿するボットを、実際に動くコードで構築します。アーキテクチャ設計から本番デプロイまで、各ステップで「なぜそう実装するのか」の理由も含めてお伝えします。
システム全体のアーキテクチャ
まず全体像を把握しましょう。処理の流れはシンプルです。
GitHub PR 作成/更新
↓
Webhook イベント送信(POST /webhook)
↓
Express サーバー(署名検証)
↓
GitHub API で PR diff 取得
↓
Claude API(Tool Use)でレビュー生成
↓
GitHub API でレビューコメント投稿
技術スタックは以下の通りです。
ランタイム : Node.js 22 + TypeScript
Webフレームワーク : Express.js 4.x
Claude API : @anthropic-ai/sdk v1.x
GitHub API : @octokit/rest v21
デプロイ先 : Railway / Render(常時起動が前提)
なぜ常時起動サーバーが必要か
GitHub Webhook はリアルタイムで POST リクエストを送ってきます。Cloudflare Workers や Vercel Serverless Functions でも動作しますが、コールドスタートが発生するアーキテクチャは避けたほうが無難 です。GitHub は Webhook のタイムアウトを10秒に設定しているため、コールドスタートで応答が遅れると再送ループに入ることがあります。
Railway であれば月$5程度から常時起動できます。個人プロジェクトや小規模チームには十分です。
まず依存関係をインストールします。
npm init -y
npm install express @anthropic-ai/sdk @octokit/rest
npm install -D typescript @types/express @types/node ts-node
Step 1: GitHub Webhook サーバーの実装
Webhook の受信から始めます。最初に詰まりやすいのが署名検証 です。GitHub は Webhook リクエストに X-Hub-Signature-256 ヘッダーを付けて送ってきます。これを検証しないと、第三者から偽のリクエストを送られても判別できません。
// src/webhook.ts
import express from 'express' ;
import crypto from 'crypto' ;
import { handlePullRequestEvent } from './handlers/pullRequest' ;
const app = express ();
// ⚠️ 重要: raw body が必要なため express.json() は使わない
app. use (
express. raw ({ type: 'application/json' })
);
function verifyWebhookSignature (
rawBody : Buffer ,
signature : string ,
secret : string
) : boolean {
const hmac = crypto. createHmac ( 'sha256' , secret);
hmac. update (rawBody);
const expectedSignature = `sha256=${ hmac . digest ( 'hex' ) }` ;
// タイミング攻撃を防ぐため timingSafeEqual を使う
try {
return crypto. timingSafeEqual (
Buffer. from (signature),
Buffer. from (expectedSignature)
);
} catch {
// 長さが違う場合は false(timingSafeEqual は同じ長さ必須)
return false ;
}
}
app. post ( '/webhook' , async ( req , res ) => {
const signature = req.headers[ 'x-hub-signature-256' ] as string ;
const eventType = req.headers[ 'x-github-event' ] as string ;
if ( ! signature) {
return res. status ( 401 ). json ({ error: 'Missing signature' });
}
const isValid = verifyWebhookSignature (
req.body as Buffer ,
signature,
process.env. GITHUB_WEBHOOK_SECRET !
);
if ( ! isValid) {
console. error ( 'Invalid webhook signature — possible spoofed request' );
return res. status ( 401 ). json ({ error: 'Invalid signature' });
}
// レスポンスを先に返す(GitHub のタイムアウト10秒対策)
res. status ( 202 ). json ({ accepted: true });
const payload = JSON . parse ((req.body as Buffer ). toString ());
if (
eventType === 'pull_request' &&
[ 'opened' , 'synchronize' ]. includes (payload.action)
) {
// 非同期で処理(レスポンス後に実行)
handlePullRequestEvent (payload). catch ( err => {
console. error ( '[PR handler] Unhandled error:' , err);
});
}
});
app. get ( '/health' , ( _req , res ) => {
res. json ({ status: 'ok' , timestamp: new Date (). toISOString () });
});
const PORT = process.env. PORT || 3000 ;
app. listen ( PORT , () => {
console. log ( `Webhook server listening on port ${ PORT }` );
});
2つの重要なポイントがあります。
express.raw() を使う理由 : express.json() は body をパースしてから渡してきます。しかし署名検証は生の bytes に対して HMAC を計算 する必要があります。パース後の JSON 文字列では計算結果が一致しません。ここで詰まる方が非常に多いです。
レスポンスを先に返す理由 : GitHub の Webhook タイムアウトは10秒です。Claude API の呼び出しには数秒かかることがあるため、202 Accepted を即座に返してから処理を非同期で続けます。レスポンスを待ってから処理すると、タイムアウトで GitHub が再送し、同じ PR が2回レビューされる事態になります。
Step 2: PR diff の取得と前処理
Webhook イベントを受信したら、次は GitHub API で PR の差分を取得します。
// src/github/prDiff.ts
import { Octokit } from '@octokit/rest' ;
export interface PRFile {
filename : string ;
status : string ; // 'added' | 'modified' | 'deleted' | 'renamed'
additions : number ;
deletions : number ;
patch : string ;
}
export interface PRDiff {
files : PRFile [];
totalAdditions : number ;
totalDeletions : number ;
skippedFiles : string []; // 除外されたファイル名のリスト
}
export async function getPRDiff (
owner : string ,
repo : string ,
pullNumber : number
) : Promise < PRDiff > {
const octokit = new Octokit ({
auth: process.env. GITHUB_TOKEN ,
});
const { data : files } = await octokit.pulls. listFiles ({
owner,
repo,
pull_number: pullNumber,
per_page: 100 ,
});
const skippedFiles : string [] = [];
const reviewableFiles : PRFile [] = [];
for ( const file of files) {
// バイナリファイル(patch なし)は除外
if ( ! file.patch) {
skippedFiles. push ( `${ file . filename } (binary/too large)` );
continue ;
}
// 500行超の変更は大きすぎる(コスト・品質の両面)
if (file.changes > 500 ) {
skippedFiles. push ( `${ file . filename } (${ file . changes } lines — too large)` );
continue ;
}
// テストファイルは別途処理(本番コードに集中)
if ( isTestFile (file.filename)) {
skippedFiles. push ( `${ file . filename } (test file)` );
continue ;
}
reviewableFiles. push ({
filename: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
patch: file.patch,
});
}
return {
files: reviewableFiles,
totalAdditions: files. reduce (( sum , f ) => sum + f.additions, 0 ),
totalDeletions: files. reduce (( sum , f ) => sum + f.deletions, 0 ),
skippedFiles,
};
}
function isTestFile ( filename : string ) : boolean {
return (
filename. includes ( '__tests__' ) ||
filename. includes ( '.test.' ) ||
filename. includes ( '.spec.' ) ||
filename. endsWith ( '_test.ts' ) ||
filename. endsWith ( '_test.py' ) ||
filename. endsWith ( 'Test.java' )
);
}
500行超のファイルを除外している点について補足します。Claude Sonnet 4.6 は100万トークンのコンテキストウィンドウを持っていますが、大きすぎる diff を1回のリクエストに詰め込むとコストが跳ね上がり、かつレビュー品質も下がります 。500行を目安に除外するか、変更の多い PR は「ファイル単位で順番にレビュー」する設計が現実的です。
また、テストファイルを除外しているのは意図的です。テストコードのレビューは観点が異なるため、別のプロンプトで処理する方が品質が上がります。まずは本番コードに集中するシンプルな設計から始めましょう。
Step 3: Claude API の Tool Use でレビューを構造化する
ここが実装の核心です。単純に「このコードをレビューしてください」と渡すだけでは、返ってくる内容が毎回バラバラになります。Tool Use を使って構造化された出力を強制すること で、後続のコメント整形処理が安定します。
// src/claude/reviewer.ts
import Anthropic from '@anthropic-ai/sdk' ;
const anthropic = new Anthropic ({
apiKey: process.env. ANTHROPIC_API_KEY ,
});
// レビュー結果の型定義
export interface ReviewResult {
overall_score : number ; // 0〜100
security_issues : SecurityIssue [];
quality_issues : QualityIssue [];
improvements : Improvement [];
summary : string ;
}
interface SecurityIssue {
severity : 'critical' | 'high' | 'medium' | 'low' ;
file : string ;
line_hint : string ; // 問題箇所のコードスニペット(1〜2行)
description : string ;
recommendation : string ;
}
interface QualityIssue {
category : 'error_handling' | 'performance' | 'readability' | 'testability' ;
file : string ;
description : string ;
suggestion : string ;
}
interface Improvement {
type : 'refactor' | 'optimization' | 'best_practice' ;
description : string ;
}
const reviewTool : Anthropic . Tool = {
name: 'submit_code_review' ,
description:
'コードレビューの結果を構造化して提出する。分析完了後に必ず1回呼び出すこと。' ,
input_schema: {
type: 'object' as const ,
properties: {
overall_score: {
type: 'number' ,
description:
'全体的なコード品質スコア (0-100)。セキュリティ問題があれば大幅減点。100 が最高品質。' ,
minimum: 0 ,
maximum: 100 ,
},
security_issues: {
type: 'array' ,
description: 'セキュリティ上の問題点のリスト。問題なければ空配列。' ,
items: {
type: 'object' ,
properties: {
severity: {
type: 'string' ,
enum: [ 'critical' , 'high' , 'medium' , 'low' ],
description:
'critical: 即時修正必須 / high: マージ前に修正 / medium: 次回対応可 / low: 参考情報' ,
},
file: { type: 'string' , description: 'ファイルパス' },
line_hint: {
type: 'string' ,
description: '問題のあるコード行(1〜2行のスニペット)' ,
},
description: { type: 'string' },
recommendation: { type: 'string' },
},
required: [
'severity' ,
'file' ,
'line_hint' ,
'description' ,
'recommendation' ,
],
},
},
quality_issues: {
type: 'array' ,
description: 'コード品質の問題点。問題なければ空配列。' ,
items: {
type: 'object' ,
properties: {
category: {
type: 'string' ,
enum: [
'error_handling' ,
'performance' ,
'readability' ,
'testability' ,
],
},
file: { type: 'string' },
description: { type: 'string' },
suggestion: { type: 'string' },
},
required: [ 'category' , 'file' , 'description' , 'suggestion' ],
},
},
improvements: {
type: 'array' ,
description: '改善提案(必須ではないが推奨する変更)。最大3件まで。' ,
items: {
type: 'object' ,
properties: {
type: {
type: 'string' ,
enum: [ 'refactor' , 'optimization' , 'best_practice' ],
},
description: { type: 'string' },
},
required: [ 'type' , 'description' ],
},
},
summary: {
type: 'string' ,
description:
'PR全体の評価を3〜5文で。スコアの根拠・主要な問題・良い点を含める。' ,
},
},
required: [
'overall_score' ,
'security_issues' ,
'quality_issues' ,
'improvements' ,
'summary' ,
],
},
};
const SYSTEM_PROMPT = `あなたはシニアソフトウェアエンジニアです。GitHub の PR diff を受け取り、セキュリティ・コード品質・改善点を分析します。
分析の優先順位:
1. セキュリティ: SQLインジェクション・XSS・SSRF・認証バイパス・シークレット露出・不適切な権限チェック
2. エラーハンドリング: 例外の握りつぶし・不適切な try/catch・エラー伝播の欠如・タイムアウト未設定
3. パフォーマンス: N+1 クエリ・不要なループ・リソースリーク・非同期処理の誤用
4. 可読性: 複雑すぎる関数(20行超)・意味のない変数名・デッドコード
必ず submit_code_review ツールを1回呼び出して結果を提出してください。` ;
export async function reviewPRDiff (
diffContent : string ,
prTitle : string ,
prDescription : string ,
skippedFiles : string []
) : Promise < ReviewResult > {
const skippedNote =
skippedFiles. length > 0
? ` \n\n ⚠️ 以下のファイルはレビュー対象外です: \n ${ skippedFiles . map ( f => `- ${ f }` ). join ( ' \n ' ) }`
: '' ;
const userMessage = `## PR タイトル
${ prTitle }
## PR 説明
${ prDescription || '(説明なし)'}
${ skippedNote }
## コード差分
\`\`\` diff
${ diffContent }
\`\`\`
上記の PR diff をレビューし、submit_code_review ツールで結果を提出してください。` ;
let retryCount = 0 ;
const maxRetries = 3 ;
while (retryCount < maxRetries) {
try {
const response = await anthropic.messages. create ({
model: 'claude-sonnet-4-6' ,
max_tokens: 4096 ,
system: SYSTEM_PROMPT ,
tools: [reviewTool],
tool_choice: { type: 'any' }, // ツール呼び出しを強制
messages: [{ role: 'user' , content: userMessage }],
});
// Tool Use ブロックを取り出す
const toolUseBlock = response.content. find (
( block ) : block is Anthropic . ToolUseBlock =>
block.type === 'tool_use' && block.name === 'submit_code_review'
);
if ( ! toolUseBlock) {
throw new Error (
`submit_code_review が呼ばれませんでした。stop_reason: ${ response . stop_reason }`
);
}
// トークン使用量をログ(コスト管理用)
console. log (
JSON . stringify ({
event: 'claude_review_complete' ,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
model: response.model,
})
);
return toolUseBlock.input as ReviewResult ;
} catch (error) {
retryCount ++ ;
console. error ( `[Claude API] エラー (試行 ${ retryCount }/${ maxRetries }):` , error);
if (retryCount >= maxRetries) {
throw error;
}
// レート制限(429)は指数バックオフでリトライ
if ( isRateLimitError (error)) {
const waitMs = Math. pow ( 2 , retryCount) * 1000 ; // 2s, 4s, 8s
console. log ( `[Claude API] レート制限 — ${ waitMs }ms 待機してリトライ` );
await sleep (waitMs);
} else {
// その他のエラーは即時中断(リトライ不要)
throw error;
}
}
}
throw new Error ( '最大リトライ回数を超えました' );
}
function isRateLimitError ( error : unknown ) : boolean {
return (
error instanceof Anthropic . RateLimitError ||
(error instanceof Error && error.message. includes ( '429' ))
);
}
function sleep ( ms : number ) : Promise < void > {
return new Promise ( resolve => setTimeout (resolve, ms));
}
tool_choice: { type: 'any' } を指定している点が重要です。これを指定しないと、Claude はテキストで回答を返すことがあります。any の代わりに { type: 'tool', name: 'submit_code_review' } を指定すれば特定のツールを強制できますが、any の方が将来複数ツールを追加したときに柔軟に対応できます。
Tool Use の詳細な設計パターンについては Claude API Tool Use 完全ガイド も参照してください。
Step 4: GitHub にレビューコメントを投稿する
Claude API からレビュー結果が返ってきたら、GitHub の PR Review API で投稿します。
// src/github/postReview.ts
import { Octokit } from '@octokit/rest' ;
import type { ReviewResult } from '../claude/reviewer' ;
export async function postReviewComment (
owner : string ,
repo : string ,
pullNumber : number ,
review : ReviewResult
) : Promise < void > {
const octokit = new Octokit ({
auth: process.env. GITHUB_TOKEN ,
});
// critical/high のセキュリティ問題があれば REQUEST_CHANGES
const requiresChanges = review.security_issues. some (
issue => issue.severity === 'critical' || issue.severity === 'high'
);
const event = requiresChanges ? 'REQUEST_CHANGES' : 'COMMENT' ;
const body = formatReviewBody (review);
await octokit.pulls. createReview ({
owner,
repo,
pull_number: pullNumber,
event,
body,
});
console. log (
`[GitHub] PR #${ pullNumber } にレビューコメントを投稿しました (${ event })`
);
}
function formatReviewBody ( review : ReviewResult ) : string {
const scoreEmoji =
review.overall_score >= 80 ? '🟢' :
review.overall_score >= 60 ? '🟡' : '🔴' ;
let body = `## 🤖 Claude Code Review \n\n ` ;
body += `${ scoreEmoji } **品質スコア: ${ review . overall_score }/100** \n\n ` ;
body += `${ review . summary } \n\n ` ;
if (review.security_issues. length > 0 ) {
body += `### 🔒 セキュリティ指摘 \n\n ` ;
for ( const issue of review.security_issues) {
const badge =
issue.severity === 'critical' ? '🚨 CRITICAL' :
issue.severity === 'high' ? '⚠️ HIGH' :
issue.severity === 'medium' ? '⚡ MEDIUM' : 'ℹ️ LOW' ;
body += `**${ badge }** \` ${ issue . file } \`\n\n ` ;
body += ` \`\`\`\n ${ issue . line_hint } \n\`\`\`\n\n ` ;
body += `${ issue . description } \n\n ` ;
body += `> 💡 **推奨対応**: ${ issue . recommendation } \n\n ` ;
body += `--- \n\n ` ;
}
}
if (review.quality_issues. length > 0 ) {
body += `### 🔍 品質指摘 \n\n ` ;
const categoryLabel : Record < string , string > = {
error_handling: 'エラーハンドリング' ,
performance: 'パフォーマンス' ,
readability: '可読性' ,
testability: 'テスト容易性' ,
};
for ( const issue of review.quality_issues) {
body += `**[${ categoryLabel [ issue . category ] }]** \` ${ issue . file } \`\n\n ` ;
body += `${ issue . description } \n\n ` ;
body += `> 💡 ${ issue . suggestion } \n\n ` ;
}
}
if (review.improvements. length > 0 ) {
body += `### 💡 改善提案(任意対応) \n\n ` ;
const typeLabel : Record < string , string > = {
refactor: 'リファクタリング' ,
optimization: '最適化' ,
best_practice: 'ベストプラクティス' ,
};
for ( const improvement of review.improvements) {
body += `- **[${ typeLabel [ improvement . type ] }]** ${ improvement . description } \n ` ;
}
body += ' \n ' ;
}
body += `--- \n *このレビューは Claude Sonnet 4.6 によって自動生成されました。内容は参考情報です。*` ;
return body;
}
REQUEST_CHANGES を自動で付けることに抵抗を感じる方もいるかもしれません。私も最初は COMMENT に固定していました。ただ実際に運用すると、critical な問題があるのにコメントだけでは見落とす ケースが出てきました。高リスクな指摘に限って REQUEST_CHANGES を使う設計に落ち着いています。
チームの文化次第では全て COMMENT にしても良いです。環境変数でフラグ制御できるようにしておくと後で調整しやすいです。
Step 5: 全体をつなぐ PR ハンドラー
ここまでのパーツを組み合わせます。
// src/handlers/pullRequest.ts
import { getPRDiff } from '../github/prDiff' ;
import { reviewPRDiff } from '../claude/reviewer' ;
import { postReviewComment } from '../github/postReview' ;
interface PullRequestPayload {
action : string ;
pull_request : {
number : number ;
title : string ;
body : string | null ;
draft : boolean ;
};
repository : {
owner : { login : string };
name : string ;
};
}
// デバウンス用(短時間に複数コミットが来た場合に最後の1回だけ処理)
const pendingReviews = new Map < string , ReturnType < typeof setTimeout>>();
export async function handlePullRequestEvent (
payload : PullRequestPayload
) : Promise < void > {
const { pull_request , repository } = payload;
const owner = repository.owner.login;
const repo = repository.name;
const pullNumber = pull_request.number;
const prKey = `${ owner }/${ repo }#${ pullNumber }` ;
// Draft PR はレビューしない
if (pull_request.draft) {
console. log ( `[PR #${ pullNumber }] Draft PR のためスキップ` );
return ;
}
// デバウンス: 30秒以内に次のイベントが来たら前のタイマーをキャンセル
if (pendingReviews. has (prKey)) {
clearTimeout (pendingReviews. get (prKey) ! );
console. log ( `[PR #${ pullNumber }] デバウンス: タイマーをリセット` );
}
const timer = setTimeout ( async () => {
pendingReviews. delete (prKey);
await executeReview (owner, repo, pullNumber, pull_request.title, pull_request.body || '' );
}, 30_000 ); // 30秒後に実行
pendingReviews. set (prKey, timer);
console. log ( `[PR #${ pullNumber }] レビュー予約 (30秒後): ${ pull_request . title }` );
}
async function executeReview (
owner : string ,
repo : string ,
pullNumber : number ,
prTitle : string ,
prDescription : string
) : Promise < void > {
console. log ( `[PR #${ pullNumber }] レビュー開始` );
// Step 1: diff 取得
const prDiff = await getPRDiff (owner, repo, pullNumber);
if (prDiff.files. length === 0 ) {
console. log ( `[PR #${ pullNumber }] レビュー対象ファイルなし(スキップ)` );
return ;
}
if (prDiff.skippedFiles. length > 0 ) {
console. log (
`[PR #${ pullNumber }] スキップされたファイル:` ,
prDiff.skippedFiles
);
}
// diff をテキスト形式に整形
const diffContent = prDiff.files
. map ( f => `### ${ f . filename } (${ f . status }, +${ f . additions }/-${ f . deletions }) \n ${ f . patch }` )
. join ( ' \n\n ' );
// コスト見積もりをログ(概算: 文字数 / 4 ≒ トークン数)
const estimatedInputTokens = Math. ceil (
( SYSTEM_PROMPT_CHARS + diffContent. length ) / 4
);
console. log (
`[PR #${ pullNumber }] ファイル数: ${ prDiff . files . length }, 推定入力トークン: ~${ estimatedInputTokens }`
);
// Step 2: Claude API でレビュー
const review = await reviewPRDiff (
diffContent,
prTitle,
prDescription,
prDiff.skippedFiles
);
console. log (
`[PR #${ pullNumber }] レビュー完了: スコア ${ review . overall_score }/100, ` +
`セキュリティ ${ review . security_issues . length }件, 品質 ${ review . quality_issues . length }件`
);
// Step 3: GitHub にコメント投稿
await postReviewComment (owner, repo, pullNumber, review);
console. log ( `[PR #${ pullNumber }] 完了` );
}
// システムプロンプトの文字数(概算コスト計算用)
const SYSTEM_PROMPT_CHARS = 800 ;
デバウンス処理は実際の開発フローで重要です。「push → push → push」と連続してコミットが来る場面は多く、その都度 Claude API を呼ぶと無駄なコストが発生します。30秒待って最後の push だけ処理することで、コストを抑えつつレビューの鮮度も保てます。
よくある実装ミスと落とし穴
実際に本番で運用して経験した問題を共有します。同じ失敗をしないための参考にしてください。
ミス1: express.json() で署名検証が常に失敗する
最も頻繁に遭遇する問題です。express.json() は body をパースして req.body を JavaScript オブジェクトにします。しかし署名検証は生の bytes に対して HMAC を計算 する必要があります。パース後のオブジェクトを JSON.stringify() しても、プロパティの順序や空白が変わると HMAC が一致しません。
express.raw({ type: 'application/json' }) を使い、JSON.parse() は自分で行います。エラーメッセージが分かりにくいため、「GitHub の設定が間違っている」と思い込んで数時間溶かしやすいポイントです。
ミス2: 同じ PR で重複レビューが投稿される
synchronize イベントは PR に新しいコミットが push されるたびに発火します。小さな修正を push するたびに Claude API が呼ばれてコストが膨らみます。デバウンス処理 で解決できます(Step 5 のコード参照)。
あわせて Draft PR のスキップも忘れずに。Draft 中はレビュー不要なことが多く、payload.pull_request.draft フラグで判定できます。
ミス3: GitHub API のレート制限に引っかかる
GitHub REST API の Personal Access Token は1時間に5,000リクエストまでです。大規模チームや多数のリポジトリで運用する場合は、GitHub App を作成してインストールレート制限(1時間15,000リクエスト)を使うほうが安全です。
エラーレスポンスを確認する場合は Claude API エラー完全ガイド や レート制限 429 エラー対応 も参考にしてください。
ミス4: 巨大な diff をそのまま送ってコスト爆発
モノレポで500ファイル以上変更する PR をそのまま送ると、1回のレビューで数百円のコストになることがあります。per_page: 100 の制限に加えて、対象ファイルを絞るビジネスルール を最初から決めておく点が肝心です。
例えば src/ 以下の TypeScript ファイルのみ、あるいは packages/ui/ は除外する、といったフィルタリングを getPRDiff() に追加します。
ミス5: ツールの呼び出しがされない場合のエラーハンドリングが甘い
Claude が何らかの理由でツールを呼ばずにテキストで回答するケースがあります(まれですが起きます)。tool_choice: { type: 'any' } を指定していても、max_tokens が不足してレスポンスが途中で切れるとツール呼び出しが発生しないことがあります。
toolUseBlock が undefined の場合のエラーハンドリングを確実に実装してください。
本番運用のベストプラクティス
コスト管理と見える化
claude-sonnet-4-6 の料金はアウトプット 1M トークンあたり約$15です。PR 1件あたり平均2,000トークンのアウトプットとすると、1,000件の PR で約$30です。チームの規模にもよりますが、思ったより低コストです。
ただし予算超過を防ぐため、使用トークン数をログに記録して可視化 することをお勧めします。先述のコードではすでに input_tokens と output_tokens をログに出力しています。これを Datadog や CloudWatch に送れば、日次コストのモニタリングができます。
必要な環境変数
ANTHROPIC_API_KEY = sk-ant-... # Anthropic コンソールで取得
GITHUB_TOKEN = ghp_... # PR への書き込み権限(repo スコープ)
GITHUB_WEBHOOK_SECRET = xxxxx # GitHub Webhook 設定で指定した秘密鍵
PORT = 3000 # サーバーポート
コードに直接書くのは絶対に避けてください。Railway や Render では Environment Variables として設定します。
Webhook の設定
GitHub リポジトリの Settings → Webhooks → Add webhook で以下を設定します。
Payload URL : https://your-server.railway.app/webhook
Content type : application/json
Secret : 任意の長いランダム文字列(環境変数と一致させる)
Events : "Pull requests" のみ選択
デプロイと確認
Railway を使う場合、railway.json を追加するだけで TypeScript プロジェクトをデプロイできます。
{
"build" : {
"builder" : "NIXPACKS"
},
"deploy" : {
"startCommand" : "npx ts-node src/webhook.ts" ,
"healthcheckPath" : "/health" ,
"healthcheckTimeout" : 10
}
}
デプロイ後、/health エンドポイントに curl でアクセスして {"status":"ok"} が返ってくれば稼働中です。
公式ドキュメントに書かれていない、運用して気づいたこと
ここからは、個人開発で複数のリポジトリにこのボットを入れて運用するなかで見えてきた、ドキュメントには載っていない調整ポイントをお伝えします。私自身、最初は「動けば十分」と考えていたのですが、毎日 PR が流れ始めると想定外の摩擦がいくつも出てきました。Dolice Labs の記事自動生成パイプラインでも同じ構造化出力の仕組みを回しており、その運用で得た勘所をこのボットにも持ち込みました。
最初に効いたのはデバウンス時間の調整 です。当初の 30 秒は短すぎました。レビュー前に CI の lint 修正コミットが立て続けに入るリポジトリでは、30 秒では拾いきれず二重レビューが残ります。CI の所要時間を計測したうえで 90 秒に伸ばしたところ、重複レビューが体感で 7 割ほど減りました。
次に意外だったのがトークン使用量の偏り です。PR 1 件あたりの入力トークンは diff サイズにほぼ比例しますが、出力トークンは「指摘の多い PR」ほど跳ね上がります。実測では、指摘ゼロの PR が出力 600〜900 トークン、critical を含む PR が 2,500〜3,500 トークンでした。コストの大半は問題のある PR に集中します。コード品質が上がるほど、ボットの運用コストはむしろ下がっていきます。
そして見落としがちなのが誤検知への向き合い方 です。overall_score をそのまま CI のゲートにすると、スコア 60 前後のグレーな PR でマージが止まり、チームの不満が溜まります。私はスコアを必須ゲートにはせず、critical/high のセキュリティ指摘がある場合だけ REQUEST_CHANGES を出す設計に落ち着きました。スコアはあくまで参考値として扱うのが現実的だと感じています。
この先に向けて
PR レビューボットが動き始めると、最初に実感するのは「レビューの後回し」が減ることです。私自身、個人開発で一人レビューが滞りがちだった時期に、この第一次チェックがあるだけで手が動くようになりました。人間のレビューを待たずに第一次チェックが終わっているため、レビュアーは Claude の指摘を確認・補足するだけで済みます。
まず手元の小さなリポジトリで Webhook を設定して、curl でテストリクエストを送ってみてください。署名検証が通って 202 が返ってくれば、あとはパイプラインをつなぐだけです。
本番に向けて拡張するなら、ファイルタイプ別のプロンプト分岐 (フロントエンドとバックエンドで重点項目を変える)や、レビュー結果をデータベースに蓄積してチームの傾向分析 を加えると、さらに価値が高まります。GitHub Actions との連携については GitHub Actions × Claude API 自動化ガイド も参考にしてください。
実際の PR を立てずにテストする
開発中に毎回本物の PR を作るのは手間がかかります。GitHub の Webhook 設定には過去のイベントを再送する「Redeliver」ボタンがありますが、開発初期はローカルでペイロードを偽装したほうが速く回せます。
以下は、正しい署名付きで Webhook 配信をシミュレートする最小のテストスクリプトです。
// scripts/testWebhook.ts
import crypto from 'crypto' ;
import fetch from 'node-fetch' ;
const WEBHOOK_SECRET = process.env. GITHUB_WEBHOOK_SECRET ! ;
const SERVER_URL = process.env. SERVER_URL || 'http://localhost:3000' ;
const mockPayload = {
action: 'opened' ,
pull_request: {
number: 1 ,
title: 'Test PR: ユーザー認証を追加' ,
body: 'JWT ベースの認証を追加します。#42 を参照。' ,
draft: false ,
},
repository: {
owner: { login: 'your-org' },
name: 'your-repo' ,
},
};
const payloadStr = JSON . stringify (mockPayload);
const hmac = crypto. createHmac ( 'sha256' , WEBHOOK_SECRET );
hmac. update (payloadStr);
const signature = `sha256=${ hmac . digest ( 'hex' ) }` ;
const response = await fetch ( `${ SERVER_URL }/webhook` , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'X-GitHub-Event' : 'pull_request' ,
'X-Hub-Signature-256' : signature,
},
body: payloadStr,
});
console. log ( 'Status:' , response.status);
console. log ( 'Body:' , await response. json ());
ローカルサーバーに対して実行してみてください(一方のターミナルで npx ts-node src/webhook.ts、もう一方でこのスクリプト)。202 が返り、サーバーログにレビューパイプラインの開始が見えれば、署名検証は正しく動いています。
実際の GitHub API 呼び出しまで確かめるには、本物の PR diff が必要です。テスト用リポジトリを作り、わざと欠陥のあるコード(エラーハンドリング漏れ、ハードコードされたシークレットなど)を含む PR を立てて、Claude のレビューがそれを捕まえるか確認します。デプロイ前にこの手動確認をしておく価値は大きいです。
監視と可観測性
静かに失敗するボットは、ボットがないより厄介です。最低限、次の 3 つは整えておきます。
構造化ログ : 先述のコードはすでに Claude API 呼び出しごとに JSON のログ行を出力しています。標準出力をログ集約基盤(Datadog、Papertrail、あるいは Railway 内蔵のログビューア)に流し、エラー率にアラートを設定します。
コスト追跡 : claude_review_complete のログ行を解析して、日次のトークン消費を把握します。
# ログから概算コストを出す(料金は実際の単価に合わせて調整)
cat app.log | grep claude_review_complete | python3 -c "
import sys, json
total_in, total_out = 0, 0
for line in sys.stdin:
try:
d = json.loads(line)
total_in += d.get('input_tokens', 0)
total_out += d.get('output_tokens', 0)
except: pass
# claude-sonnet-4-6: \$ 3/M input, \$ 15/M output
cost = (total_in / 1_000_000 * 3) + (total_out / 1_000_000 * 15)
print(f'Input: {total_in:,} tokens, Output: {total_out:,} tokens')
print(f'Estimated cost: \$ {cost:.4f}')
"
レビュー網羅率の追跡 : レビューがスキップされた理由(対象ファイルなし、Draft PR など)も成功レビューと並べて記録します。スキップ率が想定より高ければ、ファイルフィルターが厳しすぎる可能性があります。
アラート : 日次のコスト予算にアラートを設定します。たとえば 1 日の Claude API 支出が $5 を超えたら、何かが壊れている可能性が高いです(ボットの PR がレビューループを起こしている、数千行の巨大 PR がフィルターをすり抜けた、など)。
PAT と GitHub App、どちらを選ぶか
Personal Access Token は最短で動かせますが、規模が大きくなると無視できないトレードオフがあります。
PAT は特定ユーザーとして認証します。そのユーザーが組織を離れるとトークンは止まり、広い repo 権限を持っていればトークンも不必要に広い権限を抱えていたことになります。GitHub App はインストール自体として認証し、必要な範囲だけに権限を絞れます。
このボットに必要な最小権限は次の通りです。
Pull requests : Read and write(レビュー投稿のため)
Contents : Read(必要に応じてファイル内容を取得するため)
Metadata : Read(常に必須)
GitHub App にすると、PAT の 1 時間 5,000 リクエストではなく、インストールの 1 時間 15,000 リクエスト制限が使えます。アクティブな組織では効いてきます。
コード変更はわずかです。Octokit の生成を @octokit/auth-app に置き換えます。
import { createAppAuth } from '@octokit/auth-app' ;
import { Octokit } from '@octokit/rest' ;
function createOctokitForInstallation ( installationId : number ) : Octokit {
return new Octokit ({
authStrategy: createAppAuth,
auth: {
appId: process.env. GITHUB_APP_ID ,
privateKey: process.env. GITHUB_APP_PRIVATE_KEY ,
installationId,
},
});
}
installationId は Webhook ペイロード(payload.installation.id)から取得できます。これで複数の組織をまたいでボットを動かせ、各組織のインストールが別々の権限を持てます。
個人プロジェクトや単一組織のデプロイなら PAT で十分です。複数リポジトリを組織横断で扱うなら、GitHub App が正解です。
本記事のサンプルコードは Node.js 22 + TypeScript 5.4 + @anthropic-ai/sdk 1.x + @octokit/rest 21 で動作確認しています。