先週、自社サイトのリポジトリでプルリクエスト一覧を開いたら、オープンのPRが12本並んでいた。
一番古いものは11日前に作られたまま止まっていた。CIは全部グリーンで、コンフリクトもない。ただ誰もマージしていないだけ。
正直なところ、最初に頭に浮かんだのは「レビューをサボっている」という自己批判だった。でもそれは原因の説明になっていない。現場では、個人の頑張りで説明がつく話はたいてい構造の問題だ。だから今回は感覚で語るのをやめて、リポジトリに残っている実データで測ることにした。
結論から言うと、詰まっていたのは機械ではなく人間の判断で、そして溜まった分は最後にレビューされずまとめて捨てられていた。
まずCIを疑って、外した
最初の仮説は「CIが遅くてマージが進まない」だった。プレビュー環境のビルドとデプロイが走るので、そこが重ければ待ち時間になる。
測ってみたら、これは即座に否定された。
| ワークフロー | 実行時間の中央値 | サンプル数 |
|---|---|---|
| Preview(PR向けビルド・デプロイ) | 123秒 | 40件 |
| Deploy Production(マージ後の本番反映) | 74秒 | 20件 |
PRが作られてからマージされるまで、8月は中央値で98.2時間かかっていた。そのうち機械が動いている時間は約2分。割合にすると0.03%だ。
CIを半分に縮めても、リードタイムは1分しか縮まらない。改善する対象を間違えるところだった。
リードタイムを平均で見ると、何も見えない
ここからは実測の話に入る。GitHub CLIでPRの作成時刻とマージ時刻をまとめて取り、分布を出した。
# PR履歴をまとめて取得(作成・マージ・クローズの時刻とブランチ名)
gh pr list --state all --limit 400 \
--json number,title,createdAt,mergedAt,closedAt,state,headRefName > pr-history.json
# pr-history.json からマージ済みPRのリードタイム分布を出す
import json, statistics
from datetime import datetime
def to_dt(s):
return datetime.fromisoformat(s.replace("Z", "+00:00"))
records = json.load(open("pr-history.json", encoding="utf-8"))
merged = [r for r in records if r["mergedAt"]]
hours = sorted(
(to_dt(r["mergedAt"]) - to_dt(r["createdAt"])).total_seconds() / 3600
for r in merged
)
print("件数 :", len(hours))
print("中央値 : %.1f時間" % statistics.median(hours))
print("平均 : %.1f時間" % statistics.mean(hours))
print("90パーセンタイル: %.1f時間" % hours[int(len(hours) * 0.9)])
print("最大 : %.1f時間" % hours[-1])
出てきた数字がこれだった。対象は2026年3月27日から8月19日までの全PR 378件(マージ310件、未マージのままクローズ56件、オープン12件)。
- 中央値: 1.2時間
- 平均: 20.5時間
- 90パーセンタイル: 61.2時間
- 最大: 333.8時間
中央値と平均が17倍も離れている。この形の分布を平均だけで報告していたら、「平均1日弱ですね」で終わってしまう。実際には半分のPRは1時間ちょっとで通っていて、一部が2週間近く止まっている。
理屈はそうなんですが、と自分でも思う。分布が歪むことは知識としては知っていた。知っていたのに、進捗報告では平均を書いていた。数字を出すこと自体より、どの代表値で見るかのほうが効いてくる。
月ごとに割ると、悪化した時期がはっきり出た。
| 月 | 作成されたPR | マージされたPR | リードタイム中央値 |
|---|---|---|---|
| 2026-06 | 96 | 76 | 1.2時間 |
| 2026-07 | 64 | 51 | 3.8時間 |
| 2026-08(19日時点) | 33 | 19 | 98.2時間 |
作成数はむしろ減っている。減っているのにリードタイムが80倍になった。作る量の問題ではないということになる。
詰まっているのはコードではなく、判断だった
ブランチ名の接頭辞でPRを種類ごとに分けたら、待ちの正体がはっきりした。うちのリポジトリは、実装系の変更と、記事や実績ページのコンテンツ追加が同じリポジトリに同居している。
マージ済みPRのリードタイム中央値は、実装系が0.6時間(197件)。一方でコンテンツ系は8月だけ見ると149.4時間(10件)だった。
同じCI、同じリポジトリ、同じレビュー担当。違うのは判断の性質だけだ。
実装系は「テストが通っているか」「壊していないか」で判定できる。コンテンツ系は「これを自社の名前で出していいか」を人が読んで決めるしかない。前者は基準が外にあるが、後者は基準が人の頭の中にある。
現場では、この差はよく「レビューが重い作業か軽い作業か」として語られる。でも実態はそこじゃなくて、判定基準が言語化されているかどうかなんですよね。基準が外に出ていない工程は、担当者の可処分時間そのものが処理能力の上限になる。
リトルの法則を、自分の行列に当てはめてみた
制約理論やカンバンの文脈でよく出てくる、待ち行列の関係式がある。
平均リードタイム = 仕掛かり中の件数 ÷ 完了レート
フレームワークは目的ではなく道具、というのが私の口癖なので、これも「知っている」で終わらせずに自分の数字を入れてみた。
# 8月の到着レート・完了レートと、現在のWIPから待ち時間を見積もる
import json
from datetime import datetime, timezone
records = json.load(open("pr-history.json", encoding="utf-8"))
elapsed_days = 19 # 2026-08-01 から 08-19 まで
created = [r for r in records if r["createdAt"] >= "2026-08-01"]
merged = [r for r in records if r["mergedAt"] and r["mergedAt"] >= "2026-08-01"]
wip = [r for r in records if r["state"] == "OPEN"]
arrival = len(created) / elapsed_days # 到着レート(件/日)
throughput = len(merged) / elapsed_days # 完了レート(件/日)
print("到着レート : %.2f 件/日" % arrival)
print("完了レート : %.2f 件/日" % throughput)
print("仕掛かり : %d 件" % len(wip))
print("待ち見積もり: %.1f 日" % (len(wip) / throughput))
print("1日あたりの積み増し: %.2f 件" % (arrival - throughput))
結果は身も蓋もなかった。
- 到着レート: 1.74件/日
- 完了レート: 1.00件/日
- 仕掛かり: 12件
- 待ち見積もり: 12.0日
- 1日あたりの積み増し: 0.74件
毎日0.74件ずつ、確実に増えていく。行列が伸びているのだから、後から入ったPRの待ち時間も伸びる。実際、オープン12本の最古は263.9時間(11.0日)で、見積もりの12日とほぼ一致していた。
ここで効くのは、レビューを速くすることではなく、到着レートを下げるか完了レートを上げるかのどちらかしかない、という当たり前の結論だ。当たり前なんですが、行列として見るまで私は「もっと頑張ってレビューしよう」の側で考えていた。
想定外だったのは、溜まった分が消えていたこと
ここまでは「遅い」という話でしかない。遅いだけなら、いつか処理されるはずだ。
未マージのままクローズされたPRを調べて、手が止まった。
# 同じ時間帯にまとめてクローズされたPRを検出する
import json
from collections import defaultdict
from datetime import datetime
records = json.load(open("pr-history.json", encoding="utf-8"))
dropped = [r for r in records if r["state"] == "CLOSED"]
by_day = defaultdict(list)
for r in dropped:
by_day[r["closedAt"][:10]].append(r)
for day, items in sorted(by_day.items()):
if len(items) < 3:
continue
items.sort(key=lambda r: r["closedAt"])
span = items[-1]["closedAt"][11:19], items[0]["closedAt"][11:19]
print(f"{day}: {len(items)}件 を {span[1]}〜{span[0]} にクローズ")
for r in items:
age = (
datetime.fromisoformat(r["closedAt"].replace("Z", "+00:00"))
- datetime.fromisoformat(r["createdAt"].replace("Z", "+00:00"))
).days
print(f" #{r['number']} 滞留{age}日 {r['headRefName']}")
2026年8月8日、26分間(01:02:01〜01:28:15 UTC)で8本のPRが未マージのままクローズされていた。滞留期間は最短0.6日、最長11.9日。中身は記事や実績ページの原稿で、CIは全部通っていた。
つまり2週間分の成果物が、読まれないまま一括で消えている。
覚えている限り、このときは「古いのが溜まって邪魔だから一旦整理しよう」という判断だった。悪意はないし、その場では正しい判断にすら見える。ただ、リードタイムの観点で言えば、これは行列があふれたときに末尾から落ちているだけだ。
全体を数えると、自動生成されたPR 353本のうち、マージ287本(81.3%)、未マージでクローズ54本(15.3%)、オープン12本(3.4%)。さらに種類別に見ると、実績ページの系列はマージ12本に対してクローズ24本で、作った分の3分の2が捨てられていた。
作る速度を上げても、成果は増えていなかった。増えていたのは仕掛かりと廃棄だった。
何を変えたか
対策として決めたのは3つ。どれも派手ではない。
1つ目は、オープンPRの上限を設けること。上限を超えている間は新しい生成タスクを走らせない。作らないことが改善になる、という発想は最初かなり抵抗があった。手が空いているなら作ればいいじゃないか、と思ってしまう。でも行列に積むだけなら、作らないほうが廃棄が減る。
2つ目は、レビューの時間枠を先に固定すること。空いた時間にやるのではなく、朝の30分をレビュー枠として先に確保する。完了レートを上げるには、担当者の可処分時間を先に押さえるしかない。
3つ目は、判定基準を外に出すこと。コンテンツ系のレビューで見ている観点をチェックリストにして、機械で判定できるものはCIに寄せる。ここは着手したばかりで、まだ効果は測れていない。
上限のチェックは、いまのところこの程度のスクリプトで運用している。
#!/usr/bin/env bash
# オープンPRが上限を超えていたら、生成タスクを走らせずに終了する
set -euo pipefail
WIP_LIMIT=6
open_count=$(gh pr list --state open --limit 100 --json number --jq 'length')
if [ "$open_count" -ge "$WIP_LIMIT" ]; then
echo "オープンPR ${open_count}件(上限 ${WIP_LIMIT}件)。生成をスキップします。"
gh pr list --state open --limit 100 \
--json number,createdAt,headRefName \
--jq '.[] | " #\(.number) \(.createdAt[:10]) \(.headRefName)"'
exit 0
fi
echo "オープンPR ${open_count}件。生成を続行します。"
これは改善の余地がある。いまは生成タスクの先頭で人間が手で叩いているだけで、CIにもスケジューラにも組み込めていない。本来は生成を起動する側のワークフローに入れて、上限超過なら自動でスキップしログを残すべきだ。上限値の6も、完了レート1.0件/日から逆算して「6日分」と置いただけで、根拠としては弱い。しばらく回して、廃棄率が下がるかどうかで調整するつもりでいる。
振り返って
今回いちばん効いたのは、対策ではなく測り方を変えたことだった。
「レビューが追いついていない」は感覚だが、「到着1.74件/日に対して完了1.00件/日」は事実で、事実には打ち手が紐づく。逆に言えば、測っていない工程はいくら気合を入れても改善できない。
もうひとつ、自分で書いていて耳が痛いのは、生産側だけ自動化すると廃棄が増えるという点だ。上流の処理能力を上げたら、下流のどこかに必ず溜まる。この当たり前を、うちは3か月かけて実データで確認してしまった。
次はレビュー側の判定基準をどこまで機械に渡せるかを試したい。全部は渡せないと思っている。ただ「明らかにダメなもの」を先に落とせるだけでも、人が読む本数は減る。そこまで行けたら、また実測して書きます。