repocheck-cli 0.1.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/LICENSE +21 -0
- repocheck_cli-0.1.0/PKG-INFO +110 -0
- repocheck_cli-0.1.0/README.md +88 -0
- repocheck_cli-0.1.0/pyproject.toml +35 -0
- repocheck_cli-0.1.0/repocheck/__init__.py +3 -0
- repocheck_cli-0.1.0/repocheck/checks.py +293 -0
- repocheck_cli-0.1.0/repocheck/cli.py +114 -0
- repocheck_cli-0.1.0/repocheck/github_client.py +125 -0
- repocheck_cli-0.1.0/repocheck/report.py +53 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/PKG-INFO +110 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/SOURCES.txt +17 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/dependency_links.txt +1 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/entry_points.txt +2 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/requires.txt +3 -0
- repocheck_cli-0.1.0/repocheck_cli.egg-info/top_level.txt +1 -0
- repocheck_cli-0.1.0/setup.cfg +4 -0
- repocheck_cli-0.1.0/tests/test_checks.py +204 -0
- repocheck_cli-0.1.0/tests/test_end_to_end.py +88 -0
- repocheck_cli-0.1.0/tests/test_github_client.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syed Ahmed Ali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repocheck-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan any GitHub repo and get an instant engineering health report card.
|
|
5
|
+
Author: Syed Ahmed Ali
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Ahmed0754/repocheck
|
|
8
|
+
Project-URL: Repository, https://github.com/Ahmed0754/repocheck
|
|
9
|
+
Project-URL: Issues, https://github.com/Ahmed0754/repocheck/issues
|
|
10
|
+
Keywords: github,cli,code-quality,devtools,ci
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: click>=8.1
|
|
19
|
+
Requires-Dist: requests>=2.31
|
|
20
|
+
Requires-Dist: rich>=13.7
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# repocheck
|
|
24
|
+
|
|
25
|
+
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
|
+
|
|
27
|
+
```
|
|
28
|
+
$ repocheck pallets/flask
|
|
29
|
+
|
|
30
|
+
╭─ repo ──────────────────╮
|
|
31
|
+
│ pallets/flask │
|
|
32
|
+
│ ★ 68234 ⑂ 16200 Python│
|
|
33
|
+
╰──────────────────────────╯
|
|
34
|
+
╭─ overall health score ──╮
|
|
35
|
+
│ 91/100 (A) │
|
|
36
|
+
╰───────────────────────────╯
|
|
37
|
+
|
|
38
|
+
Check Score Grade
|
|
39
|
+
README Quality 100 A
|
|
40
|
+
CI/CD 90 A
|
|
41
|
+
Test Presence 85 B
|
|
42
|
+
Dependency Hygiene 80 B
|
|
43
|
+
License 100 A
|
|
44
|
+
Commit Activity 95 A
|
|
45
|
+
Issue Hygiene 85 B
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install repocheck-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# owner/repo shorthand
|
|
58
|
+
repocheck pallets/flask
|
|
59
|
+
|
|
60
|
+
# full URL also works
|
|
61
|
+
repocheck https://github.com/psf/requests
|
|
62
|
+
|
|
63
|
+
# detailed reasoning behind every score
|
|
64
|
+
repocheck torvalds/linux -v
|
|
65
|
+
|
|
66
|
+
# machine-readable output (for CI pipelines, scripts)
|
|
67
|
+
repocheck your-org/your-repo --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Higher rate limits
|
|
71
|
+
|
|
72
|
+
GitHub's anonymous API limit is 60 requests/hour. `repocheck` makes ~8 requests per scan, so you'll hit that fast without auth. Set a token to bump it to 5,000/hour:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
76
|
+
repocheck your-org/your-repo
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
No special scopes needed — a basic personal access token works fine for public repos.
|
|
80
|
+
|
|
81
|
+
## What it checks
|
|
82
|
+
|
|
83
|
+
| Check | What it looks at |
|
|
84
|
+
|---|---|
|
|
85
|
+
| **README Quality** | Presence, length, install/usage sections, badges, code blocks |
|
|
86
|
+
| **CI/CD** | GitHub Actions workflows, legacy CI configs |
|
|
87
|
+
| **Test Presence** | Test directories, test config files, declared test scripts |
|
|
88
|
+
| **Dependency Hygiene** | Manifest presence, version pinning, lockfiles |
|
|
89
|
+
| **License** | OSS license presence and type |
|
|
90
|
+
| **Commit Activity** | Recency and message quality of recent commits |
|
|
91
|
+
| **Issue Hygiene** | Open issue count and staleness |
|
|
92
|
+
|
|
93
|
+
Each check is scored 0–100 and the overall score is an average across all seven.
|
|
94
|
+
|
|
95
|
+
## Why
|
|
96
|
+
|
|
97
|
+
Most "is this repo any good" judgments are vibes-based — a glance at stars, maybe the README. `repocheck` turns that into a repeatable, objective-ish checklist you can run on your own repos before sharing them, or use to quickly evaluate a dependency before adopting it.
|
|
98
|
+
|
|
99
|
+
## Local development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/Ahmed0754/repocheck
|
|
103
|
+
cd repocheck
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
repocheck pallets/flask
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# repocheck
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
```
|
|
6
|
+
$ repocheck pallets/flask
|
|
7
|
+
|
|
8
|
+
╭─ repo ──────────────────╮
|
|
9
|
+
│ pallets/flask │
|
|
10
|
+
│ ★ 68234 ⑂ 16200 Python│
|
|
11
|
+
╰──────────────────────────╯
|
|
12
|
+
╭─ overall health score ──╮
|
|
13
|
+
│ 91/100 (A) │
|
|
14
|
+
╰───────────────────────────╯
|
|
15
|
+
|
|
16
|
+
Check Score Grade
|
|
17
|
+
README Quality 100 A
|
|
18
|
+
CI/CD 90 A
|
|
19
|
+
Test Presence 85 B
|
|
20
|
+
Dependency Hygiene 80 B
|
|
21
|
+
License 100 A
|
|
22
|
+
Commit Activity 95 A
|
|
23
|
+
Issue Hygiene 85 B
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install repocheck-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# owner/repo shorthand
|
|
36
|
+
repocheck pallets/flask
|
|
37
|
+
|
|
38
|
+
# full URL also works
|
|
39
|
+
repocheck https://github.com/psf/requests
|
|
40
|
+
|
|
41
|
+
# detailed reasoning behind every score
|
|
42
|
+
repocheck torvalds/linux -v
|
|
43
|
+
|
|
44
|
+
# machine-readable output (for CI pipelines, scripts)
|
|
45
|
+
repocheck your-org/your-repo --json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Higher rate limits
|
|
49
|
+
|
|
50
|
+
GitHub's anonymous API limit is 60 requests/hour. `repocheck` makes ~8 requests per scan, so you'll hit that fast without auth. Set a token to bump it to 5,000/hour:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
54
|
+
repocheck your-org/your-repo
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
No special scopes needed — a basic personal access token works fine for public repos.
|
|
58
|
+
|
|
59
|
+
## What it checks
|
|
60
|
+
|
|
61
|
+
| Check | What it looks at |
|
|
62
|
+
|---|---|
|
|
63
|
+
| **README Quality** | Presence, length, install/usage sections, badges, code blocks |
|
|
64
|
+
| **CI/CD** | GitHub Actions workflows, legacy CI configs |
|
|
65
|
+
| **Test Presence** | Test directories, test config files, declared test scripts |
|
|
66
|
+
| **Dependency Hygiene** | Manifest presence, version pinning, lockfiles |
|
|
67
|
+
| **License** | OSS license presence and type |
|
|
68
|
+
| **Commit Activity** | Recency and message quality of recent commits |
|
|
69
|
+
| **Issue Hygiene** | Open issue count and staleness |
|
|
70
|
+
|
|
71
|
+
Each check is scored 0–100 and the overall score is an average across all seven.
|
|
72
|
+
|
|
73
|
+
## Why
|
|
74
|
+
|
|
75
|
+
Most "is this repo any good" judgments are vibes-based — a glance at stars, maybe the README. `repocheck` turns that into a repeatable, objective-ish checklist you can run on your own repos before sharing them, or use to quickly evaluate a dependency before adopting it.
|
|
76
|
+
|
|
77
|
+
## Local development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/Ahmed0754/repocheck
|
|
81
|
+
cd repocheck
|
|
82
|
+
pip install -e ".[dev]"
|
|
83
|
+
repocheck pallets/flask
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "repocheck-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scan any GitHub repo and get an instant engineering health report card."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Syed Ahmed Ali" }]
|
|
13
|
+
keywords = ["github", "cli", "code-quality", "devtools", "ci"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"click>=8.1",
|
|
22
|
+
"requests>=2.31",
|
|
23
|
+
"rich>=13.7",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Ahmed0754/repocheck"
|
|
28
|
+
Repository = "https://github.com/Ahmed0754/repocheck"
|
|
29
|
+
Issues = "https://github.com/Ahmed0754/repocheck/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
repocheck = "repocheck.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = ["repocheck*"]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Individual health checks. Each returns a CheckResult with a 0-100 score."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .github_client import GitHubClient, RepoRef
|
|
10
|
+
|
|
11
|
+
DEPENDENCY_FILES = (
|
|
12
|
+
"requirements.txt",
|
|
13
|
+
"package.json",
|
|
14
|
+
"Pipfile",
|
|
15
|
+
"pyproject.toml",
|
|
16
|
+
"go.mod",
|
|
17
|
+
"Cargo.toml",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
TEST_DIR_HINTS = ("test", "tests", "__tests__", "spec")
|
|
21
|
+
TEST_CONFIG_HINTS = (
|
|
22
|
+
"pytest.ini",
|
|
23
|
+
"setup.cfg",
|
|
24
|
+
"tox.ini",
|
|
25
|
+
"jest.config.js",
|
|
26
|
+
"jest.config.ts",
|
|
27
|
+
"vitest.config.ts",
|
|
28
|
+
"phpunit.xml",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CheckResult:
|
|
34
|
+
name: str
|
|
35
|
+
score: int # 0-100
|
|
36
|
+
grade: str
|
|
37
|
+
details: list[str] = field(default_factory=list)
|
|
38
|
+
weight: float = 1.0
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def grade_for(score: int) -> str:
|
|
42
|
+
if score >= 90:
|
|
43
|
+
return "A"
|
|
44
|
+
if score >= 80:
|
|
45
|
+
return "B"
|
|
46
|
+
if score >= 70:
|
|
47
|
+
return "C"
|
|
48
|
+
if score >= 60:
|
|
49
|
+
return "D"
|
|
50
|
+
return "F"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _result(name: str, score: int, details: list[str], weight: float = 1.0) -> CheckResult:
|
|
54
|
+
score = max(0, min(100, score))
|
|
55
|
+
return CheckResult(name=name, score=score, grade=CheckResult.grade_for(score), details=details, weight=weight)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Individual checks
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def check_readme(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
63
|
+
files = client.root_files(ref)
|
|
64
|
+
readme_name = next((f for f in files if f.lower().startswith("readme")), None)
|
|
65
|
+
|
|
66
|
+
if not readme_name:
|
|
67
|
+
return _result("README", 0, ["No README file found at repo root."])
|
|
68
|
+
|
|
69
|
+
text = client.file_text(ref, readme_name) or ""
|
|
70
|
+
details = [f"Found {readme_name} ({len(text)} chars)."]
|
|
71
|
+
score = 30 # baseline for existing
|
|
72
|
+
|
|
73
|
+
length = len(text)
|
|
74
|
+
if length > 300:
|
|
75
|
+
score += 15
|
|
76
|
+
details.append("Has substantive content (>300 chars).")
|
|
77
|
+
else:
|
|
78
|
+
details.append("Very short — consider expanding (currently <300 chars).")
|
|
79
|
+
|
|
80
|
+
lower = text.lower()
|
|
81
|
+
|
|
82
|
+
has_install = bool(re.search(r"\b(install|pip install|npm install|getting started|setup)\b", lower))
|
|
83
|
+
if has_install:
|
|
84
|
+
score += 20
|
|
85
|
+
details.append("Includes install/setup instructions.")
|
|
86
|
+
else:
|
|
87
|
+
details.append("No clear install/setup section found.")
|
|
88
|
+
|
|
89
|
+
has_usage = bool(re.search(r"\b(usage|example|quick ?start)\b", lower))
|
|
90
|
+
if has_usage:
|
|
91
|
+
score += 20
|
|
92
|
+
details.append("Includes usage/example section.")
|
|
93
|
+
else:
|
|
94
|
+
details.append("No usage or example section found.")
|
|
95
|
+
|
|
96
|
+
has_badges = "![" in text and ("shields.io" in text or "badge" in lower)
|
|
97
|
+
if has_badges:
|
|
98
|
+
score += 10
|
|
99
|
+
details.append("Has status badges (CI, version, etc).")
|
|
100
|
+
|
|
101
|
+
has_code_block = "```" in text
|
|
102
|
+
if has_code_block:
|
|
103
|
+
score += 5
|
|
104
|
+
details.append("Contains code blocks.")
|
|
105
|
+
|
|
106
|
+
return _result("README Quality", score, details)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_ci(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
110
|
+
workflows = client.workflows(ref)
|
|
111
|
+
if workflows:
|
|
112
|
+
names = [w["name"] for w in workflows if w.get("type") == "file"]
|
|
113
|
+
return _result(
|
|
114
|
+
"CI/CD",
|
|
115
|
+
90 if len(names) >= 1 else 70,
|
|
116
|
+
[f"GitHub Actions configured ({len(names)} workflow file(s)): {', '.join(names) or 'unnamed'}."],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
root_files = client.root_files(ref)
|
|
120
|
+
legacy = [f for f in root_files if f in (".travis.yml", ".circleci", "azure-pipelines.yml", ".gitlab-ci.yml")]
|
|
121
|
+
if legacy:
|
|
122
|
+
return _result("CI/CD", 60, [f"Legacy CI config found: {', '.join(legacy)}. Consider migrating to GitHub Actions."])
|
|
123
|
+
|
|
124
|
+
return _result("CI/CD", 0, ["No CI/CD configuration found (no .github/workflows, no legacy CI config)."])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_tests(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
128
|
+
dirs = [d.lower() for d in client.root_dirs(ref)]
|
|
129
|
+
files = [f.lower() for f in client.root_files(ref)]
|
|
130
|
+
|
|
131
|
+
has_test_dir = any(hint in dirs for hint in TEST_DIR_HINTS)
|
|
132
|
+
has_test_config = any(hint in files for hint in TEST_CONFIG_HINTS)
|
|
133
|
+
|
|
134
|
+
details = []
|
|
135
|
+
score = 0
|
|
136
|
+
|
|
137
|
+
if has_test_dir:
|
|
138
|
+
score += 60
|
|
139
|
+
matched = [d for d in dirs if d in TEST_DIR_HINTS]
|
|
140
|
+
details.append(f"Found test directory: {', '.join(matched)}.")
|
|
141
|
+
else:
|
|
142
|
+
details.append("No conventional test directory found (tests/, test/, __tests__/, spec/).")
|
|
143
|
+
|
|
144
|
+
if has_test_config:
|
|
145
|
+
score += 25
|
|
146
|
+
matched = [f for f in files if f in [h.lower() for h in TEST_CONFIG_HINTS]]
|
|
147
|
+
details.append(f"Found test config: {', '.join(matched)}.")
|
|
148
|
+
|
|
149
|
+
# package.json may declare a test script
|
|
150
|
+
pkg = client.file_text(ref, "package.json")
|
|
151
|
+
if pkg and '"test"' in pkg and "no test specified" not in pkg.lower():
|
|
152
|
+
score += 15
|
|
153
|
+
details.append("package.json declares a test script.")
|
|
154
|
+
|
|
155
|
+
if score == 0:
|
|
156
|
+
details.append("No evidence of automated testing.")
|
|
157
|
+
|
|
158
|
+
return _result("Test Presence", score, details)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_dependencies(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
162
|
+
files = client.root_files(ref)
|
|
163
|
+
found = [f for f in DEPENDENCY_FILES if f in files]
|
|
164
|
+
|
|
165
|
+
if not found:
|
|
166
|
+
return _result("Dependency Hygiene", 50, ["No standard dependency manifest found — may be dependency-free or non-standard layout."])
|
|
167
|
+
|
|
168
|
+
details = [f"Found manifest(s): {', '.join(found)}."]
|
|
169
|
+
score = 70 # baseline for having a manifest
|
|
170
|
+
|
|
171
|
+
# Check for pinned vs unpinned versions as a rough hygiene signal
|
|
172
|
+
if "requirements.txt" in found:
|
|
173
|
+
text = client.file_text(ref, "requirements.txt") or ""
|
|
174
|
+
lines = [l for l in text.splitlines() if l.strip() and not l.startswith("#")]
|
|
175
|
+
pinned = [l for l in lines if "==" in l]
|
|
176
|
+
if lines:
|
|
177
|
+
pin_ratio = len(pinned) / len(lines)
|
|
178
|
+
if pin_ratio > 0.7:
|
|
179
|
+
score += 15
|
|
180
|
+
details.append(f"{len(pinned)}/{len(lines)} dependencies are version-pinned.")
|
|
181
|
+
else:
|
|
182
|
+
details.append(f"Only {len(pinned)}/{len(lines)} dependencies are version-pinned — consider pinning for reproducibility.")
|
|
183
|
+
|
|
184
|
+
if "package.json" in found:
|
|
185
|
+
text = client.file_text(ref, "package.json") or ""
|
|
186
|
+
if '"dependencies"' in text or '"devDependencies"' in text:
|
|
187
|
+
score += 10
|
|
188
|
+
details.append("package.json declares dependencies.")
|
|
189
|
+
if "lock" in [f.lower() for f in files] or "package-lock.json" in files or "yarn.lock" in files:
|
|
190
|
+
score += 10
|
|
191
|
+
details.append("Lockfile present (reproducible installs).")
|
|
192
|
+
else:
|
|
193
|
+
details.append("No lockfile found (package-lock.json / yarn.lock) — installs may not be reproducible.")
|
|
194
|
+
|
|
195
|
+
return _result("Dependency Hygiene", score, details)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_license(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
199
|
+
repo_data = client.repo(ref)
|
|
200
|
+
license_info = repo_data.get("license")
|
|
201
|
+
if license_info:
|
|
202
|
+
return _result("License", 100, [f"Licensed under {license_info.get('name', license_info.get('spdx_id'))}."])
|
|
203
|
+
|
|
204
|
+
files = client.root_files(ref)
|
|
205
|
+
license_file = next((f for f in files if f.lower().startswith("license")), None)
|
|
206
|
+
if license_file:
|
|
207
|
+
return _result("License", 80, [f"License file present ({license_file}) but not detected by GitHub's API."])
|
|
208
|
+
|
|
209
|
+
return _result("License", 0, ["No license found. Repo is effectively 'all rights reserved' by default."])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_activity(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
213
|
+
commits = client.commits(ref, per_page=30)
|
|
214
|
+
if not commits:
|
|
215
|
+
return _result("Commit Activity", 0, ["No commit history accessible."])
|
|
216
|
+
|
|
217
|
+
details = []
|
|
218
|
+
score = 0
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
latest_date_str = commits[0]["commit"]["committer"]["date"]
|
|
222
|
+
latest_date = datetime.fromisoformat(latest_date_str.replace("Z", "+00:00"))
|
|
223
|
+
days_since = (datetime.now(timezone.utc) - latest_date).days
|
|
224
|
+
details.append(f"Last commit {days_since} day(s) ago.")
|
|
225
|
+
if days_since <= 30:
|
|
226
|
+
score += 50
|
|
227
|
+
elif days_since <= 180:
|
|
228
|
+
score += 30
|
|
229
|
+
elif days_since <= 365:
|
|
230
|
+
score += 15
|
|
231
|
+
else:
|
|
232
|
+
details.append("Repo appears inactive (no commits in over a year).")
|
|
233
|
+
except (KeyError, ValueError):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Commit message quality — rough heuristic: average length, non-trivial messages
|
|
237
|
+
messages = [c["commit"]["message"].splitlines()[0] for c in commits if c.get("commit")]
|
|
238
|
+
trivial = sum(1 for m in messages if m.strip().lower() in ("fix", "update", "wip", "test", "asdf", "."))
|
|
239
|
+
avg_len = sum(len(m) for m in messages) / len(messages) if messages else 0
|
|
240
|
+
|
|
241
|
+
if avg_len >= 20:
|
|
242
|
+
score += 30
|
|
243
|
+
details.append(f"Commit messages average {avg_len:.0f} chars — reasonably descriptive.")
|
|
244
|
+
else:
|
|
245
|
+
details.append(f"Commit messages average {avg_len:.0f} chars — consider more descriptive messages.")
|
|
246
|
+
|
|
247
|
+
if trivial / max(len(messages), 1) > 0.3:
|
|
248
|
+
details.append(f"{trivial}/{len(messages)} recent commits have low-effort messages (e.g. 'fix', 'wip').")
|
|
249
|
+
else:
|
|
250
|
+
score += 20
|
|
251
|
+
|
|
252
|
+
return _result("Commit Activity", score, details)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def check_issue_hygiene(client: GitHubClient, ref: RepoRef) -> CheckResult:
|
|
256
|
+
repo_data = client.repo(ref)
|
|
257
|
+
open_issues = repo_data.get("open_issues_count", 0)
|
|
258
|
+
|
|
259
|
+
if open_issues == 0:
|
|
260
|
+
return _result("Issue Hygiene", 100, ["No open issues — clean backlog (or issues disabled)."])
|
|
261
|
+
|
|
262
|
+
issues = client.issues(ref, state="open", per_page=50)
|
|
263
|
+
# Filter out PRs, which the issues endpoint includes
|
|
264
|
+
real_issues = [i for i in issues if "pull_request" not in i]
|
|
265
|
+
|
|
266
|
+
if not real_issues:
|
|
267
|
+
return _result("Issue Hygiene", 90, [f"{open_issues} open issue(s) reported, but none returned detail (possibly all PRs)."])
|
|
268
|
+
|
|
269
|
+
now = datetime.now(timezone.utc)
|
|
270
|
+
stale = 0
|
|
271
|
+
for issue in real_issues:
|
|
272
|
+
created = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
|
|
273
|
+
if (now - created).days > 90:
|
|
274
|
+
stale += 1
|
|
275
|
+
|
|
276
|
+
stale_ratio = stale / len(real_issues)
|
|
277
|
+
score = int(100 - (stale_ratio * 80))
|
|
278
|
+
details = [
|
|
279
|
+
f"{open_issues} open issue(s) total.",
|
|
280
|
+
f"{stale}/{len(real_issues)} sampled issues are older than 90 days.",
|
|
281
|
+
]
|
|
282
|
+
return _result("Issue Hygiene", score, details)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
CHECKS: list[Callable[[GitHubClient, RepoRef], CheckResult]] = [
|
|
286
|
+
check_readme,
|
|
287
|
+
check_ci,
|
|
288
|
+
check_tests,
|
|
289
|
+
check_dependencies,
|
|
290
|
+
check_license,
|
|
291
|
+
check_activity,
|
|
292
|
+
check_issue_hygiene,
|
|
293
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
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()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Thin GitHub REST API client used by all checks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
GITHUB_API = "https://api.github.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitHubError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RepoRef:
|
|
21
|
+
owner: str
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def full_name(self) -> str:
|
|
26
|
+
return f"{self.owner}/{self.name}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_repo_arg(arg: str) -> RepoRef:
|
|
30
|
+
"""Accepts 'owner/repo', a full GitHub URL, or a .git URL."""
|
|
31
|
+
arg = arg.strip()
|
|
32
|
+
|
|
33
|
+
# owner/repo shorthand
|
|
34
|
+
if re.fullmatch(r"[\w.-]+/[\w.-]+", arg):
|
|
35
|
+
owner, name = arg.split("/", 1)
|
|
36
|
+
return RepoRef(owner, name.removesuffix(".git"))
|
|
37
|
+
|
|
38
|
+
# full URL
|
|
39
|
+
m = re.search(r"github\.com[:/]+([\w.-]+)/([\w.-]+?)(?:\.git)?/?$", arg)
|
|
40
|
+
if m:
|
|
41
|
+
return RepoRef(m.group(1), m.group(2))
|
|
42
|
+
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Could not parse repo argument: {arg!r}. "
|
|
45
|
+
"Use 'owner/repo' or a github.com URL."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GitHubClient:
|
|
50
|
+
def __init__(self, token: Optional[str] = None):
|
|
51
|
+
self.token = token or os.environ.get("GITHUB_TOKEN")
|
|
52
|
+
self.session = requests.Session()
|
|
53
|
+
headers = {
|
|
54
|
+
"Accept": "application/vnd.github+json",
|
|
55
|
+
"User-Agent": "repocheck-cli",
|
|
56
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
57
|
+
}
|
|
58
|
+
if self.token:
|
|
59
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
60
|
+
self.session.headers.update(headers)
|
|
61
|
+
self.rate_remaining: Optional[int] = None
|
|
62
|
+
|
|
63
|
+
def _get(self, path: str, **kwargs) -> requests.Response:
|
|
64
|
+
resp = self.session.get(f"{GITHUB_API}{path}", **kwargs)
|
|
65
|
+
self.rate_remaining = resp.headers.get("X-RateLimit-Remaining")
|
|
66
|
+
if resp.status_code == 404:
|
|
67
|
+
raise GitHubError(f"Not found: {path}")
|
|
68
|
+
if resp.status_code == 403 and self.rate_remaining == "0":
|
|
69
|
+
raise GitHubError(
|
|
70
|
+
"GitHub API rate limit exceeded. Set GITHUB_TOKEN env var "
|
|
71
|
+
"for a higher limit (60/hr -> 5000/hr)."
|
|
72
|
+
)
|
|
73
|
+
resp.raise_for_status()
|
|
74
|
+
return resp
|
|
75
|
+
|
|
76
|
+
def repo(self, ref: RepoRef) -> dict[str, Any]:
|
|
77
|
+
return self._get(f"/repos/{ref.full_name}").json()
|
|
78
|
+
|
|
79
|
+
def contents(self, ref: RepoRef, path: str = "") -> Any:
|
|
80
|
+
try:
|
|
81
|
+
return self._get(f"/repos/{ref.full_name}/contents/{path}").json()
|
|
82
|
+
except GitHubError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def file_text(self, ref: RepoRef, path: str) -> Optional[str]:
|
|
86
|
+
data = self.contents(ref, path)
|
|
87
|
+
if not data or "content" not in data:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return base64.b64decode(data["content"]).decode("utf-8", errors="replace")
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def workflows(self, ref: RepoRef) -> list[dict[str, Any]]:
|
|
95
|
+
data = self.contents(ref, ".github/workflows")
|
|
96
|
+
return data if isinstance(data, list) else []
|
|
97
|
+
|
|
98
|
+
def commits(self, ref: RepoRef, per_page: int = 30) -> list[dict[str, Any]]:
|
|
99
|
+
try:
|
|
100
|
+
return self._get(
|
|
101
|
+
f"/repos/{ref.full_name}/commits", params={"per_page": per_page}
|
|
102
|
+
).json()
|
|
103
|
+
except GitHubError:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
def issues(self, ref: RepoRef, state: str = "open", per_page: int = 50) -> list[dict[str, Any]]:
|
|
107
|
+
try:
|
|
108
|
+
return self._get(
|
|
109
|
+
f"/repos/{ref.full_name}/issues",
|
|
110
|
+
params={"state": state, "per_page": per_page},
|
|
111
|
+
).json()
|
|
112
|
+
except GitHubError:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
def root_files(self, ref: RepoRef) -> list[str]:
|
|
116
|
+
data = self.contents(ref, "")
|
|
117
|
+
if not isinstance(data, list):
|
|
118
|
+
return []
|
|
119
|
+
return [item["name"] for item in data if item["type"] == "file"]
|
|
120
|
+
|
|
121
|
+
def root_dirs(self, ref: RepoRef) -> list[str]:
|
|
122
|
+
data = self.contents(ref, "")
|
|
123
|
+
if not isinstance(data, list):
|
|
124
|
+
return []
|
|
125
|
+
return [item["name"] for item in data if item["type"] == "dir"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Aggregates individual CheckResults into an overall report."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from .checks import CHECKS, CheckResult
|
|
7
|
+
from .github_client import GitHubClient, RepoRef
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Report:
|
|
12
|
+
repo: RepoRef
|
|
13
|
+
checks: list[CheckResult]
|
|
14
|
+
overall_score: int
|
|
15
|
+
overall_grade: str
|
|
16
|
+
repo_meta: dict
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"repo": self.repo.full_name,
|
|
21
|
+
"overall_score": self.overall_score,
|
|
22
|
+
"overall_grade": self.overall_grade,
|
|
23
|
+
"stars": self.repo_meta.get("stargazers_count"),
|
|
24
|
+
"forks": self.repo_meta.get("forks_count"),
|
|
25
|
+
"language": self.repo_meta.get("language"),
|
|
26
|
+
"checks": [
|
|
27
|
+
{
|
|
28
|
+
"name": c.name,
|
|
29
|
+
"score": c.score,
|
|
30
|
+
"grade": c.grade,
|
|
31
|
+
"details": c.details,
|
|
32
|
+
}
|
|
33
|
+
for c in self.checks
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_report(client: GitHubClient, ref: RepoRef) -> Report:
|
|
39
|
+
repo_meta = client.repo(ref)
|
|
40
|
+
results = [check_fn(client, ref) for check_fn in CHECKS]
|
|
41
|
+
|
|
42
|
+
total_weight = sum(r.weight for r in results)
|
|
43
|
+
weighted_sum = sum(r.score * r.weight for r in results)
|
|
44
|
+
overall_score = round(weighted_sum / total_weight) if total_weight else 0
|
|
45
|
+
overall_grade = CheckResult.grade_for(overall_score)
|
|
46
|
+
|
|
47
|
+
return Report(
|
|
48
|
+
repo=ref,
|
|
49
|
+
checks=results,
|
|
50
|
+
overall_score=overall_score,
|
|
51
|
+
overall_grade=overall_grade,
|
|
52
|
+
repo_meta=repo_meta,
|
|
53
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repocheck-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan any GitHub repo and get an instant engineering health report card.
|
|
5
|
+
Author: Syed Ahmed Ali
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Ahmed0754/repocheck
|
|
8
|
+
Project-URL: Repository, https://github.com/Ahmed0754/repocheck
|
|
9
|
+
Project-URL: Issues, https://github.com/Ahmed0754/repocheck/issues
|
|
10
|
+
Keywords: github,cli,code-quality,devtools,ci
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: click>=8.1
|
|
19
|
+
Requires-Dist: requests>=2.31
|
|
20
|
+
Requires-Dist: rich>=13.7
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# repocheck
|
|
24
|
+
|
|
25
|
+
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
|
+
|
|
27
|
+
```
|
|
28
|
+
$ repocheck pallets/flask
|
|
29
|
+
|
|
30
|
+
╭─ repo ──────────────────╮
|
|
31
|
+
│ pallets/flask │
|
|
32
|
+
│ ★ 68234 ⑂ 16200 Python│
|
|
33
|
+
╰──────────────────────────╯
|
|
34
|
+
╭─ overall health score ──╮
|
|
35
|
+
│ 91/100 (A) │
|
|
36
|
+
╰───────────────────────────╯
|
|
37
|
+
|
|
38
|
+
Check Score Grade
|
|
39
|
+
README Quality 100 A
|
|
40
|
+
CI/CD 90 A
|
|
41
|
+
Test Presence 85 B
|
|
42
|
+
Dependency Hygiene 80 B
|
|
43
|
+
License 100 A
|
|
44
|
+
Commit Activity 95 A
|
|
45
|
+
Issue Hygiene 85 B
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install repocheck-cli
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# owner/repo shorthand
|
|
58
|
+
repocheck pallets/flask
|
|
59
|
+
|
|
60
|
+
# full URL also works
|
|
61
|
+
repocheck https://github.com/psf/requests
|
|
62
|
+
|
|
63
|
+
# detailed reasoning behind every score
|
|
64
|
+
repocheck torvalds/linux -v
|
|
65
|
+
|
|
66
|
+
# machine-readable output (for CI pipelines, scripts)
|
|
67
|
+
repocheck your-org/your-repo --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Higher rate limits
|
|
71
|
+
|
|
72
|
+
GitHub's anonymous API limit is 60 requests/hour. `repocheck` makes ~8 requests per scan, so you'll hit that fast without auth. Set a token to bump it to 5,000/hour:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
76
|
+
repocheck your-org/your-repo
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
No special scopes needed — a basic personal access token works fine for public repos.
|
|
80
|
+
|
|
81
|
+
## What it checks
|
|
82
|
+
|
|
83
|
+
| Check | What it looks at |
|
|
84
|
+
|---|---|
|
|
85
|
+
| **README Quality** | Presence, length, install/usage sections, badges, code blocks |
|
|
86
|
+
| **CI/CD** | GitHub Actions workflows, legacy CI configs |
|
|
87
|
+
| **Test Presence** | Test directories, test config files, declared test scripts |
|
|
88
|
+
| **Dependency Hygiene** | Manifest presence, version pinning, lockfiles |
|
|
89
|
+
| **License** | OSS license presence and type |
|
|
90
|
+
| **Commit Activity** | Recency and message quality of recent commits |
|
|
91
|
+
| **Issue Hygiene** | Open issue count and staleness |
|
|
92
|
+
|
|
93
|
+
Each check is scored 0–100 and the overall score is an average across all seven.
|
|
94
|
+
|
|
95
|
+
## Why
|
|
96
|
+
|
|
97
|
+
Most "is this repo any good" judgments are vibes-based — a glance at stars, maybe the README. `repocheck` turns that into a repeatable, objective-ish checklist you can run on your own repos before sharing them, or use to quickly evaluate a dependency before adopting it.
|
|
98
|
+
|
|
99
|
+
## Local development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/Ahmed0754/repocheck
|
|
103
|
+
cd repocheck
|
|
104
|
+
pip install -e ".[dev]"
|
|
105
|
+
repocheck pallets/flask
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
repocheck/__init__.py
|
|
5
|
+
repocheck/checks.py
|
|
6
|
+
repocheck/cli.py
|
|
7
|
+
repocheck/github_client.py
|
|
8
|
+
repocheck/report.py
|
|
9
|
+
repocheck_cli.egg-info/PKG-INFO
|
|
10
|
+
repocheck_cli.egg-info/SOURCES.txt
|
|
11
|
+
repocheck_cli.egg-info/dependency_links.txt
|
|
12
|
+
repocheck_cli.egg-info/entry_points.txt
|
|
13
|
+
repocheck_cli.egg-info/requires.txt
|
|
14
|
+
repocheck_cli.egg-info/top_level.txt
|
|
15
|
+
tests/test_checks.py
|
|
16
|
+
tests/test_end_to_end.py
|
|
17
|
+
tests/test_github_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
repocheck
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from repocheck.checks import (
|
|
7
|
+
check_activity,
|
|
8
|
+
check_ci,
|
|
9
|
+
check_dependencies,
|
|
10
|
+
check_issue_hygiene,
|
|
11
|
+
check_license,
|
|
12
|
+
check_readme,
|
|
13
|
+
check_tests,
|
|
14
|
+
)
|
|
15
|
+
from repocheck.github_client import RepoRef
|
|
16
|
+
|
|
17
|
+
REF = RepoRef("acme", "widget")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_client(**overrides):
|
|
21
|
+
"""Build a MagicMock GitHubClient with sane defaults, override what you need."""
|
|
22
|
+
client = MagicMock()
|
|
23
|
+
client.root_files.return_value = overrides.get("root_files", [])
|
|
24
|
+
client.root_dirs.return_value = overrides.get("root_dirs", [])
|
|
25
|
+
client.file_text.return_value = overrides.get("file_text", None)
|
|
26
|
+
client.workflows.return_value = overrides.get("workflows", [])
|
|
27
|
+
client.commits.return_value = overrides.get("commits", [])
|
|
28
|
+
client.issues.return_value = overrides.get("issues", [])
|
|
29
|
+
client.repo.return_value = overrides.get("repo", {})
|
|
30
|
+
return client
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# README
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def test_readme_missing():
|
|
38
|
+
client = make_client(root_files=[])
|
|
39
|
+
result = check_readme(client, REF)
|
|
40
|
+
assert result.score == 0
|
|
41
|
+
assert result.grade == "F"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_readme_full_quality():
|
|
45
|
+
text = (
|
|
46
|
+
"# Widget\n\n" + ("x" * 400) + "\n\n## Installation\npip install widget\n\n"
|
|
47
|
+
"## Usage\n```python\nimport widget\n```\n\n"
|
|
48
|
+
)
|
|
49
|
+
client = make_client(root_files=["README.md"], file_text=text)
|
|
50
|
+
result = check_readme(client, REF)
|
|
51
|
+
assert result.score >= 90
|
|
52
|
+
assert result.grade in ("A", "B")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_readme_present_but_thin():
|
|
56
|
+
client = make_client(root_files=["README.md"], file_text="just a title")
|
|
57
|
+
result = check_readme(client, REF)
|
|
58
|
+
assert 0 < result.score < 60
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# CI
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def test_ci_github_actions_present():
|
|
66
|
+
client = make_client(workflows=[{"name": "ci.yml", "type": "file"}])
|
|
67
|
+
result = check_ci(client, REF)
|
|
68
|
+
assert result.score >= 70
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_ci_none():
|
|
72
|
+
client = make_client(workflows=[], root_files=["README.md"])
|
|
73
|
+
result = check_ci(client, REF)
|
|
74
|
+
assert result.score == 0
|
|
75
|
+
assert result.grade == "F"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_ci_legacy_travis():
|
|
79
|
+
client = make_client(workflows=[], root_files=[".travis.yml"])
|
|
80
|
+
result = check_ci(client, REF)
|
|
81
|
+
assert 0 < result.score < 90
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Tests presence
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def test_tests_dir_present():
|
|
89
|
+
client = make_client(root_dirs=["tests"], root_files=[])
|
|
90
|
+
result = check_tests(client, REF)
|
|
91
|
+
assert result.score >= 60
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_tests_absent():
|
|
95
|
+
client = make_client(root_dirs=["src"], root_files=["main.py"])
|
|
96
|
+
result = check_tests(client, REF)
|
|
97
|
+
assert result.score == 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_tests_config_only():
|
|
101
|
+
client = make_client(root_dirs=[], root_files=["pytest.ini"])
|
|
102
|
+
result = check_tests(client, REF)
|
|
103
|
+
assert result.score == 25
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Dependencies
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def test_dependencies_pinned_requirements():
|
|
111
|
+
text = "flask==2.3.0\nrequests==2.31.0\nclick==8.1.7\n"
|
|
112
|
+
client = make_client(root_files=["requirements.txt"], file_text=text)
|
|
113
|
+
result = check_dependencies(client, REF)
|
|
114
|
+
assert result.score >= 80
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_dependencies_unpinned_requirements():
|
|
118
|
+
text = "flask\nrequests\nclick\n"
|
|
119
|
+
client = make_client(root_files=["requirements.txt"], file_text=text)
|
|
120
|
+
result = check_dependencies(client, REF)
|
|
121
|
+
assert result.score == 70 # baseline only, no pin bonus
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_dependencies_none_found():
|
|
125
|
+
client = make_client(root_files=["README.md"])
|
|
126
|
+
result = check_dependencies(client, REF)
|
|
127
|
+
assert result.score == 50 # neutral, not penalized
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# License
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def test_license_detected_by_api():
|
|
135
|
+
client = make_client(repo={"license": {"name": "MIT License", "spdx_id": "MIT"}})
|
|
136
|
+
result = check_license(client, REF)
|
|
137
|
+
assert result.score == 100
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_license_file_only():
|
|
141
|
+
client = make_client(repo={"license": None}, root_files=["LICENSE"])
|
|
142
|
+
result = check_license(client, REF)
|
|
143
|
+
assert result.score == 80
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_license_missing():
|
|
147
|
+
client = make_client(repo={"license": None}, root_files=["README.md"])
|
|
148
|
+
result = check_license(client, REF)
|
|
149
|
+
assert result.score == 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Activity
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _commit(days_ago: int, message: str):
|
|
157
|
+
date = (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat().replace("+00:00", "Z")
|
|
158
|
+
return {"commit": {"committer": {"date": date}, "message": message}}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_activity_recent_and_descriptive():
|
|
162
|
+
commits = [_commit(1, "Add retry logic to API client for transient failures")]
|
|
163
|
+
client = make_client(commits=commits)
|
|
164
|
+
result = check_activity(client, REF)
|
|
165
|
+
assert result.score >= 80
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_activity_stale_repo():
|
|
169
|
+
commits = [_commit(500, "fix")]
|
|
170
|
+
client = make_client(commits=commits)
|
|
171
|
+
result = check_activity(client, REF)
|
|
172
|
+
assert result.score < 50
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_activity_no_commits():
|
|
176
|
+
client = make_client(commits=[])
|
|
177
|
+
result = check_activity(client, REF)
|
|
178
|
+
assert result.score == 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Issue hygiene
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def test_issue_hygiene_zero_open():
|
|
186
|
+
client = make_client(repo={"open_issues_count": 0})
|
|
187
|
+
result = check_issue_hygiene(client, REF)
|
|
188
|
+
assert result.score == 100
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_issue_hygiene_all_fresh():
|
|
192
|
+
fresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
193
|
+
issues = [{"created_at": fresh} for _ in range(5)]
|
|
194
|
+
client = make_client(repo={"open_issues_count": 5}, issues=issues)
|
|
195
|
+
result = check_issue_hygiene(client, REF)
|
|
196
|
+
assert result.score == 100
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_issue_hygiene_all_stale():
|
|
200
|
+
old = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat().replace("+00:00", "Z")
|
|
201
|
+
issues = [{"created_at": old} for _ in range(5)]
|
|
202
|
+
client = make_client(repo={"open_issues_count": 5}, issues=issues)
|
|
203
|
+
result = check_issue_hygiene(client, REF)
|
|
204
|
+
assert result.score == 20
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""End-to-end test: mocks GitHub's HTTP responses and runs a full report."""
|
|
2
|
+
import json
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from repocheck.github_client import GitHubClient, parse_repo_arg
|
|
6
|
+
from repocheck.report import run_report
|
|
7
|
+
|
|
8
|
+
FAKE_REPO_META = {
|
|
9
|
+
"full_name": "acme/widget",
|
|
10
|
+
"stargazers_count": 1234,
|
|
11
|
+
"forks_count": 56,
|
|
12
|
+
"language": "Python",
|
|
13
|
+
"license": {"name": "MIT License", "spdx_id": "MIT"},
|
|
14
|
+
"open_issues_count": 2,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
FAKE_README = (
|
|
18
|
+
"# Widget\n\nA delightful widget library.\n\n"
|
|
19
|
+
"## Installation\n```\npip install widget\n```\n\n"
|
|
20
|
+
"## Usage\n```python\nimport widget\nwidget.run()\n```\n\n"
|
|
21
|
+
"\n"
|
|
22
|
+
) * 3 # pad past the 300-char threshold
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fake_response(json_data, status=200, headers=None):
|
|
26
|
+
resp = MagicMock()
|
|
27
|
+
resp.status_code = status
|
|
28
|
+
resp.json.return_value = json_data
|
|
29
|
+
resp.headers = headers or {"X-RateLimit-Remaining": "59"}
|
|
30
|
+
resp.raise_for_status = MagicMock()
|
|
31
|
+
return resp
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fake_get(url, params=None, **kwargs):
|
|
35
|
+
if url.endswith("/repos/acme/widget"):
|
|
36
|
+
return _fake_response(FAKE_REPO_META)
|
|
37
|
+
if "/contents/" in url and url.endswith("/contents/"):
|
|
38
|
+
return _fake_response([
|
|
39
|
+
{"name": "README.md", "type": "file"},
|
|
40
|
+
{"name": "requirements.txt", "type": "file"},
|
|
41
|
+
{"name": "tests", "type": "dir"},
|
|
42
|
+
{"name": "src", "type": "dir"},
|
|
43
|
+
])
|
|
44
|
+
if url.endswith("/contents/README.md"):
|
|
45
|
+
import base64
|
|
46
|
+
return _fake_response({"content": base64.b64encode(FAKE_README.encode()).decode()})
|
|
47
|
+
if url.endswith("/contents/requirements.txt"):
|
|
48
|
+
import base64
|
|
49
|
+
return _fake_response({"content": base64.b64encode(b"flask==2.3.0\nrequests==2.31.0\n").decode()})
|
|
50
|
+
if url.endswith("/contents/.github/workflows"):
|
|
51
|
+
return _fake_response([{"name": "ci.yml", "type": "file"}])
|
|
52
|
+
if url.endswith("/commits"):
|
|
53
|
+
return _fake_response([
|
|
54
|
+
{
|
|
55
|
+
"commit": {
|
|
56
|
+
"committer": {"date": "2026-06-15T12:00:00Z"},
|
|
57
|
+
"message": "Add retry logic for transient network failures",
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
])
|
|
61
|
+
if url.endswith("/issues"):
|
|
62
|
+
return _fake_response([])
|
|
63
|
+
# default: not found
|
|
64
|
+
return _fake_response({"message": "Not Found"}, status=404)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_end_to_end_report_with_mocked_api():
|
|
68
|
+
ref = parse_repo_arg("acme/widget")
|
|
69
|
+
client = GitHubClient(token="fake-token")
|
|
70
|
+
|
|
71
|
+
with patch.object(client.session, "get", side_effect=fake_get):
|
|
72
|
+
report = run_report(client, ref)
|
|
73
|
+
|
|
74
|
+
assert report.repo.full_name == "acme/widget"
|
|
75
|
+
assert 0 <= report.overall_score <= 100
|
|
76
|
+
assert report.overall_grade in ("A", "B", "C", "D", "F")
|
|
77
|
+
assert len(report.checks) == 7
|
|
78
|
+
|
|
79
|
+
as_dict = report.to_dict()
|
|
80
|
+
# Confirm it's JSON-serializable end to end, like --json mode produces
|
|
81
|
+
serialized = json.dumps(as_dict)
|
|
82
|
+
assert "acme/widget" in serialized
|
|
83
|
+
|
|
84
|
+
# Sanity check a couple of specific checks given our fake data
|
|
85
|
+
names = {c.name: c for c in report.checks}
|
|
86
|
+
assert names["License"].score == 100
|
|
87
|
+
assert names["CI/CD"].score >= 70
|
|
88
|
+
assert names["Issue Hygiene"].score == 90 # count=2 but issues list empty -> "possibly all PRs" branch
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from repocheck.github_client import parse_repo_arg, RepoRef
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"arg,expected_owner,expected_name",
|
|
8
|
+
[
|
|
9
|
+
("pallets/flask", "pallets", "flask"),
|
|
10
|
+
("https://github.com/psf/requests", "psf", "requests"),
|
|
11
|
+
("https://github.com/psf/requests.git", "psf", "requests"),
|
|
12
|
+
("git@github.com:torvalds/linux.git", "torvalds", "linux"),
|
|
13
|
+
("github.com/Ahmed0754/repocheck", "Ahmed0754", "repocheck"),
|
|
14
|
+
("github.com/Ahmed0754/repocheck/", "Ahmed0754", "repocheck"),
|
|
15
|
+
],
|
|
16
|
+
)
|
|
17
|
+
def test_parse_repo_arg_valid(arg, expected_owner, expected_name):
|
|
18
|
+
ref = parse_repo_arg(arg)
|
|
19
|
+
assert ref.owner == expected_owner
|
|
20
|
+
assert ref.name == expected_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_parse_repo_arg_full_name():
|
|
24
|
+
ref = parse_repo_arg("octocat/Hello-World")
|
|
25
|
+
assert ref.full_name == "octocat/Hello-World"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("bad_arg", ["", "not a repo", "just-a-word", "http://example.com/foo/bar"])
|
|
29
|
+
def test_parse_repo_arg_invalid(bad_arg):
|
|
30
|
+
with pytest.raises(ValueError):
|
|
31
|
+
parse_repo_arg(bad_arg)
|