284 lines
9.1 KiB
Python
284 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
GATE4_PATH = ROOT / "docs" / "gates" / "gate-04-pilot-hardening.md"
|
|
P1P2_PATH = ROOT / "docs" / "gates" / "p1-p2-defects.md"
|
|
ACCEPTED_RELEASE_PATH = ROOT / "docs" / "releases" / "v1.0.0-mvp-accepted.md"
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def iso_now() -> str:
|
|
return utc_now().isoformat()
|
|
|
|
|
|
def _normalized_status(value: str) -> str:
|
|
return value.strip().lower()
|
|
|
|
|
|
def _is_open_status(value: str) -> bool:
|
|
normalized = _normalized_status(value)
|
|
return normalized not in {"closed", "resolved", "verified"}
|
|
|
|
|
|
def parse_open_defect_counts(defect_log_path: Path) -> dict[str, int]:
|
|
counts = {"P1": 0, "P2": 0, "P3": 0, "P4": 0}
|
|
with defect_log_path.open("r", newline="", encoding="utf-8") as handle:
|
|
reader = csv.DictReader(handle)
|
|
for row in reader:
|
|
severity = (row.get("severity") or "").strip().upper()
|
|
status = row.get("status") or ""
|
|
if severity in counts and _is_open_status(status):
|
|
counts[severity] += 1
|
|
return counts
|
|
|
|
|
|
def validate_session_protocol(session_protocol_path: Path) -> list[str]:
|
|
content = session_protocol_path.read_text(encoding="utf-8")
|
|
errors: list[str] = []
|
|
blank_markers = [
|
|
"- Session ID:",
|
|
"- Environment URL:",
|
|
"- Build/version:",
|
|
"- Deployment date:",
|
|
"- Cluster/namespace:",
|
|
"- Business owner:",
|
|
"- IT owner:",
|
|
]
|
|
for marker in blank_markers:
|
|
if f"{marker}\n" in content or f"{marker}\r\n" in content:
|
|
errors.append(f"Session protocol still contains blank field: {marker}")
|
|
|
|
if "## Decision" not in content or "[x]" not in content:
|
|
errors.append("Session protocol does not contain a selected decision checkbox")
|
|
|
|
return errors
|
|
|
|
|
|
def validate_signoff_sheet(signoff_sheet_path: Path) -> list[str]:
|
|
content = signoff_sheet_path.read_text(encoding="utf-8")
|
|
errors: list[str] = []
|
|
placeholder_lines = [
|
|
" - Name:",
|
|
" - Signature:",
|
|
" - Date:",
|
|
]
|
|
for placeholder in placeholder_lines:
|
|
if content.count(placeholder) > 0:
|
|
errors.append(f"Sign-off sheet still contains placeholder lines matching: {placeholder.strip()}")
|
|
break
|
|
|
|
decision_lines = [line for line in content.splitlines() if line.startswith("- [")]
|
|
if sum(1 for line in decision_lines if "[x]" in line.lower()) != 1:
|
|
errors.append("Sign-off sheet must have exactly one selected decision checkbox")
|
|
|
|
return errors
|
|
|
|
|
|
def apply_gate4_closure(
|
|
content: str,
|
|
*,
|
|
session_id: str,
|
|
session_dir: Path,
|
|
acceptance: str,
|
|
open_counts: dict[str, int],
|
|
) -> str:
|
|
updated = content
|
|
updated = updated.replace(
|
|
"- [ ] UAT signed with real operators/supervisors",
|
|
"- [x] UAT signed with real operators/supervisors",
|
|
)
|
|
updated = updated.replace(
|
|
"- [ ] P1/P2 defects closed",
|
|
"- [x] P1/P2 defects closed",
|
|
)
|
|
|
|
marker = "Manual closure update:"
|
|
if marker in updated:
|
|
updated = updated.split(marker, 1)[0].rstrip()
|
|
|
|
lines = [
|
|
"",
|
|
"Manual closure update:",
|
|
f"- Date (UTC): {iso_now()}",
|
|
f"- Session ID: {session_id}",
|
|
f"- Session directory: `{session_dir.as_posix()}`",
|
|
f"- Acceptance: {acceptance}",
|
|
(
|
|
"- Remaining open defects: "
|
|
f"P1={open_counts['P1']}, P2={open_counts['P2']}, "
|
|
f"P3={open_counts['P3']}, P4={open_counts['P4']}"
|
|
),
|
|
]
|
|
return updated.rstrip() + "\n" + "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_p1p2_register(
|
|
*,
|
|
session_id: str,
|
|
session_dir: Path,
|
|
open_counts: dict[str, int],
|
|
acceptance: str,
|
|
) -> str:
|
|
lines = [
|
|
"# P1/P2 Defect Register (Pilot)",
|
|
"",
|
|
f"Last update: {utc_now().date().isoformat()} ({session_id})",
|
|
"",
|
|
"## Current Status",
|
|
f"- Open P1: {open_counts['P1']}",
|
|
f"- Open P2: {open_counts['P2']}",
|
|
"- Source: manual UAT close-out",
|
|
f"- Manual session evidence: `{session_dir.as_posix()}`",
|
|
"",
|
|
"## Triage Policy",
|
|
"- Only `P1` and `P2` issues belong to MVP remediation.",
|
|
"- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.",
|
|
"",
|
|
"## Manual Closure",
|
|
f"- Acceptance: {acceptance}",
|
|
"- P1/P2 closure confirmed by signed manual UAT package.",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_acceptance_release_note(
|
|
*,
|
|
session_id: str,
|
|
session_dir: Path,
|
|
acceptance: str,
|
|
open_counts: dict[str, int],
|
|
) -> str:
|
|
lines = [
|
|
"# Release Notes - v1.0.0-mvp Accepted",
|
|
"",
|
|
f"Date: {utc_now().date().isoformat()}",
|
|
"",
|
|
"## Acceptance Result",
|
|
f"- Session ID: {session_id}",
|
|
f"- Acceptance: {acceptance}",
|
|
f"- Manual session evidence: `{session_dir.as_posix()}`",
|
|
"",
|
|
"## Closed Gate 4 Conditions",
|
|
"- UAT signed with real operators/supervisors",
|
|
"- P1/P2 defects closed",
|
|
"",
|
|
"## Remaining Deferred Items",
|
|
f"- Open P3: {open_counts['P3']}",
|
|
f"- Open P4: {open_counts['P4']}",
|
|
"- Deferred scope remains tracked in `docs/roadmap/05-wave2-backlog.md`.",
|
|
"",
|
|
"## Baseline",
|
|
"- Accepted baseline remains the current MVP v1 scope without API expansion.",
|
|
"- Stage 1-3 public routes remain unchanged.",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def finalize_manual_session(
|
|
*,
|
|
session_dir: Path,
|
|
acceptance: str,
|
|
dry_run: bool = False,
|
|
) -> dict[str, int]:
|
|
required_files = {
|
|
"session_protocol": session_dir / "session-protocol.md",
|
|
"scenario_checklist": session_dir / "scenario-checklist.md",
|
|
"defect_log": session_dir / "defect-log.csv",
|
|
"signoff_sheet": session_dir / "signoff-sheet.md",
|
|
"manifest": session_dir / "manifest.json",
|
|
}
|
|
missing = [name for name, path in required_files.items() if not path.exists()]
|
|
attachments_dir = session_dir / "attachments"
|
|
if not attachments_dir.exists():
|
|
missing.append("attachments")
|
|
if missing:
|
|
raise FileNotFoundError(f"Manual session bundle is incomplete: {missing}")
|
|
|
|
validation_errors: list[str] = []
|
|
validation_errors.extend(validate_session_protocol(required_files["session_protocol"]))
|
|
validation_errors.extend(validate_signoff_sheet(required_files["signoff_sheet"]))
|
|
open_counts = parse_open_defect_counts(required_files["defect_log"])
|
|
if open_counts["P1"] != 0 or open_counts["P2"] != 0:
|
|
validation_errors.append(
|
|
f"Cannot close MVP pilot with open P1/P2 defects: P1={open_counts['P1']}, P2={open_counts['P2']}"
|
|
)
|
|
|
|
if validation_errors:
|
|
details = "\n".join(f"- {error}" for error in validation_errors)
|
|
raise ValueError(f"Manual UAT package validation failed:\n{details}")
|
|
|
|
if dry_run:
|
|
return open_counts
|
|
|
|
session_id = session_dir.name.replace("manual_", "", 1)
|
|
gate4_content = GATE4_PATH.read_text(encoding="utf-8")
|
|
updated_gate4 = apply_gate4_closure(
|
|
gate4_content,
|
|
session_id=session_id,
|
|
session_dir=session_dir,
|
|
acceptance=acceptance,
|
|
open_counts=open_counts,
|
|
)
|
|
GATE4_PATH.write_text(updated_gate4, encoding="utf-8")
|
|
P1P2_PATH.write_text(
|
|
build_p1p2_register(
|
|
session_id=session_id,
|
|
session_dir=session_dir,
|
|
open_counts=open_counts,
|
|
acceptance=acceptance,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
ACCEPTED_RELEASE_PATH.write_text(
|
|
build_acceptance_release_note(
|
|
session_id=session_id,
|
|
session_dir=session_dir,
|
|
acceptance=acceptance,
|
|
open_counts=open_counts,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return open_counts
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Validate a manual UAT bundle and close MVP Gate 4")
|
|
parser.add_argument("--session-dir", required=True, help="Path to the prepared manual session directory")
|
|
parser.add_argument(
|
|
"--acceptance",
|
|
choices=["accepted_for_pilot_completion", "accepted_with_conditions"],
|
|
default="accepted_for_pilot_completion",
|
|
help="Acceptance mode recorded in gate closure artifacts",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Validate the manual session bundle without updating gate/release documents",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
session_dir = Path(args.session_dir)
|
|
if not session_dir.is_absolute():
|
|
session_dir = (ROOT / session_dir).resolve()
|
|
|
|
counts = finalize_manual_session(
|
|
session_dir=session_dir,
|
|
acceptance=args.acceptance,
|
|
dry_run=args.dry_run,
|
|
)
|
|
prefix = "MVP pilot finalization dry-run passed:" if args.dry_run else "MVP pilot finalized:"
|
|
print(f"{prefix} P1={counts['P1']} P2={counts['P2']} P3={counts['P3']} P4={counts['P4']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|