From fe3eb61e85960b5a3d9f556452255536c1f84835 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:06:07 +0000 Subject: [PATCH 1/9] =?UTF-8?q?Add:=20=E6=97=A2=E5=AE=9A=E3=83=96=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=81=E5=AE=9B=E3=81=AE=20PR=20=E3=81=AE=E6=9C=AC?= =?UTF-8?q?=E6=96=87=E3=81=AE=20closing=20keywords=20=E3=82=92=E9=96=89?= =?UTF-8?q?=E3=81=98=E3=82=8B=E4=BA=88=E5=AE=9A=E3=81=AE=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=81=B8=E7=BD=AE=E3=81=8D=E6=8F=9B=E3=81=88=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2228=E3=83=BB#2082=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/closing.py に閉じる予定の参照(最終工程の後に閉じる: #N)と defer_closing を足し、closing-issues.sh と closing_issues / closing_refs / closing_numbers が 2 つの形を読む。pr-steps.py と supervise_lib/pr.py は、宛先が 既定ブランチ(決められないときを含む)なら本文を置き換えてから送る(lib/repo.py の merge_closes_issues)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/closing-issues.sh | 7 +- plugins/ndf/scripts/lib/closing.py | 42 ++++++++--- plugins/ndf/scripts/lib/repo.py | 9 +++ plugins/ndf/scripts/pr-steps.py | 47 +++++++++--- plugins/ndf/scripts/supervise_lib/pr.py | 22 +++++- plugins/ndf/scripts/tests/test_closing.py | 46 ++++++++++++ plugins/ndf/scripts/tests/test_pr_steps.py | 66 +++++++++++++++++ .../tests/test_supervise_pr_closing.py | 73 +++++++++++++++++++ 8 files changed, 282 insertions(+), 30 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_supervise_pr_closing.py diff --git a/plugins/ndf/scripts/lib/closing-issues.sh b/plugins/ndf/scripts/lib/closing-issues.sh index a675da40f..d00974c79 100755 --- a/plugins/ndf/scripts/lib/closing-issues.sh +++ b/plugins/ndf/scripts/lib/closing-issues.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# NDF plugin: Pull Request の本文から、閉じる語が指す issue を取り出す。 +# NDF plugin: Pull Request の本文から、閉じる語と閉じる予定の参照が指す issue を取り出す。 # # closing-issues.sh [--repo <所有者>/<リポジトリ>] [本文のファイル] # 引数が無ければ標準入力から読む。`<所有者>/<リポジトリ>` と `<番号>` をタブで @@ -16,6 +16,9 @@ # # 起点ブランチが既定ブランチでないリポジトリでは、マージしても自動クローズが働かない。 # 後片付けの工程がこの一覧を読み、まだ OPEN のものだけを閉じる。 +# +# **閉じる予定の参照(`最終工程の後に閉じる: #12`)も同じ指し先の規則で取り出す**(#2228)。 +# 既定ブランチ宛の本文では NDF の手順が閉じる語をこの形へ置き換える。形の正本は lib/closing.py の DEFERRED。 set -uo pipefail DEFAULT_REPO="" @@ -46,7 +49,7 @@ body=$(cat -- "${1:--}") # **Pull Request の URL は拾わない。** 閉じる対象は issue である。 NAME='[A-Za-z0-9._-]+' REF="(https?://github\.com/$NAME/$NAME/issues/[0-9]+|$NAME/$NAME#[0-9]+|#[0-9]+)" -KEYWORD='\b(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*' +KEYWORD='(\b(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*|最終工程の後に閉じる[[:space:]]*:[[:space:]]*)' # **1 件も見つからないことは正常な結果である。** `grep` の終了コードをそのまま返すと、 # 閉じる語の無い Pull Request で失敗したように見える。 diff --git a/plugins/ndf/scripts/lib/closing.py b/plugins/ndf/scripts/lib/closing.py index 2aa5e1ace..5903502b1 100644 --- a/plugins/ndf/scripts/lib/closing.py +++ b/plugins/ndf/scripts/lib/closing.py @@ -1,16 +1,34 @@ -"""本文の課題を閉じる closing keywords(GitHub が課題を自動で閉じるキーワード `Closes #n` など)と、課題の参照の読み取り。 -`pr-steps.py`(closing keywords の検査)と `release-steps.py`(版に含む PR から課題へたどる。宛先が既定ブランチでない PR は -GitHub の `closingIssuesReferences` が空になる)、`sprint-close.py`(参照だけの課題)が使う。""" +"""本文の課題を閉じる closing keywords(GitHub が課題を自動で閉じるキーワード `Closes #n` など)と、閉じる予定の参照 +(`最終工程の後に閉じる: #n`。GitHub は閉じず、`sprint-close.py` がスプリント課題として集める)と、課題の参照の読み取り。 +`pr-steps.py`(closing keywords の検査と、既定ブランチ宛の本文の置き換え)と `supervise_lib/pr.py`(同じ置き換え)、 +`release-steps.py`(版に含む PR から課題へたどる。宛先が既定ブランチでない PR は GitHub の `closingIssuesReferences` が +空になる)、`sprint-close.py`(参照だけの課題)が使う。閉じる指定の形はこのモジュールだけが持ち、 +`closing-issues.sh` は同じ形を読む(#2228)。""" from __future__ import annotations import re -CLOSING = re.compile( - r"\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*" - r"(https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+|[\w.-]+/[\w.-]+#\d+|#\d+)", - re.I, -) +_TARGET = r"(https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+|[\w.-]+/[\w.-]+#\d+|#\d+)" +_KEYWORD = r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*" +DEFERRED = "最終工程の後に閉じる" # 閉じる予定の参照の語。GitHub の closing keywords を含まない(#2228 の I9) +_DEFERRED_LEAD = DEFERRED + r"\s*:\s*" + +CLOSING = re.compile(_KEYWORD + _TARGET, re.I) +_ANY_CLOSING = re.compile(r"(?:" + _KEYWORD + r"|" + _DEFERRED_LEAD + r")" + _TARGET, re.I) + + +def defer_closing(text): + """closing keywords を閉じる予定の参照へ置き換える。(置き換えた本文, 置き換えた指し先の字面の並び) を返す。 + + 指し先の字面(`#12`・`owner/repo#12`・課題の URL)は変えない。宛先が既定ブランチの Pull Request の本文だけに使う。""" + targets: list[str] = [] + + def repl(m): + targets.append(m.group(1)) + return f"{DEFERRED}: {m.group(1)}" + + return CLOSING.sub(repl, text or ""), targets def closing_words(text): @@ -19,12 +37,12 @@ def closing_words(text): def closing_issues(text): - """closing keywords が指す課題の字面(`#12`・`owner/repo#12`・課題の URL)。""" - return [m.group(2) for m in CLOSING.finditer(text or "")] + """closing keywords と閉じる予定の参照が指す課題の字面(`#12`・`owner/repo#12`・課題の URL)を現れた順で返す。""" + return [m.group(1) for m in _ANY_CLOSING.finditer(text or "")] def closing_numbers(text) -> list[int]: - """closing keywords が指す課題の番号を、重ねずに現れた順で返す。""" + """closing keywords と閉じる予定の参照が指す課題の番号を、重ねずに現れた順で返す。""" out: list[int] = [] for ref in closing_issues(text): n = int(re.search(r"(\d+)$", ref).group(1)) @@ -45,7 +63,7 @@ def issue_ref(ref, repo): def closing_refs(text, repo) -> list[tuple[str, int]]: - """closing keywords が指す課題を (owner/name, 番号) で、重ねずに現れた順で返す。`#12` は `repo` の課題。""" + """closing keywords と閉じる予定の参照が指す課題を (owner/name, 番号) で、重ねずに現れた順で返す。`#12` は `repo` の課題。""" out: list[tuple[str, int]] = [] for ref in closing_issues(text): got = issue_ref(ref, repo) diff --git a/plugins/ndf/scripts/lib/repo.py b/plugins/ndf/scripts/lib/repo.py index a60a2de9e..dd3019ffa 100644 --- a/plugins/ndf/scripts/lib/repo.py +++ b/plugins/ndf/scripts/lib/repo.py @@ -85,6 +85,15 @@ def default_branch(root) -> str | None: return None +def merge_closes_issues(root, base) -> bool: + """宛先 `base` の Pull Request をマージすると、GitHub が本文の closing keywords で課題を閉じるか。 + + GitHub が閉じるのは既定ブランチへのマージだけである。既定ブランチを決められない・宛先が分からないときも真を返す + (置き換えても閉じる予定の参照として NDF の手順で閉じられ、失うものが無い。#2228 の決定 9)。""" + d = default_branch(root) + return d is None or not base or str(base).removeprefix("origin/") == d + + def base_branch(root) -> str | None: """開発の起点。`.ndf/worktree.json` の `base_branch` → 既定ブランチ(`default_branch`)の順。決まらなければ `None`。""" return declared_base(root) or default_branch(root) diff --git a/plugins/ndf/scripts/pr-steps.py b/plugins/ndf/scripts/pr-steps.py index 02872eea2..1d47ecd0f 100644 --- a/plugins/ndf/scripts/pr-steps.py +++ b/plugins/ndf/scripts/pr-steps.py @@ -41,7 +41,7 @@ run, ) import gh_parts # noqa: E402 -from closing import closing_issues, closing_words # noqa: E402 +from closing import closing_issues, closing_words, defer_closing # noqa: E402 import repo # noqa: E402 import tool_paths # noqa: E402 from design_diff import DESIGN_DIFF_HEADING # noqa: E402 @@ -349,12 +349,34 @@ def rest_create(root, branch, base, title, body, draft): return d["number"], d["html_url"] -def send_body(root, branch, body, body_path, a, must_exist): - """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" +def _pr_base(root, number): + """番号で指した PR の宛先。読めなければ None(宛先が分からない。merge_closes_issues は置き換える側に倒す)。""" + p = gh_parts.gh(["pr", "view", str(number), "--json", "baseRefName"], cwd=root) + try: + d = json.loads(p.stdout) if p.returncode == 0 else None + except ValueError: + d = None + return (d.get("baseRefName") or None) if isinstance(d, dict) else None + + +def target_pr(root, branch, a, must_exist): + """書く先の PR と宛先。(既存の PR か None, 宛先のブランチか None) を返す。 + + 宛先は、既存の PR なら baseRefName、新しく作るなら --base か起点(#2228 の決定 9)。""" if getattr(a, "pr", None): - pr = {"number": a.pr, "url": f"#{a.pr}"} - else: - pr = existing_pr(root, branch) + return {"number": a.pr, "url": f"#{a.pr}"}, _pr_base(root, a.pr) + pr = existing_pr(root, branch) + if pr: + return pr, pr.get("baseRefName") or None + if must_exist: + raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) + if not a.title: + raise StepError("--title が要る", EXIT_UNREADABLE) + return None, a.base or repo.base_branch(root) or "main" + + +def send_body(root, branch, body, body_path, a, pr, base): + """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" if pr: args = ["pr", "edit", str(pr["number"]), "--body-file", body_path] if a.title: @@ -363,11 +385,6 @@ def send_body(root, branch, body, body_path, a, must_exist): if p.returncode != 0: raise StepError(f"gh pr edit {pr['number']} が失敗: {p.stderr.strip()[:300]}") return pr["number"], pr["url"], "updated" - if must_exist: - raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) - if not a.title: - raise StepError("--title が要る", EXIT_UNREADABLE) - base = a.base or repo.base_branch(root) or "main" args = ["pr", "create", "--base", base, "--title", a.title, "--body-file", body_path] if a.draft: args.append("--draft") @@ -386,11 +403,15 @@ def upsert(a, must_exist): root = git_root(a.root) branch = current_branch(root) body = with_mode_line(body_with_mark(a.body_file), getattr(a, "mode", None), split_stages(getattr(a, "stages", None))) + pr, base = target_pr(root, branch, a, must_exist) + deferred = [] + if repo.merge_closes_issues(root, base): # 既定ブランチ宛はマージで閉じないよう置き換える(#2228 の I7) + body, deferred = defer_closing(body) with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f: f.write(body) body_path = f.name try: - number, url, action = send_body(root, branch, body, body_path, a, must_exist) + number, url, action = send_body(root, branch, body, body_path, a, pr, base) finally: Path(body_path).unlink(missing_ok=True) # 本文を /tmp に残さない sync_exit = decisions_sync(root, number) if number else None @@ -401,9 +422,11 @@ def upsert(a, must_exist): "branch": branch, "draft": bool(getattr(a, "draft", False)), "closing_issues_in_body": closing_issues(body), + "deferred_closing": deferred, "decisions_sync_exit": sync_exit, } items = [{"kind": "pr", "name": url, "result": action}] + items += [{"kind": "closing", "name": t, "result": "deferred"} for t in deferred] if sync_exit is not None: items.append({"kind": "decisions", "name": "pr-body-decisions.sh sync", "result": f"exit={sync_exit}"}) nxt = None diff --git a/plugins/ndf/scripts/supervise_lib/pr.py b/plugins/ndf/scripts/supervise_lib/pr.py index 41a6d6f2f..c2fc26516 100644 --- a/plugins/ndf/scripts/supervise_lib/pr.py +++ b/plugins/ndf/scripts/supervise_lib/pr.py @@ -11,6 +11,8 @@ import gh_call import gh_sections import mdtable +import repo +from closing import defer_closing from pr_mode import with_mode_line from release_lib.others import MIGRATION_HEADING from supervise_lib.claude import TAIL @@ -317,10 +319,22 @@ def _llm_body(self, ctx, step: dict, body: str, changes: str, issues: str, mats: return body def _publish(self, ctx, base: str, branch: str, title: str, body: str, retitle: bool = False) -> tuple[bool, str]: - """既存の PR があれば本文を書き直し(retitle なら題も。#1289 の決定 6)、無ければ Draft で作る。""" - found = gh_call.gh( - ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url"], cwd=ctx.cwd - ).stdout.rstrip() + """既存の PR があれば本文を書き直し(retitle なら題も。#1289 の決定 6)、無ければ Draft で作る。 + + 宛先(既存の PR なら baseRefName、無ければ base)が既定ブランチなら、本文の closing keywords を閉じる予定の参照へ + 置き換えてから送る(マージで課題を閉じない。#2228 の I7)。""" + found, _, found_base = ( + gh_call.gh( + ["pr", "list", "--head", branch, "--state", "open", "--json", "url,baseRefName", "--jq", '.[0] | [.url, .baseRefName] | @tsv'], + cwd=ctx.cwd, + ) + .stdout.strip() + .partition("\t") + ) + if repo.merge_closes_issues(ctx.cwd, (found_base or None) if found else base): + body, deferred = defer_closing(body) + if deferred: + ctx.state.cur["deferred_closing"] = deferred if found: p = gh_call.gh(["pr", "edit", found, *(["--title", title] if retitle else []), "--body", body], cwd=ctx.cwd) url = found diff --git a/plugins/ndf/scripts/tests/test_closing.py b/plugins/ndf/scripts/tests/test_closing.py index 5e8a517cb..3b769c9fb 100644 --- a/plugins/ndf/scripts/tests/test_closing.py +++ b/plugins/ndf/scripts/tests/test_closing.py @@ -34,3 +34,49 @@ def test_reference_forms(text, expected): def test_order_of_appearance_without_duplicates(): assert referenced_numbers("#3 https://github.com/o/r/issues/1 #3 Fixes #2 #1", "o/r") == [3, 1, 2] + + +# --- 閉じる予定の参照(#2228) -------------------------------------------------------- + +import os # noqa: E402 +import subprocess # noqa: E402 + +from closing import DEFERRED, closing_issues, closing_numbers, closing_refs, closing_words, defer_closing # noqa: E402 + +SH = Path(__file__).resolve().parents[1] / "lib" / "closing-issues.sh" +BODY = ( + "## 閉じる課題\n\nCloses #12\nfixes: o/r#3, #4\nResolves https://github.com/o/r/issues/9\n" + "最終工程の後に閉じる:#20\nRefs #30\nclosed #12\n" +) + + +def test_defer_closing_keeps_the_targets_and_leaves_no_closing_keywords(): + new, targets = defer_closing(BODY) + assert targets == ["#12", "o/r#3", "https://github.com/o/r/issues/9", "#12"] + assert closing_words(new) == [] + assert closing_issues(new) == closing_issues(BODY) + assert f"{DEFERRED}: o/r#3" in new and "Refs #30" in new + + +def test_the_deferred_form_is_read_as_a_closing_target_in_order(): + text = "最終工程の後に閉じる: #20\nCloses #12\n最終工程の後に閉じる : https://github.com/o/r/issues/9" + assert closing_issues(text) == ["#20", "#12", "https://github.com/o/r/issues/9"] + assert closing_numbers(text) == [20, 12, 9] + assert closing_refs(text, "a/b") == [("a/b", 20), ("a/b", 12), ("o/r", 9)] + assert closing_words(text) == ["Closes #12"] + + +def test_the_deferred_form_needs_the_colon(): + assert closing_issues("最終工程の後に閉じる #20") == [] + + +@pytest.mark.parametrize("locale", ["C", "C.UTF-8"]) +def test_closing_issues_sh_reads_the_same_targets_as_closing_refs(locale): + """2 つの読み手(closing-issues.sh と closing.closing_refs)が同じ本文から同じ指し先を返す。""" + for text in (BODY, defer_closing(BODY)[0]): + p = subprocess.run( + ["bash", str(SH), "--repo", "a/b"], input=text, capture_output=True, text=True, env={**os.environ, "LC_ALL": locale} + ) + assert p.returncode == 0, p.stderr + got = [(r, int(n)) for r, n in (ln.split("\t") for ln in p.stdout.splitlines() if ln)] + assert got == closing_refs(text, "a/b") diff --git a/plugins/ndf/scripts/tests/test_pr_steps.py b/plugins/ndf/scripts/tests/test_pr_steps.py index 352f64705..aaa3ed948 100644 --- a/plugins/ndf/scripts/tests/test_pr_steps.py +++ b/plugins/ndf/scripts/tests/test_pr_steps.py @@ -511,3 +511,69 @@ def test_plan_sprint_base_summary_names_the_sprint_review(repo, env): code, out, err = call(["plan", "--base", "sprint/m1"], env, repo) assert code == 0, err assert out["summary"].endswith("新しい PR を作る(スプリントブランチ宛て。実装レビューはスプリントの PR で通す)") + + +# --- 既定ブランチ宛の本文の closing keywords(#2228・#2082) ------------------------- + + +def set_default(repo, name): + """origin の HEAD(既定ブランチ)を name にする。name が origin に無ければ develop から作る。""" + if name != "develop": + git(repo, "push", "-q", "origin", f"develop:{name}") + git(repo, "fetch", "-q", "origin") + git(repo, "remote", "set-head", "origin", name) + + +def sent_body(env): + return Path(env["FAKE_GH_LOG"] + ".body").read_text(encoding="utf-8") + + +def test_create_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + sys.path.insert(0, str(SCRIPTS / "lib")) + from closing import closing_words + + set_default(repo, "develop") # 起点と既定ブランチが同じ構成 + text = "## Summary\n\nCloses #858\nfixes: o/r#3\nResolves https://github.com/o/r/issues/9\n" + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path, text))], env, repo) + assert code == 0, err + sent = sent_body(env) + assert closing_words(sent) == [] + assert "最終工程の後に閉じる: #858" in sent and "最終工程の後に閉じる: o/r#3" in sent + m = out["metrics"] + assert m["deferred_closing"] == ["#858", "o/r#3", "https://github.com/o/r/issues/9"] + assert m["closing_issues_in_body"] == m["deferred_closing"] + assert [i["name"] for i in out["items"] if i["kind"] == "closing" and i["result"] == "deferred"] == m["deferred_closing"] + assert out["status"] == "ok" + + +def test_update_of_a_pr_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + set_default(repo, "main") + env["FAKE_GH_PRS"] = json.dumps({"feature/x": [{"number": 7, "url": "https://github.com/o/r/pull/7", "isDraft": False, "baseRefName": "main"}]}) + code, out, err = call(["update", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes" not in sent_body(env) and out["metrics"]["deferred_closing"] == ["#858"] + + +def test_update_by_number_reads_the_base_of_that_pr(repo, env, tmp_path): + set_default(repo, "main") + env["FAKE_GH_VIEW"] = json.dumps({"7": {"baseRefName": "main"}}) + code, out, err = call(["update", "--pr", "7", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert out["metrics"]["deferred_closing"] == ["#858"] + + +def test_create_without_a_known_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes #858" not in sent_body(env) and out["metrics"]["deferred_closing"] == ["#858"] + + +@pytest.mark.parametrize("existing", [False, True]) +def test_a_pr_to_another_branch_keeps_the_closing_keywords(repo, env, tmp_path, existing): + set_default(repo, "main") # 起点 develop ≠ 既定ブランチ main(このリポジトリの構成) + if existing: + env["FAKE_GH_PRS"] = json.dumps({"feature/x": [{"number": 7, "url": "u", "isDraft": False, "baseRefName": "develop"}]}) + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes #858" in sent_body(env) + assert out["metrics"]["deferred_closing"] == [] and out["metrics"]["closing_issues_in_body"] == ["#858"] diff --git a/plugins/ndf/scripts/tests/test_supervise_pr_closing.py b/plugins/ndf/scripts/tests/test_supervise_pr_closing.py new file mode 100644 index 000000000..c3db43b60 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_supervise_pr_closing.py @@ -0,0 +1,73 @@ +"""supervise_lib/pr.py の _publish: 既定ブランチ宛の本文の closing keywords を閉じる予定の参照へ置き換える(#2228 の I7・I8)。""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +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 closing import closing_words # noqa: E402 +from supervise_lib.pr import PrStep # noqa: E402 + +BODY = "## 閉じる課題\n\nCloses #12\nCloses #13\n" + + +def git(cwd, *args): + subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True) + + +@pytest.fixture +def repo(tmp_path): + """origin の HEAD が main の作業場所(develop も origin にある)。""" + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True) + root = tmp_path / "wt" + root.mkdir() + git(root, "init", "-q", "-b", "main") + git(root, "-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init") + git(root, "remote", "add", "origin", str(origin)) + git(root, "push", "-q", "origin", "main", "main:develop") + git(root, "fetch", "-q", "origin") + git(root, "remote", "set-head", "origin", "main") + return root + + +def publish(monkeypatch, repo, base, found=""): + calls = [] + + def fake(args, stdin=None, cwd=None): + calls.append(args) + out = found if args[:2] == ["pr", "list"] else ("https://github.com/o/r/pull/5\n" if args[:2] == ["pr", "create"] else "") + return gh_call.GhResult(0, out, "") + + monkeypatch.setattr(gh_call, "gh", fake) + ctx = SimpleNamespace(cwd=str(repo), plan={}, state=SimpleNamespace(cur={})) + ok, _ = PrStep()._publish(ctx, base, "feat/x", "題", BODY) + assert ok + sent = next(c for c in calls if c[:2] in (["pr", "create"], ["pr", "edit"])) + return sent[sent.index("--body") + 1], ctx.state.cur + + +def test_a_new_pr_to_the_default_branch_gets_deferred_references(monkeypatch, repo): + body, cur = publish(monkeypatch, repo, "main") + assert closing_words(body) == [] and "最終工程の後に閉じる: #13" in body + assert cur["deferred_closing"] == ["#12", "#13"] + + +def test_an_existing_pr_is_judged_by_its_own_base(monkeypatch, repo): + body, _ = publish(monkeypatch, repo, "develop", found="https://github.com/o/r/pull/5\tmain\n") + assert closing_words(body) == [] + + +@pytest.mark.parametrize("base, found", [("develop", ""), ("main", "https://github.com/o/r/pull/5\tdevelop\n")]) +def test_a_pr_to_another_branch_keeps_the_closing_keywords(monkeypatch, repo, base, found): + body, cur = publish(monkeypatch, repo, base, found=found) + assert closing_words(body) == ["Closes #12", "Closes #13"] and "deferred_closing" not in cur From 1dea1b7a9bd8a897b79e7c023358b1f853a5303b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:13:55 +0000 Subject: [PATCH 2/9] =?UTF-8?q?Add:=20sprint-close.py=20=E3=81=8C=E6=9C=80?= =?UTF-8?q?=E7=B5=82=E5=B7=A5=E7=A8=8B=E3=81=AE=E8=A8=98=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=A6=E3=81=8B=E3=82=89=E8=AA=B2?= =?UTF-8?q?=E9=A1=8C=E3=82=92=E9=96=89=E3=81=98=E3=82=8B=EF=BC=88#2228?= =?UTF-8?q?=E3=83=BB#2119=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --final-stage(配布 / リリース後テスト / 振り返り。省けば振り返り)を受け、閉じる条件 3 を課題ごとに判定する。 振り返りは課題の本文の「振り返り: <コメントの URL>」の行のうち、識別子が記録の PR の最後の配布の記録のコメントの 識別子以上のものを数える(lib/dist_record.py の dist_comment_id。記録の PR の読みは 1 回のまま)。本文を読めなければ failed で止まる。リリース後テストは --with-verification の有無にかかわらず条件 2 を見る。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/dist_record.py | 25 ++- plugins/ndf/scripts/sprint-close.py | 135 ++++++++++++-- plugins/ndf/scripts/supervise_lib/pr.py | 13 +- plugins/ndf/scripts/tests/test_pr_steps.py | 4 +- .../ndf/scripts/tests/test_release_record.py | 4 +- .../tests/test_sprint_close_acceptance.py | 27 ++- .../tests/test_sprint_close_final_stage.py | 166 ++++++++++++++++++ .../scripts/tests/test_sprint_close_issues.py | 21 ++- .../tests/test_sprint_close_merge_green.py | 22 ++- .../tests/test_sprint_close_references.py | 2 +- 10 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_sprint_close_final_stage.py diff --git a/plugins/ndf/scripts/lib/dist_record.py b/plugins/ndf/scripts/lib/dist_record.py index 070851680..37ce58400 100644 --- a/plugins/ndf/scripts/lib/dist_record.py +++ b/plugins/ndf/scripts/lib/dist_record.py @@ -103,22 +103,41 @@ def _pick_verify_block(blocks, stage, version, dist_start): return "\n".join(after[-1]["lines"]) if after else None -def parse_record(text): +def _dist_comment_id(parts, dist_line): + """最後の配布の記録の見出しの行を含む部分のコメントの識別子(振り返りの行を比べる基準。#2228 の決定 10)。 + + parts は (先頭の行の番号, コメントの識別子) の並び(本文は識別子が None)。記録が本文にあれば、記録の PR の + 最初のコメントの識別子を使い、コメントが 1 件も無ければ None。parts が無ければ None。""" + if not parts: + return None + hit = None + for start, cid in parts: + if start <= dist_line: + hit = cid + if hit is not None: + return hit + return next((cid for _, cid in parts if cid is not None), None) + + +def parse_record(text, parts=None): """最後の配布の記録と、使うリリース後テストのブロックを読む。 - 戻り値: {"found", "stage", "version", "sprint_prs", "verify_block"}。 + parts は text を組んだ部分ごとの (先頭の行の番号, コメントの識別子) の並び(sprint-close.py の read_record が返す)。 + 戻り値: {"found", "stage", "version", "sprint_prs", "verify_block", "dist_comment_id"}。 verify_block は本番なら対象の版が一致する最後のブロック(無ければ最後の配布の記録より後で、対象の版が版数でない 最後のブロック)、 配布なしなら最後の配布の記録より後の最後のブロック。無ければ None。 + dist_comment_id は最後の配布の記録のコメントの識別子(_dist_comment_id。記録が無ければ None)。 """ lines = text.splitlines() sections = [s for s in md.md_sections(text) if lines[s.heading.line].startswith("#")] dists = [s for s in sections if lines[s.heading.line].startswith(DIST)] dist_start = dists[-1].heading.line if dists else None - out = {"found": dist_start is not None, "stage": None, "version": None, "sprint_prs": [], "verify_block": None} + out = {"found": dist_start is not None, "stage": None, "version": None, "sprint_prs": [], "verify_block": None, "dist_comment_id": None} if dist_start is None: return out out.update(_read_dist_block(lines[dists[-1].start : dists[-1].end])) blocks = _verify_blocks(lines, sections) out["verify_block"] = _pick_verify_block(blocks, out["stage"] or "", out["version"], dist_start) + out["dist_comment_id"] = _dist_comment_id(parts, dist_start) return out diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 601b07573..a7205401d 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -1,19 +1,24 @@ #!/usr/bin/env python3 """sprint-close.py: スプリントを閉じる(progress-tracking の「スプリントを閉じる」の手順)。 - python3 sprint-close.py --record-pr [--prs 1,2] [--issues 3,4] [--with-verification] + python3 sprint-close.py --record-pr [--final-stage 配布|リリース後テスト|振り返り] + [--prs 1,2] [--issues 3,4] [--with-verification] [--label "<マイルストーン>の<工程名>"] [--dry-run] [--root ] 1. 記録の PR の本文とコメントを読み、最後の「## 配布の記録」ブロックを取る 2. スプリントの PR の一覧(--prs、無ければブロックの `スプリント:` の行)の本文から、closing keywords - (GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)が指す課題を集める + (GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)と閉じる予定の参照 + (`最終工程の後に閉じる: #N`。既定ブランチ宛の本文で closing keywords を置き換えた形)が指す課題を集める (lib/closing-issues.sh)。--issues の課題(記録のリポジトリ)を足す(`pace: fast` の実装 PR は closing keywords を持たない)。PR を読んだのに集めた課題が 0 件なら、何も書かずに止まる(終了コード 2) -3. 閉じる条件 1(本番への配布まで済んだ、または配布なし)と、--with-verification のときは +3. 閉じる条件 1(本番への配布まで済んだ、または配布なし)と、--with-verification か最終工程がリリース後テストのときは 条件 2(その課題のリリース後テストの行がすべて合格で、本文の受け入れ条件の番号のすべてに合格の行がある) を課題ごとに判定する。`課題` の列が課題を指さない行(`—`・`PR 1869` など)は飛ばす。条件 2 は課題ごとの錠 (lib/gh_parts.py の issue_lock)の中で状態と本文を 1 回で読んで判定し、満たした記録のリポジトリの課題は - 本文の受け入れ条件に印(`[x]`)を付けてから閉じる(規則は lib/acceptance.py。#1683) + 本文の受け入れ条件に印(`[x]`)を付けてから閉じる(規則は lib/acceptance.py。#1683)。 + 最後に条件 3(最終工程の記録。--final-stage、省けば振り返り)を判定する。振り返りは課題の本文の + `振り返り: <コメントの URL>` の行のうち、コメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上の + ものがあること(final_stage_gap。#2228)。条件 2 と同じ 1 回の本文の読みを使い、満たさなければ印も書かない 4. 条件を満たす課題ごとに (a) 状態を読む → (b) 記録のリポジトリの課題ならボードを Done → (c) 読み直して OPEN なら閉じる → (d) 読み直して CLOSED を確かめる 5. スプリントの PR の題と本文が番号で参照し、2 で集めた課題に入らない開いた課題(参照だけの課題、 @@ -24,9 +29,9 @@ closed / already_closed / failed / kept_open / closed_unverified(既に閉じていたが条件 2 を満たさない。 開き直さない)(--dry-run では would_close)。checked は印を付けた受け入れ条件の番号、would_check は --dry-run で付ける予定の番号。ボードへ Done を書けなかった(`projects-sync.sh` の結果 JSON が ok でない)ときは -{kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。 +{kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。metrics.final_stage は判定に使った最終工程。 `--record-pr 0` は「本番の記録なし」(最終の検査で変更が無く本番を飛ばした)。配布の記録を読まず、 -閉じる条件も見ずに --issues の課題を閉じる。--issues と一緒のときだけ受ける。 +閉じる条件 1・2 を見ずに --issues の課題を閉じる(条件 3 の振り返りは、比べる配布の記録が無いため行があれば満たす)。--issues と一緒のときだけ受ける。 `--record-pr unknown` は「記録の置き場が分からない」(リリースの経路が雛形でも昇格でもなく、まとめのプランが 記録の PR を決められない)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの kept_open で返す(終了コード 0)。--issues と一緒のときだけ受ける。 @@ -38,6 +43,7 @@ import argparse import json +import re import shlex import subprocess import sys @@ -78,8 +84,14 @@ # --- 記録の読み取り ------------------------------------------------------------- +_COMMENT_ID = re.compile(r"#issuecomment-(\d+)$") + + def read_record(root, repo, n): - """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。""" + """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。 + + (連結した文字列, 部分ごとの (先頭の行の番号, コメントの識別子) の並び) を返す。本文の識別子は None、コメントの + 識別子は `comments[].url` の `#issuecomment-<識別子>` の数字(読めなければ None)。""" p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "body,comments"], cwd=root) if p.returncode != 0: raise StepError(f"記録の PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) @@ -87,8 +99,16 @@ def read_record(root, repo, n): d = json.loads(p.stdout) except ValueError: raise StepError(f"記録の PR #{n} の出力を読めない", EXIT_UNREADABLE) - parts = [d.get("body") or ""] + [(c or {}).get("body") or "" for c in d.get("comments") or []] - return "\n".join(parts) + comments = [c or {} for c in d.get("comments") or []] + parts = [(d.get("body") or "", None)] + for c in comments: + m = _COMMENT_ID.search(c.get("url") or "") + parts.append((c.get("body") or "", int(m.group(1)) if m else None)) + starts, line = [], 0 + for text, cid in parts: + starts.append((line, cid)) + line += len(text.split("\n")) + return "\n".join(t for t, _ in parts), starts IssueRows = acceptance.IssueRows # リリース後テストの表は承認の材料と同じ規則で読む(lib/acceptance.py。#1885 の決定 8) @@ -98,6 +118,8 @@ def read_record(root, repo, n): NO_ROWS = "リリース後テストの行が無い" NOT_PASSED = "リリース後テストに合格でない条件がある(不合格・保留)" UNVERIFIED = "閉じているが閉じる条件 2 を満たさない: " +UNVERIFIED_FINAL = "閉じているが閉じる条件 3 を満たさない: " +UNREADABLE_BODY = "本文を読めない(最終工程の記録を確かめられない)" def judge_acceptance(rows, criteria): @@ -114,6 +136,41 @@ def judge_acceptance(rows, criteria): return True, None, [c.number for c in criteria if not c.checked] +# --- 閉じる条件 3(最終工程の記録。#2228) ----------------------------------------------- + +STAGE_DIST, STAGE_VERIFY, STAGE_RETRO = "配布", "リリース後テスト", "振り返り" +FINAL_STAGES = (STAGE_DIST, STAGE_VERIFY, STAGE_RETRO) +NO_BASE = 0 # --record-pr 0 の比べる基準(配布の記録が無いため、どの識別子も数える) +_RETRO_LINE = re.compile(r"^振り返り:\s*https://github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/\d+#issuecomment-(\d+)\s*$", re.M) +FINAL_GAP = "最終工程({stage})の記録が無い: {what}" +FINAL_GAP_LEAD = "最終工程(" + + +def final_stage_gap(stage, body, since): + """最終工程の記録が無ければ、何が無いかを含む理由を返す。あれば None。 + + 配布とリリース後テストの記録は条件 1・2 が見るため、ここでは振り返りだけを判定する。振り返りは本文の + `振り返り: <コメントの URL>` の行のうち、識別子が since(記録の PR の最後の配布の記録のコメントの識別子)以上の + ものを数える。since が None なら比べる基準が無いため満たさない。""" + if stage != STAGE_RETRO: + return None + if since is None: + return FINAL_GAP.format(stage=stage, what="比べる配布の記録のコメントが無い") + ids = [int(x) for x in _RETRO_LINE.findall(body or "")] + if not ids: + return FINAL_GAP.format(stage=stage, what="課題の本文に振り返りの記録の行が無い") + if max(ids) < since: + return FINAL_GAP.format(stage=stage, what="課題の本文の振り返りの行が今回の配布の記録より前のもの") + return None + + +def _gap_item(base, state, gap): + """条件 3 を満たさない課題の項目。閉じていれば開き直さない。""" + if state == "CLOSED": + return {**base, "result": "closed_unverified", "reason": UNVERIFIED_FINAL + gap} + return {**base, "result": "kept_open", "reason": gap} + + # --- 課題の収集 ----------------------------------------------------------------- @@ -160,10 +217,12 @@ def issue_state(root, repo, n): class CloseContext: - """全課題で一定の閉じる処理の材料。notes は課題ごとのボードの記録の失敗を溜める。retry_cmd は今の引数で組んだやり直しの cmd。""" + """全課題で一定の閉じる処理の材料。notes は課題ごとのボードの記録の失敗を溜める。retry_cmd は今の引数で組んだやり直しの cmd。 + final_stage と since は条件 3(final_stage_gap)の材料。""" - def __init__(self, root, record_repo, comment, argv=()): + def __init__(self, root, record_repo, comment, argv=(), final_stage=STAGE_RETRO, since=None): self.root, self.record_repo, self.comment = root, record_repo, comment + self.final_stage, self.since = final_stage, since # 条件 3 の最終工程と、振り返りの行を比べる基準 self.notes = [] self.retry_cmd = shlex.join(["python3", str(Path(__file__).resolve()), *argv]) @@ -223,7 +282,7 @@ def _read_sprint_record(a, root, record_repo): return {"found": True, "stage": "配布なし(本番の記録なし)", "sprint_prs": [], "verify_block": None}, [] if a.record_pr == RECORD_UNKNOWN: return {"found": False, "stage": "記録の置き場が分からない", "sprint_prs": [], "verify_block": None}, [] - rec = parse_record(read_record(root, record_repo, a.record_pr)) + rec = parse_record(*read_record(root, record_repo, a.record_pr)) return rec, a.prs or rec["sprint_prs"] @@ -239,7 +298,8 @@ def _closing_blocker(a, rec, record_repo): elif not (stage.startswith("本番") or stage.startswith("配布なし")): kept_all = "本番への配布の前(本番への配布の後に閉じる)" verdicts = None - if kept_all is None and a.with_verification and a.record_pr != 0: + 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 = ( @@ -265,11 +325,11 @@ def _read_state_body(root, repo, n): def _judge_and_mark(a, ctx, repo, n, rows): - """錠の中で状態と本文を読み、条件 2 を判定し、満たせば印を書く。(結果の項目か None, 印を付けた番号) を返す。""" + """錠の中で状態と本文を読み、条件 2 と条件 3 を判定し、満たせば印を書く。(結果の項目か None, 印を付けた番号) を返す。""" base = {"kind": "issue", "repo": repo, "number": n} got = _read_state_body(ctx.root, repo, n) if got is None: - return {**base, "result": "failed", "reason": "本文を読めない", "cmd": ctx.retry_cmd}, [] + return {**base, "result": "failed", "reason": UNREADABLE_BODY, "cmd": ctx.retry_cmd}, [] state, body = got ok, why, to_check = judge_acceptance(rows, acceptance.criteria(body)) own = repo == ctx.record_repo # 他のリポジトリの課題の本文は書かない(I7) @@ -277,6 +337,9 @@ def _judge_and_mark(a, ctx, repo, n, rows): if state == "CLOSED": return {**base, "result": "closed_unverified", "reason": UNVERIFIED + why}, [] return {**base, "result": "kept_open", "reason": why}, [] + gap = final_stage_gap(ctx.final_stage, body, ctx.since) + if gap: # 条件 3 を満たさない課題の本文は書かない + return _gap_item(base, state, gap), [] if a.dry_run: out = {**base, "result": "already_closed" if state == "CLOSED" else "would_close"} return ({**out, "would_check": to_check} if own and to_check else out), [] @@ -308,6 +371,16 @@ def _close_item(a, ctx, repo, n, kept_all, verdicts): return {**base, "result": "kept_open", "reason": kept_all} if verdicts is not None: return _close_verified(a, ctx, repo, n, verdicts.get((repo, n))) + if ctx.final_stage == STAGE_RETRO: + got = _read_state_body(ctx.root, repo, n) + if got is None: # 記録の置き場を読めないときは記録があるとは読まない(I2) + return {**base, "result": "failed", "reason": UNREADABLE_BODY, "cmd": ctx.retry_cmd} + state, body = got + gap = final_stage_gap(ctx.final_stage, body, ctx.since) + if gap: + return _gap_item(base, state, gap) + if a.dry_run: + return {**base, "result": "already_closed" if state == "CLOSED" else "would_close"} if a.dry_run: st = issue_state(ctx.root, repo, n) return { @@ -407,7 +480,8 @@ def cmd_close(a): items = [] comment = f"スプリント({a.label})を通りました" if a.label else "スプリントの終わりの工程を通りました" - ctx = CloseContext(root, record_repo, comment, a.argv) + since = NO_BASE if a.record_pr == 0 else rec.get("dist_comment_id") + ctx = CloseContext(root, record_repo, comment, a.argv, a.final_stage, since) notes = ctx.notes for repo, n in issues: items.append(_close_item(a, ctx, repo, n, kept_all, verdicts)) @@ -415,13 +489,30 @@ def cmd_close(a): items += refs_items count, metrics, summary = _close_summary(items, prs, a.dry_run, referenced=len(refs_items)) + metrics["final_stage"] = a.final_stage + decisions = [ + {"decision": f"最終工程を {a.final_stage} として判定した", "basis": f"--final-stage {a.final_stage}(省略時は{STAGE_RETRO})"} + ] if count["failed"]: - emit(result(TOOL, "stopped", summary, items + notes, metrics, next="失敗した課題の cmd でやり直す。棚卸しへ進まない")) + emit( + result( + TOOL, + "stopped", + summary, + items + notes, + metrics, + next="失敗した課題の cmd でやり直す。棚卸しへ進まない", + decisions=decisions, + ) + ) if a.record_pr == RECORD_UNKNOWN: summary = f"記録の置き場が分からないため閉じない(リリースの経路が雛形でも昇格でもない): {summary}" nxt = "リリース記録とリリース後テストの記録を確かめ、受け入れ条件を満たした課題を手で閉じる" - emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt)) - emit(result(TOOL, "ok", summary, items + notes, metrics)) + emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt, decisions=decisions)) + nxt = None + if any(FINAL_GAP_LEAD in (i.get("reason") or "") for i in items): + nxt = "最終工程の記録が無い課題は、その工程(振り返りなど)を通してから打ち直す。前の版で工程を通った課題は確かめて手で閉じる" + emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt, decisions=decisions)) class Parser(argparse.ArgumentParser): @@ -467,6 +558,12 @@ def build_parser(): ) ap.add_argument("--repo", help="記録のリポジトリ(owner/name)。省けば gh repo view で決める") ap.add_argument("--with-verification", action="store_true", help="リリース後テストを通る経路(閉じる条件 2 を見る)") + ap.add_argument( + "--final-stage", + choices=FINAL_STAGES, + default=STAGE_RETRO, + help="その実行の最終工程(閉じる条件 3)。省けば振り返り。リリース後テストは条件 2 を必ず見る", + ) ap.add_argument("--label", help="閉じるときのコメントに入れる「<マイルストーン>の<工程名>」") ap.add_argument("--dry-run", action="store_true", help="状態を読むだけで、ボードも課題も書かない") ap.add_argument("--root", help="対象のリポジトリの根(既定はカレントの git の根)") diff --git a/plugins/ndf/scripts/supervise_lib/pr.py b/plugins/ndf/scripts/supervise_lib/pr.py index c2fc26516..89e34b60c 100644 --- a/plugins/ndf/scripts/supervise_lib/pr.py +++ b/plugins/ndf/scripts/supervise_lib/pr.py @@ -325,7 +325,18 @@ def _publish(self, ctx, base: str, branch: str, title: str, body: str, retitle: 置き換えてから送る(マージで課題を閉じない。#2228 の I7)。""" found, _, found_base = ( gh_call.gh( - ["pr", "list", "--head", branch, "--state", "open", "--json", "url,baseRefName", "--jq", '.[0] | [.url, .baseRefName] | @tsv'], + [ + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--json", + "url,baseRefName", + "--jq", + ".[0] | [.url, .baseRefName] | @tsv", + ], cwd=ctx.cwd, ) .stdout.strip() diff --git a/plugins/ndf/scripts/tests/test_pr_steps.py b/plugins/ndf/scripts/tests/test_pr_steps.py index aaa3ed948..fd6ff8415 100644 --- a/plugins/ndf/scripts/tests/test_pr_steps.py +++ b/plugins/ndf/scripts/tests/test_pr_steps.py @@ -548,7 +548,9 @@ def test_create_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp def test_update_of_a_pr_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp_path): set_default(repo, "main") - env["FAKE_GH_PRS"] = json.dumps({"feature/x": [{"number": 7, "url": "https://github.com/o/r/pull/7", "isDraft": False, "baseRefName": "main"}]}) + env["FAKE_GH_PRS"] = json.dumps( + {"feature/x": [{"number": 7, "url": "https://github.com/o/r/pull/7", "isDraft": False, "baseRefName": "main"}]} + ) code, out, err = call(["update", "--body-file", str(body_file(tmp_path))], env, repo) assert code == 0, err assert "Closes" not in sent_body(env) and out["metrics"]["deferred_closing"] == ["#858"] diff --git a/plugins/ndf/scripts/tests/test_release_record.py b/plugins/ndf/scripts/tests/test_release_record.py index c7d1847d0..aa6836cff 100644 --- a/plugins/ndf/scripts/tests/test_release_record.py +++ b/plugins/ndf/scripts/tests/test_release_record.py @@ -212,7 +212,9 @@ def test_sprint_close_closes_with_the_record_the_step_wrote(repo, gh, tmp_path): encoding="utf-8", ) env = {**gh.env, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}", "FAKE_GH_STATE": str(state)} - code, out, err = harness.call("sprint-close.py", ["--record-pr", "40", "--repo", "o/r", "--issues", "5", "--dry-run"], env, repo) + code, out, err = harness.call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "40", "--repo", "o/r", "--issues", "5", "--dry-run"], env, repo + ) assert code == 0, (out, err) assert harness.issues_of(out)["o/r#5"]["result"] == "would_close" diff --git a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py index 8ffb86cd4..16e28c2cc 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py @@ -8,6 +8,7 @@ from __future__ import annotations import importlib.util +import shlex import sys from pathlib import Path @@ -23,7 +24,15 @@ sys.path.insert(0, str(HERE.parent / "lib")) import acceptance # noqa: E402 -CLOSE = ["--record-pr", "20", "--repo", "o/r", "--with-verification"] +CLOSE = [ + "--record-pr", + "20", + "--repo", + "o/r", + "--with-verification", + "--final-stage", + "配布", +] # 条件 2 を見る(条件 3 は test_sprint_close_final_stage.py) THREE = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n- [ ] 2. 二\n- [ ] 3. 三\n\n## 進行\n\nモード: standard\n" @@ -182,7 +191,7 @@ def test_a_failed_write_does_not_close(repo, gh): code, out, err = call("sprint-close.py", CLOSE, gh.env, repo) it = issues_of(out)["o/r#1"] assert code == 1 and it["result"] == "failed" and it["reason"] == "受け入れ条件の印を書けない" - assert it["cmd"].endswith("sprint-close.py " + " ".join(CLOSE)) + assert it["cmd"].endswith("sprint-close.py " + shlex.join(CLOSE)) assert not calls(gh, ["issue", "close"]) @@ -195,7 +204,13 @@ def test_issue_without_checkboxes_is_judged_by_the_rows_only(repo, gh, body): assert not calls(gh, ["api"]) -@pytest.mark.parametrize("args", [["--record-pr", "20", "--repo", "o/r"], ["--record-pr", "0", "--repo", "o/r", "--issues", "1"]]) +@pytest.mark.parametrize( + "args", + [ + ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布"], + ["--record-pr", "0", "--repo", "o/r", "--issues", "1", "--final-stage", "配布"], + ], +) def test_paths_without_verification_do_not_read_the_body(repo, gh, args): """--with-verification なし・--record-pr 0 は本文を読まず書かない(受け入れ条件 12)。""" setup(gh, [("#1", "1. c", "合格")], {"o/r#1": THREE}) @@ -226,7 +241,7 @@ def test_body_is_read_inside_the_issue_lock_and_a_held_lock_fails(tmp_path, monk """本文の読み書きは課題ごとの錠の中で行う。錠を取れなければ failed(非機能の条件)。""" monkeypatch.setenv("NDF_LOCK_DIR", str(tmp_path)) mod = load_sprint_close() - ctx = mod.CloseContext(str(tmp_path), "o/r", "c") + ctx = mod.CloseContext(str(tmp_path), "o/r", "c", final_stage="配布") seen = [] def read(root, repo_, n): @@ -256,7 +271,9 @@ def test_a_check_that_changes_more_than_the_marks_is_not_written(tmp_path, monke monkeypatch.setattr(mod.acceptance, "check_criteria", lambda body, nums: body.replace("# 課題", "# 変えた")) wrote = [] monkeypatch.setattr(mod.gh_rest, "update_body", lambda *a: wrote.append(a) or True) - it = mod._close_verified(_A(), mod.CloseContext(str(tmp_path), "o/r", "c"), "o/r", 1, mod.IssueRows(True, frozenset({1, 2, 3}))) + it = mod._close_verified( + _A(), mod.CloseContext(str(tmp_path), "o/r", "c", final_stage="配布"), "o/r", 1, mod.IssueRows(True, frozenset({1, 2, 3})) + ) assert it["result"] == "failed" and not wrote diff --git a/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py b/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py new file mode 100644 index 000000000..9f6b5cfcb --- /dev/null +++ b/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py @@ -0,0 +1,166 @@ +"""sprint-close.py の閉じる条件 3(最終工程の記録)と閉じる予定の参照(#2228・#2119・#2082)。 + +gh の偽物は test_sprint_close_merge_green.py のものを使う。記録の PR のコメントは url に `#issuecomment-<識別子>` を持つ。 +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("sprint_close_harness_final", HERE / "test_sprint_close_merge_green.py") +harness = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(harness) +call, issues_of, DIST_PROD, verify_block = harness.call, harness.issues_of, harness.DIST_PROD, harness.verify_block +repo, gh = harness.repo, harness.gh # pytest の fixture + +URL = "https://github.com/o/r/pull/20#issuecomment-{}" + + +def retro(cid): + return f"# 課題\n\n本文\n\n振り返り: https://github.com/o/r/pull/21#issuecomment-{cid}\n" + + +def comment(body, cid): + return {"body": body, "url": URL.format(cid)} + + +def setup(gh, issue_body, *, dist_in_comment=False, comments=None, state="OPEN", prs="Fixes #1"): + """記録の PR 20 と、PR 11 が閉じる課題 #1。dist_in_comment なら配布の記録を識別子 200 のコメントに置く。""" + if comments is None: + comments = [comment(DIST_PROD, 200)] if dist_in_comment else [comment("メモ", 100)] + gh.set( + records={"20": {"body": "記録" if dist_in_comment else DIST_PROD, "comments": comments}}, + bodies={"11": prs, "12": ""}, + issues={"o/r#1": [state]}, + issue_bodies={"o/r#1": issue_body}, + ) + + +def wrote(gh): + st = gh.get() + return "closed" in st or "written" in st or any(c[:2] == ["project", "item-edit"] for c in st["calls"]) + + +@pytest.mark.parametrize("verify", [False, True]) +def test_an_issue_without_the_retro_record_is_kept_open(repo, gh, verify): + comments = [comment("メモ", 100)] + ([comment(verify_block("1.1.0", [("#1", "合格")]), 101)] if verify else []) + setup(gh, "# 課題\n\n本文\n", comments=comments) + args = ["--record-pr", "20", "--repo", "o/r"] + (["--with-verification"] if verify else []) + code, out, err = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "kept_open" and "振り返り" in it["reason"], err + assert not wrote(gh) + assert out["metrics"]["final_stage"] == "振り返り" + assert out["decisions"][0]["decision"] == "最終工程を 振り返り として判定した" + assert "振り返り" in (out.get("next") or "") + + +def test_a_retro_line_of_a_previous_run_does_not_count(repo, gh): + setup(gh, retro(150), dist_in_comment=True) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "kept_open" and "振り返り" in it["reason"] and "前のもの" in it["reason"] + setup(gh, retro(150) + retro(250), dist_in_comment=True) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + assert sum(1 for c in gh.get()["calls"] if c[:3] == ["pr", "view", "20"]) == 1 + + +def test_a_record_in_the_body_compares_with_the_first_comment(repo, gh): + setup(gh, retro(100), comments=[comment("振り返りの記録", 100), comment("x", 300)]) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + + +def test_a_record_without_comments_has_no_base_and_keeps_open(repo, gh): + setup(gh, retro(100), comments=[]) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "kept_open" and "比べる配布の記録のコメントが無い" in it["reason"] + + +def test_a_closed_issue_without_the_record_is_not_reopened(repo, gh): + setup(gh, "本文", state="CLOSED") + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "closed_unverified" and "振り返り" in it["reason"] + assert not any(c[:2] == ["issue", "reopen"] for c in gh.get()["calls"]) + + +@pytest.mark.parametrize("verify", [False, True]) +def test_an_unreadable_body_fails_and_stops(repo, gh, verify): + comments = [comment("メモ", 100)] + ([comment(verify_block("1.1.0", [("#1", "合格")]), 101)] if verify else []) + setup(gh, retro(100), comments=comments, state=None) + args = ["--record-pr", "20", "--repo", "o/r"] + (["--with-verification"] if verify else []) + code, out, _ = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 1 and out["status"] == "stopped" and it["result"] == "failed" and "最終工程" in it["reason"] + assert it["cmd"].endswith(" ".join(args)) and not wrote(gh) + + +def test_the_retro_record_and_passing_rows_close_and_mark(repo, gh): + body = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n\n振り返り: https://github.com/o/r/pull/21#issuecomment-500\n" + rows = "| 課題 | 受け入れ条件 | 実行したこと | 実行時刻 | 結果 |\n| --- | --- | --- | --- | --- |\n| #1 | 1. 一 | x | t | 合格 |\n" + setup(gh, body, comments=[comment(f"## リリース後テスト\n対象の版: 1.1.0(2026-09-24)\n\n{rows}\n合否: 済\n", 101)]) + code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "closed" and it["checked"] == [1], it + assert gh.get()["issue_bodies"]["o/r#1"].count("- [x] 1.") == 1 + + +def test_record_zero_closes_with_a_retro_line_and_keeps_without(repo, gh): + setup(gh, retro(5)) + code, out, _ = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + setup(gh, "本文") + code, out, _ = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert issues_of(out)["o/r#1"]["result"] == "kept_open" + + +def test_record_unknown_does_not_read_the_body(repo, gh): + setup(gh, retro(5)) + code, out, _ = call("sprint-close.py", ["--record-pr", "unknown", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "kept_open" + assert not any(c[:2] == ["issue", "view"] for c in gh.get().get("calls", [])) + + +def test_the_verification_stage_always_reads_the_rows(repo, gh): + setup(gh, "本文", comments=[comment(verify_block("1.1.0", [("#1", "不合格")]), 101)]) + args = ["--record-pr", "20", "--repo", "o/r", "--final-stage", "リリース後テスト"] + code, out, _ = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "kept_open" and "合格でない" in it["reason"] + assert out["metrics"]["final_stage"] == "リリース後テスト" + + +def test_the_distribution_stage_closes_without_a_retro_line(repo, gh): + setup(gh, "本文") + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + + +def test_an_unknown_final_stage_is_a_call_error(repo, gh): + setup(gh, "本文") + code, _, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "棚卸し"], gh.env, repo) + assert code == 3 + + +def test_a_pr_with_only_deferred_references_collects_its_issue(repo, gh): + """既定ブランチ宛で置き換えた本文だけのスプリントでも、課題が閉じる対象になる(#2082 の受け入れ条件 3)。""" + setup(gh, retro(100), prs="## 閉じる課題\n\n最終工程の後に閉じる: #1\n") + code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo) + assert code == 0, err + assert issues_of(out)["o/r#1"]["result"] == "would_close" and out["metrics"]["referenced_only"] == 0 + assert not wrote(gh) + + +def test_final_stage_gap_counts_only_lines_from_the_start(): + mod = harness.load_sprint_close() + body = "- 振り返り: https://github.com/o/r/pull/2#issuecomment-9\n" + assert "行が無い" in mod.final_stage_gap("振り返り", body, 1) + assert mod.final_stage_gap("振り返り", body.lstrip("- "), 9) is None + assert mod.final_stage_gap("配布", "", None) is None diff --git a/plugins/ndf/scripts/tests/test_sprint_close_issues.py b/plugins/ndf/scripts/tests/test_sprint_close_issues.py index 15f59f9ca..9a5014a16 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_issues.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_issues.py @@ -65,7 +65,12 @@ def get(self): def test_record_zero_closes_the_given_issues_without_reading_a_record(repo, gh): gh.set(issues={"o/r#1": ["OPEN"], "o/r#2": ["CLOSED"]}) - code, out, err = call("sprint-close.py", ["--record-pr", "0", "--issues", "1,2", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", + ["--final-stage", "配布", "--record-pr", "0", "--issues", "1,2", "--repo", "o/r", "--with-verification"], + gh.env, + repo, + ) assert code == 0, (out, err) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" and res["o/r#2"]["result"] == "already_closed" @@ -73,7 +78,7 @@ def test_record_zero_closes_the_given_issues_without_reading_a_record(repo, gh): def test_record_zero_alone_returns_two(repo, gh): - code, out, err = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "0", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" assert gh.get().get("calls", []) == [] @@ -90,19 +95,19 @@ def test_record_unknown_keeps_the_given_issues_open_with_a_reason(repo, gh): assert gh.get().get("calls", []) == [] # 記録も状態も読まず、閉じもボードも書かない assert out["summary"].startswith("記録の置き場が分からない") and out["metrics"]["closed"] == 0 gh.set(issues={"o/r#1": ["OPEN"], "o/r#2": ["OPEN"]}) - _, zero, _ = call("sprint-close.py", ["--record-pr", "0", "--issues", "1,2", "--repo", "o/r"], gh.env, repo) + _, zero, _ = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "0", "--issues", "1,2", "--repo", "o/r"], gh.env, repo) assert zero["summary"] != out["summary"] and "置き場" not in zero["summary"] def test_record_unknown_alone_returns_two(repo, gh): - code, out, err = call("sprint-close.py", ["--record-pr", "unknown", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "unknown", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" and "unknown" in out["summary"] assert gh.get().get("calls", []) == [] @pytest.mark.parametrize("value", ["-1", "none"]) def test_record_pr_rejects_other_values(repo, gh, value): - code, _, _ = call("sprint-close.py", ["--record-pr", value, "--issues", "1", "--repo", "o/r"], gh.env, repo) + code, _, _ = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", value, "--issues", "1", "--repo", "o/r"], gh.env, repo) assert code == 3 @@ -112,7 +117,9 @@ def test_issues_are_added_to_the_closing_words_of_the_prs(repo, gh): bodies={"11": "Fixes #1", "12": ""}, issues={"o/r#1": ["OPEN"], "o/r#3": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--issues", "3,1"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--issues", "3,1"], gh.env, repo + ) assert code == 0, (out, err) assert sorted(issues_of(out)) == ["o/r#1", "o/r#3"] assert all(i["result"] == "closed" for i in issues_of(out).values()) @@ -123,6 +130,6 @@ def test_issues_alone_do_not_need_the_sprint_prs(repo, gh): records={"20": {"body": "## 配布の記録\n段階: 本番(2026-09-25 承認)\n版: 1.0.0 → 1.1.0\n", "comments": []}}, issues={"o/r#5": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--issues", "5"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--issues", "5"], gh.env, repo) assert code == 0, (out, err) assert issues_of(out)["o/r#5"]["result"] == "closed" diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 07a52ce24..212e08ae1 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -206,7 +206,9 @@ def test_sprint_close_four_outcomes(repo, gh): bodies={"11": "Fixes #1, closes #2", "12": "Resolves #3 and fixes other/x#4"}, issues={"o/r#1": ["OPEN"], "o/r#2": ["CLOSED"], "o/r#3": ["OPEN"], "other/x#4": [None]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--label", "M26の振り返り"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--label", "M26の振り返り"], gh.env, repo + ) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" and res["o/r#1"]["cmd"] == "gh issue reopen 1 --repo o/r" assert res["o/r#2"]["result"] == "already_closed" @@ -223,7 +225,7 @@ def test_sprint_close_failed_when_close_does_not_take(repo, gh): issues={"o/r#1": ["OPEN"]}, close_works=False, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) it = issues_of(out)["o/r#1"] assert code == 1 and it["result"] == "failed" and it["cmd"] == "gh issue close 1 --repo o/r" @@ -234,7 +236,7 @@ def test_sprint_close_kept_open_before_production(repo, gh): bodies={"11": "Fixes #1"}, issues={"o/r#1": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) assert code == 0 and out["status"] == "ok" assert issues_of(out)["o/r#1"]["result"] == "kept_open" assert "closed" not in gh.get() @@ -249,7 +251,9 @@ def test_sprint_close_verification_per_issue(repo, gh): bodies={"11": "Fixes #1", "12": "Fixes #2 fixes #3"}, issues={"o/r#1": ["OPEN"], "o/r#2": ["OPEN"], "o/r#3": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo + ) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" assert res["o/r#2"]["result"] == "kept_open" and "合格でない" in res["o/r#2"]["reason"] @@ -259,13 +263,15 @@ def test_sprint_close_verification_per_issue(repo, gh): def test_sprint_close_no_verification_record_keeps_all(repo, gh): gh.set(records={"20": {"body": DIST_PROD, "comments": []}}, bodies={"11": "Fixes #1", "12": ""}, issues={"o/r#1": ["OPEN"]}) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo + ) assert code == 0 and issues_of(out)["o/r#1"]["reason"] == "本番の版のリリース後テストの記録が無い" def test_sprint_close_empty_list_is_2(repo, gh): gh.set(records={"20": {"body": "## 配布の記録\n段階: 配布なし(文書だけ)\n", "comments": []}}) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" @@ -275,7 +281,9 @@ def test_sprint_close_dry_run_writes_nothing(repo, gh): bodies={"11": "Fixes #1"}, issues={"o/r#1": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo + ) assert code == 0 and issues_of(out)["o/r#1"]["result"] == "would_close" assert "closed" not in gh.get() diff --git a/plugins/ndf/scripts/tests/test_sprint_close_references.py b/plugins/ndf/scripts/tests/test_sprint_close_references.py index 5a77e2154..9ff3ddfd4 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_references.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_references.py @@ -22,7 +22,7 @@ def run(gh, repo, *extra): - return call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", *extra], gh.env, repo) + return call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布", *extra], gh.env, repo) def issue_calls(gh, verb, n=None): From 17fd3effa2bd8c63c659484c88eeb36ea6f1633b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:21:41 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Change:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=82=92=E6=8C=AF=E3=82=8A?= =?UTF-8?q?=E8=BF=94=E3=82=8A=20=E2=86=92=20=E8=AA=B2=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E9=96=89=E3=81=98=E3=82=8B=20=E2=86=92=20=E6=A3=9A=E5=8D=B8?= =?UTF-8?q?=E3=81=97=E3=81=AE=E9=A0=86=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2228=E3=83=BB#2119=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge →(release-verify)→ retro → close → refine の並びにし、close は --final-stage 振り返りを明示して渡す。 retro の指示に課題を閉じない旨を足す。契約の写し(plans/close/4-close.json)を新しい並びに合わせる。 Co-Authored-By: Claude Opus 5.5 --- .../supervise_lib/release_templates.py | 4 +- plugins/ndf/scripts/supervise_lib/sprint.py | 9 ++- .../supervise_lib/sprint_close_steps.py | 75 ++++++++++--------- .../scripts/supervise_lib/sprint_routes.py | 2 +- .../plans/close/4-close.json | 20 ++--- .../ndf/scripts/tests/test_supervise_pace.py | 10 ++- .../tests/test_supervise_pace_verify.py | 48 +++++++++++- 7 files changed, 110 insertions(+), 58 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index ed22eba17..0e8a71c12 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -236,7 +236,7 @@ def judge_record_step() -> dict: def release_verify_step(sprint: str, refs: str, record_pr: str) -> dict: - """リリース後テスト(/ndf:release-verification)。記録を配布の記録の PR へ置き、まとめの close がそれを読む(#1683)。""" + """リリース後テスト(/ndf:release-verification)。記録を配布の記録の PR へ置き、振り返りの後のまとめの close がそれを読む(#1683)。""" return { "id": "release-verify", "type": "work", @@ -247,7 +247,7 @@ def release_verify_step(sprint: str, refs: str, record_pr: str) -> dict: "prompt": f"/ndf:release-verification スプリント {sprint}({refs})。配布の記録は Pull Request #{record_pr} にある。" "リリース後テストの記録をその Pull Request へコメントで書く。課題は閉じない(次の close が閉じる)。" "番号が 0 なら本番へ出ていないため、何もせずに終える。", - "next": "close", + "next": "retro", } diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index c9ef44671..6fa66f4de 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -48,7 +48,7 @@ route_waves, ) from supervise_lib.verify_steps import merge_steps -from supervise_lib.sprint_close_steps import _close_step, _retro_refine_steps, _spec_pr_steps +from supervise_lib.sprint_close_steps import _close_step, _refine_step, _retro_step, _spec_pr_steps def prod_version(version: str) -> str: @@ -139,7 +139,7 @@ def auto_sprint_plans(a) -> list[dict]: def close_plan(a, repo: str) -> dict: - """まとめ: 確定仕様化 → Pull Request →(配布の記録があれば)リリース後テスト → 課題を閉じる → 振り返り → 棚卸し。""" + """まとめ: 確定仕様化 → Pull Request →(配布の記録があれば)リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。""" issues = ",".join(map(str, a.issue)) refs = " ".join(f"#{i}" for i in a.issue) branch = f"spec/{a.name}" @@ -158,10 +158,11 @@ def close_plan(a, repo: str) -> dict: "進め方": "auto" if getattr(a, "pace", None) == "auto" else "fast", "steps": [ *_spec_pr_steps(a, refs), - *merge_steps(a, next="release-verify" if has_record(record_pr) else "close"), + *merge_steps(a, next="release-verify" if has_record(record_pr) else "retro"), *([release_verify_step(a.name, refs, record_pr)] if has_record(record_pr) else []), + _retro_step(a, refs, stats), _close_step(a, repo, record_pr, issues), - *_retro_refine_steps(a, refs, stats), + _refine_step(a, refs), ], }, a, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py index 591c14435..b744baa38 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py @@ -1,4 +1,7 @@ -"""new close のまとめのステップ(確定仕様化 → Pull Request・課題を閉じる・振り返りと棚卸し)。`sprint.close_plan` が組む。""" +"""new close のまとめのステップ(確定仕様化 → Pull Request・振り返り → 課題を閉じる → 棚卸し)。`sprint.close_plan` が組む。 + +閉じる時点の決まりの正本は `skills/progress-tracking/SKILL.md` の「スプリントを閉じる」。閉じてよいかは sprint-close.py が +判定し、プランは並び(振り返りの後に close)と `--final-stage` の値だけを持つ(#2228 の I6)。""" from __future__ import annotations @@ -36,7 +39,7 @@ def _spec_pr_steps(a, refs: str) -> list[dict]: def _close_step(a, repo: str, record_pr: str, issues: str) -> dict: - """課題を閉じる(sprint-close.py)。""" + """課題を閉じる(sprint-close.py)。プランはどのモードでも retro を通すため、最終工程は振り返りを明示して渡す。""" return { "id": "close", "type": "run", @@ -44,8 +47,8 @@ def _close_step(a, repo: str, record_pr: str, issues: str) -> dict: "cwd": repo, "timeout": 900, "cmd": f"python3 {HERE / 'sprint-close.py'} --record-pr {record_pr} --issues {issues} " - f"--with-verification --label {shlex.quote(f'スプリント {a.name}の後片付け')}", - "next": "retro", + f"--final-stage 振り返り --with-verification --label {shlex.quote(f'スプリント {a.name}の後片付け')}", + "next": "refine", } @@ -58,34 +61,36 @@ def carry_text(a) -> str: return f"前の周回の持ち越し {nums} を `upkeep.py` の `--add` で候補へ渡す({' '.join(map(str, carry))})。" -def _retro_refine_steps(a, refs: str, stats: str) -> list[dict]: - """振り返りと棚卸し。""" - return [ - { - "id": "retro", - "type": "work", - "full": True, - "kind": "振り返り", - "stage": "振り返り", - "timeout": 3600, - "prompt": f"/ndf:retrospective スプリント {a.name}({refs})。材料に検査の記録の集計(`{stats}` の出力: " - "トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の" - "見直しが要るかを書く。", - "next": "refine", - }, - { - "id": "refine", - "type": "work", - "full": True, - "kind": "棚卸し", - "stage": "棚卸し", - "timeout": 3600, - "prompt": f"/ndf:backlog-refinement スプリント {a.name}({refs})。棚卸しの工程として無人で通す。{carry_text(a)}" - "候補が上限を超えて持ち越し(`metrics.deferred`)が出ても、`--limit` を上げて打ち直さず、番号と件数を" - "報告に載せる。`upkeep.py apply` が終了コード 10 を返したら、承認を求めず、承認資料のパス" - "(`presentation_path`)と対象の番号を報告に載せて終える。`--repo` を渡さない(記録のリポジトリの外へ" - "書かない)。親 issue の起票と子 issue の結び付けは行わず、クラスタと提案する親 issue の内容(題・子の" - "番号)を報告の「人の判断待ち」に載せて終える。", - "next": "end", - }, - ] +def _retro_step(a, refs: str, stats: str) -> dict: + """振り返り。課題は閉じない(次の close が閉じる)。""" + return { + "id": "retro", + "type": "work", + "full": True, + "kind": "振り返り", + "stage": "振り返り", + "timeout": 3600, + "prompt": f"/ndf:retrospective スプリント {a.name}({refs})。材料に検査の記録の集計(`{stats}` の出力: " + "トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の" + "見直しが要るかを書く。課題は閉じない(次の close が閉じる)。", + "next": "close", + } + + +def _refine_step(a, refs: str) -> dict: + """棚卸し。""" + return { + "id": "refine", + "type": "work", + "full": True, + "kind": "棚卸し", + "stage": "棚卸し", + "timeout": 3600, + "prompt": f"/ndf:backlog-refinement スプリント {a.name}({refs})。棚卸しの工程として無人で通す。{carry_text(a)}" + "候補が上限を超えて持ち越し(`metrics.deferred`)が出ても、`--limit` を上げて打ち直さず、番号と件数を" + "報告に載せる。`upkeep.py apply` が終了コード 10 を返したら、承認を求めず、承認資料のパス" + "(`presentation_path`)と対象の番号を報告に載せて終える。`--repo` を渡さない(記録のリポジトリの外へ" + "書かない)。親 issue の起票と子 issue の結び付けは行わず、クラスタと提案する親 issue の内容(題・子の" + "番号)を報告の「人の判断待ち」に載せて終える。", + "next": "end", + } diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index b809e4bc0..374fe6896 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -57,7 +57,7 @@ def has_release_template(a) -> bool: return any(r.stage == delivery.STAGE_TEMPLATE for r in routes_of(a)) -RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件を見ずに閉じる。#1336) +RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件 1・2 を見ない。#1336) RECORD_UNKNOWN = "unknown" # 記録の置き場が分からない(sprint-close.py は課題を開いたまま残す。#1875) diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json index ef1922b9a..244102dea 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json @@ -80,15 +80,6 @@ "stage": "リリース後テスト", "timeout": 3600, "prompt": "/ndf:release-verification スプリント m9(#15)。配布の記録は Pull Request #{queue_pr:release-prod} にある。リリース後テストの記録をその Pull Request へコメントで書く。課題は閉じない(次の close が閉じる)。番号が 0 なら本番へ出ていないため、何もせずに終える。", - "next": "close" - }, - { - "id": "close", - "type": "run", - "stage": "後片付け", - "cwd": "/repo", - "timeout": 900, - "cmd": "python3 /scripts/sprint-close.py --record-pr {queue_pr:release-prod} --issues 15 --with-verification --label 'スプリント m9の後片付け'", "next": "retro" }, { @@ -98,7 +89,16 @@ "kind": "振り返り", "stage": "振り返り", "timeout": 3600, - "prompt": "/ndf:retrospective スプリント m9(#15)。材料に検査の記録の集計(`python3 /scripts/check-trigger.py stats --root /repo` の出力: トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の見直しが要るかを書く。", + "prompt": "/ndf:retrospective スプリント m9(#15)。材料に検査の記録の集計(`python3 /scripts/check-trigger.py stats --root /repo` の出力: トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の見直しが要るかを書く。課題は閉じない(次の close が閉じる)。", + "next": "close" + }, + { + "id": "close", + "type": "run", + "stage": "後片付け", + "cwd": "/repo", + "timeout": 900, + "cmd": "python3 /scripts/sprint-close.py --record-pr {queue_pr:release-prod} --issues 15 --final-stage 振り返り --with-verification --label 'スプリント m9の後片付け'", "next": "refine" }, { diff --git a/plugins/ndf/scripts/tests/test_supervise_pace.py b/plugins/ndf/scripts/tests/test_supervise_pace.py index e27bc1f90..086a95f4d 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace.py @@ -291,7 +291,7 @@ def test_check_since_last_with_pr_is_a_usage_error(tmp_path): # ---------- new close ---------- -def test_close_runs_spec_close_and_retro_once_each_in_order(tmp_path): +def test_close_runs_spec_retro_and_close_once_each_in_order(tmp_path): out = tmp_path / "close" p = cli( "new", @@ -325,19 +325,21 @@ def test_close_runs_spec_close_and_retro_once_each_in_order(tmp_path): stages = [s.get("stage") for s in close["steps"]] for stage in ("確定仕様化", "後片付け", "振り返り"): assert stages.count(stage) == 1 - assert stages.index("確定仕様化") < stages.index("後片付け") < stages.index("振り返り") + assert stages.index("確定仕様化") < stages.index("振り返り") < stages.index("後片付け") # 振り返りの後に閉じる(#2228) cl = steps_of(close)["close"]["cmd"] assert "--record-pr {queue_pr:release-prod}" in cl and "--issues 11,12" in cl and "--with-verification" in cl + assert "--final-stage 振り返り" in cl assert close["課題"] == [11, 12] and close.get("記録") cmd = waves["最終の検査"]["command"] assert cmd.count("--then") == 3 assert_transitions_exist(close) # 棚卸しは振り返りの後の別のステップ(#842)。振り返りのプロンプトは棚卸しを含まない steps = steps_of(close) - assert steps["retro"]["next"] == "refine" and "backlog-refinement" not in steps["retro"]["prompt"] + assert steps["retro"]["next"] == "close" and steps["close"]["next"] == "refine" + assert "backlog-refinement" not in steps["retro"]["prompt"] refine = steps["refine"] assert refine["stage"] == "棚卸し" and refine["type"] == "work" and refine["timeout"] > 0 - assert refine["next"] == "end" and stages.index("振り返り") < stages.index("棚卸し") + assert refine["next"] == "end" and stages.index("後片付け") < stages.index("棚卸し") # 無人で通す: 上限を上げて打ち直さない・終了コード 10 で承認を求めない・記録のリポジトリの外へ書かない for words in ("--limit` を上げて打ち直さず", "承認を求めず", "`--repo` を渡さない", "親 issue の起票"): assert words in refine["prompt"] diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 90de1fe16..4036f09d0 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -298,7 +298,7 @@ def test_close_of_the_promote_route_reads_the_promote_pr(tmp_path): ids = [s["id"] for s in steps] close = next(s for s in steps if s["id"] == "close") assert "--record-pr {queue_pr:promote} " in close["cmd"] - assert ids.index("release-verify") == ids.index("close") - 1 + assert ids.index("release-verify") < ids.index("retro") < ids.index("close") rv = steps[ids.index("release-verify")] assert rv["type"] == "work" and rv["prompt"].startswith("/ndf:release-verification") and "{queue_pr:promote}" in rv["prompt"] @@ -309,7 +309,7 @@ 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。""" + リリース後テストを置かず merge の次が振り返り、その次が 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] @@ -321,3 +321,47 @@ def test_close_of_no_delivery_keeps_record_pr_zero(tmp_path): steps = close_steps(tmp_path, []) close = next(s for s in steps if s["id"] == "close") assert "--record-pr 0 " in close["cmd"] and "release-verify" not in [s["id"] for s in steps] + + +def chain(steps) -> list[str]: + """merge から `next` をたどった id の並び(end まで)。""" + by = {s["id"]: s for s in steps} + out, cur = [], "merge" + while cur in by and cur not in out: + out.append(cur) + cur = by[cur].get("next") + return out + + +@pytest.mark.parametrize( + "rows, want", + [ + ([STG, PRD], ["merge", "release-verify", "retro", "close", "refine"]), + ([], ["merge", "retro", "close", "refine"]), + ([STG], ["merge", "retro", "close", "refine"]), + ], + ids=["record", "no-delivery", "unknown"], +) +def test_close_comes_after_the_retro_and_before_the_refine(tmp_path, rows, want): + """#2228 の受け入れ条件 5・6(I6): next のつながりで リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。 + 振り返りとリリース後テストの指示は課題を閉じない。close は最終工程を振り返りとして渡す。""" + steps = close_steps(tmp_path, rows) + assert chain(steps)[-len(want) :] == want and chain(steps)[0] == "merge" + by = {s["id"]: s for s in steps} + assert "--final-stage 振り返り" in by["close"]["cmd"] + for sid in ("retro", "release-verify"): + if sid in by: + assert "課題は閉じない" in by[sid]["prompt"] + + +@pytest.mark.parametrize("mode", ["light", "operation", "documentation", "legacy-refactor"]) +def test_close_comes_after_the_retro_in_every_mode(tmp_path, mode): + """#2228 の受け入れ条件 6: どのモードで組んでも close は振り返りの後、棚卸しの前にある。""" + repo = make_repo(tmp_path, []) + out = tmp_path / "c" + args = ["new", "close", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--mode", mode] + args += ["--state", str(sprint_state(tmp_path, "auto")), "--out", str(out)] + p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + assert p.returncode == 0, p.stdout + p.stderr + close = next(load(f) for f in sorted(out.glob("*.json")) if load(f).get("フェーズ") == "まとめ") + assert chain(close["steps"])[-3:] == ["retro", "close", "refine"] From 522fc96a2c01c5eb46be51c8215e7a62235a6443 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:26:55 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Refactor:=20=E6=97=A2=E5=AE=9A=E3=83=96?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=81=E5=AE=9B=E3=81=AE=E7=BD=AE=E3=81=8D?= =?UTF-8?q?=E6=8F=9B=E3=81=88=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92=20lib/cl?= =?UTF-8?q?osing.py=20=E3=81=AE=20defer=5Ffor=5Fbase=20=E3=81=AB=E5=AF=84?= =?UTF-8?q?=E3=81=9B=E3=82=8B=EF=BC=88#2228=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pr-steps.py と supervise_lib/pr.py が同じ判定(merge_closes_issues → defer_closing)を持たないようにする。 番号で指した PR の宛先は既存の pr_for_report で読む(REST の退避も同じものを使う)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/closing.py | 8 ++++++++ plugins/ndf/scripts/pr-steps.py | 18 +++--------------- plugins/ndf/scripts/supervise_lib/pr.py | 10 ++++------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/plugins/ndf/scripts/lib/closing.py b/plugins/ndf/scripts/lib/closing.py index 5903502b1..b6c4e4d60 100644 --- a/plugins/ndf/scripts/lib/closing.py +++ b/plugins/ndf/scripts/lib/closing.py @@ -31,6 +31,14 @@ def repl(m): return CLOSING.sub(repl, text or ""), targets +def defer_for_base(root, base, text): + """宛先 base の Pull Request の本文を、マージで GitHub が課題を閉じるとき(lib/repo.py の merge_closes_issues)だけ + defer_closing に通す。(本文, 置き換えた指し先の字面の並び) を返す。`pr-steps.py` と `supervise_lib/pr.py` が使う。""" + import repo # git を読む層。本文だけを扱う関数の読み手に git の依存を持ち込まない + + return defer_closing(text) if repo.merge_closes_issues(root, base) else (text or "", []) + + def closing_words(text): """closing keywords の一致の字面(`Closes #12` など)。""" return [m.group(0) for m in CLOSING.finditer(text or "")] diff --git a/plugins/ndf/scripts/pr-steps.py b/plugins/ndf/scripts/pr-steps.py index 1d47ecd0f..4ff0c761c 100644 --- a/plugins/ndf/scripts/pr-steps.py +++ b/plugins/ndf/scripts/pr-steps.py @@ -41,7 +41,7 @@ run, ) import gh_parts # noqa: E402 -from closing import closing_issues, closing_words, defer_closing # noqa: E402 +from closing import closing_issues, closing_words, defer_for_base # noqa: E402 import repo # noqa: E402 import tool_paths # noqa: E402 from design_diff import DESIGN_DIFF_HEADING # noqa: E402 @@ -349,22 +349,12 @@ def rest_create(root, branch, base, title, body, draft): return d["number"], d["html_url"] -def _pr_base(root, number): - """番号で指した PR の宛先。読めなければ None(宛先が分からない。merge_closes_issues は置き換える側に倒す)。""" - p = gh_parts.gh(["pr", "view", str(number), "--json", "baseRefName"], cwd=root) - try: - d = json.loads(p.stdout) if p.returncode == 0 else None - except ValueError: - d = None - return (d.get("baseRefName") or None) if isinstance(d, dict) else None - - def target_pr(root, branch, a, must_exist): """書く先の PR と宛先。(既存の PR か None, 宛先のブランチか None) を返す。 宛先は、既存の PR なら baseRefName、新しく作るなら --base か起点(#2228 の決定 9)。""" if getattr(a, "pr", None): - return {"number": a.pr, "url": f"#{a.pr}"}, _pr_base(root, a.pr) + return {"number": a.pr, "url": f"#{a.pr}"}, pr_for_report(root, a.pr).get("baseRefName") or None pr = existing_pr(root, branch) if pr: return pr, pr.get("baseRefName") or None @@ -404,9 +394,7 @@ def upsert(a, must_exist): branch = current_branch(root) body = with_mode_line(body_with_mark(a.body_file), getattr(a, "mode", None), split_stages(getattr(a, "stages", None))) pr, base = target_pr(root, branch, a, must_exist) - deferred = [] - if repo.merge_closes_issues(root, base): # 既定ブランチ宛はマージで閉じないよう置き換える(#2228 の I7) - body, deferred = defer_closing(body) + body, deferred = defer_for_base(root, base, body) # 既定ブランチ宛はマージで閉じないよう置き換える(#2228 の I7) with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f: f.write(body) body_path = f.name diff --git a/plugins/ndf/scripts/supervise_lib/pr.py b/plugins/ndf/scripts/supervise_lib/pr.py index 89e34b60c..dfb7699ae 100644 --- a/plugins/ndf/scripts/supervise_lib/pr.py +++ b/plugins/ndf/scripts/supervise_lib/pr.py @@ -11,8 +11,7 @@ import gh_call import gh_sections import mdtable -import repo -from closing import defer_closing +from closing import defer_for_base from pr_mode import with_mode_line from release_lib.others import MIGRATION_HEADING from supervise_lib.claude import TAIL @@ -342,10 +341,9 @@ def _publish(self, ctx, base: str, branch: str, title: str, body: str, retitle: .stdout.strip() .partition("\t") ) - if repo.merge_closes_issues(ctx.cwd, (found_base or None) if found else base): - body, deferred = defer_closing(body) - if deferred: - ctx.state.cur["deferred_closing"] = deferred + body, deferred = defer_for_base(ctx.cwd, (found_base or None) if found else base, body) + if deferred: + ctx.state.cur["deferred_closing"] = deferred if found: p = gh_call.gh(["pr", "edit", found, *(["--title", title] if retitle else []), "--body", body], cwd=ctx.cwd) url = found From fea8391997afaf7e83003e2c106fa380e9c09fb2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:26:55 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Docs:=20=E8=AA=B2=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E9=96=89=E3=81=98=E3=82=8B=E6=99=82=E7=82=B9=E3=81=AE=E6=AD=A3?= =?UTF-8?q?=E6=9C=AC=E3=82=92=20progress-tracking=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E3=82=B9=E3=83=97=E3=83=AA=E3=83=B3=E3=83=88=E3=82=92=E9=96=89?= =?UTF-8?q?=E3=81=98=E3=82=8B=E3=80=8D=E3=81=AB=E7=BD=AE=E3=81=8D=E3=80=81?= =?UTF-8?q?=E5=91=BC=E3=81=B6=E5=81=B4=E3=81=AF=E3=81=9D=E3=81=93=E3=82=92?= =?UTF-8?q?=E6=8C=87=E3=81=99=EF=BC=88#2228=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 閉じる条件 3(最終工程の記録)・--final-stage の値と呼ぶ側の対応・まとめのプランの並び・閉じる予定の参照を 正本へ書く。pr・release-verification・retrospective・release・merged と development-workflow の references は 閉じる条件と順序を写さず正本を指す。用語集の「最終工程の記録」をその実行の振り返りに絞り、docs/glossary.md を作り直す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary/glossary.json | 2 +- .../references/glossary.md | 2 +- .../development-workflow/references/loop.md | 2 +- .../development-workflow/references/pace.md | 6 +- .../references/stage-completeness.md | 2 +- plugins/ndf/skills/merged/SKILL.md | 6 +- plugins/ndf/skills/pr/SKILL.md | 7 ++ plugins/ndf/skills/progress-tracking/SKILL.md | 74 ++++++++++++++----- .../ndf/skills/release-verification/SKILL.md | 8 +- plugins/ndf/skills/release/SKILL.md | 6 +- plugins/ndf/skills/retrospective/SKILL.md | 8 +- 11 files changed, 82 insertions(+), 41 deletions(-) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index bd4923b32..57f61c095 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4670,7 +4670,7 @@ { "term": "最終工程の記録", "context": "ndf-workflow", - "meaning": "最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行。sprint-close.py が閉じる前に確かめる", + "meaning": "最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる", "source": "plugins/ndf/skills/progress-tracking/SKILL.md" }, { diff --git a/plugins/ndf/skills/development-workflow/references/glossary.md b/plugins/ndf/skills/development-workflow/references/glossary.md index 28b2e8ff0..862d47007 100644 --- a/plugins/ndf/skills/development-workflow/references/glossary.md +++ b/plugins/ndf/skills/development-workflow/references/glossary.md @@ -60,7 +60,7 @@ flowchart TB | --- | --- | --- | --- | | マイルストーン | 着手の順序を表す単位。スプリントはこの中から切り出す | GitHub の milestone。open は `<2 桁の連番> <主題>`、closed は `v<版数>` | [parallel-work.md](parallel-work.md) の「用語」 | | スプリント | 1 回のリリースとして出す課題と Pull Request のセット。版数を持つプロジェクトでは 1 つの版になる。区切りは 1 回のリリースとして出す中身で決める。工程はスプリント単位で 1 回ずつ通し、モードもスプリントで 1 つにする | ブランチ `sprint/<名前>`、目録 `sprint.json`、状態のファイル(例 `sprint-state.json`) | [parallel-work.md](parallel-work.md) の「用語」 | -| スプリント課題 | 「スプリントを閉じる」が閉じる対象にする課題。スプリントに含まれる Pull Request の本文が GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)で指す課題と、`--issues` で渡した課題の和 | `sprint-close.py` が閉じる | [progress-tracking](../../progress-tracking/SKILL.md) | +| スプリント課題 | 「スプリントを閉じる」が閉じる対象にする課題。スプリントに含まれる Pull Request の本文が GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)か閉じる予定の参照(`最終工程の後に閉じる: #N`)で指す課題と、`--issues` で渡した課題の和 | `sprint-close.py` が閉じる | [progress-tracking](../../progress-tracking/SKILL.md) | | 工程 | 工程表(モードごとに起動する Skill の表)の 1 行。要求と受け入れ条件・設計・実装・リリースなど | 進捗記録の `stage` | [../SKILL.md](../SKILL.md) の「モードごとに起動する Skill」 | | フェーズ | supervisor 1 つ(またはプラン 1 本)が通す、連続する工程のグループ。設計・実装・検査・取り込み・仕上げ・リリースの 6 つ | プランの `"フェーズ"`、Agent の `description` の先頭語 | [agent-layers.md](agent-layers.md) の「フェーズ」 | | プラン | `supervise.py` が流す 1 本の JSON。フェーズの手順をステップの列として持つ | `plan.json`、`supervise.py new impl` / `check` / `release` / `sprint` / `close` | [supervise.py](../../../scripts/supervise.py) の docstring | diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index 6f0eee90d..6fdde0e7f 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -12,7 +12,7 @@ 2. `loop select l20261025` が `upkeep.py rank` を打ち、#11 と #12 を選ぶ。#13 は「順位(切り出しの境界の外)」で外す 3. conductor がモードを `standard`、版を `10.20.0-dev.1` と決め、`loop sprint l20261025 --mode standard --dev 10.20.0-dev.1` を打つ 4. 返った `pipeline` をそのまま流す。設計 → 承認ゲート 1(MVV 判定)→ 実装 → 検査 → 開発版 → 本番(承認ゲート 2)→ - まとめ(確定仕様化・課題を閉じる・振り返り・棚卸し)→ `loop lap` + まとめ(確定仕様化・振り返り・課題を閉じる・棚卸し)→ `loop lap` 5. #12 の設計が承認ゲート 1 で「判定できない」だった。`loop lap`(または `loop gate`)が #12 を人の判断待ちへ移し、 課題の本文に `## 人の判断待ち(開発ループ l20261025)` を書き、#11 だけで続きの `pipeline` を返す 6. `loop lap` が周回を締め、止まる条件に当たらなければ `ndf-next` を返す。ラッパーが次のセッションを起動し、手順 1 から続ける diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index a0e567703..683fbf468 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -71,7 +71,7 @@ graph LR | --- | --- | --- | | その場で通す | 作業場所の用意 / 計画 / 実装 / 完了判定 / Pull Request / 後片付け / 配布 / リリース後テスト | 課題ごと。実装のプランが範囲テスト → Draft の Pull Request(CI と並べる)→ 全体テスト → doc-lint → マージまでを通す。リリースは開発版と verify-install まで。本番は下のゲート 2 に従う | | トリガーで通す | リファクタリング / コードレビュー | 検査のトリガーが立ったとき、次の開発版の前に 1 回。範囲は前回の検査からの差分 | -| スプリントの終わりにまとめる | 確定仕様化 / 振り返り(受け入れ条件の確認と課題を閉じる作業を含む)/ 棚卸し | スプリントの終わりに 1 回ずつ | +| スプリントの終わりにまとめる | 確定仕様化 / 振り返り / 課題を閉じる(受け入れ条件の確認を含む。`progress-tracking` の「スプリントを閉じる」)/ 棚卸し | スプリントの終わりに 1 回ずつ | | 省かない | 要求と受け入れ条件 / 設計 / ドキュメント再構成 / ドキュメントレビュー | モードの定めどおり。設計の承認が要る変更は設計 Pull Request を出す | | 承認ゲート | `normal` | `auto` | `fast` | @@ -202,9 +202,9 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの **スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --version <開発版> --prod <正式版> --state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 -最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request → `close` 後片付け → `retro` 振り返り → `refine` 棚卸し) +最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request →(配布の記録があれば `release-verify` リリース後テスト →)`retro` 振り返り → `close` 後片付け → `refine` 棚卸し) を `--then` のステージで流す。`close` のステップは `sprint-close.py --record-pr {queue_pr:release-prod} --issues <課題> ---with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は +--final-stage 振り返り --with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は 行わず、承認資料のパスと番号を報告に載せて終える)。まとめのプランは課題すべてへ 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 リリースの経路がテンプレートで組むものでなければ `--version`・`--prod` は要らず、開発版と本番の代わりに下の経路のステージが入り、 diff --git a/plugins/ndf/skills/development-workflow/references/stage-completeness.md b/plugins/ndf/skills/development-workflow/references/stage-completeness.md index 161fd5c0c..50dde00a5 100644 --- a/plugins/ndf/skills/development-workflow/references/stage-completeness.md +++ b/plugins/ndf/skills/development-workflow/references/stage-completeness.md @@ -192,7 +192,7 @@ bash "$SCRIPTS/../skills/development-workflow/scripts/stage-check.sh" report < ## Pull Request の作成の時点で見るもの -**リリース(`配布`)の記録まで待たない。** `gh pr create` を観測し、本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が指す課題の通過記録を +**リリース(`配布`)の記録まで待たない。** `gh pr create` を観測し、本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)と閉じる予定の参照(`最終工程の後に閉じる: #N`)が指す課題の通過記録を 読んで、記録の無い必須の工程を案内する(#424)。 | 何を見るか | 出すもの | diff --git a/plugins/ndf/skills/merged/SKILL.md b/plugins/ndf/skills/merged/SKILL.md index de44ab8b6..738fbd247 100644 --- a/plugins/ndf/skills/merged/SKILL.md +++ b/plugins/ndf/skills/merged/SKILL.md @@ -198,14 +198,14 @@ python3 "$SCRIPTS/merged-steps.py" probe (--pr | --head <ブランチ # **gate も同じ実体を読む**ため、Skill の下にコピーは無い。 CLOSING="$SCRIPTS/lib/closing-issues.sh" -# 1. 本文から closing keywords が指す先を取り出す(<所有者>/<リポジトリ> と <番号> をタブ区切りで出す) +# 1. 本文から closing keywords と閉じる予定の参照が指す先を取り出す(<所有者>/<リポジトリ> と <番号> をタブ区切りで出す) gh pr view --json body -q .body | bash "$CLOSING" # 2. まだ OPEN のものだけに絞り、報告へ載せる gh issue view <番号> --repo <所有者>/<リポジトリ> --json state -q .state # OPEN / CLOSED ``` -- GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が 1 つも無ければ何もしない。スクリプトは何も出さずに終了コード 0 で終わる +- GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)も閉じる予定の参照(`最終工程の後に閉じる: #N`。`progress-tracking` の「スプリントを閉じる」)も 1 つも無ければ何もしない。スクリプトは何も出さずに終了コード 0 で終わる - **この一覧は、後片付けの進捗記録を書く先でもある**(`progress-tracking` の「工程の単位と 記録する課題」。後片付けは Pull Request 単位で、マージした側が本文の closing keywords(`Closes #N` などの記法)が指す課題 すべてへ書く) @@ -278,7 +278,7 @@ bash "$SCRIPTS/../skills/development-workflow/scripts/stage-check.sh" report ..origin/<名前>`)を添える | | 止まった対象 | 「止まる条件」の「止まったときの一覧」と、同意の結果 | | 未完了 | 退避が失敗した worktreeのパス、退避先・退避済みのパス・失敗したパスと `mv` の出力(退避済みのものは退避先に残す。戻すなら同じ `mv` の逆)、「原因を取り除いた後に `/ndf:merged ` をもう一度実行する」 | -| スプリント課題 | このマージの closing keywords(`Closes #N` などの記法)が指す課題のうち OPEN のもの。「閉じるのはスプリントの最終工程(`progress-tracking` の「スプリントを閉じる」)」と添える | +| スプリント課題 | このマージの closing keywords(`Closes #N` などの記法)と閉じる予定の参照が指す課題のうち OPEN のもの。「閉じるのはスプリントの最終工程(`progress-tracking` の「スプリントを閉じる」)」と添える | | スプリントの最後か | 最後 / 残りあり / **判断できない** | あわせて次も載せる。 diff --git a/plugins/ndf/skills/pr/SKILL.md b/plugins/ndf/skills/pr/SKILL.md index e050ccc2c..6a92d9521 100644 --- a/plugins/ndf/skills/pr/SKILL.md +++ b/plugins/ndf/skills/pr/SKILL.md @@ -152,6 +152,8 @@ URL は最終行に生のまま置く(Markdown リンクにすると利用者 課題の Pull Request は `--base sprint/<名前>` で出す。スプリントの Pull Request は、 スプリントブランチの worktree から `--base` を省いて出し、本文にスプリントが閉じる課題を番号ごとに書く。 +開発の起点(`base_branch`)が既定ブランチと同じリポジトリでは、上の develop の行の Pull Request が既定ブランチ宛になる。 +そのときの本文の扱いは次の節にある。 ## closing keywords は本文だけに書く @@ -159,6 +161,11 @@ URL は最終行に生のまま置く(Markdown リンクにすると利用者 メッセージに書くと、マージでそのコミットが指す課題だけが先に閉じ、スプリントが割れる。**本文の closing keywords は 外さない**(`stage-completeness.md` と `progress-tracking` の「スプリントを閉じる」が本文を入力にする)。 +**宛先が既定ブランチの Pull Request では、`pr-steps.py create` / `update` が本文の closing keywords を閉じる予定の参照 +(`最終工程の後に閉じる: #<番号>`)へ置き換えてから送る。** 書く側は宛先によらず closing keywords で書いてよい。置き換えた +指し先は結果の `metrics.deferred_closing` に載る。課題を閉じる時点と閉じる予定の参照の形は `progress-tracking` の +「スプリントを閉じる」が正本である。 + ## 設計 Pull Request の本文 **head のブランチ名が `design/` で始まる Pull Request の本文は、決定の中身を持たない**(決定は設計文書だけが持つ)。 diff --git a/plugins/ndf/skills/progress-tracking/SKILL.md b/plugins/ndf/skills/progress-tracking/SKILL.md index 8a242d800..6307024b7 100644 --- a/plugins/ndf/skills/progress-tracking/SKILL.md +++ b/plugins/ndf/skills/progress-tracking/SKILL.md @@ -142,7 +142,7 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 | 工程の単位(`parallel-work.md` の表) | 記録する側 | 記録する課題 | | --- | --- | --- | | 課題 | その課題を扱う supervisor | その課題 | -| Pull Request(後片付けを含む) | その工程を行った側。後片付けはマージした側 | その Pull Request の本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が指す課題すべて | +| Pull Request(後片付けを含む) | その工程を行った側。後片付けはマージした側 | その Pull Request の本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)と閉じる予定の参照(下の「閉じる予定の参照」)が指す課題すべて | | スプリント(確定仕様化・リリース・体裁レビュー・リリース後テスト・振り返り) | その工程を行った側。リリース以降はオーケストレーター | スプリント課題すべて | **記録は 1 回の実行に課題を並べてよい。** どの記録も記録のスクリプトが自分で通過記録へ積むため、 @@ -153,20 +153,28 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 ## スプリントを閉じる -**契機は最終工程を出るときである。** この手順はそこで **1 度だけ**行う。 +**閉じる時点の決まりの正本はこの節だけである。** 呼ぶ側(Skill・まとめのプラン・`pr`)は閉じる時点と順序と +閉じる条件を写さず、この節を指す。worker は呼ばない(supervisor か conductor が呼ぶ)。 + +**課題を閉じてよいのは、その課題がその実行の最終工程を終えたときだけである。** NDF のどの経路 +(まとめのプランの `close`・最終工程を担う Skill・conductor の手打ち)で打っても、`sprint-close.py` が課題ごとに +最終工程の記録(閉じる条件 3)を確かめ、無ければ閉じない。呼ぶ側の手順の並びに頼らない。 **最終工程とは、棚卸しの前にその実行で最後に通る工程である**(工程表は `development-workflow` の 「モードごとに起動する Skill」。`operation` のリリース後テストと振り返りはそれぞれ条件付き)。 検証リリースで止めた実行も、その実行の最終工程でこの手順を行う。課題は閉じる条件に 当たらず OPEN のまま残り、後で本番へのリリースを行った実行の最終工程が改めて行う。 -**正本はここだけである。** 呼ぶ側は手順をコピーせず、この節を指す。worker は呼ばない -(supervisor か conductor が呼ぶ)。 +| 呼ぶ側 | 呼ぶ時点 | 呼ぶ条件 | `--final-stage` | +| --- | --- | --- | --- | +| まとめのプラン(`supervise.py new close`)の `close` のステップ | 振り返りの後、棚卸しの前 | プランで仕上げる実行(どのモードも `retro` を通す) | `振り返り` | +| `retrospective` | 記録を投稿し、課題の本文に辿る経路の行を足した後 | 振り返りを通る変更 | `振り返り` | +| `release-verification` | 検証結果を記録した後 | 後に振り返りが続かない変更(振り返りを通らない `operation`) | `リリース後テスト` | +| `release` | 配布を終えた後 | 後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`) | `配布` | -| 呼ぶ Skill | 呼ぶ時点 | 呼ぶ条件 | -| --- | --- | --- | -| `retrospective` | 記録を投稿した後 | 振り返りを通る変更 | -| `release-verification` | 検証結果を記録した後 | 後に振り返りが続かない変更(振り返りを通らない `operation`) | -| `release` | 配布を終えた後 | 後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`) | +まとめのプランの並びは `merge →(配布の記録があれば)release-verify → retro → close → refine` である。 +`release-verify` と `retro` の指示は課題を閉じない。プランの中では最終工程が振り返りになるため、`close` が閉じる。 +プランの外で Skill を直接通すときは、上の表の Skill がこの手順を行う。両方が打っても、早すぎる時点では +閉じる条件 3 で閉じず、2 度目は `already_closed` になる。 **閉じた後は、工程表の次の工程 `棚卸し`(`backlog-refinement`)へ進む。「スプリントを閉じる → 棚卸し」の 順序の正本はここである。** 逆にすると、`backlog-refinement` の手順 1 が読む「このスプリントで閉じた課題」が @@ -181,20 +189,25 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 (別の実行なら `retrospective` の「Pull Request の番号を特定する」で引く)。 ```bash -python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ - [--with-verification] [--label "<マイルストーン>の<工程名>"] [--prs <番号,番号>] [--dry-run] +python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> [--final-stage <配布|リリース後テスト|振り返り>] \ + [--with-verification] [--label "<マイルストーン>の<工程名>"] [--prs <番号,番号>] [--issues <番号,番号>] [--dry-run] ``` -- `--with-verification`: リリース後テストを通る経路で付ける(閉じる条件 2 を見る) +- `--final-stage`: その実行の最終工程(上の表の値)。省けば `振り返り`。省いても閉じる条件は緩まない +- `--with-verification`: リリース後テストを通る経路で付ける(閉じる条件 2 を見る)。`--final-stage リリース後テスト` + では付けなくても条件 2 を見る - `--prs`: 同じ実行の中で `release` が承認の提示に並べた一覧を渡すとき。省けば最後の 「## 配布の記録」の `スプリント:` の行を使う +- `--issues`: スプリントの PR の本文が指さない課題を足すとき(`pace: fast` の実装 PR など) - スクリプトが行うこと: 配布の記録の読み取り → スプリント課題(スプリントの PR の本文の closing keywords - (GitHub が課題を自動で閉じるキーワード。`Closes #N` などの記法)が指す課題と `--issues` の課題)の収集 → - 閉じる条件の判定 → 課題ごとに「状態を読む → 記録のリポジトリの課題ならボードを Done → 読み直して OPEN なら閉じる → + (GitHub が課題を自動で閉じるキーワード。`Closes #N` などの記法)と閉じる予定の参照が指す課題と `--issues` の課題)の収集 → + 閉じる条件 1〜3 の判定 → 課題ごとに「状態を読む → 記録のリポジトリの課題ならボードを Done → 読み直して OPEN なら閉じる → 読み直して CLOSED を確かめる」→ 参照だけの課題の報告。他のリポジトリの課題にはボードを書かない - `--with-verification` の経路では、課題ごとの錠の中で状態と本文を 1 回で読んで閉じる条件 2 を判定し、満たした 記録のリポジトリの課題は、本文の `## 受け入れ条件` のチェックボックスに印(`[x]`)を付けてから閉じる。変えるのは - チェックボックスの空白 1 文字だけで、節の外と説明の行は変えない。他のリポジトリの課題の本文は書かない + チェックボックスの空白 1 文字だけで、節の外と説明の行は変えない。他のリポジトリの課題の本文は書かない。 + 閉じる条件 3 を満たさない課題には印を付けない +- 閉じる条件 3 は条件 2 と同じ 1 回の本文の読みを使う(`--final-stage 振り返り` で条件 2 を見ないときも本文を 1 回読む) - 参照だけの課題: スプリントの PR の題か本文が `#<番号>` か課題の URL で参照し、スプリント課題に入らない 記録のリポジトリの開いた課題。閉じずボードも書かず、`kept_open` で載せる。件数は `metrics.referenced_only` にある @@ -204,20 +217,21 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ | --- | --- | | 0 | `items` の表を報告に貼り、棚卸しの工程へ進む | | 1 | 失敗した課題がある。`items[].reason` と `cmd`(やり直すコマンド)を報告し、最終工程を完了と報告せず、棚卸しへ進まずに止まる | -| 2 | スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞き、`--prs` で打ち直す。または、PR を読んだのにスプリント課題が 0 件(PR の本文に closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)が無く、`--issues` も無い)。`next` に並ぶ PR が参照した番号から閉じる課題を選び、`--issues` で渡して打ち直す | +| 2 | スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞き、`--prs` で打ち直す。または、PR を読んだのにスプリント課題が 0 件(PR の本文に closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も閉じる予定の参照も無く、`--issues` も無い)。`next` に並ぶ PR が参照した番号から閉じる課題を選び、`--issues` で渡して打ち直す | | 3 | 呼び出しの誤り。引数を直して打ち直す | `items[]` の `kind: "issue"` の `result` は次の 5 つのどれかになる(`--dry-run` では `would_close` も出る)。`kind: "board_note"` はボードへ Done を書けなかった理由(`projects-sync.sh` の結果 JSON が `ok` でない)で、報告にそのまま載せる。課題を閉じる処理は止めない。 印を付けた受け入れ条件の番号は `checked`、`--dry-run` で付ける予定の番号は `would_check` に載る。 +判定に使った最終工程は `metrics.final_stage` と `decisions` に載る。 | `result` | 意味 | 報告に載せるもの | | --- | --- | --- | | `closed` | 閉じた(ボードの自動化が閉じた場合を含む) | 番号と `cmd`(戻し方 `gh issue reopen`) | | `already_closed` | 手順の前から閉じていた(既定ブランチへのマージで GitHub が閉じた場合を含む) | 番号 | -| `failed` | 状態を読めない、または閉じた後も CLOSED でない。`--with-verification` では、本文の錠を取れない・本文を読めない・受け入れ条件の印を書けない(閉じない) | 番号・`reason`・`cmd`(やり直す `gh issue close`。本文の失敗では同じ `sprint-close.py` の呼び出し) | -| `closed_unverified` | 既に閉じていたが閉じる条件 2 を満たさない。開き直さない | 番号と `reason`(満たさない理由と番号)。受け入れ条件を確かめて印を付けるか、開き直すかを人が決める | -| `kept_open` | 閉じる条件に当たらない。または参照だけの課題(`reason` が「closing keywords(…)も --issues も無い参照」。状態を読めなければ先頭に「状態を読めない」) | 番号と `reason`(いつ閉じるか。参照だけの課題は、閉じるなら `--issues` で渡す) | +| `failed` | 状態を読めない、または閉じた後も CLOSED でない。本文を読めない(最終工程の記録を確かめられない。閉じない)。`--with-verification` では、本文の錠を取れない・受け入れ条件の印を書けない(閉じない) | 番号・`reason`・`cmd`(やり直す `gh issue close`。本文の失敗では同じ `sprint-close.py` の呼び出し) | +| `closed_unverified` | 既に閉じていたが閉じる条件 2 か 3 を満たさない。開き直さない | 番号と `reason`(満たさない理由と番号)。受け入れ条件を確かめて印を付けるか、開き直すかを人が決める | +| `kept_open` | 閉じる条件に当たらない(条件 3 では `reason` が「最終工程(<工程>)の記録が無い: <何が無いか>」)。または参照だけの課題(`reason` が「closing keywords(…)も --issues も無い参照」。状態を読めなければ先頭に「状態を読めない」) | 番号と `reason`(いつ閉じるか。参照だけの課題は、閉じるなら `--issues` で渡す) | **`kept_open` と `closed_unverified` は止める理由にしない。** 棚卸しへ進んでよい。 @@ -226,11 +240,31 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ | # | 条件 | 満たさないとき | | --- | --- | --- | | 1 | 最後の「## 配布の記録」の `段階:` が `本番` または `配布なし` で始まる | スプリント課題すべてを OPEN のまま残す | -| 2 | リリース後テストを通る経路では、その課題の行がすべて `合格` で、課題の本文の `## 受け入れ条件` のチェックボックスの番号(上から数えた順、1 から)のすべてに合格の行がある。同じ番号に合格でない行があればその番号は合格でない。チェックボックスが無い課題は行の合否だけで見る | その課題を OPEN のまま残す(`reason` に合格の行が無い番号)。既に閉じていれば `closed_unverified` | +| 2 | リリース後テストを通る経路(`--with-verification` か `--final-stage リリース後テスト`)では、その課題の行がすべて `合格` で、課題の本文の `## 受け入れ条件` のチェックボックスの番号(上から数えた順、1 から)のすべてに合格の行がある。同じ番号に合格でない行があればその番号は合格でない。チェックボックスが無い課題は行の合否だけで見る | その課題を OPEN のまま残す(`reason` に合格の行が無い番号)。既に閉じていれば `closed_unverified` | +| 3 | その課題の最終工程の記録がある。`配布` は条件 1、`リリース後テスト` は条件 2 がそれに当たる。`振り返り` は課題の本文に行頭から `振り返り: https://github.com/<所有者>/<リポジトリ>/(issues\|pull)/<番号>#issuecomment-<識別子>` の行があり、そのうち 1 つ以上の識別子が、記録の PR の最後の配布の記録を含むコメントの識別子以上である(配布の記録が本文にあれば記録の PR の最初のコメントの識別子と比べ、コメントが無ければ満たさない。`--record-pr 0` は行があれば満たす)。前の実行の振り返りの行は数えない | その課題を OPEN のまま残す(`reason` に通っていない工程)。既に閉じていれば `closed_unverified`。本文を読めなければ `failed` | **検証リリースは閉じる条件を満たさない。** 利用者が常用する版にまだ届いていない段階で、 確かめていないことが終わったように見える。 +`--record-pr 0`(本番の記録なし)は条件 1・2 を見ない。`--record-pr unknown`(記録の置き場が分からない)は +本文を読まずにすべて `kept_open` で返す。 + +### 閉じる予定の参照 + +**既定ブランチ宛の Pull Request の本文には closing keywords を残さない。** GitHub は既定ブランチへのマージの時点に +closing keywords の課題を閉じ、最終工程より先に閉じてしまう。NDF の手順(`pr-steps.py create` / `update` と +まとめのプランの pr のステップ)は、宛先が既定ブランチ(既定ブランチを決められないときを含む)なら、本文の +closing keywords を閉じる予定の参照へ置き換えてから送る。宛先が既定ブランチでない Pull Request の本文は書き換えない。 + +```text +最終工程の後に閉じる: <指し先> +``` + +`<指し先>` は closing keywords と同じ 3 つ(`#<番号>`・`<所有者>/<リポジトリ>#<番号>`・課題の URL)で、1 つの指し先に +1 つ書く。GitHub はこの形では閉じず、`sprint-close.py`(`lib/closing-issues.sh`)がスプリント課題として集める。 +形の正本は `$SCRIPTS/lib/closing.py` の `DEFERRED` である。置き換えた指し先は `pr-steps.py` の結果の +`metrics.deferred_closing` に載る。 + ### 配布の記録 **配布の記録は Pull Request に置く。** 書く側はこの形で書き、`sprint-close.py` はこの形だけを読む。 diff --git a/plugins/ndf/skills/release-verification/SKILL.md b/plugins/ndf/skills/release-verification/SKILL.md index 2e100dd8f..7d4d2f719 100644 --- a/plugins/ndf/skills/release-verification/SKILL.md +++ b/plugins/ndf/skills/release-verification/SKILL.md @@ -153,11 +153,9 @@ ref と一致するか、利用者の HOME の設定が変わっていないか **後に振り返りが続かない変更(振り返りを通らない `operation`)では、この工程がその実行の 最終工程である。** 検証結果を記録した後に、`progress-tracking` の「スプリントを閉じる」を -行う。**手順はそこが正本で、ここにはコピーしない。** 振り返りを通る変更では、そちらが行うため -ここでは行わない。閉じた後の順序(棚卸しへ進む)は、その正本にある。 - -**不合格・保留の条件を持つ課題と、合格の行が無い受け入れ条件を持つ課題は閉じない。** その課題は `開いたまま` になり、判定と理由が -報告に載る。確かめていないことが終わったように見えないためである。 +`--final-stage リリース後テスト` で行う。**手順・閉じる条件・閉じた後の順序はそこが正本で、ここにはコピーしない。** +振り返りを通る変更と、まとめのプランの `release-verify` のステップでは、課題を閉じない(振り返りの後に閉じる)。 +閉じなかった課題は `開いたまま` になり、判定と理由が報告に載る。 ## 関連 diff --git a/plugins/ndf/skills/release/SKILL.md b/plugins/ndf/skills/release/SKILL.md index 2cf867d9f..84ac62b4d 100644 --- a/plugins/ndf/skills/release/SKILL.md +++ b/plugins/ndf/skills/release/SKILL.md @@ -330,8 +330,8 @@ Pull Request へコメントで投稿する)。 そのことと根拠のコマンドを残す。書かずに省くと、リリースし忘れたのか、リリースが不要だったのかを 後から区別できない。**飛ばしたときも同じブロックを残す。** `段階: 配布なし(<飛ばした理由>)` と `スプリント:` の 2 行だけを書き(`版:` は書かない)、置き場所は Pull Request を作らない形と同じにする。 -**この記録が無いと、スプリント課題が閉じられない**(閉じる条件は「本番リリースまで済んだ、または -リリースを飛ばした」。読み方は `progress-tracking` の「スプリントを閉じる」が持つ)。 +**この記録が無いと、スプリント課題が閉じられない**(閉じる条件と読み方は `progress-tracking` の +「スプリントを閉じる」が持つ)。 ## モードごとの要否 @@ -346,7 +346,7 @@ Pull Request へコメントで投稿する)。 **後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`)では、 この工程がその実行の最終工程である。** 配布を終えた後に、 -`progress-tracking` の「スプリントを閉じる」を行う。**手順と、閉じた後の順序(棚卸しへ進む)はそこが正本で、ここにはコピーしない。** +`progress-tracking` の「スプリントを閉じる」を `--final-stage 配布` で行う。**手順と、閉じた後の順序(棚卸しへ進む)はそこが正本で、ここにはコピーしない。** 続く工程があるなら呼ばない(`release-verification` か `retrospective` が行う)。 **検証リリースで止めた実行でも呼ぶ。** 課題は閉じる条件に当たらず `開いたまま` になり、 閉じるのは後で本番リリースを行った実行の最終工程である。 diff --git a/plugins/ndf/skills/retrospective/SKILL.md b/plugins/ndf/skills/retrospective/SKILL.md index 602b35204..bf43cfa23 100644 --- a/plugins/ndf/skills/retrospective/SKILL.md +++ b/plugins/ndf/skills/retrospective/SKILL.md @@ -352,9 +352,11 @@ gh issue edit --repo "$RECORD_REPO" --body-file /tmp/issue-body.md ## スプリントを閉じる -**振り返りを通る変更では、この工程がその実行の最終工程である。** 記録を投稿した後に、 -`progress-tracking` の「スプリントを閉じる」を行う。**手順はそこが正本で、ここには写さない。** -ボードの `Status` を `Done` にするのも、課題を閉じるのも、その手順の中で行う。 +**振り返りを通る変更では、この工程がその実行の最終工程である。** 記録を投稿し、課題の本文に辿る経路の行を +足した後に、`progress-tracking` の「スプリントを閉じる」を `--final-stage 振り返り` で行う。辿る経路の行は、 +その手順が確かめる最終工程の記録である。**手順と閉じる条件はそこが正本で、ここには写さない。** +ボードの `Status` を `Done` にするのも、課題を閉じるのも、その手順の中で行う。まとめのプランの `retro` の +ステップでは課題を閉じない(次の `close` のステップが閉じる)。 ## 振り返りの範囲 From ad5219305dc89d035b1afaa8b40df2fe10a7eb35 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:37:27 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Docs:=20docs/glossary.md=20=E3=82=92?= =?UTF-8?q?=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=8B=E3=82=89=E4=BD=9C=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=99=EF=BC=88#2228=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 --- docs/glossary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/glossary.md b/docs/glossary.md index c6e615036..023687272 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -320,7 +320,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 欠け | `gaps` | 結果 JSON の gaps の 1 件。出すはずだったのに出せなかった材料・記録、届かなかった目標を、種類・名前・理由で書く | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 過程の項目 | `process` | 結果 JSON の process。スクリプトが実行したこと(コマンド・操作)と、その結果の要旨を順に並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 判断の項目 | `decisions` | 結果 JSON の decisions。スクリプトが決めたことと、その根拠を並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | -| 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | +| 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | | 閉じる予定の参照 | — | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | ## NDF の Slack 通知(`ndf-notification`) From bf6f06b8e2f7216c66734ec82ff45225cf9d5b01 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 14:36:09 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Refactor:=20sprint-close.py=20=E3=81=AE?= =?UTF-8?q?=E6=9C=80=E7=B5=82=E5=B7=A5=E7=A8=8B=E3=81=AE=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=A8=E8=AA=B2=E9=A1=8C=E3=81=AE=E9=9B=86=E3=82=81=E6=96=B9?= =?UTF-8?q?=E3=82=92=20sprint=5Fclose=5Flib=20=E3=81=B8=E3=80=81pr-steps.p?= =?UTF-8?q?y=20=E3=81=AE=20PR=20=E3=81=AE=E8=AA=AD=E3=81=BF=E6=9B=B8?= =?UTF-8?q?=E3=81=8D=E3=82=92=20pr=5Fsteps=5Flib=20=E3=81=B8=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=82=8B=EF=BC=88#2228=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sprint_close_lib/final_stage.py: 閉じる条件 3(final_stage_gap・gap_item と最終工程の定数) - sprint_close_lib/collect.py: 記録の PR の読み取り(read_record)、スプリントの PR の課題の集め方(sprint_issues)、参照だけの課題(referenced_items) - pr_steps_lib/gh_pr.py: repo_owner_name・existing_pr・pr_for_report・target_pr・send_body・rest_create - pr-steps.py が 500 行以下になったため、例外リストの行数の項目を消す 振る舞いは変えない。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP7yPdcB8dBzbZpQCnR4 --- plugins/ndf/scripts/pr-steps.py | 132 +--------------- plugins/ndf/scripts/pr_steps_lib/__init__.py | 1 + plugins/ndf/scripts/pr_steps_lib/gh_pr.py | 143 ++++++++++++++++++ plugins/ndf/scripts/sprint-close.py | 143 ++---------------- .../ndf/scripts/sprint_close_lib/__init__.py | 1 + .../ndf/scripts/sprint_close_lib/collect.py | 96 ++++++++++++ .../scripts/sprint_close_lib/final_stage.py | 41 +++++ ...ins__ndf__scripts__pr-steps.py--lines.json | 1 - 8 files changed, 300 insertions(+), 258 deletions(-) create mode 100644 plugins/ndf/scripts/pr_steps_lib/__init__.py create mode 100644 plugins/ndf/scripts/pr_steps_lib/gh_pr.py create mode 100644 plugins/ndf/scripts/sprint_close_lib/__init__.py create mode 100644 plugins/ndf/scripts/sprint_close_lib/collect.py create mode 100644 plugins/ndf/scripts/sprint_close_lib/final_stage.py delete mode 100644 scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json diff --git a/plugins/ndf/scripts/pr-steps.py b/plugins/ndf/scripts/pr-steps.py index 4ff0c761c..480d5c4af 100644 --- a/plugins/ndf/scripts/pr-steps.py +++ b/plugins/ndf/scripts/pr-steps.py @@ -17,7 +17,6 @@ from __future__ import annotations import argparse -import json import re import sys import tempfile @@ -40,13 +39,15 @@ result, run, ) -import gh_parts # noqa: E402 from closing import closing_issues, closing_words, defer_for_base # noqa: E402 import repo # noqa: E402 import tool_paths # noqa: E402 from design_diff import DESIGN_DIFF_HEADING # noqa: E402 from pr_mode import needs_review, pr_target, split_stages, with_mode_line # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pr_steps_lib.gh_pr import existing_pr, pr_for_report, send_body, target_pr # noqa: E402 PR の読み書き + TOOL = "pr" SCRIPTS = Path(__file__).resolve().parent REVIEW_MARK = "" @@ -96,51 +97,6 @@ def compare_ref(root, base): raise StepError(f"起点 {base} が無い(git fetch origin {base} を先に打つ)", EXIT_PRECONDITION) -def repo_owner_name(root): - p = gh_parts.gh(["repo", "view", "--json", "owner,name"], cwd=root) - if p.returncode == 0: - try: - d = json.loads(p.stdout) - return d["owner"]["login"], d["name"] - except (ValueError, KeyError, TypeError): - pass - slug = repo.owner_repo(root) - if not slug: - raise StepError("リポジトリの所有者と名前を決められない", EXIT_UNREADABLE) - owner, _, name = slug.partition("/") - return owner, name - - -def existing_pr(root, branch): - """head が branch の OPEN の PR({number, url, isDraft, baseRefName})。無ければ None。上限なら REST。""" - p = gh_parts.gh(["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,isDraft,baseRefName"], cwd=root) - if p.returncode == 0: - try: - prs = json.loads(p.stdout or "[]") - except ValueError: - raise StepError("gh pr list の出力を読めない", EXIT_UNREADABLE) - return prs[0] if prs else None - if not gh_parts.is_rate_limited(p.stderr): - raise StepError(f"gh pr list が失敗: {p.stderr.strip()[:300]}", EXIT_PRECONDITION) - owner, name = repo_owner_name(root) - q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls?head={owner}:{branch}&state=open"], cwd=root) - if q.returncode != 0: - raise StepError(f"REST でも既存の PR を引けない: {q.stderr.strip()[:300]}") - try: - prs = json.loads(q.stdout or "[]") - except ValueError: - raise StepError("REST の出力を読めない", EXIT_UNREADABLE) - if not prs: - return None - pr = prs[0] - return { - "number": pr["number"], - "url": pr["html_url"], - "isDraft": pr.get("draft", False), - "baseRefName": pr.get("base", {}).get("ref", ""), - } - - def diff_numbers(root, ref): # 3 点(分岐点からの差分)。2 点だと起点が進んでいたとき他 PR の変更まで数える last = (git(root, "diff", "--shortstat", f"{ref}...HEAD").stdout.strip().splitlines() or [""])[-1] @@ -333,62 +289,6 @@ def decisions_sync(root, number): return run(["bash", str(script), "sync", str(number)], cwd=root, check=False).returncode -def rest_create(root, branch, base, title, body, draft): - owner, name = repo_owner_name(root) - payload = {"title": title, "head": branch, "base": base, "body": body, "draft": bool(draft)} - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) - tmp = f.name - try: - p = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls", "--input", tmp], cwd=root) - finally: - Path(tmp).unlink(missing_ok=True) # 本文を /tmp に残さない - if p.returncode != 0: - raise StepError(f"REST でも作成が失敗: {p.stderr.strip()[:300]}") - d = json.loads(p.stdout) - return d["number"], d["html_url"] - - -def target_pr(root, branch, a, must_exist): - """書く先の PR と宛先。(既存の PR か None, 宛先のブランチか None) を返す。 - - 宛先は、既存の PR なら baseRefName、新しく作るなら --base か起点(#2228 の決定 9)。""" - if getattr(a, "pr", None): - return {"number": a.pr, "url": f"#{a.pr}"}, pr_for_report(root, a.pr).get("baseRefName") or None - pr = existing_pr(root, branch) - if pr: - return pr, pr.get("baseRefName") or None - if must_exist: - raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) - if not a.title: - raise StepError("--title が要る", EXIT_UNREADABLE) - return None, a.base or repo.base_branch(root) or "main" - - -def send_body(root, branch, body, body_path, a, pr, base): - """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" - if pr: - args = ["pr", "edit", str(pr["number"]), "--body-file", body_path] - if a.title: - args += ["--title", a.title] - p = gh_parts.gh(args, cwd=root) - if p.returncode != 0: - raise StepError(f"gh pr edit {pr['number']} が失敗: {p.stderr.strip()[:300]}") - return pr["number"], pr["url"], "updated" - args = ["pr", "create", "--base", base, "--title", a.title, "--body-file", body_path] - if a.draft: - args.append("--draft") - p = gh_parts.gh(args, cwd=root) - if p.returncode == 0: - url = (p.stdout.strip().splitlines() or [""])[-1] - m = re.search(r"/pull/(\d+)", url) - return (int(m.group(1)) if m else None), url, "created" - if gh_parts.is_rate_limited(p.stderr): - number, url = rest_create(root, branch, base, a.title, body, a.draft) - return number, url, "created_rest" - raise StepError(f"gh pr create が失敗: {p.stderr.strip()[:300]}") - - def upsert(a, must_exist): root = git_root(a.root) branch = current_branch(root) @@ -470,32 +370,6 @@ def cmd_update(a): # --- report ----------------------------------------------------------------------- -def pr_for_report(root, number): - fields = "number,title,url,isDraft,baseRefName,headRefName,body" - p = gh_parts.gh(["pr", "view", str(number), "--json", fields], cwd=root) - if p.returncode == 0: - try: - return json.loads(p.stdout) - except ValueError: - raise StepError("gh pr view の出力を読めない", EXIT_UNREADABLE) - if not gh_parts.is_rate_limited(p.stderr): - raise StepError(f"gh pr view {number} が失敗: {p.stderr.strip()[:300]}") - owner, name = repo_owner_name(root) - q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls/{number}"], cwd=root) - if q.returncode != 0: - raise StepError(f"REST でも PR を引けない: {q.stderr.strip()[:300]}") - d = json.loads(q.stdout) - return { - "number": d["number"], - "title": d["title"], - "url": d["html_url"], - "isDraft": d.get("draft", False), - "baseRefName": d["base"]["ref"], - "headRefName": d["head"]["ref"], - "body": d.get("body") or "", - } - - def section(body, heading): """見出しが `heading` で始まる(大文字と小文字を区別しない)`## ` の節の行(子の節を含む)。""" return [line for title, lines in h2_sections(body) if title.lower().startswith(heading.lower()) for line in lines] diff --git a/plugins/ndf/scripts/pr_steps_lib/__init__.py b/plugins/ndf/scripts/pr_steps_lib/__init__.py new file mode 100644 index 000000000..35ec6cdcf --- /dev/null +++ b/plugins/ndf/scripts/pr_steps_lib/__init__.py @@ -0,0 +1 @@ +"""pr-steps.py から分けたモジュール。""" diff --git a/plugins/ndf/scripts/pr_steps_lib/gh_pr.py b/plugins/ndf/scripts/pr_steps_lib/gh_pr.py new file mode 100644 index 000000000..63c4c57f4 --- /dev/null +++ b/plugins/ndf/scripts/pr_steps_lib/gh_pr.py @@ -0,0 +1,143 @@ +"""PR を gh で読み書きする部品(`pr-steps.py` から分けた)。上限(rate limit)なら REST へ退避する。 + +書く先の PR と宛先を決める `target_pr`、本文ファイルで作る・更新する `send_body`、既存の PR と report の読み取りを持つ。 +出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import json +import re +import tempfile +from pathlib import Path + +import gh_parts +import repo +from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, StepError + + +def repo_owner_name(root): + p = gh_parts.gh(["repo", "view", "--json", "owner,name"], cwd=root) + if p.returncode == 0: + try: + d = json.loads(p.stdout) + return d["owner"]["login"], d["name"] + except (ValueError, KeyError, TypeError): + pass + slug = repo.owner_repo(root) + if not slug: + raise StepError("リポジトリの所有者と名前を決められない", EXIT_UNREADABLE) + owner, _, name = slug.partition("/") + return owner, name + + +def existing_pr(root, branch): + """head が branch の OPEN の PR({number, url, isDraft, baseRefName})。無ければ None。上限なら REST。""" + p = gh_parts.gh(["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,isDraft,baseRefName"], cwd=root) + if p.returncode == 0: + try: + prs = json.loads(p.stdout or "[]") + except ValueError: + raise StepError("gh pr list の出力を読めない", EXIT_UNREADABLE) + return prs[0] if prs else None + if not gh_parts.is_rate_limited(p.stderr): + raise StepError(f"gh pr list が失敗: {p.stderr.strip()[:300]}", EXIT_PRECONDITION) + owner, name = repo_owner_name(root) + q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls?head={owner}:{branch}&state=open"], cwd=root) + if q.returncode != 0: + raise StepError(f"REST でも既存の PR を引けない: {q.stderr.strip()[:300]}") + try: + prs = json.loads(q.stdout or "[]") + except ValueError: + raise StepError("REST の出力を読めない", EXIT_UNREADABLE) + if not prs: + return None + pr = prs[0] + return { + "number": pr["number"], + "url": pr["html_url"], + "isDraft": pr.get("draft", False), + "baseRefName": pr.get("base", {}).get("ref", ""), + } + + +def pr_for_report(root, number): + fields = "number,title,url,isDraft,baseRefName,headRefName,body" + p = gh_parts.gh(["pr", "view", str(number), "--json", fields], cwd=root) + if p.returncode == 0: + try: + return json.loads(p.stdout) + except ValueError: + raise StepError("gh pr view の出力を読めない", EXIT_UNREADABLE) + if not gh_parts.is_rate_limited(p.stderr): + raise StepError(f"gh pr view {number} が失敗: {p.stderr.strip()[:300]}") + owner, name = repo_owner_name(root) + q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls/{number}"], cwd=root) + if q.returncode != 0: + raise StepError(f"REST でも PR を引けない: {q.stderr.strip()[:300]}") + d = json.loads(q.stdout) + return { + "number": d["number"], + "title": d["title"], + "url": d["html_url"], + "isDraft": d.get("draft", False), + "baseRefName": d["base"]["ref"], + "headRefName": d["head"]["ref"], + "body": d.get("body") or "", + } + + +def target_pr(root, branch, a, must_exist): + """書く先の PR と宛先。(既存の PR か None, 宛先のブランチか None) を返す。 + + 宛先は、既存の PR なら baseRefName、新しく作るなら --base か起点(#2228 の決定 9)。""" + if getattr(a, "pr", None): + return {"number": a.pr, "url": f"#{a.pr}"}, pr_for_report(root, a.pr).get("baseRefName") or None + pr = existing_pr(root, branch) + if pr: + return pr, pr.get("baseRefName") or None + if must_exist: + raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) + if not a.title: + raise StepError("--title が要る", EXIT_UNREADABLE) + return None, a.base or repo.base_branch(root) or "main" + + +def send_body(root, branch, body, body_path, a, pr, base): + """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" + if pr: + args = ["pr", "edit", str(pr["number"]), "--body-file", body_path] + if a.title: + args += ["--title", a.title] + p = gh_parts.gh(args, cwd=root) + if p.returncode != 0: + raise StepError(f"gh pr edit {pr['number']} が失敗: {p.stderr.strip()[:300]}") + return pr["number"], pr["url"], "updated" + args = ["pr", "create", "--base", base, "--title", a.title, "--body-file", body_path] + if a.draft: + args.append("--draft") + p = gh_parts.gh(args, cwd=root) + if p.returncode == 0: + url = (p.stdout.strip().splitlines() or [""])[-1] + m = re.search(r"/pull/(\d+)", url) + return (int(m.group(1)) if m else None), url, "created" + if gh_parts.is_rate_limited(p.stderr): + number, url = rest_create(root, branch, base, a.title, body, a.draft) + return number, url, "created_rest" + raise StepError(f"gh pr create が失敗: {p.stderr.strip()[:300]}") + + +def rest_create(root, branch, base, title, body, draft): + owner, name = repo_owner_name(root) + payload = {"title": title, "head": branch, "base": base, "body": body, "draft": bool(draft)} + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + tmp = f.name + try: + p = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls", "--input", tmp], cwd=root) + finally: + Path(tmp).unlink(missing_ok=True) # 本文を /tmp に残さない + if p.returncode != 0: + raise StepError(f"REST でも作成が失敗: {p.stderr.strip()[:300]}") + d = json.loads(p.stdout) + return d["number"], d["html_url"] diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index a7205401d..06c76e821 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -43,9 +43,7 @@ import argparse import json -import re import shlex -import subprocess import sys from contextlib import nullcontext from pathlib import Path @@ -71,46 +69,27 @@ import gh_rest # noqa: E402 import locks # noqa: E402 import step_read # noqa: E402 projects-sync.sh の結果 JSON を読む(#2227) -from closing import referenced_numbers # noqa: E402 from dist_record import parse_record # 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 +from sprint_close_lib.final_stage import ( # noqa: E402 閉じる条件 3(#2228) + FINAL_GAP_LEAD, + FINAL_STAGES, + NO_BASE, + STAGE_RETRO, + STAGE_VERIFY, + final_stage_gap, + gap_item, +) + TOOL = "sprint-close" RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(supervise_lib/sprint_routes.py が渡す。#1875) UNKNOWN_REASON = "配布の記録の置き場が分からない(リリースの経路が雛形でも昇格でもない。リリース記録を確かめて手で閉じる)" LOCK_TIMEOUT = 30 # 課題ごとの錠の待ち(body-lock の既定と同じ) -# --- 記録の読み取り ------------------------------------------------------------- - - -_COMMENT_ID = re.compile(r"#issuecomment-(\d+)$") - - -def read_record(root, repo, n): - """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。 - - (連結した文字列, 部分ごとの (先頭の行の番号, コメントの識別子) の並び) を返す。本文の識別子は None、コメントの - 識別子は `comments[].url` の `#issuecomment-<識別子>` の数字(読めなければ None)。""" - p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "body,comments"], cwd=root) - if p.returncode != 0: - raise StepError(f"記録の PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) - try: - d = json.loads(p.stdout) - except ValueError: - raise StepError(f"記録の PR #{n} の出力を読めない", EXIT_UNREADABLE) - comments = [c or {} for c in d.get("comments") or []] - parts = [(d.get("body") or "", None)] - for c in comments: - m = _COMMENT_ID.search(c.get("url") or "") - parts.append((c.get("body") or "", int(m.group(1)) if m else None)) - starts, line = [], 0 - for text, cid in parts: - starts.append((line, cid)) - line += len(text.split("\n")) - return "\n".join(t for t, _ in parts), starts - - IssueRows = acceptance.IssueRows # リリース後テストの表は承認の材料と同じ規則で読む(lib/acceptance.py。#1885 の決定 8) verification_verdicts = acceptance.verification_verdicts @@ -118,7 +97,6 @@ def read_record(root, repo, n): NO_ROWS = "リリース後テストの行が無い" NOT_PASSED = "リリース後テストに合格でない条件がある(不合格・保留)" UNVERIFIED = "閉じているが閉じる条件 2 を満たさない: " -UNVERIFIED_FINAL = "閉じているが閉じる条件 3 を満たさない: " UNREADABLE_BODY = "本文を読めない(最終工程の記録を確かめられない)" @@ -136,78 +114,6 @@ def judge_acceptance(rows, criteria): return True, None, [c.number for c in criteria if not c.checked] -# --- 閉じる条件 3(最終工程の記録。#2228) ----------------------------------------------- - -STAGE_DIST, STAGE_VERIFY, STAGE_RETRO = "配布", "リリース後テスト", "振り返り" -FINAL_STAGES = (STAGE_DIST, STAGE_VERIFY, STAGE_RETRO) -NO_BASE = 0 # --record-pr 0 の比べる基準(配布の記録が無いため、どの識別子も数える) -_RETRO_LINE = re.compile(r"^振り返り:\s*https://github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/\d+#issuecomment-(\d+)\s*$", re.M) -FINAL_GAP = "最終工程({stage})の記録が無い: {what}" -FINAL_GAP_LEAD = "最終工程(" - - -def final_stage_gap(stage, body, since): - """最終工程の記録が無ければ、何が無いかを含む理由を返す。あれば None。 - - 配布とリリース後テストの記録は条件 1・2 が見るため、ここでは振り返りだけを判定する。振り返りは本文の - `振り返り: <コメントの URL>` の行のうち、識別子が since(記録の PR の最後の配布の記録のコメントの識別子)以上の - ものを数える。since が None なら比べる基準が無いため満たさない。""" - if stage != STAGE_RETRO: - return None - if since is None: - return FINAL_GAP.format(stage=stage, what="比べる配布の記録のコメントが無い") - ids = [int(x) for x in _RETRO_LINE.findall(body or "")] - if not ids: - return FINAL_GAP.format(stage=stage, what="課題の本文に振り返りの記録の行が無い") - if max(ids) < since: - return FINAL_GAP.format(stage=stage, what="課題の本文の振り返りの行が今回の配布の記録より前のもの") - return None - - -def _gap_item(base, state, gap): - """条件 3 を満たさない課題の項目。閉じていれば開き直さない。""" - if state == "CLOSED": - return {**base, "result": "closed_unverified", "reason": UNVERIFIED_FINAL + gap} - return {**base, "result": "kept_open", "reason": gap} - - -# --- 課題の収集 ----------------------------------------------------------------- - - -def _read_sprint_pr(root, repo, n): - """スプリントの PR の題と本文を 1 回で読む。""" - p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "title,body"], cwd=root) - if p.returncode != 0: - raise StepError(f"スプリントの PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) - try: - d = json.loads(p.stdout) - except ValueError: - raise StepError(f"スプリントの PR #{n} の出力を読めない", EXIT_UNREADABLE) - return d.get("title") or "", d.get("body") or "" - - -def sprint_issues(root, repo, prs): - """(closing keywords が指す課題, 題と本文が参照する記録のリポジトリの番号) を返す。 - - closing keywords は本文だけから読む(GitHub は題の closing keywords で課題を閉じない)。""" - seen, out, refs = set(), [], [] - for n in prs: - title, body = _read_sprint_pr(root, repo, n) - refs += [r for r in referenced_numbers(title + "\n" + body, repo) if r not in refs] - c = subprocess.run( - ["bash", str(HERE / "lib" / "closing-issues.sh"), "--repo", repo], cwd=root, input=body, capture_output=True, text=True - ) - for ln in c.stdout.splitlines(): - if "\t" not in ln: - continue - r, num = ln.split("\t", 1) - key = (r.strip(), int(num.strip())) - if key not in seen: - seen.add(key) - out.append(key) - return out, refs - - # --- 閉じる --------------------------------------------------------------------- @@ -339,7 +245,7 @@ def _judge_and_mark(a, ctx, repo, n, rows): return {**base, "result": "kept_open", "reason": why}, [] gap = final_stage_gap(ctx.final_stage, body, ctx.since) if gap: # 条件 3 を満たさない課題の本文は書かない - return _gap_item(base, state, gap), [] + return gap_item(base, state, gap), [] if a.dry_run: out = {**base, "result": "already_closed" if state == "CLOSED" else "would_close"} return ({**out, "would_check": to_check} if own and to_check else out), [] @@ -378,7 +284,7 @@ def _close_item(a, ctx, repo, n, kept_all, verdicts): state, body = got gap = final_stage_gap(ctx.final_stage, body, ctx.since) if gap: - return _gap_item(base, state, gap) + return gap_item(base, state, gap) if a.dry_run: return {**base, "result": "already_closed" if state == "CLOSED" else "would_close"} if a.dry_run: @@ -391,25 +297,6 @@ def _close_item(a, ctx, repo, n, kept_all, verdicts): return close_one(ctx, repo, n) -NOT_GIVEN = "closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も --issues も無い参照" - - -def _referenced_items(root, repo, refs, issues): - """参照だけの課題の行。1 番号につき状態と URL を 1 回読み、Pull Request と閉じた課題は行にしない。""" - items = [] - for n in refs: - if (repo, n) in issues: - continue - p = gh_call.gh(["issue", "view", str(n), "--repo", repo, "--json", "state,url", "-q", "[.state, .url] | @tsv"], cwd=root) - state, _, url = p.stdout.strip().partition("\t") - base = {"kind": "issue", "repo": repo, "number": n, "result": "kept_open"} - if p.returncode != 0 or not state: - items.append({**base, "reason": f"状態を読めない({NOT_GIVEN})"}) - elif "/pull/" not in url and state != "CLOSED": - items.append({**base, "reason": NOT_GIVEN}) - return items - - def _close_summary(items, prs, dry_run, referenced): kinds = ("closed", "already_closed", "failed", "closed_unverified", "kept_open", "would_close") count = {k: sum(1 for i in items if i["result"] == k) for k in kinds} @@ -485,7 +372,7 @@ def cmd_close(a): notes = ctx.notes for repo, n in issues: items.append(_close_item(a, ctx, repo, n, kept_all, verdicts)) - refs_items = _referenced_items(root, record_repo, refs, issues) + refs_items = referenced_items(root, record_repo, refs, issues) items += refs_items count, metrics, summary = _close_summary(items, prs, a.dry_run, referenced=len(refs_items)) diff --git a/plugins/ndf/scripts/sprint_close_lib/__init__.py b/plugins/ndf/scripts/sprint_close_lib/__init__.py new file mode 100644 index 000000000..73217df0e --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/__init__.py @@ -0,0 +1 @@ +"""sprint-close.py から分けたモジュール。""" diff --git a/plugins/ndf/scripts/sprint_close_lib/collect.py b/plugins/ndf/scripts/sprint_close_lib/collect.py new file mode 100644 index 000000000..e7be8763a --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/collect.py @@ -0,0 +1,96 @@ +"""記録の PR とスプリントの PR の読み取りと、閉じる課題・参照だけの課題の集め方(`sprint-close.py` から分けた)。 + +記録の形は `lib/dist_record.py`、closing keywords の読み取りは `lib/closing-issues.sh` と `lib/closing.py` が持つ。 +出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import gh_call +from closing import referenced_numbers +from step_result import EXIT_UNREADABLE, StepError + +CLOSING_ISSUES = Path(__file__).resolve().parent.parent / "lib" / "closing-issues.sh" + + +_COMMENT_ID = re.compile(r"#issuecomment-(\d+)$") + + +def read_record(root, repo, n): + """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。 + + (連結した文字列, 部分ごとの (先頭の行の番号, コメントの識別子) の並び) を返す。本文の識別子は None、コメントの + 識別子は `comments[].url` の `#issuecomment-<識別子>` の数字(読めなければ None)。""" + p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "body,comments"], cwd=root) + if p.returncode != 0: + raise StepError(f"記録の PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) + try: + d = json.loads(p.stdout) + except ValueError: + raise StepError(f"記録の PR #{n} の出力を読めない", EXIT_UNREADABLE) + comments = [c or {} for c in d.get("comments") or []] + parts = [(d.get("body") or "", None)] + for c in comments: + m = _COMMENT_ID.search(c.get("url") or "") + parts.append((c.get("body") or "", int(m.group(1)) if m else None)) + starts, line = [], 0 + for text, cid in parts: + starts.append((line, cid)) + line += len(text.split("\n")) + return "\n".join(t for t, _ in parts), starts + + +def _read_sprint_pr(root, repo, n): + """スプリントの PR の題と本文を 1 回で読む。""" + p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "title,body"], cwd=root) + if p.returncode != 0: + raise StepError(f"スプリントの PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) + try: + d = json.loads(p.stdout) + except ValueError: + raise StepError(f"スプリントの PR #{n} の出力を読めない", EXIT_UNREADABLE) + return d.get("title") or "", d.get("body") or "" + + +def sprint_issues(root, repo, prs): + """(closing keywords が指す課題, 題と本文が参照する記録のリポジトリの番号) を返す。 + + closing keywords は本文だけから読む(GitHub は題の closing keywords で課題を閉じない)。""" + seen, out, refs = set(), [], [] + for n in prs: + title, body = _read_sprint_pr(root, repo, n) + refs += [r for r in referenced_numbers(title + "\n" + body, repo) if r not in refs] + c = subprocess.run(["bash", str(CLOSING_ISSUES), "--repo", repo], cwd=root, input=body, capture_output=True, text=True) + for ln in c.stdout.splitlines(): + if "\t" not in ln: + continue + r, num = ln.split("\t", 1) + key = (r.strip(), int(num.strip())) + if key not in seen: + seen.add(key) + out.append(key) + return out, refs + + +NOT_GIVEN = "closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も --issues も無い参照" + + +def referenced_items(root, repo, refs, issues): + """参照だけの課題の行。1 番号につき状態と URL を 1 回読み、Pull Request と閉じた課題は行にしない。""" + items = [] + for n in refs: + if (repo, n) in issues: + continue + p = gh_call.gh(["issue", "view", str(n), "--repo", repo, "--json", "state,url", "-q", "[.state, .url] | @tsv"], cwd=root) + state, _, url = p.stdout.strip().partition("\t") + base = {"kind": "issue", "repo": repo, "number": n, "result": "kept_open"} + if p.returncode != 0 or not state: + items.append({**base, "reason": f"状態を読めない({NOT_GIVEN})"}) + elif "/pull/" not in url and state != "CLOSED": + items.append({**base, "reason": NOT_GIVEN}) + return items diff --git a/plugins/ndf/scripts/sprint_close_lib/final_stage.py b/plugins/ndf/scripts/sprint_close_lib/final_stage.py new file mode 100644 index 000000000..e5e274684 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/final_stage.py @@ -0,0 +1,41 @@ +"""閉じる条件 3(最終工程の記録。#2228)の判定(`sprint-close.py` から分けた)。 + +配布とリリース後テストの記録は条件 1・2 が見るため、ここは振り返りの記録だけを判定する。出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import re + +UNVERIFIED_FINAL = "閉じているが閉じる条件 3 を満たさない: " +STAGE_DIST, STAGE_VERIFY, STAGE_RETRO = "配布", "リリース後テスト", "振り返り" +FINAL_STAGES = (STAGE_DIST, STAGE_VERIFY, STAGE_RETRO) +NO_BASE = 0 # --record-pr 0 の比べる基準(配布の記録が無いため、どの識別子も数える) +_RETRO_LINE = re.compile(r"^振り返り:\s*https://github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/\d+#issuecomment-(\d+)\s*$", re.M) +FINAL_GAP = "最終工程({stage})の記録が無い: {what}" +FINAL_GAP_LEAD = "最終工程(" + + +def final_stage_gap(stage, body, since): + """最終工程の記録が無ければ、何が無いかを含む理由を返す。あれば None。 + + 配布とリリース後テストの記録は条件 1・2 が見るため、ここでは振り返りだけを判定する。振り返りは本文の + `振り返り: <コメントの URL>` の行のうち、識別子が since(記録の PR の最後の配布の記録のコメントの識別子)以上の + ものを数える。since が None なら比べる基準が無いため満たさない。""" + if stage != STAGE_RETRO: + return None + if since is None: + return FINAL_GAP.format(stage=stage, what="比べる配布の記録のコメントが無い") + ids = [int(x) for x in _RETRO_LINE.findall(body or "")] + if not ids: + return FINAL_GAP.format(stage=stage, what="課題の本文に振り返りの記録の行が無い") + if max(ids) < since: + return FINAL_GAP.format(stage=stage, what="課題の本文の振り返りの行が今回の配布の記録より前のもの") + return None + + +def gap_item(base, state, gap): + """条件 3 を満たさない課題の項目。閉じていれば開き直さない。""" + if state == "CLOSED": + return {**base, "result": "closed_unverified", "reason": UNVERIFIED_FINAL + gap} + return {**base, "result": "kept_open", "reason": gap} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json deleted file mode 100644 index 0ecfbea43..000000000 --- a/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json +++ /dev/null @@ -1 +0,0 @@ -{"path": "plugins/ndf/scripts/pr-steps.py", "name": "", "kind": "lines", "reason": "一括の整形(#1323)で物理行が増え、ツールのパスの分類と除外(#1436)で 6 行増えた。500 行以下にしたら消す", "lines": 599} From 10fa6af9c962900fd1702ff0a72e8fd5061193bf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 16:25:26 +0000 Subject: [PATCH 8/9] =?UTF-8?q?Docs:=20=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E3=81=BE=E3=81=A8=E3=82=81=E3=81=AE=E3=83=97?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=81=AE=E4=B8=A6=E3=81=B3=E3=81=A8=E9=96=89?= =?UTF-8?q?=E3=81=98=E3=82=8B=E6=9D=A1=E4=BB=B6=E3=83=BB=E9=96=89=E3=81=98?= =?UTF-8?q?=E3=82=8B=E4=BA=88=E5=AE=9A=E3=81=AE=E5=8F=82=E7=85=A7=E3=82=92?= =?UTF-8?q?=20#2228=20=E3=81=AE=E6=8C=AF=E3=82=8B=E8=88=9E=E3=81=84?= =?UTF-8?q?=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計の構成要素の契約の写しのパスをリポジトリの根からの形に直す。 Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-backlog-refinement-stage.md | 6 +++--- .../specifications/ndf-cleanup-and-bundle-closing.md | 12 +++++++----- .../ndf-sprint-close-referenced-issues.md | 2 ++ ...nt-misjudgments-unknown-record-and-manual-none.md | 8 ++++---- docs/specifications/ndf-workflow-development-loop.md | 2 +- issues/issue-2228-design.md | 2 +- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/specifications/ndf-backlog-refinement-stage.md b/docs/specifications/ndf-backlog-refinement-stage.md index c442d6236..d0f62d0c9 100644 --- a/docs/specifications/ndf-backlog-refinement-stage.md +++ b/docs/specifications/ndf-backlog-refinement-stage.md @@ -100,8 +100,8 @@ conductor が打ち切った。今の形では次のように動く。 ### スプリントの終わりのプランの `refine` ステップ -`supervise.py new close` が組むプラン(`supervise_lib/sprint.py` の `close_plan`)は、`retro` の後に -`refine` を置く。`retro` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 +`supervise.py new close` が組むプラン(`supervise_lib/sprint.py` の `close_plan`)は、`retro` の後に課題を閉じる `close` を、 +その後に `refine` を置く(#2228)。`retro` の `next` は `close`、`close` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 | キー | 値 | | --- | --- | @@ -131,7 +131,7 @@ worker の歯止めで、費用の上限は件数が持つ。プランの `上 | 不正な上限は候補を集めずに終了コード 2 | 同上の `test_candidates_reject_invalid_limit_before_collecting` | | 候補が 0 件の棚卸しは上限の導入で持ち越しにならない | 同上の `test_candidates_zero_is_ok_under_limit` | | 書き込み先・plan の `repo`・記録のどれかが食い違えば、1 件も書かずに終了コード 3 | 同上の `test_apply_refuses_*` | -| `new close` のプランで `retro` の次が `refine`、`refine` の `stage` が `棚卸し` で `timeout` を持ち、プロンプトが無人の扱いを渡す | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_close_and_retro_once_each_in_order`) | +| `new close` のプランで `retro` の次が `close`、その次が `refine`、`refine` の `stage` が `棚卸し` で `timeout` を持ち、プロンプトが無人の扱いを渡す | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_close_and_retro_once_each_in_order`) | | `STEP_PHASES` がフェーズの表と一致する | `plugins/ndf/scripts/tests/test_transcript_agents.py`(`test_the_step_names_match_the_phase_table`) | | `stage 棚卸し` が記録され、5 モードで必須として扱われる | `plugins/ndf/scripts/tests/test_progress_record_stages.py`(`test_the_refinement_stage_is_recorded_and_required_in_every_mode`) | | 最終工程を持つ 3 つの Skill(`retrospective` / `release-verification` / `release`)が棚卸しを呼ばない。順序の制約と規則 14 がそれぞれ 1 か所にある | 文書を `grep -n` で読んで確かめる(文言を照合するテストは書かない) | diff --git a/docs/specifications/ndf-cleanup-and-bundle-closing.md b/docs/specifications/ndf-cleanup-and-bundle-closing.md index 4e49ca1be..24017c929 100644 --- a/docs/specifications/ndf-cleanup-and-bundle-closing.md +++ b/docs/specifications/ndf-cleanup-and-bundle-closing.md @@ -29,8 +29,8 @@ Request の head のリモートブランチは、同意を求めずに消す。 有無にかかわらず消さない。 **スプリント課題は、最終工程を出るときに閉じる。** 閉じる条件は「本番リリースまで済んで -いる、またはリリースを飛ばした」と「リリース後テストを通る経路では受け入れ条件がすべて `合格`」の -2 つで、課題ごとに見る。`merged` は課題を閉じない。 +いる、またはリリースを飛ばした」と「リリース後テストを通る経路では受け入れ条件がすべて `合格`」と +「その実行の最終工程の記録がある(`--final-stage`、省けば振り返り。#2228)」の 3 つで、課題ごとに見る。`merged` は課題を閉じない。 **リリース記録は Pull Request に置く。** `release` の出力物は会話に出るだけで別の実行からは 読めないため、書く側の形を 1 つに決め、読む側はその形だけを読む。 @@ -227,7 +227,7 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 | `既に閉じていた` | 最初の読みが CLOSED(既定ブランチへのマージで GitHub が先に閉じた場合を含む) | | `失敗(理由)` | 最後の読みが CLOSED でない、または読み取りが 0 以外で終わった | | `開いたまま(理由)` | 閉じる条件に当たらない | -| `閉じているが条件を満たさない(理由)`(`closed_unverified`) | 最初の読みが CLOSED で、閉じる条件 2 を満たさない | +| `閉じているが条件を満たさない(理由)`(`closed_unverified`) | 最初の読みが CLOSED で、閉じる条件 2 か 3 を満たさない | **`失敗` が 1 件でもあれば、最終工程を完了と報告せず、棚卸しへ進まずに止まる。** `開いたまま` と `閉じているが条件を満たさない` は止める理由にせず、棚卸しへ進んでよい。 @@ -299,8 +299,10 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 一時ディレクトリにあり、共通の git ディレクトリと別のファイルシステムのことがあるため、`mv` が コピーになる。 -**チャネルを分けないリポジトリでは、既定ブランチへのマージで GitHub が先に閉じる。** 「スプリントを -閉じる」は `既に閉じていた` を数えるだけで、二重に閉じようとしても結果は変わらない。 +**チャネルを分けないリポジトリでも、NDF の手順で書いた Pull Request の本文では GitHub が先に閉じない。** +既定ブランチ宛の本文の closing keywords は、`pr-steps.py` とプランの pr のステップが閉じる予定の参照 +(`最終工程の後に閉じる: #<番号>`)へ置き換える(#2228)。手で書いた本文・コミットメッセージの closing keywords で +GitHub が先に閉じたときは、「スプリントを閉じる」は `既に閉じていた` を数えるだけで、二重に閉じようとしても結果は変わらない。 ## 関連リンク diff --git a/docs/specifications/ndf-sprint-close-referenced-issues.md b/docs/specifications/ndf-sprint-close-referenced-issues.md index 3b073c0e7..075739407 100644 --- a/docs/specifications/ndf-sprint-close-referenced-issues.md +++ b/docs/specifications/ndf-sprint-close-referenced-issues.md @@ -21,6 +21,8 @@ 語の正本の行は `development-workflow` の [`references/glossary.md`](../../plugins/ndf/skills/development-workflow/references/glossary.md) にもある。 closing keywords は、Pull Request の本文に課題の番号と一緒に書くと、マージのときに GitHub がその課題を自動で閉じるキーワードである。 +閉じる予定の参照(`最終工程の後に閉じる: #12`。既定ブランチ宛の本文で NDF の手順が closing keywords を置き換えた形)も、 +`closing-issues.sh` が closing keywords と同じ指し先の規則で本文から集め、参照だけの課題にしない(#2228)。 ## 背景 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 110729faf..663b9bd8b 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 @@ -51,9 +51,9 @@ | # | 条件 | 破れたときの扱い | | --- | --- | --- | | I1 | まとめのプランの記録の参照が `0`(`RECORD_NONE`)になるのは、リリースの経路がすべて届けない経路(`STAGE_NONE`)のときだけである | テストが落とす | -| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `close` になる | テストが落とす | +| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | | I3 | `--record-pr unknown` の実行は、どの課題も閉じず、ボードも書かず、課題の本文も読まない(gh へ書き込まない) | テストが落とす | -| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件を見ずに `--issues` を閉じる。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | +| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は #2228 から見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | | I5 | 要求の 2 列目が「無し」の形の行は `ManualRow` にならない。それ以外の空でない 2 列目は手動確認の行になる | テストが落とす | ### 入出力の契約 @@ -112,9 +112,9 @@ flowchart TD C -- いいえ --> D{"経路がすべて STAGE_NONE"} D -- はい --> Z["0"] D -- いいえ --> U["unknown"] - T --> V["merge → release-verify → close"] + T --> V["merge → release-verify → retro → close"] P --> V - Z --> W["merge → close(release-verify なし)"] + Z --> W["merge → retro → close(release-verify なし)"] U --> W ``` diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 6eec23341..1c6044eb7 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -190,7 +190,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | | `sprint-state.py init --name <名>- --pace auto --issue ... --dev V --prod V --goal <再開のコマンド> [--mvv mvv.md --milestone M]` | `--prod` を省けば開発版の接尾辞を外した版 | | `supervise.py new sprint --pace auto --issue <選んだ課題> --design <承認済みでない課題> [--designed ] ... [<-- の後の引数>]` | `loop answer --approved` で答えた課題は設計のプランを作らず、設計の結果のステージが設計 PR の設計の結果を読む | -| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 課題を閉じる → 振り返り → 棚卸し)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | +| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 振り返り → 課題を閉じる → 棚卸し。#2228)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | `pipeline` は、最初のステージの queue の最後に `--then <まとめのプラン>` をつなぎ、`supervise.py wait ` → `loop lap <名> --done ` を続けた 1 行である。手で行うステージか別の queue のステージが残るときは、それを終えてから diff --git a/issues/issue-2228-design.md b/issues/issue-2228-design.md index 111b34635..b5a9a4d75 100644 --- a/issues/issue-2228-design.md +++ b/issues/issue-2228-design.md @@ -104,7 +104,7 @@ | `scripts/pr-steps.py` | `upsert` で、宛先(新しく作るなら `--base` か起点、既存なら `baseRefName`)が `merge_closes_issues` に当たれば本文を `defer_closing` に通す。置き換えた指し先を `metrics.deferred_closing` に載せる | | `scripts/supervise_lib/pr.py` | `_publish` で同じ判定と置き換えを行ってから `gh pr create` / `gh pr edit` へ渡す | | `scripts/supervise_lib/sprint_close_steps.py`・`sprint.py`・`release_templates.py` | まとめのプランの並びを `merge →(release-verify)→ retro → close → refine` にする。`close` は `--final-stage 振り返り` を明示し、`retro` の指示に「課題を閉じない(次の close が閉じる)」を足す | -| `tests/fixtures/supervise-contract/plans/close/4-close.json` | まとめのプランの契約の写しを新しい並びに合わせる | +| `plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json` | まとめのプランの契約の写しを新しい並びに合わせる | | `skills/progress-tracking/SKILL.md` の「スプリントを閉じる」 | 閉じる時点の正本。閉じる条件 3・`--final-stage` の値と呼ぶ Skill の対応・閉じる予定の参照・プランの並びを持つ | | `skills/pr/SKILL.md` の「closing keywords は本文だけに書く」 | 既定ブランチ宛では NDF の手順が置き換えることを書き、閉じる時点は正本を指す | | `skills/release-verification/SKILL.md`・`retrospective`・`release`・`merged` と `development-workflow` の references(`pace.md`・`loop.md`・`stage-completeness.md`・`glossary.md`) | 閉じる時点・順序・閉じる条件の写しを外し、正本を指す。順序の説明の文を新しい並びに直す | From 70158f2af7d57917a02c3c3e9a1e6b8f5b60a851 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 16:40:24 +0000 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20doc-lint=20=E3=81=AE=E3=83=92?= =?UTF-8?q?=E3=83=83=E3=83=88=EF=BC=88=E8=AA=B2=E9=A1=8C=E7=95=AA=E5=8F=B7?= =?UTF-8?q?=E3=81=AE=E7=94=B1=E6=9D=A5=E3=83=BB=E6=AF=94=E8=BC=83=E3=81=AE?= =?UTF-8?q?=E8=AA=9E=EF=BC=89=E3=82=92=E4=BB=8A=E3=81=AE=E6=B1=BA=E3=81=BE?= =?UTF-8?q?=E3=82=8A=E3=81=AE=E5=BD=A2=E3=81=B8=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-backlog-refinement-stage.md | 2 +- docs/specifications/ndf-cleanup-and-bundle-closing.md | 2 +- docs/specifications/ndf-sprint-close-referenced-issues.md | 2 +- .../ndf-sprint-misjudgments-unknown-record-and-manual-none.md | 2 +- plugins/ndf/skills/pr/SKILL.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specifications/ndf-backlog-refinement-stage.md b/docs/specifications/ndf-backlog-refinement-stage.md index d0f62d0c9..4fd2befd6 100644 --- a/docs/specifications/ndf-backlog-refinement-stage.md +++ b/docs/specifications/ndf-backlog-refinement-stage.md @@ -101,7 +101,7 @@ conductor が打ち切った。今の形では次のように動く。 ### スプリントの終わりのプランの `refine` ステップ `supervise.py new close` が組むプラン(`supervise_lib/sprint.py` の `close_plan`)は、`retro` の後に課題を閉じる `close` を、 -その後に `refine` を置く(#2228)。`retro` の `next` は `close`、`close` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 +その後に `refine` を置く。`retro` の `next` は `close`、`close` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 | キー | 値 | | --- | --- | diff --git a/docs/specifications/ndf-cleanup-and-bundle-closing.md b/docs/specifications/ndf-cleanup-and-bundle-closing.md index 24017c929..d2334731b 100644 --- a/docs/specifications/ndf-cleanup-and-bundle-closing.md +++ b/docs/specifications/ndf-cleanup-and-bundle-closing.md @@ -301,7 +301,7 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 **チャネルを分けないリポジトリでも、NDF の手順で書いた Pull Request の本文では GitHub が先に閉じない。** 既定ブランチ宛の本文の closing keywords は、`pr-steps.py` とプランの pr のステップが閉じる予定の参照 -(`最終工程の後に閉じる: #<番号>`)へ置き換える(#2228)。手で書いた本文・コミットメッセージの closing keywords で +(`最終工程の後に閉じる: #<番号>`)へ置き換える。手で書いた本文・コミットメッセージの closing keywords で GitHub が先に閉じたときは、「スプリントを閉じる」は `既に閉じていた` を数えるだけで、二重に閉じようとしても結果は変わらない。 ## 関連リンク diff --git a/docs/specifications/ndf-sprint-close-referenced-issues.md b/docs/specifications/ndf-sprint-close-referenced-issues.md index 075739407..c257845b9 100644 --- a/docs/specifications/ndf-sprint-close-referenced-issues.md +++ b/docs/specifications/ndf-sprint-close-referenced-issues.md @@ -22,7 +22,7 @@ [`references/glossary.md`](../../plugins/ndf/skills/development-workflow/references/glossary.md) にもある。 closing keywords は、Pull Request の本文に課題の番号と一緒に書くと、マージのときに GitHub がその課題を自動で閉じるキーワードである。 閉じる予定の参照(`最終工程の後に閉じる: #12`。既定ブランチ宛の本文で NDF の手順が closing keywords を置き換えた形)も、 -`closing-issues.sh` が closing keywords と同じ指し先の規則で本文から集め、参照だけの課題にしない(#2228)。 +`closing-issues.sh` が closing keywords と同じ指し先の規則で本文から集め、参照だけの課題にしない。 ## 背景 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 663b9bd8b..34fafe161 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 @@ -53,7 +53,7 @@ | I1 | まとめのプランの記録の参照が `0`(`RECORD_NONE`)になるのは、リリースの経路がすべて届けない経路(`STAGE_NONE`)のときだけである | テストが落とす | | I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | | I3 | `--record-pr unknown` の実行は、どの課題も閉じず、ボードも書かず、課題の本文も読まない(gh へ書き込まない) | テストが落とす | -| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は #2228 から見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | +| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | | I5 | 要求の 2 列目が「無し」の形の行は `ManualRow` にならない。それ以外の空でない 2 列目は手動確認の行になる | テストが落とす | ### 入出力の契約 diff --git a/plugins/ndf/skills/pr/SKILL.md b/plugins/ndf/skills/pr/SKILL.md index 6a92d9521..2e326fa7b 100644 --- a/plugins/ndf/skills/pr/SKILL.md +++ b/plugins/ndf/skills/pr/SKILL.md @@ -162,7 +162,7 @@ URL は最終行に生のまま置く(Markdown リンクにすると利用者 外さない**(`stage-completeness.md` と `progress-tracking` の「スプリントを閉じる」が本文を入力にする)。 **宛先が既定ブランチの Pull Request では、`pr-steps.py create` / `update` が本文の closing keywords を閉じる予定の参照 -(`最終工程の後に閉じる: #<番号>`)へ置き換えてから送る。** 書く側は宛先によらず closing keywords で書いてよい。置き換えた +(`最終工程の後に閉じる: #<番号>`)へ置き換えてから送る。** 書く側は closing keywords で書いてよい。置き換えた 指し先は結果の `metrics.deferred_closing` に載る。課題を閉じる時点と閉じる予定の参照の形は `progress-tracking` の 「スプリントを閉じる」が正本である。