mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
281 lines
10 KiB
Python
Executable file
281 lines
10 KiB
Python
Executable file
#!/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 = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
(
|
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
|
f'viewBox="0 0 {width} {height}" role="img">'
|
|
),
|
|
f"<title>{escape(repository)} daily new GitHub stars</title>",
|
|
'<rect width="100%" height="100%" fill="#ffffff"/>',
|
|
(
|
|
f'<text x="{width / 2}" y="34" text-anchor="middle" font-family="sans-serif" '
|
|
f'font-size="22" font-weight="600" fill="#24292f">{escape(repository)} daily new stars</text>'
|
|
),
|
|
]
|
|
|
|
for tick in range(0, axis_max + 1, tick_step):
|
|
y = margin_top + plot_height - (tick / axis_max * plot_height)
|
|
svg.append(
|
|
f'<line x1="{margin_left}" y1="{y:.2f}" x2="{width - margin_right}" y2="{y:.2f}" '
|
|
'stroke="#d8dee4" stroke-width="1"/>',
|
|
)
|
|
svg.append(
|
|
f'<text x="{margin_left - 10}" y="{y + 4:.2f}" text-anchor="end" font-family="sans-serif" '
|
|
f'font-size="12" fill="#57606a">{tick}</text>',
|
|
)
|
|
|
|
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'<rect x="{x:.2f}" y="{y:.2f}" width="{bar_width:.2f}" height="{bar_height:.2f}" '
|
|
f'fill="#2f81f7"><title>{current_date.isoformat()}: {value} new stars</title></rect>',
|
|
)
|
|
|
|
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'<line x1="{x:.2f}" y1="{baseline}" x2="{x:.2f}" y2="{baseline + 5}" stroke="#57606a"/>',
|
|
)
|
|
svg.append(
|
|
f'<text x="{x:.2f}" y="{baseline + 20}" text-anchor="end" '
|
|
f'transform="rotate(-35 {x:.2f} {baseline + 20})" font-family="sans-serif" '
|
|
f'font-size="11" fill="#57606a">{dates[index].isoformat()}</text>',
|
|
)
|
|
|
|
svg.extend(
|
|
[
|
|
(
|
|
f'<line x1="{margin_left}" y1="{baseline}" x2="{width - margin_right}" y2="{baseline}" '
|
|
'stroke="#57606a"/>'
|
|
),
|
|
(
|
|
f'<text x="18" y="{margin_top + plot_height / 2}" text-anchor="middle" '
|
|
f'transform="rotate(-90 18 {margin_top + plot_height / 2})" font-family="sans-serif" '
|
|
'font-size="13" fill="#24292f">New stars</text>'
|
|
),
|
|
"</svg>",
|
|
],
|
|
)
|
|
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())
|