From 67f6c9ccac214e060a96a69d156bffad49a9ab63 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:56:54 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Feat:=20=E5=8F=97=E3=81=91=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=81=AE=E7=A2=BA=E8=AA=8D=E3=81=AE?= =?UTF-8?q?=E5=8C=BA=E5=88=86=EF=BC=88=E3=83=9E=E3=83=BC=E3=82=B8=E5=89=8D?= =?UTF-8?q?=20/=20=E3=83=AA=E3=83=AA=E3=83=BC=E3=82=B9=E5=BE=8C=EF=BC=89?= =?UTF-8?q?=E3=82=92=E8=AA=B2=E9=A1=8C=E3=81=AE=E6=9C=AC=E6=96=87=E3=81=B8?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=81=97=E3=80=81=E6=89=BF=E8=AA=8D=E8=B3=87?= =?UTF-8?q?=E6=96=99=E3=81=A8=E9=96=89=E3=81=98=E3=82=8B=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=81=8C=E8=AA=AD=E3=82=80=20(#2144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acceptance-check.py record / show が本文の「## 受け入れ条件の確認」の表を書く・読む - read_unverified はマージ前に確かめた条件を「未実施のリリース後テスト」に載せない - judge_acceptance はマージ前に確かめた条件に合格の行を求めない - 記録の無い課題は今までどおり Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/acceptance-check.py | 148 +++++++++++++++ plugins/ndf/scripts/lib/acceptance.py | 50 ++++++ plugins/ndf/scripts/release_lib/materials.py | 6 +- plugins/ndf/scripts/sprint-close.py | 14 +- .../scripts/tests/test_acceptance_checks.py | 168 ++++++++++++++++++ .../tests/test_sprint_close_acceptance.py | 10 ++ 6 files changed, 389 insertions(+), 7 deletions(-) create mode 100755 plugins/ndf/scripts/acceptance-check.py create mode 100644 plugins/ndf/scripts/tests/test_acceptance_checks.py diff --git a/plugins/ndf/scripts/acceptance-check.py b/plugins/ndf/scripts/acceptance-check.py new file mode 100755 index 000000000..893eee1b2 --- /dev/null +++ b/plugins/ndf/scripts/acceptance-check.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""受け入れ条件の確認の区分を課題の本文へ記録する・読む(#2144。完了判定 `quality-gates` が呼ぶ)。 + + python3 acceptance-check.py record --issue <番号> [--repo <所有者>/<リポジトリ>] + [--pre-merge '<条件の番号>=<手段>']... [--post-release <条件の番号>]... + python3 acceptance-check.py show --issue <番号> [--repo <所有者>/<リポジトリ>] + +記録の置き場は課題の本文の `## 受け入れ条件の確認` の節の表(`| 条件 | 区分 | 手段 |`)である。区分は +`マージ前`(マージ前に確かめた。手段にテスト名か実行したコマンドを書く)か `リリース後`(リリースの後でしか +確かめられない)のどちらか。record は今の表へ渡した番号の行を足すか置き換え、課題ごとの錠の中で本文を読んでから +書く。番号は本文の `## 受け入れ条件` にある番号だけを受ける(規則は lib/acceptance.py)。 + +この記録を読むのは、承認資料の「未実施のリリース後テスト」(release_lib/materials.read_unverified。マージ前の +条件を載せない)と、sprint-close.py --with-verification の閉じる条件 2(マージ前の条件に合格の行を求めない)である。 + +結果は lib/step_result.py の形の 1 行の JSON(`tool` は `acceptance-check`)。items は条件ごとに +{kind:"criterion", name:<番号>, result:"マージ前"|"リリース後", means?}。終了コード: 0 = 済んだ(変更なしを含む)/ +1 = 本文を書けない / 2 = 本文を読めない・引数の誤り(受け入れ条件に無い番号・手段の無いマージ前・同じ番号を 2 度)/ +3 = リポジトリを決められない・本文に受け入れ条件が無い。 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) +import deps # noqa: E402 + +deps.require("md", "mdtable", "locks") +import acceptance # noqa: E402 +import gh_call # noqa: E402 +import gh_parts # noqa: E402 +import gh_rest # noqa: E402 +import gh_sections # noqa: E402 +import mdtable # noqa: E402 +from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, EXIT_VIOLATION, StepError, emit, main_with, result # noqa: E402 + +TOOL = "acceptance-check" + + +def parse_pre_merge(values) -> dict[int, str]: + """`<番号>=<手段>` の並びを {番号: 手段} にする。形が違えば StepError(2)。""" + out: dict[int, str] = {} + for v in values or (): + num, sep, means = v.partition("=") + if not sep or not num.strip().isdigit() or not means.strip(): + raise StepError(f"--pre-merge は '<条件の番号>=<手段>' で渡す: {v!r}", EXIT_UNREADABLE) + out[int(num)] = means.strip() + return out + + +def merged_checks(body: str, pre_merge: dict[int, str], post_release) -> dict[int, acceptance.Check]: + """本文の今の記録へ渡した区分を重ねた記録。受け入れ条件に無い番号・両方に出る番号は StepError。""" + numbers = {c.number for c in acceptance.criteria(body)} + if not numbers: + raise StepError("本文に受け入れ条件(`## 受け入れ条件` のチェックボックス)が無い", EXIT_PRECONDITION) + post = set(post_release or ()) + both = sorted(post & set(pre_merge)) + if both: + raise StepError(f"マージ前とリリース後の両方に渡した番号: {both}", EXIT_UNREADABLE) + unknown = sorted((post | set(pre_merge)) - numbers) + if unknown: + raise StepError(f"受け入れ条件に無い番号: {unknown}(本文の条件は 1〜{max(numbers)})", EXIT_UNREADABLE) + checks = dict(acceptance.read_check_kinds(body)) + checks.update({n: acceptance.Check(acceptance.PRE_MERGE, m) for n, m in pre_merge.items()}) + checks.update({n: acceptance.Check(acceptance.POST_RELEASE, "") for n in post}) + return checks + + +def checks_table(checks: dict[int, acceptance.Check]) -> str: + """`## 受け入れ条件の確認` の節の中身(番号の順の表)。""" + rows = [(str(n), c.kind, c.means if c.kind == acceptance.PRE_MERGE else acceptance.NO_MEANS) for n, c in sorted(checks.items())] + return mdtable.table_markdown(acceptance.CHECKS_HEAD, rows, align=("left", "left", "left")) + + +def recorded_body(body: str, pre_merge: dict[int, str], post_release) -> str: + """記録を書いた本文。節の外は変えない。""" + table = checks_table(merged_checks(body, pre_merge, post_release)) + return gh_sections.replace_section(body, acceptance.CHECKS_HEADING, table) + + +def _items(checks: dict[int, acceptance.Check]) -> list[dict]: + return [ + {"kind": "criterion", "name": str(n), "result": c.kind, **({"means": c.means} if c.means else {})} + for n, c in sorted(checks.items()) + ] + + +def _slug(a) -> str: + slug = gh_call.resolve_repo(a.repo) + if not slug: + raise StepError("リポジトリを決められない(--repo を渡す)", EXIT_PRECONDITION) + return slug + + +def _fetch(slug: str, n: int) -> str: + body = gh_rest.fetch_body(slug, n) + if body is None: + raise StepError(f"{slug}#{n} の本文を読めない", EXIT_UNREADABLE) + return body + + +def cmd_record(a): + pre = parse_pre_merge(a.pre_merge) + if not pre and not a.post_release: + raise StepError("--pre-merge か --post-release を 1 つ以上渡す", EXIT_UNREADABLE) + slug = _slug(a) + with gh_parts.issue_lock(slug, a.issue): # 読みから書きまでを課題ごとの錠の中で行う + body = _fetch(slug, a.issue) + new = recorded_body(body, pre, a.post_release) + if new != body and not gh_rest.update_body(slug, a.issue, new): + emit(result(TOOL, "stopped", f"{slug}#{a.issue} の本文を書けない"), EXIT_VIOLATION) + checks = acceptance.read_check_kinds(new) + pre_n = sum(c.kind == acceptance.PRE_MERGE for c in checks.values()) + state = "変更なし" if new == body else "記録した" + emit(result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _items(checks)), 0) + + +def cmd_show(a): + slug = _slug(a) + checks = acceptance.read_check_kinds(_fetch(slug, a.issue)) + emit(result(TOOL, "ok", f"#{a.issue}: 記録 {len(checks)} 件", _items(checks)), 0) + + +def build_parser(): + ap = argparse.ArgumentParser(prog="acceptance-check.py", description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("record", help="条件ごとの区分(マージ前 / リリース後)と手段を本文へ書く") + p.add_argument("--issue", type=int, required=True) + p.add_argument("--repo") + p.add_argument("--pre-merge", action="append", default=[], help="'<条件の番号>=<手段(テスト名・実行したコマンド)>'") + p.add_argument("--post-release", action="append", type=int, default=[], help="リリースの後でしか確かめられない条件の番号") + p.set_defaults(func=cmd_record) + p = sub.add_parser("show", help="本文の記録を読む") + p.add_argument("--issue", type=int, required=True) + p.add_argument("--repo") + p.set_defaults(func=cmd_show) + return ap + + +def main(argv=None): + return main_with(build_parser(), lambda a: TOOL, argv) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/lib/acceptance.py b/plugins/ndf/scripts/lib/acceptance.py index a262a8d6d..956481444 100644 --- a/plugins/ndf/scripts/lib/acceptance.py +++ b/plugins/ndf/scripts/lib/acceptance.py @@ -80,6 +80,56 @@ def criteria_lines(body: str) -> list[tuple[Criterion, str]]: return out +# --- 受け入れ条件の確認の区分(#2144。完了判定が書き、承認資料と閉じる判定が読む) + + +CHECKS_HEADING = "## 受け入れ条件の確認" +PRE_MERGE = "マージ前" # マージ前に確かめた。手段(テスト名・実行したコマンド)を必ず持つ +POST_RELEASE = "リリース後" # リリースの後でしか確かめられない +KINDS = (PRE_MERGE, POST_RELEASE) +CHECKS_HEAD = ("条件", "区分", "手段") +NO_MEANS = "—" + + +class Check(NamedTuple): + """1 つの受け入れ条件の確認の区分。means はマージ前に確かめた手段(リリース後なら空)。""" + + kind: str + means: str + + +def read_check_kinds(body: str) -> dict[int, Check]: + """本文の `## 受け入れ条件の確認` の表を {条件の番号: Check} で返す。節・表が無ければ空(今の課題)。 + + `条件` の列の頭の数字を番号として読む。区分が `マージ前` / `リリース後` のどちらでもない行と、手段の無い + `マージ前` の行は読まない(確かめた手段を書けない条件は確認済みと言えない)。使う側は `deps.require("md")` を先に呼ぶ。""" + import md + + section = gh_sections.get_section(body or "", CHECKS_HEADING) + found = md.tables(section) if section else [] + if not found: + return {} + head = [c.strip() for c in found[0].header] + if any(h not in head for h in CHECKS_HEAD): + return {} + ni, ki, mi = (head.index(h) for h in CHECKS_HEAD) + out: dict[int, Check] = {} + for row in found[0].rows: + cs = [c.strip() for c in row] + num, kind, means = re.match(r"^(\d+)", cs[ni]), cs[ki], cs[mi].strip("`").strip() + if not num or kind not in KINDS: + continue + if kind == PRE_MERGE and (not means or means == NO_MEANS): + continue + out[int(num.group(1))] = Check(kind, "" if kind == POST_RELEASE else cs[mi]) + return out + + +def pre_merge_numbers(body: str) -> frozenset: + """マージ前に確かめたと記録された受け入れ条件の番号。""" + return frozenset(n for n, c in read_check_kinds(body).items() if c.kind == PRE_MERGE) + + # --- リリース後テストの表(sprint-close.py の閉じる判定と release-steps.py の承認の材料が同じ規則で読む。#1885 の決定 8) diff --git a/plugins/ndf/scripts/release_lib/materials.py b/plugins/ndf/scripts/release_lib/materials.py index 8cedacbb6..4969b8271 100644 --- a/plugins/ndf/scripts/release_lib/materials.py +++ b/plugins/ndf/scripts/release_lib/materials.py @@ -270,7 +270,8 @@ def read_unconfirmed(root: Path, base: str, issues) -> tuple[Unconfirmed, ...] | def read_unverified(bodies: dict[tuple[str, int], str], verdicts: dict, repo: str) -> tuple[Unverified, ...] | None: """閉じる課題((owner/name, 番号))の受け入れ条件のうち、印が無く、リリース後テストの表(verdicts)にその - リポジトリの課題として合格の行が無いもの。どの課題にも受け入れ条件が無ければ None。""" + リポジトリの課題として合格の行が無いもの。本文の `## 受け入れ条件の確認` でマージ前に確かめたと記録された + 条件は載せない(#2144。記録の無い課題は今までどおり)。どの課題にも受け入れ条件が無ければ None。""" out, found = [], False for (owner, n), body in bodies.items(): rows = acceptance.criteria_lines(body or "") @@ -282,7 +283,8 @@ def read_unverified(bodies: dict[tuple[str, int], str], verdicts: dict, repo: st continue passed = v.passed if v is not None else frozenset() other = None if same_repo(owner, repo) else owner - out += [Unverified(n, c.number, text, other) for c, text in rows if not c.checked and c.number not in passed] + done = passed | acceptance.pre_merge_numbers(body or "") + out += [Unverified(n, c.number, text, other) for c, text in rows if not c.checked and c.number not in done] return tuple(out) if found else None diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 601b07573..751b245e7 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -12,7 +12,8 @@ 3. 閉じる条件 1(本番への配布まで済んだ、または配布なし)と、--with-verification のときは 条件 2(その課題のリリース後テストの行がすべて合格で、本文の受け入れ条件の番号のすべてに合格の行がある) を課題ごとに判定する。`課題` の列が課題を指さない行(`—`・`PR 1869` など)は飛ばす。条件 2 は課題ごとの錠 - (lib/gh_parts.py の issue_lock)の中で状態と本文を 1 回で読んで判定し、満たした記録のリポジトリの課題は + (lib/gh_parts.py の issue_lock)の中で状態と本文を 1 回で読んで判定し(本文の `## 受け入れ条件の確認` で + マージ前に確かめたと記録された条件には合格の行を求めない。#2144)、満たした記録のリポジトリの課題は 本文の受け入れ条件に印(`[x]`)を付けてから閉じる(規則は lib/acceptance.py。#1683) 4. 条件を満たす課題ごとに (a) 状態を読む → (b) 記録のリポジトリの課題ならボードを Done → (c) 読み直して OPEN なら閉じる → (d) 読み直して CLOSED を確かめる @@ -100,15 +101,18 @@ def read_record(root, repo, n): UNVERIFIED = "閉じているが閉じる条件 2 を満たさない: " -def judge_acceptance(rows, criteria): +def judge_acceptance(rows, criteria, pre_merge=frozenset()): """閉じる条件 2 の判定。(満たすか, 満たさない理由, 印を付ける番号) を返す。 - 受け入れ条件が 0 個なら表の行の合否だけで決める。""" + 受け入れ条件が 0 個なら表の行の合否だけで決める。pre_merge(本文の `## 受け入れ条件の確認` でマージ前に + 確かめたと記録された番号。#2144)の条件には合格の行を求めない。すべての条件がそれなら行が無くても満たす。""" + if rows is None and criteria and all(c.number in pre_merge for c in criteria): + return True, None, [c.number for c in criteria if not c.checked] if rows is None: return False, NO_ROWS, [] if not rows.all_pass: return False, NOT_PASSED, [] - missing = [c.number for c in criteria if c.number not in rows.passed] + missing = [c.number for c in criteria if c.number not in rows.passed and c.number not in pre_merge] if missing: return False, "合格の行が無い受け入れ条件: " + ", ".join(map(str, missing)), [] return True, None, [c.number for c in criteria if not c.checked] @@ -271,7 +275,7 @@ def _judge_and_mark(a, ctx, repo, n, rows): if got is None: return {**base, "result": "failed", "reason": "本文を読めない", "cmd": ctx.retry_cmd}, [] state, body = got - ok, why, to_check = judge_acceptance(rows, acceptance.criteria(body)) + ok, why, to_check = judge_acceptance(rows, acceptance.criteria(body), acceptance.pre_merge_numbers(body)) own = repo == ctx.record_repo # 他のリポジトリの課題の本文は書かない(I7) if not ok: if state == "CLOSED": diff --git a/plugins/ndf/scripts/tests/test_acceptance_checks.py b/plugins/ndf/scripts/tests/test_acceptance_checks.py new file mode 100644 index 000000000..c1195a965 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_acceptance_checks.py @@ -0,0 +1,168 @@ +"""受け入れ条件の確認の区分(#2144)。完了判定が `acceptance-check.py record` で本文へ書き、承認資料 +(release_lib/materials.read_unverified)と sprint-close.py の閉じる条件 2 が同じ記録を読む。 + +本文の読み書き(gh_rest.fetch_body / update_body)は代役に差し替え、課題ごとの錠は一時の置き場で取る。 +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +SCRIPTS = HERE.parent +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import deps # noqa: E402 + +deps.require("md", "mdtable", "locks") +import acceptance # noqa: E402 +from release_lib import materials # noqa: E402 + + +def _load(name: str, file: str): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / file) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +check = _load("acceptance_check", "acceptance-check.py") +sprint_close = _load("sprint_close_for_checks", "sprint-close.py") + +BODY = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n- [ ] 2. 二\n- [x] 3. 三\n- [ ] 4. 四\n\n## 進行\n\nモード: standard\n" + + +@pytest.fixture +def issue(monkeypatch, tmp_path): + """課題 o/r#7 の本文の代役。bodies["o/r#7"] を読み書きする。""" + monkeypatch.setenv("NDF_LOCK_DIR", str(tmp_path / "locks")) + store = {"o/r#7": BODY, "writes": 0} + + def fetch(repo, n): + return store.get(f"{repo}#{n}") + + def update(repo, n, body): + store[f"{repo}#{n}"] = body + store["writes"] += 1 + return True + + monkeypatch.setattr(check.gh_rest, "fetch_body", fetch) + monkeypatch.setattr(check.gh_rest, "update_body", update) + return store + + +def run(capsys, *argv): + with pytest.raises(SystemExit) as e: + check.main(list(argv)) + return e.value.code, json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + + +# --- 記録を書く・読む(受け入れ条件 1) ------------------------------------------- + + +def test_record_writes_a_table_that_read_check_kinds_reads(issue, capsys): + code, out = run( + capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "1=pytest tests/test_x.py::test_a", "--post-release", "4" + ) + assert code == 0 and out["status"] == "ok" + body = issue["o/r#7"] + assert acceptance.read_check_kinds(body) == { + 1: acceptance.Check("マージ前", "pytest tests/test_x.py::test_a"), + 4: acceptance.Check("リリース後", ""), + } + assert [(i["name"], i["result"]) for i in out["items"]] == [("1", "マージ前"), ("4", "リリース後")] + assert acceptance.criteria(body) == acceptance.criteria(BODY) # 受け入れ条件の節は変えない + + +def test_record_again_overrides_and_keeps_the_rest(issue, capsys): + run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "1=a", "--post-release", "2") + code, _ = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=b") + assert code == 0 + assert acceptance.read_check_kinds(issue["o/r#7"]) == {1: acceptance.Check("マージ前", "a"), 2: acceptance.Check("マージ前", "b")} + writes = issue["writes"] + code, out = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=b") + assert code == 0 and "変更なし" in out["summary"] and issue["writes"] == writes + + +@pytest.mark.parametrize( + "args", + [ + ["--pre-merge", "9=a"], # 受け入れ条件に無い番号 + ["--pre-merge", "1"], # 手段が無い + ["--pre-merge", "1=a", "--post-release", "1"], # 両方 + [], # 何も渡さない + ], +) +def test_record_rejects_bad_arguments_without_writing(issue, capsys, args): + code, out = run(capsys, "record", "--issue", "7", "--repo", "o/r", *args) + assert code == 2 and out["status"] == "stopped" + assert issue["o/r#7"] == BODY + + +def test_record_without_criteria_stops(issue, capsys): + issue["o/r#7"] = "# 課題\n\n本文だけ\n" + code, _ = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--post-release", "1") + assert code == 3 + + +def test_show_reads_the_record(issue, capsys): + run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=`ruff check .`") + code, out = run(capsys, "show", "--issue", "7", "--repo", "o/r") + assert code == 0 and out["items"] == [{"kind": "criterion", "name": "2", "result": "マージ前", "means": "`ruff check .`"}] + + +def test_read_check_kinds_skips_rows_without_means_or_kind(): + body = ( + BODY + + "\n## 受け入れ条件の確認\n\n| 条件 | 区分 | 手段 |\n| --- | --- | --- |\n| 1 | マージ前 | — |\n| 2 | 済み | x |\n| 4 | リリース後 | — |\n" + ) + assert acceptance.read_check_kinds(body) == {4: acceptance.Check("リリース後", "")} + assert acceptance.read_check_kinds(BODY) == {} + + +# --- 承認資料(受け入れ条件 2・3) --------------------------------------------------- + + +def _recorded(pre, post): + return check.recorded_body(BODY, pre, post) + + +def test_unverified_leaves_out_pre_merge_and_keeps_post_release(): + body = _recorded({1: "pytest -k a", 2: "pytest -k b"}, [4]) + got = materials.read_unverified({("o/r", 7): body}, {}, "o/r") + assert [u.number for u in got] == [4] + + +def test_unverified_without_a_record_lists_every_unchecked_criterion(): + got = materials.read_unverified({("o/r", 7): BODY}, {}, "o/r") + assert [u.number for u in got] == [1, 2, 4] + + +# --- 閉じる条件 2(受け入れ条件 4) -------------------------------------------------- + + +def _judge(body, rows): + return sprint_close.judge_acceptance(rows, acceptance.criteria(body), acceptance.pre_merge_numbers(body)) + + +def test_pre_merge_criteria_need_no_verification_row(): + body = _recorded({1: "pytest -k a", 2: "pytest -k b"}, [4]) + ok, why, to_check = _judge(body, acceptance.IssueRows(True, frozenset({3, 4}))) + assert ok and why is None and to_check == [1, 2, 4] + + +def test_post_release_criteria_still_need_a_row(): + body = _recorded({1: "pytest -k a"}, [2, 4]) + ok, why, _ = _judge(body, acceptance.IssueRows(True, frozenset({4}))) + assert not ok and "2" in why + + +def test_all_pre_merge_closes_without_rows_and_unrecorded_still_needs_rows(): + body = _recorded({1: "a", 2: "b", 3: "c", 4: "d"}, []) + assert _judge(body, None)[0] + assert _judge(BODY, None) == (False, sprint_close.NO_ROWS, []) diff --git a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py index 8ffb86cd4..5bcc6ac41 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py @@ -109,6 +109,16 @@ def test_a_criterion_without_a_passing_row_keeps_the_issue_open(repo, gh): assert gh.get()["issue_bodies"]["o/r#1"] == THREE +def test_a_pre_merge_criterion_needs_no_passing_row(repo, gh): + """本文の `## 受け入れ条件の確認` でマージ前に確かめたと記録した条件 3 は、合格の行が無くても閉じる(#2144)。""" + record = "\n## 受け入れ条件の確認\n\n| 条件 | 区分 | 手段 |\n| --- | --- | --- |\n| 3 | マージ前 | `pytest -k three` |\n" + setup(gh, [("#1", "1. 一", "合格"), ("#1", "2. 二", "合格")], {"o/r#1": THREE + record}) + code, out, err = call("sprint-close.py", CLOSE, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0, (out, err) + assert it["result"] == "closed" and it["checked"] == [1, 2, 3] + + def test_all_criteria_passed_closes_and_marks_before_closing(repo, gh): """1〜3 がそろうと閉じ、本文の 3 個のチェックボックスに印が付く。書くのは閉じる前(受け入れ条件 4)。""" setup(gh, [("#1", f"{i}. c", "合格") for i in (1, 2, 3)], {"o/r#1": THREE}) From d0639d6ecefc378889453e6a0cf2246d149e27f7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 13:57:23 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Docs:=20=E5=AE=8C=E4=BA=86=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=A7=E6=9D=A1=E4=BB=B6=E3=81=94=E3=81=A8=E3=81=AE?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=97=E3=80=81?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E5=AE=9A=E7=BE=A9=E3=81=A7=E9=80=80=E8=A1=8C?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84=E6=9D=A1=E4=BB=B6=E3=81=AE=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E3=82=92=E4=BB=8A=E3=81=AE=E6=8C=AF=E3=82=8B=E8=88=9E?= =?UTF-8?q?=E3=81=84=E3=81=A7=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B=20(#2144?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/quality-gates/SKILL.md | 15 +++++++++++++++ .../references/acceptance-criteria.md | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/plugins/ndf/skills/quality-gates/SKILL.md b/plugins/ndf/skills/quality-gates/SKILL.md index e663a3c7e..005d89934 100644 --- a/plugins/ndf/skills/quality-gates/SKILL.md +++ b/plugins/ndf/skills/quality-gates/SKILL.md @@ -129,6 +129,21 @@ flowchart LR 条件ごとに検証手段を書く。手段を書けない条件は満たしたと言えない。 +**条件ごとに、マージ前に確かめたか、リリースの後でしか確かめられないかを課題の本文へ記録する。** +承認資料の「未実施のリリース後テスト」は、この記録で `マージ前` とした条件を載せない。記録の無い条件は、 +マージ前に確かめていても載る。閉じる判定(`sprint-close.py --with-verification`)も、`マージ前` の条件には +リリース後テストの合格の行を求めない。 + +```bash +python3 "$SCRIPTS/acceptance-check.py" record --issue 2144 \ + --pre-merge '1=pytest tests/test_acceptance_checks.py' --pre-merge '4=pytest tests/test_sprint_close_acceptance.py' \ + --post-release 5 --post-release 6 +``` + +本文の `## 受け入れ条件の確認` の節に `| 条件 | 区分 | 手段 |` の表として残り、打ち直すと渡した番号の行だけが +置き換わる。`マージ前` には手段(テスト名か実行したコマンド)が要る。打っても確かめられない条件(本番の利用者の +環境・次のスプリントで見ることなど)だけを `リリース後` にする。記録は `acceptance-check.py show --issue <番号>` で読める。 + **「人手の確認」として残す前に、機械で打てないかを確かめる。** 端末・画面・操作を伴う条件でも、 擬似端末(`script` / `expect` / pty)に本物のクライアントをつなぎ、その端末へ入力を打てば、振る舞いは マージ前に確かめられる。使い捨ての環境(実装で建てたイメージ・コンテナ)があれば、その中で打つ。 diff --git a/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md b/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md index 4577e4c05..aba3d8b5e 100644 --- a/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md +++ b/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md @@ -84,6 +84,18 @@ - [ ] 個人情報がログへ出力されない ``` +**退行しない条件は、前提にした今の振る舞いを書く前に 1 度実行して確かめる。** 「今と同じく〜する」は、 +今の振る舞いが本当にそうであることを前提にしている。前提が事実と違うと、条件はリリース後テストで初めて崩れる +(例: 「収束しないときは止まる」と書いたが、`final` が `max_rounds` のときは変更前から止まらなかった)。 +確かめた手段(実行したコマンドかテストの名前)とその結果を、条件のそばか課題の本文に書く。 + +```markdown +- [ ] 区分の記録が無い課題では、今と同じく印の無い条件がすべて載る(退行しない) + 前提の確認: `pytest tests/test_release_materials.py -k unverified` → 印の無い 3 件が載る(10.27.3) +``` + +前提が事実と違ったら、条件を今の振る舞いに合わせて書き直すか、違いを別の課題にする。 + ## 受け入れ条件とテストの対応 受け入れ条件は、そのままテストの名前になる粒度で書く。1 条件に複数の判定を詰め込むと、 From 450101c3fa77147e60781b135f14ca31c7b162eb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 14:00:04 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Fix:=20acceptance-check=20=E3=81=AE?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E5=90=8D=E3=81=AE=E9=87=8D=E8=A4=87=E3=81=A8?= =?UTF-8?q?=20deps.require=20=E3=81=AE=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E4=B8=8D=E8=B6=B3=E3=82=92=E7=9B=B4=E3=81=99=20(#2144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/acceptance-check.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/ndf/scripts/acceptance-check.py b/plugins/ndf/scripts/acceptance-check.py index 893eee1b2..f73f500f3 100755 --- a/plugins/ndf/scripts/acceptance-check.py +++ b/plugins/ndf/scripts/acceptance-check.py @@ -28,7 +28,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) import deps # noqa: E402 -deps.require("md", "mdtable", "locks") +deps.require("md", "mdtable", "locks", "durable", "waits") import acceptance # noqa: E402 import gh_call # noqa: E402 import gh_parts # noqa: E402 @@ -81,21 +81,21 @@ def recorded_body(body: str, pre_merge: dict[int, str], post_release) -> str: return gh_sections.replace_section(body, acceptance.CHECKS_HEADING, table) -def _items(checks: dict[int, acceptance.Check]) -> list[dict]: +def _criterion_items(checks: dict[int, acceptance.Check]) -> list[dict]: return [ {"kind": "criterion", "name": str(n), "result": c.kind, **({"means": c.means} if c.means else {})} for n, c in sorted(checks.items()) ] -def _slug(a) -> str: +def _repo_slug(a) -> str: slug = gh_call.resolve_repo(a.repo) if not slug: raise StepError("リポジトリを決められない(--repo を渡す)", EXIT_PRECONDITION) return slug -def _fetch(slug: str, n: int) -> str: +def _fetch_issue_body(slug: str, n: int) -> str: body = gh_rest.fetch_body(slug, n) if body is None: raise StepError(f"{slug}#{n} の本文を読めない", EXIT_UNREADABLE) @@ -106,22 +106,22 @@ def cmd_record(a): pre = parse_pre_merge(a.pre_merge) if not pre and not a.post_release: raise StepError("--pre-merge か --post-release を 1 つ以上渡す", EXIT_UNREADABLE) - slug = _slug(a) + slug = _repo_slug(a) with gh_parts.issue_lock(slug, a.issue): # 読みから書きまでを課題ごとの錠の中で行う - body = _fetch(slug, a.issue) + body = _fetch_issue_body(slug, a.issue) new = recorded_body(body, pre, a.post_release) if new != body and not gh_rest.update_body(slug, a.issue, new): emit(result(TOOL, "stopped", f"{slug}#{a.issue} の本文を書けない"), EXIT_VIOLATION) checks = acceptance.read_check_kinds(new) pre_n = sum(c.kind == acceptance.PRE_MERGE for c in checks.values()) state = "変更なし" if new == body else "記録した" - emit(result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _items(checks)), 0) + emit(result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _criterion_items(checks)), 0) def cmd_show(a): - slug = _slug(a) - checks = acceptance.read_check_kinds(_fetch(slug, a.issue)) - emit(result(TOOL, "ok", f"#{a.issue}: 記録 {len(checks)} 件", _items(checks)), 0) + slug = _repo_slug(a) + checks = acceptance.read_check_kinds(_fetch_issue_body(slug, a.issue)) + emit(result(TOOL, "ok", f"#{a.issue}: 記録 {len(checks)} 件", _criterion_items(checks)), 0) def build_parser(): From a18850871a9814cce26b0671e8309a13da7a7a07 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 31 Oct 2026 14:00:20 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Update:=20=E5=AE=A3=E8=A8=80=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=EF=BC=88?= =?UTF-8?q?lint-fix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf/scripts/acceptance-check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/acceptance-check.py b/plugins/ndf/scripts/acceptance-check.py index f73f500f3..ba2802796 100755 --- a/plugins/ndf/scripts/acceptance-check.py +++ b/plugins/ndf/scripts/acceptance-check.py @@ -115,7 +115,9 @@ def cmd_record(a): checks = acceptance.read_check_kinds(new) pre_n = sum(c.kind == acceptance.PRE_MERGE for c in checks.values()) state = "変更なし" if new == body else "記録した" - emit(result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _criterion_items(checks)), 0) + emit( + result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _criterion_items(checks)), 0 + ) def cmd_show(a):