diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index b9e1fc6fa..ac26f8495 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -167,14 +167,20 @@ python3 release-steps.py record --version <版> --prs ... [--plugin < ### まとめのプランの記録の PR と `release-verify` まとめのプラン(`supervise_lib/sprint.py` の `close_plan`)は、記録の PR の参照を経路から決め(`supervise_lib/sprint_routes.py` の -`record_pr_ref`)、記録の PR が `{queue_pr:…}` で決まる経路ではマージの後・`close` の前に work ステップ `release-verify`(`/ndf:release-verification`)を置く。 +`record_pr_ref`)、記録の PR の参照が `0` でない経路ではマージの後・`close` の前に work ステップ `release-verify`(`/ndf:release-verification`)を置く。 | 経路 | `close` の `--record-pr` | `release-verify` | | --- | --- | --- | | 雛形(`release.form`) | `{queue_pr:release-prod}` | 置く | | 昇格(`route_waves` が昇格のプランを置き、開発版のチャネルが手で届ける本番でないとき) | `{queue_pr:promote}` | 置く | | 経路がすべて届けない経路(none) | `0`(本番の記録なし。条件を見ずに閉じる) | 置かない | -| それ以外(merge・manual・検証の環境だけ・手動反映の本番系の昇格) | `unknown`(記録の置き場が分からない。課題を理由つきの `kept_open` で開いたまま残し、ボードも書かない) | 置かない | +| それ以外(merge・manual・検証の環境だけ・手動反映の本番系の昇格) | `{queue_record}`(まとめを流し始める時点に queue が GitHub の参照から記録の PR を探して埋める。#2230) | 置く | +| 開発ループのまとめ(`new close --pace auto`)で、経路がすべて届けない経路でないとき | 経路にかかわらず `{queue_record}`(承認ゲート 2 の後のまとめは本番のプランと別の queue にあり、`{queue_pr:…}` を解けない) | 置く | + +`{queue_record}` は、まとめの課題を閉じる PR とそれを参照する PR のリリース記録のうち、段階が本番か配布なしで `スプリント:` が +その閉じる PR を含む記録を置いた PR の番号で埋まる(`sprint_close_lib/record_find.py`)。見つからない・2 つ以上のスプリントにある・ +GitHub を読めなければ、まとめを流さずに止まる(queue の結果は `stopped`、開発ループの周回は失敗で締まる)。まとめのプランは +`sprint-close.py --record-pr unknown` を組まず、`unknown` を渡すのは手で打つ入口と #2230 より前に組んだプランだけである。 `release-verify` は、記録の PR にリリース後テストの記録をコメントで置き、課題は閉じない。実行時に番号が `0` になったとき (最終の検査で変更が無く、まとめの queue の本番が飛ばされたとき)は何もせずに終える。 @@ -229,10 +235,14 @@ stateDiagram-v2 本番のプランは開発版と同じ `sprint_waves.plan_sprint_release` に `channel="prod"` と `version=prod_version(--version)`(接尾辞を外した版。 `10.18.0-dev.1` → `10.18.0`)を渡して組む。worktree とブランチは `release/v<正式版>`、出す PR は `{queue_pr:check}` である。 +`--version` を省いたときは `version` が空で、worktree・ブランチ・承認資料のパスは版の差し込み(`{release_version}`・`{release_base}`)で書き、 +プランの「版」の欄の `系列` に開発版のプランの名前 `release` を入れる。キューが本番のプランを流す直前に、開発版のプランの版の記録(`version.json`)の +`base` で埋める(#2164)。 雛形(`release_templates.plan_release`)が組んだステップを目録の側で書き換えず、`new release --version <正式版> --prs {queue_pr:check} --channel prod` と同じ出力に `課題` が入る(スプリントの工程は雛形に対して順応者の関係にある)。 -承認ゲート 2 の文は、開発版の配布が書いた承認資料(元のリポジトリの下の `issues/approval-<プラグイン>-v<正式版>.md` の絶対パス)を示して +承認ゲート 2 の文は、開発版の配布が書いた承認資料(元のリポジトリの下の `issues/approval-<プラグイン>-v<正式版>.md` の絶対パス。版を明示しなかったときは +`v` と書き、`` が開発版のプラン `release` の状態ディレクトリの `version.json` の `base` であることを添える)を示して 本番への配布の承認を取ることを書く。`--state` を渡さないときは、承認されたら `release-steps.py approve --approval <承認資料> --approved-sha --by user` で承認の記録を書いてから「本番」のステージの `command` を打つこと(差し戻されたら打たない)を書く。`--state` を渡したときは、利用者が答えたら `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved|rejected` を打ち、承認されたら「本番」のステージの `command` を打つことと、 @@ -247,7 +257,7 @@ stateDiagram-v2 | --- | --- | --- | | N1 | 「本番」のステージは `then_of` を持たず、どの queue の `--then` にも入らない(承認ゲート 2 は利用者が答える。連結すると承認を待たずに本番が流れる) | テストで落とす | | N2 | 並びは「配布」→「`関門 2`」→「本番」で、間に他のステージが入らない | テストで落とす | -| N3 | 本番のプランの版は `--version` から接尾辞を外したもの、チャネルは `prod`、出す PR は `{queue_pr:check}` | テストで落とす | +| N3 | 本番のプランの版は `--version` から接尾辞を外したもの(`--version` を省けば版の差し込みで、開発版の版の記録の `base` で埋まる)、チャネルは `prod`、出す PR は `{queue_pr:check}` | テストで落とす | | N4 | `--state` を渡しても、本番のプランは MVV 判定のステップ(`mvv`・`note`・`handoff`)を持たない(`advise` は `channel` が `dev` のときだけ渡す) | テストで落とす | | N5 | 「`関門 2`」を置くとき、承認ゲート 2 の文はその `gate` の 1 か所にだけあり、「配布」の `note` に重ねない。ほかの経路(N6 と `route_waves`)では「配布」か経路のステージの `note` が正本である | テストで落とす | | N6 | 本番のブランチが決まっていないときは「`関門 2`」と「本番」を置かず、「配布」で終える(`--state` なら `note` に `normal_gate_2` の文)。`next` に置かなかった理由を 1 文足す(`no_prod_stage`) | テストで落とす | diff --git a/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md b/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md index 34fafe161..ce5e70205 100644 --- a/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md +++ b/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md @@ -4,10 +4,12 @@ スプリントの機械の判定が、記録の無い状態や「確かめる中身が無い」行を取り違えないようにする決まりを 2 つ置く。 -- **記録の置き場が分からないまとめの `close` は、課題を閉じない。** リリースの経路が雛形(`release.form`)でも昇格(promote)でも - なく、届ける経路を 1 つでも含むとき、まとめのプランは `sprint-close.py --record-pr unknown` を打つ(`new close --record-pr <番号>` - で組んだときはその番号を打つ。#2089)。`sprint-close.py` は - 配布の記録を読まず、どの課題も閉じず、ボードも書かずに、`--issues` の課題を理由つきの `kept_open` で返す(終了コード 0) +- **記録の置き場が分からない `close` は、課題を閉じない。** `sprint-close.py --record-pr unknown` は + 配布の記録を読まず、どの課題も閉じず、ボードも書かずに、`--issues` の課題を理由つきの `kept_open` で返す(終了コード 0)。 + #2230 から、まとめのプランは `unknown` を組まない。リリースの経路が雛形(`release.form`)でも昇格(promote)でもなく、届ける経路を + 1 つでも含むとき(と開発ループのまとめ)は `--record-pr {queue_record}` を組み、まとめを流し始める時点に queue が記録の PR を探して埋める + ([ndf-production-release-record.md](ndf-production-release-record.md) の「まとめのプランの記録の PR と `release-verify`」)。 + `unknown` を渡すのは手で打つ入口と、#2230 より前に組んだまとめのプランだけである - **「手動確認は無い」と書いた行は、手動確認の行として数えない。** 要求の「検証手段」の表で 2 列目が「無し」の形の行は、 PR 本文の `## 手動確認` の節に載らず、merge-gate も承認ゲート(終了コード 10)で止まらない @@ -51,7 +53,7 @@ | # | 条件 | 破れたときの扱い | | --- | --- | --- | | I1 | まとめのプランの記録の参照が `0`(`RECORD_NONE`)になるのは、リリースの経路がすべて届けない経路(`STAGE_NONE`)のときだけである | テストが落とす | -| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | +| I2 | 記録の参照が `0` のとき(`has_record` が偽。#2230 からは `0` のときだけ偽)、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | | I3 | `--record-pr unknown` の実行は、どの課題も閉じず、ボードも書かず、課題の本文も読まない(gh へ書き込まない) | テストが落とす | | I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | | I5 | 要求の 2 列目が「無し」の形の行は `ManualRow` にならない。それ以外の空でない 2 列目は手動確認の行になる | テストが落とす | @@ -66,10 +68,11 @@ | --- | --- | | 雛形の経路がある | `{queue_pr:release-prod}` | | 昇格の経路があり、開発版のチャネルが手動反映の本番系(`delivery.MANUAL_PRODUCTION`)でない | `{queue_pr:promote}` | -| 経路がすべて `STAGE_NONE`(`delivery` が `[]` を含む) | `0`(`RECORD_NONE`) | -| それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格) | `unknown`(`RECORD_UNKNOWN`) | +| それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格) | `{queue_record}`(`RECORD_FOUND`。#2230) | -語 `0` と `unknown` と `has_record` は `supervise_lib/sprint_routes.py` に置き、`close_plan` はそれを呼ぶだけにする。 +#2230 から、最初に経路がすべて `STAGE_NONE`(`delivery` が `[]` を含む)かを見て `0`(`RECORD_NONE`)を返し、次に開発ループのまとめ +(`--pace auto`)なら `{queue_record}` を返し、その後に上の表を上から見る。`unknown`(`RECORD_UNKNOWN`)は `sprint_routes.py` から消えた。 +語 `0` と `{queue_record}` と `has_record` は `supervise_lib/sprint_routes.py` に置き、`close_plan` はそれを呼ぶだけにする。 #### `sprint-close.py --record-pr` @@ -105,17 +108,19 @@ ```mermaid flowchart TD - A["record_pr_ref"] --> B{"雛形の経路がある"} + A["record_pr_ref"] --> D{"経路がすべて STAGE_NONE"} + D -- はい --> Z["0"] + D -- いいえ --> L{"開発ループのまとめ(--pace auto)"} + L -- はい --> U["{queue_record}"] + L -- いいえ --> B{"雛形の経路がある"} B -- はい --> T["{queue_pr:release-prod}"] B -- いいえ --> C{"昇格の経路があり、手動反映の本番系でない"} C -- はい --> P["{queue_pr:promote}"] - C -- いいえ --> D{"経路がすべて STAGE_NONE"} - D -- はい --> Z["0"] - D -- いいえ --> U["unknown"] + C -- いいえ --> U T --> V["merge → release-verify → retro → close"] P --> V + U --> V Z --> W["merge → retro → close(release-verify なし)"] - U --> W ``` `sprint-close.py` の中では、`cmd_close` が `--issues` の有無を確かめ、`_read_sprint_record` が `0` と `unknown` で記録を読まずに @@ -130,7 +135,7 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 決定 | 理由 | 採らなかった案 | | --- | --- | --- | | `--record-pr` に語 `unknown` を足し、`0` の意味は変えない | 語ならプランの `cmd` と結果を読む人がそのまま意味を取れる。`0` を手で打つ既存の手順の意味が黙って変わらない | 負の数(`-1`)で表す。`0` を「置き場を知らない」に変え、配布なしに別の値を足す | -| 検証の環境だけ(本番の行の無い)経路・手動反映の本番系の昇格も `unknown` にする | 開いたまま残った課題は人が 1 回の操作で閉じられるが、条件を見ずに閉じた課題は確かめていないことが結果から読めない | 検証の環境だけの経路を配布なしとして `0` で閉じる | +| 検証の環境だけ(本番の行の無い)経路・手動反映の本番系の昇格も `unknown` にする(#2230 で `{queue_record}` に置き換わった。閉じる条件を見ずに閉じない方針は変わらない) | 開いたまま残った課題は人が 1 回の操作で閉じられるが、条件を見ずに閉じた課題は確かめていないことが結果から読めない | 検証の環境だけの経路を配布なしとして `0` で閉じる | | 「無し」の判定を `rows_of` の 1 か所に置き、語の直後を括弧・句読点・空白・文末に限る | PR 本文の節も merge-gate の数え方も `rows_of` の出力から作られる。後続を限れば「無しの場合は〜」のような確認の文を落とさない | merge-gate で「無し」の行を数えない(PR 本文に未チェックの行が残り続ける)。要求の雛形の文を変える(雛形に「無ければ行を消す」が既にある) | ## データ・設定 @@ -157,7 +162,7 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 観点 | テスト | | --- | --- | -| merge・手で届ける・検証の環境だけの経路で、`close` の `cmd` が `--record-pr unknown` になり `release-verify` が無いこと(I1・I2) | `test_supervise_pace_verify.py` の `test_close_without_a_known_record_keeps_the_issues_open` | +| merge・手で届ける・検証の環境だけの経路で、`close` の `cmd` が `--record-pr {queue_record}` になり `unknown` を持たず、`release-verify` が同じ参照を持つこと(I1・I2。#2230) | `test_supervise_pace_verify.py` の `test_close_without_a_plan_record_resolves_it_when_run` | | `delivery: []` で `--record-pr 0` のまま変わらないこと(I1) | 同じファイルの `test_close_of_no_delivery_keeps_record_pr_zero` | | `--record-pr unknown --issues …` で全課題が `kept_open`・理由つき・終了コード 0、gh の書き込みが 0 回で、`summary` が先頭の文を持つこと(I3) | `test_sprint_close_issues.py` の `test_record_unknown_keeps_the_given_issues_open_with_a_reason` | | `unknown` に `--issues` が無ければ終了コード 2、それ以外の値・負の数は終了コード 3 | 同じファイルの `test_record_unknown_alone_returns_two`・`test_record_pr_rejects_other_values` | @@ -170,15 +175,13 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 項目 | 内容 | | --- | --- | -| 記録の PR を `close` へ渡す手段が無い | merge・手で届ける・検証の環境だけの経路の課題は、`unknown` で開いたまま残り、人が手で閉じる。受け渡しの手段は課題 #2044 が扱う | -| 手動反映の本番系の昇格はテストで縛っていない | 見本のリポジトリ(`make_repo`)は起点 develop・本番 main で、起点と本番が同じ形を組めない。`record_pr_ref` の分岐(`manual_prod`)は昇格を飛ばして `unknown` へ落ちる形で入っている | -| 語 `unknown` が 2 か所にある | `sprint-close.py` は `supervise_lib` を import しない独立のスクリプトで、`RECORD_UNKNOWN` を `sprint_routes.py` と別に持つ(コメントで指し合う) | +| 手動反映の本番系の昇格はテストで縛っていない | 見本のリポジトリ(`make_repo`)は起点 develop・本番 main で、起点と本番が同じ形を組めない。`record_pr_ref` の分岐(`manual_prod`)は昇格を飛ばして `{queue_record}` へ落ちる形で入っている | | 既に閉じた課題も `kept_open` で載る | `unknown` は課題の状態を読まずに返すため、closing keywords で GitHub が先に閉じた課題も `kept_open` に並ぶ | | 流し直さない PR 本文 | 既に作られた PR 本文の「無し」の未チェック行は、pr ステップを流し直さない限り残る | ## 関連リンク -- 課題: #1875(記録の置き場が分からない close)・#1675(「無し」の手動確認の行)。実装は PR #2051 +- 課題: #1875(記録の置き場が分からない close)・#1675(「無し」の手動確認の行)。実装は PR #2051。まとめのプランが `unknown` の代わりに `{queue_record}` を組む変更は #2230 - [ndf-production-release-record.md](ndf-production-release-record.md): 経路ごとの記録の PR と `release-verify` - [ndf-sprint-close-acceptance-criteria.md](ndf-sprint-close-acceptance-criteria.md): 閉じる条件 2 と受け入れ条件の印(`unknown` で本文を読まない条件 I8) - [ndf-sprint-close-referenced-issues.md](ndf-sprint-close-referenced-issues.md): 参照だけの課題(`unknown` ではスプリントの PR を読まない) diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 1c6044eb7..85047351e 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -202,9 +202,9 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | --- | | `--pace auto` | まとめのステージだけを組む(上の表) | 今の形(`fast` のスプリントの終わり)。出力は変わらない | | `--carry N...` | 棚卸しのステップの指示へ前の周回の持ち越しを `--add` で渡す | 指示は今のまま | -| `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 配布の経路から導く印(`{queue_pr:release-prod}` など)を残し、queue が前のステージの本番のプランの Pull Request で埋める | +| `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 経路がすべて届けない経路なら `0`、それ以外は経路にかかわらず `{queue_record}` を残し、queue がまとめを流し始める時点に GitHub の参照からリリース記録の PR を探して埋める(#2230) | -周回の中では `--record-pr` を渡さない。渡すのは承認ゲート 2 の承認の後にまとめを組み直すとき(`stop.values.pr`)だけである。 +ループは `--record-pr` を渡さない。承認ゲート 2 の承認の後にまとめを組み直すときも渡さない(`stop.values.pr` は検査のスプリント PR で、記録の PR ではない。#2230)。 ### 承認ゲートで止まったとき(`gate`、`lap --done`) @@ -240,7 +240,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 失敗の鍵は、スプリント状態の計画の行のうち結果が `まだ`・`流さなかった`・`完了`・`関門` でない最初の行の `{issues, kind, step}` である。理由の文は含めない。`loop lap --failed` で締めて該当の行が無ければ `{issues: 選んだ課題, kind: 周回, step: conductor}` にする。周回は、`--failed`・done に完了でも関門でも流さなかったでもない -結果がある・失敗の鍵がある、のどれかで `失敗`、ほかは `完了` で締まる。 +結果がある・done に差し込みを埋められずに流さなかった計画(`unfilled`。記録の PR が見つからないまとめなど。#2230)がある・失敗の鍵がある、のどれかで `失敗`、ほかは `完了` で締まる。 `loop start` の再開は止まった理由ごとに次のとおりである。 @@ -249,7 +249,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `user_stop` | ファイルを消して解ける | | 予算 | 再開の引数で上書きした予算で判定し直す。まだ予算以上なら 3 | | `same_failure` | 再開の起動そのものを解けた印とし、失敗の数を数え直す | -| `gate2_returned` | 答えが無ければ 3。差し戻しなら周回を `人へ戻した` で締める。承認なら `next` に、止まった計画を経路に合うステップ(雛形の本番は `--from bump`、昇格は `--from promote-approved`、マージで止まった計画は `--from merge-approved`)から `supervise.py run` で続け → 同じ queue の残りのステージ → `--record-pr ` で組み直したまとめ → `wait` → `loop lap` の 1 行を載せる。続けるステップが無い計画は、手で行う本番を先に示す | +| `gate2_returned` | 答えが無ければ 3。差し戻しなら周回を `人へ戻した` で締める。承認なら `next` に、止まった計画を経路に合うステップ(雛形の本番は `--from bump`、昇格は `--from promote-approved`、マージで止まった計画は `--from merge-approved`)から `supervise.py run` で続け → 同じ queue の残りのステージ → `--record-pr` なしで組み直したまとめ(`{queue_record}` を持つ)→ `wait` → `loop lap` の 1 行を載せる。続けるステップが無い計画は、手で行う本番を先に示す | | `refused`(続きの queue) | 人の判断待ちへ移した課題を戻さず、残した課題で続きを組み直す。組めなければ 3 | | `refused`(ほか) | `next` に同じ周回での `loop sprint` のやり直しを載せる(モードと版は conductor が決め直す) | | `mvv_changed` | 解けない。新しい名前でループを始め直すよう示す(開始時の値を今の値へ置き換えない) | diff --git a/plugins/ndf/scripts/lib/dist_record.py b/plugins/ndf/scripts/lib/dist_record.py index fd1777f67..d6fd156cb 100644 --- a/plugins/ndf/scripts/lib/dist_record.py +++ b/plugins/ndf/scripts/lib/dist_record.py @@ -1,7 +1,7 @@ """リリース記録(`## 配布の記録`)の形を 1 か所に持つ(#1273 の決定 3)。 形の正本は `skills/progress-tracking/SKILL.md` の「配布の記録」の表である。書く側(`release-steps.py record`)は -`format_record` で組み、読む側(`sprint-close.py`)は `parse_record` で読む。見出しと行の名前をほかのモジュールに写さない。 +`format_record` で組み、読む側(`sprint-close.py`・`sprint_close_lib/record_find.py`)は `parse_record` で読む。見出しと行の名前をほかのモジュールに写さない。 使う側は `deps.require("md")` を先に呼ぶ。 """ @@ -38,6 +38,12 @@ def format_record(prev: str | None, new: str, prs, *, stage_note: str, version_n ) +def released(stage) -> bool: + """段階が本番か配布なしか(課題を閉じてよい段階。`sprint-close.py` と `sprint_close_lib/record_find.py` が使う)。""" + stage = stage or "" + return stage.startswith("本番") or stage.startswith("配布なし") + + def _strip_note(text): """版の文字列から全角の括弧の注記を落とす。""" return re.sub(r"\s*(.*$", "", text).strip() diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py new file mode 100644 index 000000000..632db0106 --- /dev/null +++ b/plugins/ndf/scripts/lib/release_version.py @@ -0,0 +1,224 @@ +"""配布の版の判定(#2164)。次に空いている開発版を決め、ある版がもう出ているかを判定する。 + +キューの版の差し込み(`supervise_lib/queue_fill.py` の `fill_release_version`)と `release-steps.py bump` が呼ぶ 1 か所で、 +開発版と本番・キューと `bump` で版の規則を分けない(#2164 の決定 4)。ファイルを書かない。 + +- `observe(repo, plugin, base, production)`: `git fetch -q origin --tags` の後に、origin のベースブランチと本番のブランチの + `plugin.json` の版と、`--v<版>` の正式版のタグを読む。読めなかったものは `unreadable` に名前で入れる +- `decide_dev(obs, level)`: 次に空いている開発版 +- `taken(obs, version)`: その版がもう出ているか(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある) +- `own_released(repo, branch, base)`: `branch` → `base` のマージ済みの PR があるか(照会できなければ None) + +版の比較は `versions`(semver)を使う。呼ぶ側は先に `deps.require("versions")` を済ませておく。 +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import versions + +LEVELS = ("patch", "minor", "major") + + +@dataclass +class Observed: + plugin: str + base_ref: str # origin/<ベースブランチ> + base_version: str | None + production_ref: str # origin/<本番のブランチ>。本番のブランチを宣言していなければ空 + production_version: str | None + latest_tag: str | None # 最新の正式版のタグの版(タグが 1 つも無ければ None) + tags: list[str] = field(default_factory=list) # 正式版のタグの版(`--v` を外したもの) + unreadable: list[str] = field(default_factory=list) + + def as_dict(self) -> dict: + """版の記録の `observed`(読んだ値)。タグの一覧と読めなかったものは載せない。""" + d = asdict(self) + for k in ("tags", "unreadable", "plugin"): + d.pop(k) + return d + + +@dataclass +class Decision: + version: str | None # 決めた・確かめた版(決められなければ None) + base: str | None # version の接尾辞を外した版 + how: str # decided / explicit / series + existing: str | None = None # すでに出ている版(ベースブランチの版かタグ) + next_free: str | None = None # 次に空いている版(すでに出ているときだけ) + observed: dict = field(default_factory=dict) + unreadable: list[str] = field(default_factory=list) + + @property + def is_taken(self) -> bool: + return self.existing is not None + + +def plugin_json_rel(repo: str | Path, ref: str, plugin: str) -> str | None: + """`ref` の木で、名前が `plugin` のディレクトリの `.claude-plugin/plugin.json` の位置。無い・2 つ以上なら None。""" + p = _git_run(repo, "ls-tree", "-r", "--name-only", ref) + if p.returncode: + return None + tail = f"{plugin}/.claude-plugin/plugin.json" + hits = [f for f in p.stdout.splitlines() if f == tail or f.endswith(f"/{tail}")] + return hits[0] if len(hits) == 1 else None + + +def _git_run(repo: str | Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) + + +def _version_at(repo: str | Path, ref: str, rel: str) -> str | None: + p = _git_run(repo, "show", f"{ref}:{rel}") + if p.returncode: + return None + try: + v = json.loads(p.stdout)["version"] + except (ValueError, KeyError, TypeError): + return None + return v if isinstance(v, str) and versions.parse_version(v) else None + + +def observe(repo: str | Path, plugin: str, base: str, production: str | None, fetch: bool = True, rel: str | None = None) -> Observed: + """origin を取り直してから、ベースブランチ・本番のブランチ・タグを読む。本番のブランチが無ければ読まない(読めないに数えない)。 + タグが 1 つも無いことは読めないに数えない。取り直しに落ちたら、読んだ値が古いかもしれないため `origin` を読めないに数える。 + `rel` は `plugin.json` のリポジトリの根からの位置で、省けばベースブランチの木から探す。""" + unreadable: list[str] = [] + if fetch and _git_run(repo, "fetch", "-q", "origin", "--tags").returncode: + unreadable.append("origin") + base_ref = f"origin/{base}" + rel = rel or plugin_json_rel(repo, base_ref, plugin) + base_version = _version_at(repo, base_ref, rel) if rel else None + if base_version is None: + unreadable.append(base_ref) + prod_ref = f"origin/{production}" if production else "" + prod_version = _version_at(repo, prod_ref, rel) if prod_ref and rel else None + if prod_ref and prod_version is None: + unreadable.append(prod_ref) + p = _git_run(repo, "tag", "--list", f"{plugin}--v*") + tags: list[str] = [] + if p.returncode: + unreadable.append("tags") + else: + head = f"{plugin}--v" + for t in p.stdout.split(): + v = t[len(head) :] + ver = versions.parse_version(v) + if ver is not None and not ver.prerelease: + tags.append(v) + latest = max(tags, key=versions.version_order) if tags else None + return Observed(plugin, base_ref, base_version, prod_ref, prod_version, latest, tags, unreadable) + + +def _max_base(vs: list[str | None]) -> str: + bases = [versions.release_base(v) for v in vs if v] + return max(bases, key=versions.version_order) + + +def decide_dev(obs: Observed, level: str = "patch") -> Decision: + """次に空いている開発版。ベースブランチの版 B が `X-dev.N` で X のタグが無ければ `X-dev.(N+1)`、そうでなければ + 最新のタグ T・本番のブランチ P・B の基底の版のうち最も大きいものを上げ幅で上げた Y の `Y-dev.1`。読めないものがあれば決めない。""" + if obs.unreadable: + return Decision(None, None, "decided", observed=obs.as_dict(), unreadable=list(obs.unreadable)) + b = obs.base_version or "" + if versions.dev_number(b) is not None and versions.release_base(b) not in obs.tags: + v = versions.next_dev(b) + else: + v = f"{versions.next_version(_max_base([obs.latest_tag, obs.production_version, b]), level)}-dev.1" + return Decision(v, versions.release_base(v), "decided", observed=obs.as_dict()) + + +def taken(obs: Observed, version: str, how: str = "explicit", level: str = "patch") -> Decision: + """`version` がもう出ているか。開発版(接尾辞つき)はベースブランチの版が同じか大きいとき、正式版は同じ版のタグがあるとき、 + `existing` に既に出ている版、`next_free` に次に空いている版を入れる。読めないものがあれば判定しない(`unreadable`)。""" + d = Decision(version, versions.release_base(version), how, observed=obs.as_dict(), unreadable=list(obs.unreadable)) + if obs.unreadable: + return d + if versions.parse_version(version).prerelease: + if obs.base_version and versions.compare_versions(obs.base_version, version) >= 0: + d.existing = obs.base_version + d.next_free = decide_dev(obs, level).version + return d + if version in obs.tags: + d.existing = f"{obs.plugin}--v{version}" + nxt = version + while nxt in obs.tags: + nxt = versions.next_patch(nxt) + d.next_free = nxt + return d + + +def own_released(repo: str | Path, branch: str, base: str) -> bool | None: + """`branch` → `base` のマージ済みの PR があるか(自分の配布か)。1 回だけ照会し、照会できなければ None。""" + import gh_call + + r = gh_call.gh(["pr", "list", "--head", branch, "--base", base, "--state", "merged", "--json", "number", "--limit", "1"], cwd=str(repo)) + if r.returncode: + return None + try: + return bool(json.loads(r.stdout or "[]")) + except ValueError: + return None + + +@dataclass +class BumpStop: + """`bump` を止める理由(要約・数値・終了コード)。""" + + summary: str + metrics: dict + code: int + + +def bump_check(repo: str | Path, plugin: str, old: str, new: str, base: str, rel: str) -> tuple[str | None, BumpStop | None]: + """`bump` が書き換える前の判定(#2164 の I7)。打ち直しなら (origin のベースブランチの版, None)、書き換えてよければ (None, None)。 + 新しい版がベースブランチの版以下か、正式版でそのタグがあれば終了コード 3 で止める(版を戻さない)。 + 版の形が違う・ベースブランチの版を読めない・自分の配布の照会ができなければ、推定で進めずに終了コード 2 で止める。 + 作業場所がすでに新しい版で、ベースブランチの版が等しいときだけ `release/v<新しい版>` → ベースブランチのマージ済みの + PR を 1 回照会し、あれば自分の配布の後の流し直しとして打ち直しにする。""" + if versions.parse_version(new) is None: + return None, BumpStop(f"版の形が違う: {new}(X.Y.Z / X.Y.Z-dev.N / X.Y.Z-rc.N)", {"plugin": plugin, "to": new}, 2) + obs = observe(repo, plugin, base, None, rel=rel) + metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} + if obs.unreadable: + why = f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない" + return None, BumpStop(why, {**metrics, "unreadable": obs.unreadable}, 2) + cmp = versions.compare_versions(obs.base_version, new) + if old == new: + rerun = _rerun(repo, new, base, obs, cmp, metrics) + if rerun is not None: + return rerun + d = taken(obs, new) + if cmp < 0 and not d.is_taken: + return None, None + return None, _taken_stop(plugin, new, obs, cmp, d, metrics) + + +def _rerun(repo: str | Path, new: str, base: str, obs: Observed, cmp: int, metrics: dict) -> tuple[str | None, BumpStop | None] | None: + """作業場所がすでに新しい版のときの打ち直しの判定。打ち直しか止めるなら (already, stop)、どちらでもなければ None。""" + if cmp < 0: + return obs.base_version, None + if cmp > 0: + return None + own = own_released(repo, f"release/v{new}", base) + if own is None: + why = f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない" + return None, BumpStop(why, {**metrics, "unreadable": ["pr"]}, 2) + return (obs.base_version, None) if own else None + + +def _taken_stop(plugin: str, new: str, obs: Observed, cmp: int, d: Decision, metrics: dict) -> BumpStop: + """新しい版がすでに出ているときに止める理由(既に出ている版と次に空いている版)。""" + existing = obs.base_version if cmp >= 0 else d.existing + if d.is_taken: + nxt = d.next_free + elif versions.parse_version(new).prerelease: + nxt = decide_dev(obs).version + else: + nxt = versions.next_patch(obs.base_version) + why = f"{plugin} の {new} はすでに出ている({existing})。版を戻さない(次に空いている版は {nxt})" + return BumpStop(why, {**metrics, "existing": existing, "next_free": nxt}, 3) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 501d3a961..d2fa224a3 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -12,8 +12,6 @@ from pathlib import Path from typing import Callable -import clock -import gate_rejections as gr import jsonio import project_mvv as pm import repo as repo_lib @@ -34,7 +32,6 @@ FAILED, RETURNED, GATE_1, - GATE_2, FINISHED, NOT_RUN, LoopError, @@ -61,14 +58,12 @@ loop_ok, select_cmd, wait_lap, + sprint_pipeline, refusal_order, build_close, refused_stop, copy_mvv_gate, read_done_items, - plan_info, - gate2_resume_step, - stages_after, continuation, CONTINUATION_STEP, mark_issue, @@ -79,6 +74,8 @@ after_close, ) +from sprint_loop_gate2 import gate2_answer, gate2_pipeline + # ---------------------------------------------------------------- start @@ -169,102 +166,100 @@ def start_new(env: Env, name: str, decl: dict, project, milestones: list[str], b return loop_ok(f"開発ループ {name} を始めた", rec, status="new", next_=select_cmd(name)) -def resume_stopped(env: Env, rec: dict, before: dict) -> dict: - """止まった理由ごとに解けたかを見る(I7)。解ければ stop を stops へ移す。""" - stop, name = rec["stop"], rec["name"] - cond, nxt = stop["condition"], select_cmd(name) - lap = current_lap(rec) - if cond == "mvv_changed": - return stopped_result( +def resume_record(env: Env, rec: dict) -> None: + """止まった記録を解く: stop を再開の時刻つきで stops へ移し、running に戻す。""" + rec["stops"].append({**rec["stop"], "resumed_at": env.now()}) + rec["stop"], rec["status"] = None, "running" + + +def _resume_mvv_changed(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + return ( + stopped_result( env, rec, "プロジェクト MVV が改訂されたため、このループは再開しない。新しい名前でループを始め直す(/ndf:development-workflow --loop <新しい名前>)", - ) - if cond == "user_stop": - (loop_dir(env, name) / STOP_FILE).unlink(missing_ok=True) - elif cond.startswith("budget_"): - unit = cond.removeprefix("budget_") - used = consumed(rec)[unit] - if used >= rec["budget"][unit]: - write_record(env, rec, before) - return stopped_result( + ), + nxt, + ) + + +def _resume_user_stop(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + (loop_dir(env, rec["name"]) / STOP_FILE).unlink(missing_ok=True) + return None, nxt + + +def _resume_budget(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + unit = stop["condition"].removeprefix("budget_") + used = consumed(rec)[unit] + if used >= rec["budget"][unit]: + write_record(env, rec, before) + return ( + stopped_result( env, rec, f"予算({unit})を超えたまま(消費 {used} / 予算 {rec['budget'][unit]})。--budget-{unit} で上書きして再開する" - ) - elif cond == "gate2_returned" and lap: - answer = gate2_answer(lap, stop.get("at")) - if answer is None: - return stopped_result(env, rec, "承認ゲート 2 の答え(承認か差し戻し)がまだ無い") - if answer == "rejected": - close_lap(env, rec, lap, RETURNED) - else: - nxt = gate2_pipeline(env, rec, lap, stop) - elif cond == "refused" and lap and (stop.get("values") or {}).get("step") == CONTINUATION_STEP: - # 承認ゲート 1 の後の続きを組めなかった: 判断待ちへ移した課題を戻さず、残した課題で続きを組み直す - moved = [w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]] - try: - queue, done = continuation(env, rec, lap, moved, lap.get("issues_kept") or []) - except (LoopError, OSError, ValueError, KeyError, StopIteration) as e: - write_record(env, rec, before) - return stopped_result(env, rec, f"承認ゲート 1 の後の続きをまだ組めない: {str(e)[:200]}") - nxt = f"{queue} && {wait_lap(rec, done)}" - elif cond == "refused" and lap: - nxt = f"python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {name} --mode <モード> --dev <開発版>(モードと版を決め直す)" - rec["stops"].append({**stop, "resumed_at": env.now()}) - rec["stop"], rec["status"] = None, "running" - write_record(env, rec, before) - update_handoff(env, rec, f"再開した({cond} が解けた)", resume_command(rec), nxt) - return loop_ok(f"開発ループ {name} を再開した({cond} が解けた)", rec, status="resumed", next_=nxt, resolved=cond) + ), + nxt, + ) + return None, nxt + + +def _resume_gate2(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + if not lap: + return None, nxt + answer = gate2_answer(lap, stop.get("at")) + if answer is None: + return stopped_result(env, rec, "承認ゲート 2 の答え(承認か差し戻し)がまだ無い"), nxt + if answer == "rejected": + close_lap(env, rec, lap, RETURNED) + return None, nxt + return None, gate2_pipeline(env, rec, lap, stop) + + +def _resume_refused(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + if not lap: + return None, nxt + if (stop.get("values") or {}).get("step") != CONTINUATION_STEP: + return ( + None, + f"python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {rec['name']} --mode <モード> --dev <開発版>(モードと版を決め直す)", + ) + # 承認ゲート 1 の後の続きを組めなかった: 判断待ちへ移した課題を戻さず、残した課題で続きを組み直す + moved = [w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]] + try: + queue, done = continuation(env, rec, lap, moved, lap.get("issues_kept") or []) + except (LoopError, OSError, ValueError, KeyError, StopIteration) as e: + write_record(env, rec, before) + return stopped_result(env, rec, f"承認ゲート 1 の後の続きをまだ組めない: {str(e)[:200]}"), nxt + return None, f"{queue} && {wait_lap(rec, done)}" -EPOCH = clock.parse("1970-01-01T00:00:00Z") # 時刻の無い答え(止まった時刻の無い古い記録のとき)の並びの値 +_RESUME_HANDLERS = { + "mvv_changed": _resume_mvv_changed, + "user_stop": _resume_user_stop, + "gate2_returned": _resume_gate2, + "refused": _resume_refused, +} -def gate2_answer(lap: dict, since: str | None = None) -> str | None: - """承認ゲート 2 の答え: 止まった時刻(since)より後の、周回の状態の利用者の承認と差し戻しの記録のうち最新のもの。 - approved / rejected、どちらも無ければ None。since より前の答え(同じ周回の前の関門への答え)は数えない。""" - after = clock.parse(since, naive="utc") if since else None +def _resume_handler(cond: str): + """止まった理由に対応する再開の手続き。budget_* は接頭辞で引く。無ければ None(そのまま解く)。""" + if cond.startswith("budget_"): + return _resume_budget + return _RESUME_HANDLERS.get(cond) - def fresh(at): - t = clock.parse(at, naive="utc") if at else None - if after is None: - return t or EPOCH - return t if t is not None and t >= after else None - state = jsonio.read(lap.get("state_path") or "", missing={}, broken={}, want=dict) if lap.get("state_path") else {} - answers = [ - (k, "approved") for g in state.get("gates", []) if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at"))) - ] - try: - rows = gr.read_rejections() - except jsonio.JsonReadError: - rows = [] - sp = str(Path(lap.get("state_path") or "").resolve()) if lap.get("state_path") else None - rows = [r for r in rows if gr.is_rejection(r)] # 承認の行(#2183)は状態ファイルの gates の方で読む - answers += [(k, "rejected") for r in rows if r.get("gate") == GATE_2 and r.get("sprint") == sp and (k := fresh(r.get("at")))] - return max(answers, key=lambda a: a[0])[1] if answers else None - - -def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: - """承認ゲート 2 の承認の後の続き: 止まった計画を経路に合う再開のステップから続け → 同じ queue の残りのステージと - まとめの queue → wait → loop lap の 1 行。続けるステップが無い計画(gate-2 のプラン)は手で行う本番を先に示す。""" - pr = (stop.get("values") or {}).get("pr") - close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap), record_pr=pr if isinstance(pr, int) else None) - done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" - plan = lap.get("gate2_plan") or lap.get("prod_plan") or "<本番のプラン>" - moved = {w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]} - rest = [ - [p for p in s if not (set(plan_info(p).get("課題") or []) and set(plan_info(p).get("課題") or []) <= moved)] - for s in stages_after(lap, plan) - ] - stages = [s for s in rest if s] + [close] - queue = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + " --max 3" - queue += "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}" - lap.setdefault("dones", []).append(str(done)) - step = gate2_resume_step(plan) - if step is None: - return f"手で行う本番のステージ({plan} の後)を終えてから: {queue} && {wait_lap(rec, done)}" - run = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(plan)} --from {step}" - return f"{run} && {queue} && {wait_lap(rec, done)}" +def resume_stopped(env: Env, rec: dict, before: dict) -> dict: + """止まった理由ごとに解けたかを見る(I7)。解ければ stop を stops へ移す。""" + stop, name = rec["stop"], rec["name"] + cond, nxt = stop["condition"], select_cmd(name) + handler = _resume_handler(cond) + if handler: + stopped, nxt = handler(env, rec, before, stop, current_lap(rec), nxt) + if stopped is not None: + return stopped + resume_record(env, rec) + write_record(env, rec, before) + update_handoff(env, rec, f"再開した({cond} が解けた)", resume_command(rec), nxt) + return loop_ok(f"開発ループ {name} を再開した({cond} が解けた)", rec, status="resumed", next_=nxt, resolved=cond) # ---------------------------------------------------------------- sprint @@ -332,24 +327,11 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra index = json.loads(manifest.read_text(encoding="utf-8"))["ステージ"] lap["manifest"] = str(manifest) lap["prod_plan"] = next((p for e in index if e.get("name") == "本番" for p in e.get("plans", [])), None) - first = next(e for e in index if e.get("command")) done = d / "done-1.json" + queue, pipeline = sprint_pipeline(index, close, done, rec) lap["dones"] = [str(done)] - hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う - if not hands: - queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" - pipeline = f"{queue} && {wait_lap(rec, done)}" - else: # 手で行うステージか別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない - queue = ( - f"python3 {shlex.quote(str(SUPERVISE))} queue " - + " ".join(map(shlex.quote, close)) - + f" --max 3 --done {shlex.quote(str(done))}" - ) - todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) - pipeline = f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" if rec.get("status") == "stopped": # refused からのやり直し - rec["stops"].append({**rec["stop"], "resumed_at": env.now()}) - rec["stop"], rec["status"] = None, "running" + resume_record(env, rec) write_record(env, rec, before) update_handoff(env, rec, f"周回 {lap['n']} のスプリントを組んだ。pipeline を流す", resume_command(rec), pipeline) return loop_ok( @@ -447,7 +429,8 @@ def cmd_lap(env: Env, name: str, failed: bool, done: str | None, deferred_issues if any(i.get("result") == "関門" for i in items): return handle_gate(env, rec, before, lap, items) state = jsonio.read(lap["state_path"], missing={}, broken={}, want=dict) if lap.get("state_path") else {} - bad = [i for i in items if i.get("result") not in FINISHED and i.get("result") != NOT_RUN] + # 差し込みを埋められずに流さなかった計画(unfilled。記録の PR が見つからないまとめなど)は失敗に数える(#2230 の決定 7) + bad = [i for i in items if i.get("unfilled") or (i.get("result") not in FINISHED and i.get("result") != NOT_RUN)] outcome = FAILED if failed or bad or failure_key(state) else DONE close_lap(env, rec, lap, outcome, state, deferred_issues) return after_close(env, rec, before, f"周回 {lap['n']} を {outcome} で締めた") diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 42073ae6c..e6438c52c 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -31,7 +31,6 @@ NOT_RUN, LoopError, Env, - prod_of, write_record, open_record, current_lap, @@ -63,10 +62,32 @@ def select_cmd(name: str) -> str: return f"python3 {shlex.quote(str(SPRINT_STATE))} loop select {name}" +QUEUE_MAX = 3 # まとめの queue の同時に流す計画の上限(supervise.py queue --max) + + +def queue_command(stages: list[list[str]], done: Path) -> str: + """まとめの queue の 1 行: 先頭のステージを --max で流し、残りのステージを --then でつなぎ、--done で終わる。""" + head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + f" --max {QUEUE_MAX}" + return head + "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}" + + def wait_lap(rec: dict, done: Path) -> str: return f"python3 {shlex.quote(str(SUPERVISE))} wait {shlex.quote(str(done))} && python3 {shlex.quote(str(SPRINT_STATE))} loop lap {rec['name']} --done {shlex.quote(str(done))}" +def sprint_pipeline(index: list[dict], close: list[str], done: Path, rec: dict) -> tuple[str, str]: + """manifest のステージ配列から queue と pipeline の文字列を組む。最初の command の後に手で行うステージか + 別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない。""" + first = next(e for e in index if e.get("command")) + hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う + if not hands: + queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" + return queue, f"{queue} && {wait_lap(rec, done)}" + queue = queue_command([close], done) + todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) + return queue, f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" + + def refusal_order(summary: str) -> dict: return {**step_result.result(TOOL, "stopped", summary, [], {"status": "refused"}), "exit": EXIT_REFUSED} @@ -74,20 +95,18 @@ def refusal_order(summary: str) -> dict: # ---------------------------------------------------------------- sprint -def build_close(env: Env, rec: dict, lap: dict, issues: list[int], record_pr=None, tag: str = "close") -> list[str]: - """まとめのステージ(new close --pace auto)を組み、プランのパスを返す。断れば LoopError。""" +def build_close(env: Env, rec: dict, lap: dict, issues: list[int], tag: str = "close") -> list[str]: + """まとめのステージ(new close --pace auto)を組み、プランのパスを返す。断れば LoopError。 + 記録の PR は渡さない(まとめのプランが {queue_record} を持ち、流す時点に探す。#2230)。 + 版も渡さない。周回の版はもう出ているため、明示すると最終の検査で変更があったときに既出で止まる。 + まとめの配布の版は流す時点に次に空いている版で決める(#2164)。""" prev = rec["laps"][lap["n"] - 2] if lap["n"] >= 2 else None carry = ((prev or {}).get("refine") or {}).get("deferred") or [] out = Path(lap["dir"]) / tag cmd = [sys.executable, str(SUPERVISE), "new", "close", "--pace", "auto", "--name", lap["sprint"], "--worktree", str(env.main_dir())] cmd += ["--issue", *map(str, issues), "--state", lap["state_path"], "--out", str(out), "--mode", lap.get("mode") or "standard"] - v = lap.get("versions") or {} - if v.get("dev"): - cmd += ["--version", v["dev"], "--prod", v.get("prod") or prod_of(v["dev"])] if carry: cmd += ["--carry", *map(str, carry)] - if record_pr: - cmd += ["--record-pr", str(record_pr)] if lap.get("milestone"): cmd += ["--milestone", lap["milestone"]] code, so, se = env.run(cmd, cwd=str(env.main_dir())) @@ -340,8 +359,7 @@ def continuation(env: Env, rec: dict, lap: dict, moved: list[int], kept: list[in stages.append(build_close(env, rec, lap, kept, tag=tag)) done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" lap.setdefault("dones", []).append(str(done)) - head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + " --max 3" - return head + "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}", done + return queue_command(stages, done), done GATE_2_RESUME_STEPS = ("bump", "promote-approved", "merge-approved") # 承認ゲート 2 の承認の後に入るステップ(pace.md の続け方) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py new file mode 100644 index 000000000..e612ffba2 --- /dev/null +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -0,0 +1,75 @@ +"""開発ループの承認ゲート 2 の答えの読み取りと、承認の後の続きのコマンド(sprint_loop から分けた)。""" + +from __future__ import annotations + +import shlex +from pathlib import Path + +import clock +import gate_rejections as gr +import jsonio + +from sprint_loop_record import GATE_2, SUPERVISE, Env, selected_issues +from sprint_loop_gate import ( + build_close, + gate2_resume_step, + plan_info, + queue_command, + stages_after, + wait_lap, +) + + +EPOCH = clock.parse("1970-01-01T00:00:00Z") # 時刻の無い答え(止まった時刻の無い古い記録のとき)の並びの値 + + +def gate2_answer(lap: dict, since: str | None = None) -> str | None: + """承認ゲート 2 の答え: 止まった時刻(since)より後の、周回の状態の利用者の承認と差し戻しの記録のうち最新のもの。 + approved / rejected、どちらも無ければ None。since より前の答え(同じ周回の前の関門への答え)は数えない。""" + path = lap.get("state_path") or "" + state = jsonio.read(path, missing={}, broken={}, want=dict) if path else {} + try: + rows = gr.read_rejections() + except jsonio.JsonReadError: + rows = [] + sp = str(Path(path).resolve()) if path else None + # 承認の行(#2183)は状態ファイルの gates の方で読む + rejections = [r for r in rows if gr.is_rejection(r) and r.get("gate") == GATE_2 and r.get("sprint") == sp] + return latest_gate2_answer(state.get("gates", []), rejections, since) + + +def latest_gate2_answer(gates: list[dict], rejections: list[dict], since: str | None) -> str | None: + """関門 2 の利用者の承認(gates)と差し戻し(rejections)のうち、since 以後で最新のもの。同じ時刻は承認を採る。""" + after = clock.parse(since, naive="utc") if since else None + + def fresh(at): + t = clock.parse(at, naive="utc") if at else None + if after is None: + return t or EPOCH + return t if t is not None and t >= after else None + + answers = [(k, "approved") for g in gates if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at")))] + answers += [(k, "rejected") for r in rejections if (k := fresh(r.get("at")))] + return max(answers, key=lambda a: a[0])[1] if answers else None + + +def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: + """承認ゲート 2 の承認の後の続き: 止まった計画を経路に合う再開のステップから続け → 同じ queue の残りのステージと + まとめの queue → wait → loop lap の 1 行。続けるステップが無い計画(gate-2 のプラン)は手で行う本番を先に示す。""" + # 止まった記録の values.pr は検査のスプリント PR で、記録の PR ではない。まとめは {queue_record} で流す時点に探す(#2230 の決定 5) + close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap)) + done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" + plan = lap.get("gate2_plan") or lap.get("prod_plan") or "<本番のプラン>" + moved = {w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]} + rest = [ + [p for p in s if not (set(plan_info(p).get("課題") or []) and set(plan_info(p).get("課題") or []) <= moved)] + for s in stages_after(lap, plan) + ] + stages = [s for s in rest if s] + [close] + queue = queue_command(stages, done) + lap.setdefault("dones", []).append(str(done)) + step = gate2_resume_step(plan) + if step is None: + return f"手で行う本番のステージ({plan} の後)を終えてから: {queue} && {wait_lap(rec, done)}" + run = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(plan)} --from {step}" + return f"{run} && {queue} && {wait_lap(rec, done)}" diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index e8451713e..3bb1c202e 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -383,6 +383,17 @@ def plan_bump(root, pdir, plugin, old, new): return ed +def bump_guard(root, plugin, old, new, base) -> str | None: + """`bump` が書き換える前の判定(#2164 の I7。中身は release_version.bump_check)。打ち直しなら origin のベースブランチの版。""" + import release_version + + rel = (plugin_dir(root, plugin) / ".claude-plugin" / "plugin.json").relative_to(root).as_posix() + already, stop = release_version.bump_check(root, plugin, old, new, base, rel) + if stop: + emit(result(TOOL, "stopped", stop.summary, [], stop.metrics), code=stop.code) + return already + + def cmd_bump(a): root = git_root(a.root) a.base = a.base or delivery.load_delivery(root).base # 既定は宣言のベースブランチ(#1336) @@ -392,18 +403,16 @@ def cmd_bump(a): except (OSError, ValueError, KeyError) as e: raise StepError(f"旧版を plugin.json から読めない: {e}", 2) new = a.to - if old == new: - base_ver = base_version(root, pdir, f"origin/{a.base}") - if base_ver is None or base_ver == new: - raise StepError(f"旧版と新版が同じ: {old}" + ("" if base_ver else f"(origin/{a.base} の版を読めない)")) - # 同じステップの打ち直し(作業場所の版はすでに上げてある) + already = bump_guard(root, a.plugin, old, new, a.base) + if already: + # 同じステップの打ち直し(作業場所の版はすでに上げてある)か、自分の配布の後の流し直し emit( result( TOOL, "ok", - f"{a.plugin} はすでに {new}(origin/{a.base} は {base_ver})。上げ直さない", + f"{a.plugin} はすでに {new}(origin/{a.base} は {already})。上げ直さない", [], - {"plugin": a.plugin, "from": base_ver, "to": new, "already": True}, + {"plugin": a.plugin, "from": already, "to": new, "already": True}, ) ) ed = plan_bump(root, pdir, a.plugin, old, new) @@ -693,6 +702,35 @@ def _release_pr_body(root, a, plugin, plugins): ) +def _merge_release_pr(root, a, plugin, plugins, branch, base): + """開発版の PR(release/v<版> → ベースブランチ)を使い回すか作ってマージし、PR 番号とマージコミットを返す。""" + pr = _reusable_release_pr(root, branch, base) + if pr is None: + body = _release_pr_body(root, a, plugin, plugins) + pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{a.version}", body), "state": "OPEN"} + n = pr["number"] + return n, (wait_and_merge(root, n, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, n)) + + +def _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics): + """本番の PR を使い回すか作り、承認と比べた先端を指定してマージする。先端が動いたら比べ直し、許可リストの下に + 収まる限り HEAD_MOVED_RETRIES 回まで入れ直し、それ以外は承認ゲートで止める。(PR 番号, マージコミット, 判定) を返す。""" + mp = find_pr(root, base, prod, states=("OPEN",)) + title = f"Release: {plugin} v{a.version} を {prod} へ" + body = f"{plugin} v{a.version} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)" + main_pr = mp["number"] if mp else create_pr(root, prod, base, title, body) + for left in range(HEAD_MOVED_RETRIES, -1, -1): + try: + merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる + break + except HeadMoved: + moved = approval.check_approved(root, approved, base, allowed) + if not moved.ok or not left: + emit(approval.gate(root, a, plugin, base, moved, items, metrics, moved=not left), EXIT_GATE) + verdict = moved # 進んだ分が許可リストの下に収まるので、比べ直した先端で入れ直す(#2221) + return main_pr, merge, verdict + + def cmd_release(a): root = git_root(a.root) ver = a.version @@ -709,15 +747,7 @@ def cmd_release(a): bump.require_bumped(root, plugin, ver) git(root, "push", "-q", "-u", "origin", "HEAD") - - # 開発版の PR(release/v<版> → ベースブランチ) - pr = _reusable_release_pr(root, branch, base) - if pr is None: - body = _release_pr_body(root, a, plugin, plugins) - pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{ver}", body), "state": "OPEN"} - release_pr = pr["number"] - release_commit = wait_and_merge(root, release_pr, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, release_pr) - + release_pr, release_commit = _merge_release_pr(root, a, plugin, plugins, branch, base) metrics = { "channel": a.channel, "version": ver, @@ -742,32 +772,11 @@ def cmd_release(a): if not verdict.ok: emit(approval.gate(root, a, plugin, base, verdict, items, metrics), EXIT_GATE) metrics.update({"approved_sha": approved, "compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) - mp = find_pr(root, base, prod, states=("OPEN",)) - main_pr = ( - mp["number"] - if mp - else create_pr( - root, - prod, - base, - f"Release: {plugin} v{ver} を {prod} へ", - f"{plugin} v{ver} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)", - ) - ) - for left in range(HEAD_MOVED_RETRIES, -1, -1): - try: - merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる - break - except HeadMoved: - moved = approval.check_approved(root, approved, base, allowed) - if not moved.ok or not left: - emit(approval.gate(root, a, plugin, base, moved, items, metrics, moved=not left), EXIT_GATE) - verdict = moved # 進んだ分が許可リストの下に収まるので、比べ直した先端で入れ直す(#2221) + main_pr, merge, verdict = _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics) metrics.update({"compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) items += approval.tolerated_items(verdict) git(root, "fetch", "-q", "origin") - if not merge: - merge = git(root, "rev-parse", f"origin/{prod}").stdout.strip() + merge = merge or git(root, "rev-parse", f"origin/{prod}").stdout.strip() _publish_tag_and_release(root, plugin, ver, tag, merge) metrics.update({"main_pr": main_pr, "tag": tag, "merge_commit": merge}) diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 5a23794ad..feadede8c 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -33,8 +33,8 @@ {kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。metrics.final_stage は判定に使った最終工程。 `--record-pr 0` は「本番の記録なし」(最終の検査で変更が無く本番を飛ばした)。配布の記録を読まず、 閉じる条件 1・2 を見ずに --issues の課題を閉じる(条件 3 の振り返りは、比べる配布の記録が無いため行があれば満たす)。--issues と一緒のときだけ受ける。 -`--record-pr unknown` は「記録の置き場が分からない」(リリースの経路が雛形でも昇格でもなく、まとめのプランが -記録の PR を決められない)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの +`--record-pr unknown` は「記録の置き場が分からない」(手で打つ入口と、#2230 より前に組んだまとめのプランが渡す。 +今のまとめのプランは組まず、記録の PR を流す時点に `{queue_record}` で解決する)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの kept_open で返す(終了コード 0)。--issues と一緒のときだけ受ける。 終了コード: 0 = 失敗なし / 1 = 失敗あり(棚卸しへ進まない)/ 2 = 一覧が取れない・ 閉じる課題が 0 件・--record-pr 0 / unknown に --issues が無い / 3 = 呼び出しの誤り。 @@ -71,7 +71,7 @@ import locks # noqa: E402 import step_read # noqa: E402 projects-sync.sh の結果 JSON を読む(#2227) -from dist_record import parse_record # noqa: E402 記録の形は lib/dist_record.py が持つ(#1273) +from dist_record import parse_record, released # noqa: E402 記録の形は lib/dist_record.py が持つ(#1273) sys.path.insert(0, str(HERE)) from sprint_close_lib.collect import read_record, referenced_items, sprint_issues # noqa: E402 @@ -86,7 +86,7 @@ ) TOOL = "sprint-close" -RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(supervise_lib/sprint_routes.py が渡す。#1875) +RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(手で打つ入口と、#2230 より前に組んだまとめのプランが渡す。#1875) UNKNOWN_REASON = "配布の記録の置き場が分からない(リリースの経路が雛形でも昇格でもない。リリース記録を確かめて手で閉じる)" LOCK_TIMEOUT = 30 # 課題ごとの錠の待ち(body-lock の既定と同じ) @@ -212,32 +212,41 @@ def _read_sprint_record(a, root, record_repo): return rec, a.prs or rec["sprint_prs"] -def _closing_blocker(a, rec, record_repo): +def _stage_blocker(a, rec): + """本番の記録の PR と配布の段階から、閉じられない理由を返す。閉じてよければ None。""" stage = rec["stage"] or "" - kept_all = None if a.record_pr == 0: - pass # 本番の記録なし: 閉じる条件(配布とリリース後テスト)を見ない - elif a.record_pr == RECORD_UNKNOWN: - kept_all = UNKNOWN_REASON # 記録を読めないので閉じない(#1875) - elif not rec["found"] or not stage: - kept_all = "配布の記録が読めない" - elif not (stage.startswith("本番") or stage.startswith("配布なし")): - kept_all = "本番への配布の前(本番への配布の後に閉じる)" - verdicts = None + return None # 本番の記録なし: 閉じる条件(配布とリリース後テスト)を見ない + if a.record_pr == RECORD_UNKNOWN: + return UNKNOWN_REASON # 記録を読めないので閉じない(#1875) + if not rec["found"] or not stage: + return "配布の記録が読めない" + if not released(stage): + return "本番への配布の前(本番への配布の後に閉じる)" + return None + + +def _verify_verdicts(rec, record_repo): + """リリース後テストの記録から (閉じられない理由か None, verdicts) を返す。""" + blk = rec["verify_block"] + if blk is None: + stage = rec["stage"] or "" + return ( + "配布なしの後のリリース後テストの記録が無い" if stage.startswith("配布なし") else "本番の版のリリース後テストの記録が無い" + ), None + verdicts = verification_verdicts(blk, record_repo) + if verdicts is not None: + return None, verdicts + # 課題を指す行が無い表: すべての条件をマージ前に確かめた課題だけ判定へ進める(#2144) + return NO_MAPPING, ({} if acceptance.verdict_table_readable(blk) else None) + + +def _closing_blocker(a, rec, record_repo): + kept_all = _stage_blocker(a, rec) verify = a.with_verification or a.final_stage == STAGE_VERIFY # 最終工程がリリース後テストなら必ず見る(I4) - if kept_all is None and verify and a.record_pr != 0: - blk = rec["verify_block"] - if blk is None: - kept_all = ( - "配布なしの後のリリース後テストの記録が無い" if stage.startswith("配布なし") else "本番の版のリリース後テストの記録が無い" - ) - else: - verdicts = verification_verdicts(blk, record_repo) - if verdicts is None: - kept_all = NO_MAPPING - if acceptance.verdict_table_readable(blk): - verdicts = {} # 課題を指す行が無い表: すべての条件をマージ前に確かめた課題だけ判定へ進める(#2144) - return kept_all, verdicts + if kept_all is not None or not verify or a.record_pr == 0: + return kept_all, None + return _verify_verdicts(rec, record_repo) def _read_state_body(root, repo, n): diff --git a/plugins/ndf/scripts/sprint_close_lib/record_find.py b/plugins/ndf/scripts/sprint_close_lib/record_find.py new file mode 100644 index 000000000..626245ab9 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/record_find.py @@ -0,0 +1,151 @@ +"""まとめの課題から GitHub の参照をたどり、リリース記録(`## 配布の記録`)を置いた PR を 1 本決める(#2230)。 + +queue の差し込み `{queue_record}`(`supervise_lib/queue_fill.py`)だけが呼ぶ。決め方(設計 `issues/issue-2230-design.md` +の `find_record` の規則): + +1. まとめの課題ごとに、課題の履歴の参照(`cross-referenced`)のうち同じリポジトリの Pull Request を集める +2. そのうち本文の closing keywords か閉じる予定の参照(`closing.closing_refs`)がその課題を指すものを、スプリントの PR とする。 + 閉じる PR が見つかった課題が 0 件なら見つからない +3. 候補はスプリントの PR と、スプリントの PR の履歴の参照のうち Pull Request のものの和 +4. 候補ごとに最後のリリース記録を読み、段階が本番か配布なし(`dist_record.released`)で、`スプリント:` の PR が + 閉じる PR の見つかった課題の 1 件ごとにその閉じる PR を 1 本以上含むものだけを残す +5. 残った記録の `スプリント:` の並び(番号の集合)が 2 通り以上なら特定できない(見つからない) +6. 残った中で記録のコメントの識別子(本文は 0)が最も大きいもの、同じなら PR の番号の大きいものを採る + +GitHub は `gh`(`lib/gh_call.py`)で読むだけで、書かない。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +import gh_call +from closing import closing_refs +from dist_record import parse_record, released +from step_result import StepError + +from sprint_close_lib.collect import read_record + + +@dataclass(frozen=True) +class RecordHit: + """記録の PR を探した結果。`pr` が None なら見つからず、`reason()` が理由の文を返す。""" + + pr: int | None = None + issues: tuple[int, ...] = () + closers: tuple[int, ...] = () # まとめの課題を閉じる PR(スプリントの PR) + searched: tuple[int, ...] = () # 記録を読んだ PR + pre_release: tuple[int, ...] = () # 記録はあるが段階が本番の前だった PR + error: str = "" # GitHub を読めなかったときの理由 + ambiguous: tuple[tuple[int, tuple[int, ...]], ...] = () # (記録の PR, その `スプリント:` の PR の集合) の並び + + def reason(self) -> str: + if self.pr is not None: + return "" + issues = " ".join(f"#{n}" for n in self.issues) or "(無し)" + if self.error: + return f"GitHub を読めない: {self.error}" + if self.ambiguous: + sets = "・".join(f"PR #{pr}(スプリント: {' '.join(f'#{n}' for n in prs)})" for pr, prs in self.ambiguous) + return f"課題 {issues} を閉じる記録が 2 つ以上のスプリントにある: {sets}。`--record-pr <番号>` を明示して組み直す" + if not self.closers: + return f"課題 {issues} を閉じる PR が見つからない(課題の履歴の参照を読んだ)" + closers = " ".join(f"#{n}" for n in self.closers) + others = [n for n in self.searched if n not in self.closers] + refs = f"と、それを参照する PR {' '.join(f'#{n}' for n in others)} " if others else "" + tail = f"(本番の前の記録だけ: PR {' '.join(f'#{n}' for n in self.pre_release)})" if self.pre_release else "" + return f"課題 {issues} を閉じる PR {closers} {refs}を読んだが、本番か配布なしの段階のリリース記録が無い{tail}" + + +class _Unreadable(Exception): + pass + + +def _repo_of(root) -> str: + p = gh_call.gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd=root) + name = p.stdout.strip() + if p.returncode != 0 or not name: + raise _Unreadable((p.stderr or "記録のリポジトリを決められない").strip()[:300]) + return name + + +def _referencing_prs(root, repo: str, n: int) -> list[dict]: + """課題か PR `n` の履歴の参照のうち、同じリポジトリの Pull Request(番号と本文)。""" + p = gh_call.gh(["api", "--paginate", "--slurp", f"repos/{repo}/issues/{n}/timeline"], cwd=root) + if p.returncode != 0: + raise _Unreadable((p.stderr or "").strip()[:300] or f"#{n} の履歴の参照を読めない") + try: + pages = json.loads(p.stdout or "[]") + except ValueError: + raise _Unreadable(f"#{n} の履歴の参照の出力を読めない") + events = [e for page in pages for e in (page if isinstance(page, list) else [page])] + out: dict[int, dict] = {} + for e in events: + src = (e or {}).get("source", {}).get("issue") or {} + if e.get("event") != "cross-referenced" or src.get("pull_request") is None: + continue + if ((src.get("repository") or {}).get("full_name") or repo).lower() != repo.lower(): + continue + out.setdefault(int(src["number"]), {"number": int(src["number"]), "body": src.get("body") or ""}) + return list(out.values()) + + +def _closers_of(root, repo: str, issues: tuple[int, ...]) -> dict[int, set[int]]: + """まとめの課題ごとに、その課題を閉じる PR の集合。閉じる PR が見つからない課題は含めない。""" + closers_of: dict[int, set[int]] = {} + for n in issues: + hit = {pr["number"] for pr in _referencing_prs(root, repo, n) if (repo, n) in closing_refs(pr["body"], repo)} + if hit: + closers_of[n] = hit + return closers_of + + +def _record_candidates(root, repo: str, closers: tuple[int, ...]) -> set[int]: + """閉じる PR と、それを参照する PR の和。""" + cands = set(closers) + for pr in closers: + cands |= {r["number"] for r in _referencing_prs(root, repo, pr)} + return cands + + +def _released_records(root, repo: str, cands: set[int], closers_of: dict[int, set[int]]) -> tuple[list, list[int]]: + """候補ごとに最後のリリース記録を読み、(本番か配布なしで閉じる PR を含む記録, 本番の前の記録の PR) に振り分ける。""" + kept, pre = [], [] + for pr in sorted(cands): + try: + rec = parse_record(*read_record(root, repo, pr)) + except StepError as e: + raise _Unreadable(str(e)) + if not rec["found"]: + continue + if not released(rec["stage"]): + pre.append(pr) + continue + sprint = set(rec["sprint_prs"]) + if all(sprint & want for want in closers_of.values()): + kept.append((rec["dist_comment_id"] or 0, pr, tuple(sorted(sprint)))) + return kept, pre + + +def find_record(root, repo: str | None, issues) -> RecordHit: + """まとめの課題 `issues` のリリース記録を置いた PR を決める。`root` は `gh` を打つ作業場所、`repo` は owner/name(None なら `root` から決める)。""" + issues = tuple(int(n) for n in issues) + if not issues: + return RecordHit(error="まとめの課題が無い") + try: + repo = repo or _repo_of(root) + closers_of = _closers_of(root, repo, issues) + closers = tuple(sorted(set().union(*closers_of.values()))) if closers_of else () + if not closers: + return RecordHit(issues=issues) + cands = _record_candidates(root, repo, closers) + kept, pre = _released_records(root, repo, cands, closers_of) + except _Unreadable as e: + return RecordHit(issues=issues, error=str(e)) + base = {"issues": issues, "closers": closers, "searched": tuple(sorted(cands)), "pre_release": tuple(pre)} + if len({k[2] for k in kept}) > 1: + return RecordHit(**base, ambiguous=tuple((pr, prs) for _, pr, prs in sorted(kept, key=lambda k: k[1]))) + if not kept: + return RecordHit(**base) + return RecordHit(pr=max(kept)[1], **base) diff --git a/plugins/ndf/scripts/supervise_lib/decl.py b/plugins/ndf/scripts/supervise_lib/decl.py index d4281b323..26d5e868e 100644 --- a/plugins/ndf/scripts/supervise_lib/decl.py +++ b/plugins/ndf/scripts/supervise_lib/decl.py @@ -232,7 +232,7 @@ def apply_decls(a) -> None: - 実装の上限(a.impl_timeout)と想定最大時間(a.refactor_budget_minutes): 引数 → supervise.json の同じ名前の項目。 宣言の形は引数があっても見る(#1916 の決定 5) - リリースの経路(a.routes)は sprint と close だけが使い、`sprint_routes.apply_routes` が delivery_decl(宣言。a.delivery)と require_versions で組む。 + リリースの経路(a.routes)は sprint と close だけが使い、`sprint_routes.apply_routes` が delivery_decl(宣言。a.delivery)で組む。 """ roots = decl_roots(a.worktree, getattr(a, "repo", None)) wt = read_decl(roots, WORKTREE_DECL) @@ -326,16 +326,6 @@ def delivery_decl(a) -> "delivery.DeliveryDecl": ) -def require_versions(a) -> None: - """版数(--version・close は --prod も)は、リリースの経路が雛形(template)で組むときだけ要る(#1336 の I6)。""" - if not delivery.needs_version(a.routes): - return - need = [("version", "--version", "版数"), *([("prod", "--prod", "本番の版")] if a.kind == "close" else [])] - gaps = [f"{what}({flag}。release.form の雛形は版数で組む)" for attr, flag, what in need if not getattr(a, attr, None)] - if gaps: - raise DeclError(f"new {a.kind} に要る宣言が無い: " + "・".join(gaps)) - - def _project_decl(roots, sv: dict) -> tuple[dict, str | None]: """roots の順に `.ndf/project.json` を探し、`test` を持つ最初の宣言。無ければ supervise.json の test を 1 つの suite として読む。""" import test_strategy as ts diff --git a/plugins/ndf/scripts/supervise_lib/engine.py b/plugins/ndf/scripts/supervise_lib/engine.py index 2cbac833e..65c8f7a66 100644 --- a/plugins/ndf/scripts/supervise_lib/engine.py +++ b/plugins/ndf/scripts/supervise_lib/engine.py @@ -10,7 +10,6 @@ import os import shutil -import subprocess import time from datetime import datetime from pathlib import Path @@ -26,7 +25,7 @@ from supervise_lib.pr import PrStep from supervise_lib.slow import SLOW_EXIT, SlowAction, SlowWatch from supervise_lib.state import RunState -from supervise_lib.steps import JudgeStep, RunStep, StepContext, is_gate, last_json +from supervise_lib.steps import COND_RUN, COND_SKIP, JudgeStep, RunStep, StepContext, is_gate, last_json, run_condition from supervise_lib.worker_steps import DriveStep, WorkStep @@ -412,34 +411,13 @@ def _next_after_slow(self, e: SlowAction, sid: str, step: dict) -> tuple[str | N def check_condition(self, cond: dict) -> tuple[str, str] | None: """計画の実行の条件を、作業ツリーを作る前に打つ。流すなら None、流さないなら (結果, 理由)。""" - cmd = str(cond.get("cmd") or "").replace("{state_dir}", str(self.state.dir)) - wt = Path(self.plan["作業場所"]) - cwd = self.plan.get("リポジトリ") or ( - str(wt) if wt.is_dir() else str(wt).split("/.worktrees/")[0] if "/.worktrees/" in str(wt) else None - ) - if cwd and not Path(cwd).is_dir(): - cwd = None - started = time.time() - try: - p = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=cond.get("timeout", 900)) - code, text = p.returncode, p.stdout + p.stderr - except subprocess.TimeoutExpired as e: - code, text = 124, f"打ち切り({e.timeout} 秒)" - out = last_json(text) or {} - summary = str(out.get("summary") or text.strip()[-200:] or f"exit={code}") + verdict, code, summary, seconds = run_condition(cond, self.plan, self.state.dir) self.state.progress_write( - { - "kind": "step", - "step": "実行の条件", - "type": "run", - "exit": code, - "seconds": round(time.time() - started, 1), - "summary": summary[:300], - } + {"kind": "step", "step": "実行の条件", "type": "run", "exit": code, "seconds": seconds, "summary": summary[:300]} ) - if code == 0: + if verdict == COND_RUN: return None - if code == cond.get("skip_code", 3): + if verdict == COND_SKIP: return "完了", f"実行の条件に当たらない({summary})" self.state.attention("止まった", f"実行の条件を判定できない(exit={code}): {summary}") return "止まった", f"実行の条件を判定できない(exit={code}: {summary})" diff --git a/plugins/ndf/scripts/supervise_lib/new_args.py b/plugins/ndf/scripts/supervise_lib/new_args.py index 8f7be077c..d1dacb553 100644 --- a/plugins/ndf/scripts/supervise_lib/new_args.py +++ b/plugins/ndf/scripts/supervise_lib/new_args.py @@ -222,8 +222,8 @@ def positive_int(text: str) -> int: { "help": { "*": "配る版(例 10.17.11-dev.1)", - "sprint": "開発版の版(例 3.8.0-dev.1。release.form の雛形で配るときだけ要る)", - "close": "開発版の版(最終の検査で変更があったときに配る。release.form の雛形で配るときだけ要る)", + "sprint": "開発版の明示の版(例 3.8.0-dev.1。省けば配布のステップを流す時点に origin から次に空いている版を決める)", + "close": "開発版の明示の版(最終の検査で変更があったときに配る。省けば配布のステップを流す時点に決める)", } }, "release sprint close", @@ -302,7 +302,15 @@ def positive_int(text: str) -> int: }, "sprint", ), - ("--prod", {"help": "本番の版(例 10.18.0。release.form の雛形で配るときだけ要る)"}, "close"), + ("--prod", {"help": "本番の明示の版(例 10.18.0。省けば開発版のプランの版の記録の基底の版)"}, "close"), + ( + "--level", + { + "choices": ["patch", "minor", "major"], + "help": "版を流す時点に決めるときの上げ幅(既定 patch。新しい系列を始めるときだけ使い、明示の版には使わない)", + }, + "sprint close", + ), ( "--record-pr", {"type": int, "help": "課題を閉じるステップへ渡す本番の記録の Pull Request(省けば配布の経路から導く。#2089)"}, @@ -320,8 +328,8 @@ def positive_int(text: str) -> int: "fix": "--worktree か --branch・--tests・--title", "check": "--pr か --since-last(--id と組)", "release": "--version・--prs(か --prs-from-queue)・--channel", - "sprint": "--name・--issue(release.form の雛形で配るときは --version も)", - "close": "--name・--issue・--state(release.form の雛形で配るときは --version・--prod も)", + "sprint": "--name・--issue", + "close": "--name・--issue・--state", } @@ -412,8 +420,7 @@ def check_new(ap: argparse.ArgumentParser, a) -> None: def check_sprint(ap: argparse.ArgumentParser, a) -> None: - """sprint / close の引数の組を確かめる。版数(--version・--prod)はリリースの経路が雛形のときだけ要り、 - 宣言を読んだ後に apply_decls が確かめる(#1336)。""" + """sprint / close の引数の組を確かめる。版数(--version・--prod)は任意で、無ければ配布のステップを流す時点に決める(#2164)。""" if not (a.name and a.issue): ap.error(f"new {a.kind} には --name・--issue が要る") if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", a.name): diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index 47aa485e8..d2646f874 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -144,8 +144,11 @@ 名前(ファイル名の stem か、その末尾の `-<計画名>`)が一致する計画の Pull Request 1 本(前のステージに無ければ 同じディレクトリの計画の報告。完了して Pull Request が無い・飛ばされたなら `0`)、`{queue_state:<計画名>}` は 同じ名前の計画の状態ディレクトリの絶対パス。名前の一致する計画の結果(完了した報告・状態ディレクトリ)が見つからなければ -埋めずに、そのプランを流さない(`0`・空文字で黙って埋めない。#2079)。`{queue_state?:<計画名>}` は無いことを読む側が扱う差し込みで、無ければ空文字で埋め、残っても止めない。置き換わらずに `{queue_pr:…}`・`{queue_state:…}` -が残ったコマンドのステップは、run でも queue でもコマンドを打たずに止まる +埋めずに、そのプランを流さない(`0`・空文字で黙って埋めない。#2079)。`{queue_state?:<計画名>}` は無いことを読む側が扱う差し込みで、無ければ空文字で埋め、残っても止めない。 +`{queue_record}` は計画の `課題` のリリース記録(`## 配布の記録`)を置いた PR の番号で、課題を閉じる PR とそれを参照する PR を +GitHub から読み、段階が本番か配布なしで `スプリント:` がその閉じる PR を含む記録を採る。見つからない・2 つ以上のスプリントに +ある・GitHub を読めなければ、同じく埋めずにそのプランを流さない(#2230)。置き換わらずに `{queue_pr:…}`・`{queue_state:…}`・ +`{queue_record}` が残ったコマンドのステップは、run でも queue でもコマンドを打たずに止まる ステップの遷移: - `next` に `end` を書くと、そこでフェーズを完了として終える @@ -205,12 +208,12 @@ # queue が --then のプランを流す前に、先行のプランの Pull Request の番号へ置き換える印 QUEUE_PRS = "{queue_prs}" -# queue が名前の一致する計画の結果で置き換える差し込み({queue_pr:<名>}・{queue_state:<名>}) -QUEUE_NAMED = re.compile(r"\{queue_(?:pr|state):[A-Za-z0-9._-]+\}") +# queue が名前の一致する計画の結果とリリース記録で置き換える差し込み({queue_pr:<名>}・{queue_state:<名>}・{queue_record}) +QUEUE_NAMED = re.compile(r"\{queue_(?:pr|state):[A-Za-z0-9._-]+\}|\{queue_record\}") def leftover_placeholders(text: str) -> list[str]: - """置き換わらずに残った {queue_pr:<名>}・{queue_state:<名>}(出た順、重なりは 1 つ。#2079)。""" + """置き換わらずに残った {queue_pr:<名>}・{queue_state:<名>}・{queue_record}(出た順、重なりは 1 つ。#2079・#2230)。""" return list(dict.fromkeys(QUEUE_NAMED.findall(text or ""))) diff --git a/plugins/ndf/scripts/supervise_lib/queue.py b/plugins/ndf/scripts/supervise_lib/queue.py index 6d0de9c6d..1e3e23adf 100644 --- a/plugins/ndf/scripts/supervise_lib/queue.py +++ b/plugins/ndf/scripts/supervise_lib/queue.py @@ -19,7 +19,7 @@ from supervise_lib.decl import DeclError, queue_decl_of from supervise_lib.paths import queue_done_path, queue_plans_path, state_dir_of, wait_cursor_path from supervise_lib.plan import QUEUE_PRS, pr_number -from supervise_lib.queue_fill import FINISHED, fill_queue_pr, named, results_named, write_text_atomic # noqa: F401 -- 公開を保つ +from supervise_lib.queue_fill import FINISHED, fill_queue_pr, fill_release_version, named, results_named, write_text_atomic # noqa: F401 -- 公開を保つ def attention_lines(prog: Path, offset: int) -> tuple[list[dict], int]: @@ -130,17 +130,11 @@ def cmd_queue( import durable from supervise_lib import flow # DBOS の import は run と queue だけが払う - then_stages = [list(t) for t in then] if then and not isinstance(then[0], str) else ([list(then)] if then else []) - stages = [list(plans), *then_stages] - all_plans = [p for stage in stages for p in stage] - done_path = queue_done_path(plans, done) - try: - decl = queue_decl_of([_read_plan(p) for p in all_plans]) - except DeclError as e: - return _not_started(str(e), max_, done_path) - done_path.unlink(missing_ok=True) # 前の queue の終わりを待つ側が読まないように、始めに消す - seen = {p: progress_size(p) for p in all_plans} # 前の実行の行は知らせない - listing = _read_listing(queue_plans_path(done_path)) + stages, all_plans = _normalize_stages(plans, then) + prepared = _prepare_queue(all_plans, plans, done, max_) + if isinstance(prepared, dict): + return prepared + decl, done_path, seen, listing = prepared try: prefix = flow.launch_queue(done_path, max_, decl["resources"], listing is not None and listing.get("plans") != all_plans) except durable.DurableError as e: @@ -167,6 +161,26 @@ def cmd_queue( durable.close() +def _normalize_stages(plans: list[str], then: list | None) -> tuple[list[list[str]], list[str]]: + """then(プランの並びかステージの並び)を、先頭のステージ plans に続くステージの並びとすべてのプランの並びにする。""" + then_stages = [list(t) for t in then] if then and not isinstance(then[0], str) else ([list(then)] if then else []) + stages = [list(plans), *then_stages] + return stages, [p for stage in stages for p in stage] + + +def _prepare_queue(all_plans: list[str], plans: list[str], done: str | None, max_: int): + """宣言を読み、done を消して知らせ始める位置と前の一覧を読む。宣言の形が違えば _not_started の結果を返す。""" + done_path = queue_done_path(plans, done) + try: + decl = queue_decl_of([_read_plan(p) for p in all_plans]) + except DeclError as e: + return _not_started(str(e), max_, done_path) + done_path.unlink(missing_ok=True) # 前の queue の終わりを待つ側が読まないように、始めに消す + seen = {p: progress_size(p) for p in all_plans} # 前の実行の行は知らせない + listing = _read_listing(queue_plans_path(done_path)) + return decl, done_path, seen, listing + + def _read_plan(plan: str) -> dict: try: data = json.loads(Path(plan).read_text(encoding="utf-8")) @@ -195,7 +209,7 @@ def start_listing(done_path: Path, all_plans: list[str], **extra) -> None: def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[dict], list[str]]: - """ステージを流す前に、プランの {queue_pr:<名>} と QUEUE_PRS を埋める。(流さないプランの items, 流すプラン) を返す。 + """ステージを流す前に、プランの {queue_pr:<名>} と QUEUE_PRS と版の差し込みを埋める。(流さないプランの items, 流すプラン) を返す。 後続のステージ(first でない)は、前のすべてのプランが 完了 のときだけ流す。埋められないプランは流さずに理由と `unfilled` を残す(#2079)。""" not_done = [i for i in items if i["result"] != "完了"] @@ -211,13 +225,35 @@ def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[d skipped, runnable = [], [] for p in stage: err = (None if prs is None else fill_queue_prs(p, prs)) or fill_queue_pr(p, items) + version = None + if not err and _release_skipped_by_condition(p): + runnable.append(p) # 版を照会せずに流す。engine が実行の条件を打ち直して完了で終える + continue + if not err: # 版の差し込みは、ほかの差し込みを埋めた後に(差し込み前の写しに版だけを残す。#2164) + err, version = fill_release_version(p, items) if err: - skipped.append({"plan": p, "result": NOT_RUN, "reason": err, "unfilled": True}) + skipped.append({"plan": p, "result": NOT_RUN, "reason": err, "unfilled": True, **({"version": version} if version else {})}) else: runnable.append(p) return skipped, runnable +def _release_skipped_by_condition(plan: str) -> bool: + """「版」と「実行の条件」を持つ配布のプランで、条件が配布不要(skip_code)を返すか。 + + 条件で飛ぶ配布は版を使わないため、版の照会・差し込みより先に条件を打つ。明示の版が既出でも止めない(#2164)。 + 判定できないときと流すときは False(版を埋めてから流し、engine が条件を打ち直す)。""" + try: + info = json.loads(Path(plan).read_text()) + except (OSError, ValueError): + return False + if not isinstance(info, dict) or not isinstance(info.get("版"), dict) or not isinstance(info.get("実行の条件"), dict): + return False + from supervise_lib.steps import COND_SKIP, run_condition # 条件のあるプランだけが読み込む + + return run_condition(info["実行の条件"], info, state_dir_of(str(Path(plan).absolute())))[0] == COND_SKIP + + def touched_files(body: str) -> list[str]: """プランの JSON の `触るファイル`(読めない・無いなら空)。""" try: diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index 2ca39af0b..f7e0ed40a 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -1,10 +1,12 @@ """queue の差し込み(`{queue_pr:<名>}`・`{queue_state:<名>}`)を、名前の一致する計画の結果で埋める(#1488・#2079)。 +`{queue_record}` は、計画の `課題` のリリース記録を置いた PR を GitHub から探して埋める(#2230。`sprint_close_lib/record_find.py`)。 `queue.py` から切り出した。`queue` は同じ名前で読み込み直して公開する。 """ from __future__ import annotations +import json import os import re from pathlib import Path @@ -16,6 +18,7 @@ QUEUE_PR = re.compile(r"\{queue_pr:([A-Za-z0-9._-]+)\}") # 名前の一致する計画の Pull Request 1 本 +QUEUE_RECORD = "{queue_record}" # 計画の `課題` のリリース記録を置いた PR(#2230) QUEUE_STATE = re.compile(r"\{queue_state(\??):([A-Za-z0-9._-]+)\}") # 名前の一致する計画の状態ディレクトリ(#1488)。`?` 付きは無ければ空 @@ -76,22 +79,41 @@ def _fill_states(text: str, states: list[tuple[str, str]], items: list[dict], pl return text, missing +def _fill_record(text: str, plan: str) -> tuple[str, list[str]]: + """{queue_record} を、計画の `課題` のリリース記録を置いた PR の番号で置き換える。見つからなければ理由の文で返す。""" + from sprint_close_lib.record_find import find_record # GitHub を読む差し込みを持つ計画だけが読み込む + + try: + info = json.loads(text) + except ValueError: + return text, [f"{QUEUE_RECORD}(計画の JSON を読めない)"] + issues = [n for n in info.get("課題") or [] if isinstance(n, int)] if isinstance(info, dict) else [] + root = (info.get("リポジトリ") if isinstance(info, dict) else None) or str(Path(plan).parent) + hit = find_record(root, None, issues) + if hit.pr is None: + return text, [f"{QUEUE_RECORD}({hit.reason()})"] + return text.replace(QUEUE_RECORD, str(hit.pr)), [] + + def fill_queue_pr(plan: str, items: list[dict]) -> str | None: """計画の {queue_pr:<名>} を、前のステージの名前の一致する計画の Pull Request 1 本で置き換える。 前のステージに無ければ同じディレクトリの計画の報告を読む(関門の後に単独で流すとき)。完了して PR が無い・飛ばされたなら 0。 {queue_state:<名>} は、名前の一致する計画の状態ディレクトリの絶対パスで置き換える(#1488)。結果(完了した報告・ 状態ディレクトリ)が見つからなければ、0・空文字で埋めずに計画を書き換えず理由を返す(#2079)。 - {queue_state?:<名>} だけは、無いことを読む側が扱うと明示した差し込みで、無ければ空文字で埋める(単独で流す本番の approve)。""" + {queue_state?:<名>} だけは、無いことを読む側が扱うと明示した差し込みで、無ければ空文字で埋める(単独で流す本番の approve)。 + {queue_record} は計画の `課題` のリリース記録を置いた PR で置き換え、見つからなければ同じく書き換えずに理由を返す(#2230)。""" try: text = Path(plan).read_text() except OSError as e: return f"計画を読めない: {e}" names, states = sorted(set(QUEUE_PR.findall(text))), sorted(set(QUEUE_STATE.findall(text))) - if not names and not states: + record = QUEUE_RECORD in text + if not names and not states and not record: return None text, missing_prs = _fill_prs(text, names, items, plan) text, missing_states = _fill_states(text, states, items, plan) - missing = missing_prs + missing_states + text, missing_record = _fill_record(text, plan) if record else (text, []) + missing = missing_prs + missing_states + missing_record if missing: return "差し込みを埋める前の計画の結果が見つからない: " + "、".join(missing) write_text_atomic(Path(plan), text) @@ -104,3 +126,245 @@ def write_text_atomic(path: Path, text: str) -> None: tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") tmp.write_text(text) os.replace(tmp, path) + + +# ---- 版の差し込み(#2164) ---- + +RELEASE_VERSION, RELEASE_BASE = "{release_version}", "{release_base}" # 版の差し込みの語(雛形の release_templates も読む) +VERSION_RECORD = "version.json" # 版の記録 +VERSION_PLAN = "version-plan.json" # 差し込み前のプランの写し +SUPERSEDED = "superseded" # 決め直した前の版のステップの記録の退避先 +KEEP_ON_REDECIDE = {VERSION_RECORD, VERSION_PLAN, SUPERSEDED, "plan.json"} # 決め直しで退避しないもの(plan.json は状態の実体の写し) +GATE_2_STEPS = ("approve", "mvv") # 本番のプランの先頭で承認ゲート 2 を通すステップ + + +def _version_now() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def _read_version_json(path: Path) -> dict | None: + try: + d = json.loads(path.read_text()) + except (OSError, ValueError): + return None + return d if isinstance(d, dict) else None + + +def _write_version_json(path: Path, obj: dict) -> None: + write_text_atomic(path, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") + + +def _filled(text: str, version: str) -> str: + import versions + + return text.replace(RELEASE_VERSION, version).replace(RELEASE_BASE, versions.release_base(version)) + + +def _stopped(cause: str, version: str | None = None, existing: str | None = None, next_free: str | None = None, unreadable=None): + """埋められないときの (理由, キューの項目の version)。""" + info = {"cause": cause, "version": version, "existing": existing, "next_free": next_free, "unreadable": list(unreadable or [])} + why = { + "taken": f"版 {version} はすでに出ている({existing}。次に空いている版は {next_free})。別の版で黙って進めない", + "unreadable": f"版を決める値を読めない: {'・'.join(info['unreadable'])}。推定で決めない", + "no_series": "本番が版を読む開発版のプランの版の記録が無い", + "series_changed": f"承認ゲート 2 の後に、本番が読む開発版の記録の版が変わった({existing} → {version})", + }[cause] + return f"版の差し込みを埋められない: {why}", info + + +class _Fill: + """1 本のプランの版の差し込み。状態ディレクトリの版の記録と差し込み前の写しを読み書きする。""" + + def __init__(self, plan: str, text: str, info: dict, items: list[dict]): + import delivery + + self.path, self.text, self.info, self.items = Path(plan), text, info, items + self.field = info["版"] + self.sd = state_dir_of(str(self.path.absolute())) + self.sd.mkdir(parents=True, exist_ok=True) + self.repo = info.get("リポジトリ") or "." + self.base = str(info.get("起点") or "").removeprefix("origin/") + self.production = delivery.load_delivery(self.repo).production + self.plugin = self.field.get("プラグイン") + self.level = self.field.get("上げ幅") or "patch" + self.explicit = self.field.get("明示") or None + + @property + def record_path(self) -> Path: + return self.sd / VERSION_RECORD + + def observe(self): + import release_version + + return release_version.observe(self.repo, self.plugin, self.base, self.production) + + def branch_of(self, version: str) -> str: + b = self.info.get("branch") + return b if b and RELEASE_VERSION not in b else f"release/v{version}" + + def log(self, text: str, **extra) -> None: + line = json.dumps({"kind": "version", "at": _version_now(), "text": text, **extra}, ensure_ascii=False) + with (self.sd / "progress.jsonl").open("a") as f: + f.write(line + "\n") + + def write_record(self, version: str, how: str, observed: dict, series_from: str | None = None) -> dict: + import versions + + if not (self.sd / VERSION_PLAN).exists(): + write_text_atomic(self.sd / VERSION_PLAN, self.text) + rec = { + "version": version, + "base": versions.release_base(version), + "how": how, + "level": self.level, + "observed": observed, + "series_from": series_from, + "at": _version_now(), + "history": [], + } + _write_version_json(self.record_path, rec) + return rec + + def ensure_filled(self, rec: dict) -> None: + """プランが記録の版で埋まっていなければ、差し込み前の写しを記録の版で埋めて書き直す(決め直しの途中で落ちた後も同じ)。""" + if RELEASE_VERSION not in self.text and RELEASE_BASE not in self.text and rec["version"] in self.text: + return + src = self.sd / VERSION_PLAN + text = src.read_text() if src.is_file() else self.text + write_text_atomic(self.path, _filled(text, rec["version"])) + self.log(f"版の差し込みを {rec['version']} で埋めた({self.record_path})", version=rec["version"], record=str(self.record_path)) + + def redecide(self, rec: dict, new: str, reason: str, existing: str | None, observed: dict) -> dict: + """記録の版を決め直す。ステップの記録を退避 → 記録を書き換え → 写しから埋め直す の順(落ちても流し直しで同じ所へ戻る)。""" + import versions + + old = rec["version"] + dst = self.sd / SUPERSEDED / f"v{old}" + dst.mkdir(parents=True, exist_ok=True) + for f in sorted(self.sd.iterdir()): + if f.name in KEEP_ON_REDECIDE: + continue + target = dst / f.name + n = 1 + while target.exists(): + target, n = dst / f"{f.name}.{n}", n + 1 + os.replace(f, target) + left = {"作業場所": self.info.get("作業場所"), "branch": self.info.get("branch"), "superseded": str(dst)} + hist = {"from": old, "to": new, "reason": reason, "existing": existing, "observed": observed, "left": left, "at": _version_now()} + rec = {**rec, "version": new, "base": versions.release_base(new), "observed": observed, "at": _version_now()} + rec["history"] = [*(rec.get("history") or []), hist] + _write_version_json(self.record_path, rec) + self.log( + f"版を {old} から {new} へ決め直した({reason})。前の作業場所 {left['作業場所']} と branch {left['branch']} は残した", + version=new, + record=str(self.record_path), + ) + return rec + + def series_record(self) -> tuple[dict | None, str | None]: + """本番が版を読む開発版のプラン(`系列` の名前)の版の記録と、そのパス。最も新しく書かれたもの。""" + found = [] + for i in results_named(self.field.get("系列") or "", self.items, str(self.path)): + if not i.get("plan"): + continue + f = state_dir_of(str(Path(i["plan"]).absolute())) / VERSION_RECORD + rec = _read_version_json(f) + if rec and rec.get("base"): + found.append((f.stat().st_mtime, rec, str(f))) + if not found: + return None, None + _, rec, f = max(found, key=lambda t: t[0]) + return rec, f + + def approved(self) -> bool: + """本番のプランの状態に承認ゲート 2 の通過(approve か mvv のステップが 0 で終えた記録)があるか。""" + state = _read_version_json(self.sd / "state.json") or {} + return any(e.get("id") in GATE_2_STEPS and e.get("exit") == 0 for e in state.get("log") or [] if isinstance(e, dict)) + + +def _recheck_dev(f: _Fill, obs, rec: dict): + """記録がある開発版の版を確かめ直す。(使う記録, 止める理由) を返す。止めるときは記録が None。""" + import release_version + + d = release_version.taken(obs, rec["version"], rec.get("how") or "decided", f.level) + if not d.is_taken: + return rec, None + own = release_version.own_released(f.repo, f.branch_of(rec["version"]), f.base) + if own is None: + return None, _stopped("unreadable", rec["version"], unreadable=["pr"]) + if own: + return rec, None + if rec.get("how") == "explicit": + return None, _stopped("taken", d.version, d.existing, d.next_free) + return f.redecide(rec, release_version.decide_dev(obs, f.level).version, "taken", d.existing, obs.as_dict()), None + + +def _fill_dev(f: _Fill, rec: dict | None): + import release_version + + obs = f.observe() + if obs.unreadable: + return _stopped("unreadable", (rec or {}).get("version") or f.explicit, unreadable=obs.unreadable) + if rec is None: + d = release_version.taken(obs, f.explicit, "explicit", f.level) if f.explicit else release_version.decide_dev(obs, f.level) + if d.is_taken: + return _stopped("taken", d.version, d.existing, d.next_free) + rec = f.write_record(d.version, d.how, obs.as_dict()) + else: + rec, stopped = _recheck_dev(f, obs, rec) + if stopped: + return stopped + f.ensure_filled(rec) + return None, None + + +def _fill_prod(f: _Fill, rec: dict | None): + import release_version + + if rec is not None and rec.get("how") != "series": + f.ensure_filled(rec) + return None, None + series, series_from = (None, None) if f.explicit else f.series_record() + if not f.explicit and series is None: + return _stopped("no_series") + want = f.explicit or series["base"] + if rec is not None and rec["version"] == want: + f.ensure_filled(rec) + return None, None + if rec is not None and f.approved(): + return _stopped("series_changed", want, rec["version"]) + obs = f.observe() + if obs.unreadable: + return _stopped("unreadable", want, unreadable=obs.unreadable) + d = release_version.taken(obs, want, "explicit" if f.explicit else "series") + if d.is_taken: + return _stopped("taken", d.version, d.existing, d.next_free) + if rec is None: + rec = f.write_record(want, d.how, obs.as_dict(), series_from) + else: + rec = f.redecide(rec, want, "series_changed", rec["version"], obs.as_dict()) + f.ensure_filled(rec) + return None, None + + +def fill_release_version(plan: str, items: list[dict]) -> tuple[str | None, dict | None]: + """配布のプランの版の差し込み(`{release_version}`・`{release_base}`)を、版の記録の値で埋める(#2164)。 + (埋められない理由, キューの項目に載せる `version`) を返す。どちらも None なら流してよい。「版」の欄を持たないプランは触らない。 + + 開発版は、記録が無ければ origin から次に空いている版を決めて(明示の版はまだ出ていないかを確かめて)記録を書く。記録が + あれば記録の版を使い、それがもう出ていて自分の配布のマージ済みの PR が無ければ決め直す(明示の版は止める)。本番は + 明示の版か `系列` の開発版の記録の base で出し、タグがあれば止める。承認ゲート 2 の後に開発版の記録の版が変わったら止める。""" + try: + text = Path(plan).read_text() + info = json.loads(text) + except (OSError, ValueError) as e: + return f"計画を読めない: {e}", None + if not isinstance(info, dict) or not isinstance(info.get("版"), dict): + return None, None + f = _Fill(plan, text, info, items) + if not f.plugin or not f.base: + return "版の差し込みを埋められない: プランの「版」の欄にプラグインが無いか、起点が無い", None + rec = _read_version_json(f.record_path) + return (_fill_prod if f.field.get("チャネル") == "prod" else _fill_dev)(f, rec) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 464b9fd2e..474dc789f 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -12,8 +12,10 @@ from supervise_lib.plan import QUEUE_PRS from supervise_lib.verify_steps import INFRA_WAIT_STOP, handoff_step, plan_limits, reject_step, with_margin from supervise_lib.procedures import with_record +from supervise_lib.queue_fill import RELEASE_BASE, RELEASE_VERSION # 版の差し込み(キューが流す直前に版の記録で埋める。#2164 の決定 7) +BUMP_STOP = {"2": "stop", "3": "stop"} # bump が版を読めない(2)・版がもう出ている(3)なら judge を通らずに止める(#2164 の I7) MVV_NOTE = "{state_dir}/work/mvv-note.md" # mvv-gate.py が書く判定の記録(PR のコメントか承認資料の末尾) RULE_RELEASE_DEV = ( "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)" @@ -39,6 +41,41 @@ def plan_release(a) -> dict: return maker(a) +DEV_SERIES = "release" # スプリントの本番のプランが版を読む開発版のプランの名前(#2164) + + +def release_names(version: str | None) -> tuple[str, str]: + """配布のプランの (作業場所のリポジトリからの位置, branch)。版が無ければ版の差し込みで書き、キューが流す時点に埋める。""" + v = version or RELEASE_VERSION + return f".worktrees/release/v{v}", f"release/v{v}" + + +def strip_suffix(v: str) -> str: + """開発版の版数から接尾辞(最初の `-` から後ろ)を外す。""" + return re.sub(r"-.*$", "", v) + + +def release_version_of(a) -> tuple[str, str]: + """(出す版, その接尾辞を外した版)。明示の版が無ければ版の差し込み(#2164)。""" + v = getattr(a, "version", None) + return (v, strip_suffix(v)) if v else (RELEASE_VERSION, RELEASE_BASE) + + +def version_field(a, plugin: str, dev: bool) -> dict: + """プランの「版」の欄(#2164)。キューの版の差し込みが読む。本番で明示の版も系列も無ければ組めない。""" + explicit = getattr(a, "version", None) or None + series = None if dev or explicit else getattr(a, "series", None) or None + if not dev and not explicit and not series: + raise DeclError("本番の配布に要る版が無い(--prod か、版を読む開発版のプランの系列)") + return { + "プラグイン": plugin, + "チャネル": "dev" if dev else "prod", + "上げ幅": getattr(a, "level", None) or "patch", + "明示": explicit, + "系列": series, + } + + def _validate_release_decl(rel: dict, dev: bool, a) -> tuple[str, list[str]]: """宣言の release の plugin・runtimes と、本番の配布の本番のブランチを確かめる。""" plugin, runtimes = rel.get("plugin"), rel.get("runtimes") @@ -79,8 +116,7 @@ def _cleanup_step(v: str, prs: str, repo: str | None) -> dict: def _dev_approval_steps(a, repo: str | None, approval: str, prs: str, rts: str) -> list[dict]: """開発版の approval-facts → 提示物の欄 → 他のプラグインの上げ幅(→ 助言の MVV 判定)のステップ。""" - v = a.version - base = re.sub(r"-.*$", "", v) + v, base = release_version_of(a) advise = getattr(a, "advise", None) # 助言の MVV 判定を置くスプリントの状態(pace: normal。#1400) prev = f" --prev-tag {a.prev_tag}" if a.prev_tag else "" m = re.fullmatch(r"\{queue_pr:([A-Za-z0-9._-]+)\}", prs.strip()) @@ -156,6 +192,7 @@ def _prepare_steps(a, plugin: str, v: str, prs: str, dev: bool, after_notes: str "type": "run", "stage": "配布", "cmd": f"{STEPS_PY} bump --plugin {plugin} --to {v} --base {a.base}", + "on_exit": dict(BUMP_STOP), "on_fail": "judge", "next": "changelog" if dev else "bump-others", }, @@ -305,10 +342,10 @@ def plan_release_package_plugin(a) -> dict: (本番のブランチ)→ 後片付け。本番は release の後に record(本番のリリースの PR へリリース記録)を挟み、落ちたら judge-record(record か stop)へ回す。sync-check は同期とチェックの宣言があるときだけ置く。 説明文と提示物の欄は release-steps.py notes が PR 本文の「利用者向けの変化」から組む(LLM を使わない)。""" - v, dev = a.version, a.channel == "dev" + dev = a.channel == "dev" plugin, runtimes = _validate_release_decl(a.release, dev, a) rts = ",".join(runtimes) - base = re.sub(r"-.*$", "", v) # 開発版の本番承認の提示物は正式版の番号で作る + v, base = release_version_of(a) # 開発版の本番承認の提示物は正式版の番号で作る # --prs-from-queue なら、queue が先行の計画の Pull Request の番号で QUEUE_PRS を置き換える prs = " ".join([*map(str, a.prs), *([QUEUE_PRS] if getattr(a, "prs_from_queue", False) else [])]) repo = a.repo or (a.worktree.split("/.worktrees/")[0] if "/.worktrees/" in a.worktree else None) @@ -350,6 +387,7 @@ def plan_release_package_plugin(a) -> dict: "作業場所": a.worktree, "branch": a.branch or f"release/v{v}", "起点": f"origin/{a.base}", + "版": version_field(a, plugin, dev), "規則": rule, "steps": steps, } diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 6fa66f4de..9ed641eb0 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re import shlex from pathlib import Path @@ -32,7 +31,7 @@ from supervise_lib.new_args import NEW_ARGS from supervise_lib.paths import CHECK_PY, SELF from supervise_lib.procedures import sprint_out, with_record -from supervise_lib.release_templates import release_verify_step +from supervise_lib.release_templates import release_verify_step, strip_suffix from supervise_lib.plan import sprint_state_path from supervise_lib.sprint_routes import ( normal_gate_1, @@ -51,8 +50,9 @@ from supervise_lib.sprint_close_steps import _close_step, _refine_step, _retro_step, _spec_pr_steps -def prod_version(version: str) -> str: - return re.sub(r"-.*$", "", version) +def prod_version(version: str | None) -> str | None: + """明示の開発版から本番の明示の版を作る。開発版が無ければ None(本番は開発版のプランの版の記録から読む。#2164)。""" + return strip_suffix(version) if version else None DESIGN_RESULTS_STAGE = "設計の結果" @@ -185,7 +185,11 @@ def close_waves(a) -> list[dict]: return [ check, {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", changed)}, "then_of": "最終の検査"}, - {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, a.prod, "prod", changed)}, "then_of": "最終の検査"}, + { + "name": "本番", + "plans": {"release-prod": plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)}, + "then_of": "最終の検査", + }, close, ] diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index 374fe6896..6e482ff21 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -8,7 +8,7 @@ from gate_answer import GATE_1, GATE_2 from pace import PaceError, read_pace from sprint_mvv import MVV_PACES -from supervise_lib.decl import DeclError, decl_roots, delivery_decl, require_versions +from supervise_lib.decl import DeclError, decl_roots, delivery_decl from supervise_lib.paths import HERE, SELF from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.delivery_templates import GATE_2_MATERIAL, PromoteParams, plan_gate_2, plan_promote, plan_verify @@ -32,10 +32,9 @@ def dev_channel_of(a) -> delivery.DevChannel: def apply_routes(a) -> None: - """a.routes(リリースの経路)を宣言から組み、雛形で組む経路なら版数を確かめる(足りなければ DeclError)。""" + """a.routes(リリースの経路)を宣言から組む。雛形で組む経路でも版数は要らない(無ければ配布のステップを流す時点に決める。#2164)。""" a.delivery = delivery_decl(a) a.routes = delivery.routes(a.delivery, tuple(RELEASE_FORMS)) - require_versions(a) def routes_of(a) -> list: @@ -58,27 +57,30 @@ def has_release_template(a) -> bool: RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件 1・2 を見ない。#1336) -RECORD_UNKNOWN = "unknown" # 記録の置き場が分からない(sprint-close.py は課題を開いたまま残す。#1875) +RECORD_FOUND = "{queue_record}" # まとめを流し始める時点に queue が GitHub から探すリリース記録の PR(#2230) def has_record(record_pr: str) -> bool: - """まとめの close が読む配布の記録の PR({queue_pr:…})があるか。""" - return record_pr not in (RECORD_NONE, RECORD_UNKNOWN) + """まとめの close が読む配布の記録の PR(番号か {queue_pr:…}・{queue_record})があるか。""" + return record_pr != RECORD_NONE def record_pr_ref(a) -> str: - """まとめの close が読む配布の記録の PR: 雛形の本番の PR か、昇格の PR(#837)。昇格は route_waves が昇格の - プラン(計画名 promote)を置くときだけ。経路がすべて届けない経路なら RECORD_NONE、それ以外の届ける経路 - (merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格)は記録の置き場が分からず RECORD_UNKNOWN。""" + """まとめの close が読む配布の記録の PR。経路がすべて届けない経路なら RECORD_NONE。開発ループのまとめ(pace: auto)は + 経路にかかわらず RECORD_FOUND(承認ゲート 2 の後のまとめは本番のプランと別の queue にある。#2230 の決定 5)。それ以外は + 雛形の本番の PR か、昇格の PR(#837。route_waves が昇格のプランを置くときだけ)で、残りの届ける経路(merge・手で届ける・ + 検証の環境だけ・手動反映の本番系)は RECORD_FOUND。""" + rs = routes_of(a) + if all(r.stage == delivery.STAGE_NONE for r in rs): + return RECORD_NONE + if getattr(a, "pace", None) == "auto": + return RECORD_FOUND if has_release_template(a): return "{queue_pr:release-prod}" - rs = routes_of(a) manual_prod = getattr(a, "state", None) and dev_channel_of(a).value == delivery.MANUAL_PRODUCTION if not manual_prod and any(r.stage == delivery.STAGE_PROMOTE for r in rs): return "{queue_pr:promote}" - if all(r.stage == delivery.STAGE_NONE for r in rs): - return RECORD_NONE - return RECORD_UNKNOWN + return RECORD_FOUND def manual_release_wave(a) -> dict: @@ -209,18 +211,21 @@ def normal_gate_2(a) -> str: ) -def normal_gate_2_stage(a, repo: str | None, prod: str, advise: bool) -> str: +def normal_gate_2_stage(a, repo: str | None, prod: str | None, advise: bool) -> str: """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。 - 承認資料は本番のプランの release と同じく元のリポジトリの下の絶対パスで示す(作業場所の git の根に依らない)。""" - approval = material_path(repo, f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md") + 承認資料は本番のプランの release と同じく元のリポジトリの下の絶対パスで示す(作業場所の git の根に依らない)。 + 本番の版を明示していなければ、承認資料の版は開発版のプランの版の記録(状態ディレクトリの version.json)の base である(#2164)。""" + plugin = (a.release or {}).get("plugin") + approval = material_path(repo, f"issues/approval-{plugin}-v{prod or ''}.md") + where = "" if prod else "。 は開発版のプラン release の状態ディレクトリの version.json の base" if getattr(a, "state", None): # 本番のプランの先頭の approve が状態の記録を証跡に承認の記録を書く(#1426 の決定 1) return ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、利用者が答えたら " + f"開発版の配布が書いた承認資料({approval}{where})を示して本番への配布の承認を取り、利用者が答えたら " f"`{user_gate_cmd(a, GATE_2, False)}` を打ち、承認されたら「本番」のステージの command を打つ(差し戻されたら打たない)。" "本番のプランの先頭の approve が、開発版の facts の承認したコミットとこの記録を照らして承認資料へ承認の記録を書く" + ("。承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)" if advise else "") ) return ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " + f"開発版の配布が書いた承認資料({approval}{where})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" ) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index a539ed8ac..728218681 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -15,7 +15,7 @@ from supervise_lib.decl import decl_fields from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record -from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release +from supervise_lib.release_templates import DEV_SERIES, MVV_NOTE, advise_steps, plan_release, release_names from supervise_lib.plan import sprint_state_path from supervise_lib.templates import plan_check, plan_check_since, plan_impl from supervise_lib.verify_steps import handoff_step, merge_steps, reject_step @@ -337,18 +337,23 @@ def plan_sprint_check(a, repo: str) -> dict: def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev", version: str | None = None) -> dict: """pace: normal の配布。出す PR は検査のプランのスプリントの PR(`{queue_pr:check}`)で、検査の queue の --then でも 単独で流し直しても埋まる(#1915)。本番(channel が prod)も同じ組み立てで、承認ゲート 2 の後に単独で流す(#1914)。 - `advise` なら開発版の承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。本番には置かない。""" - version = version or a.version + `advise` なら開発版の承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。本番には置かない。 + `version` は明示の版(開発版は省けば --version)で、無ければ版を流す時点に決める(開発版は origin から、本番は開発版の + プランの版の記録から。#2164)。""" + version = version or (a.version if channel == "dev" else None) + wt, branch = release_names(version) ns = argparse.Namespace( **decl_fields(a), version=version, + level=getattr(a, "level", None), + series=DEV_SERIES if channel == "prod" else None, prs=["{queue_pr:check}"], prs_from_queue=False, channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{version}", - branch=f"release/v{version}", + worktree=f"{repo}/{wt}", + branch=branch, issue=a.issue, mode=a.mode, advise=a.state if advise and channel == "dev" else None, @@ -465,19 +470,23 @@ def plan_fast_check(a, repo: str, name: str, final: bool = False, review_only: b return plan_check_since(ns) -def plan_mvv_release(a, repo: str, version: str, channel: str, condition: dict | None = None, prs: list[str] | None = None) -> dict: +def plan_mvv_release(a, repo: str, version: str | None, channel: str, condition: dict | None = None, prs: list[str] | None = None) -> dict: """pace: fast と auto のリリース。開発版は承認資料を gate_as_ok で写し、本番は先頭の mvv のステップが関門 2 を判定する。 - prs を渡せばその Pull Request(`{queue_pr:<名>}` でもよい)を出す版の PR にし、省けば前のステージの PR のすべて。""" + prs を渡せばその Pull Request(`{queue_pr:<名>}` でもよい)を出す版の PR にし、省けば前のステージの PR のすべて。 + `version` は明示の版で、無ければ版を流す時点に決める(#2164)。""" + wt, branch = release_names(version) ns = argparse.Namespace( **decl_fields(a), version=version, + level=getattr(a, "level", None), + series=DEV_SERIES if channel == "prod" else None, prs=prs or [], prs_from_queue=prs is None, channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{version}", - branch=f"release/v{version}", + worktree=f"{repo}/{wt}", + branch=branch, issue=a.issue, mode=a.mode, mvv=a.state, diff --git a/plugins/ndf/scripts/supervise_lib/steps.py b/plugins/ndf/scripts/supervise_lib/steps.py index e156d0894..0e6640191 100644 --- a/plugins/ndf/scripts/supervise_lib/steps.py +++ b/plugins/ndf/scripts/supervise_lib/steps.py @@ -50,6 +50,35 @@ def last_json(text: str) -> dict | None: return None +# ---- 計画の実行の条件(engine の実行の前と、queue の版の差し込みの前で共有する) ---- + +COND_RUN, COND_SKIP, COND_UNKNOWN = "run", "skip", "unknown" + + +def condition_cwd(plan: dict) -> str | None: + """条件を打つ場所: プランの `リポジトリ`、無ければ作業場所(まだ無ければその親のリポジトリ)。""" + wt = Path(plan.get("作業場所") or "") + cwd = plan.get("リポジトリ") or ( + str(wt) if str(wt) and wt.is_dir() else str(wt).split("/.worktrees/")[0] if "/.worktrees/" in str(wt) else None + ) + return cwd if cwd and Path(cwd).is_dir() else None + + +def run_condition(cond: dict, plan: dict, state_dir: str | Path) -> tuple[str, int, str, float]: + """条件を打ち、(COND_RUN / COND_SKIP / COND_UNKNOWN, 終了コード, 要約, 秒) を返す。""" + cmd = str(cond.get("cmd") or "").replace("{state_dir}", str(state_dir)) + started = time.time() + try: + p = subprocess.run(cmd, shell=True, cwd=condition_cwd(plan), capture_output=True, text=True, timeout=cond.get("timeout", 900)) + code, text = p.returncode, p.stdout + p.stderr + except subprocess.TimeoutExpired as e: + code, text = 124, f"打ち切り({e.timeout} 秒)" + out = last_json(text) or {} + summary = str(out.get("summary") or text.strip()[-200:] or f"exit={code}") + verdict = COND_RUN if code == 0 else COND_SKIP if code == cond.get("skip_code", 3) else COND_UNKNOWN + return verdict, code, summary, round(time.time() - started, 1) + + def prior_results(state) -> dict[str, dict]: """前の実行の結果(`--from` の再開で inputs が読む。#1676 の I7・I8)。前の実行の範囲(`RunState.prior_rows`)の `step` の行のうち、ステップごとに最後の行の `exit` と、出力ファイル(`out`)の本文を返す。`out` の無い古い行は `summary`。""" diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json index 0484b648c..653f52dc8 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json @@ -10,12 +10,12 @@ "design-glossary": "usage: supervise.py design-glossary [-h] [--mode MODE] [--root ROOT] --out OUT", "note": "usage: supervise.py note [-h] --report REPORT [--next NEXT] [--section SECTION] doc", "sync-check": "usage: supervise.py sync-check [-h] [--root ROOT] [--commit]", - "new sprint": "usage: supervise.py new sprint [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--scope PATH [PATH ...]] [--impl-timeout SECONDS] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--design DESIGN [DESIGN ...]] [--designed DESIGNED [DESIGNED ...]] [--pace {normal,fast,auto}] [--state STATE] [--absorb ISSUE:HOST [ISSUE:HOST ...]]", + "new sprint": "usage: supervise.py new sprint [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--scope PATH [PATH ...]] [--impl-timeout SECONDS] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--design DESIGN [DESIGN ...]] [--designed DESIGNED [DESIGNED ...]] [--pace {normal,fast,auto}] [--state STATE] [--absorb ISSUE:HOST [ISSUE:HOST ...]] [--level {patch,minor,major}]", "new impl": "usage: supervise.py new impl [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--impl-timeout SECONDS] [--title TITLE] [--summary SUMMARY] [--prompt PROMPT] [--prompt-file PROMPT_FILE] [--files PATH [PATH ...]] [--after NAME [NAME ...]] [--changes CHANGES] [--branch BRANCH] [--escape-of ESCAPE_OF]", "new fix": "usage: supervise.py new fix [-h] [--worktree WORKTREE] [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--title TITLE] [--summary SUMMARY] [--branch BRANCH] [--escape-of ESCAPE_OF]", "new check": "usage: supervise.py new check [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--scope PATH [PATH ...]] [--refactor-budget-minutes MINUTES] [--pr PR] [--since-last] [--id ID] [--final] [--since-ref SINCE_REF] [--review-only] [--sprint SPRINT]", "new release": "usage: supervise.py new release [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--mode MODE] [--out OUT] [--branch BRANCH] [--version VERSION] [--prs PRS [PRS ...]] [--prs-from-queue] [--channel {dev,prod}] [--prev-tag PREV_TAG] [--repo REPO] [--mvv MVV] [--dev-state DEV_STATE] [--state STATE]", - "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE]", + "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--level {patch,minor,major}] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE]", "history import": "usage: supervise.py history import [-h] [--history HISTORY] progress [progress ...]" }, "results": { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json index 1064ab17d..fb6fab526 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json @@ -7,6 +7,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0-dev.2", "branch": "release/v1.2.0-dev.2", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.2", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -15,6 +22,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.2 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json index b817949ba..7d100f1b7 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json @@ -7,6 +7,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0", "branch": "release/v1.2.0", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "prod", + "上げ幅": "patch", + "明示": "1.2.0", + "系列": null + }, "規則": "関門 2 は利用者か MVV 判定が承認した(先頭の mvv のステップが 0 を返したときだけ先へ進む)。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", "上限": 100, "steps": [ @@ -31,6 +38,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "bump-others" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json index 4da837f05..2d54ae4f9 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json @@ -7,6 +7,13 @@ "作業場所": "/repo", "branch": "release/v1.2.0-dev.1", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.1", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -15,6 +22,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.1 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json index 801c31119..753a0dedc 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json @@ -8,6 +8,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0", "branch": "release/v1.2.0", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "prod", + "上げ幅": "patch", + "明示": "1.2.0", + "系列": null + }, "規則": "利用者は関門 2 を承認した。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", "上限": 99, "steps": [ @@ -26,6 +33,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "bump-others" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json index c8e4e4d19..38bf4de87 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json @@ -8,6 +8,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0-dev.1", "branch": "release/v1.2.0-dev.1", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.1", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -16,6 +23,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.1 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, diff --git a/plugins/ndf/scripts/tests/release_repo.py b/plugins/ndf/scripts/tests/release_repo.py index 185a4472d..38a171c92 100644 --- a/plugins/ndf/scripts/tests/release_repo.py +++ b/plugins/ndf/scripts/tests/release_repo.py @@ -27,3 +27,14 @@ def seed_release_repo(tmp_path: Path) -> tuple[Path, Path]: (seed / ".ndf" / "worktree.json").write_text(json.dumps({"version": 1, "base_branch": "trunk", "production_branch": "live"})) (seed / ".ndf" / "supervise.json").write_text(json.dumps({"release": {"form": "package-plugin", "plugin": "foo"}})) return origin, seed + + +def push_origin(root: Path, branch: str = "develop") -> Path: + """root の HEAD を、隣に置く bare の origin の `branch` へ送り、取り直す(`bump` は origin を取り直してから版を読む。#2164)。""" + bare = root.parent / f"{root.name}-origin.git" + if not bare.exists(): + subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True) + _git(root, "remote", "add", "origin", str(bare)) + _git(root, "push", "-q", "-f", "--tags", "origin", f"HEAD:refs/heads/{branch}") + _git(root, "fetch", "-q", "origin") + return bare diff --git a/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py b/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py index 82bda019d..fef5033a7 100644 --- a/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py +++ b/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py @@ -15,6 +15,9 @@ import pytest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from release_repo import push_origin # noqa: E402 + SCRIPTS = Path(__file__).resolve().parents[1] RELEASE_STEPS = SCRIPTS / "release-steps.py" INSTRUCTIONS_CHECK = SCRIPTS / "instructions-check.py" @@ -70,7 +73,7 @@ def read_size(root: Path) -> int: def commit(root: Path) -> None: git(root, "add", "-A") git(root, "commit", "-q", "-m", "t") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) def check(root: Path) -> subprocess.CompletedProcess: diff --git a/plugins/ndf/scripts/tests/test_record_find.py b/plugins/ndf/scripts/tests/test_record_find.py new file mode 100644 index 000000000..dc3e52faa --- /dev/null +++ b/plugins/ndf/scripts/tests/test_record_find.py @@ -0,0 +1,174 @@ +"""まとめの差し込み `{queue_record}` と、リリース記録を置いた PR の探し方(#2230 の I3〜I5・決定 2・3)。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import gh_call # noqa: E402 +from sprint_close_lib import record_find # noqa: E402 +from sprint_close_lib.record_find import RecordHit, find_record # noqa: E402 +from supervise_lib import queue # noqa: E402 +from supervise_lib.plan import leftover_placeholders # noqa: E402 +from supervise_lib.queue_fill import fill_queue_pr # noqa: E402 + +REPO = "o/r" + + +def record(stage="本番(npm)", sprint=(10,)): + return f"## 配布の記録\n\n段階: {stage}\n版: 1.0.0 → 1.1.0(minor)\nスプリント: PR " + " / ".join(f"#{n}" for n in sprint) + "\n" + + +class FakeGh: + """課題と PR の履歴の参照(refs: 番号 → [(PR, 本文)])と、PR の本文とコメント(prs: 番号 → (本文, [(識別子, 本文)]))。""" + + def __init__(self, refs, prs=None, fail=()): + self.refs, self.prs, self.fail, self.calls = refs, prs or {}, set(fail), [] + + def __call__(self, args, stdin=None, cwd=None): + self.calls.append(args) + if args[:2] == ["repo", "view"]: + return gh_call.GhResult(0, REPO + "\n", "") + if args[0] == "api": + n = int(args[-1].split("/")[-2]) + if n in self.fail: + return gh_call.GhResult(1, "", "HTTP 502") + events = [ + { + "event": "cross-referenced", + "source": {"issue": {"number": pr, "body": body, "pull_request": {}, "repository": {"full_name": REPO}}}, + } + for pr, body in self.refs.get(n, []) + ] + return gh_call.GhResult(0, json.dumps([events + [{"event": "labeled"}]]), "") + if args[:2] == ["pr", "view"]: + body, comments = self.prs.get(int(args[2]), ("", [])) + cs = [{"body": b, "url": f"https://github.com/{REPO}/pull/{args[2]}#issuecomment-{cid}"} for cid, b in comments] + return gh_call.GhResult(0, json.dumps({"body": body, "comments": cs}), "") + raise AssertionError(args) + + +@pytest.fixture +def fake(monkeypatch): + def install(refs, prs=None, fail=()): + g = FakeGh(refs, prs, fail) + monkeypatch.setattr(gh_call, "gh", g) + return g + + return install + + +def pr_views(g): + return [int(a[2]) for a in g.calls if a[:2] == ["pr", "view"]] + + +def test_skips_the_later_sprint_reached_through_a_pr_that_only_refers_to_the_issue(fake): + """#2089 の形: 閉じる PR #2103 の記録 #2126 を採り、参照だけの #2128 を通じた後のスプリントの記録 #2138 を採らない。""" + g = fake( + { + 2089: [(2103, "Closes #2089"), (2128, "#2089 の確定仕様化"), (2091, "refs #2089")], + 2103: [(2120, "#2103"), (2126, "#2103")], + 2128: [(2138, "#2128")], + }, + {2126: ("", [(500, record(sprint=(2098, 2103)))]), 2138: ("", [(900, record(sprint=(2128,)))])}, + ) + hit = find_record(".", None, [2089]) + assert hit.pr == 2126 and hit.closers == (2103,) + assert 2138 not in pr_views(g) + + +def test_two_sprints_closing_the_same_issue_are_not_guessed(fake): + """課題を持ち越して 2 つのスプリントで閉じる PR を出した形: どちらも採らず、理由に両方の記録の PR が載る。""" + fake( + {5: [(10, "Closes #5"), (20, "最終工程の後に閉じる: #5")], 10: [(30, "#10")], 20: [(40, "#20")]}, + {30: ("", [(100, record(sprint=(10,)))]), 40: ("", [(200, record(sprint=(20, 21)))])}, + ) + hit = find_record(".", None, [5]) + assert hit.pr is None and [pr for pr, _ in hit.ambiguous] == [30, 40] + assert "PR #30(スプリント: #10)" in hit.reason() and "PR #40(スプリント: #20 #21)" in hit.reason() + + +def test_a_record_covering_only_some_of_the_issues_is_not_taken(fake): + fake( + {5: [(10, "Closes #5")], 6: [(11, "Fixes #6")], 10: [(30, "#10")]}, + {30: ("", [(100, record(sprint=(10,)))])}, + ) + assert find_record(".", None, [5, 6]).pr is None + + +def test_an_issue_without_a_closing_pr_does_not_block_the_record(fake): + fake( + {5: [(10, "Closes #5")], 7: [(12, "#7 に触れた")], 10: [(30, "#10")]}, + {30: ("", [(100, record(sprint=(10,)))])}, + ) + assert find_record(".", None, [5, 7]).pr == 30 + + +def test_no_closing_pr_at_all_reads_no_record(fake): + g = fake({5: [(12, "#5 に触れた")]}) + hit = find_record(".", None, [5]) + assert hit.pr is None and pr_views(g) == [] and "#5 を閉じる PR が見つからない" in hit.reason() + + +def test_a_record_before_the_production_release_is_not_taken(fake): + fake({5: [(10, "Closes #5")], 10: [(30, "#10")]}, {30: ("", [(100, record(stage="検証(staging)"))])}) + hit = find_record(".", None, [5]) + assert hit.pr is None and hit.pre_release == (30,) + assert "閉じる PR #10 と、それを参照する PR #30 を読んだ" in hit.reason() and "本番の前の記録だけ: PR #30" in hit.reason() + + +def test_the_record_in_the_latest_comment_wins_over_a_body_record(fake): + fake( + {5: [(10, "Closes #5")], 10: [(30, "#10"), (40, "#10")]}, + {30: ("", [(900, record())]), 40: (record(), [])}, + ) + assert find_record(".", None, [5]).pr == 30 + + +def test_github_errors_are_reported(fake): + fake({}, fail=[5]) + hit = find_record(".", None, [5]) + assert hit.pr is None and hit.reason().startswith("GitHub を読めない: HTTP 502") + + +def close_plan(tmp_path, issues=(5,)): + p = tmp_path / "close.json" + steps = [ + {"id": "release-verify", "type": "work", "prompt": "配布の記録は Pull Request #{queue_record} にある。"}, + {"id": "close", "type": "run", "cmd": "sprint-close.py --record-pr {queue_record} --issues 5"}, + ] + p.write_text(json.dumps({"課題": list(issues), "リポジトリ": str(tmp_path), "steps": steps}, ensure_ascii=False)) + return p + + +def test_fill_writes_the_found_record_into_every_step(tmp_path, monkeypatch): + seen = [] + monkeypatch.setattr(record_find, "find_record", lambda root, repo, issues: (seen.append((root, issues)), RecordHit(pr=30))[1]) + p = close_plan(tmp_path) + assert fill_queue_pr(str(p), []) is None + steps = json.loads(p.read_text())["steps"] + assert "#30 にある" in steps[0]["prompt"] and "--record-pr 30 " in steps[1]["cmd"] + assert seen == [(str(tmp_path), [5])] + + +def test_fill_leaves_the_plan_and_the_stage_is_unfilled_when_no_record_is_found(tmp_path, monkeypatch): + monkeypatch.setattr(record_find, "find_record", lambda root, repo, issues: RecordHit(issues=(5,))) + p = close_plan(tmp_path) + before = p.read_text() + skipped, runnable = queue.fill_stage(True, [str(p)], []) + assert runnable == [] and skipped[0]["unfilled"] is True + assert "{queue_record}(課題 #5 を閉じる PR が見つからない" in skipped[0]["reason"] + assert p.read_text() == before and leftover_placeholders(before) == ["{queue_record}"] + + +def test_plans_without_the_marker_do_not_read_github(tmp_path, monkeypatch): + monkeypatch.setattr(record_find, "find_record", lambda *a: pytest.fail("GitHub を読んだ")) + p = tmp_path / "x.json" + p.write_text(json.dumps({"課題": [5], "steps": [{"id": "a", "cmd": "sprint-close.py --record-pr unknown"}]})) + assert fill_queue_pr(str(p), []) is None diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index d1f6b5a99..05cc8a195 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -16,6 +17,7 @@ SCRIPT = Path(__file__).resolve().parents[1] / "release-steps.py" sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 PY = sys.executable @@ -558,7 +560,7 @@ def bump_repo(root: Path, base_version: str, work_version: str) -> None: f.write_text(json.dumps({"version": base_version}), encoding="utf-8") git(root, "add", "-A") git(root, "commit", "-q", "-m", "base") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) if work_version != base_version: f.write_text(json.dumps({"version": work_version}), encoding="utf-8") git(root, "commit", "-q", "-am", "bump") @@ -572,11 +574,73 @@ def test_bump_rerun_after_the_version_was_raised_is_ok(repo): assert res["status"] == "ok" and res["metrics"]["from"] == "1.2.3" and res["items"] == [] -def test_bump_to_the_version_of_the_base_still_stops(repo): +def fake_gh_list(tmp: Path, out: str, code: int = 0) -> dict: + """`gh pr list` の答えを固定する偽の gh を PATH の先頭に置いた環境(自分の配布の照会。#2164)。""" + d = tmp / "fakebin" + d.mkdir(exist_ok=True) + (d / "gh").write_text(f"#!/bin/sh\nprintf '%s' '{out}'\nexit {code}\n") + (d / "gh").chmod(0o755) + return {**os.environ, "PATH": f"{d}:{os.environ['PATH']}"} + + +def run_env(root: Path, env: dict, *args: str) -> subprocess.CompletedProcess: + return subprocess.run([PY, str(SCRIPT), *args[:1], "--root", str(root), *args[1:]], capture_output=True, text=True, env=env) + + +def last_json(p: subprocess.CompletedProcess) -> dict: + return json.loads(p.stdout.strip().splitlines()[-1]) + + +def manifest(root: Path) -> str: + return (root / "plugins/ndf/.claude-plugin/plugin.json").read_text(encoding="utf-8") + + +def test_bump_below_or_at_the_base_stops_without_writing(repo): + """ベースブランチが 10.27.5 のとき 10.27.5-dev.1 へは上げない(版を戻さない。受け入れ条件 5)。""" + bump_repo(repo, "10.27.5", "10.27.5") + before = manifest(repo) + p = run(repo, "bump", "--plugin", "ndf", "--to", "10.27.5-dev.1") + res = last_json(p) + assert p.returncode == 3 and res["status"] == "stopped", p.stdout + assert res["metrics"]["existing"] == "10.27.5" and res["metrics"]["next_free"] == "10.27.6-dev.1" + assert manifest(repo) == before + + +def test_bump_to_a_release_with_a_tag_stops(repo): + bump_repo(repo, "1.2.3", "1.2.3") + git(repo, "tag", "ndf--v1.2.4") + push_origin(repo) + p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") + res = last_json(p) + assert p.returncode == 3 and res["metrics"]["existing"] == "ndf--v1.2.4" and res["metrics"]["next_free"] == "1.2.5" + + +def test_bump_to_a_release_below_the_base_without_a_tag_stops(repo): + """現状固定: タグの無い正式版でもベースブランチの版以下なら止め、次に空いている版はベースブランチの次のパッチ。""" + bump_repo(repo, "1.2.5", "1.2.5") + p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") + res = last_json(p) + assert p.returncode == 3 and res["metrics"]["existing"] == "1.2.5" and res["metrics"]["next_free"] == "1.2.6", p.stdout + + +def test_bump_at_the_base_is_a_rerun_only_after_its_own_release(repo, tmp_path): + """作業場所とベースブランチがともに新しい版なら、release/v<版> のマージ済みの PR があるときだけ打ち直し(決定 2)。""" bump_repo(repo, "1.2.4", "1.2.4") + own = run_env(repo, fake_gh_list(tmp_path, '[{"number": 7}]'), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert own.returncode == 0 and last_json(own)["metrics"]["already"] is True, own.stdout + other = run_env(repo, fake_gh_list(tmp_path, "[]"), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert other.returncode == 3 and last_json(other)["metrics"]["existing"] == "1.2.4" + unknown = run_env(repo, fake_gh_list(tmp_path, "", 1), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert unknown.returncode == 2 and last_json(unknown)["metrics"]["unreadable"] == ["pr"] + + +def test_bump_without_origin_stops_as_unreadable(repo): + bump_repo(repo, "1.2.3", "1.2.3") + git(repo, "remote", "remove", "origin") + before = manifest(repo) p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") - assert p.returncode != 0 - assert "旧版と新版が同じ" in p.stdout + p.stderr + assert p.returncode == 2 and "origin" in last_json(p)["metrics"]["unreadable"] + assert manifest(repo) == before def test_bump_leaves_a_line_repeated_elsewhere_to_the_hand(repo): @@ -650,7 +714,7 @@ def test_changed_plugins_without_the_tag_is_two(repo): def test_bump_raises_the_other_plugin_from_changed_plugins(repo): root = changed_repo(repo) - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) _, res = changed(root, "--since", "ndf--v1.0.0") it = res["items"][0] p = run(root, "bump", "--plugin", it["name"], "--to", it["to"]) diff --git a/plugins/ndf/scripts/tests/test_release_version.py b/plugins/ndf/scripts/tests/test_release_version.py new file mode 100644 index 000000000..56079579d --- /dev/null +++ b/plugins/ndf/scripts/tests/test_release_version.py @@ -0,0 +1,343 @@ +"""配布の版を流す時点に決める(#2164): 版の判定(lib/release_version.py)とキューの版の差し込み(queue_fill.fill_release_version)。 + +一時の bare の origin にベースブランチ develop・本番のブランチ main・タグ `ndf--v<版>` を作り、プランを作った後に +別のスプリントが版を出した状況を、origin へ版を送って再現する。自分の配布のマージ済みの PR の照会は差し替える。 +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(SCRIPTS / "lib")) + +import release_version as rv # noqa: E402 +from supervise_lib import queue, queue_fill # noqa: E402 +from supervise_lib.paths import state_dir_of # noqa: E402 + + +def git(root: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True).stdout + + +def obs(base, latest=None, prod=None, tags=None) -> rv.Observed: + tags = tags if tags is not None else ([latest] if latest else []) + return rv.Observed("ndf", "origin/develop", base, "origin/main", prod, latest, tags) + + +# ---- 版の判定 ---- + + +@pytest.mark.parametrize( + ("base", "latest", "prod", "level", "want"), + [ + ("10.27.5", "10.27.5", "10.27.5", "patch", "10.27.6-dev.1"), # 受け入れ条件 2 の例 1 + ("10.27.6-dev.1", "10.27.5", "10.27.5", "patch", "10.27.6-dev.2"), # 例 2(10.27.6 のタグが無い) + ("10.27.6", "10.27.6", "10.27.6", "patch", "10.27.7-dev.1"), # 受け入れ条件 3(別のスプリントが 10.27.6 を出した) + ("10.27.6-dev.1", "10.27.6", "10.27.6", "patch", "10.27.7-dev.1"), # ベースブランチの系列のタグがもうある + ("10.27.5", "10.27.5", "10.28.0", "patch", "10.28.1-dev.1"), # 本番のブランチが先にある + ("10.27.5", "10.27.5", "10.27.5", "minor", "10.28.0-dev.1"), + ], +) +def test_decide_dev_takes_the_next_free_version_above_the_base(base, latest, prod, level, want): + d = rv.decide_dev(obs(base, latest, prod), level) + assert d.version == want and d.base == want.split("-")[0] + assert rv.versions.compare_versions(d.version, base) > 0 # I2 + + +def test_decide_dev_does_not_decide_when_something_is_unreadable(): + o = obs("10.27.5", "10.27.5") + o.unreadable = ["origin/main"] + assert rv.decide_dev(o).version is None and rv.decide_dev(o).unreadable == ["origin/main"] + + +def test_taken_compares_dev_versions_with_the_base_and_releases_with_tags(): + o = obs("10.27.5", "10.27.5", "10.27.5") + assert rv.taken(o, "10.27.5-dev.1").existing == "10.27.5" + assert rv.taken(o, "10.27.5-dev.1").next_free == "10.27.6-dev.1" + assert not rv.taken(o, "10.27.6-dev.1").is_taken + assert rv.taken(o, "10.27.5").existing == "ndf--v10.27.5" and rv.taken(o, "10.27.5").next_free == "10.27.6" + assert not rv.taken(o, "10.27.6").is_taken + + +# ---- 一時の origin ---- + + +class World: + """bare の origin と、版を送る作業用の clone。`repo` はプランのリポジトリ(版を読む側)。""" + + def __init__(self, tmp: Path, version: str = "10.27.5"): + self.origin = tmp / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "develop", str(self.origin)], check=True) + self.repo = tmp / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q", "-b", "develop") + for k, v in (("user.email", "t@example.com"), ("user.name", "t"), ("commit.gpgsign", "false")): + git(self.repo, "config", k, v) + (self.repo / ".ndf").mkdir() + (self.repo / ".ndf" / "worktree.json").write_text(json.dumps({"version": 1, "base_branch": "develop", "production_branch": "main"})) + git(self.repo, "remote", "add", "origin", str(self.origin)) + self.publish(version, "develop", "main", tag=True) + + def publish(self, version: str, *branches: str, tag: bool = False) -> None: + """別のスプリントの配布を模す: plugin.json を version にしたコミットを branches へ送り、tag なら正式版のタグも送る。""" + f = self.repo / "plugins" / "ndf" / ".claude-plugin" / "plugin.json" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(json.dumps({"version": version})) + git(self.repo, "add", "-A") + git(self.repo, "commit", "-q", "-m", version) + for b in branches: + git(self.repo, "push", "-q", "-f", "origin", f"HEAD:refs/heads/{b}") + if tag: + git(self.repo, "tag", f"ndf--v{version}") + git(self.repo, "push", "-q", "origin", f"ndf--v{version}") + + +@pytest.fixture +def world(tmp_path, monkeypatch): + monkeypatch.setenv("NDF_SV_STATE_DIR", str(tmp_path / "sv")) + return World(tmp_path) + + +def write_plan(tmp: Path, world: World, name: str = "7-release", channel: str = "dev", explicit=None, series=None, level="patch") -> Path: + v = explicit or "{release_version}" + base = explicit.split("-")[0] if explicit else "{release_base}" + plan = { + "フェーズ": "配布", + "作業場所": f"{world.repo}/.worktrees/release/v{v}", + "branch": f"release/v{v}", + "起点": "origin/develop", + "版": {"プラグイン": "ndf", "チャネル": channel, "上げ幅": level, "明示": explicit, "系列": series}, + "リポジトリ": str(world.repo), + "steps": [ + {"id": "bump", "type": "run", "cmd": f"bump --to {v}"}, + {"id": "facts", "type": "run", "cmd": f"approval-facts --version {base} --approval issues/approval-ndf-v{base}.md"}, + ], + } + d = tmp / "plans" + d.mkdir(exist_ok=True) + p = d / f"{name}.json" + p.write_text(json.dumps(plan, ensure_ascii=False)) + return p + + +def record(plan: Path) -> dict: + return json.loads((state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).read_text()) + + +def own(monkeypatch, answer): + calls = [] + + def fake(repo, branch, base): + calls.append(branch) + return answer + + monkeypatch.setattr(rv, "own_released", fake) + return calls + + +def fill(plan: Path, items=None): + return queue_fill.fill_release_version(str(plan), items or []) + + +# ---- 開発版 ---- + + +def test_dev_plan_takes_the_version_that_is_free_when_it_runs(tmp_path, world): + """受け入れ条件 3: 作った後に別のスプリントが 10.27.6 を本番へ出しても、作り直さずに 10.27.7-dev.1 で埋まる。""" + plan = write_plan(tmp_path, world, level="patch") + world.publish("10.27.6", "develop", "main", tag=True) + assert fill(plan) == (None, None) + got = json.loads(plan.read_text()) + assert got["branch"] == "release/v10.27.7-dev.1" and got["作業場所"].endswith("/release/v10.27.7-dev.1") + assert got["steps"][0]["cmd"] == "bump --to 10.27.7-dev.1" # 受け入れ条件 9: release の --version と同じ差し込み + assert "approval-ndf-v10.27.7.md" in got["steps"][1]["cmd"] + rec = record(plan) + assert rec["version"] == "10.27.7-dev.1" and rec["base"] == "10.27.7" and rec["how"] == "decided" and rec["level"] == "patch" + assert rec["observed"] == { + "base_ref": "origin/develop", + "base_version": "10.27.6", + "production_ref": "origin/main", + "production_version": "10.27.6", + "latest_tag": "10.27.6", + } + assert rec["history"] == [] + + +def test_explicit_dev_version_that_is_already_out_stops_before_the_plan_runs(tmp_path, world): + """受け入れ条件 4・I5: 明示の版がベースブランチの版以下なら、記録を書かず、プランを書き換えずに止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + before = plan.read_text() + err, info = fill(plan) + assert err and info["cause"] == "taken" and info["existing"] == "10.27.5" and info["next_free"] == "10.27.6-dev.1" + assert plan.read_text() == before + assert not (state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).exists() + + +def test_explicit_dev_version_that_is_free_runs_unchanged(tmp_path, world): + """受け入れ条件 10・I8: 明示の版がまだ出ていなければ、プランはそのまま流れる。""" + plan = write_plan(tmp_path, world, explicit="10.27.6-dev.1") + before = plan.read_text() + assert fill(plan) == (None, None) + assert plan.read_text() == before and record(plan)["how"] == "explicit" + + +def test_rerun_keeps_the_recorded_version(tmp_path, world, monkeypatch): + """受け入れ条件 7・I3: 流し直しは記録の版を使う。ベースブランチがまだ小さければ照会せず、自分の配布の後なら照会して同じ版。""" + plan = write_plan(tmp_path, world) + fill(plan) + rec = record(plan) + calls = own(monkeypatch, True) + assert fill(plan) == (None, None) and calls == [] + world.publish("10.27.6-dev.1", "develop") # 自分の release/v10.27.6-dev.1 がマージされた + assert fill(plan) == (None, None) and calls == ["release/v10.27.6-dev.1"] + assert record(plan) == rec and json.loads(plan.read_text())["branch"] == "release/v10.27.6-dev.1" + + +def test_rerun_redecides_when_another_sprint_took_the_version_first(tmp_path, world, monkeypatch): + """決定 8・I9: 記録の版が自分の配布のマージより前に別のスプリントに出ていたら、写しから次の版で埋め直す。""" + plan = write_plan(tmp_path, world) + fill(plan) + sd = state_dir_of(str(plan.absolute())) + (sd / "state.json").write_text('{"log": [{"id": "bump", "exit": 3}]}') + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + assert fill(plan) == (None, None) + got = json.loads(plan.read_text()) + assert got["branch"] == "release/v10.27.7-dev.1" and got["steps"][0]["cmd"] == "bump --to 10.27.7-dev.1" + rec = record(plan) + assert rec["version"] == "10.27.7-dev.1" + [h] = rec["history"] + assert (h["from"], h["to"], h["reason"], h["existing"]) == ("10.27.6-dev.1", "10.27.7-dev.1", "taken", "10.27.6") + assert h["left"]["branch"] == "release/v10.27.6-dev.1" and h["left"]["作業場所"].endswith("/release/v10.27.6-dev.1") + assert (sd / "superseded" / "v10.27.6-dev.1" / "state.json").is_file() and not (sd / "state.json").exists() + + +def test_redecide_interrupted_after_the_record_refills_once(tmp_path, world, monkeypatch): + """決定 8(落ちたときの戻り): 記録を書き換えた後、プランを書き直す前に落ちても、流し直しで同じ版に埋まり履歴は 1 件。""" + plan = write_plan(tmp_path, world) + fill(plan) + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + real = queue_fill._Fill.ensure_filled + + def crash(self, rec): + raise RuntimeError("落ちた") + + monkeypatch.setattr(queue_fill._Fill, "ensure_filled", crash) + with pytest.raises(RuntimeError): + fill(plan) + assert json.loads(plan.read_text())["branch"] == "release/v10.27.6-dev.1" + monkeypatch.setattr(queue_fill._Fill, "ensure_filled", real) + assert fill(plan) == (None, None) + assert json.loads(plan.read_text())["branch"] == "release/v10.27.7-dev.1" and len(record(plan)["history"]) == 1 + + +def test_explicit_record_is_not_redecided(tmp_path, world, monkeypatch): + """決定 8・受け入れ条件 4: 明示の版の記録は、別のスプリントに出ていても決め直さずに止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.6-dev.1") + fill(plan) + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + err, info = fill(plan) + assert err and info["cause"] == "taken" and record(plan)["version"] == "10.27.6-dev.1" and record(plan)["history"] == [] + + +def test_unreadable_origin_stops_without_a_record(tmp_path, world, monkeypatch): + """受け入れ条件 8・I6: origin を読めなければ記録を書かずに止め、読めなかったものを載せる。記録がある流し直しも変えない。""" + plan = write_plan(tmp_path, world) + git(world.repo, "remote", "set-url", "origin", str(tmp_path / "gone.git")) + err, info = fill(plan) + assert err and info["cause"] == "unreadable" and "origin" in info["unreadable"] + assert not (state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).exists() + git(world.repo, "remote", "set-url", "origin", str(world.origin)) + fill(plan) + rec, text = record(plan), plan.read_text() + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, None) # 自分の配布の照会ができない + err, info = fill(plan) + assert info["cause"] == "unreadable" and info["unreadable"] == ["pr"] and record(plan) == rec and plan.read_text() == text + + +def test_plan_without_the_version_field_is_left_alone(tmp_path, world): + """非機能(移行性): 「版」の欄を持たない、この変更より前のプランには触らない。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + body = json.loads(plan.read_text()) + del body["版"] + plan.write_text(json.dumps(body)) + before = plan.read_text() + assert fill(plan) == (None, None) and plan.read_text() == before + + +# ---- 本番 ---- + + +def dev_and_prod(tmp_path, world): + dev = write_plan(tmp_path, world) + fill(dev) + (state_dir_of(str(dev.absolute())) / "report.md").write_text("- 結果: 完了\n") + prod = write_plan(tmp_path, world, "8-release-prod", "prod", series="release") + return dev, prod + + +def test_prod_plan_uses_the_base_of_the_dev_record(tmp_path, world): + """受け入れ条件 6・I4: 本番は開発版の記録の base で埋まる。""" + _, prod = dev_and_prod(tmp_path, world) + assert fill(prod) == (None, None) + assert json.loads(prod.read_text())["branch"] == "release/v10.27.6" and record(prod)["how"] == "series" + + +def test_prod_plan_stops_when_the_release_is_already_tagged(tmp_path, world): + """受け入れ条件 6: 本番の版のタグがすでにあれば、次に空いている版へ移らずに止め、既にある版と次に空いている版を載せる。""" + _, prod = dev_and_prod(tmp_path, world) + world.publish("10.27.6", "develop", "main", tag=True) + before = prod.read_text() + err, info = fill(prod) + assert err and info["cause"] == "taken" and info["existing"] == "ndf--v10.27.6" and info["next_free"] == "10.27.7" + assert prod.read_text() == before + + +def test_prod_plan_without_the_dev_record_stops(tmp_path, world): + prod = write_plan(tmp_path, world, "8-release-prod", "prod", series="release") + err, info = fill(prod) + assert err and info["cause"] == "no_series" + + +def test_prod_follows_a_changed_dev_record_only_before_gate_2(tmp_path, world): + """決定 8・I4: 本番の記録の後に開発版の記録の base が変わったら、承認ゲート 2 の前は追い、後は止める。""" + dev, prod = dev_and_prod(tmp_path, world) + fill(prod) + rec_path = state_dir_of(str(dev.absolute())) / queue_fill.VERSION_RECORD + rec = json.loads(rec_path.read_text()) + rec_path.write_text(json.dumps({**rec, "version": "10.27.7-dev.1", "base": "10.27.7"})) + assert fill(prod) == (None, None) + assert json.loads(prod.read_text())["branch"] == "release/v10.27.7" and record(prod)["history"][0]["reason"] == "series_changed" + (state_dir_of(str(prod.absolute())) / "state.json").write_text('{"log": [{"id": "approve", "exit": 0}]}') + rec_path.write_text(json.dumps({**rec, "version": "10.27.8-dev.1", "base": "10.27.8"})) + err, info = fill(prod) + assert err and info["cause"] == "series_changed" and json.loads(prod.read_text())["branch"] == "release/v10.27.7" + + +# ---- キューのステージ ---- + + +def test_fill_stage_reports_the_version_of_a_plan_it_did_not_run(tmp_path, world): + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + skipped, runnable = queue.fill_stage(True, [str(plan)], []) + assert runnable == [] and skipped[0]["unfilled"] is True and skipped[0]["version"]["cause"] == "taken" + + +@pytest.mark.parametrize(("code", "runs"), [(3, True), (0, False)]) +def test_fill_stage_asks_the_condition_before_the_version(tmp_path, world, code, runs): + """実行の条件が配布不要なら、明示の版が既出でも版を照会せずに流す(engine が条件で完了にする)。要るなら従来どおり止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + info = json.loads(plan.read_text()) + plan.write_text(json.dumps({**info, "実行の条件": {"cmd": f"exit {code}", "skip_code": 3}}, ensure_ascii=False)) + skipped, runnable = queue.fill_stage(True, [str(plan)], []) + assert (runnable == [str(plan)] and skipped == []) if runs else (runnable == [] and skipped[0]["unfilled"] is True) diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 7d9f0dbc7..cca5555d1 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -610,6 +610,8 @@ def test_lap_copies_values_carry_reaches_the_next_close_and_list_shows_laps(env) sl_select.cmd_select(env, "l1") out = sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.2", None, []) assert "--carry 31 32" in out["metrics"]["close_command"] and "--pace auto" in out["metrics"]["close_command"] + # 周回の版は出し終えているので、まとめへ明示の版として渡さない(流す時点に次に空いている版で決める。#2164) + assert "--version" not in out["metrics"]["close_command"] and "--prod" not in out["metrics"]["close_command"] assert "1-close.json" in out["metrics"]["queue"].split("--then")[-1] assert out["metrics"]["pipeline"].count("&&") == 2 and "wait" in out["metrics"]["pipeline"] rows = sl.cmd_list(env, "l1")["items"] @@ -918,6 +920,22 @@ def run(cmd, cwd=None, stdin=None): assert rest.startswith("python3 ") and "1-close.json" in rest and "loop lap l1" in rest +def test_sprint_pipeline_chains_close_after_the_first_command_without_hands(tmp_path): + done, rec_ = tmp_path / "done-1.json", {"name": "l1"} + index = [{"name": "設計", "gate": True}, {"name": "実装", "command": "Q1"}] + queue, pipeline = sl.sprint_pipeline(index, ["c1", "c 2"], done, rec_) + assert queue == f"Q1 --then c1 'c 2' --done {done}" + assert pipeline == f"{queue} && {sl_gate.wait_lap(rec_, done)}" + + +def test_sprint_pipeline_waits_for_hands_before_the_close(tmp_path): + done, rec_ = tmp_path / "done-1.json", {"name": "l1"} + index = [{"name": "実装", "command": "Q1"}, {"name": "ゲート 2", "command": "Q2"}, {"name": "リリース", "manual": "/ndf:release"}] + queue, pipeline = sl.sprint_pipeline(index, ["c1"], done, rec_) + assert queue == f"python3 {sl.SUPERVISE} queue c1 --max 3 --done {done}" + assert pipeline == f"Q1 を流し、続けて Q2 → リリース(/ndf:release で行う) を終えてから: {queue} && {sl_gate.wait_lap(rec_, done)}" + + def test_refused_continuation_after_gate_1_is_rebuilt_from_the_kept_issues(env, monkeypatch): lap = built_lap(env) real = sl_gate.continuation @@ -931,3 +949,254 @@ def test_refused_continuation_after_gate_1_is_rebuilt_from_the_kept_issues(env, assert got["status"] == "ok" and "loop lap l1" in got["metrics"]["next"] close = next(c for c in reversed(env.calls) if "close" in c) assert close[close.index("--issue") + 1 : close.index("--state")] == ["12"] + + +def test_gate_2_resume_builds_the_close_without_the_check_pr_as_the_record(env): + """#2230 の受け入れ条件 2: 承認ゲート 2 の後のまとめへ、止まった記録の values.pr(検査のスプリント PR)を --record-pr で渡さない。""" + lap = built_lap(env) + assert sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / "prod.json"), "result": "関門"}]))["exit"] == 3 + r = rec(env) + r["stop"]["values"]["pr"] = 2103 + sl.write_record(env, r) + approve_gate_2(sl.current_lap(rec(env))) + env.calls.clear() + nxt = sl.cmd_start(env, "l1", [], {})["metrics"]["next"] + closes = [c for c in env.calls if "new" in c and "close" in c] + assert closes and all("--record-pr" not in c for c in closes) and "loop lap l1" in nxt + + +def test_lap_with_an_unfilled_close_is_closed_as_failed(env): + """#2230 の決定 7: 差し込みを埋められずに流さなかったまとめ(記録の PR が見つからない)がある周回は完了で締めない。""" + lap = built_lap(env, nums=(11,)) + set_state(env, lap) + items = [ + {"plan": str(Path(lap["out"]) / "check.json"), "result": "完了"}, + { + "plan": str(Path(lap["out"]) / "close" / "1-close.json"), + "result": "流さなかった", + "reason": "{queue_record}(…)", + "unfilled": True, + }, + ] + sl.cmd_lap(env, "l1", False, write_done(env, items), []) + assert rec(env)["laps"][0]["outcome"] == sl.FAILED + + +def test_lap_with_a_plan_skipped_after_a_finished_stage_is_still_done(env): + lap = built_lap(env, nums=(11,)) + set_state(env, lap) + items = [ + {"plan": str(Path(lap["out"]) / "check.json"), "result": "完了"}, + {"plan": str(Path(lap["out"]) / "prod.json"), "result": "流さなかった", "reason": "前のステージを流さなかった"}, + ] + sl.cmd_lap(env, "l1", False, write_done(env, items), []) + assert rec(env)["laps"][0]["outcome"] == sl.DONE + + +# ---------------------------------------------------------------- gate2_answer の現状固定(I-004) + + +def g2_lap(tmp_path, gates=None): + state = tmp_path / "state.json" + state.write_text(json.dumps({"gates": gates or []})) + return {"state_path": str(state)}, str(state.resolve()) + + +def g2_ledger(*rows, raw=None): + import gate_rejections as gr + + p = gr.ledger_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(raw if raw is not None else "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)) + + +def test_gate2_answer_is_none_without_a_state_path_or_answers(tmp_path): + assert sl.gate2_answer({}) is None + assert sl.gate2_answer({}, "2026-10-01T00:00:00Z") is None + lap, _ = g2_lap(tmp_path) + assert sl.gate2_answer(lap) is None + assert sl.gate2_answer({"state_path": str(tmp_path / "missing.json")}) is None + + +def test_gate2_answer_reads_user_approvals_of_gate_2_only_after_since(tmp_path): + gates = [ + {"name": "関門 2", "by": "mvv", "at": "2026-10-05T00:00:00Z"}, # MVV の通過は数えない + {"name": "関門 1", "by": "user", "at": "2026-10-05T00:00:00Z"}, # 別の関門は数えない + {"name": "関門 2", "by": "user", "at": "2026-10-01T00:00:00Z"}, # since より前 + ] + lap, _ = g2_lap(tmp_path, gates) + assert sl.gate2_answer(lap, "2026-10-02T00:00:00Z") is None + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" # 同じ時刻は数える + assert sl.gate2_answer(lap) == "approved" + lap2, _ = g2_lap(tmp_path, [{"name": "関門 2", "by": "user"}]) # 時刻の無い答え + assert sl.gate2_answer(lap2) == "approved" + assert sl.gate2_answer(lap2, "2026-10-01T00:00:00Z") is None + + +def test_gate2_answer_reads_rejections_of_the_same_sprint_from_the_ledger(tmp_path): + lap, sp = g2_lap(tmp_path) + g2_ledger( + {"gate": "関門 2", "sprint": sp, "at": "2026-10-03T00:00:00Z"}, # outcome の無い行は差し戻し + {"gate": "関門 2", "sprint": sp, "at": "2026-10-09T00:00:00Z", "outcome": "approved"}, # 承認の行は読まない + {"gate": "関門 1", "sprint": sp, "at": "2026-10-09T00:00:00Z"}, + {"gate": "関門 2", "sprint": "/other.json", "at": "2026-10-09T00:00:00Z"}, + ) + assert sl.gate2_answer(lap, "2026-10-02T00:00:00Z") == "rejected" + assert sl.gate2_answer(lap, "2026-10-04T00:00:00Z") is None + assert sl.gate2_answer(lap) == "rejected" + + +def test_gate2_answer_takes_the_latest_answer(tmp_path): + lap, sp = g2_lap(tmp_path, [{"name": "関門 2", "by": "user", "at": "2026-10-05T00:00:00Z"}]) + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-04T00:00:00Z", "outcome": "rejected"}) + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-06T00:00:00Z", "outcome": "rejected"}) + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "rejected" + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-05T00:00:00Z", "outcome": "rejected"}) # 同じ時刻は先の承認 + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + + +def test_gate2_answer_treats_a_broken_ledger_as_empty(tmp_path): + lap, _ = g2_lap(tmp_path, [{"name": "関門 2", "by": "user", "at": "2026-10-05T00:00:00Z"}]) + g2_ledger(raw="{壊れた行\n") + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + (tmp_path / "x").mkdir() + lap2, _ = g2_lap(tmp_path / "x") + assert sl.gate2_answer(lap2) is None + + +# ---------------- 現状固定: resume_stopped の止まった理由ごとの分岐 ---------------- + + +def stopped_on(env, cond, **stop): + """記録を cond で止まった状態にして返す(resume_stopped を直接通すため)。""" + r = rec(env) + r["status"], r["stop"] = "stopped", {"condition": cond, "at": "2026-10-25T00:00:00Z", "values": {}, **stop} + sl.write_record(env, r) + return rec(env) + + +def test_resume_stopped_mvv_changed_stays_stopped_and_points_to_a_new_loop(env): + start(env) + r = stopped_on(env, "mvv_changed") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "新しい名前でループを始め直す" in out["summary"] + assert rec(env)["status"] == "stopped" and rec(env)["stop"]["condition"] == "mvv_changed" + + +def test_resume_stopped_user_stop_removes_the_stop_file_and_resumes(env): + start(env) + sl.cmd_stop(env, "l1") + stop_file = env.root / ".ndf" / "loop" / "l1" / "stop" + assert stop_file.exists() + r = stopped_on(env, "user_stop") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["status"] == "ok" and out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "user_stop" + assert out["metrics"]["next"] == sl.select_cmd("l1") and "user_stop が解けた" in out["summary"] + assert not stop_file.exists() + after = rec(env) + assert after["status"] == "running" and after["stop"] is None and after["stops"][-1]["condition"] == "user_stop" + assert "resumed_at" in after["stops"][-1] + + +def test_resume_stopped_budget_still_exceeded_writes_the_record_and_stays_stopped(env): + start(env, laps=1) + run_lap(env) + r = rec(env) + assert r["stop"]["condition"] == "budget_laps" + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "予算(laps)を超えたまま(消費 1 / 予算 1)" in out["summary"] and "--budget-laps" in out["summary"] + assert rec(env)["status"] == "stopped" + + +def test_resume_stopped_budget_within_the_limit_resumes(env): + start(env, laps=3) + r = stopped_on(env, "budget_laps") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "budget_laps" + assert out["metrics"]["next"] == sl.select_cmd("l1") and rec(env)["status"] == "running" + + +@pytest.mark.parametrize("cond", ["same_failure", "no_issue", "gate2_returned", "refused"]) +def test_resume_stopped_other_conditions_without_a_lap_resume_with_select(env, cond): + start(env) + r = stopped_on(env, cond) + assert sl.current_lap(r) is None + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["status"] == "ok" and out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == cond + assert out["metrics"]["next"] == sl.select_cmd("l1") and f"再開した({cond} が解けた)" in out["summary"] + assert rec(env)["stops"][-1]["condition"] == cond + + +def test_resume_stopped_gate2_without_an_answer_stays_stopped(env, monkeypatch): + built_lap(env) + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: None) + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "承認ゲート 2 の答え(承認か差し戻し)がまだ無い" in out["summary"] + assert rec(env)["status"] == "stopped" + + +def test_resume_stopped_gate2_rejected_closes_the_lap_as_returned_and_selects_next(env, monkeypatch): + built_lap(env) + seen = [] + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: seen.append(since) or "rejected") + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert seen == ["2026-10-25T00:00:00Z"] + assert out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "gate2_returned" + assert out["metrics"]["next"] == sl.select_cmd("l1") + after = rec(env) + assert after["laps"][0]["outcome"] == sl.RETURNED and sl.current_lap(after) is None and after["status"] == "running" + + +def test_resume_stopped_gate2_approved_runs_the_gate2_pipeline(env, monkeypatch): + built_lap(env) + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: "approved") + monkeypatch.setattr(sl, "gate2_pipeline", lambda env, rec, lap, stop: f"PIPE {lap['n']} {stop['condition']}") + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["metrics"]["next"] == "PIPE 1 gate2_returned" and out["metrics"]["status"] == "resumed" + assert sl.current_lap(rec(env)) is not None + + +def test_resume_stopped_refused_in_a_lap_asks_to_rebuild_the_sprint(env): + built_lap(env) + r = stopped_on(env, "refused", values={"step": "new sprint"}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + nxt = out["metrics"]["next"] + assert out["metrics"]["status"] == "resumed" and "loop sprint l1 --mode <モード> --dev <開発版>" in nxt + + +def test_resume_stopped_refused_continuation_rebuilds_the_queue_with_kept_issues(env, monkeypatch): + lap = built_lap(env) + seen = [] + + def cont(env_, rec_, lap_, moved, kept): + seen.append((lap_["n"], moved, kept)) + return "QUEUE", Path("/tmp/done.json") + + monkeypatch.setattr(sl, "continuation", cont) + r = rec(env) + r["waiting"] = [{"number": 11, "lap": lap["n"]}, {"number": 99, "lap": lap["n"] + 1}] + r["laps"][-1]["issues_kept"] = [12] + sl.write_record(env, r) + r = stopped_on(env, "refused", values={"step": sl.CONTINUATION_STEP}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert seen == [(1, [11], [12])] + assert out["metrics"]["next"] == f"QUEUE && {sl.wait_lap(r, Path('/tmp/done.json'))}" + assert out["metrics"]["status"] == "resumed" + + +@pytest.mark.parametrize("err", [sl.LoopError("組めない"), OSError("os"), ValueError("v"), KeyError("k"), StopIteration()]) +def test_resume_stopped_refused_continuation_failure_stays_stopped(env, monkeypatch, err): + built_lap(env) + + def cont(*a): + raise err + + monkeypatch.setattr(sl, "continuation", cont) + r = stopped_on(env, "refused", values={"step": sl.CONTINUATION_STEP}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "承認ゲート 1 の後の続きをまだ組めない: " in out["summary"] + assert rec(env)["status"] == "stopped" diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index e6d564125..cfe238d9b 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -16,6 +16,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 PY = sys.executable @@ -354,8 +355,9 @@ def plugin_repo(repo, name="mcp-x", version="1.0.0"): def test_bump_updates_manifest_and_leaves_the_readme(repo, env): """受け入れ条件 6: bump は plugin の README に触れず、版ごとの更新の節が無いことを manual に出さない。""" pdir = plugin_repo(repo) + push_origin(repo) # bump は origin のベースブランチの版を読んでから上げる(#2164) before = (pdir / "README.md").read_bytes() - code, out, err = call("release-steps.py", ["bump", "--plugin", "mcp-x", "--to", "1.1.0", "--root", str(repo)], env) + code, out, err = call("release-steps.py", ["bump", "--plugin", "mcp-x", "--to", "1.1.0", "--base", "develop", "--root", str(repo)], env) assert code == 0, err check_shape(out) assert out["tool"] == "release" and out["status"] == "ok" diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 2b27cdc23..53bfdf604 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -1674,7 +1674,7 @@ def test_new_sprint_without_design_skips_gate(tmp_path): assert names == ["スプリントブランチ", "実装", "検査", "配布", "関門 2", "本番"] -@pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"], ["--name", "m", "--issue", "1"]]) +@pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"]]) def test_new_sprint_rejects_bad_args(tmp_path, args): assert cli("new", "sprint", "--worktree", str(tmp_path), *args).returncode == 2 @@ -2059,6 +2059,7 @@ def batch(plans, adm, args): return real(plans, adm, args) monkeypatch.setattr(flow, "run_stage", batch) + monkeypatch.setattr(queue, "fill_release_version", lambda plan, items: (None, None)) # 版の差し込みは test_release_version が見る done = tmp_path / "done.json" res = queue.cmd_queue(impl, 3, poll=0.1, then=[str(rel)], done=str(done)) assert res["status"] == "ok", res diff --git a/plugins/ndf/scripts/tests/test_supervise_close_auto.py b/plugins/ndf/scripts/tests/test_supervise_close_auto.py index a3f877334..b93f641d2 100644 --- a/plugins/ndf/scripts/tests/test_supervise_close_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_close_auto.py @@ -16,12 +16,15 @@ def close(tmp_path, *extra): def test_auto_builds_only_the_wrapup_stage_with_the_record_marker(tmp_path): + """#2230 の決定 5: 開発ループのまとめは雛形の経路でも {queue_record} を組み、リリース後テストも同じ参照を受ける。""" manifest = close(tmp_path, "--pace", "auto") assert [w["name"] for w in manifest["ステージ"]] == ["まとめ"] assert manifest["進め方"] == "auto" plan = load(manifest["ステージ"][0]["plans"][0]) assert plan["進め方"] == "auto" - assert "--record-pr {queue_pr:release-prod}" in steps_of(plan)["close"]["cmd"] + steps = steps_of(plan) + assert "--record-pr {queue_record} " in steps["close"]["cmd"] + assert "#{queue_record}" in steps["release-verify"]["prompt"] def test_carry_reaches_the_refinement_prompt_and_record_pr_overrides(tmp_path): diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 8fd5da3ed..f7e2e5cce 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -4,6 +4,7 @@ import json import os +import re import subprocess import sys from pathlib import Path @@ -13,6 +14,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable @@ -127,16 +129,39 @@ def test_new_close_without_release_form_puts_manual_stage(tmp_path, delivery): note = manifest["ステージ"][1]["note"] assert "delivery" in note and ("不明" in note if delivery else "無い" in note) close = json.loads(Path(manifest["ステージ"][2]["plans"][0]).read_text()) - assert "--record-pr unknown " in next(s["cmd"] for s in close["steps"] if s["id"] == "close") # #1875 + assert "--record-pr {queue_record} " in next(s["cmd"] for s in close["steps"] if s["id"] == "close") # #2230 + assert "unknown" not in json.dumps(close["steps"], ensure_ascii=False) -def test_new_sprint_and_close_with_package_plugin_still_need_versions(tmp_path): - """#1336 の I6: 雛形で組む経路だけが版数を要する。""" +VERSION_LIKE = re.compile(r"\b\d+\.\d+\.\d+(?:-(?:dev|rc)\.\d+)?\b") + + +def release_plans(out: Path) -> dict[str, dict]: + return {f.stem: json.loads(f.read_text()) for f in sorted(out.glob("*release*.json"))} + + +def test_new_sprint_and_close_without_versions_write_no_version(tmp_path): + """#2164 の受け入れ条件 1・I1: 版を渡さずに作ったプランと sprint.json は具体的な版を持たず、作業場所・branch・承認資料は + 版の差し込みで書く。本番は開発版のプラン release の版の記録を読む(系列)。""" root = plain_repo(tmp_path, {"form": "package-plugin", "plugin": "foo", "runtimes": ["claude"]}) - p = cli(*new_sprint_args(root, tmp_path / "m", name="m6", issue=("216",), version=None), cwd=root) - assert p.returncode == 2 and "--version" in p.stderr - p = close_cli(root, tmp_path / "c", "--version", "3.8.0-dev.1") - assert p.returncode == 2 and "--prod" in p.stderr and "--version" not in p.stderr.split("無い:")[-1] + (root / ".ndf" / "worktree.json").write_text('{"version": 1, "base_branch": "main", "production_branch": "live"}\n') + out = tmp_path / "m" + p = cli(*new_sprint_args(root, out, "--level", "minor", name="m6", issue=("216",), version=None), cwd=root) + assert p.returncode == 0, p.stderr + c = close_cli(root, tmp_path / "c") + assert c.returncode == 0, c.stderr + for d in (out, tmp_path / "c"): + assert not VERSION_LIKE.search((d / "sprint.json").read_text()) + plans = release_plans(d) + assert plans, d + for name, plan in plans.items(): + assert not VERSION_LIKE.search(json.dumps(plan, ensure_ascii=False)), name + assert plan["branch"] == "release/v{release_version}" and plan["作業場所"].endswith("/release/v{release_version}") + prod = plan["版"]["チャネル"] == "prod" + assert plan["版"]["明示"] is None and plan["版"]["系列"] == ("release" if prod else None) + approval = "approval-foo-v{release_base}.md" + assert approval in json.dumps(plan["steps"], ensure_ascii=False), name + assert {p["版"]["上げ幅"] for p in release_plans(out).values()} == {"minor"} SAMPLES = { @@ -519,7 +544,7 @@ def plugin_repo(tmp_path): (root / "plugins" / "mcp" / "mcp-serena" / "README.md").write_text("changed\n") git(root, "add", "-A") git(root, "commit", "-q", "-m", "serena") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) return root diff --git a/plugins/ndf/scripts/tests/test_supervise_pace.py b/plugins/ndf/scripts/tests/test_supervise_pace.py index f6fec9272..776f387fe 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace.py @@ -486,6 +486,7 @@ def batch(plans, adm, args): return [{"plan": q, "result": "完了", "report": str(paths.state_dir_of(q) / "report.md")} for q in plans] monkeypatch.setattr(flow, "run_stage", batch) + monkeypatch.setattr(queue, "fill_release_version", lambda plan, items: (None, None)) # 版の差し込みは test_release_version が見る if alone: # 検査の関門の後・開発版の出し直し: 検査の報告だけが残っている report_with_pr(check, "https://github.com/o/r/pull/1915") queue.cmd_queue([release], 3, poll=0.05) diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 05c15e83d..868568f82 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -312,13 +312,13 @@ def test_close_of_the_promote_route_reads_the_promote_pr(tmp_path): @pytest.mark.parametrize("rows", [[STG], [PRD_ON_BASE], [API], [STG, API]], ids=["stg-only", "merged", "manual", "stg-manual"]) -def test_close_without_a_known_record_keeps_the_issues_open(tmp_path, rows): - """#1875 1875-1・1875-2: 雛形も昇格も無い届ける経路(merge・手で届ける・検証の環境だけ)は --record-pr unknown で、 - リリース後テストを置かず merge の次が振り返り、その次が close。""" +def test_close_without_a_plan_record_resolves_it_when_run(tmp_path, rows): + """#2230 の受け入れ条件 1・3・5: 雛形も昇格も無い届ける経路(merge・手で届ける・検証の環境だけ)は --record-pr unknown を + 組まず、{queue_record} を close とリリース後テストの両方に持つ。""" steps = close_steps(tmp_path, rows) - close = next(s for s in steps if s["id"] == "close") - assert "--record-pr unknown " in close["cmd"] and "release-verify" not in [s["id"] for s in steps] - assert [s for s in steps if s.get("next") == "close"] + by = {s["id"]: s for s in steps} + assert "--record-pr {queue_record} " in by["close"]["cmd"] and "unknown" not in by["close"]["cmd"] + assert "#{queue_record}" in by["release-verify"]["prompt"] def test_close_of_no_delivery_keeps_record_pr_zero(tmp_path): @@ -343,9 +343,9 @@ def chain(steps) -> list[str]: [ ([STG, PRD], ["merge", "release-verify", "retro", "close", "refine"]), ([], ["merge", "retro", "close", "refine"]), - ([STG], ["merge", "retro", "close", "refine"]), + ([STG], ["merge", "release-verify", "retro", "close", "refine"]), ], - ids=["record", "no-delivery", "unknown"], + ids=["record", "no-delivery", "queue-record"], ) def test_close_comes_after_the_retro_and_before_the_refine(tmp_path, rows, want): """#2228 の受け入れ条件 5・6(I6): next のつながりで リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。 diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 683fbf468..7046d8294 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -200,23 +200,47 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの | 開発版 | `release`(`facts` は `gate_as_ok`) | 次の `--then` | | 本番 | `release-prod`(先頭が `mvv` のステップ) | 最後の `--then`。`関門` ならキューの結果が `gate` になり、承認の後に `resume` に `--from release-prod=bump` を足して続ける | -**スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --version <開発版> --prod <正式版> ---state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 +**スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request →(配布の記録があれば `release-verify` リリース後テスト →)`retro` 振り返り → `close` 後片付け → `refine` 棚卸し) を `--then` のステージで流す。`close` のステップは `sprint-close.py --record-pr {queue_pr:release-prod} --issues <課題> --final-stage 振り返り --with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は 行わず、承認資料のパスと番号を報告に載せて終える)。まとめのプランは課題すべてへ 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 -リリースの経路がテンプレートで組むものでなければ `--version`・`--prod` は要らず、開発版と本番の代わりに下の経路のステージが入り、 +リリースの経路がテンプレートで組むものでなければ、開発版と本番の代わりに下の経路のステージが入り、 `close` のステップの `--record-pr` は、昇格の経路(手動反映の本番系を除く)なら `{queue_pr:promote}`、経路がすべて届けない経路なら `0`、 -それ以外は `unknown`(記録の置き場が分からない。課題を開いたまま理由つきで残す)になる。 +それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系)は `{queue_record}` になる。開発ループのまとめ(`new close --pace auto`)は、 +届けない経路だけでなければ経路にかかわらず `{queue_record}` になる。`{queue_record}` は queue がまとめを流し始める時点に、まとめの +課題を閉じる PR とそれを参照する PR から、段階が本番か配布なしのリリース記録を置いた PR を探して埋め、`release-verify` と `close` が +同じ番号を受ける。見つからない・2 つ以上のスプリントにある・GitHub を読めなければ、まとめを流さずに止まり(queue の結果は `stopped`、 +開発ループの周回は失敗で締まる)、理由に探した課題と PR が載る。2 つ以上のスプリントにあるときは `--record-pr <番号>` を明示して組み直す。 ## リリースの経路からステージを組む **`new sprint` / `new close` は、リリースの経路(`lib/delivery.py` の `routes`)から検査の後のステージを組む。** -`.ndf/supervise.json` に `release.form` があれば経路はテンプレート(`template`)で、今の開発版と本番のステージを置き、版数 -(`--version`、`close` は `--prod` も)が要る。無ければ `.ndf/project.json` の `delivery` の行ごとに経路を決め、版数は要らない。 -選んだ経路は `sprint.json` の `リリースの経路` に並ぶ。 +`.ndf/supervise.json` に `release.form` があれば経路はテンプレート(`template`)で、今の開発版と本番のステージを置く。 +無ければ `.ndf/project.json` の `delivery` の行ごとに経路を決める。選んだ経路は `sprint.json` の `リリースの経路` に並ぶ。 + +**テンプレートの経路の版数は、配布のプランを流す時点に決まる。** `--version`(`close` は `--prod` も)は明示の版で、 +省けばプランは具体的な版の代わりに版の差し込み(`{release_version}`・`{release_base}`)を持ち、作業場所・`branch`・承認資料の +パスも差し込みで書く。queue が開発版のプランを流す直前に origin のベースブランチの版と最新のリリースタグから次に空いている +版を決め(新しい系列を始めるときの上げ幅は `--level`、既定は `patch`)、プランの状態ディレクトリの版の記録(`version.json`)へ +決めた版・読んだ値・上げ幅を書いてから差し込みを埋める。流し直しは記録の版を使い、自分の配布がベースブランチへマージされる +前に別のスプリントがその版を出していたときだけ、次に空いている版へ決め直す(前の版・理由・前の作業場所と `branch` は +記録の `history` に残し、前のステップの記録は状態ディレクトリの `superseded/v<前の版>/` へ移してプランを頭から流す)。 +本番のプランは開発版のプラン `release` の版の記録の `base` で出す。 + +**次のときはプランを流さずに止まる。** queue の結果は `stopped` で、流さなかったプランの項目の `version` に `cause` と +材料が載る。別の版で黙って進めないため、どの版で出すかは人へ戻す。 + +| `cause` | いつ | 載る材料 | +| --- | --- | --- | +| `taken` | 明示の版がすでに出ている(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある)。本番の版のタグがすでにある | `existing`(既に出ている版)・`next_free`(次に空いている版) | +| `unreadable` | origin のベースブランチ・本番のブランチ・タグのどれか、または自分の配布の PR の照会を読めない | `unreadable`(読めなかったもの) | +| `no_series` | 本番が版を読む開発版のプランの版の記録が無い | — | +| `series_changed` | 承認ゲート 2 を通った後に、開発版の記録の版が変わった | `existing`(承認した版)・`version`(新しい版) | + +配布の `bump` も、新しい版がベースブランチの版以下か、既にタグのある正式版なら書き換えずに止まる(終了コード 3。 +読めなければ 2)。どちらも judge を通らずにプランが止まる。版の差し込みを持たない前のプランにも、この止まり方が当たる。 | `delivery` の行 | 経路 | 検査の後に置くステージ | | --- | --- | --- | diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index f83cc7e84..b5738d9d4 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -161,7 +161,8 @@ python3 plugins/ndf/scripts/supervise.py wait q/done.json ステージの `command` と `resume` をそのまま写す。 1. プランを書き出す。ステージごとのプランと目録(`$O/sprint.json`)ができる: - `sv new sprint --name m6 --worktree <リポジトリの根> --issue 1052 1053 --design 1052 --version 10.18.0-dev.1 --state $S --out $O`。 + `sv new sprint --name m6 --worktree <リポジトリの根> --issue 1052 1053 --design 1052 --state $S --out $O`。 + 版数は渡さず、開発版のプランを流す時点に queue が決める(明示するなら `--version`) 設計の前に別の課題を取り込むと決めていれば `--absorb <課題>:<取り込み先>` を足す(取り込んだ課題は設計のプランを持たない) 2. 設計: `sv queue $O/1-design-1052.json --max 3 --done $O/done-1.json` と `sv wait $O/done-1.json` 3. 承認ゲート 1: キューの結果が `gate` なら、設計 Pull Request をまとめて 1 回の承認に載せる。利用者が答えたら、設計 PR ごとに @@ -172,7 +173,7 @@ python3 plugins/ndf/scripts/supervise.py wait q/done.json 付けてからマージする(記録が無ければ承認ゲートで止まる)。設計の結果のプランがマージした設計文書の「設計の結果」の表を 実装のプランへ写し、検査のプランがスプリントの Pull Request をベースブランチへマージし、開発版のリリースプランが続けて流れる 5. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、`sprint.json` の `関門 2` のステージの `gate` に沿って - 承認資料を添えて本番の承認(`ndf-gate:2`)を取る。利用者が答えたら `sprint-state.py gate $S "関門 2" --what <要約> --by user --outcome approved|rejected` + 承認資料(版は `$O/8-release-state/version.json` の `base`)を添えて本番の承認(`ndf-gate:2`)を取る。利用者が答えたら `sprint-state.py gate $S "関門 2" --what <要約> --by user --outcome approved|rejected` を打つ。差し戻されたら 6 を打たない 6. 本番: `sv queue $O/10-release-prod.json --max 3 --done $O/done-10.json` と `sv wait $O/done-10.json`。先頭の `approve` が 5 の記録を見て承認資料へ承認の記録を書き、出す PR は検査の報告から埋まる。最後のステップが後片付けを行う diff --git a/plugins/ndf/skills/progress-tracking/SKILL.md b/plugins/ndf/skills/progress-tracking/SKILL.md index c4ffd3e84..1754d3b6c 100644 --- a/plugins/ndf/skills/progress-tracking/SKILL.md +++ b/plugins/ndf/skills/progress-tracking/SKILL.md @@ -247,7 +247,8 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> [--final-stag 確かめていないことが終わったように見える。 `--record-pr 0`(本番の記録なし)は条件 1・2 を見ない。`--record-pr unknown`(記録の置き場が分からない)は -本文を読まずにすべて `kept_open` で返す。 +本文を読まずにすべて `kept_open` で返す。まとめのプランは `unknown` を組まず、記録の PR を流す時点に `{queue_record}` で探す +(`development-workflow` の `references/pace.md` の「スプリントの終わり」)。`unknown` を渡すのは手で打つ入口と、前の版で組んだまとめのプランだけである。 ### 閉じる予定の参照