#!/usr/bin/env python3 """Report daily new GitHub stars for a repository using the GitHub CLI.""" from __future__ import annotations import argparse import csv import json import math import subprocess import sys from collections import Counter from datetime import UTC, date, datetime, timedelta from pathlib import Path from typing import Any, TextIO from xml.sax.saxutils import escape DEFAULT_REPOSITORY = "agentscope-ai/ReMe" QUERY = """ query($owner: String!, $name: String!, $before: String) { repository(owner: $owner, name: $name) { nameWithOwner stargazerCount stargazers(last: 100, before: $before) { edges { starredAt } pageInfo { hasPreviousPage startCursor } } } } """ def parse_args() -> argparse.Namespace: """Parse and validate command-line arguments.""" parser = argparse.ArgumentParser( description="Print daily new-star counts for the latest N UTC calendar days.", ) parser.add_argument( "--repo", default=DEFAULT_REPOSITORY, metavar="OWNER/REPO", help=f"GitHub repository (default: {DEFAULT_REPOSITORY})", ) parser.add_argument( "--days", type=int, default=365, help="number of UTC calendar days to include (default: 365)", ) parser.add_argument( "--output", type=Path, metavar="PATH", help="write CSV to PATH instead of standard output", ) parser.add_argument( "--chart", type=Path, metavar="PATH", help="write the SVG chart to PATH (default: CSV path with an .svg suffix)", ) args = parser.parse_args() if args.days < 1: parser.error("--days must be at least 1") if args.repo.count("/") != 1 or any(not part for part in args.repo.split("/")): parser.error("--repo must have the form OWNER/REPO") return args def query_github(owner: str, name: str, before: str | None) -> dict[str, Any]: """Fetch one page of repository stargazers through ``gh api graphql``.""" request = { "query": QUERY, "variables": {"owner": owner, "name": name, "before": before}, } try: result = subprocess.run( ["gh", "api", "graphql", "--input", "-"], input=json.dumps(request), text=True, capture_output=True, check=False, ) except FileNotFoundError as exc: raise RuntimeError("GitHub CLI 'gh' is not installed or is not on PATH") from exc if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"gh api graphql failed: {detail}") try: payload = json.loads(result.stdout) except json.JSONDecodeError as exc: raise RuntimeError("gh returned invalid JSON") from exc if payload.get("errors"): messages = "; ".join(error.get("message", str(error)) for error in payload["errors"]) raise RuntimeError(f"GitHub GraphQL API error: {messages}") repository = payload.get("data", {}).get("repository") if repository is None: raise RuntimeError(f"repository not found or inaccessible: {owner}/{name}") return repository def fetch_daily_stars(repository: str, start_date: date) -> tuple[Counter[date], int]: """Fetch current stargazers and group their star timestamps by UTC date.""" owner, name = repository.split("/", maxsplit=1) daily_stars: Counter[date] = Counter() before: str | None = None total_stars = 0 while True: data = query_github(owner, name, before) total_stars = data["stargazerCount"] connection = data["stargazers"] edges = connection["edges"] reached_start = False for edge in edges: starred_at = datetime.fromisoformat(edge["starredAt"].replace("Z", "+00:00")) starred_date = starred_at.astimezone(UTC).date() if starred_date < start_date: reached_start = True continue daily_stars[starred_date] += 1 page_info = connection["pageInfo"] if reached_start or not page_info["hasPreviousPage"]: break before = page_info["startCursor"] if before is None: raise RuntimeError("GitHub returned an invalid pagination cursor") return daily_stars, total_stars def write_csv(stream: TextIO, start_date: date, days: int, counts: Counter[date]) -> None: """Write a row for every date in the requested period, including zero days.""" writer = csv.writer(stream, lineterminator="\n") writer.writerow(["date", "new_stars"]) for offset in range(days): current_date = start_date + timedelta(days=offset) writer.writerow([current_date.isoformat(), counts[current_date]]) def nice_tick_step(maximum: int, tick_count: int = 5) -> int: """Return a readable positive interval for numeric axis ticks.""" rough_step = max(maximum, 1) / tick_count magnitude = 10 ** math.floor(math.log10(rough_step)) normalized = rough_step / magnitude if normalized <= 1: multiplier = 1 elif normalized <= 2: multiplier = 2 elif normalized <= 5: multiplier = 5 else: multiplier = 10 return max(1, multiplier * magnitude) def write_svg_chart(path: Path, repository: str, dates: list[date], values: list[int]) -> None: """Render daily star increments as a dependency-free SVG bar chart.""" width, height = 1280, 640 margin_left, margin_right = 75, 30 margin_top, margin_bottom = 70, 85 plot_width = width - margin_left - margin_right plot_height = height - margin_top - margin_bottom tick_step = nice_tick_step(max(values, default=0)) axis_max = max(tick_step, math.ceil(max(values, default=0) / tick_step) * tick_step) bar_slot = plot_width / len(values) bar_width = max(0.8, bar_slot * 0.82) svg = [ '', ( f'' ), f"{escape(repository)} daily new GitHub stars", '', ( f'{escape(repository)} daily new stars' ), ] for tick in range(0, axis_max + 1, tick_step): y = margin_top + plot_height - (tick / axis_max * plot_height) svg.append( f'', ) svg.append( f'{tick}', ) for index, (current_date, value) in enumerate(zip(dates, values, strict=True)): bar_height = value / axis_max * plot_height x = margin_left + index * bar_slot + (bar_slot - bar_width) / 2 y = margin_top + plot_height - bar_height svg.append( f'{current_date.isoformat()}: {value} new stars', ) label_count = min(12, len(dates)) label_indexes = sorted({round(index * (len(dates) - 1) / max(label_count - 1, 1)) for index in range(label_count)}) baseline = margin_top + plot_height for index in label_indexes: x = margin_left + (index + 0.5) * bar_slot svg.append( f'', ) svg.append( f'{dates[index].isoformat()}', ) svg.extend( [ ( f'' ), ( f'New stars' ), "", ], ) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(svg) + "\n", encoding="utf-8") def main() -> int: """Run the report and return a process exit code.""" args = parse_args() end_date = datetime.now(UTC).date() start_date = end_date - timedelta(days=args.days - 1) try: counts, total_stars = fetch_daily_stars(args.repo, start_date) dates = [start_date + timedelta(days=offset) for offset in range(args.days)] values = [counts[current_date] for current_date in dates] if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) with args.output.open("w", encoding="utf-8", newline="") as stream: write_csv(stream, start_date, args.days, counts) else: write_csv(sys.stdout, start_date, args.days, counts) chart_path = args.chart or (args.output.with_suffix(".svg") if args.output else Path("star_growth.svg")) write_svg_chart(chart_path, args.repo, dates, values) except (OSError, RuntimeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 destination = str(args.output) if args.output else "stdout" period_stars = sum(counts.values()) print( f"Fetched {period_stars} current stargazers in {start_date}..{end_date}; " f"repository currently has {total_stars} stars. CSV: {destination}; chart: {chart_path}", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main())