76 lines
5.2 KiB
Python
76 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
from html import escape
|
|
from urllib.parse import quote
|
|
|
|
from .models import ScanReport
|
|
|
|
INVALID_FILENAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]')
|
|
|
|
REPORT_MARK = """<svg class=\"brand-mark\" viewBox=\"0 0 32 32\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"32\" height=\"32\" rx=\"8\" fill=\"#123245\"/><path d=\"M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25\" fill=\"none\" stroke=\"#91ebe0\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"7\" cy=\"9.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"7\" cy=\"22.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"25\" cy=\"16\" r=\"2\" fill=\"#91ebe0\"/></svg>"""
|
|
|
|
|
|
def safe_report_filename(repository_name: str, extension: str) -> str:
|
|
cleaned = INVALID_FILENAME_CHARS.sub("_", repository_name).strip(" .")
|
|
cleaned = cleaned[:100].rstrip(" .") or "repository"
|
|
return f"repopulse-{cleaned}.{extension}"
|
|
|
|
|
|
def content_disposition(filename: str) -> str:
|
|
fallback = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii")
|
|
fallback = INVALID_FILENAME_CHARS.sub("_", fallback).strip(" .") or "repopulse-report"
|
|
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
|
|
|
|
|
|
def render_html_report(report: ScanReport) -> str:
|
|
category_rows = "".join(
|
|
f"<tr><td>{escape(name)}</td><td>{score}/100</td></tr>"
|
|
for name, score in report.category_scores.items()
|
|
)
|
|
finding_rows = "".join(
|
|
"<tr>"
|
|
f"<td>{escape(item.severity.value)}</td>"
|
|
f"<td>{escape(item.category)}</td>"
|
|
f"<td>{escape(item.message)}</td>"
|
|
f"<td>{escape(item.path or '')}{':' + str(item.line) if item.line else ''}</td>"
|
|
"</tr>"
|
|
for item in report.findings
|
|
)
|
|
hotspot_rows = "".join(
|
|
"<tr>"
|
|
f"<td>{escape(item.path)}</td><td>{escape(item.language)}</td>"
|
|
f"<td>{item.score}</td><td>{item.lines_of_code}</td><td>{item.churn}</td>"
|
|
"</tr>"
|
|
for item in report.hotspots[:25]
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>RepoPulse report — {escape(report.repository_name)}</title>
|
|
<style>
|
|
*{{box-sizing:border-box}}body{{font-family:Inter,ui-sans-serif,system-ui,sans-serif;margin:42px auto;max-width:1120px;color:#172638;line-height:1.45}}
|
|
.brand{{display:flex;align-items:center;gap:10px;margin-bottom:34px}}.brand-mark{{width:34px;height:34px;flex:0 0 auto}}.brand strong{{display:block;font-size:15px;letter-spacing:-.03em}}.brand small{{display:block;color:#66808d;font-size:8px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}}
|
|
.hero{{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:23px 25px;border:1px solid #dce8e8;border-radius:18px;background:linear-gradient(120deg,#effafa,#f9fcfc)}}h1{{margin:0 0 4px;font-size:30px;letter-spacing:-.04em}}h2{{margin-top:36px;font-size:18px;letter-spacing:-.02em}}.muted{{color:#607588}}.score{{color:#176c70;font-size:54px;font-weight:750;letter-spacing:-.08em}}.readiness{{color:#176c70;font-size:11px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}}
|
|
table{{width:100%;border-collapse:collapse;margin:14px 0 34px}}th,td{{text-align:left;padding:10px 9px;border-bottom:1px solid #e4ebef;vertical-align:top}}th{{color:#607588;font-size:10px;letter-spacing:.06em;text-transform:uppercase}}.grid{{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;overflow:hidden;border:1px solid #dce8e8;border-radius:13px;background:#dce8e8}}.card{{padding:15px;background:#fff}}.card strong{{color:#176c70;font-size:24px}}
|
|
@media print{{body{{margin:18mm;max-width:none}}.hero{{background:#fff}}}}@media(max-width:760px){{.grid{{grid-template-columns:1fr 1fr}}body{{margin:22px}}.hero{{align-items:start;flex-direction:column}}}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="brand">{REPORT_MARK}<span><strong>RepoPulse</strong><small>Repository intelligence</small></span></div>
|
|
<section class="hero"><div><p class="muted">Deterministic repository intelligence report</p><h1>{escape(report.repository_name)}</h1><p class="muted">{escape(report.repository_path)} · {escape(report.scanned_at.isoformat())}</p></div><div><div class="score">{report.health_score}</div><div class="readiness">{escape(report.release_readiness)}</div></div></section>
|
|
<div class="grid">
|
|
<div class="card"><strong>{report.metrics.source_files}</strong><br>source files</div>
|
|
<div class="card"><strong>{report.metrics.lines_of_code}</strong><br>LOC</div>
|
|
<div class="card"><strong>{report.metrics.test_files}</strong><br>test files</div>
|
|
<div class="card"><strong>{len(report.findings)}</strong><br>findings</div>
|
|
</div>
|
|
<h2>Category scores</h2><table><tbody>{category_rows}</tbody></table>
|
|
<h2>Hotspots</h2><table><thead><tr><th>Path</th><th>Language</th><th>Score</th><th>LOC</th><th>Churn</th></tr></thead><tbody>{hotspot_rows}</tbody></table>
|
|
<h2>Findings</h2><table><thead><tr><th>Severity</th><th>Category</th><th>Finding</th><th>Location</th></tr></thead><tbody>{finding_rows}</tbody></table>
|
|
</body>
|
|
</html>"""
|