from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path import shutil from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_EVIDENCE_ROOT = ROOT / "docs" / "uat" / "evidence" DEFAULT_TEMPLATE_ROOT = ROOT / "docs" / "uat" def utc_now() -> datetime: return datetime.now(timezone.utc) def now_compact() -> str: return utc_now().strftime("%Y%m%d_%H%M%S") def iso_now() -> str: return utc_now().isoformat() def find_latest_preflight(evidence_root: Path) -> Path: candidates = sorted( evidence_root.glob("preflight_*.md"), key=lambda path: path.stat().st_mtime, reverse=True, ) if not candidates: raise FileNotFoundError("No preflight report found in docs/uat/evidence") return candidates[0] def find_latest_dry_run_dir(evidence_root: Path) -> Path: candidates = sorted( [path for path in evidence_root.glob("dry_run_*") if path.is_dir()], key=lambda path: path.stat().st_mtime, reverse=True, ) if not candidates: raise FileNotFoundError("No dry-run directory found in docs/uat/evidence") return candidates[0] def _replace_line(content: str, prefix: str, value: str) -> str: replacement = f"{prefix} {value}".rstrip() return content.replace(prefix, replacement, 1) def render_session_protocol( template_text: str, *, session_id: str, environment_url: str, build_version: str, deployment_date: str, cluster_namespace: str, participants: dict[str, str], preflight_name: str, defect_log_name: str, ) -> str: content = template_text today = utc_now().date().isoformat() content = _replace_line(content, "- Session ID:", session_id) content = _replace_line(content, "- Date:", today) content = _replace_line(content, "- Start time:", "") content = _replace_line(content, "- End time:", "") content = _replace_line(content, "- Environment URL:", environment_url) content = _replace_line(content, "- Build/version:", build_version) content = _replace_line(content, "- Deployment date:", deployment_date) content = _replace_line(content, "- Cluster/namespace:", cluster_namespace) field_map = { "- Operators:": participants["operators"], "- Supervisor:": participants["supervisor"], "- Analyst:": participants["analyst"], "- Admin:": participants["admin"], "- Business owner:": participants["business_owner"], "- IT owner:": participants["it_owner"], } for prefix, value in field_map.items(): content = _replace_line(content, prefix, value) content = content.replace( "- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)", f"- [x] Preflight report attached (`attachments/{preflight_name}`)", 1, ) content = content.replace( "- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`", f"- [x] Defect log prepared from `{defect_log_name}`", 1, ) return content def render_signoff_sheet( template_text: str, *, session_id: str, environment_url: str, build_version: str, ) -> str: lines = template_text.splitlines() if not lines: return template_text header = [ lines[0], "", f"- Session ID: {session_id}", f"- Environment URL: {environment_url}", f"- Build/version: {build_version}", f"- Prepared at (UTC): {iso_now()}", "", ] return "\n".join(header + lines[1:]) def build_manual_readme( *, session_id: str, environment_url: str, preflight_name: str, dry_run_name: str, ) -> str: lines = [ "# Manual UAT Session Bundle", "", f"- Session ID: {session_id}", f"- Environment URL: {environment_url}", f"- Prepared at (UTC): {iso_now()}", "", "## Included Files", "", "- `session-protocol.md`", "- `scenario-checklist.md`", "- `defect-log.csv`", "- `signoff-sheet.md`", "- `manifest.json`", "", "## Attached Evidence", "", f"- `attachments/{preflight_name}`", f"- `attachments/{dry_run_name}/`", "", "## Next Step", "", "Run the live manual UAT on the pilot gateway, record any defects, and use the", "`scripts/finalize_mvp_pilot.py` command only after signatures are captured and", "all open P1/P2 defects are closed.", ] return "\n".join(lines) def prepare_manual_bundle( *, session_id: str, environment_url: str, build_version: str, deployment_date: str, cluster_namespace: str, participants: dict[str, str], output_root: Path, template_root: Path, preflight_report: Path, dry_run_dir: Path, overwrite: bool = False, ) -> Path: session_dir = output_root / f"manual_{session_id}" if session_dir.exists(): if not overwrite: raise FileExistsError(f"Session directory already exists: {session_dir}") shutil.rmtree(session_dir) attachments_dir = session_dir / "attachments" attachments_dir.mkdir(parents=True, exist_ok=True) copied_preflight = attachments_dir / preflight_report.name shutil.copy2(preflight_report, copied_preflight) copied_dry_run = attachments_dir / dry_run_dir.name shutil.copytree(dry_run_dir, copied_dry_run) session_template = (template_root / "session-template.md").read_text(encoding="utf-8") signoff_template = (template_root / "signoff-template.md").read_text(encoding="utf-8") checklist_template = (template_root / "scenario-checklist.md").read_text(encoding="utf-8") defect_template = (template_root / "defect-log-template.csv").read_text(encoding="utf-8") rendered_session = render_session_protocol( session_template, session_id=session_id, environment_url=environment_url, build_version=build_version, deployment_date=deployment_date, cluster_namespace=cluster_namespace, participants=participants, preflight_name=preflight_report.name, defect_log_name="defect-log.csv", ) rendered_signoff = render_signoff_sheet( signoff_template, session_id=session_id, environment_url=environment_url, build_version=build_version, ) (session_dir / "session-protocol.md").write_text(rendered_session, encoding="utf-8") (session_dir / "signoff-sheet.md").write_text(rendered_signoff, encoding="utf-8") (session_dir / "scenario-checklist.md").write_text(checklist_template, encoding="utf-8") (session_dir / "defect-log.csv").write_text(defect_template, encoding="utf-8") manifest: dict[str, Any] = { "prepared_at": iso_now(), "session_id": session_id, "environment_url": environment_url, "build_version": build_version, "deployment_date": deployment_date, "cluster_namespace": cluster_namespace, "participants": participants, "artifacts": { "preflight_report": f"attachments/{preflight_report.name}", "dry_run_bundle": f"attachments/{dry_run_dir.name}", "session_protocol": "session-protocol.md", "scenario_checklist": "scenario-checklist.md", "defect_log": "defect-log.csv", "signoff_sheet": "signoff-sheet.md", }, } (session_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") (session_dir / "README.md").write_text( build_manual_readme( session_id=session_id, environment_url=environment_url, preflight_name=preflight_report.name, dry_run_name=dry_run_dir.name, ), encoding="utf-8", ) return session_dir def main() -> None: parser = argparse.ArgumentParser(description="Prepare a manual UAT bundle from the latest preflight and dry-run") parser.add_argument("--session-id", default=f"UAT-MANUAL-{now_compact()}", help="Manual UAT session id") parser.add_argument( "--environment-url", default="http://:8080", help="Pilot gateway URL recorded in the session pack", ) parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version under test") parser.add_argument( "--deployment-date", default=utc_now().date().isoformat(), help="Deployment date recorded in the session pack", ) parser.add_argument( "--cluster-namespace", default="pilot/production-like", help="Cluster or namespace label recorded in the session pack", ) parser.add_argument("--operators", default="TBD", help="Operator participants") parser.add_argument("--supervisor", default="TBD", help="Supervisor participant") parser.add_argument("--analyst", default="TBD", help="Analyst participant") parser.add_argument("--admin", default="TBD", help="Admin participant") parser.add_argument("--business-owner", default="TBD", help="Business owner") parser.add_argument("--it-owner", default="TBD", help="IT owner") parser.add_argument( "--preflight-report", default="", help="Optional explicit preflight report path (default: latest preflight report)", ) parser.add_argument( "--dry-run-dir", default="", help="Optional explicit dry-run directory path (default: latest dry-run evidence folder)", ) parser.add_argument( "--output-root", default="", help="Output root directory (default: docs/uat/evidence)", ) parser.add_argument("--overwrite", action="store_true", help="Overwrite the session folder if it already exists") args = parser.parse_args() output_root = Path(args.output_root) if args.output_root else DEFAULT_EVIDENCE_ROOT output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve() preflight_report = Path(args.preflight_report) if args.preflight_report else find_latest_preflight(DEFAULT_EVIDENCE_ROOT) if not preflight_report.is_absolute(): preflight_report = (ROOT / preflight_report).resolve() dry_run_dir = Path(args.dry_run_dir) if args.dry_run_dir else find_latest_dry_run_dir(DEFAULT_EVIDENCE_ROOT) if not dry_run_dir.is_absolute(): dry_run_dir = (ROOT / dry_run_dir).resolve() participants = { "operators": args.operators, "supervisor": args.supervisor, "analyst": args.analyst, "admin": args.admin, "business_owner": args.business_owner, "it_owner": args.it_owner, } session_dir = prepare_manual_bundle( session_id=args.session_id, environment_url=args.environment_url, build_version=args.build_version, deployment_date=args.deployment_date, cluster_namespace=args.cluster_namespace, participants=participants, output_root=output_root, template_root=DEFAULT_TEMPLATE_ROOT, preflight_report=preflight_report, dry_run_dir=dry_run_dir, overwrite=args.overwrite, ) print(f"Manual UAT bundle prepared: {session_dir}") if __name__ == "__main__": main()