repocheck-cli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/PKG-INFO +3 -1
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/README.md +2 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/pyproject.toml +1 -1
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/checks.py +110 -0
- repocheck_cli-0.2.0/repocheck/cli.py +247 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/github_client.py +15 -0
- repocheck_cli-0.2.0/repocheck/history.py +40 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/report.py +40 -1
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/PKG-INFO +3 -1
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/SOURCES.txt +1 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/tests/test_end_to_end.py +1 -1
- repocheck_cli-0.1.0/repocheck/cli.py +0 -114
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/LICENSE +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/__init__.py +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/dependency_links.txt +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/entry_points.txt +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/requires.txt +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/top_level.txt +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/setup.cfg +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/tests/test_checks.py +0 -0
- {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/tests/test_github_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repocheck-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Scan any GitHub repo and get an instant engineering health report card.
|
|
5
5
|
Author: Syed Ahmed Ali
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
# repocheck
|
|
24
24
|
|
|
25
|
+
[](https://pypi.org/project/repocheck-cli/)
|
|
26
|
+
|
|
25
27
|
Scan any public GitHub repo and get an instant engineering health report card — README quality, CI/CD, test presence, dependency hygiene, license, commit activity, and issue hygiene, scored A–F.
|
|
26
28
|
|
|
27
29
|
```
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# repocheck
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/repocheck-cli/)
|
|
4
|
+
|
|
3
5
|
Scan any public GitHub repo and get an instant engineering health report card — README quality, CI/CD, test presence, dependency hygiene, license, commit activity, and issue hygiene, scored A–F.
|
|
4
6
|
|
|
5
7
|
```
|
|
@@ -282,6 +282,112 @@ def check_issue_hygiene(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
|
282
282
|
return _result("Issue Hygiene", score, details)
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
def check_security(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
286
|
+
root_files = client.root_files(ref)
|
|
287
|
+
github_contents = client.contents(ref, ".github") or []
|
|
288
|
+
github_names = [item["name"].lower() for item in github_contents if isinstance(github_contents, list)]
|
|
289
|
+
|
|
290
|
+
details = []
|
|
291
|
+
score = 0
|
|
292
|
+
|
|
293
|
+
has_security_md = any(f.lower() == "security.md" for f in root_files)
|
|
294
|
+
if has_security_md:
|
|
295
|
+
score += 40
|
|
296
|
+
details.append("SECURITY.md present — vulnerability disclosure policy defined.")
|
|
297
|
+
else:
|
|
298
|
+
details.append("No SECURITY.md — add one to define how to report vulnerabilities.")
|
|
299
|
+
|
|
300
|
+
has_dependabot = "dependabot.yml" in github_names or "dependabot.yaml" in github_names
|
|
301
|
+
if has_dependabot:
|
|
302
|
+
score += 35
|
|
303
|
+
details.append("dependabot.yml configured — automated dependency updates enabled.")
|
|
304
|
+
else:
|
|
305
|
+
details.append("No dependabot.yml — consider adding automated dependency updates.")
|
|
306
|
+
|
|
307
|
+
has_codeowners = "codeowners" in [f.lower() for f in root_files] or "codeowners" in github_names
|
|
308
|
+
if has_codeowners:
|
|
309
|
+
score += 25
|
|
310
|
+
details.append("CODEOWNERS file found — code review ownership defined.")
|
|
311
|
+
else:
|
|
312
|
+
details.append("No CODEOWNERS file.")
|
|
313
|
+
|
|
314
|
+
return _result("Security", score, details)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def check_contributing(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
318
|
+
files = client.root_files(ref)
|
|
319
|
+
contrib_file = next((f for f in files if f.lower().startswith("contributing")), None)
|
|
320
|
+
|
|
321
|
+
if not contrib_file:
|
|
322
|
+
return _result("Contributing Guide", 0, ["No CONTRIBUTING.md — makes it harder for others to contribute."], weight=0.75)
|
|
323
|
+
|
|
324
|
+
text = client.file_text(ref, contrib_file) or ""
|
|
325
|
+
details = [f"CONTRIBUTING.md found ({len(text)} chars)."]
|
|
326
|
+
score = 70
|
|
327
|
+
if len(text) > 500:
|
|
328
|
+
score += 30
|
|
329
|
+
details.append("Substantive contribution guide (>500 chars).")
|
|
330
|
+
else:
|
|
331
|
+
details.append("Short contribution guide — consider expanding.")
|
|
332
|
+
|
|
333
|
+
return _result("Contributing Guide", score, details, weight=0.75)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def check_docker(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
337
|
+
files = [f.lower() for f in client.root_files(ref)]
|
|
338
|
+
details = []
|
|
339
|
+
score = 0
|
|
340
|
+
|
|
341
|
+
if "dockerfile" in files:
|
|
342
|
+
score += 60
|
|
343
|
+
details.append("Dockerfile found.")
|
|
344
|
+
|
|
345
|
+
if "docker-compose.yml" in files or "docker-compose.yaml" in files:
|
|
346
|
+
score += 40
|
|
347
|
+
details.append("docker-compose.yml found.")
|
|
348
|
+
|
|
349
|
+
deploy_hints = ["railway.json", "heroku.yml", "render.yaml", "fly.toml", "vercel.json", "netlify.toml"]
|
|
350
|
+
found_deploy = [f for f in deploy_hints if f in files]
|
|
351
|
+
if found_deploy:
|
|
352
|
+
score = max(score, 50)
|
|
353
|
+
details.append(f"Deployment config found: {', '.join(found_deploy)}.")
|
|
354
|
+
|
|
355
|
+
if score == 0:
|
|
356
|
+
details.append("No Docker or deployment configuration found.")
|
|
357
|
+
|
|
358
|
+
return _result("Docker/Deploy", score, details, weight=0.5)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def check_branch_protection(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
362
|
+
protection = client.branch_protection(ref, "main") or client.branch_protection(ref, "master")
|
|
363
|
+
|
|
364
|
+
if protection is None:
|
|
365
|
+
return _result("Branch Protection", 0, ["No branch protection on main/master — anyone can push directly."], weight=0.75)
|
|
366
|
+
|
|
367
|
+
details = []
|
|
368
|
+
score = 40
|
|
369
|
+
|
|
370
|
+
required_reviews = protection.get("required_pull_request_reviews")
|
|
371
|
+
if required_reviews:
|
|
372
|
+
count = required_reviews.get("required_approving_review_count", 1)
|
|
373
|
+
score += 30
|
|
374
|
+
details.append(f"PR reviews required ({count} approver(s)).")
|
|
375
|
+
else:
|
|
376
|
+
details.append("No required PR reviews.")
|
|
377
|
+
|
|
378
|
+
if protection.get("required_status_checks"):
|
|
379
|
+
score += 20
|
|
380
|
+
details.append("Required status checks configured.")
|
|
381
|
+
else:
|
|
382
|
+
details.append("No required status checks.")
|
|
383
|
+
|
|
384
|
+
if protection.get("enforce_admins", {}).get("enabled"):
|
|
385
|
+
score += 10
|
|
386
|
+
details.append("Protection enforced for admins too.")
|
|
387
|
+
|
|
388
|
+
return _result("Branch Protection", score, details, weight=0.75)
|
|
389
|
+
|
|
390
|
+
|
|
285
391
|
CHECKS: list[Callable[[GitHubClient, RepoRef], CheckResult]] = [
|
|
286
392
|
check_readme,
|
|
287
393
|
check_ci,
|
|
@@ -290,4 +396,8 @@ CHECKS: list[Callable[[GitHubClient, RepoRef], CheckResult]] = [
|
|
|
290
396
|
check_license,
|
|
291
397
|
check_activity,
|
|
292
398
|
check_issue_hygiene,
|
|
399
|
+
check_security,
|
|
400
|
+
check_contributing,
|
|
401
|
+
check_docker,
|
|
402
|
+
check_branch_protection,
|
|
293
403
|
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""repocheck CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from .github_client import GitHubClient, GitHubError, RepoRef, parse_repo_arg
|
|
15
|
+
from .history import get_last_run, save_run
|
|
16
|
+
from .report import run_report
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
GRADE_COLORS = {
|
|
21
|
+
"A": "green",
|
|
22
|
+
"B": "cyan",
|
|
23
|
+
"C": "yellow",
|
|
24
|
+
"D": "orange3",
|
|
25
|
+
"F": "red",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _grade_text(grade: str) -> Text:
|
|
30
|
+
color = GRADE_COLORS.get(grade, "white")
|
|
31
|
+
return Text(grade, style=f"bold {color}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render(report, verbose: bool, last_run: dict | None = None) -> None:
|
|
35
|
+
grade_color = GRADE_COLORS.get(report.overall_grade, "white")
|
|
36
|
+
header = Text()
|
|
37
|
+
header.append(f"{report.repo.full_name}\n", style="bold")
|
|
38
|
+
header.append(f"★ {report.repo_meta.get('stargazers_count', 0)} ", style="dim")
|
|
39
|
+
header.append(f"⑂ {report.repo_meta.get('forks_count', 0)} ", style="dim")
|
|
40
|
+
header.append(f"{report.repo_meta.get('language') or 'unknown'}", style="dim")
|
|
41
|
+
|
|
42
|
+
score_text = Text()
|
|
43
|
+
score_text.append(f"{report.overall_score}/100 ", style=f"bold {grade_color}")
|
|
44
|
+
score_text.append(f"({report.overall_grade})", style=f"bold {grade_color}")
|
|
45
|
+
|
|
46
|
+
if last_run:
|
|
47
|
+
diff = report.overall_score - last_run["score"]
|
|
48
|
+
sign = "+" if diff >= 0 else ""
|
|
49
|
+
color = "green" if diff > 0 else ("red" if diff < 0 else "dim")
|
|
50
|
+
score_text.append(f" {sign}{diff} since last run", style=color)
|
|
51
|
+
|
|
52
|
+
console.print(Panel(header, title="repo", expand=False))
|
|
53
|
+
console.print(Panel(score_text, title="overall health score", expand=False))
|
|
54
|
+
|
|
55
|
+
table = Table(show_header=True, header_style="bold")
|
|
56
|
+
table.add_column("Check")
|
|
57
|
+
table.add_column("Score", justify="right")
|
|
58
|
+
table.add_column("Grade", justify="center")
|
|
59
|
+
if verbose:
|
|
60
|
+
table.add_column("Details")
|
|
61
|
+
|
|
62
|
+
for check in report.checks:
|
|
63
|
+
score_str = str(check.score)
|
|
64
|
+
if last_run and check.name in last_run.get("checks", {}):
|
|
65
|
+
prev = last_run["checks"][check.name]
|
|
66
|
+
diff = check.score - prev
|
|
67
|
+
if diff != 0:
|
|
68
|
+
sign = "+" if diff > 0 else ""
|
|
69
|
+
color = "green" if diff > 0 else "red"
|
|
70
|
+
score_str = f"{check.score} [{color}]({sign}{diff})[/{color}]"
|
|
71
|
+
|
|
72
|
+
row = [check.name, score_str, _grade_text(check.grade)]
|
|
73
|
+
if verbose:
|
|
74
|
+
row.append("\n".join(f"• {d}" for d in check.details))
|
|
75
|
+
table.add_row(*row)
|
|
76
|
+
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
if not verbose:
|
|
80
|
+
console.print("[dim]Run with -v for detailed reasoning behind each score.[/dim]")
|
|
81
|
+
|
|
82
|
+
if not os.environ.get("GITHUB_TOKEN"):
|
|
83
|
+
console.print(
|
|
84
|
+
"[dim]Tip: set GITHUB_TOKEN to raise your API rate limit from 60/hr to 5000/hr.[/dim]"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_fix(report) -> None:
|
|
89
|
+
suggestions = report.fix_suggestions()
|
|
90
|
+
if not suggestions:
|
|
91
|
+
console.print("[green bold]Nothing to fix — all checks are at 90+![/green bold]")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
console.print(f"\n[bold]Fix checklist for {report.repo.full_name}[/bold]\n")
|
|
95
|
+
for check_name, hints in suggestions:
|
|
96
|
+
check = next(c for c in report.checks if c.name == check_name)
|
|
97
|
+
color = GRADE_COLORS.get(check.grade, "white")
|
|
98
|
+
console.print(f"[{color}]{check_name} ({check.score}/100)[/{color}]")
|
|
99
|
+
for hint in hints:
|
|
100
|
+
console.print(f" [ ] {hint}")
|
|
101
|
+
console.print()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _render_compare(report_a, report_b) -> None:
|
|
105
|
+
console.print(f"\n[bold]Comparing repos[/bold]\n")
|
|
106
|
+
|
|
107
|
+
table = Table(show_header=True, header_style="bold")
|
|
108
|
+
table.add_column("Check")
|
|
109
|
+
table.add_column(report_a.repo.full_name, justify="right")
|
|
110
|
+
table.add_column(report_b.repo.full_name, justify="right")
|
|
111
|
+
|
|
112
|
+
all_checks = {c.name for c in report_a.checks} | {c.name for c in report_b.checks}
|
|
113
|
+
map_a = {c.name: c for c in report_a.checks}
|
|
114
|
+
map_b = {c.name: c for c in report_b.checks}
|
|
115
|
+
|
|
116
|
+
for name in sorted(all_checks):
|
|
117
|
+
ca = map_a.get(name)
|
|
118
|
+
cb = map_b.get(name)
|
|
119
|
+
score_a = _grade_text(ca.grade) if ca else Text("—", style="dim")
|
|
120
|
+
score_b = _grade_text(cb.grade) if cb else Text("—", style="dim")
|
|
121
|
+
if ca and cb:
|
|
122
|
+
score_a = Text(f"{ca.score} ({ca.grade})", style=f"bold {GRADE_COLORS.get(ca.grade, 'white')}")
|
|
123
|
+
score_b = Text(f"{cb.score} ({cb.grade})", style=f"bold {GRADE_COLORS.get(cb.grade, 'white')}")
|
|
124
|
+
table.add_row(name, score_a, score_b)
|
|
125
|
+
|
|
126
|
+
# Overall row
|
|
127
|
+
overall_a = Text(f"{report_a.overall_score} ({report_a.overall_grade})", style=f"bold {GRADE_COLORS.get(report_a.overall_grade, 'white')}")
|
|
128
|
+
overall_b = Text(f"{report_b.overall_score} ({report_b.overall_grade})", style=f"bold {GRADE_COLORS.get(report_b.overall_grade, 'white')}")
|
|
129
|
+
table.add_section()
|
|
130
|
+
table.add_row("OVERALL", overall_a, overall_b)
|
|
131
|
+
|
|
132
|
+
console.print(table)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render_org(results: list[tuple[RepoRef, int, str]]) -> None:
|
|
136
|
+
table = Table(show_header=True, header_style="bold", title="Org Health Report")
|
|
137
|
+
table.add_column("Repo")
|
|
138
|
+
table.add_column("Score", justify="right")
|
|
139
|
+
table.add_column("Grade", justify="center")
|
|
140
|
+
|
|
141
|
+
for ref, score, grade in results:
|
|
142
|
+
color = GRADE_COLORS.get(grade, "white")
|
|
143
|
+
table.add_row(ref.name, str(score), Text(grade, style=f"bold {color}"))
|
|
144
|
+
|
|
145
|
+
console.print(table)
|
|
146
|
+
avg = round(sum(s for _, s, _ in results) / len(results)) if results else 0
|
|
147
|
+
console.print(f"\n[bold]Org average: {avg}/100[/bold]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@click.command()
|
|
151
|
+
@click.argument("repo", required=False, default=None)
|
|
152
|
+
@click.option("--token", envvar="GITHUB_TOKEN", help="GitHub personal access token.")
|
|
153
|
+
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON.")
|
|
154
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed reasoning for every check.")
|
|
155
|
+
@click.option("--min-score", type=int, default=None, help="Exit with code 1 if score is below this threshold.")
|
|
156
|
+
@click.option("--compare", default=None, metavar="REPO2", help="Compare against a second repo.")
|
|
157
|
+
@click.option("--org", default=None, metavar="ORG", help="Scan all public repos in a GitHub org.")
|
|
158
|
+
@click.option("--fix", "show_fix", is_flag=True, help="Print a checklist of improvements.")
|
|
159
|
+
@click.option("--md", "as_markdown", is_flag=True, help="Output a markdown report.")
|
|
160
|
+
@click.option("--limit", type=int, default=20, show_default=True, help="Max repos to scan with --org.")
|
|
161
|
+
@click.version_option()
|
|
162
|
+
def main(repo, token, as_json, verbose, min_score, compare, org, show_fix, as_markdown, limit):
|
|
163
|
+
"""Scan a GitHub REPO (owner/repo or URL) and print a health report card.
|
|
164
|
+
|
|
165
|
+
\b
|
|
166
|
+
Examples:
|
|
167
|
+
repocheck pallets/flask
|
|
168
|
+
repocheck pallets/flask --compare psf/requests
|
|
169
|
+
repocheck pallets/flask --fix
|
|
170
|
+
repocheck pallets/flask --md > report.md
|
|
171
|
+
repocheck pallets/flask --min-score 80
|
|
172
|
+
repocheck --org pallets
|
|
173
|
+
"""
|
|
174
|
+
if not repo and not org:
|
|
175
|
+
raise click.UsageError("Provide a REPO argument or use --org ORG.")
|
|
176
|
+
|
|
177
|
+
client = GitHubClient(token=token)
|
|
178
|
+
|
|
179
|
+
# --org mode: scan all public repos in an org
|
|
180
|
+
if org:
|
|
181
|
+
with console.status(f"[bold blue]Fetching repos for {org}..."):
|
|
182
|
+
repos = client.org_repos(org, per_page=min(limit, 100))
|
|
183
|
+
|
|
184
|
+
if not repos:
|
|
185
|
+
console.print(f"[red]No public repos found for org: {org}[/red]")
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
repos = repos[:limit]
|
|
189
|
+
results = []
|
|
190
|
+
for r in repos:
|
|
191
|
+
ref = RepoRef(r["owner"]["login"], r["name"])
|
|
192
|
+
try:
|
|
193
|
+
with console.status(f"Scanning {ref.full_name}..."):
|
|
194
|
+
report = run_report(client, ref)
|
|
195
|
+
results.append((ref, report.overall_score, report.overall_grade))
|
|
196
|
+
except GitHubError:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
200
|
+
_render_org(results)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Normal single-repo mode
|
|
204
|
+
try:
|
|
205
|
+
ref = parse_repo_arg(repo)
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
with console.status(f"[bold blue]Scanning {ref.full_name}..."):
|
|
212
|
+
report = run_report(client, ref)
|
|
213
|
+
except GitHubError as e:
|
|
214
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
# --compare mode
|
|
218
|
+
if compare:
|
|
219
|
+
try:
|
|
220
|
+
ref2 = parse_repo_arg(compare)
|
|
221
|
+
with console.status(f"[bold blue]Scanning {ref2.full_name}..."):
|
|
222
|
+
report2 = run_report(client, ref2)
|
|
223
|
+
_render_compare(report, report2)
|
|
224
|
+
except (ValueError, GitHubError) as e:
|
|
225
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
226
|
+
sys.exit(1)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
last_run = get_last_run(ref.full_name)
|
|
230
|
+
save_run(ref.full_name, report.overall_score, [{"name": c.name, "score": c.score} for c in report.checks])
|
|
231
|
+
|
|
232
|
+
if as_json:
|
|
233
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
234
|
+
elif as_markdown:
|
|
235
|
+
print(report.to_markdown())
|
|
236
|
+
else:
|
|
237
|
+
_render(report, verbose=verbose, last_run=last_run)
|
|
238
|
+
if show_fix:
|
|
239
|
+
_render_fix(report)
|
|
240
|
+
|
|
241
|
+
if min_score is not None and report.overall_score < min_score:
|
|
242
|
+
console.print(f"\n[red bold]Score {report.overall_score} is below --min-score {min_score}. Exiting with code 1.[/red bold]")
|
|
243
|
+
sys.exit(1)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
main()
|
|
@@ -123,3 +123,18 @@ class GitHubClient:
|
|
|
123
123
|
if not isinstance(data, list):
|
|
124
124
|
return []
|
|
125
125
|
return [item["name"] for item in data if item["type"] == "dir"]
|
|
126
|
+
|
|
127
|
+
def branch_protection(self, ref: RepoRef, branch: str = "main") -> Optional[dict[str, Any]]:
|
|
128
|
+
try:
|
|
129
|
+
return self._get(f"/repos/{ref.full_name}/branches/{branch}/protection").json()
|
|
130
|
+
except (GitHubError, requests.exceptions.HTTPError):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def org_repos(self, org: str, per_page: int = 100) -> list[dict[str, Any]]:
|
|
134
|
+
try:
|
|
135
|
+
return self._get(
|
|
136
|
+
f"/orgs/{org}/repos",
|
|
137
|
+
params={"per_page": per_page, "type": "public", "sort": "updated"},
|
|
138
|
+
).json()
|
|
139
|
+
except GitHubError:
|
|
140
|
+
return []
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Local run history — stores scores in ~/.repocheck/history.json."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
HISTORY_FILE = Path.home() / ".repocheck" / "history.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load() -> dict:
|
|
13
|
+
if not HISTORY_FILE.exists():
|
|
14
|
+
return {}
|
|
15
|
+
try:
|
|
16
|
+
return json.loads(HISTORY_FILE.read_text())
|
|
17
|
+
except Exception:
|
|
18
|
+
return {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _save(data: dict) -> None:
|
|
22
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
HISTORY_FILE.write_text(json.dumps(data, indent=2))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_last_run(repo: str) -> Optional[dict]:
|
|
27
|
+
runs = _load().get(repo, [])
|
|
28
|
+
return runs[-1] if runs else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_run(repo: str, score: int, checks: list[dict]) -> None:
|
|
32
|
+
data = _load()
|
|
33
|
+
runs = data.setdefault(repo, [])
|
|
34
|
+
runs.append({
|
|
35
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
36
|
+
"score": score,
|
|
37
|
+
"checks": {c["name"]: c["score"] for c in checks},
|
|
38
|
+
})
|
|
39
|
+
data[repo] = runs[-20:]
|
|
40
|
+
_save(data)
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
"""Aggregates individual CheckResults into an overall report."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass
|
|
5
5
|
|
|
6
6
|
from .checks import CHECKS, CheckResult
|
|
7
7
|
from .github_client import GitHubClient, RepoRef
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
_FIX_SIGNALS = ("No ", "consider", "appears inactive", "low-effort", "Short ")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _is_actionable(detail: str) -> bool:
|
|
14
|
+
return any(signal in detail for signal in _FIX_SIGNALS)
|
|
15
|
+
|
|
16
|
+
|
|
10
17
|
@dataclass
|
|
11
18
|
class Report:
|
|
12
19
|
repo: RepoRef
|
|
@@ -34,6 +41,38 @@ class Report:
|
|
|
34
41
|
],
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
def to_markdown(self) -> str:
|
|
45
|
+
lines = [
|
|
46
|
+
f"# repocheck: {self.repo.full_name}",
|
|
47
|
+
"",
|
|
48
|
+
f"**Overall: {self.overall_score}/100 ({self.overall_grade})** ",
|
|
49
|
+
f"★ {self.repo_meta.get('stargazers_count', 0)} "
|
|
50
|
+
f"⑂ {self.repo_meta.get('forks_count', 0)} "
|
|
51
|
+
f"{self.repo_meta.get('language') or 'unknown'}",
|
|
52
|
+
"",
|
|
53
|
+
"| Check | Score | Grade |",
|
|
54
|
+
"|---|---|---|",
|
|
55
|
+
]
|
|
56
|
+
for c in self.checks:
|
|
57
|
+
lines.append(f"| {c.name} | {c.score} | {c.grade} |")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("## Details")
|
|
60
|
+
for c in self.checks:
|
|
61
|
+
lines.append(f"\n### {c.name} — {c.score}/100 ({c.grade})")
|
|
62
|
+
for d in c.details:
|
|
63
|
+
lines.append(f"- {d}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
|
|
66
|
+
def fix_suggestions(self) -> list[tuple[str, list[str]]]:
|
|
67
|
+
suggestions = []
|
|
68
|
+
for c in sorted(self.checks, key=lambda x: x.score):
|
|
69
|
+
if c.score >= 90:
|
|
70
|
+
continue
|
|
71
|
+
hints = [d for d in c.details if _is_actionable(d)]
|
|
72
|
+
if hints:
|
|
73
|
+
suggestions.append((c.name, hints))
|
|
74
|
+
return suggestions
|
|
75
|
+
|
|
37
76
|
|
|
38
77
|
def run_report(client: GitHubClient, ref: RepoRef) -> Report:
|
|
39
78
|
repo_meta = client.repo(ref)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repocheck-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Scan any GitHub repo and get an instant engineering health report card.
|
|
5
5
|
Author: Syed Ahmed Ali
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
# repocheck
|
|
24
24
|
|
|
25
|
+
[](https://pypi.org/project/repocheck-cli/)
|
|
26
|
+
|
|
25
27
|
Scan any public GitHub repo and get an instant engineering health report card — README quality, CI/CD, test presence, dependency hygiene, license, commit activity, and issue hygiene, scored A–F.
|
|
26
28
|
|
|
27
29
|
```
|
|
@@ -74,7 +74,7 @@ def test_end_to_end_report_with_mocked_api():
|
|
|
74
74
|
assert report.repo.full_name == "acme/widget"
|
|
75
75
|
assert 0 <= report.overall_score <= 100
|
|
76
76
|
assert report.overall_grade in ("A", "B", "C", "D", "F")
|
|
77
|
-
assert len(report.checks) ==
|
|
77
|
+
assert len(report.checks) == 11
|
|
78
78
|
|
|
79
79
|
as_dict = report.to_dict()
|
|
80
80
|
# Confirm it's JSON-serializable end to end, like --json mode produces
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
"""repocheck CLI."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
import click
|
|
8
|
-
from rich.console import Console
|
|
9
|
-
from rich.panel import Panel
|
|
10
|
-
from rich.table import Table
|
|
11
|
-
from rich.text import Text
|
|
12
|
-
|
|
13
|
-
from .github_client import GitHubClient, GitHubError, parse_repo_arg
|
|
14
|
-
from .report import run_report
|
|
15
|
-
|
|
16
|
-
console = Console()
|
|
17
|
-
|
|
18
|
-
GRADE_COLORS = {
|
|
19
|
-
"A": "green",
|
|
20
|
-
"B": "cyan",
|
|
21
|
-
"C": "yellow",
|
|
22
|
-
"D": "orange3",
|
|
23
|
-
"F": "red",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _grade_text(grade: str) -> Text:
|
|
28
|
-
color = GRADE_COLORS.get(grade, "white")
|
|
29
|
-
return Text(grade, style=f"bold {color}")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@click.command()
|
|
33
|
-
@click.argument("repo")
|
|
34
|
-
@click.option("--token", envvar="GITHUB_TOKEN", help="GitHub personal access token (or set GITHUB_TOKEN env var).")
|
|
35
|
-
@click.option("--json", "as_json", is_flag=True, help="Output raw JSON instead of a formatted report.")
|
|
36
|
-
@click.option("--verbose", "-v", is_flag=True, help="Show detailed reasoning for every check.")
|
|
37
|
-
@click.version_option()
|
|
38
|
-
def main(repo: str, token: str | None, as_json: bool, verbose: bool):
|
|
39
|
-
"""Scan a GitHub REPO (owner/repo or URL) and print a health report card.
|
|
40
|
-
|
|
41
|
-
\b
|
|
42
|
-
Examples:
|
|
43
|
-
repocheck pallets/flask
|
|
44
|
-
repocheck https://github.com/psf/requests
|
|
45
|
-
repocheck torvalds/linux --json
|
|
46
|
-
"""
|
|
47
|
-
try:
|
|
48
|
-
ref = parse_repo_arg(repo)
|
|
49
|
-
except ValueError as e:
|
|
50
|
-
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
51
|
-
sys.exit(1)
|
|
52
|
-
|
|
53
|
-
client = GitHubClient(token=token)
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
with console.status(f"[bold blue]Scanning {ref.full_name}..."):
|
|
57
|
-
report = run_report(client, ref)
|
|
58
|
-
except GitHubError as e:
|
|
59
|
-
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
60
|
-
sys.exit(1)
|
|
61
|
-
|
|
62
|
-
if as_json:
|
|
63
|
-
print(json.dumps(report.to_dict(), indent=2))
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
_render(report, verbose=verbose)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _render(report, verbose: bool):
|
|
70
|
-
grade_color = GRADE_COLORS.get(report.overall_grade, "white")
|
|
71
|
-
header = Text()
|
|
72
|
-
header.append(f"{report.repo.full_name}\n", style="bold")
|
|
73
|
-
header.append(f"★ {report.repo_meta.get('stargazers_count', 0)} ", style="dim")
|
|
74
|
-
header.append(f"⑂ {report.repo_meta.get('forks_count', 0)} ", style="dim")
|
|
75
|
-
header.append(f"{report.repo_meta.get('language') or 'unknown'}", style="dim")
|
|
76
|
-
|
|
77
|
-
score_text = Text()
|
|
78
|
-
score_text.append(f"{report.overall_score}/100 ", style=f"bold {grade_color}")
|
|
79
|
-
score_text.append(f"({report.overall_grade})", style=f"bold {grade_color}")
|
|
80
|
-
|
|
81
|
-
console.print(Panel(header, title="repo", expand=False))
|
|
82
|
-
console.print(Panel(score_text, title="overall health score", expand=False))
|
|
83
|
-
|
|
84
|
-
table = Table(show_header=True, header_style="bold")
|
|
85
|
-
table.add_column("Check")
|
|
86
|
-
table.add_column("Score", justify="right")
|
|
87
|
-
table.add_column("Grade", justify="center")
|
|
88
|
-
if verbose:
|
|
89
|
-
table.add_column("Details")
|
|
90
|
-
|
|
91
|
-
for check in report.checks:
|
|
92
|
-
row = [check.name, str(check.score), _grade_text(check.grade)]
|
|
93
|
-
if verbose:
|
|
94
|
-
row.append("\n".join(f"• {d}" for d in check.details))
|
|
95
|
-
table.add_row(*row)
|
|
96
|
-
|
|
97
|
-
console.print(table)
|
|
98
|
-
|
|
99
|
-
if not verbose:
|
|
100
|
-
console.print("[dim]Run with -v for detailed reasoning behind each score.[/dim]")
|
|
101
|
-
|
|
102
|
-
if client_rate_hint(report):
|
|
103
|
-
console.print(
|
|
104
|
-
"[dim]Tip: set GITHUB_TOKEN to raise your API rate limit from 60/hr to 5000/hr.[/dim]"
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def client_rate_hint(report) -> bool:
|
|
109
|
-
import os
|
|
110
|
-
return not os.environ.get("GITHUB_TOKEN")
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if __name__ == "__main__":
|
|
114
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|