mirror of
https://github.com/mukul975/Anthropic-Cybersecurity-Skills.git
synced 2026-08-28 20:29:40 +03:00
GitHub's /contributors endpoint is heavily cached and can lag a merge by up to a day. dakshverma23's commit from #129 was already linked to their account - /commits reports it, and the commit API confirms the link - but they were absent from the contributor wall because /contributors had not refreshed. update-contributors.py now unions the two endpoints: /contributors for the authoritative counts and ordering, /commits for anyone linked but not yet surfaced. Commits authored with an unlinkable email still appear in neither, which matches what GitHub's own contributor graph shows. Wall goes from 13 to 14.
172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate the contributor avatar wall in README.md from the GitHub API.
|
|
|
|
Writes between the markers:
|
|
|
|
<!-- contributors:start -->
|
|
...generated...
|
|
<!-- contributors:end -->
|
|
|
|
Avatars are served from github.com/<login>.png rather than a third-party
|
|
contributor-image service. That is deliberate: a README image is fetched on
|
|
every page view, so an external host would be an uncontrolled dependency in the
|
|
most-viewed file in the repository. GitHub's own avatar endpoint has neither
|
|
problem, and GitHub proxies it through camo like any other image.
|
|
|
|
Usage:
|
|
python tools/update-contributors.py # rewrite README.md
|
|
python tools/update-contributors.py --check # exit 1 if out of date
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
|
|
REPO = os.environ.get("GITHUB_REPOSITORY", "mukul975/Anthropic-Cybersecurity-Skills")
|
|
MAINTAINER = REPO.split("/")[0]
|
|
README = "README.md"
|
|
|
|
START = "<!-- contributors:start -->"
|
|
END = "<!-- contributors:end -->"
|
|
|
|
# Accounts that are bots or automation, excluded from the wall.
|
|
EXCLUDE_SUFFIXES = ("[bot]",)
|
|
EXCLUDE_LOGINS = {"github-actions", "dependabot", "pull"}
|
|
|
|
AVATAR_PX = 72
|
|
|
|
|
|
def _get(path: str):
|
|
req = urllib.request.Request(f"https://api.github.com/repos/{REPO}/{path}", headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "update-contributors",
|
|
})
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
if token:
|
|
req.add_header("Authorization", f"Bearer {token}")
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
return json.load(resp)
|
|
|
|
|
|
def _keep(login: str) -> bool:
|
|
return bool(login) and not login.endswith(EXCLUDE_SUFFIXES) and login not in EXCLUDE_LOGINS
|
|
|
|
|
|
def fetch_contributors() -> list[dict]:
|
|
"""Every non-bot contributor, most contributions first.
|
|
|
|
Two endpoints, because one of them lies. /contributors carries the
|
|
authoritative contribution counts but is heavily cached — a merge can take
|
|
up to a day to show up there. /commits is live. So anyone whose commit is
|
|
already linked to their account but has not yet surfaced in /contributors
|
|
gets picked up from the commit list instead of waiting a day to be thanked.
|
|
|
|
Commits authored with an unlinkable email (a machine hostname such as
|
|
user@HOST.localdomain) have no `author` object and are skipped by both
|
|
paths. They never appear in GitHub's own contributor graph either, so the
|
|
wall matches what GitHub itself shows.
|
|
"""
|
|
people: list[dict] = []
|
|
page = 1
|
|
while True:
|
|
batch = _get(f"contributors?per_page=100&page={page}")
|
|
if not batch:
|
|
break
|
|
people.extend(batch)
|
|
if len(batch) < 100:
|
|
break
|
|
page += 1
|
|
|
|
known = {p.get("login") for p in people}
|
|
ranked = [p for p in people if p.get("type") != "Bot" and _keep(p.get("login", ""))]
|
|
|
|
# Catch anyone the cached endpoint has not caught up with yet.
|
|
recent: dict[str, int] = {}
|
|
for page in (1, 2):
|
|
for commit in _get(f"commits?per_page=100&page={page}") or []:
|
|
author = commit.get("author")
|
|
if not author or author.get("type") == "Bot":
|
|
continue
|
|
login = author.get("login", "")
|
|
if login in known or not _keep(login):
|
|
continue
|
|
recent[login] = recent.get(login, 0) + 1
|
|
|
|
for login, count in sorted(recent.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
ranked.append({"login": login, "contributions": count})
|
|
|
|
return ranked
|
|
|
|
|
|
def render(people: list[dict]) -> str:
|
|
lines = ['<p align="center">']
|
|
for person in people:
|
|
login = person["login"]
|
|
count = person.get("contributions", 0)
|
|
plural = "" if count == 1 else "s"
|
|
title = f"{login} — maintainer" if login == MAINTAINER else f"{login} — {count} contribution{plural}"
|
|
lines.append(
|
|
f'<a href="https://github.com/{login}" title="{title}">'
|
|
f'<img src="https://github.com/{login}.png?size=100" '
|
|
f'width="{AVATAR_PX}" height="{AVATAR_PX}" alt="@{login}"></a>'
|
|
)
|
|
lines.append("</p>")
|
|
lines.append("")
|
|
lines.append(
|
|
f'<p align="center"><sub>{len(people)} contributors, ordered by contribution count · '
|
|
f'see the full <a href="https://github.com/{REPO}/graphs/contributors">contributor graph</a>'
|
|
"</sub></p>"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def splice(readme: str, block: str) -> str:
|
|
if START not in readme or END not in readme:
|
|
raise SystemExit(
|
|
f"ERROR: {README} is missing the {START} / {END} markers. "
|
|
"Add them around the contributor wall."
|
|
)
|
|
head, rest = readme.split(START, 1)
|
|
_, tail = rest.split(END, 1)
|
|
return f"{head}{START}\n{block}\n{END}{tail}"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true",
|
|
help="exit 1 if README.md is out of date; do not write")
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.isfile(README):
|
|
print(f"ERROR: {README} not found. Run from the repository root.")
|
|
return 1
|
|
|
|
people = fetch_contributors()
|
|
if not people:
|
|
print("ERROR: the API returned no contributors; refusing to blank the section.")
|
|
return 1
|
|
|
|
current = open(README, encoding="utf-8").read()
|
|
updated = splice(current, render(people))
|
|
|
|
if updated == current:
|
|
print(f"OK: contributor wall is current ({len(people)} contributors)")
|
|
return 0
|
|
|
|
if args.check:
|
|
print(f"ERROR: contributor wall is out of date ({len(people)} contributors). "
|
|
"Run: python tools/update-contributors.py")
|
|
return 1
|
|
|
|
with open(README, "w", encoding="utf-8", newline="") as handle:
|
|
handle.write(updated)
|
|
print(f"Updated contributor wall: {len(people)} contributors")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|