From 68460bde1b4a8126e0d9949e5aeb7f5bbffc59ff Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 5 Oct 2026 15:41:11 +0200 Subject: [PATCH] feat: generate the profile numbers from ghapi scripts/update_readme_stats.py rewrites the intro totals, the numbers table, the languages table, the frameworks line and the open source list in both copies of both READMEs from ghapi.dualfroz.com, between stats markers, and a daily workflow runs it. --- .github/workflows/stats.yml | 24 +++++ scripts/update_readme_stats.py | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 .github/workflows/stats.yml create mode 100644 scripts/update_readme_stats.py diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml new file mode 100644 index 0000000..3198d2d --- /dev/null +++ b/.github/workflows/stats.yml @@ -0,0 +1,24 @@ +name: stats + +on: + schedule: + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: write + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Refresh numbers from ghapi + run: python3 scripts/update_readme_stats.py + - name: Commit when something changed + run: | + if git diff --quiet; then exit 0; fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "chore: refresh readme stats" + git push diff --git a/scripts/update_readme_stats.py b/scripts/update_readme_stats.py new file mode 100644 index 0000000..0753ab5 --- /dev/null +++ b/scripts/update_readme_stats.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Refresh the numbers in the VulCodeCom profile READMEs (pl and en) from ghapi. + +Rewrites four places, all from https://ghapi.dualfroz.com/v2/public: +- the intro sentence with lines of code and open source totals, +- the "In numbers" table and the diff block (between markers), +- the languages table and the frameworks line (), +- the open source list (). +Run it from the repository root: python3 scripts/update_readme_stats.py +""" +import json +import re +import urllib.request + +API = "https://ghapi.dualfroz.com/v2/public" +FILES = {"pl": ["profile/README.md", "README.md"], "en": ["profile/README.en.md", "README.en.md"]} + +TEXT = { + "pl": { + "loc": "linii kodu", "commits": "commitów", "projects": "projektów", + "oss_stars": "gwiazdek projektów OSS", "oss_projects": "projektów open source", + "languages": "języków", "days": "dni pracy", + "added": "linii dodanych", "removed": "linii usuniętych", "net": "linii netto", + "head": ("Język", "Linie kodu", "Commity", "Udział"), + "oss_intro": "> Projekty, do których regularnie kontrybuujemy (tylko te z zaakceptowanymi zmianami). " + "Projekty posortowane według liczby gwiazdek. Łącznie {stars}+ ⭐ w {repos} repozytoriach.", + "oss_head": "| Projekt | Gwiazdki | Język | Zmiany |", + }, + "en": { + "loc": "lines of code", "commits": "commits", "projects": "projects", + "oss_stars": "OSS project stars", "oss_projects": "open source projects", + "languages": "languages", "days": "working days", + "added": "lines added", "removed": "lines removed", "net": "net lines", + "head": ("Language", "Lines of code", "Commits", "Share"), + "oss_intro": "Code that made it into projects we use every day. **{prs}** merged pull requests " + "in **{repos}** repositories with **{stars}+** stars combined.", + "oss_head": "| Project | Stars | Language | Changes |", + }, +} + + +def get(path): + request = urllib.request.Request(f"{API}{path}", headers={"User-Agent": "dualfroz-readme"}) + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + + +def num(value, lang): + text = f"{int(value):,}" + return text.replace(",", " ") if lang == "pl" else text + + +def floor_to(value, step): + return value // step * step + + +def stars_short(value, lang): + if value < 1000: + return str(value) + k = f"{value / 1000:.1f}".rstrip("0").rstrip(".") + return f"{k.replace('.', ',')} tys." if lang == "pl" else f"{k}k" + + +def block(content, name, body): + start, end = f"", f"" + pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.S) + if not pattern.search(content): + raise SystemExit(f"missing {start} markers") + return pattern.sub(lambda _: f"{start}\n{body}\n{end}", content) + + +def summary(s, lang): + t, oss = s["totals"], s["openSource"]["contributed"] + T = TEXT[lang] + net = t["linesAdded"] - t["linesRemoved"] + return f""" + + + + + + + + + + + +

{num(t["linesAdded"], lang)}

{T["loc"]}

{num(t["commits"], lang)}

{T["commits"]}

200+

{T["projects"]}

{num(floor_to(oss["stars"], 10_000), lang)}+

{T["oss_stars"]}

{num(oss["repos"], lang)}

{T["oss_projects"]}

{num(t["languages"], lang)}

{T["languages"]}

{num(t["activeDays"], lang)}

{T["days"]}
+ +```diff ++ {num(t["linesAdded"], lang)} {T["added"]} +- {num(t["linesRemoved"], lang)} {T["removed"]} +! {num(net, lang)} {T["net"]} +```""" + + +def languages(langs, frameworks, icons, lang): + T = TEXT[lang] + rows = [] + for item in langs: + pct = item["share"] * 100 + share = f"{pct:.1f}%" + if lang == "pl": + share = share.replace(".", ",") + bar = "█" * max(1, round(pct / 4.5)) + icon = icons.get(item["name"], "") + icon_td = f'{item[' if icon else "" + rows.append( + f' {icon_td}{item["name"]}' + f'{num(item["linesAdded"], lang)}' + f'{num(item["commits"], lang)}' + f'{share} {bar}' + ) + head = "".join(f'{h}' for i, h in enumerate(T["head"])) + table = '\n ' + head + "\n" + "\n".join(rows) + "\n
" + fw = " · ".join(f'{f["name"]} {f["repos"]}' for f in frameworks) + heading = ("**Frameworki i narzędzia wykryte w projektach** (liczba repozytoriów)" if lang == "pl" + else "**Frameworks & tools detected across projects** (repository count)") + return f"{table}\n\n{heading}\n\n{fw}" + + +def opensource(oss, lang): + T = TEXT[lang] + merged = sorted((p for p in oss["projects"] if p["merged"] > 0), key=lambda p: -p["stars"]) + stars = floor_to(oss["contributed"]["stars"], 10_000) + intro_line = T["oss_intro"].format(stars=num(stars, lang), repos=len(merged), prs=num(oss["pullRequests"]["merged"], lang)) + lines = [intro_line, "", T["oss_head"], "|---|---|---|---|"] + for p in merged: + lines.append( + f'| [{p["name"]}]({p["url"]}) | ⭐ {stars_short(p["stars"], lang)} | {p.get("language") or "-"} | ' + f'+{p["linesAdded"]} -{p["linesRemoved"]} |' + ) + return "\n".join(lines) + + +def intro(content, s, lang): + t, oss = s["totals"], s["openSource"]["contributed"] + millions = t["linesAdded"] // 1_000_000 + stars = floor_to(oss["stars"], 10_000) + if lang == "pl": + content = re.sub(r"ponad \*\*\d+ mln linijek kodu\*\*", f"ponad **{millions} mln linijek kodu**", content) + content = re.sub(r"cegiełkę do \*\*\d+ repozytoriów open source\*\*", f"cegiełkę do **{oss['repos']} repozytoriów open source**", content) + content = re.sub(r"mają \*\*[\d ]+\+ gwiazdek\*\*", f"mają **{num(stars, lang)}+ gwiazdek**", content) + else: + content = re.sub(r"more than \*\*\d+ million lines of code\*\*", f"more than **{millions} million lines of code**", content) + content = re.sub(r"contributed to \*\*\d+ open source repositories\*\*", f"contributed to **{oss['repos']} open source repositories**", content) + content = re.sub(r"\*\*[\d,]+\+ combined stars\*\*", f"**{num(stars, lang)}+ combined stars**", content) + return content + + +def main(): + frameworks = get("/frameworks")["frameworks"] + oss = get("/opensource?limit=500") + for lang, paths in FILES.items(): + s = get(f"/summary?lang={lang}") + langs = get(f"/languages?limit=100&lang={lang}")["languages"] + for path in paths: + content = open(path, encoding="utf-8").read() + icons = dict((m.group(2), m.group(1)) for m in re.finditer( + r'([^', content)) + content = intro(content, s, lang) + content = block(content, "summary", summary(s, lang)) + content = block(content, "languages", languages(langs, frameworks, icons, lang)) + content = block(content, "opensource", opensource(oss, lang)) + open(path, "w", encoding="utf-8").write(content) + print(f"{path}: updated") + + +if __name__ == "__main__": + main()