This commit is contained in:
nprasad2077 committed 2026-11-06 10:12:04 -06:00
1 parent de7259d32b
commit 41858dbf48
5 files changed
+314 -1

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Regenerate profile stats cards
# Runs daily; recompute stats cards via the repo's own generate_stats.py
# (stdlib-only, no external service) and commit them to this repo, where
# README.md embeds them.
on:
schedule:
- cron: "30 4 * * *"
workflow_dispatch: {}
jobs:
stats:
runs-on: ubuntu-latest
steps:
- name: Checkout profile repo
uses: actions/checkout@v4
- name: Generate stats images
env:
GIT_URL: https://git.turbo-data.com
USER: doctasmooth
# Token with "read:user" + "read:repository" scopes; include private
# repos in the stats. Leave empty to only use public data.
ACCESS_TOKEN: ${{ secrets.STATS_READ_TOKEN }}
run: python3 generate_stats.py
- name: Commit updated cards
run: |
git config user.name "stats-bot"
git config user.email "[email protected]"
git add stats/
if [[ -n "$(git status --porcelain stats/)" ]]; then
git commit -m "chore: regenerate profile stats cards"
git push https://${{ secrets.STATS_COMMIT_TOKEN }}@git.turbo-data.com/doctasmooth/.profile.git HEAD:main
else
echo "Stats unchanged, nothing to commit"
fi
+24 -1
View File
@@ -1,2 +1,25 @@
# .profile
# doctasmooth
<!-- Overview card shown on the Gitea profile page.
Images referenced by relative path render from this repo. -->
<!-- Generated stats cards (see .gitea/workflows/stats.yml).
Transparent background + neutral palette, readable in light AND dark mode. -->
<p align="center">
<img src="stats/overview.svg" alt="Activity stats" width="480" />
<img src="stats/languages.svg" alt="Most used languages" width="480" />
</p>
## About
Short bio / what I work on goes here.
## Active projects
- [repo-1](https://git.turbo-data.com/doctasmooth/repo-1) — one-line description
- [repo-2](https://git.turbo-data.com/doctasmooth/repo-2) — one-line description
## Contact
- `@doctasmooth` on this instance
- email / URL of your choice
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""Generate profile stats cards (SVG) for a Gitea/Forgejo user profile README.
Stdlib only (no pip dependencies). Pulls public data from the Gitea API and
renders two cards:
stats/overview.svg - repos, stars earned, forks, followers, stars given
stats/languages.svg - top languages bar + legend
Configuration via environment variables:
GIT_URL base URL of the instance (default: https://git.turbo-data.com)
USER username to generate cards for
ACCESS_TOKEN optional API token ("read:user" + "read:repository" scopes);
omit if the profile is fully public
OUTPUT_DIR where to write the cards (default: stats)
Cards use a transparent background and a neutral palette that stays readable
in both Gitea light and dark themes.
"""
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timezone
GIT_URL = os.environ.get("GIT_URL", "https://git.turbo-data.com").rstrip("/")
USER = os.environ.get("USER", "") or sys.exit("USER env var not set")
TOKEN = os.environ.get("ACCESS_TOKEN", "") # optional
OUT = os.environ.get("OUTPUT_DIR", "stats")
UA = "gitea-profile-stats/1.0"
API = GIT_URL + "/api/v1"
# Neutral palette readable on both light (#fff) and dark (#0d1117) backgrounds
INK = "#768390" # secondary text
INK_STRONG = "#a5acaf" # primary text
ACCENT = "#4493f8"
STROKE = "#768390"
MAX_LANGS = 5
# Small fallback language color table (used if remote colors fetch fails)
FALLBACK_COLORS = {
"Python": "#3572A5", "TypeScript": "#3178c6", "JavaScript": "#f1e05a",
"Shell": "#89e051", "Go": "#00ADD8", "Rust": "#dea584", "C": "#555555",
"C++": "#f34b7d", "Java": "#b07219", "HTML": "#e34c26", "CSS": "#563d7c",
"SQL": "#e38c00", "Dockerfile": "#384d54", "Makefile": "#427819",
"JSON": "#404e5c", "Markdown": "#083fa1", "YAML": "#cb171e",
"Ruby": "#701516", "PHP": "#4F5D95", "Lua": "#000080",
"SCSS": "#c6538c", "Vue": "#41b883", "Dart": "#00B4AB", "Kotlin": "#A97BFF",
}
def api(path, params=None):
"""GET {API}{path} -> (json_body, response_headers) or (None, None) on failure."""
url = f"{API}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": UA})
if TOKEN:
req.add_header("Authorization", f"token {TOKEN}")
try:
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r), dict(r.headers)
except Exception: # noqa: BLE001 - some endpoints 401 without a token
return None, None
def total_count(path, params=None):
"""Count a list endpoint via X-Total-Count, using a cheap page-1 call.
Returns None when the endpoint is not available (e.g. 401 without a token).
"""
_body, headers = api(path, dict(params or {}, limit=1))
if not headers:
return None
return int(headers.get("x-total-count", 0))
def esc(s):
return (
str(s).replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;")
)
def fetch():
user, _ = api(f"/users/{urllib.parse.quote(USER)}")
repos, _ = api(f"/users/{urllib.parse.quote(USER)}/repos", {"limit": 50})
data = {
"name": user.get("full_name") or user.get("login", USER),
"login": user.get("login", USER),
"visit_year": user.get("created", "")[:4],
"repositories": user.get("public_repos", len(repos)),
"followers": total_count(f"/users/{urllib.parse.quote(USER)}/followers"),
"following": total_count(f"/users/{urllib.parse.quote(USER)}/following"),
}
stars = forks = 0
by_lang = {}
for r in repos:
stars += r.get("stars_count", 0) or 0
forks += r.get("forks_count", 0) or 0
lang = (r.get("language") or "").strip()
if lang:
weight = r.get("size") or 1 # primary-language weight by repo size
by_lang[lang] = by_lang.get(lang, 0) + weight
data.update(stars=stars, forks=forks, languages=by_lang,
starred=total_count(f"/users/{urllib.parse.quote(USER)}/starred"))
return data
def colors_for(languages):
colors = {}
try:
req = urllib.request.Request(
"https://raw.githubusercontent.com/ozh/github-colors/master/colors.json",
headers={"User-Agent": UA},
)
with urllib.request.urlopen(req, timeout=10) as r:
colors = {k: "#" + v["color"] for k, v in json.load(r).items()
if k in languages}
except Exception: # noqa: BLE001 - offline instances still need cards
pass
autofill = [f"#{(abs(hash(l)) % 0xAAAAAA) + 0x555555:06x}"
for i, l in enumerate(languages)]
return {l: colors.get(l, FALLBACK_COLORS.get(l, c))
for l, c in zip(languages, autofill)}
def svg_head(w, h):
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" '
f'height="{h}" role="img" aria-label="profile stats">'
'<style>text{font-family:-apple-system,BlinkMacSystemFont,'
'"Segoe UI",Helvetica,Arial,sans-serif;}</style>')
def stat_items(d):
return [
("Repositories", d["repositories"]),
("Stars earned", d["stars"]),
("Forks", d["forks"]),
("Followers", d["followers"]),
("Stars given", d["starred"]),
("Member since", d["visit_year"] or "—"),
]
def draw_overview(d):
items = stat_items(d)
cells = []
for i, (label, value) in enumerate(items):
x = 26 + (i % 2) * 228
y = 84 + (i // 2) * 44
cells.append(
f'<g><rect x="{x}" y="{y - 15}" width="11" height="11" rx="2.5" '
f'fill="{ACCENT}" opacity="0.85"/>'
f'<text x="{x + 20}" y="{y}" font-size="13" fill="{INK}">{esc(label)}</text>'
f'<text x="{x + 192}" y="{y}" font-size="13" text-anchor="end" '
f'fill="{INK_STRONG}" font-weight="600">{esc(value if value is not None else "—")}</text></g>'
)
return (
svg_head(480, 200)
+ f'<rect x="1.5" y="1.5" width="477" height="197" rx="12" fill="none" '
f'stroke="{STROKE}" stroke-opacity="0.35" stroke-width="1.5"/>'
+ f'<text x="26" y="44" font-size="18" font-weight="700" fill="{ACCENT}">'
+ esc(d["name"]) + "</text>"
+ f'<text x="454" y="44" font-size="12" fill="{INK}" text-anchor="end">'
+ esc(f"Gitea · @{d['login']}") + "</text>"
+ "".join(cells)
+ f'<text x="26" y="188" font-size="10" fill="{INK}" opacity="0.7">'
+ esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d"))
+ "</text></svg>"
)
def draw_languages(d):
langs = sorted(d["languages"].items(), key=lambda kv: -kv[1])[:MAX_LANGS]
cells = []
if langs:
total = sum(v for _, v in langs)
colors = colors_for([l for l, _ in langs])
x = 26
bar_w = 428
segs = []
legend = []
for lang, v in langs:
share = v / total
w = bar_w * share if share > 0.02 else max(bar_w * share, 3)
segs.append(
f'<rect x="{x:.1f}" y="66" width="{w:.1f}" height="10" rx="4" '
f'fill="{colors[lang]}"/>'
)
lx = 26 + ((len(legend) % 2) * 214)
ly = 106 + (len(legend) // 2) * 26
legend.append(
f'<g><circle cx="{lx + 5}" cy="{ly - 4}" r="5" fill="{colors[lang]}"/>'
f'<text x="{lx + 16}" y="{ly}" font-size="12" fill="{INK}">'
+ esc(lang)
+ f'</text><text x="{lx + 198}" y="{ly}" font-size="12" '
f'text-anchor="end" fill="{INK_STRONG}" font-weight="600">'
f"{share * 100:.1f}%</text></g>"
)
x += w
label = f"<text x=\"26\" y=\"42\" font-size=\"16\" font-weight=\"700\" fill=\"{INK_STRONG}\">Top languages</text>"
footer_y = 186 if len(legend) > 3 else 146
cells = (
[label]
+ [f'<rect x="{bar_x}" y="{bar_y}" width="{bar_w}" height="10" rx="5" '
f'fill="none" stroke="{STROKE}" stroke-opacity="0.35"/>'
for bar_x, bar_y in [(25.5, 65.5)]]
+ segs
+ legend
+ [f'<text x="26" y="{footer_y}" font-size="10" fill="{INK}" opacity="0.7">'
+ esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d"))
+ "</text>"]
)
height = footer_y + 16
else:
height = 100
cells = [
f'<text x="26" y="46" font-size="14" fill="{INK}">No public repositories '
f'with languages yet.</text>',
]
body = (
svg_head(480, height)
+ f'<rect x="1.5" y="1.5" width="477" height="{height - 3}" rx="12" '
f'fill="none" stroke="{STROKE}" stroke-opacity="0.35" stroke-width="1.5"/>'
+ "".join(cells)
+ "</svg>"
)
return body
def main():
os.makedirs(OUT, exist_ok=True)
d = fetch()
print(f"Loaded profile for {d['login']}: {d['repositories']} repos, "
f"{d['stars']} stars, {d['forks']} forks, {d['followers']} followers")
for name, svg in (("overview", draw_overview(d)),
("languages", draw_languages(d))):
path = os.path.join(OUT, f"{name}.svg")
with open(path, "w", encoding="utf-8") as f:
f.write(svg)
print(f"wrote {path}")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="100" role="img" aria-label="profile stats"><style>text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}</style><rect x="1.5" y="1.5" width="477" height="97" rx="12" fill="none" stroke="#768390" stroke-opacity="0.35" stroke-width="1.5"/><text x="26" y="46" font-size="14" fill="#768390">No public repositories with languages yet.</text></svg>

After

Width:  |  Height:  |  Size: 437 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="200" role="img" aria-label="profile stats"><style>text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}</style><rect x="1.5" y="1.5" width="477" height="197" rx="12" fill="none" stroke="#768390" stroke-opacity="0.35" stroke-width="1.5"/><text x="26" y="44" font-size="18" font-weight="700" fill="#4493f8">doctasmooth</text><text x="454" y="44" font-size="12" fill="#768390" text-anchor="end">Gitea · @doctasmooth</text><g><rect x="26" y="69" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="46" y="84" font-size="13" fill="#768390">Repositories</text><text x="218" y="84" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">1</text></g><g><rect x="254" y="69" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="274" y="84" font-size="13" fill="#768390">Stars earned</text><text x="446" y="84" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">0</text></g><g><rect x="26" y="113" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="46" y="128" font-size="13" fill="#768390">Forks</text><text x="218" y="128" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">0</text></g><g><rect x="254" y="113" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="274" y="128" font-size="13" fill="#768390">Followers</text><text x="446" y="128" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">—</text></g><g><rect x="26" y="157" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="46" y="172" font-size="13" fill="#768390">Stars given</text><text x="218" y="172" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">—</text></g><g><rect x="254" y="157" width="11" height="11" rx="2.5" fill="#4493f8" opacity="0.85"/><text x="274" y="172" font-size="13" fill="#768390">Member since</text><text x="446" y="172" font-size="13" text-anchor="end" fill="#a5acaf" font-weight="600">2026</text></g><text x="26" y="188" font-size="10" fill="#768390" opacity="0.7">updated 2026-11-06</text></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB