profile
This commit is contained in:
1 parent
de7259d32b
commit
41858dbf48
5 files changed
+314
-1
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -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 |
@@ -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 |
Reference in new issue
Block a user