222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
"""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")
|