Skip to content

Commit 780419e

Browse files
committed
ci: daily stats script
1 parent f0962e2 commit 780419e

3 files changed

Lines changed: 260 additions & 1 deletion

File tree

.github/workflows/stats.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: stats
2+
3+
on:
4+
schedule:
5+
- cron: "0 12 * * *" # Run daily at 12:00 UTC
6+
workflow_dispatch: # Allow manual trigger
7+
8+
jobs:
9+
stats:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Bun
17+
uses: oven-sh/setup-bun@v2
18+
with:
19+
bun-version: latest
20+
21+
- name: Run stats script
22+
run: bun scripts/stats.ts
23+
24+
- name: Commit stats
25+
run: |
26+
git config --local user.email "action@github.com"
27+
git config --local user.name "GitHub Action"
28+
git add STATS.md
29+
git diff --staged --quiet || git commit -m "Update download stats $(date -I)"
30+
git push

scripts/stats.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env bun
2+
3+
interface Asset {
4+
name: string
5+
download_count: number
6+
}
7+
8+
interface Release {
9+
tag_name: string
10+
name: string
11+
assets: Asset[]
12+
}
13+
14+
interface NpmDownloadsRange {
15+
start: string
16+
end: string
17+
package: string
18+
downloads: Array<{
19+
downloads: number
20+
day: string
21+
}>
22+
}
23+
24+
async function fetchNpmDownloads(packageName: string): Promise<number> {
25+
try {
26+
// Use a range from 2020 to current year + 5 years to ensure it works forever
27+
const currentYear = new Date().getFullYear()
28+
const endYear = currentYear + 5
29+
const response = await fetch(
30+
`https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`,
31+
)
32+
if (!response.ok) {
33+
console.warn(
34+
`Failed to fetch npm downloads for ${packageName}: ${response.status}`,
35+
)
36+
return 0
37+
}
38+
const data: NpmDownloadsRange = await response.json()
39+
return data.downloads.reduce((total, day) => total + day.downloads, 0)
40+
} catch (error) {
41+
console.warn(`Error fetching npm downloads for ${packageName}:`, error)
42+
return 0
43+
}
44+
}
45+
46+
async function fetchReleases(): Promise<Release[]> {
47+
const releases: Release[] = []
48+
let page = 1
49+
const per = 100
50+
51+
while (true) {
52+
const url = `https://api.github.com/repos/sst/opencode/releases?page=${page}&per_page=${per}`
53+
54+
const response = await fetch(url)
55+
if (!response.ok) {
56+
throw new Error(
57+
`GitHub API error: ${response.status} ${response.statusText}`,
58+
)
59+
}
60+
61+
const batch: Release[] = await response.json()
62+
if (batch.length === 0) break
63+
64+
releases.push(...batch)
65+
console.log(`Fetched page ${page} with ${batch.length} releases`)
66+
67+
if (batch.length < per) break
68+
page++
69+
}
70+
71+
return releases
72+
}
73+
74+
function calculate(releases: Release[]) {
75+
let total = 0
76+
const stats = []
77+
78+
for (const release of releases) {
79+
let downloads = 0
80+
const assets = []
81+
82+
for (const asset of release.assets) {
83+
downloads += asset.download_count
84+
assets.push({
85+
name: asset.name,
86+
downloads: asset.download_count,
87+
})
88+
}
89+
90+
total += downloads
91+
stats.push({
92+
tag: release.tag_name,
93+
name: release.name,
94+
downloads,
95+
assets,
96+
})
97+
}
98+
99+
return { total, stats }
100+
}
101+
102+
async function save(githubTotal: number, npmDownloads: number) {
103+
const file = "STATS.md"
104+
const date = new Date().toISOString().split("T")[0]
105+
const total = githubTotal + npmDownloads
106+
107+
let previousGithub = 0
108+
let previousNpm = 0
109+
let previousTotal = 0
110+
let content = ""
111+
112+
try {
113+
content = await Bun.file(file).text()
114+
const lines = content.trim().split("\n")
115+
116+
for (let i = lines.length - 1; i >= 0; i--) {
117+
const line = lines[i].trim()
118+
if (
119+
line.startsWith("|") &&
120+
!line.includes("Date") &&
121+
!line.includes("---")
122+
) {
123+
const match = line.match(
124+
/\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/,
125+
)
126+
if (match) {
127+
previousGithub = parseInt(match[1].replace(/,/g, ""))
128+
previousNpm = parseInt(match[2].replace(/,/g, ""))
129+
previousTotal = parseInt(match[3].replace(/,/g, ""))
130+
break
131+
}
132+
}
133+
}
134+
} catch {
135+
content =
136+
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
137+
}
138+
139+
const githubChange = githubTotal - previousGithub
140+
const npmChange = npmDownloads - previousNpm
141+
const totalChange = total - previousTotal
142+
143+
const githubChangeStr =
144+
githubChange > 0
145+
? ` (+${githubChange.toLocaleString()})`
146+
: githubChange < 0
147+
? ` (${githubChange.toLocaleString()})`
148+
: " (+0)"
149+
const npmChangeStr =
150+
npmChange > 0
151+
? ` (+${npmChange.toLocaleString()})`
152+
: npmChange < 0
153+
? ` (${npmChange.toLocaleString()})`
154+
: " (+0)"
155+
const totalChangeStr =
156+
totalChange > 0
157+
? ` (+${totalChange.toLocaleString()})`
158+
: totalChange < 0
159+
? ` (${totalChange.toLocaleString()})`
160+
: " (+0)"
161+
const line = `| ${date} | ${githubTotal.toLocaleString()}${githubChangeStr} | ${npmDownloads.toLocaleString()}${npmChangeStr} | ${total.toLocaleString()}${totalChangeStr} |\n`
162+
163+
if (!content.includes("# Download Stats")) {
164+
content =
165+
"# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n"
166+
}
167+
168+
await Bun.write(file, content + line)
169+
await Bun.spawn(["bunx", "prettier", "--write", file]).exited
170+
171+
console.log(
172+
`\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`,
173+
)
174+
}
175+
176+
console.log("Fetching GitHub releases for sst/opencode...\n")
177+
178+
const releases = await fetchReleases()
179+
console.log(`\nFetched ${releases.length} releases total\n`)
180+
181+
const { total: githubTotal, stats } = calculate(releases)
182+
183+
console.log("Fetching npm all-time downloads for opencode-ai...\n")
184+
const npmDownloads = await fetchNpmDownloads("opencode-ai")
185+
console.log(
186+
`Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`,
187+
)
188+
189+
await save(githubTotal, npmDownloads)
190+
191+
const totalDownloads = githubTotal + npmDownloads
192+
193+
console.log("=".repeat(60))
194+
console.log(`TOTAL DOWNLOADS: ${totalDownloads.toLocaleString()}`)
195+
console.log(` GitHub: ${githubTotal.toLocaleString()}`)
196+
console.log(` npm: ${npmDownloads.toLocaleString()}`)
197+
console.log("=".repeat(60))
198+
199+
console.log("\nDownloads by release:")
200+
console.log("-".repeat(60))
201+
202+
stats
203+
.sort((a, b) => b.downloads - a.downloads)
204+
.forEach((release) => {
205+
console.log(
206+
`${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`,
207+
)
208+
209+
if (release.assets.length > 1) {
210+
release.assets
211+
.sort((a, b) => b.downloads - a.downloads)
212+
.forEach((asset) => {
213+
console.log(
214+
` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`,
215+
)
216+
})
217+
}
218+
})
219+
220+
console.log("-".repeat(60))
221+
console.log(
222+
`GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`,
223+
)
224+
console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`)
225+
console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`)

tsconfig.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
{}
1+
{
2+
"$schema": "https://json.schemastore.org/tsconfig",
3+
"extends": "@tsconfig/bun/tsconfig.json",
4+
"compilerOptions": {}
5+
}

0 commit comments

Comments
 (0)