fix: update CI workflow for repohealth-cli with separate lint, test, and build jobs

This commit is contained in:
Developer
2026-02-05 17:36:28 +00:00
parent 98e8df8906
commit f049fe1c81
28 changed files with 3449 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""Reporting modules for different output formats."""
from repohealth.reporters.html_reporter import HTMLReporter
from repohealth.reporters.json_reporter import JSONReporter
from repohealth.reporters.terminal import TerminalReporter
__all__ = ["TerminalReporter", "JSONReporter", "HTMLReporter"]

View File

@@ -0,0 +1,348 @@
"""HTML reporter using Jinja2 templates."""
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)

View File

@@ -0,0 +1,132 @@
"""JSON reporter for machine-readable output."""
import json
from repohealth.analyzers.risk_analyzer import DiversificationSuggestion, Hotspot
from repohealth.models.file_stats import FileAnalysis
from repohealth.models.result import RepositoryResult
class JSONReporter:
"""Reporter for JSON output."""
def __init__(self, indent: int = 2):
"""Initialize the reporter.
Args:
indent: JSON indentation level.
"""
self.indent = indent
def generate(self, result: RepositoryResult) -> str:
"""Generate JSON output from a result.
Args:
result: RepositoryResult to convert.
Returns:
JSON string.
"""
output = {
"version": "1.0",
"repository": result.repository_path,
"analyzed_at": result.analyzed_at.isoformat(),
"files_analyzed": result.files_analyzed,
"summary": {
"files_analyzed": result.files_analyzed,
"total_commits": result.total_commits,
"unique_authors": result.unique_authors,
"overall_bus_factor": round(result.overall_bus_factor, 2),
"gini_coefficient": round(result.gini_coefficient, 3),
"overall_risk": result.risk_summary.get("overall_risk", "unknown")
},
"risk_summary": result.risk_summary,
"files": result.files,
"hotspots": result.hotspots,
"suggestions": result.suggestions,
"metadata": result.metadata
}
indent = self.indent if self.indent else None
return json.dumps(output, indent=indent, default=str)
def save(self, result: RepositoryResult, file_path: str) -> None:
"""Save JSON output to a file.
Args:
result: RepositoryResult to save.
file_path: Path to output file.
"""
json_str = self.generate(result)
with open(file_path, 'w') as f:
f.write(json_str)
def generate_file_dict(self, analysis: FileAnalysis) -> dict:
"""Convert a FileAnalysis to a dictionary.
Args:
analysis: FileAnalysis to convert.
Returns:
Dictionary representation.
"""
return {
"path": analysis.path,
"total_commits": analysis.total_commits,
"num_authors": analysis.num_authors,
"author_commits": analysis.author_commits,
"gini_coefficient": round(analysis.gini_coefficient, 3),
"bus_factor": round(analysis.bus_factor, 2),
"risk_level": analysis.risk_level,
"top_author_share": round(analysis.top_author_share, 3),
"module": analysis.module,
"extension": analysis.extension,
"first_commit": (
analysis.first_commit.isoformat()
if analysis.first_commit else None
),
"last_commit": (
analysis.last_commit.isoformat()
if analysis.last_commit else None
)
}
def generate_hotspot_dict(self, hotspot: Hotspot) -> dict:
"""Convert a Hotspot to a dictionary.
Args:
hotspot: Hotspot to convert.
Returns:
Dictionary representation.
"""
return {
"file_path": hotspot.file_path,
"risk_level": hotspot.risk_level,
"bus_factor": round(hotspot.bus_factor, 2),
"top_author": hotspot.top_author,
"top_author_share": round(hotspot.top_author_share, 3),
"total_commits": hotspot.total_commits,
"num_authors": hotspot.num_authors,
"module": hotspot.module,
"suggestion": hotspot.suggestion
}
def generate_suggestion_dict(self, suggestion: DiversificationSuggestion) -> dict:
"""Convert a DiversificationSuggestion to a dictionary.
Args:
suggestion: Suggestion to convert.
Returns:
Dictionary representation.
"""
return {
"file_path": suggestion.file_path,
"current_author": suggestion.current_author,
"suggested_authors": suggestion.suggested_authors,
"priority": suggestion.priority,
"reason": suggestion.reason,
"action": suggestion.action
}

View File

@@ -0,0 +1,253 @@
"""Terminal reporter using Rich library."""
from typing import Optional
from rich.box import ROUNDED
from rich.console import Console
from rich.panel import Panel
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
from rich.table import Table
from rich.text import Text
from repohealth.models.result import RepositoryResult
class TerminalReporter:
"""Reporter for terminal output using Rich."""
RISK_COLORS = {
"critical": "red",
"high": "orange3",
"medium": "yellow",
"low": "green",
"unknown": "grey"
}
def __init__(self, console: Optional[Console] = None):
"""Initialize the reporter.
Args:
console: Rich Console instance.
"""
self.console = console or Console()
def display_result(self, result: RepositoryResult) -> None:
"""Display a complete analysis result.
Args:
result: RepositoryResult to display.
"""
self.console.print(Panel(
self._get_overview_text(result),
title="Repository Health Analysis",
subtitle=f"Analyzed: {result.analyzed_at.strftime('%Y-%m-%d %H:%M')}",
expand=False
))
self._display_risk_summary(result)
self._display_file_stats(result)
self._display_hotspots(result)
self._display_suggestions(result)
def _get_overview_text(self, result: RepositoryResult) -> Text:
"""Get overview text for the result.
Args:
result: RepositoryResult to display.
Returns:
Rich Text object.
"""
text = Text()
text.append("Repository: ", style="bold")
text.append(f"{result.repository_path}\n")
text.append("Files Analyzed: ", style="bold")
text.append(f"{result.files_analyzed}\n")
text.append("Total Commits: ", style="bold")
text.append(f"{result.total_commits}\n")
text.append("Unique Authors: ", style="bold")
text.append(f"{result.unique_authors}\n")
text.append("Overall Bus Factor: ", style="bold")
text.append(f"{result.overall_bus_factor:.2f}\n")
text.append("Gini Coefficient: ", style="bold")
text.append(f"{result.gini_coefficient:.3f}\n")
return text
def _display_risk_summary(self, result: RepositoryResult) -> None:
"""Display risk summary.
Args:
result: RepositoryResult to display.
"""
summary = result.risk_summary
if not summary:
return
table = Table(title="Risk Summary", box=ROUNDED)
table.add_column("Risk Level", justify="center")
table.add_column("Count", justify="center")
table.add_column("Percentage", justify="center")
levels = ["critical", "high", "medium", "low"]
for level in levels:
count = summary.get(level, 0)
pct = summary.get(f"percentage_{level}", 0)
color = self.RISK_COLORS.get(level, "grey")
table.add_row(
f"[{color}]{level.upper()}[/]",
str(count),
f"{pct:.1f}%"
)
self.console.print(Panel(table, title="Risk Overview", expand=False))
def _display_file_stats(self, result: RepositoryResult) -> None:
"""Display file statistics table.
Args:
result: RepositoryResult to display.
"""
if not result.files:
return
table = Table(title="Top Files by Risk", box=ROUNDED)
table.add_column("File", style="dim", width=40)
table.add_column("Commits", justify="right")
table.add_column("Authors", justify="right")
table.add_column("Bus Factor", justify="right")
table.add_column("Risk", justify="center")
table.add_column("Top Author %", justify="right")
sorted_files = 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)
)
)[:15]
for file_data in sorted_files:
risk_level = file_data.get("risk_level", "unknown")
color = self.RISK_COLORS.get(risk_level, "grey")
table.add_row(
file_data.get("path", "")[:40],
str(file_data.get("total_commits", 0)),
str(file_data.get("num_authors", 0)),
f"{file_data.get('bus_factor', 1):.2f}",
f"[{color}]{risk_level.upper()}[/]",
f"{file_data.get('top_author_share', 0):.0%}"
)
self.console.print(Panel(table, title="File Analysis", expand=False))
def _display_hotspots(self, result: RepositoryResult) -> None:
"""Display knowledge hotspots.
Args:
result: RepositoryResult to display.
"""
if not result.hotspots:
return
table = Table(title="Knowledge Hotspots", box=ROUNDED)
table.add_column("File", style="dim", width=35)
table.add_column("Top Author", width=20)
table.add_column("Ownership", justify="right")
table.add_column("Bus Factor", justify="right")
table.add_column("Risk", justify="center")
for hotspot in result.hotspots[:10]:
color = self.RISK_COLORS.get(hotspot.risk_level, "grey")
table.add_row(
hotspot.file_path[:35],
hotspot.top_author[:20],
f"{hotspot.top_author_share:.0%}",
f"{hotspot.bus_factor:.2f}",
f"[{color}]{hotspot.risk_level.upper()}[/]"
)
self.console.print(Panel(table, title="Hotspots", expand=False))
def _display_suggestions(self, result: RepositoryResult) -> None:
"""Display diversification suggestions.
Args:
result: RepositoryResult to display.
"""
if not result.suggestions:
return
table = Table(title="Diversification Suggestions", box=ROUNDED)
table.add_column("Priority", width=10)
table.add_column("File", style="dim", width=30)
table.add_column("Action", width=40)
priority_colors = {
"critical": "red",
"high": "orange3",
"medium": "yellow"
}
for suggestion in result.suggestions[:10]:
color = priority_colors.get(suggestion.priority, "grey")
table.add_row(
f"[{color}]{suggestion.priority.upper()}[/]",
suggestion.file_path[:30],
suggestion.action[:40]
)
self.console.print(Panel(table, title="Suggestions", expand=False))
def display_progress(self, message: str) -> Progress:
"""Display a progress indicator.
Args:
message: Progress message.
Returns:
Progress instance for updating.
"""
return Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
console=self.console
)
def display_error(self, message: str) -> None:
"""Display an error message.
Args:
message: Error message to display.
"""
self.console.print(Panel(
Text(message, style="red"),
title="Error",
expand=False
))
def display_warning(self, message: str) -> None:
"""Display a warning message.
Args:
message: Warning message to display.
"""
self.console.print(Panel(
Text(message, style="yellow"),
title="Warning",
expand=False
))
def display_info(self, message: str) -> None:
"""Display an info message.
Args:
message: Info message to display.
"""
self.console.print(Panel(
Text(message, style="blue"),
title="Info",
expand=False
))