-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgenerate_profile.py
More file actions
214 lines (182 loc) · 7.37 KB
/
Copy pathgenerate_profile.py
File metadata and controls
214 lines (182 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#!/usr/bin/env python3
"""Generate profile metrics SVGs and the 'Recently shipped' README section.
Stdlib only — no pip installs, no third-party stats services. Queries the
GitHub GraphQL API and renders theme-aware SVG panels that match the
header design system, then rewrites the marker-delimited README section.
Usage: GITHUB_TOKEN=... python3 scripts/generate_profile.py
"""
import json
import os
import re
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
USER = "copyleftdev"
ROOT = Path(__file__).resolve().parent.parent
QUERY = """
query {
user(login: "%s") {
createdAt
followers { totalCount }
contributionsCollection { contributionCalendar { totalContributions } }
allOriginals: repositories(ownerAffiliations: OWNER, isFork: false) {
totalCount
}
repositories(first: 100, ownerAffiliations: OWNER, privacy: PUBLIC,
isFork: false, orderBy: {field: PUSHED_AT, direction: DESC}) {
totalCount
nodes { name description pushedAt primaryLanguage { name } }
}
}
}
""" % USER
# GitHub linguist colors
LANG_COLORS = {
"Rust": "#dea584",
"Zig": "#ec915c",
"Python": "#3572A5",
"Go": "#00ADD8",
"Shell": "#89e051",
"TypeScript": "#3178c6",
"JavaScript": "#f1e05a",
"HTML": "#e34c26",
"Nim": "#ffc200",
"Assembly": "#6E4C13",
"C": "#555555",
"C++": "#f34b7d",
}
THEMES = {
"dark": dict(bg1="#0d1117", bg2="#161b22", border="#30363d",
text="#e6edf3", sub="#8b949e", accent="#58a6ff"),
"light": dict(bg1="#ffffff", bg2="#f6f8fa", border="#d0d7de",
text="#1f2328", sub="#57606a", accent="#0969da"),
}
MONO = "'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace"
def fetch(token):
req = urllib.request.Request(
"https://api.github.com/graphql",
data=json.dumps({"query": QUERY}).encode(),
headers={"Authorization": f"bearer {token}",
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
if "errors" in data:
sys.exit(f"GraphQL errors: {data['errors']}")
return data["data"]["user"]
def esc(s):
return (s.replace("&", "&").replace("<", "<").replace(">", ">"))
def render_metrics(user, theme):
t = THEMES[theme]
contribs = user["contributionsCollection"]["contributionCalendar"]["totalContributions"]
followers = user["followers"]["totalCount"]
repos = user["repositories"]
originals = repos["totalCount"]
created = datetime.fromisoformat(user["createdAt"].replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
years = now.year - created.year - ((now.month, now.day) < (created.month, created.day))
# allOriginals includes private repos only when the token can see them;
# pick the label to match what was actually counted so it can never lie
all_originals = user["allOriginals"]["totalCount"]
if all_originals > originals:
repo_stat = (f"{all_originals:,}", "original repos · incl private")
else:
repo_stat = (str(originals), "original public repos")
stats = [
(f"{contribs:,}", "contributions · past year"),
repo_stat,
(str(years), "years on github"),
(str(followers), "followers"),
]
langs = {}
for n in repos["nodes"]:
lang = (n["primaryLanguage"] or {}).get("name")
if lang:
langs[lang] = langs.get(lang, 0) + 1
total = sum(langs.values())
top = sorted(langs.items(), key=lambda kv: -kv[1])[:6]
shown = sum(c for _, c in top)
segments = [(name, count / total) for name, count in top]
if total - shown:
segments.append(("Other", (total - shown) / total))
parts = [
f'<svg width="100%" height="210" viewBox="0 0 1200 210" fill="none" xmlns="http://www.w3.org/2000/svg">',
'<defs>',
f'<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">'
f'<stop offset="0%" style="stop-color:{t["bg1"]}"/>'
f'<stop offset="100%" style="stop-color:{t["bg2"]}"/></linearGradient>',
'<clipPath id="bar"><rect x="100" y="148" width="1000" height="8" rx="4"/></clipPath>',
'</defs>',
f'<rect x="0.5" y="0.5" width="1199" height="209" rx="6" fill="url(#bg)" stroke="{t["border"]}"/>',
f'<text x="100" y="42" fill="{t["sub"]}" font-family="{MONO}" font-size="12" letter-spacing="3">'
f'LIVE TELEMETRY <tspan fill="{t["accent"]}">//</tspan> regenerated nightly from the GitHub API</text>',
]
x = 100
for value, label in stats:
parts.append(
f'<text x="{x}" y="100" fill="{t["text"]}" font-family="{MONO}" '
f'font-size="34" font-weight="700">{value}</text>')
parts.append(
f'<text x="{x}" y="122" fill="{t["sub"]}" font-family="{MONO}" '
f'font-size="11" letter-spacing="2">{esc(label.upper())}</text>')
x += 275
# language bar
bx = 100.0
for name, frac in segments:
w = frac * 1000
color = LANG_COLORS.get(name, t["sub"])
parts.append(
f'<rect x="{bx:.1f}" y="148" width="{w:.1f}" height="8" '
f'fill="{color}" clip-path="url(#bar)"/>')
bx += w
lx = 100
for name, frac in segments:
color = LANG_COLORS.get(name, t["sub"])
label = f"{name} {frac * 100:.0f}%"
parts.append(f'<circle cx="{lx + 4}" cy="178" r="4" fill="{color}"/>')
parts.append(
f'<text x="{lx + 14}" y="182" fill="{t["sub"]}" '
f'font-family="{MONO}" font-size="12">{esc(label)}</text>')
lx += 14 + int(len(label) * 7.3) + 26
parts.append('</svg>')
return "\n".join(parts) + "\n"
def truncate(desc, limit=96):
desc = " ".join((desc or "—").split())
if len(desc) <= limit:
return desc
return desc[: limit - 1].rsplit(" ", 1)[0].rstrip(",;:") + "…"
def render_recent(user, count=6):
rows = [
"| Repository | Description | Lang | Last push |",
"|:-----------|:------------|:----:|:----------|",
]
nodes = [n for n in user["repositories"]["nodes"] if n["name"] != USER]
for n in nodes[:count]:
lang = (n["primaryLanguage"] or {}).get("name") or "—"
desc = truncate(n["description"]).replace("|", "\\|")
pushed = n["pushedAt"][:10]
rows.append(
f"| [{n['name']}](https://github.com/{USER}/{n['name']}) "
f"| {desc} | $\\texttt{{{lang}}}$ | {pushed} |")
return "\n".join(rows)
def update_readme(section):
path = ROOT / "README.md"
text = path.read_text()
block = f"<!--RECENT:START-->\n{section}\n<!--RECENT:END-->"
new = re.sub(r"<!--RECENT:START-->.*?<!--RECENT:END-->", lambda _: block,
text, flags=re.S)
if "<!--RECENT:START-->" not in new:
sys.exit("README.md is missing the RECENT markers")
path.write_text(new)
def main():
token = os.environ.get("METRICS_TOKEN") or os.environ.get("GITHUB_TOKEN")
if not token:
sys.exit("METRICS_TOKEN or GITHUB_TOKEN is required")
user = fetch(token)
(ROOT / "assets" / "metrics.svg").write_text(render_metrics(user, "dark"))
(ROOT / "assets" / "metrics-light.svg").write_text(render_metrics(user, "light"))
update_readme(render_recent(user))
print("regenerated: assets/metrics.svg, assets/metrics-light.svg, README recent section")
if __name__ == "__main__":
main()