192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Render base/head/diff PNGs for every .drawio changed between two git refs.
|
|
|
|
For each `.drawio` that differs between `--base` and `--head`, exports the
|
|
base and head pages as PNGs via the draw.io CLI, and — for files present on
|
|
both sides — chains `drawiodiff.py` -> `autolayout.py` -> CLI export into a
|
|
third colour-coded diff PNG. Added/removed files just get the one side that
|
|
exists. Emits a Markdown report with one section per changed file (status +
|
|
image links) and a summary count, suitable for a PR comment or CI job
|
|
summary; pair with `.github/actions/drawio-diff/`.
|
|
|
|
python3 prdiff.py --base origin/main --head HEAD -o drawio-pr/report.md
|
|
|
|
Missing draw.io CLI degrades gracefully: the Markdown still lists every
|
|
changed file, just without images (a review comment listing the files is
|
|
still useful). Missing git, or `--repo` not a git repository, is fatal.
|
|
|
|
Usage: python3 prdiff.py --base <ref> [--head <ref>] [--repo <dir>] [--out-dir <dir>] [-o report.md]
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def changed_drawios(base, head, repo):
|
|
"""List of (path, status) for .drawio files that differ between base and head.
|
|
|
|
status is "added", "removed", or "modified" (renames/copies count as
|
|
modified, keyed on the new path). Shells to `git diff --name-status`.
|
|
"""
|
|
try:
|
|
r = subprocess.run(
|
|
["git", "-C", repo, "diff", "--name-status", f"{base}..{head}", "--", "*.drawio"],
|
|
capture_output=True, text=True)
|
|
except FileNotFoundError:
|
|
sys.exit("error: not a git repo / git not found (git not on PATH)")
|
|
if r.returncode != 0:
|
|
sys.exit(f"error: not a git repo / git not found: {r.stderr.strip()}")
|
|
entries = []
|
|
for line in r.stdout.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("\t")
|
|
code, path = parts[0], parts[-1]
|
|
status = "added" if code.startswith("A") else "removed" if code.startswith("D") else "modified"
|
|
entries.append((path, status))
|
|
return entries
|
|
|
|
|
|
def git_show_file(repo, ref, path, dest):
|
|
"""Write the blob at ref:path (in repo) to dest. False if it doesn't exist there."""
|
|
r = subprocess.run(["git", "-C", repo, "show", f"{ref}:{path}"], capture_output=True)
|
|
if r.returncode != 0:
|
|
return False
|
|
with open(dest, "wb") as f:
|
|
f.write(r.stdout)
|
|
return True
|
|
|
|
|
|
def export_png(src_drawio, out_png):
|
|
"""CLI-export page 1 of src_drawio to out_png. True on success."""
|
|
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", "1", "-o", out_png, src_drawio],
|
|
capture_output=True)
|
|
return r.returncode == 0 and os.path.exists(out_png)
|
|
|
|
|
|
def export_diff_png(base_drawio, head_drawio, out_png, tmp):
|
|
"""drawiodiff.py -> autolayout.py -> CLI export a coloured diff PNG. True on success."""
|
|
diff_json = os.path.join(tmp, "diff.json")
|
|
diff_drawio = os.path.join(tmp, "diff.drawio")
|
|
r1 = subprocess.run([sys.executable, os.path.join(HERE, "drawiodiff.py"),
|
|
base_drawio, head_drawio, "-o", diff_json], capture_output=True)
|
|
if r1.returncode != 0 or not os.path.exists(diff_json):
|
|
return False
|
|
r2 = subprocess.run([sys.executable, os.path.join(HERE, "autolayout.py"),
|
|
diff_json, "-o", diff_drawio], capture_output=True)
|
|
if r2.returncode != 0 or not os.path.exists(diff_drawio):
|
|
return False
|
|
return export_png(diff_drawio, out_png)
|
|
|
|
|
|
def build_entry(repo, base, head, path, status, out_dir, drawio_available):
|
|
"""One render_markdown entry: fetch both sides, export whatever PNGs it can."""
|
|
entry = {"path": path, "status": status}
|
|
if not drawio_available:
|
|
entry["skipped"] = True
|
|
return entry
|
|
slug = path.replace("/", "__")
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
base_drawio = os.path.join(tmp, "base.drawio")
|
|
head_drawio = os.path.join(tmp, "head.drawio")
|
|
have_base = git_show_file(repo, base, path, base_drawio)
|
|
have_head = git_show_file(repo, head, path, head_drawio)
|
|
if have_base:
|
|
p = os.path.join(out_dir, f"{slug}.base.png")
|
|
if export_png(base_drawio, p):
|
|
entry["base_png"] = p
|
|
if have_head:
|
|
p = os.path.join(out_dir, f"{slug}.head.png")
|
|
if export_png(head_drawio, p):
|
|
entry["head_png"] = p
|
|
if have_base and have_head:
|
|
p = os.path.join(out_dir, f"{slug}.diff.png")
|
|
if export_diff_png(base_drawio, head_drawio, p, tmp):
|
|
entry["diff_png"] = p
|
|
return entry
|
|
|
|
|
|
def render_markdown(entries, out_dir):
|
|
"""Pure: Markdown PR report from prdiff entries. No I/O, no CLI.
|
|
|
|
entries: list of {"path", "status", "base_png"?, "head_png"?, "diff_png"?,
|
|
"skipped"?} — image paths (if any) are made relative to out_dir for the
|
|
Markdown links. "skipped" means the draw.io CLI was unavailable.
|
|
"""
|
|
counts = {"added": 0, "removed": 0, "modified": 0}
|
|
for e in entries:
|
|
counts[e["status"]] = counts.get(e["status"], 0) + 1
|
|
lines = [
|
|
"# draw.io diagram changes",
|
|
"",
|
|
f"{len(entries)} file(s) changed: +{counts.get('added', 0)} added, "
|
|
f"-{counts.get('removed', 0)} removed, ~{counts.get('modified', 0)} modified",
|
|
]
|
|
if not entries:
|
|
lines.append("")
|
|
lines.append("No `.drawio` files changed.")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
def rel(png):
|
|
return os.path.relpath(png, out_dir).replace(os.sep, "/") if png else None
|
|
|
|
for e in entries:
|
|
lines.append("")
|
|
lines.append(f"## {e['path']} ({e['status']})")
|
|
if e.get("skipped"):
|
|
lines.append("")
|
|
lines.append("_draw.io CLI not available — images skipped._")
|
|
continue
|
|
base_r, head_r, diff_r = rel(e.get("base_png")), rel(e.get("head_png")), rel(e.get("diff_png"))
|
|
lines.append("")
|
|
if base_r:
|
|
lines.append(f"")
|
|
if head_r:
|
|
lines.append(f"")
|
|
if diff_r:
|
|
lines.append(f"")
|
|
if not (base_r or head_r or diff_r):
|
|
lines.append("_no image produced._")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Render PNGs + a Markdown report for .drawio files "
|
|
"changed between two git refs.")
|
|
ap.add_argument("--base", required=True, help="base git ref/sha")
|
|
ap.add_argument("--head", default="HEAD", help="head git ref/sha (default HEAD)")
|
|
ap.add_argument("--repo", default=".", help="path to the git repo (default: current directory)")
|
|
ap.add_argument("--out-dir", default="drawio-pr", help="directory for exported PNGs (default: ./drawio-pr)")
|
|
ap.add_argument("-o", "--output", help="write the Markdown report here (default: stdout)")
|
|
args = ap.parse_args()
|
|
|
|
changed = changed_drawios(args.base, args.head, args.repo)
|
|
if not changed:
|
|
sys.stderr.write("no .drawio files changed\n")
|
|
|
|
drawio_available = shutil.which("drawio") is not None
|
|
if not drawio_available and changed:
|
|
sys.stderr.write("warning: draw.io CLI not found - image export skipped, "
|
|
"Markdown will list files only (is the draw.io CLI installed?)\n")
|
|
os.makedirs(args.out_dir, exist_ok=True)
|
|
|
|
entries = [build_entry(args.repo, args.base, args.head, path, status, args.out_dir, drawio_available)
|
|
for path, status in changed]
|
|
report = render_markdown(entries, args.out_dir)
|
|
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(report)
|
|
sys.stderr.write(f"wrote {args.output}\n")
|
|
else:
|
|
sys.stdout.write(report)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|