fix: resolve CI issues - remove unused imports and fix code quality
This commit is contained in:
346
repohealth-cli/src/repohealth/reporters/html_reporter.py
Normal file
346
repohealth-cli/src/repohealth/reporters/html_reporter.py
Normal file
@@ -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 = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Repository Health Report</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; }
|
||||||
|
.header h1 { font-size: 2em; margin-bottom: 10px; }
|
||||||
|
.meta { opacity: 0.9; font-size: 0.9em; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 20px; }
|
||||||
|
.card { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.card h2 { color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
|
||||||
|
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||||
|
.stat:last-child { border-bottom: none; }
|
||||||
|
.stat-label { color: #666; }
|
||||||
|
.stat-value { font-weight: bold; }
|
||||||
|
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.8em; font-weight: bold; color: white; }
|
||||||
|
.badge-critical { background: #dc3545; }
|
||||||
|
.badge-high { background: #fd7e14; }
|
||||||
|
.badge-medium { background: #ffc107; color: #333; }
|
||||||
|
.badge-low { background: #28a745; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #eee; }
|
||||||
|
th { background: #f8f9fa; font-weight: 600; }
|
||||||
|
tr:hover { background: #f8f9fa; }
|
||||||
|
.chart-container { position: relative; height: 250px; margin: 20px 0; }
|
||||||
|
.suggestion { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 10px; border-left: 4px solid #667eea; }
|
||||||
|
.suggestion-priority-critical { border-left-color: #dc3545; }
|
||||||
|
.suggestion-priority-high { border-left-color: #fd7e14; }
|
||||||
|
.suggestion-priority-medium { border-left-color: #ffc107; }
|
||||||
|
.progress-bar { background: #e9ecef; border-radius: 10px; overflow: hidden; height: 20px; }
|
||||||
|
.progress-fill { height: 100%; border-radius: 10px; transition: width 0.3s; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Repository Health Report</h1>
|
||||||
|
<p class="meta">{{ result.repository_path }}</p>
|
||||||
|
<p class="meta">Generated: {{ generated_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Summary</h2>
|
||||||
|
<div class="stat"><span class="stat-label">Files Analyzed</span><span class="stat-value">{{ result.files_analyzed }}</span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Total Commits</span><span class="stat-value">{{ result.total_commits }}</span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Unique Authors</span><span class="stat-value">{{ result.unique_authors }}</span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Bus Factor</span><span class="stat-value">{{ "%.2f"|format(result.overall_bus_factor) }}</span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Gini Coefficient</span><span class="stat-value">{{ "%.3f"|format(result.gini_coefficient) }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Risk Distribution</h2>
|
||||||
|
<div class="stat"><span class="stat-label">Critical</span><span class="stat-value"><span class="badge badge-critical">{{ result.risk_summary.get('critical', 0) }}</span></span></div>
|
||||||
|
<div class="stat"><span class="stat-label">High</span><span class="stat-value"><span class="badge badge-high">{{ result.risk_summary.get('high', 0) }}</span></span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Medium</span><span class="stat-value"><span class="badge badge-medium">{{ result.risk_summary.get('medium', 0) }}</span></span></div>
|
||||||
|
<div class="stat"><span class="stat-label">Low</span><span class="stat-value"><span class="badge badge-low">{{ result.risk_summary.get('low', 0) }}</span></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Risk by Percentage</h2>
|
||||||
|
<p style="margin-bottom: 10px;">Critical: {{ "%.1f"|format(result.risk_summary.get('percentage_critical', 0)) }}%</p>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width: {{ result.risk_summary.get('percentage_critical', 0) }}%; background: #dc3545;"></div></div>
|
||||||
|
<p style="margin: 10px 0 5px;">High: {{ "%.1f"|format(result.risk_summary.get('percentage_high', 0)) }}%</p>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width: {{ result.risk_summary.get('percentage_high', 0) }}%; background: #fd7e14;"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Risk Distribution Chart</h2>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="riskChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Top Knowledge Hotspots</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>File</th><th>Author</th><th>Share</th><th>Risk</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for hotspot in result.hotspots[:10] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ hotspot.file_path[:30] }}</td>
|
||||||
|
<td>{{ hotspot.top_author[:15] }}</td>
|
||||||
|
<td>{{ "%.0f"|format(hotspot.top_author_share * 100) }}%</td>
|
||||||
|
<td><span class="badge badge-{{ hotspot.risk_level }}">{{ hotspot.risk_level }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if result.suggestions %}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Diversification Suggestions</h2>
|
||||||
|
{% for suggestion in result.suggestions %}
|
||||||
|
<div class="suggestion suggestion-priority-{{ suggestion.priority }}">
|
||||||
|
<strong>{{ suggestion.priority|upper }}</strong>: {{ suggestion.action }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>All Analyzed Files</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>File</th><th>Commits</th><th>Authors</th><th>Bus Factor</th><th>Risk</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for file in result.files[:30] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ file.path[:40] }}</td>
|
||||||
|
<td>{{ file.total_commits }}</td>
|
||||||
|
<td>{{ file.num_authors }}</td>
|
||||||
|
<td>{{ "%.2f"|format(file.bus_factor) }}</td>
|
||||||
|
<td><span class="badge badge-{{ file.risk_level }}">{{ file.risk_level }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const riskData = {
|
||||||
|
labels: ['Critical', 'High', 'Medium', 'Low'],
|
||||||
|
datasets: [{
|
||||||
|
data: [
|
||||||
|
{{ result.risk_summary.get('critical', 0) }},
|
||||||
|
{{ result.risk_summary.get('high', 0) }},
|
||||||
|
{{ result.risk_summary.get('medium', 0) }},
|
||||||
|
{{ result.risk_summary.get('low', 0) }}
|
||||||
|
],
|
||||||
|
backgroundColor: ['#dc3545', '#fd7e14', '#ffc107', '#28a745']
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(document.getElementById('riskChart'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: riskData,
|
||||||
|
options: { responsive: true, maintainAspectRatio: false }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return self.env.from_string(template_str)
|
||||||
|
|
||||||
|
def generate_standalone(self, result: RepositoryResult) -> str:
|
||||||
|
"""Generate standalone HTML with inline resources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: RepositoryResult to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete HTML string.
|
||||||
|
"""
|
||||||
|
template = self.create_inline_template()
|
||||||
|
charts_data = self.generate_charts_data(result)
|
||||||
|
|
||||||
|
return template.render(
|
||||||
|
result=result,
|
||||||
|
generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
||||||
|
charts_data=charts_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_standalone(self, result: RepositoryResult, file_path: str) -> None:
|
||||||
|
"""Save standalone HTML to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: RepositoryResult to save.
|
||||||
|
file_path: Path to output file.
|
||||||
|
"""
|
||||||
|
html_content = self.generate_standalone(result)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html_content)
|
||||||
Reference in New Issue
Block a user