Skip to content

Commit cd8531f

Browse files
committed
star updater
1 parent 59a5948 commit cd8531f

3 files changed

Lines changed: 152 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Refresh package stats
2+
3+
# Hourly refresh of stars / forks / last-updated in packages/packages.json from the
4+
# GitHub (and GitLab) APIs, committed back to the branch so the site serves fresh numbers.
5+
on:
6+
schedule:
7+
- cron: '17 * * * *' # every hour at :17 (off the top of the hour — GitHub's scheduler is busiest then)
8+
workflow_dispatch: {} # allow manual runs from the Actions tab
9+
10+
permissions:
11+
contents: write # lets the default GITHUB_TOKEN push the updated JSON
12+
13+
concurrency:
14+
group: refresh-package-stats
15+
cancel-in-progress: false
16+
17+
jobs:
18+
refresh:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: '20'
26+
27+
- name: Refresh stars / forks / updated
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 5000 req/hr, and read access to public repos
30+
run: node scripts/update-package-stats.mjs
31+
32+
- name: Commit if changed
33+
run: |
34+
if [ -n "$(git status --porcelain packages/packages.json)" ]; then
35+
git config user.name 'github-actions[bot]'
36+
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
37+
git add packages/packages.json
38+
git commit -m 'Refresh package stars/forks/stats'
39+
git push
40+
echo "Pushed refreshed stats."
41+
else
42+
echo "No stat changes."
43+
fi

packages/packages.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@
477477
"unity": "2022.3",
478478
"license": "MIT",
479479
"version": "2.3.4",
480-
"stars": 1478,
480+
"stars": 1479,
481481
"forks": 171,
482482
"icon": "\uD83C\uDFA8",
483483
"updated": "2026-06-25"

scripts/update-package-stats.mjs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env node
2+
// Refresh stars / forks / last-updated for every package in packages/packages.json.
3+
//
4+
// packages.json is emitted by System.Text.Json with \uXXXX escapes for all non-ASCII
5+
// (500+ of them, em-dashes and emoji included). Round-tripping it through JSON.stringify
6+
// would rewrite every one of those escapes and bury the real change in a giant diff — so
7+
// instead we parse it only to read ids and fetch fresh numbers, then surgically rewrite
8+
// just the three value lines per package. Every other byte is left exactly as-is.
9+
//
10+
// Zero dependencies — uses the built-in fetch (Node 18+). Set GITHUB_TOKEN to lift the
11+
// GitHub API rate limit to 5000/hr (the Actions default token is plenty); without it
12+
// GitHub allows 60/hr, which still covers a single run of this catalog.
13+
//
14+
// Usage: node scripts/update-package-stats.mjs [--dry]
15+
// --dry fetch and report what would change, but don't write the file.
16+
17+
import { readFile, writeFile } from 'node:fs/promises';
18+
19+
const FILE = new URL('../packages/packages.json', import.meta.url);
20+
const TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
21+
const DRY = process.argv.includes('--dry');
22+
const CONCURRENCY = 8;
23+
24+
// owner/repo (GitHub) or group/…/repo path (GitLab) from a repo or git URL,
25+
// stripping a trailing .git, a ?path= subfolder and a #ref.
26+
function parseRepo(u){
27+
if(!u) return null;
28+
const base = String(u).split(/[?#]/)[0].replace(/\.git$/i,'').replace(/\/+$/,'');
29+
let m;
30+
if((m = base.match(/github\.com[/:]+([^/]+)\/([^/]+)/i))) return { host:'github', owner:m[1], repo:m[2] };
31+
if((m = base.match(/gitlab\.com[/:]+(.+)$/i))) return { host:'gitlab', path:m[1] };
32+
return null;
33+
}
34+
const isoDate = s => (typeof s === 'string' && s.length >= 10) ? s.slice(0,10) : null; // YYYY-MM-DD
35+
36+
async function fetchStats(r){
37+
if(r.host === 'github'){
38+
const headers = { 'Accept':'application/vnd.github+json', 'User-Agent':'basisvr-stats-bot', 'X-GitHub-Api-Version':'2022-11-28' };
39+
if(TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
40+
const res = await fetch(`https://api.github.com/repos/${r.owner}/${r.repo}`, { headers });
41+
if(!res.ok) throw new Error(`GitHub ${res.status}`);
42+
const j = await res.json();
43+
return { stars:j.stargazers_count, forks:j.forks_count, updated:isoDate(j.pushed_at) };
44+
}
45+
const res = await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(r.path)}`, { headers:{ 'User-Agent':'basisvr-stats-bot' } });
46+
if(!res.ok) throw new Error(`GitLab ${res.status}`);
47+
const j = await res.json();
48+
return { stars:j.star_count, forks:j.forks_count, updated:isoDate(j.last_activity_at) };
49+
}
50+
51+
// ---- read + fetch (fresh: id -> {stars?,forks?,updated?}, only fields the API returned) ----
52+
const raw = await readFile(FILE, 'utf8');
53+
const pkgs = JSON.parse(raw);
54+
const fresh = new Map();
55+
let failed = 0;
56+
57+
async function worker(list){
58+
for(const p of list){
59+
const r = parseRepo(p.repoUrl || p.gitUrl);
60+
if(!r) continue;
61+
try{
62+
const s = await fetchStats(r);
63+
const clean = {};
64+
for(const k of ['stars','forks','updated']) if(s[k] != null) clean[k] = s[k];
65+
fresh.set(p.id, clean);
66+
}catch(e){ failed++; console.error(`! ${p.id}: ${e.message}`); }
67+
}
68+
}
69+
const lanes = Array.from({ length: CONCURRENCY }, () => []);
70+
pkgs.forEach((p,i) => lanes[i % CONCURRENCY].push(p)); // round-robin so one slow lane doesn't stall
71+
await Promise.all(lanes.map(worker));
72+
73+
// ---- report what changed (old -> new), independent of the surgical rewrite ----
74+
const report = [];
75+
for(const p of pkgs){
76+
const s = fresh.get(p.id); if(!s) continue;
77+
const diffs = [];
78+
for(const k of ['stars','forks','updated'])
79+
if(s[k] != null && p[k] !== s[k]) diffs.push(`${k} ${JSON.stringify(p[k])}${JSON.stringify(s[k])}`);
80+
if(diffs.length) report.push(` ${p.id}: ${diffs.join(', ')}`);
81+
}
82+
83+
// ---- surgical rewrite: only the stars/forks/updated value line of the current package ----
84+
let currentId = null;
85+
const lines = raw.split('\n').map(line => {
86+
const idm = line.match(/^\s*"id":\s*"([^"]+)"/);
87+
if(idm){ currentId = idm[1]; return line; }
88+
const s = currentId && fresh.get(currentId);
89+
if(!s) return line;
90+
let m;
91+
if(s.stars != null && (m = line.match(/^(\s*)"stars":\s*\d+(,?)\s*$/))) return `${m[1]}"stars": ${s.stars}${m[2]}`;
92+
if(s.forks != null && (m = line.match(/^(\s*)"forks":\s*\d+(,?)\s*$/))) return `${m[1]}"forks": ${s.forks}${m[2]}`;
93+
if(s.updated != null && (m = line.match(/^(\s*)"updated":\s*"[^"]*"(,?)\s*$/))) return `${m[1]}"updated": "${s.updated}"${m[2]}`;
94+
return line;
95+
});
96+
const out = lines.join('\n');
97+
98+
// ---- write / report ----
99+
if(report.length){ console.log(`${report.length} package(s) changed:`); console.log(report.join('\n')); }
100+
if(out !== raw && !DRY){
101+
await writeFile(FILE, out);
102+
console.log(`Wrote packages.json (${fresh.size} looked up${failed ? `, ${failed} failed` : ''}).`);
103+
}else{
104+
console.log(DRY ? '[dry run] not writing.' : `No changes${failed ? ` (${failed} lookup failure(s))` : ''}.`);
105+
}
106+
107+
// Surface a systemic failure (bad token / rate limit) instead of committing a no-op.
108+
if(failed && fresh.size === 0){ console.error('Every lookup failed — aborting.'); process.exit(1); }

0 commit comments

Comments
 (0)