Skip to content
150 changes: 150 additions & 0 deletions plugins/ndf/scripts/acceptance-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/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", "durable", "waits")
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 _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 _repo_slug(a) -> str:
slug = gh_call.resolve_repo(a.repo)
if not slug:
raise StepError("リポジトリを決められない(--repo を渡す)", EXIT_PRECONDITION)
return slug


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)
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 = _repo_slug(a)
with gh_parts.issue_lock(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} 件)", _criterion_items(checks)), 0
)


def cmd_show(a):
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():
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()
50 changes: 50 additions & 0 deletions plugins/ndf/scripts/lib/acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
6 changes: 4 additions & 2 deletions plugins/ndf/scripts/release_lib/materials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand All @@ -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


Expand Down
14 changes: 9 additions & 5 deletions plugins/ndf/scripts/sprint-close.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 を確かめる
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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":
Expand Down
Loading
Loading