エージェントを入れた当初は、レビューコメントに全員が返信していました。半年後、ある朝のPRを開いて手が止まりました。エージェントが付けた11件の指摘が、すべて無言で解決済みにされていたのです。
議論も、修正も、返信もありません。ただ静かに閉じられている。指摘の中身は半年前と変わらず妥当そうに見えるのに、誰も読んでいない。
これはツールの故障ではありません。指摘が多すぎて、チームが「とりあえず全部閉じる」を学習してしまった状態です。数値で見ないと気づけない、静かな形骸化。個人開発で回している小さなチームでも、これは静かに起こります。私自身、この形骸化に気づくまで半年かかりました。この記事は、その兆候を計測で捉えて立て直すまでの運用メモです。
「動いている」と「効いている」は別の指標
コードレビューエージェントの健全度を、私たちは長らく「稼働しているか」で見ていました。CIが緑で、コメントが投稿されていれば正常。そう思っていました。
ですが本当に見るべきは、投稿された指摘が実際に行動につながったか です。ここを分けて考えないと、形骸化は永遠に見えません。
観点 「動いている」の指標 「効いている」の指標
稼働 CI成功率・コメント投稿数 —
受容 — 採用率(指摘に対して修正 or 議論が発生した割合)
精度 — 偽陽性率(wontfix・not-applicable で閉じられた割合)
負荷 — 1PRあたりの指摘数の中央値
投稿数はいくらでも増やせます。増やすほど採用率は下がる。この逆相関に気づかないまま指摘を増やし続けると、ある日全員が読むのをやめます。
採用率をPRイベントから自動で集計する
まず必要なのは、指摘が「その後どうなったか」を追う仕組みです。GitHub の Review Comment には、解決状態と、そこに付いた返信が残ります。これを収穫します。
エージェントが投稿したコメントを起点に、後続の人間の行動を3分類します。修正コミットが続いたら actioned、返信で議論が起きたら discussed、返信も修正もなく解決されたら ignored。
# collect_actioned_rate.py
# エージェントのレビューコメントが「行動につながったか」を集計する
import os, requests
from collections import Counter
REPO = os.environ[ "REPO" ] # 例: "owner/name"
BOT = os.environ[ "BOT_LOGIN" ] # レビューエージェントのアカウント名
TOKEN = os.environ[ "GITHUB_TOKEN" ]
H = { "Authorization" : f "Bearer { TOKEN } " , "Accept" : "application/vnd.github+json" }
def paged (url, params = None ):
params = dict (params or {}, per_page = 100 )
while url:
r = requests.get(url, headers = H, params = params, timeout = 30 )
r.raise_for_status()
yield from r.json()
url = r.links.get( "next" , {}).get( "url" )
params = None # next の URL に条件が含まれるため
def classify (comment, later_commit_shas):
# actioned: 指摘後に同一ファイルへ変更コミットがある
# discussed: 返信スレッドに人間の応答がある
# ignored: どちらもなく解決済み
if comment[ "reply_count" ] > 0 :
return "discussed"
if comment[ "path_touched_after" ]:
return "actioned"
return "ignored"
ポイントは、ignored を「悪」と即断しないことです。INFOレベルの参考指摘は無視されて当然。重大度と紐づけて初めて意味を持ちます。次でそこを切り分けます。
偽陽性と「疲労による無視」を切り分ける
ignored の中身は2種類あります。指摘そのものが誤り だった偽陽性と、指摘は妥当だが読まれなかった 疲労性の無視です。この2つは対処がまったく違います。
偽陽性は指摘の質の問題。疲労性の無視は指摘の量の問題です。混ぜて見ると、精度改善に走るべきか物量削減に走るべきか判断を誤ります。
切り分けには、解決時に軽量なラベルを1つだけ選んでもらう運用を足しました。返信を書かせると負担が重いので、リアクション絵文字1つに割り当てます。
# 解決時リアクションを偽陽性シグナルとして読む
FALSE_POSITIVE_REACTIONS = { "-1" , "confused" } # 👎 = 誤指摘 / 😕 = 的外れ
def is_false_positive (comment):
reactions = {r[ "content" ] for r in comment[ "reactions" ]}
return bool (reactions & FALSE_POSITIVE_REACTIONS )
def bucket (comment):
if comment[ "state" ] != "resolved" :
return "open"
if is_false_positive(comment):
return "false_positive" # 質の問題
if comment[ "reply_count" ] == 0 and not comment[ "path_touched_after" ]:
return "fatigue_ignored" # 量の問題
return "actioned"
半年後の私たちの数字は、偽陽性率が7%と許容範囲内なのに、疲労性の無視が全指摘の58%を占めていました。つまり質は悪くない。ただ多すぎたのです。この一点が分かった瞬間、打ち手が「精度チューニング」から「物量削減」へ切り替わりました。
重大度ごとに健全度の閾値を持つ
全体の採用率を1つの数字で見ると、INFOの洪水がHIGHの価値を薄めて見えなくします。重大度ごとに別の物差しを持つのが実務的でした。
私たちが運用で置いている閾値です。数字はチームの許容度で調整すべきものですが、出発点として共有します。
重大度 期待する採用率 許容する偽陽性率 1PRあたりの上限
HIGH(要対応) 85%以上 3%未満 制限なし(出たら必ず見せる)
MEDIUM(推奨) 50%以上 8%未満 5件
LOW(任意) 20%以上 — 3件
INFO(参考) 計測対象外 — 要求時のみ表示
HIGHの採用率が閾値を割ったら、それは重大アラートです。本当に直すべき指摘が読まれていない状態を意味します。逆にLOWの採用率が低いのは正常。放置してよい指摘は放置されるべきです。
この表を導入して最初に起きたのは、INFOをデフォルト非表示にしただけでHIGHの採用率が72%から89%へ戻ったことでした。指摘は減らしていません。埋もれていたHIGHが見えるようになっただけです。
指摘量を段階的に絞る
物量削減は一気にやると逆効果でした。急に静かになると、今度はチームが「エージェントが壊れたのでは」と不安がる。段階的に締めるのが定着しました。
締め方には順序があります。まずノイズ源のINFO/LOWを絞り、効果を1〜2スプリント計測してから、MEDIUMの上限に手をつけます。HIGHは最後まで触りません。
# review-agent.config.yml — 段階的に締めるための設定
severity_gates :
HIGH : { max_per_pr : null , always_visible : true }
MEDIUM : { max_per_pr : 5 , sort_by : confidence } # 確信度の高い順に5件
LOW : { max_per_pr : 3 , collapse : true } # 折りたたみ表示
INFO : { on_demand : true } # /review --verbose 時のみ
# 同一の観点を1PR内で繰り返さない(重複疲労の主因)
dedupe :
by : [ rule_id , file ]
keep : highest_severity
dedupe は効果が大きい設定でした。同じルール違反を1つのファイルに20回指摘されると、内容が正しくても人は読むのをやめます。ファイル単位で最も重大な1件に畳むだけで、指摘総数が4割減り、採用率が目に見えて戻りました。
締めた後は必ず数字を確認します。採用率が上がり、かつHIGHの見逃し(後から人間が見つけたバグ)が増えていないこと。この2つが両立して初めて、締めすぎていないと判断できます。
週次で健全度を1枚にまとめる
計測は続けなければ意味がありません。私たちはこの集計を週次でCIに載せ、Slackに1枚のサマリーを流しています。エージェント自身に運用させているのが、少しだけ気に入っている点です。
# weekly_health.py — 週次サマリーを生成
def summarize (comments):
total = len (comments)
actioned = sum ( 1 for c in comments if bucket(c) == "actioned" )
fp = sum ( 1 for c in comments if bucket(c) == "false_positive" )
fatigue = sum ( 1 for c in comments if bucket(c) == "fatigue_ignored" )
high = [c for c in comments if c[ "severity" ] == "HIGH" ]
high_rate = sum ( 1 for c in high if bucket(c) == "actioned" ) / max ( len (high), 1 )
return {
"actioned_rate" : round (actioned / max (total, 1 ), 3 ),
"false_positive_rate" : round (fp / max (total, 1 ), 3 ),
"fatigue_ignored_rate" : round (fatigue / max (total, 1 ), 3 ),
"high_actioned_rate" : round (high_rate, 3 ),
"median_per_pr" : median_comments_per_pr(comments),
}
# 判定: high_actioned_rate < 0.85 なら即アラート
# fatigue_ignored_rate > 0.30 なら物量削減フェーズへ
この1枚があるだけで、形骸化は「ある朝気づく」ものから「2週間前に数字が傾いていた」ものに変わりました。傾き始めに手を打てれば、全員が黙って閉じる状態まで進行させずに済みます。
振り返って
コードレビューエージェントの一番の敵は、誤指摘そのものよりも、誤指摘が積み重なって生まれる無関心でした。人は正しい指摘であっても、多すぎれば読むのをやめます。そしてそれは沈黙として現れるので、投稿数だけを見ていると気づけません。
採用率・偽陽性率・疲労性無視率という3つの物差しを分けて持つこと。重大度ごとに閾値を変えること。締めるときは段階的にやること。この3つで、私たちのエージェントは「全員が黙って閉じるツール」から「HIGHの指摘に全員が反応するツール」へ戻りました。
もし今、あなたのチームのレビューコメントが静かに閉じられているなら、私はまず採用率から測ることを推奨します。精度チューニングより先に、量の問題かどうかを切り分ける。私はこの順序を強くお勧めします。数字は、たいてい私たちが薄々感じていたことを、動けるかたちにしてくれます。実装の参考になれば幸いです。お読みいただきありがとうございました。