Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/update-star-history.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Update star history

on:
pull_request:
paths:
- ".github/workflows/update-star-history.yml"
- "scripts/generate_star_history.py"
schedule:
- cron: "17 */6 * * *"
workflow_dispatch:

permissions:
contents: write

jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Generate star history
env:
GITHUB_TOKEN: ${{ github.token }}
run: python scripts/generate_star_history.py /tmp/star-history.svg

- name: Validate SVG
run: python -c 'import xml.etree.ElementTree as ET; ET.parse("/tmp/star-history.svg")'

- name: Publish to star-history branch
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
encoded="$(base64 -w 0 /tmp/star-history.svg)"
current="$(
gh api \
"repos/${GITHUB_REPOSITORY}/contents/assets/star-history.svg?ref=star-history"
)"
current_content="$(jq -r '.content' <<< "${current}" | tr -d '\n')"

if [[ "${encoded}" == "${current_content}" ]]; then
echo "Star history is already up to date."
exit 0
fi

gh api \
--method PUT \
"repos/${GITHUB_REPOSITORY}/contents/assets/star-history.svg" \
-f branch=star-history \
-f message="Update star history" \
-f content="${encoded}" \
-f sha="$(jq -r '.sha' <<< "${current}")"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,4 @@ We would like to thank the contributors to [Wan](https://github.com/Wan-Video/Wa

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=OpenMOSS/MOVA&type=date&legend=top-left)](https://www.star-history.com/#OpenMOSS/MOVA&type=date&legend=top-left)
![Star History Chart](https://raw.githubusercontent.com/OpenMOSS/MOVA/star-history/assets/star-history.svg)
200 changes: 200 additions & 0 deletions scripts/generate_star_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
#!/usr/bin/env python3

import datetime as dt
import json
import math
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path


REPOSITORY = "OpenMOSS/MOVA"
API_VERSION = "2026-03-10"
WIDTH = 960
HEIGHT = 560
LEFT = 82
RIGHT = 32
TOP = 72
BOTTOM = 76
PLOT_WIDTH = WIDTH - LEFT - RIGHT
PLOT_HEIGHT = HEIGHT - TOP - BOTTOM


def github_json(url: str, token: str):
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github.star+json",
"Authorization": f"Bearer {token}",
"User-Agent": "OpenMOSS-MOVA-star-history-generator",
"X-GitHub-Api-Version": API_VERSION,
},
)
with urllib.request.urlopen(request) as response:
return json.load(response)


def fetch_stars(token: str):
stars = []
page = 1
while True:
query = urllib.parse.urlencode({"per_page": 100, "page": page})
batch = github_json(
f"https://api.github.com/repos/{REPOSITORY}/stargazers?{query}", token
)
stars.extend(batch)
if len(batch) < 100:
break
page += 1
return sorted(
dt.datetime.fromisoformat(item["starred_at"].replace("Z", "+00:00"))
for item in stars
)


def nice_ceiling(value: int) -> int:
if value <= 10:
return 10
raw_tick = value / 5
magnitude = 10 ** math.floor(math.log10(raw_tick))
normalized = raw_tick / magnitude
step = (
1
if normalized <= 1
else 2
if normalized <= 2
else 2.5
if normalized <= 2.5
else 5
if normalized <= 5
else 10
)
return int(math.ceil(value / (step * magnitude)) * step * magnitude)


def esc(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)


def render(stars):
if not stars:
raise RuntimeError("GitHub returned no stargazers")

start = stars[0].replace(hour=0, minute=0, second=0, microsecond=0)
now = dt.datetime.now(dt.timezone.utc)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = max(today, stars[-1])
span = max((end - start).total_seconds(), 1)
y_max = nice_ceiling(len(stars))

def x(timestamp):
return LEFT + ((timestamp - start).total_seconds() / span) * PLOT_WIDTH

def y(count):
return TOP + PLOT_HEIGHT - (count / y_max) * PLOT_HEIGHT

points = [(x(start), y(0))]
points.extend((x(timestamp), y(index)) for index, timestamp in enumerate(stars, 1))
line_path = " ".join(
("M" if index == 0 else "L") + f"{px:.2f},{py:.2f}"
for index, (px, py) in enumerate(points)
)
area_path = (
line_path
+ f" L{points[-1][0]:.2f},{TOP + PLOT_HEIGHT:.2f}"
+ f" L{LEFT:.2f},{TOP + PLOT_HEIGHT:.2f} Z"
)

y_ticks = []
for tick in range(6):
value = round(y_max * tick / 5)
py = y(value)
y_ticks.append(
f'<line class="grid" x1="{LEFT}" y1="{py:.2f}" '
f'x2="{WIDTH - RIGHT}" y2="{py:.2f}"/>'
)
y_ticks.append(
f'<text class="tick" x="{LEFT - 14}" y="{py + 5:.2f}" '
f'text-anchor="end">{value:,}</text>'
)

x_ticks = []
tick_count = 7
for tick in range(tick_count):
timestamp = start + (end - start) * tick / (tick_count - 1)
px = x(timestamp)
if tick == 0:
anchor = "start"
elif tick == tick_count - 1:
anchor = "end"
else:
anchor = "middle"
x_ticks.append(
f'<line class="grid" x1="{px:.2f}" y1="{TOP}" '
f'x2="{px:.2f}" y2="{TOP + PLOT_HEIGHT}"/>'
)
x_ticks.append(
f'<text class="tick" x="{px:.2f}" y="{TOP + PLOT_HEIGHT + 30}" '
f'text-anchor="{anchor}">{timestamp:%Y-%m-%d}</text>'
)

updated = now.strftime("%Y-%m-%d")
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}" role="img" aria-labelledby="title description">
<title id="title">{esc(REPOSITORY)} Star History</title>
<desc id="description">Cumulative GitHub stars for {esc(REPOSITORY)}, updated {updated}: {len(stars):,} stars.</desc>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2f81f7" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#2f81f7" stop-opacity="0.02"/>
</linearGradient>
<style>
.background {{ fill: #ffffff; }}
.border {{ fill: none; stroke: #d0d7de; }}
.grid {{ stroke: #d8dee4; stroke-width: 1; }}
.title {{ fill: #1f2328; font: 700 24px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.tick, .meta {{ fill: #656d76; font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
.value {{ fill: #1f2328; font: 600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
@media (prefers-color-scheme: dark) {{
.background {{ fill: #0d1117; }}
.border {{ stroke: #30363d; }}
.grid {{ stroke: #30363d; }}
.title, .value {{ fill: #e6edf3; }}
.tick, .meta {{ fill: #8b949e; }}
}}
</style>
</defs>
<rect class="background" width="{WIDTH}" height="{HEIGHT}" rx="10"/>
<rect class="border" x="0.5" y="0.5" width="{WIDTH - 1}" height="{HEIGHT - 1}" rx="10"/>
<text class="title" x="{LEFT}" y="40">{esc(REPOSITORY)} Star History</text>
{"".join(y_ticks)}
{"".join(x_ticks)}
<path d="{area_path}" fill="url(#area)"/>
<path d="{line_path}" fill="none" stroke="#2f81f7" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="{points[-1][0]:.2f}" cy="{points[-1][1]:.2f}" r="5" fill="#2f81f7"/>
<text class="value" x="{points[-1][0] - 8:.2f}" y="{points[-1][1] - 12:.2f}" text-anchor="end">{len(stars):,} stars</text>
<text class="meta" x="{LEFT}" y="{HEIGHT - 24}">Source: GitHub stargazers API</text>
<text class="meta" x="{WIDTH - RIGHT}" y="{HEIGHT - 24}" text-anchor="end">Updated {updated}</text>
</svg>
"""


def main():
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} OUTPUT.svg")
token = os.environ.get("GITHUB_TOKEN")
if not token:
raise SystemExit("GITHUB_TOKEN is required")
output = Path(sys.argv[1])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render(fetch_stars(token)), encoding="utf-8")


if __name__ == "__main__":
main()