◉ANTIGRAVITY LABEN
記事一覧/Agents & Manager
◈ Agents & Manager/2026-10-14中級

エージェントの PR は人の PR より待たされると思っていましたが、公開リポジトリの 246 件を数えると逆でした

dotnet/runtime の 2026 年 7〜8 月の PR 246 件で、エージェントが開いた PR の「最初の人の反応」までの時間を人の PR と比べました。計測スクリプトと、個人開発向けの滞留チェックまで載せています。

GitHubCopilot coding agentコードレビュー10PR個人開発104Antigravity380

✦ プレミアム記事

エージェントに 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 }
}
✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦自分のリポジトリで、エージェントの PR を「作者以外の人の最初の反応」までの時間で数え、レビュー待ちが本当に溜まっているのかを数字で確かめられるようになります
✦誰にも触られなかったエージェントの PR がどうなりやすいかを知り、放置して溜める前に、閉じるか読むかを本数で判断できるようになります
✦大きな差分のまま依頼を出して通らない PR を量産してしまう前に、100 行と 500 行の境目を自分の履歴で確かめる手順を、短い Python だけで再現できるようになります
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

◈ Agents & Manager2026-10-14
位置で読む API 応答を変えるとき、出荷済みの旧版を起動時に落とさないための門番を置きました
壁紙アプリのように、サーバーの一覧応答を「何番目の項目か」で読むクライアントは、出荷済みの旧版が長く残ります。項目の削除や並び替えで旧版を起動時に落とさないよう、末尾追加だけを通す検査と、出荷済みの版ディレクトリを凍結する台帳の作り方を、動かした出力つきで書き残します。
◈ Agents & Manager2026-04-27
Antigravity を個人開発の「夜のもう1人」として迎え入れる運用モデル
Antigravity のエージェントを、個人開発者にとっての「夜のもう1人」として運用するための設計指針。タスクの渡し方・境界線の引き方・朝の3点確認をスクリプトにする方法まで、実際に運用して整えてきた手順を書き残します。
◈ Agents & Manager2026-04-27
Antigravity エージェントを商品化して売る完全ロードマップ — 個人開発者がAgentKit 2.0で月収を作る90日設計図
Antigravity の AgentKit 2.0 で作ったAIエージェントを、個人開発者が販売可能な『商品』に育てる90日のロードマップ。商品設計、本番実装、配布チャネル、Stripe統合、ローンチ準備、運用改善までをフェーズごとに具体化しました。