#!/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 [--head ] [--repo ] [--out-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"![base]({base_r})") if head_r: lines.append(f"![head]({head_r})") if diff_r: lines.append(f"![diff]({diff_r})") 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()