utopia-analyzer 1.0.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.
- utopia/__init__.py +3 -0
- utopia/__main__.py +6 -0
- utopia/archetypes.py +168 -0
- utopia/cli.py +107 -0
- utopia/formatters/__init__.py +1 -0
- utopia/formatters/json_output.py +50 -0
- utopia/formatters/markdown_output.py +96 -0
- utopia/formatters/terminal.py +130 -0
- utopia/github_client.py +517 -0
- utopia/improvement_plan.py +120 -0
- utopia/recruiter.py +221 -0
- utopia/report.py +76 -0
- utopia/roast.py +121 -0
- utopia/scoring.py +377 -0
- utopia_analyzer-1.0.0.dist-info/METADATA +194 -0
- utopia_analyzer-1.0.0.dist-info/RECORD +20 -0
- utopia_analyzer-1.0.0.dist-info/WHEEL +5 -0
- utopia_analyzer-1.0.0.dist-info/entry_points.txt +2 -0
- utopia_analyzer-1.0.0.dist-info/licenses/LICENSE +21 -0
- utopia_analyzer-1.0.0.dist-info/top_level.txt +1 -0
utopia/github_client.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""GitHub REST API client with caching, pagination, and rate-limit handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
18
|
+
CACHE_TTL_SECONDS = 3600 # 1 hour
|
|
19
|
+
MAX_RETRIES = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GitHubError(Exception):
|
|
23
|
+
"""Base exception for GitHub client errors."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimitError(GitHubError):
|
|
32
|
+
"""Raised when GitHub API rate limit is hit."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UserNotFoundError(GitHubError):
|
|
38
|
+
"""Raised when the requested GitHub user does not exist."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cache_dir() -> Path:
|
|
44
|
+
cache = Path.home() / ".utopia_cache"
|
|
45
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return cache
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cache_path(username: str) -> Path:
|
|
50
|
+
safe_name = hashlib.sha256(username.encode()).hexdigest()[:16]
|
|
51
|
+
return _cache_dir() / f"{safe_name}.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_cache(username: str) -> dict[str, Any] | None:
|
|
55
|
+
path = _cache_path(username)
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
fetched_at = data.get("_fetched_at", 0)
|
|
61
|
+
if time.time() - fetched_at > CACHE_TTL_SECONDS:
|
|
62
|
+
return None
|
|
63
|
+
return data
|
|
64
|
+
except (json.JSONDecodeError, OSError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _save_cache(username: str, data: dict[str, Any]) -> None:
|
|
69
|
+
data["_fetched_at"] = time.time()
|
|
70
|
+
path = _cache_path(username)
|
|
71
|
+
try:
|
|
72
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clear_cache(username: str) -> None:
|
|
78
|
+
path = _cache_path(username)
|
|
79
|
+
if path.exists():
|
|
80
|
+
path.unlink(missing_ok=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RepoData:
|
|
85
|
+
"""Normalized repository data collected by the client."""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
description: str | None
|
|
89
|
+
language: str | None
|
|
90
|
+
stars: int
|
|
91
|
+
forks: int
|
|
92
|
+
size_kb: int
|
|
93
|
+
created_at: str
|
|
94
|
+
updated_at: str
|
|
95
|
+
pushed_at: str | None
|
|
96
|
+
has_readme: bool
|
|
97
|
+
readme_word_count: int
|
|
98
|
+
open_issues_count: int
|
|
99
|
+
topics: list[str]
|
|
100
|
+
license: str | None
|
|
101
|
+
is_fork: bool
|
|
102
|
+
is_archived: bool
|
|
103
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
104
|
+
commit_activity: list[int] = field(default_factory=list)
|
|
105
|
+
has_tests: bool = False
|
|
106
|
+
has_workflows: bool = False
|
|
107
|
+
has_dockerfile: bool = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ProfileData:
|
|
112
|
+
"""Complete data bundle for a GitHub profile analysis."""
|
|
113
|
+
|
|
114
|
+
username: str
|
|
115
|
+
name: str | None
|
|
116
|
+
bio: str | None
|
|
117
|
+
followers: int
|
|
118
|
+
following: int
|
|
119
|
+
public_repos_count: int
|
|
120
|
+
created_at: str
|
|
121
|
+
repos: list[RepoData] = field(default_factory=list)
|
|
122
|
+
merged_prs_count: int = 0
|
|
123
|
+
total_languages: dict[str, int] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _default_headers(token: str | None) -> dict[str, str]:
|
|
127
|
+
headers = {"Accept": "application/vnd.github.v3+json"}
|
|
128
|
+
if token:
|
|
129
|
+
headers["Authorization"] = f"token {token}"
|
|
130
|
+
return headers
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _handle_response(response: requests.Response) -> Any:
|
|
134
|
+
if response.status_code == 404:
|
|
135
|
+
raise UserNotFoundError("GitHub user not found.", status_code=404)
|
|
136
|
+
if response.status_code in (403, 429):
|
|
137
|
+
reset = response.headers.get("X-RateLimit-Reset")
|
|
138
|
+
msg = "GitHub API rate limit exceeded."
|
|
139
|
+
if reset:
|
|
140
|
+
reset_time = time.strftime("%H:%M:%S", time.localtime(int(reset)))
|
|
141
|
+
msg += f" Resets at {reset_time}."
|
|
142
|
+
msg += " Use --token <PAT> for higher limits."
|
|
143
|
+
raise RateLimitError(msg, status_code=response.status_code)
|
|
144
|
+
if response.status_code >= 400:
|
|
145
|
+
raise GitHubError(
|
|
146
|
+
f"GitHub API error: {response.status_code} {response.text[:200]}",
|
|
147
|
+
status_code=response.status_code,
|
|
148
|
+
)
|
|
149
|
+
if response.status_code == 204:
|
|
150
|
+
return None
|
|
151
|
+
if not response.content:
|
|
152
|
+
return None
|
|
153
|
+
return response.json()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get(
|
|
157
|
+
session: requests.Session,
|
|
158
|
+
url: str,
|
|
159
|
+
token: str | None,
|
|
160
|
+
params: dict[str, Any] | None = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
response = session.get(url, headers=_default_headers(token), params=params or {}, timeout=30)
|
|
163
|
+
return _handle_response(response)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_all_pages(
|
|
167
|
+
session: requests.Session,
|
|
168
|
+
url: str,
|
|
169
|
+
token: str | None,
|
|
170
|
+
params: dict[str, Any] | None = None,
|
|
171
|
+
) -> list[dict[str, Any]]:
|
|
172
|
+
items: list[dict[str, Any]] = []
|
|
173
|
+
page = 1
|
|
174
|
+
per_page = 100
|
|
175
|
+
query_params = dict(params or {})
|
|
176
|
+
query_params["per_page"] = per_page
|
|
177
|
+
|
|
178
|
+
while True:
|
|
179
|
+
query_params["page"] = page
|
|
180
|
+
data = _get(session, url, token, query_params)
|
|
181
|
+
if not isinstance(data, list):
|
|
182
|
+
break
|
|
183
|
+
items.extend(data)
|
|
184
|
+
if len(data) < per_page:
|
|
185
|
+
break
|
|
186
|
+
page += 1
|
|
187
|
+
|
|
188
|
+
return items
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _fetch_with_202_retry(
|
|
192
|
+
session: requests.Session,
|
|
193
|
+
url: str,
|
|
194
|
+
token: str | None,
|
|
195
|
+
retries: int = MAX_RETRIES,
|
|
196
|
+
) -> Any:
|
|
197
|
+
"""Fetch an endpoint that may return 202 while computing data."""
|
|
198
|
+
for attempt in range(retries):
|
|
199
|
+
response = session.get(url, headers=_default_headers(token), timeout=30)
|
|
200
|
+
if response.status_code == 202:
|
|
201
|
+
time.sleep(1.5 * (attempt + 1))
|
|
202
|
+
continue
|
|
203
|
+
return _handle_response(response)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _check_file_exists(
|
|
208
|
+
session: requests.Session,
|
|
209
|
+
owner: str,
|
|
210
|
+
repo: str,
|
|
211
|
+
path: str,
|
|
212
|
+
token: str | None,
|
|
213
|
+
) -> bool:
|
|
214
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/contents/{path}"
|
|
215
|
+
try:
|
|
216
|
+
response = session.get(url, headers=_default_headers(token), timeout=30)
|
|
217
|
+
return response.status_code == 200
|
|
218
|
+
except requests.RequestException:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _fetch_readme_word_count(
|
|
223
|
+
session: requests.Session,
|
|
224
|
+
owner: str,
|
|
225
|
+
repo: str,
|
|
226
|
+
token: str | None,
|
|
227
|
+
) -> int:
|
|
228
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/readme"
|
|
229
|
+
try:
|
|
230
|
+
data = _get(session, url, token)
|
|
231
|
+
if not isinstance(data, dict):
|
|
232
|
+
return 0
|
|
233
|
+
content = data.get("content", "")
|
|
234
|
+
import base64
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
decoded = base64.b64decode(content).decode("utf-8", errors="ignore")
|
|
238
|
+
except Exception:
|
|
239
|
+
return 0
|
|
240
|
+
return len(decoded.split())
|
|
241
|
+
except (GitHubError, requests.RequestException):
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _fetch_repo_tree_flags(
|
|
246
|
+
session: requests.Session,
|
|
247
|
+
owner: str,
|
|
248
|
+
repo: str,
|
|
249
|
+
token: str | None,
|
|
250
|
+
) -> dict[str, bool]:
|
|
251
|
+
"""Check for test files, workflows, and Dockerfile via repository tree."""
|
|
252
|
+
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/git/trees/HEAD?recursive=1"
|
|
253
|
+
try:
|
|
254
|
+
data = _get(session, url, token)
|
|
255
|
+
if not isinstance(data, dict):
|
|
256
|
+
return {"has_tests": False, "has_workflows": False, "has_dockerfile": False}
|
|
257
|
+
tree = data.get("tree", [])
|
|
258
|
+
paths = [item.get("path", "").lower() for item in tree]
|
|
259
|
+
|
|
260
|
+
has_tests = any(
|
|
261
|
+
"test" in p
|
|
262
|
+
and (
|
|
263
|
+
p.endswith((".py", ".js", ".ts", ".java", ".go", ".rs", ".rb", ".cpp", ".c"))
|
|
264
|
+
or "test" in p.split("/")[-1]
|
|
265
|
+
)
|
|
266
|
+
for p in paths
|
|
267
|
+
) or any(p.startswith("tests/") or p.startswith("test/") for p in paths)
|
|
268
|
+
|
|
269
|
+
has_workflows = any(p.startswith(".github/workflows/") for p in paths)
|
|
270
|
+
has_dockerfile = any("dockerfile" in p or "docker-compose" in p for p in paths)
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"has_tests": has_tests,
|
|
274
|
+
"has_workflows": has_workflows,
|
|
275
|
+
"has_dockerfile": has_dockerfile,
|
|
276
|
+
}
|
|
277
|
+
except (GitHubError, requests.RequestException):
|
|
278
|
+
return {"has_tests": False, "has_workflows": False, "has_dockerfile": False}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _fetch_repo_details(
|
|
282
|
+
session: requests.Session,
|
|
283
|
+
owner: str,
|
|
284
|
+
repo_name: str,
|
|
285
|
+
token: str | None,
|
|
286
|
+
) -> RepoData:
|
|
287
|
+
repo_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo_name}"
|
|
288
|
+
repo = _get(session, repo_url, token)
|
|
289
|
+
if not isinstance(repo, dict):
|
|
290
|
+
raise GitHubError(f"Unexpected response for repo {repo_name}")
|
|
291
|
+
|
|
292
|
+
has_readme = _check_file_exists(session, owner, repo_name, "README.md", token)
|
|
293
|
+
readme_word_count = _fetch_readme_word_count(session, owner, repo_name, token) if has_readme else 0
|
|
294
|
+
|
|
295
|
+
languages_url = repo.get("languages_url", "")
|
|
296
|
+
languages: dict[str, int] = {}
|
|
297
|
+
if languages_url:
|
|
298
|
+
try:
|
|
299
|
+
langs = _get(session, languages_url, token)
|
|
300
|
+
if isinstance(langs, dict):
|
|
301
|
+
languages = {k: v for k, v in langs.items() if isinstance(v, int)}
|
|
302
|
+
except GitHubError:
|
|
303
|
+
languages = {}
|
|
304
|
+
|
|
305
|
+
commit_url = f"{GITHUB_API_BASE}/repos/{owner}/{repo_name}/stats/commit_activity"
|
|
306
|
+
commit_activity_raw = _fetch_with_202_retry(session, commit_url, token)
|
|
307
|
+
commit_activity: list[int] = []
|
|
308
|
+
if isinstance(commit_activity_raw, list):
|
|
309
|
+
commit_activity = [
|
|
310
|
+
int(week.get("total", 0)) if isinstance(week, dict) else 0
|
|
311
|
+
for week in commit_activity_raw
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
tree_flags = _fetch_repo_tree_flags(session, owner, repo_name, token)
|
|
315
|
+
|
|
316
|
+
license_info = repo.get("license") or {}
|
|
317
|
+
license_name = license_info.get("spdx_id") if isinstance(license_info, dict) else None
|
|
318
|
+
|
|
319
|
+
return RepoData(
|
|
320
|
+
name=repo_name,
|
|
321
|
+
description=repo.get("description"),
|
|
322
|
+
language=repo.get("language"),
|
|
323
|
+
stars=int(repo.get("stargazers_count", 0) or 0),
|
|
324
|
+
forks=int(repo.get("forks_count", 0) or 0),
|
|
325
|
+
size_kb=int(repo.get("size", 0) or 0),
|
|
326
|
+
created_at=repo.get("created_at", ""),
|
|
327
|
+
updated_at=repo.get("updated_at", ""),
|
|
328
|
+
pushed_at=repo.get("pushed_at"),
|
|
329
|
+
has_readme=has_readme,
|
|
330
|
+
readme_word_count=readme_word_count,
|
|
331
|
+
open_issues_count=int(repo.get("open_issues_count", 0) or 0),
|
|
332
|
+
topics=list(repo.get("topics", []) or []),
|
|
333
|
+
license=license_name,
|
|
334
|
+
is_fork=bool(repo.get("fork", False)),
|
|
335
|
+
is_archived=bool(repo.get("archived", False)),
|
|
336
|
+
languages=languages,
|
|
337
|
+
commit_activity=commit_activity,
|
|
338
|
+
has_tests=tree_flags["has_tests"],
|
|
339
|
+
has_workflows=tree_flags["has_workflows"],
|
|
340
|
+
has_dockerfile=tree_flags["has_dockerfile"],
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _fetch_merged_prs_count(
|
|
345
|
+
session: requests.Session,
|
|
346
|
+
username: str,
|
|
347
|
+
token: str | None,
|
|
348
|
+
) -> int:
|
|
349
|
+
url = urljoin(
|
|
350
|
+
GITHUB_API_BASE,
|
|
351
|
+
"/search/issues",
|
|
352
|
+
)
|
|
353
|
+
params = {
|
|
354
|
+
"q": f"author:{username} type:pr is:merged",
|
|
355
|
+
"per_page": 1,
|
|
356
|
+
}
|
|
357
|
+
try:
|
|
358
|
+
data = _get(session, url, token, params)
|
|
359
|
+
if isinstance(data, dict):
|
|
360
|
+
return int(data.get("total_count", 0) or 0)
|
|
361
|
+
except (GitHubError, requests.RequestException):
|
|
362
|
+
pass
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def fetch_profile(
|
|
367
|
+
username: str,
|
|
368
|
+
token: str | None = None,
|
|
369
|
+
no_cache: bool = False,
|
|
370
|
+
progress_callback: callable | None = None,
|
|
371
|
+
) -> ProfileData:
|
|
372
|
+
"""Fetch and normalize all data needed for a UTOPIA report."""
|
|
373
|
+
if no_cache:
|
|
374
|
+
_clear_cache(username)
|
|
375
|
+
|
|
376
|
+
cached = _load_cache(username)
|
|
377
|
+
if cached is not None:
|
|
378
|
+
profile = _deserialize_profile(cached)
|
|
379
|
+
if progress_callback:
|
|
380
|
+
progress_callback("Using cached data", 1.0)
|
|
381
|
+
return profile
|
|
382
|
+
|
|
383
|
+
session = requests.Session()
|
|
384
|
+
try:
|
|
385
|
+
user_url = f"{GITHUB_API_BASE}/users/{username}"
|
|
386
|
+
user = _get(session, user_url, token)
|
|
387
|
+
if not isinstance(user, dict):
|
|
388
|
+
raise GitHubError("Unexpected response from GitHub user endpoint.")
|
|
389
|
+
|
|
390
|
+
repos_url = user.get("repos_url", "")
|
|
391
|
+
repos_raw = _get_all_pages(
|
|
392
|
+
session,
|
|
393
|
+
repos_url,
|
|
394
|
+
token,
|
|
395
|
+
{"type": "owner", "sort": "pushed", "direction": "desc"},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
repos: list[RepoData] = []
|
|
399
|
+
total = len(repos_raw)
|
|
400
|
+
for idx, repo in enumerate(repos_raw):
|
|
401
|
+
if progress_callback:
|
|
402
|
+
progress_callback(f"Analyzing {repo.get('name', 'repo')}", idx / max(total, 1))
|
|
403
|
+
try:
|
|
404
|
+
repos.append(_fetch_repo_details(session, username, repo["name"], token))
|
|
405
|
+
except GitHubError as exc:
|
|
406
|
+
if exc.status_code in (403, 429):
|
|
407
|
+
raise
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
if progress_callback:
|
|
411
|
+
progress_callback("Finalizing analysis", 1.0)
|
|
412
|
+
|
|
413
|
+
merged_prs = _fetch_merged_prs_count(session, username, token)
|
|
414
|
+
|
|
415
|
+
total_languages: dict[str, int] = {}
|
|
416
|
+
for repo in repos:
|
|
417
|
+
for lang, bytes_count in repo.languages.items():
|
|
418
|
+
total_languages[lang] = total_languages.get(lang, 0) + bytes_count
|
|
419
|
+
|
|
420
|
+
profile = ProfileData(
|
|
421
|
+
username=username,
|
|
422
|
+
name=user.get("name"),
|
|
423
|
+
bio=user.get("bio"),
|
|
424
|
+
followers=int(user.get("followers", 0) or 0),
|
|
425
|
+
following=int(user.get("following", 0) or 0),
|
|
426
|
+
public_repos_count=int(user.get("public_repos", 0) or 0),
|
|
427
|
+
created_at=user.get("created_at", ""),
|
|
428
|
+
repos=repos,
|
|
429
|
+
merged_prs_count=merged_prs,
|
|
430
|
+
total_languages=total_languages,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
_save_cache(username, _serialize_profile(profile))
|
|
434
|
+
return profile
|
|
435
|
+
finally:
|
|
436
|
+
session.close()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _serialize_profile(profile: ProfileData) -> dict[str, Any]:
|
|
440
|
+
return {
|
|
441
|
+
"username": profile.username,
|
|
442
|
+
"name": profile.name,
|
|
443
|
+
"bio": profile.bio,
|
|
444
|
+
"followers": profile.followers,
|
|
445
|
+
"following": profile.following,
|
|
446
|
+
"public_repos_count": profile.public_repos_count,
|
|
447
|
+
"created_at": profile.created_at,
|
|
448
|
+
"repos": [
|
|
449
|
+
{
|
|
450
|
+
"name": r.name,
|
|
451
|
+
"description": r.description,
|
|
452
|
+
"language": r.language,
|
|
453
|
+
"stars": r.stars,
|
|
454
|
+
"forks": r.forks,
|
|
455
|
+
"size_kb": r.size_kb,
|
|
456
|
+
"created_at": r.created_at,
|
|
457
|
+
"updated_at": r.updated_at,
|
|
458
|
+
"pushed_at": r.pushed_at,
|
|
459
|
+
"has_readme": r.has_readme,
|
|
460
|
+
"readme_word_count": r.readme_word_count,
|
|
461
|
+
"open_issues_count": r.open_issues_count,
|
|
462
|
+
"topics": r.topics,
|
|
463
|
+
"license": r.license,
|
|
464
|
+
"is_fork": r.is_fork,
|
|
465
|
+
"is_archived": r.is_archived,
|
|
466
|
+
"languages": r.languages,
|
|
467
|
+
"commit_activity": r.commit_activity,
|
|
468
|
+
"has_tests": r.has_tests,
|
|
469
|
+
"has_workflows": r.has_workflows,
|
|
470
|
+
"has_dockerfile": r.has_dockerfile,
|
|
471
|
+
}
|
|
472
|
+
for r in profile.repos
|
|
473
|
+
],
|
|
474
|
+
"merged_prs_count": profile.merged_prs_count,
|
|
475
|
+
"total_languages": profile.total_languages,
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _deserialize_profile(data: dict[str, Any]) -> ProfileData:
|
|
480
|
+
repos = [
|
|
481
|
+
RepoData(
|
|
482
|
+
name=r["name"],
|
|
483
|
+
description=r.get("description"),
|
|
484
|
+
language=r.get("language"),
|
|
485
|
+
stars=r["stars"],
|
|
486
|
+
forks=r["forks"],
|
|
487
|
+
size_kb=r["size_kb"],
|
|
488
|
+
created_at=r["created_at"],
|
|
489
|
+
updated_at=r["updated_at"],
|
|
490
|
+
pushed_at=r.get("pushed_at"),
|
|
491
|
+
has_readme=r["has_readme"],
|
|
492
|
+
readme_word_count=r["readme_word_count"],
|
|
493
|
+
open_issues_count=r["open_issues_count"],
|
|
494
|
+
topics=r["topics"],
|
|
495
|
+
license=r.get("license"),
|
|
496
|
+
is_fork=r["is_fork"],
|
|
497
|
+
is_archived=r["is_archived"],
|
|
498
|
+
languages=r.get("languages", {}),
|
|
499
|
+
commit_activity=r.get("commit_activity", []),
|
|
500
|
+
has_tests=r.get("has_tests", False),
|
|
501
|
+
has_workflows=r.get("has_workflows", False),
|
|
502
|
+
has_dockerfile=r.get("has_dockerfile", False),
|
|
503
|
+
)
|
|
504
|
+
for r in data.get("repos", [])
|
|
505
|
+
]
|
|
506
|
+
return ProfileData(
|
|
507
|
+
username=data["username"],
|
|
508
|
+
name=data.get("name"),
|
|
509
|
+
bio=data.get("bio"),
|
|
510
|
+
followers=data["followers"],
|
|
511
|
+
following=data["following"],
|
|
512
|
+
public_repos_count=data["public_repos_count"],
|
|
513
|
+
created_at=data["created_at"],
|
|
514
|
+
repos=repos,
|
|
515
|
+
merged_prs_count=data.get("merged_prs_count", 0),
|
|
516
|
+
total_languages=data.get("total_languages", {}),
|
|
517
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Generate a personalized 30-day improvement plan based on weak scoring categories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from utopia.github_client import ProfileData, RepoData
|
|
9
|
+
from utopia.scoring import ScoreBreakdown, UtopiaScore, weakest_categories
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Action templates mapped to scoring categories.
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
ACTION_TEMPLATES: dict[str, list[str]] = {
|
|
16
|
+
"project_quality": [
|
|
17
|
+
"Refactor your most-starred project and add missing features",
|
|
18
|
+
"Add a CI pipeline to at least one project",
|
|
19
|
+
"Clean up empty or fork-only repos that dilute your profile",
|
|
20
|
+
],
|
|
21
|
+
"activity": [
|
|
22
|
+
"Commit to at least 3 repos this week",
|
|
23
|
+
"Resume work on {stale_repo}",
|
|
24
|
+
"Close stale issues and merge small PRs to show momentum",
|
|
25
|
+
],
|
|
26
|
+
"documentation": [
|
|
27
|
+
"Improve README files across your top repos",
|
|
28
|
+
"Add usage examples and screenshots to {top_repo}",
|
|
29
|
+
"Write a one-paragraph project summary for every repo missing a description",
|
|
30
|
+
],
|
|
31
|
+
"tech_diversity": [
|
|
32
|
+
"Try a small project in {missing_language}",
|
|
33
|
+
"Add a new language or framework to an existing side project",
|
|
34
|
+
"Contribute to an open-source project outside your main stack",
|
|
35
|
+
],
|
|
36
|
+
"community_impact": [
|
|
37
|
+
"Add topics/tags to your top repos for discoverability",
|
|
38
|
+
"Write a blog post about {top_repo}",
|
|
39
|
+
"Share your best project on social media or developer forums",
|
|
40
|
+
],
|
|
41
|
+
"open_source": [
|
|
42
|
+
"Open a pull request to a project you use",
|
|
43
|
+
"Review and merge community PRs in your own repos",
|
|
44
|
+
"Turn a useful script into a real open-source project",
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Personalization helpers
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
def _most_starred_repo(repos: list[RepoData]) -> str | None:
|
|
53
|
+
original = [r for r in repos if not r.is_fork]
|
|
54
|
+
if not original:
|
|
55
|
+
return None
|
|
56
|
+
return max(original, key=lambda r: r.stars).name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _most_stale_repo(repos: list[RepoData]) -> str | None:
|
|
60
|
+
from datetime import datetime, timezone
|
|
61
|
+
|
|
62
|
+
def days_since(repo: RepoData) -> float:
|
|
63
|
+
if not repo.pushed_at:
|
|
64
|
+
return float("inf")
|
|
65
|
+
try:
|
|
66
|
+
dt = datetime.fromisoformat(repo.pushed_at.replace("Z", "+00:00"))
|
|
67
|
+
return (datetime.now(timezone.utc) - dt).total_seconds() / 86400
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
return float("inf")
|
|
70
|
+
|
|
71
|
+
original = [r for r in repos if not r.is_fork]
|
|
72
|
+
if not original:
|
|
73
|
+
return None
|
|
74
|
+
return max(original, key=days_since).name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _missing_common_language(profile: ProfileData) -> str:
|
|
78
|
+
"""Suggest a common language not already prominent in the profile."""
|
|
79
|
+
common = ["Python", "JavaScript", "TypeScript", "Go", "Rust", "Java"]
|
|
80
|
+
existing = {lang.lower() for lang in profile.total_languages}
|
|
81
|
+
for lang in common:
|
|
82
|
+
if lang.lower() not in existing:
|
|
83
|
+
return lang
|
|
84
|
+
return random.choice(common)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ImprovementPlan:
|
|
89
|
+
weeks: list[list[str]] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_improvement_plan(
|
|
93
|
+
profile: ProfileData,
|
|
94
|
+
score: UtopiaScore,
|
|
95
|
+
) -> ImprovementPlan:
|
|
96
|
+
"""Build a 4-week plan focusing on the weakest scoring categories."""
|
|
97
|
+
repos = profile.repos
|
|
98
|
+
top_repo = _most_starred_repo(repos) or "your best project"
|
|
99
|
+
stale_repo = _most_stale_repo(repos) or "an old project"
|
|
100
|
+
missing_language = _missing_common_language(profile)
|
|
101
|
+
|
|
102
|
+
weak = weakest_categories(score, n=4)
|
|
103
|
+
if len(weak) < 4:
|
|
104
|
+
# Pad with remaining categories if fewer than 4 are weak.
|
|
105
|
+
used = {b.category for b in weak}
|
|
106
|
+
remaining = [b for b in score.breakdown if b.category not in used]
|
|
107
|
+
weak.extend(remaining[: 4 - len(weak)])
|
|
108
|
+
|
|
109
|
+
weeks: list[list[str]] = []
|
|
110
|
+
for breakdown in weak[:4]:
|
|
111
|
+
templates = list(ACTION_TEMPLATES.get(breakdown.category, []))
|
|
112
|
+
random.shuffle(templates)
|
|
113
|
+
action = templates[0].format(
|
|
114
|
+
top_repo=top_repo,
|
|
115
|
+
stale_repo=stale_repo,
|
|
116
|
+
missing_language=missing_language,
|
|
117
|
+
)
|
|
118
|
+
weeks.append([action])
|
|
119
|
+
|
|
120
|
+
return ImprovementPlan(weeks=weeks)
|