202 lines
7.9 KiB
Python
202 lines
7.9 KiB
Python
#!/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())
|