From 4133880e845754a2d5ae689039466273dbd14b5c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:16:11 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AE=20PR=20=E3=82=92=E6=B5=81?= =?UTF-8?q?=E3=81=99=E6=99=82=E7=82=B9=E3=81=AB=20{queue=5Frecord}=20?= =?UTF-8?q?=E3=81=A7=E6=8E=A2=E3=81=99=EF=BC=88#2230=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sprint_close_lib/record_find.py を新設。まとめの課題を閉じる PR とそれを参照する PR から、 段階が本番か配布なしで、スプリント: が閉じる PR を含むリリース記録の PR を 1 本決める。 2 つ以上のスプリントにあれば特定できないとして採らない - queue_fill が {queue_record} を埋め、見つからなければ計画を書き換えずに流さない(unfilled)。 plan.QUEUE_NAMED に {queue_record} を足し、run でも残れば止まる - sprint_routes.record_pr_ref は unknown を組まず、merge・手で届ける・検証の環境だけ・ 手動反映の本番系と開発ループのまとめで {queue_record} を返す。雛形と昇格は今のまま - 開発ループの承認ゲート 2 の後のまとめへ values.pr を渡さない。差し込みを埋められずに 流さなかった計画のある周回は失敗で締める - dist_record.released を足し、sprint-close.py と共有する(振る舞いは変えない) Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/dist_record.py | 8 +- plugins/ndf/scripts/lib/sprint_loop.py | 7 +- plugins/ndf/scripts/lib/sprint_loop_gate.py | 7 +- plugins/ndf/scripts/sprint-close.py | 10 +- .../scripts/sprint_close_lib/record_find.py | 133 +++++++++++++ plugins/ndf/scripts/supervise_lib/plan.py | 13 +- .../ndf/scripts/supervise_lib/queue_fill.py | 28 ++- .../scripts/supervise_lib/sprint_routes.py | 23 ++- plugins/ndf/scripts/tests/test_record_find.py | 174 ++++++++++++++++++ plugins/ndf/scripts/tests/test_sprint_loop.py | 42 +++++ .../tests/test_supervise_close_auto.py | 5 +- .../ndf/scripts/tests/test_supervise_new.py | 3 +- .../tests/test_supervise_pace_verify.py | 16 +- 13 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 plugins/ndf/scripts/sprint_close_lib/record_find.py create mode 100644 plugins/ndf/scripts/tests/test_record_find.py 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/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 501d3a961..0e57a7cd1 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -247,8 +247,8 @@ def fresh(at): 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) + # 止まった記録の 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"]} @@ -447,7 +447,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..2c841de36 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -74,8 +74,9 @@ 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)。""" 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 @@ -86,8 +87,6 @@ def build_close(env: Env, rec: dict, lap: dict, issues: list[int], record_pr=Non 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())) diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 5a23794ad..bda70fb25 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 の既定と同じ) @@ -221,7 +221,7 @@ def _closing_blocker(a, rec, record_repo): kept_all = UNKNOWN_REASON # 記録を読めないので閉じない(#1875) elif not rec["found"] or not stage: kept_all = "配布の記録が読めない" - elif not (stage.startswith("本番") or stage.startswith("配布なし")): + elif not released(stage): kept_all = "本番への配布の前(本番への配布の後に閉じる)" verdicts = None verify = a.with_verification or a.final_stage == STAGE_VERIFY # 最終工程がリリース後テストなら必ず見る(I4) 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..c180310b3 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/record_find.py @@ -0,0 +1,133 @@ +"""まとめの課題から 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 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: 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 + closers = tuple(sorted(set().union(*closers_of.values()))) if closers_of else () + if not closers: + return RecordHit(issues=issues) + cands = set(closers) + for pr in closers: + cands |= {r["number"] for r in _referencing_prs(root, repo, 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)))) + 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/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_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index 2ca39af0b..4f280efa1 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) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index 374fe6896..5e237a2f9 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -58,27 +58,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: 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_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 7d9f0dbc7..95229f92f 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -931,3 +931,45 @@ 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 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..4c6c113ae 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -127,7 +127,8 @@ 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): 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 のつながりで リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。 From 00de444a60ff99d69fb9e2b52b84cacd1bc7440e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:16:11 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Docs:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=20--record-pr=20=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=20{queue=5Frecord}=20=E3=81=AB=E7=9B=B4=E3=81=99=EF=BC=88#2230?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/development-workflow/references/pace.md | 6 +++++- plugins/ndf/skills/progress-tracking/SKILL.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 683fbf468..da0dcd88d 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -209,7 +209,11 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 リリースの経路がテンプレートで組むものでなければ `--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 <番号>` を明示して組み直す。 ## リリースの経路からステージを組む 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` を渡すのは手で打つ入口と、前の版で組んだまとめのプランだけである。 ### 閉じる予定の参照 From 75ee6f594f9904ceb031f1fc63add21b551c42dc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:28:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Docs:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=8C=20{queue=5Frecord}=20=E3=82=92=E7=B5=84=E3=82=80?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=B8=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B=EF=BC=88#2230?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf-production-release-record.md | 10 ++++- ...udgments-unknown-record-and-manual-none.md | 41 ++++++++++--------- .../ndf-workflow-development-loop.md | 8 ++-- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index b9e1fc6fa..23a5f804c 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 の本番が飛ばされたとき)は何もせずに終える。 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` | 解けない。新しい名前でループを始め直すよう示す(開始時の値を今の値へ置き換えない) |