AIの下書きPRを122本数えた。99本はマージされ、13本は53日以上開いたままだった

AIの下書きPRを122本数えた。99本はマージされ、13本は53日以上開いたままだった

開いたままのPRで一番古いものは、8月26日の夜に作られていました。作られてから75日がたちました。中身はAIエージェントのセッションを引き継ぐ話で、CIは全部成功し、マージ可能の表示も出ています。壊れてはいません。ただ、誰も触っていません。

毎日AIに記事を書かせていると、作った本数は数えても、止まっている本数は数えません。そこで下書きPRを全部数えてみました。数えて分かったのは、止まっているPRの置かれ方にはっきりした偏りがあることと、その理由をまだデータからは説明できないことです。

まずPRを全部取る

対象は、タイトルが draft: で始まるPRです。朝稿と夜稿の下書きは、この接頭辞で作っています。取得は gh api で、PRの番号、状態、作成日時、マージ日時、閉じた日時、タイトルをタブ区切りにしました。

cd hibanas-net
gh api --paginate "repos/{owner}/{repo}/pulls?state=all&per_page=100" \
  --jq '.[] | [.number,.state,.created_at,(.merged_at//""),(.closed_at//""),.title] | @tsv' \
  > "$TMPDIR/prs.tsv"
wc -l < "$TMPDIR/prs.tsv"
200

200行で止まったので、最初は取得上限に当たったのかと疑いました。別に数えると、issueが28件、PR番号の最大が228です。200と28を足すと228になり、番号の欠けはありません。全件取れていました。

{owner} と {repo} は、カレントディレクトリのリポジトリに gh が置き換えます。このリポジトリは非公開なので、同じ数字は再現できません。自分のリポジトリで同じコマンドを動かしてください。

線は、数字を見てから引いた

数えるスクリプトは短くしました。全文は末尾に畳んであります。日付の境目を引数で渡し、前後で「作った数」「マージ数」「24時間以内にマージした数」「マージされずに閉じた数」「今も開いている数」を出します。

python3 "$TMPDIR/prq.py" "$TMPDIR/prs.tsv" 2026-09-17 'draft:'
2026-09-17の前: 作成33 マージ20(うち24時間以内18) マージなしclose0 open13
  openの経過日数: 最小53 最大75
2026-09-17の後: 作成89 マージ79(うち24時間以内56) マージなしclose6 open4
  openの経過日数: 最小0 最大2
期間作成マージ24時間以内マージなしで閉じた開いている
9月17日まで332018013
9月18日から89795664

9月17日という日付は、先に決めたものではありません。開いたままの13本を作成日で並べると、一番新しいのが9月17日でした。その日より後に作ったPRで古いものは、1本もありません。線は結果を見てから引いています。

数字の見え方は変わります。9月17日までは、13本が開いたままで、マージされた20本のうち18本は1日以内に片付いていました。9月18日からは、89本のうち古いまま残ったものがありません。いま開いている4本は、0〜2日目のものです。

その代わり、速さは落ちています。1日以内にマージされた割合は、9割から約7割に下がりました。23本は1日を超えています。下書きPR全体で最も遅かったマージは、作成から223時間、約9日でした。遅くなっても、止まってはいません。

題材のせいだと思ったが、外れた

13本のタイトルを見ると、ほぼHermes Agentの記事です。AIエージェントの運用の話が、読まれにくい、あるいは判断しにくくて残ったのだろうと、最初は思いました。

タイトルにHermes Agent、Computer Use、Beadsが入るものを1つの系統として数え直しました。9月17日までの33本のうち、30本がこの系統です。13本の放置はすべてここに入ります。

ここで題材説明は崩れました。9月18日から作った同じ系統の5本は、5本ともマージされています。それに、同じ週のなかで、扱いが割れていました。9月9日には、prompt-sizeを測る回が当日にマージされ、同じ日に作ったスキル書き込み承認の回は、今も開いています。

題材では分けられません。日付で分かれる、というところまでしか言えません。

closedを「消えた」と数えると間違える

マージされずに閉じたPRは6本ありました。最初は、この6本の記事は世に出なかったのだと読みかけました。

PRが取り込むファイルを見て、mainにその記事があるかを確かめました。コマンドと出力は畳んであります。

6本を照合したコマンドと出力
for n in 216 210 201 149 148 133; do
  f=$(gh api "repos/{owner}/{repo}/pulls/$n/files" \
    --jq '.[].filename | select(startswith("src/content/posts/"))' | head -1)
  if test -f "$f"; then
    echo "#$n $(basename $f) -> mainにあり: $(grep -m1 '^draft:' $f)"
  else
    echo "#$n $(basename $f) -> mainになし"
  fi
done
#216 faker-sample-csv-reserved-email-domain.md -> mainにあり: draft: false
#210 ai-image-16-9-1376x768-crop.md -> mainになし
#201 ai-product-reference-link-check-sample.md -> mainにあり: draft: false
#149 ai-pdf-product-hidden-links-attachments.md -> mainにあり: draft: false
#148 ai-mp3-product-hidden-metadata-check.md -> mainにあり: draft: false
#133 ai-markdown-fragment-link-gate.md -> mainになし

6本のうち4本は、同じファイル名の記事が公開済みで残っています。PRは閉じても、別のPRで同じ記事を出し直したということです。PRの状態だけを見て「閉じたから消えた」と数えると、4本を誤って未公開にしていました。

残る2本はmainにありません。#133は、同じ日の朝稿PRが先に作られていて、重複したほうを閉じたものです。#210は、10月11日に公開済みの記事と主題が重なるので、マージせずに閉じたものです。どちらも、閉じた理由がPRのコメントに残っていました。

重複を止める仕組みは、少なくともこの2回は働いています。

開いている13本の中身

では、13本は何で止まっているのか。マージ可否の表示を数えました。

for n in 45 48 50 54 58 70 82 85 86 91 93 99 101; do
  gh pr view $n --json mergeStateStatus -q .mergeStateStatus
done | sort | uniq -c
     10 CLEAN
      3 UNSTABLE

10本は、CIが全部成功したマージ可能の状態です。残る3本は、CIに取り消しか失敗が残っています。取り消しは2本、失敗は1本です。

つまり、10本は壊れていないのに動いていません。壊れていないPRには、見に行く理由がありません。緑のまま眠る10本が、数えてみて一番意外でした。

ここが副業で自動化を回すときの落とし穴です。作る側は毎日1本ずつ増やせます。確認する側が1日に見られる本数は、そう簡単には増えません。生成の本数を毎日眺めても、数字は右肩上がりに見えるだけです。見るべきは、作った本数と、片付いた本数の差でした。この差は、数えるまで1度も目に入りませんでした。

ゼロ件を成功にしない

集計スクリプトは、最初の版だと、タイトルの接頭辞を打ち間違えても終了コード0で「作成0」を返しました。0件の集計は、正しい0件なのか、取得か指定の失敗なのかが見分けられません。

最小の悪い入力を3つ流して、終了コードが1になるように直しました。

: > "$TMPDIR/empty.tsv"
python3 "$TMPDIR/prq.py" "$TMPDIR/empty.tsv" 2026-09-17 'draft:'; echo "exit=$?"
python3 "$TMPDIR/prq.py" "$TMPDIR/prs.tsv" 2026-09-17 'zzz:'; echo "exit=$?"
printf '228\topen\t2026-11-09T14:12:10Z\tdraft: x\n' > "$TMPDIR/short.tsv"
python3 "$TMPDIR/prq.py" "$TMPDIR/short.tsv" 2026-09-17 'draft:'; echo "exit=$?"
PRが0件です。取得に失敗した可能性があります
exit=1
タイトルが「zzz:」で始まるPRが0件です。接頭辞を確かめてください
exit=1
列数が6ではありません: number,state,created,merged,closed,title の順で渡してください
exit=1

空のファイル、接頭辞違い、列が足りない行。3つとも止まります。

集計スクリプト prq.py の全文
import csv, sys
from datetime import datetime, timezone

path, cutoff, prefix = sys.argv[1], sys.argv[2], sys.argv[3]
rows = list(csv.reader(open(path, encoding="utf-8"), delimiter="\t"))
if not rows:
    sys.exit("PRが0件です。取得に失敗した可能性があります")
if any(len(r) != 6 for r in rows):
    sys.exit("列数が6ではありません: number,state,created,merged,closed,title の順で渡してください")

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None

now = datetime.now(timezone.utc)
target = [r for r in rows if r[5].startswith(prefix)]
if not target:
    sys.exit(f"タイトルが「{prefix}」で始まるPRが0件です。接頭辞を確かめてください")
for label, pick in (("前", lambda r: r[2][:10] <= cutoff), ("後", lambda r: r[2][:10] > cutoff)):
    part = [r for r in target if pick(r)]
    merged = [r for r in part if r[3]]
    closed = [r for r in part if r[1] == "closed" and not r[3]]
    opened = [r for r in part if r[1] == "open"]
    within_day = sum(1 for r in merged if (ts(r[3]) - ts(r[2])).total_seconds() <= 86400)
    print(f"{cutoff}の{label}: 作成{len(part)} マージ{len(merged)}(うち24時間以内{within_day}) "
          f"マージなしclose{len(closed)} open{len(opened)}")
    if opened:
        ages = sorted((now - ts(r[2])).days for r in opened)
        print(f"  openの経過日数: 最小{ages[0]} 最大{ages[-1]}")

まだ分からないこと

9月17日の前後で何が変わったのかは、データからは読めません。PRの作り方が変わったのか、見る人の手順が変わったのか。13本を意図して残しているのかどうかも、このデータだけでは分かりません。理由を推測で書くことはしません。

分かったのは、数えないと見えなかったことです。生成の本数は毎日増えていきますが、レビューは同じ速さでは増えません。止まったPRは、エラーを出さないので、通知にも出てきません。

次の候補は、開いてから14日を超えたPRを週に一度だけ一覧にする小さな報告です。14日という数字は、いまのデータでは仮置きです。マージされた99本のうち、1日以内が74本、最も遅い1本が約9日。これを見る限り、14日を超えて動くPRは、通常の流れの外にいます。

出典

この記事をシェア