github-rep 0.1.0__py3-none-any.whl

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.
github_rep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """github-rep: honest GitHub profile analysis and reputation advice."""
2
+
3
+ __version__ = "0.1.0"
github_rep/analyzer.py ADDED
@@ -0,0 +1,435 @@
1
+ """Profile analyzer: scores genuine reputation signals across 9 dimensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from .api import GitHubClient
11
+
12
+
13
+ @dataclass
14
+ class Finding:
15
+ """A single scored observation about a GitHub profile."""
16
+
17
+ category: str
18
+ severity: str # "critical" | "high" | "medium" | "low" | "good"
19
+ title: str
20
+ detail: str
21
+ fix: Optional[str] = None
22
+
23
+ @property
24
+ def icon(self) -> str:
25
+ return {
26
+ "critical": "[CRITICAL]",
27
+ "high": "[HIGH]",
28
+ "medium": "[MEDIUM]",
29
+ "low": "[LOW]",
30
+ "good": "[GOOD]",
31
+ }.get(self.severity, "[?]")
32
+
33
+
34
+ @dataclass
35
+ class ProfileScore:
36
+ """Complete reputation analysis for one GitHub user."""
37
+
38
+ username: str
39
+ total: int # 0-100
40
+ breakdown: Dict[str, int] = field(default_factory=dict)
41
+ findings: List[Finding] = field(default_factory=list)
42
+ raw_user: Dict = field(default_factory=dict)
43
+ raw_repos: List[Dict] = field(default_factory=list)
44
+
45
+ @property
46
+ def grade(self) -> str:
47
+ if self.total >= 80:
48
+ return "A"
49
+ if self.total >= 65:
50
+ return "B"
51
+ if self.total >= 50:
52
+ return "C"
53
+ if self.total >= 35:
54
+ return "D"
55
+ return "F"
56
+
57
+ @property
58
+ def tier(self) -> str:
59
+ if self.total >= 80:
60
+ return "Established OSS contributor"
61
+ if self.total >= 65:
62
+ return "Active developer"
63
+ if self.total >= 50:
64
+ return "Growing presence"
65
+ if self.total >= 35:
66
+ return "Early stage"
67
+ return "Just starting"
68
+
69
+
70
+ # ── Helpers ───────────────────────────────────────────────────────────────────
71
+
72
+ def _days_since(dt_str: Optional[str]) -> Optional[int]:
73
+ if not dt_str:
74
+ return None
75
+ try:
76
+ dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
77
+ return (datetime.now(timezone.utc) - dt).days
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def _readme_score(readme_text: Optional[str]) -> Tuple[int, List[Finding]]:
83
+ """Score README quality 0-15 and return relevant findings."""
84
+ findings: List[Finding] = []
85
+
86
+ if not readme_text:
87
+ return 0, [Finding(
88
+ "readme_quality", "critical",
89
+ "No README",
90
+ "Your repo has no README. This is the #1 reason developers skip a project.",
91
+ "Add README.md: what it does, why it matters, how to install, quick example.",
92
+ )]
93
+
94
+ score = 0
95
+ words = len(readme_text.split())
96
+
97
+ if words < 50:
98
+ findings.append(Finding(
99
+ "readme_quality", "high",
100
+ "README too short",
101
+ f"Only {words} words. A useful README is 150-500 words.",
102
+ "Add: what it does, installation, usage example, screenshot.",
103
+ ))
104
+ score += 3
105
+ elif words < 150:
106
+ score += 7
107
+ findings.append(Finding(
108
+ "readme_quality", "medium",
109
+ "README could be richer",
110
+ f"{words} words - decent, but consider adding usage examples.",
111
+ "Add a code example or screenshot to make it concrete.",
112
+ ))
113
+ else:
114
+ score += 12
115
+
116
+ # Code examples
117
+ if "```" in readme_text or "`" in readme_text:
118
+ score += 2
119
+ else:
120
+ findings.append(Finding(
121
+ "readme_quality", "medium",
122
+ "No code examples",
123
+ "README has no inline code. Show, do not just tell.",
124
+ "Add a quick-start code block showing the most common use case.",
125
+ ))
126
+
127
+ # Installation instructions
128
+ has_install = any(
129
+ kw in readme_text.lower()
130
+ for kw in ["install", "pip install", "npm install", "brew install", "cargo add"]
131
+ )
132
+ if has_install:
133
+ score += 1
134
+ else:
135
+ findings.append(Finding(
136
+ "readme_quality", "low",
137
+ "No installation instructions",
138
+ "How should someone install this?",
139
+ 'Add an "## Installation" section even if it is just `pip install <name>`.',
140
+ ))
141
+
142
+ return min(score, 15), findings
143
+
144
+
145
+ # ── Main analyzer ─────────────────────────────────────────────────────────────
146
+
147
+ def analyze(
148
+ username: str,
149
+ token: Optional[str] = None,
150
+ top_n: int = 10,
151
+ ) -> ProfileScore:
152
+ """Fetch GitHub data and compute a ProfileScore across 9 dimensions.
153
+
154
+ Args:
155
+ username: GitHub username to analyze.
156
+ token: Optional GitHub personal access token (improves rate limits).
157
+ top_n: Number of top repos (by stars) to deep-analyze.
158
+
159
+ Returns:
160
+ ProfileScore with total (0-100), breakdown, and actionable findings.
161
+ """
162
+ client = GitHubClient(token=token)
163
+ user = client.get_user(username)
164
+ repos = client.get_repos(username, include_forks=False)
165
+ repos_sorted = sorted(repos, key=lambda r: r.get("stargazers_count", 0), reverse=True)
166
+ top_repos = repos_sorted[:top_n]
167
+
168
+ breakdown: Dict[str, int] = {}
169
+ findings: List[Finding] = []
170
+
171
+ # ── 1. Profile completeness (10 pts) ──────────────────────────────────────
172
+ pc = 0
173
+ if user.get("bio"):
174
+ pc += 3
175
+ else:
176
+ findings.append(Finding(
177
+ "profile_completeness", "high",
178
+ "Missing bio",
179
+ "Your bio is empty. It is the first thing visitors read.",
180
+ "Write 1-2 sentences: your focus, what you build, your superpower.",
181
+ ))
182
+ if user.get("location"):
183
+ pc += 1
184
+ else:
185
+ findings.append(Finding(
186
+ "profile_completeness", "low",
187
+ "No location set",
188
+ "Location builds trust and helps local dev communities find you.",
189
+ "Add your city/country - takes 5 seconds.",
190
+ ))
191
+ if user.get("blog") or user.get("twitter_username"):
192
+ pc += 2
193
+ else:
194
+ findings.append(Finding(
195
+ "profile_completeness", "medium",
196
+ "No website or social link",
197
+ "No linked website or Twitter/X. Credibility builder.",
198
+ "Add your website, LinkedIn, or Twitter handle.",
199
+ ))
200
+ if user.get("company"):
201
+ pc += 1
202
+ if user.get("email"):
203
+ pc += 1
204
+ avatar = user.get("avatar_url", "")
205
+ if avatar and "gravatar" not in avatar:
206
+ pc += 2
207
+ else:
208
+ findings.append(Finding(
209
+ "profile_completeness", "low",
210
+ "Default/gravatar avatar",
211
+ "A real photo or custom avatar increases perceived legitimacy.",
212
+ "Upload a profile photo - even a simple avatar is better than the default.",
213
+ ))
214
+ breakdown["profile_completeness"] = min(pc, 10)
215
+
216
+ # ── 2. README quality (15 pts) ────────────────────────────────────────────
217
+ readme_text: Optional[str] = None
218
+ if top_repos:
219
+ best = top_repos[0]
220
+ try:
221
+ raw = client.get(f"/repos/{username}/{best['name']}/readme")
222
+ readme_text = base64.b64decode(raw["content"]).decode("utf-8", errors="replace")
223
+ except Exception:
224
+ readme_text = None
225
+ readme_pts, readme_findings = _readme_score(readme_text)
226
+ breakdown["readme_quality"] = readme_pts
227
+ findings.extend(readme_findings)
228
+
229
+ # ── 3. Star signal (20 pts) ───────────────────────────────────────────────
230
+ total_stars = sum(r.get("stargazers_count", 0) for r in repos)
231
+ max_stars = max((r.get("stargazers_count", 0) for r in repos), default=0)
232
+ if total_stars == 0:
233
+ star_pts = 0
234
+ findings.append(Finding(
235
+ "star_signal", "high",
236
+ "Zero stars across all repos",
237
+ "Stars are the #1 public signal of value. Zero usually means: "
238
+ "no promotion, poor README, or no unique value prop.",
239
+ "Fix the README, then share the project genuinely in ONE relevant community.",
240
+ ))
241
+ elif total_stars < 5:
242
+ star_pts = 4
243
+ findings.append(Finding(
244
+ "star_signal", "medium",
245
+ f"Low star count ({total_stars} total)",
246
+ "Genuine stars come from genuine visibility.",
247
+ "Share in community WHEN you have something useful to say "
248
+ "(build log, lesson learned, solved problem).",
249
+ ))
250
+ elif total_stars < 25:
251
+ star_pts = 10
252
+ elif total_stars < 100:
253
+ star_pts = 15
254
+ elif total_stars < 500:
255
+ star_pts = 18
256
+ else:
257
+ star_pts = 20
258
+ findings.append(Finding(
259
+ "star_signal", "good",
260
+ f"Strong star signal ({total_stars} total, top repo: {max_stars})",
261
+ "Real community traction. Keep shipping.",
262
+ ))
263
+ breakdown["star_signal"] = star_pts
264
+
265
+ # ── 4. Contribution activity (15 pts) ─────────────────────────────────────
266
+ days = _days_since(user.get("updated_at"))
267
+ if days is None or days > 180:
268
+ streak_pts = 0
269
+ findings.append(Finding(
270
+ "contribution_streak", "high",
271
+ "No recent activity (6+ months)",
272
+ "Stale profiles are invisible in GitHub search and Explore.",
273
+ "Even small commits (docs, tests, fixes) signal an active developer.",
274
+ ))
275
+ elif days > 60:
276
+ streak_pts = 5
277
+ findings.append(Finding(
278
+ "contribution_streak", "medium",
279
+ f"Low recent activity ({days} days since last update)",
280
+ "Aim for a few commits per month to stay visible.",
281
+ "Pick one project and make a small meaningful improvement weekly.",
282
+ ))
283
+ elif days > 14:
284
+ streak_pts = 10
285
+ else:
286
+ streak_pts = 15
287
+ findings.append(Finding(
288
+ "contribution_streak", "good",
289
+ f"Active recent commits ({days}d ago)",
290
+ "Consistent shipping builds reputation over time.",
291
+ ))
292
+ breakdown["contribution_streak"] = streak_pts
293
+
294
+ # ── 5. Repo diversity (10 pts) ────────────────────────────────────────────
295
+ n_repos = len(repos)
296
+ if n_repos == 0:
297
+ div_pts = 0
298
+ findings.append(Finding(
299
+ "repo_diversity", "critical",
300
+ "No public repositories",
301
+ "Nothing to discover, nothing to star, nothing to learn from.",
302
+ "Publish at least one genuine project - even a small tool that solves a real problem.",
303
+ ))
304
+ elif n_repos < 3:
305
+ div_pts = 3
306
+ findings.append(Finding(
307
+ "repo_diversity", "medium",
308
+ f"Only {n_repos} public repos",
309
+ "A single repo limits discovery surface. Build in public.",
310
+ "Start a second project - even a CLI tool, a library, or documented configs.",
311
+ ))
312
+ elif n_repos < 10:
313
+ div_pts = 7
314
+ else:
315
+ div_pts = 10
316
+ languages = {r["language"] for r in repos if r.get("language")}
317
+ if len(languages) >= 3:
318
+ findings.append(Finding(
319
+ "repo_diversity", "good",
320
+ f"{n_repos} repos across {len(languages)} languages",
321
+ "Healthy breadth. Visitors can see range and depth.",
322
+ ))
323
+ breakdown["repo_diversity"] = div_pts
324
+
325
+ # ── 6. Description quality (10 pts) ───────────────────────────────────────
326
+ repos_missing_desc = [r for r in repos if not r.get("description")]
327
+ if repos_missing_desc:
328
+ pct = len(repos_missing_desc) / max(len(repos), 1)
329
+ desc_pts = max(0, int(10 * (1 - pct)))
330
+ severity = "high" if pct > 0.5 else "medium"
331
+ findings.append(Finding(
332
+ "description_quality", severity,
333
+ f"{len(repos_missing_desc)}/{len(repos)} repos missing descriptions",
334
+ "GitHub search indexes repo descriptions as keyword signal.",
335
+ "Add a 1-sentence description to every public repo - takes 30 seconds each.",
336
+ ))
337
+ else:
338
+ desc_pts = 10
339
+ findings.append(Finding(
340
+ "description_quality", "good",
341
+ "All repos have descriptions",
342
+ "Every repo is searchable and discoverable by description keyword.",
343
+ ))
344
+ breakdown["description_quality"] = desc_pts
345
+
346
+ # ── 7. Topic tags (5 pts) ─────────────────────────────────────────────────
347
+ repos_without_topics = [r for r in repos if not r.get("topics")]
348
+ if not repos_without_topics:
349
+ topic_pts = 5
350
+ findings.append(Finding(
351
+ "topic_tags", "good",
352
+ "All repos have topic tags",
353
+ "Topic tags make repos discoverable via GitHub Explore.",
354
+ ))
355
+ elif len(repos_without_topics) < len(repos) / 2:
356
+ topic_pts = 3
357
+ findings.append(Finding(
358
+ "topic_tags", "low",
359
+ f"{len(repos_without_topics)} repos missing topic tags",
360
+ "GitHub Explore uses topics to surface repos in category feeds.",
361
+ "Add 3-5 relevant topics per repo (e.g. python, cli, api, automation).",
362
+ ))
363
+ else:
364
+ topic_pts = 0
365
+ findings.append(Finding(
366
+ "topic_tags", "medium",
367
+ "Most repos have no topic tags",
368
+ "Missing from GitHub Explore category pages entirely.",
369
+ "Add topics to your top 3 repos today - GitHub UI, takes 2 minutes.",
370
+ ))
371
+ breakdown["topic_tags"] = topic_pts
372
+
373
+ # ── 8. Fork ratio (5 pts) ─────────────────────────────────────────────────
374
+ all_repos_with_forks = client.get_repos(username, include_forks=True)
375
+ fork_count = sum(1 for r in all_repos_with_forks if r.get("fork"))
376
+ total_count = len(all_repos_with_forks)
377
+ fork_ratio = fork_count / total_count if total_count > 0 else 0
378
+ if fork_ratio > 0.7:
379
+ fork_pts = 1
380
+ findings.append(Finding(
381
+ "fork_ratio", "medium",
382
+ f"High fork ratio ({fork_ratio:.0%} forks)",
383
+ "Forked repos dominate your profile. Visitors see mostly borrowed code.",
384
+ "Consider making forked experiments private, or shipping more originals.",
385
+ ))
386
+ elif fork_ratio > 0.4:
387
+ fork_pts = 3
388
+ else:
389
+ fork_pts = 5
390
+ breakdown["fork_ratio"] = fork_pts
391
+
392
+ # ── 9. Recent activity quality (10 pts) ───────────────────────────────────
393
+ recently_active = [
394
+ r for r in repos
395
+ if _days_since(r.get("pushed_at")) is not None
396
+ and (_days_since(r.get("pushed_at")) or 999) < 90
397
+ ]
398
+ if not recently_active:
399
+ ra_pts = 0
400
+ findings.append(Finding(
401
+ "recent_activity", "medium",
402
+ "No repos active in last 90 days",
403
+ "Consistent activity matters for GitHub algorithm visibility.",
404
+ "Even docs or test additions count as activity.",
405
+ ))
406
+ elif len(recently_active) < 2:
407
+ ra_pts = 5
408
+ findings.append(Finding(
409
+ "recent_activity", "low",
410
+ "Only 1 repo active in last 90 days",
411
+ "Single-repo focus is fine but spread to 2-3 for visibility.",
412
+ "Even docs or test additions count.",
413
+ ))
414
+ else:
415
+ ra_pts = 10
416
+ findings.append(Finding(
417
+ "recent_activity", "good",
418
+ f"{len(recently_active)} repos active in last 90 days",
419
+ "Consistent multi-repo activity signals a serious builder.",
420
+ ))
421
+ breakdown["recent_activity"] = ra_pts
422
+
423
+ # ── Aggregate ─────────────────────────────────────────────────────────────
424
+ total = sum(breakdown.values())
425
+ severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "good": 4}
426
+ findings.sort(key=lambda f: severity_order.get(f.severity, 5))
427
+
428
+ return ProfileScore(
429
+ username=username,
430
+ total=total,
431
+ breakdown=breakdown,
432
+ findings=findings,
433
+ raw_user=user,
434
+ raw_repos=repos,
435
+ )
github_rep/api.py ADDED
@@ -0,0 +1,100 @@
1
+ """GitHub API client with rate-limit awareness and caching."""
2
+
3
+ import time
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+ import requests
10
+
11
+ GITHUB_API = "https://api.github.com"
12
+ CACHE_DIR = Path.home() / ".cache" / "github-rep"
13
+
14
+
15
+ class RateLimitError(Exception):
16
+ pass
17
+
18
+
19
+ class GitHubClient:
20
+ """Thin wrapper around the GitHub REST API v3.
21
+
22
+ Supports both authenticated and unauthenticated usage.
23
+ Unauthenticated: 60 req/hr | Authenticated: 5000 req/hr
24
+ """
25
+
26
+ def __init__(self, token: Optional[str] = None, cache_ttl: int = 300):
27
+ self.token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PAT")
28
+ self.cache_ttl = cache_ttl
29
+ self.session = requests.Session()
30
+ self.session.headers.update({
31
+ "Accept": "application/vnd.github.v3+json",
32
+ "User-Agent": "github-rep/0.1.0",
33
+ })
34
+ if self.token:
35
+ self.session.headers["Authorization"] = f"Bearer {self.token}"
36
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
37
+
38
+ def _cache_key(self, url: str, params: dict) -> Path:
39
+ key = hashlib.md5(f"{url}{json.dumps(params, sort_keys=True)}".encode()).hexdigest()
40
+ return CACHE_DIR / f"{key}.json"
41
+
42
+ def _get_cached(self, url: str, params: dict) -> Optional[Any]:
43
+ cache_file = self._cache_key(url, params)
44
+ if cache_file.exists():
45
+ age = time.time() - cache_file.stat().st_mtime
46
+ if age < self.cache_ttl:
47
+ return json.loads(cache_file.read_text())
48
+ return None
49
+
50
+ def _set_cached(self, url: str, params: dict, data: Any) -> None:
51
+ cache_file = self._cache_key(url, params)
52
+ cache_file.write_text(json.dumps(data))
53
+
54
+ def get(self, path: str, params: Optional[Dict] = None, use_cache: bool = True) -> Any:
55
+ params = params or {}
56
+ url = f"{GITHUB_API}{path}"
57
+ if use_cache:
58
+ cached = self._get_cached(url, params)
59
+ if cached is not None:
60
+ return cached
61
+ resp = self.session.get(url, params=params)
62
+ if resp.status_code == 403 and "rate limit" in resp.text.lower():
63
+ reset = int(resp.headers.get("X-RateLimit-Reset", 0))
64
+ raise RateLimitError(f"Rate limited. Resets at {time.ctime(reset)}")
65
+ resp.raise_for_status()
66
+ data = resp.json()
67
+ if use_cache:
68
+ self._set_cached(url, params, data)
69
+ return data
70
+
71
+ def get_user(self, username: str) -> Dict:
72
+ return self.get(f"/users/{username}")
73
+
74
+ def get_repos(self, username: str, include_forks: bool = False) -> List[Dict]:
75
+ repos = []
76
+ page = 1
77
+ while True:
78
+ batch = self.get(f"/users/{username}/repos",
79
+ params={"per_page": 100, "page": page, "sort": "updated"})
80
+ if not batch:
81
+ break
82
+ repos.extend(batch)
83
+ if len(batch) < 100:
84
+ break
85
+ page += 1
86
+ if not include_forks:
87
+ repos = [r for r in repos if not r["fork"]]
88
+ return repos
89
+
90
+ def get_repo(self, owner: str, repo: str) -> Dict:
91
+ return self.get(f"/repos/{owner}/{repo}")
92
+
93
+ def get_contributors(self, owner: str, repo: str) -> List[Dict]:
94
+ try:
95
+ return self.get(f"/repos/{owner}/{repo}/contributors", params={"per_page": 30})
96
+ except Exception:
97
+ return []
98
+
99
+ def rate_limit_status(self) -> Dict:
100
+ return self.get("/rate_limit", use_cache=False)
github_rep/cli.py ADDED
@@ -0,0 +1,260 @@
1
+ """CLI entry point for github-rep."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from .analyzer import analyze, ProfileScore
17
+ from .api import RateLimitError, GitHubClient
18
+
19
+ app = typer.Typer(
20
+ name="github-rep",
21
+ help="Analyze a GitHub profile and get honest, actionable reputation advice.",
22
+ add_completion=False,
23
+ )
24
+ console = Console()
25
+
26
+
27
+ def _severity_color(severity: str) -> str:
28
+ return {
29
+ "critical": "bold red",
30
+ "high": "red",
31
+ "medium": "yellow",
32
+ "low": "blue",
33
+ "good": "green",
34
+ }.get(severity, "white")
35
+
36
+
37
+ def _grade_color(grade: str) -> str:
38
+ return {"A": "bold green", "B": "green", "C": "yellow", "D": "orange3", "F": "red"}.get(grade, "white")
39
+
40
+
41
+ def _render_score(score: ProfileScore, verbose: bool = False) -> None:
42
+ """Render a ProfileScore to the terminal using Rich."""
43
+ grade_color = _grade_color(score.grade)
44
+
45
+ # Header panel
46
+ user = score.raw_user
47
+ header_lines = [
48
+ f"[bold]@{score.username}[/bold] | {user.get('name', '')}",
49
+ f"{user.get('bio', '')}",
50
+ f"Followers: {user.get('followers', 0)} | "
51
+ f"Public repos: {user.get('public_repos', 0)} | "
52
+ f"Stars earned: {sum(r.get('stargazers_count', 0) for r in score.raw_repos)}",
53
+ ]
54
+ console.print(Panel("\n".join(header_lines), title="GitHub Profile", border_style="blue"))
55
+
56
+ # Score summary
57
+ grade_text = Text(f"Grade: {score.grade} ({score.total}/100)", style=grade_color)
58
+ tier_text = Text(f"Tier: {score.tier}", style="bold")
59
+ console.print(grade_text)
60
+ console.print(tier_text)
61
+ console.print()
62
+
63
+ # Breakdown table
64
+ table = Table(title="Score Breakdown", show_header=True, header_style="bold cyan")
65
+ table.add_column("Dimension", min_width=24)
66
+ table.add_column("Score", justify="right", min_width=8)
67
+ table.add_column("Max", justify="right", min_width=5)
68
+
69
+ dimension_max = {
70
+ "profile_completeness": 10,
71
+ "readme_quality": 15,
72
+ "star_signal": 20,
73
+ "contribution_streak": 15,
74
+ "repo_diversity": 10,
75
+ "description_quality": 10,
76
+ "topic_tags": 5,
77
+ "fork_ratio": 5,
78
+ "recent_activity": 10,
79
+ }
80
+
81
+ for dim, pts in score.breakdown.items():
82
+ max_pts = dimension_max.get(dim, 10)
83
+ bar = "█" * pts + "░" * (max_pts - pts)
84
+ label = dim.replace("_", " ").title()
85
+ color = "green" if pts >= max_pts * 0.8 else "yellow" if pts >= max_pts * 0.5 else "red"
86
+ table.add_row(label, f"[{color}]{pts}[/{color}]", str(max_pts))
87
+
88
+ console.print(table)
89
+ console.print()
90
+
91
+ # Findings
92
+ critical_and_high = [f for f in score.findings if f.severity in ("critical", "high")]
93
+ medium_and_low = [f for f in score.findings if f.severity in ("medium", "low")]
94
+ good = [f for f in score.findings if f.severity == "good"]
95
+
96
+ if critical_and_high:
97
+ console.print("[bold red]Priority fixes:[/bold red]")
98
+ for f in critical_and_high:
99
+ color = _severity_color(f.severity)
100
+ console.print(f" [{color}]{f.icon} {f.title}[/{color}]")
101
+ console.print(f" {f.detail}")
102
+ if f.fix:
103
+ console.print(f" [dim]Fix: {f.fix}[/dim]")
104
+ console.print()
105
+
106
+ if medium_and_low and (verbose or not critical_and_high):
107
+ console.print("[bold yellow]Improvements:[/bold yellow]")
108
+ for f in medium_and_low:
109
+ color = _severity_color(f.severity)
110
+ console.print(f" [{color}]{f.icon} {f.title}[/{color}]")
111
+ if verbose:
112
+ console.print(f" {f.detail}")
113
+ if f.fix:
114
+ console.print(f" [dim]Fix: {f.fix}[/dim]")
115
+ console.print()
116
+
117
+ if good:
118
+ console.print("[bold green]What is working:[/bold green]")
119
+ for f in good:
120
+ console.print(f" [green]{f.icon} {f.title}[/green]")
121
+ if verbose:
122
+ console.print(f" [dim]{f.detail}[/dim]")
123
+ console.print()
124
+
125
+
126
+ # ── Commands ──────────────────────────────────────────────────────────────────
127
+
128
+ @app.command()
129
+ def analyze_profile(
130
+ username: str = typer.Argument(..., help="GitHub username to analyze"),
131
+ token: Optional[str] = typer.Option(
132
+ None, "--token", "-t", envvar="GITHUB_TOKEN",
133
+ help="GitHub personal access token (optional; raises rate limit to 5000 req/hr)",
134
+ ),
135
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show all findings including low-priority"),
136
+ json_output: bool = typer.Option(False, "--json", help="Output results as JSON"),
137
+ top_n: int = typer.Option(10, "--top", help="Number of top repos to deep-analyze"),
138
+ ) -> None:
139
+ """Analyze a GitHub profile and score it across 9 genuine reputation dimensions.
140
+
141
+ Examples:
142
+ github-rep torvalds
143
+ github-rep basilalshukaili --verbose
144
+ github-rep sindresorhus --json
145
+ GITHUB_TOKEN=ghp_xxx github-rep gvanrossum
146
+ """
147
+ try:
148
+ with console.status(f"[cyan]Analyzing @{username}...[/cyan]", spinner="dots"):
149
+ score = analyze(username, token=token, top_n=top_n)
150
+ except RateLimitError as e:
151
+ console.print(f"[red]Rate limit hit:[/red] {e}")
152
+ raise typer.Exit(1)
153
+ except Exception as e:
154
+ console.print(f"[red]Error analyzing @{username}:[/red] {e}")
155
+ raise typer.Exit(1)
156
+
157
+ if json_output:
158
+ output = {
159
+ "username": score.username,
160
+ "total": score.total,
161
+ "grade": score.grade,
162
+ "tier": score.tier,
163
+ "breakdown": score.breakdown,
164
+ "findings": [
165
+ {
166
+ "category": f.category,
167
+ "severity": f.severity,
168
+ "title": f.title,
169
+ "detail": f.detail,
170
+ "fix": f.fix,
171
+ }
172
+ for f in score.findings
173
+ ],
174
+ }
175
+ console.print(json.dumps(output, indent=2))
176
+ return
177
+
178
+ _render_score(score, verbose=verbose)
179
+
180
+
181
+ @app.command()
182
+ def compare(
183
+ usernames: list[str] = typer.Argument(..., help="Two or more GitHub usernames to compare"),
184
+ token: Optional[str] = typer.Option(None, "--token", "-t", envvar="GITHUB_TOKEN"),
185
+ ) -> None:
186
+ """Compare two or more GitHub profiles side by side.
187
+
188
+ Example:
189
+ github-rep compare torvalds gvanrossum sindresorhus
190
+ """
191
+ if len(usernames) < 2:
192
+ console.print("[red]Provide at least 2 usernames to compare.[/red]")
193
+ raise typer.Exit(1)
194
+
195
+ scores = []
196
+ for username in usernames:
197
+ try:
198
+ with console.status(f"[cyan]Analyzing @{username}...[/cyan]", spinner="dots"):
199
+ scores.append(analyze(username, token=token))
200
+ except Exception as e:
201
+ console.print(f"[yellow]Skipping @{username}: {e}[/yellow]")
202
+
203
+ if not scores:
204
+ raise typer.Exit(1)
205
+
206
+ table = Table(title="Profile Comparison", show_header=True, header_style="bold cyan")
207
+ table.add_column("Dimension", min_width=24)
208
+ for s in scores:
209
+ table.add_column(f"@{s.username}", justify="right", min_width=12)
210
+
211
+ dimensions = [
212
+ "profile_completeness", "readme_quality", "star_signal",
213
+ "contribution_streak", "repo_diversity", "description_quality",
214
+ "topic_tags", "fork_ratio", "recent_activity",
215
+ ]
216
+ for dim in dimensions:
217
+ row = [dim.replace("_", " ").title()]
218
+ for s in scores:
219
+ pts = s.breakdown.get(dim, 0)
220
+ row.append(str(pts))
221
+ table.add_row(*row)
222
+
223
+ # Total row
224
+ totals = ["[bold]TOTAL[/bold]"]
225
+ for s in scores:
226
+ grade_color = _grade_color(s.grade)
227
+ totals.append(f"[{grade_color}]{s.total} ({s.grade})[/{grade_color}]")
228
+ table.add_row(*totals)
229
+
230
+ console.print(table)
231
+
232
+
233
+ @app.command()
234
+ def rate_limit(
235
+ token: Optional[str] = typer.Option(None, "--token", "-t", envvar="GITHUB_TOKEN"),
236
+ ) -> None:
237
+ """Check your current GitHub API rate limit status."""
238
+ client = GitHubClient(token=token)
239
+ try:
240
+ data = client.rate_limit_status()
241
+ core = data.get("resources", {}).get("core", {})
242
+ remaining = core.get("remaining", "?")
243
+ limit = core.get("limit", "?")
244
+ reset_ts = core.get("reset")
245
+ import time
246
+ reset_str = time.ctime(reset_ts) if reset_ts else "?"
247
+ console.print(f"[cyan]Rate limit:[/cyan] {remaining}/{limit} remaining, resets at {reset_str}")
248
+ if not token:
249
+ console.print("[yellow]Tip: set GITHUB_TOKEN to get 5000 req/hr instead of 60.[/yellow]")
250
+ except Exception as e:
251
+ console.print(f"[red]Error:[/red] {e}")
252
+ raise typer.Exit(1)
253
+
254
+
255
+ def main() -> None:
256
+ app()
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-rep
3
+ Version: 0.1.0
4
+ Summary: Analyze a GitHub profile and get honest, actionable advice for building real reputation
5
+ Author-email: Basil Alshukaili <basilalshukaili@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/basilalshukaili/github-reputation-engine
8
+ Project-URL: Bug Tracker, https://github.com/basilalshukaili/github-reputation-engine/issues
9
+ Keywords: github,open-source,developer-tools,profile,reputation,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.31
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: typer>=0.9
26
+ Requires-Dist: python-dateutil>=2.8
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7; extra == "dev"
29
+ Requires-Dist: responses>=0.25; extra == "dev"
30
+ Requires-Dist: pytest-cov>=4; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # GitHub Reputation Engine 🛠️
34
+
35
+ A self-improving "second brain" + operating system for building **genuine GitHub reputation**
36
+ through high-quality open-source contributions — run by an AI agent (Hermes Agent or Claude Code),
37
+ autonomously, one project at a time.
38
+
39
+ **Owner:** Basil Al Shukaili ([@basilalshukaili](https://github.com/basilalshukaili))
40
+
41
+ > Quality of contribution > volume. One merged, appreciated PR beats fifty rejected typo fixes.
42
+
43
+ ## What this repo is
44
+
45
+ This repo is the **portable brain** of the operation. It lets any agent, on any laptop, continue
46
+ the same mission with full context. It contains the plan, live state, reusable playbooks, a
47
+ journal of work done, lessons learned, and nightly strategic "dreams."
48
+
49
+ Start here → [`CLAUDE.md`](./CLAUDE.md) (operating instructions, auto-loaded by Claude Code/Hermes).
50
+
51
+ ## Structure
52
+
53
+ ```
54
+ .
55
+ ├── CLAUDE.md # Operating instructions — read first (works with Claude Code, Hermes, Codex…)
56
+ ├── 00-System/ # roadmap, architecture, guardrails (the constitution)
57
+ ├── 01-Targets/ # the ONE active project + next action
58
+ ├── 02-Repos/ # per-repo dossiers (stack, conventions, maintainers, opportunities)
59
+ ├── 03-Journal/ # daily log of what was attempted/done (append-only per day)
60
+ ├── 04-Playbooks/ # portable, agent-agnostic procedures (triage, PR craft, dreaming)
61
+ ├── 05-Lessons/ # mistakes & wins → rules for next time
62
+ ├── 06-Dreams/ # nightly reflective synthesis: patterns, connections, next moves
63
+ └── 99-Inbox/ # scratch captures
64
+ ```
65
+
66
+ ## How it works
67
+
68
+ ```
69
+ Brain (top model) — plans, reviews every diff, writes all maintainer-facing prose, commits
70
+ │ delegates mechanical, fully-specified work
71
+ Worker (mid model) — implements to spec, runs tests, triage scans
72
+
73
+ This repo (state) — feeds full context back to the Brain on every session
74
+
75
+ Playbooks (procedure) + Journal/Lessons/Dreams (self-improvement loop)
76
+ ```
77
+
78
+ **Token discipline without quality loss:** route cheap/mechanical work to cheaper models; keep
79
+ all judgment and human-facing writing on the top model. No output-degrading "compression" tricks.
80
+
81
+ ## Multi-machine usage
82
+
83
+ - **Laptop A (Hermes Agent):** runs the 24/7 cadence (cron missions + nightly dreaming),
84
+ reports to Telegram. Skills live in Hermes; this repo holds the portable state + playbooks.
85
+ - **Laptop B (Claude Code):** clone this repo, open it, and Claude Code auto-reads `CLAUDE.md`.
86
+ The playbooks in `04-Playbooks/` are agent-agnostic, so the workflow is identical.
87
+
88
+ Pull before you start, commit + push when you finish, so both machines stay in sync.
89
+
90
+ ## Safety
91
+
92
+ - No secrets are committed (API keys / bot tokens live in each machine's local env).
93
+ - Agents fork → branch → PR; never push to an upstream default branch.
94
+ - Respects each project's CONTRIBUTING rules and any anti-AI-PR policies.
95
+
96
+ ## License
97
+
98
+ MIT — see [`LICENSE`](./LICENSE).
@@ -0,0 +1,10 @@
1
+ github_rep/__init__.py,sha256=6gOxIvSxNvBq3X9sE-WA2Fkw8L5RoWY67Yosalfag08,95
2
+ github_rep/analyzer.py,sha256=VHBvSuAbRsRVrdqwaALO2splyypvWOt9C2RziNAR8fY,16813
3
+ github_rep/api.py,sha256=IpdkoKYlg0VT2BPwsu7-60EcTJvFAGMKV_VOc1gz4ug,3559
4
+ github_rep/cli.py,sha256=9M8cfLYtOipz7nYB6Ff8cjyFFaZaAG8Lnk4LHTOJC2k,9103
5
+ github_rep-0.1.0.dist-info/licenses/LICENSE,sha256=7cTzO3IgdzX4tnvADOt1rZSZKc3dYWCglc_cYdhdAxY,1074
6
+ github_rep-0.1.0.dist-info/METADATA,sha256=CJpgd-8FsK1qeI9KVuppzo-aByyHVCTHBWm3TfftXsY,4347
7
+ github_rep-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ github_rep-0.1.0.dist-info/entry_points.txt,sha256=TFGoRTxJFP6L_Y6ZoLhXznV5MG-fqo7t15kaWVaodD0,50
9
+ github_rep-0.1.0.dist-info/top_level.txt,sha256=GqG8Q5kiHgyUM6ru_6k69Y7YzoRriNtN7i94bs5iiBw,11
10
+ github_rep-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-rep = github_rep.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Basil Al Shukaili
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 @@
1
+ github_rep