#!/usr/bin/env python3 """Create a review bundle from a ready 1C patch workspace without applying it.""" from __future__ import annotations import argparse import json import shutil import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any from check_1c_patch_preflight import build_preflight from check_1c_patch_bundle import check_bundle from diff_1c_patch_workspace import build_diff from render_1c_patch_preflight_markdown import render as render_preflight_markdown def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def safe_relative(relative_path: str) -> Path: path = Path(relative_path.replace("\\", "/")) if path.is_absolute() or ".." in path.parts or not str(path): raise SystemExit(f"Unsafe relative path: {relative_path}") return path def slug_from_workspace(workspace: Path) -> str: return workspace.name or "onec-patch" def modified_records(workspace: Path, diff: dict[str, Any]) -> list[dict[str, Any]]: manifest = load_json(workspace / "manifest.json") records_by_rel = { str(record.get("relative_path") or "").replace("\\", "/"): record for record in manifest.get("files") or [] } result = [] for item in diff.get("files") or []: if item.get("status") != "modified": continue relative = str(item.get("relative_path") or "").replace("\\", "/") record = dict(records_by_rel.get(relative) or {}) record["relative_path"] = relative record["diff"] = { key: (item.get("diff") or {}).get(key) for key in ("added_lines", "removed_lines", "hunks", "patch_truncated", "patch_chars") } record["sha256"] = item.get("sha256") result.append(record) return result def render_readme(bundle: dict[str, Any]) -> str: lines = [ "# 1C Patch Review Bundle", "", f"Workspace: `{bundle.get('workspace')}`", f"Created UTC: `{bundle.get('created_at_utc')}`", f"Preflight status: `{(bundle.get('preflight') or {}).get('status')}`", f"Preferred extension: `{bundle.get('preferred_extension')}`", "", "## Rules", "", "- This bundle is for review and disposable-base validation.", "- It does not apply changes to SQL, Config, ConfigSave, ConfigCAS, or source extension files.", "- Validate loading/packaging in a disposable 1C base before any production action.", "", "## Modified Files", "", ] for item in bundle.get("files") or []: diff = item.get("diff") or {} lines.append( f"- `{item.get('relative_path')}` ({item.get('kind')}, +{diff.get('added_lines')}/-{diff.get('removed_lines')}, hunks={diff.get('hunks')})" ) lines.extend( [ "", "## Contents", "", "- `files/`: modified working files by extension-relative path.", "- `manifest.json`: machine-readable bundle manifest.", "- `preflight.json`: full preflight evidence.", "- `preflight.md`: human-readable preflight summary.", "- `diff.json`: workspace diff evidence.", ] ) return "\n".join(lines) + "\n" def create_bundle(workspace: Path, output_root: Path, slug: str | None, *, force: bool, max_patch_chars: int) -> dict[str, Any]: preflight = build_preflight(workspace, max_patch_chars=max_patch_chars) if preflight.get("status") != "ready_for_review": raise SystemExit(f"Workspace is not ready for review: {preflight.get('status')}") diff = build_diff(workspace, max_patch_chars=max_patch_chars) records = modified_records(workspace, diff) if not records: raise SystemExit("No modified files to bundle.") manifest = load_json(workspace / "manifest.json") bundle_slug = slug or slug_from_workspace(workspace) bundle_dir = output_root / bundle_slug if bundle_dir.exists() and not force: raise SystemExit(f"Bundle already exists: {bundle_dir}. Use --force to replace.") if bundle_dir.exists(): shutil.rmtree(bundle_dir) files_root = bundle_dir / "files" files_root.mkdir(parents=True, exist_ok=True) copied = [] for record in records: rel = safe_relative(str(record.get("relative_path") or "")) src = workspace / "working" / rel dst = files_root / rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) copied.append({**record, "bundle_path": str(Path("files") / rel)}) bundle = { "schema": "onec_patch_bundle.v1", "created_at_utc": datetime.now(timezone.utc).isoformat(), "workspace": str(workspace), "source_manifest": str(workspace / "manifest.json"), "preferred_extension": manifest.get("preferred_extension"), "extension_root": manifest.get("extension_root"), "task": manifest.get("task"), "preflight": { "schema": preflight.get("schema"), "status": preflight.get("status"), "passed": preflight.get("passed"), "diff_summary": preflight.get("diff_summary"), "gates": preflight.get("gates"), }, "files": copied, "counts": { "files": len(copied), "added_lines": sum(((item.get("diff") or {}).get("added_lines") or 0) for item in copied), "removed_lines": sum(((item.get("diff") or {}).get("removed_lines") or 0) for item in copied), "hunks": sum(((item.get("diff") or {}).get("hunks") or 0) for item in copied), }, } write_json(bundle_dir / "manifest.json", bundle) write_json(bundle_dir / "preflight.json", preflight) (bundle_dir / "preflight.md").write_text(render_preflight_markdown(preflight), encoding="utf-8") write_json(bundle_dir / "diff.json", diff) (bundle_dir / "README.md").write_text(render_readme(bundle), encoding="utf-8") zip_path = bundle_dir.with_suffix(".zip") if zip_path.exists(): zip_path.unlink() with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(bundle_dir.rglob("*")): if path.is_file(): archive.write(path, path.relative_to(bundle_dir)) bundle_check = check_bundle(bundle_dir, zip_path) if not bundle_check.get("passed"): raise SystemExit(f"Created bundle failed validation: {bundle_check.get('counts')}") return { "schema": "onec_patch_bundle_creation.v1", "bundle_dir": str(bundle_dir), "zip_path": str(zip_path), "manifest": str(bundle_dir / "manifest.json"), "preflight_status": preflight.get("status"), "bundle_check": { "schema": bundle_check.get("schema"), "passed": bundle_check.get("passed"), "counts": bundle_check.get("counts"), }, "counts": bundle["counts"], } def main() -> int: parser = argparse.ArgumentParser(description="Create a review bundle from a ready 1C patch workspace.") parser.add_argument("--workspace", type=Path, required=True) parser.add_argument("--output-root", type=Path, default=Path("reports/1c-sql/upo/patch-bundles")) parser.add_argument("--slug") parser.add_argument("--force", action="store_true") parser.add_argument("--max-patch-chars", type=int, default=200000) parser.add_argument("--output", type=Path) args = parser.parse_args() result = create_bundle(args.workspace, args.output_root, args.slug, force=args.force, max_patch_chars=args.max_patch_chars) if args.output: write_json(args.output, result) print(json.dumps(result, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())