"""Batch comparison reporter. Generates a Markdown report that places Track A and Track B side-by-side so the burn-phase decision can be made on objective metrics. """ from __future__ import annotations import json from pathlib import Path from migration_pipeline.models import BatchResult, FileResult, FileStatus # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def write_comparison_report( result_a: BatchResult, result_b: BatchResult, output_path: Path, ) -> None: """Write a Markdown comparison of Track A vs Track B to *output_path*.""" output_path.parent.mkdir(parents=True, exist_ok=True) md = _render_markdown(result_a, result_b) output_path.write_text(md, encoding="utf-8") # Machine-readable companion file. json_path = output_path.with_suffix(".json") json_path.write_text( json.dumps(_to_dict(result_a, result_b), indent=2, ensure_ascii=False), encoding="utf-8", ) # --------------------------------------------------------------------------- # Markdown rendering # --------------------------------------------------------------------------- def _render_markdown(a: BatchResult, b: BatchResult) -> str: ts = _utc_now() lines: list[str] = [ f"# Migrationsbericht – Batch `{a.batch_id}`", f"_Erstellt: {ts}_", "", "## Übersicht", "", "| Metrik | Track A – konservativ | Track B – modern |", "|---|---|---|", f"| Branch | `{a.branch}` | `{b.branch}` |", f"| Dateien gesamt | {a.total_files} | {b.total_files} |", f"| Erfolge | {a.success_count} ({a.success_rate:.0%}) | {b.success_count} ({b.success_rate:.0%}) |", f"| Quarantäne | {a.quarantine_count} ({a.quarantine_rate:.0%}) | {b.quarantine_count} ({b.quarantine_rate:.0%}) |", f"| Warnungen (gesamt, advisory) | {a.total_warnings} | {b.total_warnings} |", f"| Mittlere Laufzeit | {a.mean_elapsed_seconds:.0f} s | {b.mean_elapsed_seconds:.0f} s |", "", "## Datei-Detailtabelle", "", "| Datei | Track A Status | Track A Zeit | Track A Warn | Track B Status | Track B Zeit | Track B Warn |", "|---|---|---|---|---|---|---|", ] # Build lookup for track B by file path. b_by_file = {r.file_path: r for r in b.results} for ra in a.results: rb = b_by_file.get(ra.file_path) name = Path(ra.file_path).name lines.append( f"| `{name}` " f"| {_status_badge(ra.status)} " f"| {ra.elapsed_seconds:.0f} s " f"| {ra.gate.total_warnings} " f"| {_status_badge(rb.status) if rb else '—'} " f"| {f'{rb.elapsed_seconds:.0f} s' if rb else '—'} " f"| {rb.gate.total_warnings if rb else '—'} |" ) lines += [ "", "## Quarantäne-Dateien", "", ] quarantine_a = [r for r in a.results if r.status == FileStatus.QUARANTINED.value] quarantine_b = [r for r in b.results if r.status == FileStatus.QUARANTINED.value] if quarantine_a or quarantine_b: lines += [ "| Datei | Track | Fehlerklasse | Versuche | Empfehlung |", "|---|---|---|---|---|", ] for r in quarantine_a + quarantine_b: q = r.quarantine_entry if q: name = Path(r.file_path).name rec = q.manual_recommendation.replace("|", "\\|")[:80] lines.append( f"| `{name}` | {r.track} | `{q.failure_class}` " f"| {q.retry_count} | {rec}… |" ) else: lines.append("_Keine Dateien in Quarantäne._") lines += [ "", "## Warnungen (Advisory)", "", "> Warnungen blockieren keine Quality Gates. Sie sind hier zur " "Entscheidungshilfe für die Burn-Phase aufgeführt.", "", ] warn_rows = [ r for r in (a.results + b.results) if r.gate.total_warnings > 0 ] if warn_rows: lines += [ "| Datei | Track | Anzahl Warnungen |", "|---|---|---|", ] for r in sorted(warn_rows, key=lambda x: -x.gate.total_warnings): lines.append( f"| `{Path(r.file_path).name}` | {r.track} " f"| {r.gate.total_warnings} |" ) else: lines.append("_Keine Warnungen aufgetreten._") lines += [ "", "## Burn-Phase Empfehlung", "", _burn_recommendation(a, b), "", "---", "_Automatisch generiert von der Qt5→Qt6 Migrationspipeline._", ] return "\n".join(lines) def _status_badge(status: str) -> str: icons = { FileStatus.SUCCESS.value: "✅ Erfolg", FileStatus.QUARANTINED.value: "🔴 Quarantäne", FileStatus.IN_PROGRESS.value: "⏳ Läuft", FileStatus.PENDING.value: "⏸ Ausstehend", } return icons.get(status, status) def _burn_recommendation(a: BatchResult, b: BatchResult) -> str: if a.success_rate > b.success_rate + 0.05: return ( "**Track A (konservativ)** hat eine höhere Erfolgsquote. " "Empfehlung für die Burn-Phase: Track A als Basis, Track B für " "ausgewählte, gut getestete Module." ) if b.success_rate > a.success_rate + 0.05: return ( "**Track B (modern)** hat eine höhere Erfolgsquote. " "Empfehlung: Track B als Basis übernehmen." ) if a.total_warnings < b.total_warnings: return ( "Beide Tracks haben ähnliche Erfolgsquoten. " "Track A hat weniger Warnungen – bevorzuge Track A für risikoarme Module, " "Track B für Module mit guter Testabdeckung." ) return ( "Beide Tracks sind gleichwertig. " "Entscheidung liegt beim Team – empfohlene Kriterien: " "Wartbarkeit, Warnungsfreiheit, vorhandene Testabdeckung." ) # --------------------------------------------------------------------------- # JSON serialisation helper # --------------------------------------------------------------------------- def _to_dict(a: BatchResult, b: BatchResult) -> dict: def _result(r: BatchResult) -> dict: return { "batch_id": r.batch_id, "track": r.track, "branch": r.branch, "total_files": r.total_files, "success_count": r.success_count, "quarantine_count": r.quarantine_count, "total_warnings": r.total_warnings, "mean_elapsed_seconds": round(r.mean_elapsed_seconds, 1), "success_rate": round(r.success_rate, 4), "quarantine_rate": round(r.quarantine_rate, 4), "files": [_file_result(f) for f in r.results], } def _file_result(f: FileResult) -> dict: d: dict = { "file_path": f.file_path, "status": f.status, "iterations": f.iterations, "elapsed_seconds": f.elapsed_seconds, "warnings": f.gate.total_warnings, "test_name": f.test_name, } if f.quarantine_entry: d["quarantine"] = f.quarantine_entry.__dict__ return d return {"track_a": _result(a), "track_b": _result(b)} # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- def _utc_now() -> str: from datetime import datetime, timezone return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")