#!/usr/bin/env python3
"""
Generate Stargazers and Forkers roster SVG images for GitHub README.
Modern, minimal style - shows latest users with "and X others" text.
Usage:
python generate_roster.py --repo HKUDS/DeepTutor --output assets/roster
"""
import argparse
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import os
import ssl
import urllib.request
# Create SSL context that doesn't verify certificates (for macOS compatibility)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
def fetch_repo_stats(owner: str, repo: str, token: str = None) -> dict:
"""Fetch repository statistics (accurate counts) from GitHub API."""
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "Repo-Roster-Generator"}
if token:
headers["Authorization"] = f"token {token}"
url = f"https://api.github.com/repos/{owner}/{repo}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30, context=ssl_context) as response:
data = json.loads(response.read().decode())
return {
"stargazers_count": data.get("stargazers_count", 0),
"forks_count": data.get("forks_count", 0),
}
except Exception as e:
print(f"Error fetching repo stats: {e}")
return {"stargazers_count": 0, "forks_count": 0}
def fetch_github_api(url: str, token: str = None, count_only: bool = False) -> tuple:
"""Fetch data from GitHub API with pagination. Returns (users, total_count)."""
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "Repo-Roster-Generator"}
if token:
headers["Authorization"] = f"token {token}"
all_data = []
page = 1
per_page = 100
total_count = 0
while True:
paginated_url = f"{url}?per_page={per_page}&page={page}"
req = urllib.request.Request(paginated_url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30, context=ssl_context) as response:
data = json.loads(response.read().decode())
if not data:
break
all_data.extend(data)
total_count += len(data)
if len(data) < per_page:
break
page += 1
# Keep counting but limit data collection
if len(all_data) >= 500:
# Continue counting total
while True:
page += 1
paginated_url = f"{url}?per_page={per_page}&page={page}"
req = urllib.request.Request(paginated_url, headers=headers)
try:
with urllib.request.urlopen(
req, timeout=30, context=ssl_context
) as resp:
more_data = json.loads(resp.read().decode())
if not more_data:
break
total_count += len(more_data)
if len(more_data) < per_page:
break
except:
break
break
except Exception as e:
print(f"Error fetching {paginated_url}: {e}")
break
return all_data, total_count
def fetch_avatar_as_base64(avatar_url: str, size: int = 48) -> str:
"""Fetch avatar image and convert to base64 data URI."""
try:
if "?" in avatar_url:
avatar_url += f"&s={size}"
else:
avatar_url += f"?s={size}"
req = urllib.request.Request(avatar_url, headers={"User-Agent": "Repo-Roster-Generator"})
with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response:
data = response.read()
content_type = response.headers.get("Content-Type", "image/png")
base64_data = base64.b64encode(data).decode("utf-8")
return f"data:{content_type};base64,{base64_data}"
except Exception as e:
print(f"Error fetching avatar {avatar_url}: {e}")
return None
def generate_modern_roster_svg(
users: list,
total_count: int,
title: str,
theme: str = "dark",
display_count: int = 6,
avatar_size: int = 36,
) -> str:
"""Generate modern minimal SVG with overlapping avatars."""
# Get latest users (reverse to show newest first)
display_users = (
list(reversed(users[-display_count:]))
if len(users) >= display_count
else list(reversed(users))
)
others_count = total_count - len(display_users)
if not display_users:
return generate_empty_svg(title, theme)
# Theme colors
if theme == "dark":
bg_color = "#0d1117"
text_color = "#e6edf3"
muted_color = "#8b949e"
border_color = "#30363d"
accent_color = "#58a6ff"
else:
bg_color = "#ffffff"
text_color = "#1f2328"
muted_color = "#656d76"
border_color = "#d0d7de"
accent_color = "#0969da"
# Calculate dimensions
overlap = 12 # How much avatars overlap
avatar_spacing = avatar_size - overlap
avatars_width = avatar_size + (len(display_users) - 1) * avatar_spacing
padding_x = 24
padding_y = 16
# Text measurements (approximate)
title_width = len(title) * 9
others_text = f"and {others_count:,} others" if others_count > 0 else ""
others_width = len(others_text) * 7 if others_text else 0
total_content_width = avatars_width + 16 + others_width
width = max(total_content_width + padding_x * 2, 280)
height = avatar_size + padding_y * 2 + 28 # Extra space for title
# Fetch avatars
print(f"Fetching {len(display_users)} avatars for {title}...")
avatar_cache = {}
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_user = {
executor.submit(fetch_avatar_as_base64, user["avatar_url"], avatar_size * 2): user[
"login"
]
for user in display_users
}
for future in as_completed(future_to_user):
login = future_to_user[future]
try:
avatar_cache[login] = future.result()
except Exception as e:
print(f"Error processing {login}: {e}")
# Build SVG
svg_parts = [
f'")
return "\n".join(svg_parts)
def generate_empty_svg(title: str, theme: str) -> str:
"""Generate empty state SVG."""
if theme == "dark":
bg_color = "#0d1117"
text_color = "#8b949e"
else:
bg_color = "#ffffff"
text_color = "#656d76"
return f''''''
def main():
parser = argparse.ArgumentParser(description="Generate GitHub roster SVG images")
parser.add_argument("--repo", required=True, help="GitHub repo in format owner/repo")
parser.add_argument("--output", default="assets/roster", help="Output directory")
parser.add_argument("--theme", default="dark", choices=["dark", "light"], help="Color theme")
parser.add_argument("--display", type=int, default=6, help="Number of avatars to display")
parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"), help="GitHub token")
args = parser.parse_args()
owner, repo = args.repo.split("/")
# Create output directory
os.makedirs(args.output, exist_ok=True)
# Fetch accurate counts from repo API
print(f"Fetching repo stats for {args.repo}...")
repo_stats = fetch_repo_stats(owner, repo, args.token)
stargazers_total = repo_stats["stargazers_count"]
forks_total = repo_stats["forks_count"]
print(f"Repo stats: {stargazers_total:,} stars, {forks_total:,} forks")
# Fetch stargazers (only need latest ones for avatars)
print(f"Fetching stargazers for {args.repo}...")
stargazers_url = f"https://api.github.com/repos/{owner}/{repo}/stargazers"
stargazers, _ = fetch_github_api(stargazers_url, args.token)
print(f"Fetched {len(stargazers)} stargazer records for avatars")
# Fetch forkers (only need latest ones for avatars)
print(f"Fetching forkers for {args.repo}...")
forks_url = f"https://api.github.com/repos/{owner}/{repo}/forks"
forks, _ = fetch_github_api(forks_url, args.token)
forkers = [
{"login": f["owner"]["login"], "avatar_url": f["owner"]["avatar_url"]} for f in forks
]
print(f"Fetched {len(forkers)} forker records for avatars")
# Generate stargazers SVG
print("Generating stargazers SVG...")
stargazers_svg = generate_modern_roster_svg(
stargazers, stargazers_total, "Stargazers", theme=args.theme, display_count=args.display
)
stargazers_path = os.path.join(args.output, "stargazers.svg")
with open(stargazers_path, "w", encoding="utf-8") as f:
f.write(stargazers_svg)
print(f"Saved: {stargazers_path}")
# Generate forkers SVG
print("Generating forkers SVG...")
forkers_svg = generate_modern_roster_svg(
forkers, forks_total, "Forkers", theme=args.theme, display_count=args.display
)
forkers_path = os.path.join(args.output, "forkers.svg")
with open(forkers_path, "w", encoding="utf-8") as f:
f.write(forkers_svg)
print(f"Saved: {forkers_path}")
print("Done!")
if __name__ == "__main__":
main()