Skip to content

Commit af1a1ac

Browse files
Copilotjbampton
andauthored
Add leaderboard Python script and daily workflow
Agent-Logs-Url: https://github.com/NextCommunity/NextCommunity.github.io/sessions/aa529b1f-6c33-47b9-854e-af24ac43a895 Co-authored-by: jbampton <418747+jbampton@users.noreply.github.com>
1 parent eb20bc7 commit af1a1ac

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

.github/workflows/leaderboard.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Update organization leaderboard
2+
3+
on:
4+
schedule:
5+
- cron: "0 0 * * *" # every day at midnight UTC
6+
workflow_dispatch: # allow manual runs
7+
8+
jobs:
9+
leaderboard:
10+
if: github.repository == 'NextCommunity/NextCommunity.github.io'
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout code
14+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
15+
with:
16+
persist-credentials: false
17+
- name: Set up Python
18+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
19+
with:
20+
python-version: "3.12"
21+
- name: Run leaderboard script
22+
env:
23+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24+
run: python scripts/leaderboard.py
9.13 KB
Binary file not shown.

scripts/leaderboard.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Fetch contributor stats from all NextCommunity repos and update the org profile README."""
2+
3+
import base64
4+
import json
5+
import os
6+
import sys
7+
import urllib.request
8+
import urllib.error
9+
10+
ORG = "NextCommunity"
11+
TARGET_REPO = ".github"
12+
TARGET_PATH = "profile/README.md"
13+
LEADERBOARD_START = "<!-- LEADERBOARD:START -->"
14+
LEADERBOARD_END = "<!-- LEADERBOARD:END -->"
15+
API_BASE = "https://api.github.com"
16+
17+
18+
def _headers():
19+
"""Return common request headers including authentication if available."""
20+
token = os.environ.get("GITHUB_TOKEN", "")
21+
headers = {"Accept": "application/vnd.github+json", "User-Agent": ORG}
22+
if token:
23+
headers["Authorization"] = f"token {token}"
24+
return headers
25+
26+
27+
def _api_get(url):
28+
"""Perform a GET request against the GitHub API and return parsed JSON."""
29+
req = urllib.request.Request(url, headers=_headers())
30+
try:
31+
with urllib.request.urlopen(req) as resp:
32+
return json.loads(resp.read().decode())
33+
except urllib.error.HTTPError as exc:
34+
print(f"HTTP {exc.code} for {url}: {exc.reason}", file=sys.stderr)
35+
return None
36+
37+
38+
def _api_get_paginated(url):
39+
"""Paginate through all results for a GitHub API endpoint."""
40+
results = []
41+
page = 1
42+
while True:
43+
sep = "&" if "?" in url else "?"
44+
page_url = f"{url}{sep}page={page}&per_page=100"
45+
data = _api_get(page_url)
46+
if not data:
47+
break
48+
results.extend(data)
49+
if len(data) < 100:
50+
break
51+
page += 1
52+
return results
53+
54+
55+
def get_repos():
56+
"""Return a list of all public repos in the organization."""
57+
url = f"{API_BASE}/orgs/{ORG}/repos?type=public"
58+
repos = _api_get_paginated(url)
59+
return [r["full_name"] for r in repos if not r.get("fork")]
60+
61+
62+
def get_contributors(repo_full_name):
63+
"""Return contributor list for a single repo via the contributors endpoint."""
64+
url = f"{API_BASE}/repos/{repo_full_name}/contributors"
65+
data = _api_get_paginated(url)
66+
return data or []
67+
68+
69+
def build_leaderboard():
70+
"""Aggregate contributor commits across all org repos and return sorted list."""
71+
repos = get_repos()
72+
if not repos:
73+
print("No repos found.", file=sys.stderr)
74+
return []
75+
76+
contributors = {} # login -> {name, login, commits}
77+
for repo in repos:
78+
print(f"Fetching contributors for {repo} ...")
79+
for c in get_contributors(repo):
80+
if c.get("type") != "User":
81+
continue
82+
login = c["login"]
83+
if login not in contributors:
84+
contributors[login] = {
85+
"login": login,
86+
"commits": 0,
87+
}
88+
contributors[login]["commits"] += c.get("contributions", 0)
89+
90+
# Fetch display names from user profiles
91+
for login, info in contributors.items():
92+
user = _api_get(f"{API_BASE}/users/{login}")
93+
info["name"] = (user.get("name") or login) if user else login
94+
95+
# Sort by total commits descending
96+
leaderboard = sorted(contributors.values(), key=lambda x: x["commits"], reverse=True)
97+
return leaderboard
98+
99+
100+
def render_table(leaderboard):
101+
"""Render the leaderboard as a Markdown table."""
102+
lines = [
103+
"## 🏆 Organization Leaderboard",
104+
"",
105+
"| Rank | Contributor | Username | Total Commits |",
106+
"|------|------------|----------|---------------|",
107+
]
108+
for rank, entry in enumerate(leaderboard, start=1):
109+
name = entry["name"]
110+
login = entry["login"]
111+
commits = entry["commits"]
112+
lines.append(f"| {rank} | {name} | [@{login}](https://github.com/{login}) | {commits} |")
113+
114+
lines.append("")
115+
lines.append(f"_Last updated: {_now_utc()}_")
116+
return "\n".join(lines)
117+
118+
119+
def _now_utc():
120+
"""Return current UTC date-time as an ISO-like string (stdlib only)."""
121+
import datetime
122+
123+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
124+
125+
126+
def update_readme(leaderboard_md):
127+
"""Update the profile README in the target repo with the new leaderboard."""
128+
url = f"{API_BASE}/repos/{ORG}/{TARGET_REPO}/contents/{TARGET_PATH}"
129+
data = _api_get(url)
130+
if data is None:
131+
print(f"Could not fetch {TARGET_PATH} from {ORG}/{TARGET_REPO}", file=sys.stderr)
132+
sys.exit(1)
133+
134+
current_content = base64.b64decode(data["content"]).decode()
135+
sha = data["sha"]
136+
137+
section = f"{LEADERBOARD_START}\n{leaderboard_md}\n{LEADERBOARD_END}"
138+
139+
if LEADERBOARD_START in current_content and LEADERBOARD_END in current_content:
140+
start = current_content.index(LEADERBOARD_START)
141+
end = current_content.index(LEADERBOARD_END) + len(LEADERBOARD_END)
142+
new_content = current_content[:start] + section + current_content[end:]
143+
else:
144+
new_content = current_content.rstrip() + "\n\n" + section + "\n"
145+
146+
if new_content == current_content:
147+
print("Leaderboard is already up to date.")
148+
return
149+
150+
encoded = base64.b64encode(new_content.encode()).decode()
151+
payload = json.dumps(
152+
{
153+
"message": "Update organization leaderboard",
154+
"content": encoded,
155+
"sha": sha,
156+
}
157+
).encode()
158+
159+
token = os.environ.get("GITHUB_TOKEN", "")
160+
headers = {
161+
"Accept": "application/vnd.github+json",
162+
"User-Agent": ORG,
163+
"Content-Type": "application/json",
164+
}
165+
if token:
166+
headers["Authorization"] = f"token {token}"
167+
168+
req = urllib.request.Request(url, data=payload, headers=headers, method="PUT")
169+
try:
170+
with urllib.request.urlopen(req) as resp:
171+
print(f"README updated successfully ({resp.status}).")
172+
except urllib.error.HTTPError as exc:
173+
print(f"Failed to update README: HTTP {exc.code} {exc.reason}", file=sys.stderr)
174+
sys.exit(1)
175+
176+
177+
def main():
178+
leaderboard = build_leaderboard()
179+
if not leaderboard:
180+
print("No contributors found. Skipping update.")
181+
return
182+
table = render_table(leaderboard)
183+
print(table)
184+
update_readme(table)
185+
186+
187+
if __name__ == "__main__":
188+
main()

0 commit comments

Comments
 (0)