エージェントに PR を開かせる運用を考えていた夜、頭の中にあった見積もりは「エージェントの PR は、人の PR よりも後回しにされる」でした。開く速さは機械のほうが上です。読む人間が追いつかなければ、レビュー待ちの列は伸びる一方だろう、と。
その見積もりを、手元の感覚ではなく公開データで確かめてみることにしました。題材にしたのは、GitHub Copilot の coding agent が日常的に PR を開いている dotnet/runtime です。2026 年 7 月と 8 月に作られた PR のうち、エージェントが開いたもの 123 件と、人が開いたもの 123 件を数え直しました。
最初にお伝えしたいのは、結果が見積もりと逆だったことです。エージェントの PR のほうが、人の最初の反応までの時間は短く、中央値で 1.79 時間。人の PR は 5.76 時間でした。 ただし、この数字をそのまま「エージェントの PR は大切にレビューされている」と読むと、個人開発では痛い目を見ます。理由を、数え方から順に書き残します。
「最初の人の反応」だけを数える
レビュー待ちを測ろうとして、最初に迷ったのは何を「待ち」と呼ぶかでした。マージまでの時間は、差分の大きさにも CI にも左右されます。そこで、PR が開かれてから作者以外の人が最初に何かを残すまでの時間だけを数えることにしました。
「何か」には、レビュー、差分への行コメント、通常のコメントを含めます。ボットのコメントは除き、type が User のアカウントだけを見ます。エージェントが開いた PR の作者は人ではないので、依頼した人のコメントもここに入ります。この点は、後の章で効いてきます。
| 項目 | エージェント側 | 人側 |
|---|---|---|
| 対象 | author:app/copilot-swe-agent の PR | それ以外の PR のうち作者が User のもの |
| 期間 | 2026-07-01〜2026-08-31 に作成 | 同左(集計日は 2026-10-14) |
| 母数(検索 API の件数) | 233 件 | 1,697 件(ボット含む) |
| 標本 | 123 件 | 123 件 |
| 抽出方法 | 1 週ごとに、検索結果の新しい 100 件から無作為に 14 件 | 同左 |
| 状態 | マージ・クローズ・未決をすべて含める | 同左(未決を除くと、待たされた PR だけが標本から消えるため) |
標本を週ごとに分けたのは、検索結果が期間の前半に偏るのを避けるためです。最初は期間全体を一度に検索して先頭の 100 件を標本にしていたのですが、それでは期間の前半に寄ってしまうと気づき、一週間ごとに 14 件前後を引く形に直しました。
計測スクリプトの全体
標準ライブラリだけで動くようにしました。トークンが無くても動きますが、PR 1 件あたり 4 回ほど API を呼ぶので、トークンを渡して上限を 5,000 回/時に引き上げておくのが安全です。
# pr_review_lag.py
#!/usr/bin/env python3
"""公開リポジトリで、エージェントが開いた PR と人が開いた PR の
「最初の人の反応までの時間」「解決までの時間」「差分の大きさ」を数える。
標準ライブラリだけで動く。GITHUB_TOKEN があればレート上限が 5,000/時になる。
使い方: python3 pr_review_lag.py OWNER/REPO 2026-07-01 2026-08-31 123
"""
import json, os, random, statistics, sys, time
import urllib.error, urllib.parse, urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
API = "https://api.github.com"
TOKEN = os.environ.get("GITHUB_TOKEN", "")
def get(path, params=None):
url = API + path + ("?" + urllib.parse.urlencode(params) if params else "")
headers = {"Accept": "application/vnd.github+json", "User-Agent": "pr-review-lag"}
if TOKEN:
headers["Authorization"] = "Bearer " + TOKEN
for attempt in range(5):
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code in (403, 429, 502, 503):
time.sleep(15 * (attempt + 1)) # 二次レート制限は待てば戻る
continue
raise
raise RuntimeError("giving up: " + url)
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None
def hours(a, b):
return (b - a).total_seconds() / 3600 if a and b else None
def week_windows(start, end):
"""期間を 7 日ごとに区切る。検索結果が期間の前半に偏らないようにするため。"""
from datetime import date, timedelta
d, e, out = date.fromisoformat(start), date.fromisoformat(end), []
while d <= e:
out.append((d.isoformat(), min(d + timedelta(days=6), e).isoformat()))
d += timedelta(days=7)
return out
def search_prs(repo, extra, start, end, limit):
wins = week_windows(start, end)
per = max(1, -(-limit // len(wins))) # 1 週あたりの取得数(切り上げ)
items = []
tag = "agent" if "copilot" in extra and not extra.startswith("-") else "human"
for a, b in wins:
cache = f"win_{tag}_{a}.json"
if os.path.exists(cache): # 検索は毎分 30 回までなので、窓ごとに結果を残して再開できるようにする
items += json.load(open(cache))
continue
q = f"repo:{repo} is:pr created:{a}..{b} {extra}"
got = get("/search/issues", {"q": q, "per_page": 100, "sort": "created", "order": "desc"})["items"]
got = [i for i in got if i["user"]["type"] == "User" or "copilot" in i["user"]["login"].lower()]
random.Random(14).shuffle(got)
got = got[:per]
json.dump(got, open(cache, "w"))
items += got
time.sleep(6)
return items
def first_human_reaction(repo, pr):
author = pr["user"]["login"]
n = pr["number"]
opened = ts(pr["created_at"])
times = []
for r in get(f"/repos/{repo}/pulls/{n}/reviews", {"per_page": 100}):
u = r.get("user") or {}
if u.get("type") == "User" and u["login"] != author and r.get("submitted_at"):
times.append(ts(r["submitted_at"]))
for c in get(f"/repos/{repo}/pulls/{n}/comments", {"per_page": 100}):
u = c.get("user") or {}
if u.get("type") == "User" and u["login"] != author:
times.append(ts(c["created_at"]))
for c in get(f"/repos/{repo}/issues/{n}/comments", {"per_page": 100}):
u = c.get("user") or {}
if u.get("type") == "User" and u["login"] != author:
times.append(ts(c["created_at"]))
return hours(opened, min(times)) if times else None, len(times)
def collect(repo, label, extra, start, end, want, budget_s=None):
"""標本の PR 番号は pool_<label>.json に固定し、集計済みの行は rows_<label>.jsonl に追記する。
途中で止まっても、もう一度実行すれば続きから再開できる。"""
pool_file, rows_file = f"pool_{label}.json", f"rows_{label}.jsonl"
if os.path.exists(pool_file):
pool = json.load(open(pool_file))
else:
pool = [i["number"] for i in search_prs(repo, extra, start, end, want)][:want]
json.dump(pool, open(pool_file, "w"))
done = {}
if os.path.exists(rows_file):
for line in open(rows_file):
r = json.loads(line)
done[r["n"]] = r
t0 = time.time()
def one(n):
if budget_s and time.time() - t0 > budget_s:
return None
pr = get(f"/repos/{repo}/pulls/{n}")
lag, reactions = first_human_reaction(repo, pr)
end_at = ts(pr["merged_at"] or pr["closed_at"])
return {
"n": n, "kind": label, "state": "merged" if pr["merged_at"] else pr["state"],
"lag_h": lag, "reactions": reactions,
"resolve_h": hours(ts(pr["created_at"]), end_at),
"lines": pr["additions"] + pr["deletions"], "files": pr["changed_files"],
}
todo = [n for n in pool if n not in done]
with ThreadPoolExecutor(max_workers=6) as ex, open(rows_file, "a") as out:
for row in ex.map(one, todo):
if row:
out.write(json.dumps(row) + "\n")
done[row["n"]] = row
return [done[n] for n in pool if n in done], len(pool)
def pct(xs, p):
xs = sorted(xs)
return xs[min(len(xs) - 1, int(len(xs) * p))] if xs else None
def summarize(rows):
lags = [r["lag_h"] for r in rows if r["lag_h"] is not None]
res = [r["resolve_h"] for r in rows if r["resolve_h"] is not None]
n = len(rows)
return {
"n": n,
"merged_pct": round(100 * sum(r["state"] == "merged" for r in rows) / n, 1),
"open_pct": round(100 * sum(r["state"] == "open" for r in rows) / n, 1),
"no_human_reaction_pct": round(100 * (n - len(lags)) / n, 1),
"lag_median_h": round(statistics.median(lags), 2) if lags else None,
"lag_p90_h": round(pct(lags, 0.9), 2) if lags else None,
"resolve_median_h": round(statistics.median(res), 2) if res else None,
"lines_median": statistics.median(r["lines"] for r in rows),
"reactions_median": statistics.median(r["reactions"] for r in rows),
}
if __name__ == "__main__":
repo, start, end, want = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
budget = int(os.environ.get("BUDGET_S", "0")) or None
agent, na = collect(repo, "agent", "author:app/copilot-swe-agent", start, end, want, budget)
human, nh = collect(repo, "human", "-author:app/copilot-swe-agent", start, end, want, budget)
if len(agent) < na or len(human) < nh:
print(f"途中: agent {len(agent)}/{na}, human {len(human)}/{nh} — もう一度実行すると続きから再開します")
sys.exit(0)
out = {"repo": repo, "window": [start, end], "agent": summarize(agent), "human": summarize(human)}
json.dump({"summary": out, "rows": agent + human}, open("pr_review_lag.json", "w"), ensure_ascii=False)
print(json.dumps(out, ensure_ascii=False, indent=2))書き方で意識したのは三つです。一つ目は、検索の結果を窓ごとにファイルへ残していること。検索 API は毎分 30 回までで、二次レート制限の 403 に当たると数十秒待たされます。途中で止まっても、再実行すれば続きから動きます。二つ目は、PR の番号を先に固定してから詳細を引くこと。実行のたびに標本が変わると、数字を比べられません。三つ目は、反応が一度も無かった PR を None のまま残していることで、これを 0 時間として混ぜると中央値が下がり、見かけ上「速い」ことになります。
実行は次のとおりです。
export GITHUB_TOKEN=YOUR_API_KEY # 読み取りだけなので、公開リポジトリ向けの最小権限で十分です
python3 pr_review_lag.py dotnet/runtime 2026-07-01 2026-08-31 123私の環境では、標本の固定に数分、PR の詳細取得にもう数分かかりました——検索の待ちが大半です。出力の要約(抜粋)は次のようになります。
{
"agent": { "n": 123, "merged_pct": 72.4, "open_pct": 4.1, "no_human_reaction_pct": 13.0,
"lag_median_h": 1.79, "lag_p90_h": 49.57, "resolve_median_h": 31.15,
"lines_median": 46, "reactions_median": 4 },
"human": { "n": 123, "merged_pct": 72.4, "open_pct": 9.8, "no_human_reaction_pct": 13.8,
"lag_median_h": 5.76, "lag_p90_h": 71.41, "resolve_median_h": 45.09,
"lines_median": 84, "reactions_median": 2 }
}