Skip to content

Commit a0f2da7

Browse files
hummbl-devClaude (agent)claude
authored
feat(cli): add HTML leaderboard generator with --html flag (#57)
Self-contained static HTML page from Arbiter scores with dark theme, HUMMBL branding (#166534 forest green), responsive layout, color-coded score badges, and XSS-safe escaping. 12 new tests. Co-authored-by: Claude (agent) <claude@agents.hummbl.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ffe086d commit a0f2da7

3 files changed

Lines changed: 355 additions & 6 deletions

File tree

src/arbiter/__main__.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
python -m arbiter explain /path/to/repo Plain-English score explanation
88
python -m arbiter contribute [--lang python] Scan repos for fixable findings
99
python -m arbiter leaderboard [--db FILE] Markdown quality leaderboard from DB
10+
python -m arbiter leaderboard --html -o out.html HTML leaderboard
1011
python -m arbiter agents Agent leaderboard
1112
python -m arbiter trend [--days 30] Quality trend over time
1213
python -m arbiter worst [--limit 20] Worst files by quality
@@ -1516,7 +1517,7 @@ def format_leaderboard_markdown(
15161517

15171518

15181519
def cmd_leaderboard(args: argparse.Namespace) -> None:
1519-
"""Read from an existing DB and output a markdown quality leaderboard."""
1520+
"""Read from an existing DB and output a quality leaderboard."""
15201521
db_path = Path(args.db) if args.db else Path("arbiter_github_top.db")
15211522
if not db_path.exists():
15221523
print(f"Database not found: {db_path}. Run 'arbiter github-top' first.", file=sys.stderr)
@@ -1528,14 +1529,19 @@ def cmd_leaderboard(args: argparse.Namespace) -> None:
15281529
print("No data in database. Run 'arbiter github-top' first.", file=sys.stderr)
15291530
sys.exit(1)
15301531

1531-
md = format_leaderboard_markdown(report, language=args.lang)
1532+
if getattr(args, "html", False):
1533+
from arbiter.leaderboard_html import generate_leaderboard_html
1534+
content = generate_leaderboard_html(report, language=args.lang)
1535+
else:
1536+
content = format_leaderboard_markdown(report, language=args.lang)
15321537

15331538
if args.output:
15341539
out = Path(args.output)
1535-
out.write_text(md)
1536-
print(f"Leaderboard written to {out}", file=sys.stderr)
1540+
out.write_text(content)
1541+
fmt = "HTML" if getattr(args, "html", False) else "Markdown"
1542+
print(f"{fmt} leaderboard written to {out}", file=sys.stderr)
15371543
else:
1538-
print(md)
1544+
print(content)
15391545

15401546

15411547
def cmd_github_top(args: argparse.Namespace) -> None:
@@ -2214,7 +2220,8 @@ def main() -> None:
22142220
p_leaderboard = subparsers.add_parser("leaderboard",
22152221
help="Output quality leaderboard as markdown from an existing DB")
22162222
p_leaderboard.add_argument("--lang", default="Python", help="Language label for the header (default: Python)")
2217-
p_leaderboard.add_argument("--output", "-o", help="Write markdown to file instead of stdout")
2223+
p_leaderboard.add_argument("--html", action="store_true", help="Output as self-contained HTML instead of markdown")
2224+
p_leaderboard.add_argument("--output", "-o", help="Write output to file instead of stdout")
22182225

22192226
# contributions (OSS contribution tracker)
22202227
p_contrib = subparsers.add_parser("contributions",

src/arbiter/leaderboard_html.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Generate a self-contained HTML leaderboard page from Arbiter scores."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime, timezone
6+
from html import escape
7+
8+
9+
def _grade_score(score: float, total_loc: int = 1) -> str:
10+
"""Convert a numeric score to a letter grade."""
11+
if total_loc <= 0:
12+
return "N/A"
13+
if score >= 90:
14+
return "A"
15+
if score >= 80:
16+
return "B"
17+
if score >= 70:
18+
return "C"
19+
if score >= 60:
20+
return "D"
21+
return "F"
22+
23+
24+
_GRADE_COLORS = {
25+
"A": "#22c55e",
26+
"B": "#84cc16",
27+
"C": "#eab308",
28+
"D": "#f97316",
29+
"F": "#ef4444",
30+
"N/A": "#6b7280",
31+
}
32+
33+
34+
def _score_color(score: float) -> str:
35+
"""Return a CSS color for a numeric score."""
36+
if score >= 90:
37+
return "#22c55e"
38+
if score >= 80:
39+
return "#84cc16"
40+
if score >= 70:
41+
return "#eab308"
42+
if score >= 60:
43+
return "#f97316"
44+
return "#ef4444"
45+
46+
47+
def generate_leaderboard_html(
48+
report: list[dict],
49+
*,
50+
title: str = "Arbiter Quality Index",
51+
language: str = "Python",
52+
) -> str:
53+
"""Generate a self-contained HTML page with the leaderboard.
54+
55+
The page uses inline CSS only -- no external stylesheets or scripts.
56+
It is responsive and uses a dark theme with HUMMBL forest green (#166534)
57+
as the accent color.
58+
"""
59+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
60+
61+
rows: list[str] = []
62+
for rank, r in enumerate(report, 1):
63+
repo_name = escape(r.get("repo_name") or "?")
64+
score = r.get("overall_score", 0) or 0
65+
loc = r.get("total_loc", 0) or 0
66+
findings = r.get("total_findings", 0) or 0
67+
grade = _grade_score(score, loc)
68+
grade_color = _GRADE_COLORS.get(grade, "#6b7280")
69+
sc = _score_color(score)
70+
71+
if "/" in repo_name:
72+
repo_cell = (
73+
f'<a href="https://github.com/{repo_name}">{repo_name}</a>'
74+
)
75+
else:
76+
repo_cell = repo_name
77+
78+
score_display = f"{score:.1f}" if grade != "N/A" else "n/a"
79+
80+
rows.append(f""" <tr>
81+
<td class="rank">{rank}</td>
82+
<td class="repo">{repo_cell}</td>
83+
<td class="score"><span class="badge" style="background:{sc}">{score_display}</span></td>
84+
<td class="grade"><span class="badge" style="background:{grade_color}">{grade}</span></td>
85+
<td class="findings">{findings:,}</td>
86+
<td class="loc">{loc:,}</td>
87+
</tr>""")
88+
89+
rows_html = "\n".join(rows)
90+
esc_title = escape(title)
91+
esc_lang = escape(language)
92+
93+
return f"""<!DOCTYPE html>
94+
<html lang="en">
95+
<head>
96+
<meta charset="utf-8">
97+
<meta name="viewport" content="width=device-width, initial-scale=1">
98+
<title>{esc_title}</title>
99+
<style>
100+
*, *::before, *::after {{ box-sizing: border-box; }}
101+
body {{
102+
margin: 0;
103+
padding: 0;
104+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
105+
background: #0f172a;
106+
color: #e2e8f0;
107+
min-height: 100vh;
108+
}}
109+
.container {{
110+
max-width: 960px;
111+
margin: 0 auto;
112+
padding: 2rem 1rem;
113+
}}
114+
h1 {{
115+
text-align: center;
116+
font-size: 1.75rem;
117+
margin: 0 0 0.25rem;
118+
color: #f8fafc;
119+
}}
120+
.subtitle {{
121+
text-align: center;
122+
color: #94a3b8;
123+
font-size: 0.875rem;
124+
margin-bottom: 1.5rem;
125+
}}
126+
.table-wrap {{
127+
overflow-x: auto;
128+
-webkit-overflow-scrolling: touch;
129+
}}
130+
table {{
131+
width: 100%;
132+
border-collapse: collapse;
133+
font-size: 0.9rem;
134+
}}
135+
th {{
136+
background: #166534;
137+
color: #f0fdf4;
138+
padding: 0.6rem 0.75rem;
139+
text-align: left;
140+
font-weight: 600;
141+
position: sticky;
142+
top: 0;
143+
white-space: nowrap;
144+
}}
145+
td {{
146+
padding: 0.5rem 0.75rem;
147+
border-bottom: 1px solid #1e293b;
148+
}}
149+
tr:hover td {{
150+
background: #1e293b;
151+
}}
152+
.rank {{ text-align: center; color: #94a3b8; }}
153+
.repo a {{
154+
color: #86efac;
155+
text-decoration: none;
156+
}}
157+
.repo a:hover {{
158+
text-decoration: underline;
159+
}}
160+
.score, .grade {{ text-align: center; }}
161+
.findings, .loc {{ text-align: right; font-variant-numeric: tabular-nums; }}
162+
.badge {{
163+
display: inline-block;
164+
padding: 0.15rem 0.5rem;
165+
border-radius: 4px;
166+
color: #fff;
167+
font-weight: 600;
168+
font-size: 0.8rem;
169+
}}
170+
footer {{
171+
text-align: center;
172+
margin-top: 2rem;
173+
padding-top: 1rem;
174+
border-top: 1px solid #1e293b;
175+
color: #64748b;
176+
font-size: 0.8rem;
177+
}}
178+
footer a {{
179+
color: #166534;
180+
text-decoration: none;
181+
font-weight: 600;
182+
}}
183+
footer a:hover {{
184+
text-decoration: underline;
185+
}}
186+
@media (max-width: 600px) {{
187+
.container {{ padding: 1rem 0.5rem; }}
188+
h1 {{ font-size: 1.25rem; }}
189+
table {{ font-size: 0.8rem; }}
190+
th, td {{ padding: 0.4rem 0.5rem; }}
191+
}}
192+
</style>
193+
</head>
194+
<body>
195+
<div class="container">
196+
<h1>{esc_title}</h1>
197+
<p class="subtitle">Language: {esc_lang} | Top {len(report)} by quality score | Generated: {timestamp}</p>
198+
<div class="table-wrap">
199+
<table>
200+
<thead>
201+
<tr>
202+
<th>Rank</th>
203+
<th>Repository</th>
204+
<th>Score</th>
205+
<th>Grade</th>
206+
<th>Findings</th>
207+
<th>LOC</th>
208+
</tr>
209+
</thead>
210+
<tbody>
211+
{rows_html}
212+
</tbody>
213+
</table>
214+
</div>
215+
<footer>
216+
Powered by <a href="https://github.com/hummbl-research/arbiter">HUMMBL Arbiter</a> | {timestamp}
217+
</footer>
218+
</div>
219+
</body>
220+
</html>
221+
"""

tests/test_leaderboard_html.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Tests for the HTML leaderboard generator."""
2+
3+
from __future__ import annotations
4+
5+
from arbiter.leaderboard_html import generate_leaderboard_html
6+
7+
SAMPLE_REPORT = [
8+
{
9+
"repo_name": "pallets/flask",
10+
"overall_score": 92.5,
11+
"total_findings": 12,
12+
"total_loc": 15000,
13+
},
14+
{
15+
"repo_name": "psf/requests",
16+
"overall_score": 85.3,
17+
"total_findings": 28,
18+
"total_loc": 8500,
19+
},
20+
{
21+
"repo_name": "django/django",
22+
"overall_score": 71.0,
23+
"total_findings": 145,
24+
"total_loc": 250000,
25+
},
26+
]
27+
28+
29+
def test_generates_valid_html():
30+
html = generate_leaderboard_html(SAMPLE_REPORT)
31+
assert html.strip().startswith("<!DOCTYPE html>")
32+
assert "</html>" in html
33+
34+
35+
def test_contains_repo_names():
36+
html = generate_leaderboard_html(SAMPLE_REPORT)
37+
assert "pallets/flask" in html
38+
assert "psf/requests" in html
39+
assert "django/django" in html
40+
41+
42+
def test_contains_scores():
43+
html = generate_leaderboard_html(SAMPLE_REPORT)
44+
assert "92.5" in html
45+
assert "85.3" in html
46+
assert "71.0" in html
47+
48+
49+
def test_self_contained():
50+
"""No external CSS or JS references."""
51+
html = generate_leaderboard_html(SAMPLE_REPORT)
52+
# Should not link to external stylesheets or scripts
53+
assert 'rel="stylesheet"' not in html
54+
assert "<link" not in html.lower().replace("blinkmacsystemfont", "")
55+
assert "<script src=" not in html
56+
# Style should be inline
57+
assert "<style>" in html
58+
59+
60+
def test_responsive_meta_tag():
61+
html = generate_leaderboard_html(SAMPLE_REPORT)
62+
assert 'name="viewport"' in html
63+
assert "width=device-width" in html
64+
65+
66+
def test_hummbl_branding():
67+
html = generate_leaderboard_html(SAMPLE_REPORT)
68+
# Forest green accent
69+
assert "#166534" in html
70+
# Footer branding
71+
assert "HUMMBL Arbiter" in html
72+
assert "hummbl-research/arbiter" in html
73+
74+
75+
def test_repos_are_linked():
76+
html = generate_leaderboard_html(SAMPLE_REPORT)
77+
assert 'href="https://github.com/pallets/flask"' in html
78+
assert 'href="https://github.com/psf/requests"' in html
79+
80+
81+
def test_custom_title_and_language():
82+
html = generate_leaderboard_html(
83+
SAMPLE_REPORT, title="My Index", language="Rust"
84+
)
85+
assert "My Index" in html
86+
assert "Rust" in html
87+
88+
89+
def test_empty_report():
90+
html = generate_leaderboard_html([])
91+
assert html.strip().startswith("<!DOCTYPE html>")
92+
assert "Top 0" in html
93+
94+
95+
def test_grade_badges():
96+
html = generate_leaderboard_html(SAMPLE_REPORT)
97+
# Flask (92.5) should get A, Requests (85.3) B, Django (71.0) C
98+
assert ">A<" in html
99+
assert ">B<" in html
100+
assert ">C<" in html
101+
102+
103+
def test_html_escaping():
104+
"""Repo names with special characters should be escaped."""
105+
report = [
106+
{
107+
"repo_name": 'test/<script>alert("xss")</script>',
108+
"overall_score": 50.0,
109+
"total_findings": 1,
110+
"total_loc": 100,
111+
},
112+
]
113+
html = generate_leaderboard_html(report)
114+
assert "<script>" not in html
115+
assert "&lt;script&gt;" in html
116+
117+
118+
def test_timestamp_present():
119+
html = generate_leaderboard_html(SAMPLE_REPORT)
120+
assert "Generated:" in html
121+
assert "UTC" in html

0 commit comments

Comments
 (0)