From 41858dbf48cbd9de8e58432a2602a6f5e4f7e730 Mon Sep 17 00:00:00 2001 From: Ravi Prasad Date: Fri, 6 Nov 2026 10:12:04 -0600 Subject: [PATCH] profile --- .gitea/workflows/stats.yml | 37 ++++++ README.md | 25 +++- generate_stats.py | 251 +++++++++++++++++++++++++++++++++++++ stats/languages.svg | 1 + stats/overview.svg | 1 + 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/stats.yml create mode 100644 generate_stats.py create mode 100644 stats/languages.svg create mode 100644 stats/overview.svg diff --git a/.gitea/workflows/stats.yml b/.gitea/workflows/stats.yml new file mode 100644 index 0000000..499e88c --- /dev/null +++ b/.gitea/workflows/stats.yml @@ -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 "stats-bot@users.noreply.git.turbo-data.com" + 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 diff --git a/README.md b/README.md index 4ebce99..7beb9fa 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,25 @@ -# .profile +# doctasmooth + + + +

+ Activity stats + Most used languages +

+ +## 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 diff --git a/generate_stats.py b/generate_stats.py new file mode 100644 index 0000000..ab57ac0 --- /dev/null +++ b/generate_stats.py @@ -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'' + '') + + +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'' + f'{esc(label)}' + f'{esc(value if value is not None else "—")}' + ) + return ( + svg_head(480, 200) + + f'' + + f'' + + esc(d["name"]) + "" + + f'' + + esc(f"Gitea · @{d['login']}") + "" + + "".join(cells) + + f'' + + esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d")) + + "" + ) + + +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'' + ) + lx = 26 + ((len(legend) % 2) * 214) + ly = 106 + (len(legend) // 2) * 26 + legend.append( + f'' + f'' + + esc(lang) + + f'' + f"{share * 100:.1f}%" + ) + x += w + label = f"Top languages" + footer_y = 186 if len(legend) > 3 else 146 + cells = ( + [label] + + [f'' + for bar_x, bar_y in [(25.5, 65.5)]] + + segs + + legend + + [f'' + + esc("updated " + datetime.now(timezone.utc).strftime("%Y-%m-%d")) + + ""] + ) + height = footer_y + 16 + else: + height = 100 + cells = [ + f'No public repositories ' + f'with languages yet.', + ] + body = ( + svg_head(480, height) + + f'' + + "".join(cells) + + "" + ) + 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() diff --git a/stats/languages.svg b/stats/languages.svg new file mode 100644 index 0000000..3f940dc --- /dev/null +++ b/stats/languages.svg @@ -0,0 +1 @@ +No public repositories with languages yet. \ No newline at end of file diff --git a/stats/overview.svg b/stats/overview.svg new file mode 100644 index 0000000..4c1071d --- /dev/null +++ b/stats/overview.svg @@ -0,0 +1 @@ +doctasmoothGitea · @doctasmoothRepositories1Stars earned0Forks0Followers—Stars given—Member since2026updated 2026-11-06 \ No newline at end of file -- 2.54.0