diff --git a/repohealth-cli/src/repohealth/reporters/html_reporter.py b/repohealth-cli/src/repohealth/reporters/html_reporter.py new file mode 100644 index 0000000..8fca612 --- /dev/null +++ b/repohealth-cli/src/repohealth/reporters/html_reporter.py @@ -0,0 +1,346 @@ +from datetime import datetime +from pathlib import Path +from typing import Optional + +from jinja2 import Environment, FileSystemLoader, Template + +from repohealth.models.result import RepositoryResult + + +class HTMLReporter: + """Reporter for HTML output with visualizations.""" + + RISK_COLORS = { + "critical": "#dc3545", + "high": "#fd7e14", + "medium": "#ffc107", + "low": "#28a745", + "unknown": "#6c757d" + } + + def __init__(self, template_dir: Optional[str] = None): + """Initialize the reporter. + + Args: + template_dir: Directory containing Jinja2 templates. + """ + if template_dir: + self.template_dir = Path(template_dir) + else: + self.template_dir = Path(__file__).parent / "templates" + + self.env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=True + ) + + def generate(self, result: RepositoryResult) -> str: + """Generate HTML output from a result. + + Args: + result: RepositoryResult to convert. + + Returns: + HTML string. + """ + template = self.env.get_template("report.html") + return template.render( + result=result, + risk_colors=self.RISK_COLORS, + generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + ) + + def save(self, result: RepositoryResult, file_path: str) -> None: + """Save HTML output to a file. + + Args: + result: RepositoryResult to save. + file_path: Path to output file. + """ + html_content = self.generate(result) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + self._copy_assets(Path(file_path).parent) + + def _copy_assets(self, output_dir: Path) -> None: + """Copy CSS/JS assets to output directory. + + Args: + output_dir: Directory to copy assets to. + """ + assets_dir = output_dir / "assets" + assets_dir.mkdir(exist_ok=True) + + template_assets = self.template_dir / "assets" + if template_assets.exists(): + for asset in template_assets.iterdir(): + dest = assets_dir / asset.name + dest.write_text(asset.read_text()) + + def generate_charts_data(self, result: RepositoryResult) -> dict: + """Generate data for JavaScript charts. + + Args: + result: RepositoryResult to analyze. + + Returns: + Dictionary with chart data. + """ + risk_summary = result.risk_summary + + risk_distribution = { + "labels": ["Critical", "High", "Medium", "Low"], + "data": [ + risk_summary.get("critical", 0), + risk_summary.get("high", 0), + risk_summary.get("medium", 0), + risk_summary.get("low", 0) + ], + "colors": [ + self.RISK_COLORS["critical"], + self.RISK_COLORS["high"], + self.RISK_COLORS["medium"], + self.RISK_COLORS["low"] + ] + } + + def get_hotspot_attr(h, attr, default=None): + """Get attribute from hotspot dict or object.""" + if isinstance(h, dict): + return h.get(attr, default) + return getattr(h, attr, default) + + top_hotspots = [ + { + "file": get_hotspot_attr(h, "file_path", "")[:30], + "author": get_hotspot_attr(h, "top_author", "")[:20], + "share": round(get_hotspot_attr(h, "top_author_share", 0) * 100, 1), + "risk": get_hotspot_attr(h, "risk_level", "unknown") + } + for h in result.hotspots[:10] + ] + + file_data = [ + { + "name": f.get("path", "")[:30], + "commits": f.get("total_commits", 0), + "authors": f.get("num_authors", 0), + "bus_factor": round(f.get("bus_factor", 1), 2), + "risk": f.get("risk_level", "unknown") + } + for f in sorted( + result.files, + key=lambda x: ( + {"critical": 0, "high": 1, "medium": 2, "low": 3}.get( + x.get("risk_level"), 4 + ), + -x.get("bus_factor", 1) + ) + )[:20] + ] + + return { + "risk_distribution": risk_distribution, + "top_hotspots": top_hotspots, + "file_data": file_data, + "summary": { + "bus_factor": round(result.overall_bus_factor, 2), + "gini": round(result.gini_coefficient, 3), + "files": result.files_analyzed, + "authors": result.unique_authors + } + } + + def create_inline_template(self) -> Template: + """Create an inline template for standalone HTML reports. + + Returns: + Jinja2 Template with inline CSS/JS. + """ + template_str = """ + + +
+ + +Critical: {{ "%.1f"|format(result.risk_summary.get('percentage_critical', 0)) }}%
+ +High: {{ "%.1f"|format(result.risk_summary.get('percentage_high', 0)) }}%
+ +| File | Author | Share | Risk |
|---|---|---|---|
| {{ hotspot.file_path[:30] }} | +{{ hotspot.top_author[:15] }} | +{{ "%.0f"|format(hotspot.top_author_share * 100) }}% | +{{ hotspot.risk_level }} | +
| File | Commits | Authors | Bus Factor | Risk |
|---|---|---|---|---|
| {{ file.path[:40] }} | +{{ file.total_commits }} | +{{ file.num_authors }} | +{{ "%.2f"|format(file.bus_factor) }} | +{{ file.risk_level }} | +