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.
Files changed (21) hide show
  1. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/PKG-INFO +3 -1
  2. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/README.md +2 -0
  3. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/pyproject.toml +1 -1
  4. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/checks.py +110 -0
  5. repocheck_cli-0.2.0/repocheck/cli.py +247 -0
  6. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/github_client.py +15 -0
  7. repocheck_cli-0.2.0/repocheck/history.py +40 -0
  8. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/report.py +40 -1
  9. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/PKG-INFO +3 -1
  10. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/SOURCES.txt +1 -0
  11. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/tests/test_end_to_end.py +1 -1
  12. repocheck_cli-0.1.0/repocheck/cli.py +0 -114
  13. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/LICENSE +0 -0
  14. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck/__init__.py +0 -0
  15. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/dependency_links.txt +0 -0
  16. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/entry_points.txt +0 -0
  17. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/requires.txt +0 -0
  18. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/repocheck_cli.egg-info/top_level.txt +0 -0
  19. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/setup.cfg +0 -0
  20. {repocheck_cli-0.1.0 → repocheck_cli-0.2.0}/tests/test_checks.py +0 -0
  21. {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.1.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
+ [![PyPI](https://img.shields.io/pypi/v/repocheck-cli)](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
+ [![PyPI](https://img.shields.io/pypi/v/repocheck-cli)](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
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "repocheck-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Scan any GitHub repo and get an instant engineering health report card."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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, field
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.1.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
+ [![PyPI](https://img.shields.io/pypi/v/repocheck-cli)](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
  ```
@@ -5,6 +5,7 @@ repocheck/__init__.py
5
5
  repocheck/checks.py
6
6
  repocheck/cli.py
7
7
  repocheck/github_client.py
8
+ repocheck/history.py
8
9
  repocheck/report.py
9
10
  repocheck_cli.egg-info/PKG-INFO
10
11
  repocheck_cli.egg-info/SOURCES.txt
@@ -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) == 7
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