プロジェクト管理 2026年8月20日

1日1.7件作って1.0件しかマージできない。レビュー待ちの行列を実測したら、2週間分の成果が26分で捨てられていた話

XECIN リードタイムWIP制限PMO技術検証

先週、自社サイトのリポジトリでプルリクエスト一覧を開いたら、オープンの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-0696761.2時間
2026-0764513.8時間
2026-08(19日時点)331998.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か月かけて実データで確認してしまった。

次はレビュー側の判定基準をどこまで機械に渡せるかを試したい。全部は渡せないと思っている。ただ「明らかにダメなもの」を先に落とせるだけでも、人が読む本数は減る。そこまで行けたら、また実測して書きます。