Files
call-center/scripts/finalize_mvp_pilot.py
T

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()