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.
This commit is contained in:
1 parent
32648fe5ad
commit
68460bde1b
2 files changed
+194
No files matched your search
@@ -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
|
||||
@@ -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 <!-- stats:summary --> markers),
|
||||
- the languages table and the frameworks line (<!-- stats:languages -->),
|
||||
- the open source list (<!-- stats:opensource -->).
|
||||
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"<!-- stats:{name} -->", f"<!-- /stats:{name} -->"
|
||||
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"""<table>
|
||||
<tr>
|
||||
<td align="center" colspan="2" width="50%"><h3>{num(t["linesAdded"], lang)}</h3>{T["loc"]}</td>
|
||||
<td align="center" width="25%"><h3>{num(t["commits"], lang)}</h3>{T["commits"]}</td>
|
||||
<td align="center" width="25%"><h3>200+</h3>{T["projects"]}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><h3>{num(floor_to(oss["stars"], 10_000), lang)}+</h3>{T["oss_stars"]}</td>
|
||||
<td align="center" width="25%"><h3>{num(oss["repos"], lang)}</h3>{T["oss_projects"]}</td>
|
||||
<td align="center" width="25%"><h3>{num(t["languages"], lang)}</h3>{T["languages"]}</td>
|
||||
<td align="center" width="25%"><h3>{num(t["activeDays"], lang)}</h3>{T["days"]}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```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'<img src="{icon}" width="18" alt="{item["name"]}">' if icon else ""
|
||||
rows.append(
|
||||
f' <tr><td>{icon_td}</td><td><b>{item["name"]}</b></td>'
|
||||
f'<td align="right">{num(item["linesAdded"], lang)}</td>'
|
||||
f'<td align="right">{num(item["commits"], lang)}</td>'
|
||||
f'<td align="right">{share} <sub>{bar}</sub></td></tr>'
|
||||
)
|
||||
head = "".join(f'<th align="{"left" if i == 0 else "right"}">{h}</th>' for i, h in enumerate(T["head"]))
|
||||
table = '<table align="center">\n <tr><th></th>' + head + "</tr>\n" + "\n".join(rows) + "\n</table>"
|
||||
fw = " · ".join(f'{f["name"]} <sub>{f["repos"]}</sub>' for f in frameworks)
|
||||
heading = ("**Frameworki i narzędzia wykryte w projektach** <sub>(liczba repozytoriów)</sub>" if lang == "pl"
|
||||
else "**Frameworks & tools detected across projects** <sub>(repository count)</sub>")
|
||||
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'<code>+{p["linesAdded"]}</code> <code>-{p["linesRemoved"]}</code> |'
|
||||
)
|
||||
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'<tr><td><img src="([^"]+)" width="18" alt="([^"]+)"></td>', 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()
|
||||
Reference in new issue
Block a user