diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml new file mode 100644 index 00000000..8814c90f --- /dev/null +++ b/.github/workflows/stats.yml @@ -0,0 +1,30 @@ +name: stats + +on: + schedule: + - cron: "0 12 * * *" # Run daily at 12:00 UTC + workflow_dispatch: # Allow manual trigger + +jobs: + stats: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Run stats script + run: bun scripts/stats.ts + + - name: Commit stats + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add STATS.md + git diff --staged --quiet || git commit -m "Update download stats $(date -I)" + git push diff --git a/scripts/stats.ts b/scripts/stats.ts new file mode 100755 index 00000000..b30e57d9 --- /dev/null +++ b/scripts/stats.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env bun + +interface Asset { + name: string + download_count: number +} + +interface Release { + tag_name: string + name: string + assets: Asset[] +} + +interface NpmDownloadsRange { + start: string + end: string + package: string + downloads: Array<{ + downloads: number + day: string + }> +} + +async function fetchNpmDownloads(packageName: string): Promise { + try { + // Use a range from 2020 to current year + 5 years to ensure it works forever + const currentYear = new Date().getFullYear() + const endYear = currentYear + 5 + const response = await fetch( + `https://api.npmjs.org/downloads/range/2020-01-01:${endYear}-12-31/${packageName}`, + ) + if (!response.ok) { + console.warn( + `Failed to fetch npm downloads for ${packageName}: ${response.status}`, + ) + return 0 + } + const data: NpmDownloadsRange = await response.json() + return data.downloads.reduce((total, day) => total + day.downloads, 0) + } catch (error) { + console.warn(`Error fetching npm downloads for ${packageName}:`, error) + return 0 + } +} + +async function fetchReleases(): Promise { + const releases: Release[] = [] + let page = 1 + const per = 100 + + while (true) { + const url = `https://api.github.com/repos/sst/opencode/releases?page=${page}&per_page=${per}` + + const response = await fetch(url) + if (!response.ok) { + throw new Error( + `GitHub API error: ${response.status} ${response.statusText}`, + ) + } + + const batch: Release[] = await response.json() + if (batch.length === 0) break + + releases.push(...batch) + console.log(`Fetched page ${page} with ${batch.length} releases`) + + if (batch.length < per) break + page++ + } + + return releases +} + +function calculate(releases: Release[]) { + let total = 0 + const stats = [] + + for (const release of releases) { + let downloads = 0 + const assets = [] + + for (const asset of release.assets) { + downloads += asset.download_count + assets.push({ + name: asset.name, + downloads: asset.download_count, + }) + } + + total += downloads + stats.push({ + tag: release.tag_name, + name: release.name, + downloads, + assets, + }) + } + + return { total, stats } +} + +async function save(githubTotal: number, npmDownloads: number) { + const file = "STATS.md" + const date = new Date().toISOString().split("T")[0] + const total = githubTotal + npmDownloads + + let previousGithub = 0 + let previousNpm = 0 + let previousTotal = 0 + let content = "" + + try { + content = await Bun.file(file).text() + const lines = content.trim().split("\n") + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if ( + line.startsWith("|") && + !line.includes("Date") && + !line.includes("---") + ) { + const match = line.match( + /\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/, + ) + if (match) { + previousGithub = parseInt(match[1].replace(/,/g, "")) + previousNpm = parseInt(match[2].replace(/,/g, "")) + previousTotal = parseInt(match[3].replace(/,/g, "")) + break + } + } + } + } catch { + content = + "# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n" + } + + const githubChange = githubTotal - previousGithub + const npmChange = npmDownloads - previousNpm + const totalChange = total - previousTotal + + const githubChangeStr = + githubChange > 0 + ? ` (+${githubChange.toLocaleString()})` + : githubChange < 0 + ? ` (${githubChange.toLocaleString()})` + : " (+0)" + const npmChangeStr = + npmChange > 0 + ? ` (+${npmChange.toLocaleString()})` + : npmChange < 0 + ? ` (${npmChange.toLocaleString()})` + : " (+0)" + const totalChangeStr = + totalChange > 0 + ? ` (+${totalChange.toLocaleString()})` + : totalChange < 0 + ? ` (${totalChange.toLocaleString()})` + : " (+0)" + const line = `| ${date} | ${githubTotal.toLocaleString()}${githubChangeStr} | ${npmDownloads.toLocaleString()}${npmChangeStr} | ${total.toLocaleString()}${totalChangeStr} |\n` + + if (!content.includes("# Download Stats")) { + content = + "# Download Stats\n\n| Date | GitHub Downloads | npm Downloads | Total |\n|------|------------------|---------------|-------|\n" + } + + await Bun.write(file, content + line) + await Bun.spawn(["bunx", "prettier", "--write", file]).exited + + console.log( + `\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`, + ) +} + +console.log("Fetching GitHub releases for sst/opencode...\n") + +const releases = await fetchReleases() +console.log(`\nFetched ${releases.length} releases total\n`) + +const { total: githubTotal, stats } = calculate(releases) + +console.log("Fetching npm all-time downloads for opencode-ai...\n") +const npmDownloads = await fetchNpmDownloads("opencode-ai") +console.log( + `Fetched npm all-time downloads: ${npmDownloads.toLocaleString()}\n`, +) + +await save(githubTotal, npmDownloads) + +const totalDownloads = githubTotal + npmDownloads + +console.log("=".repeat(60)) +console.log(`TOTAL DOWNLOADS: ${totalDownloads.toLocaleString()}`) +console.log(` GitHub: ${githubTotal.toLocaleString()}`) +console.log(` npm: ${npmDownloads.toLocaleString()}`) +console.log("=".repeat(60)) + +console.log("\nDownloads by release:") +console.log("-".repeat(60)) + +stats + .sort((a, b) => b.downloads - a.downloads) + .forEach((release) => { + console.log( + `${release.tag.padEnd(15)} ${release.downloads.toLocaleString().padStart(10)} downloads`, + ) + + if (release.assets.length > 1) { + release.assets + .sort((a, b) => b.downloads - a.downloads) + .forEach((asset) => { + console.log( + ` └─ ${asset.name.padEnd(25)} ${asset.downloads.toLocaleString().padStart(8)}`, + ) + }) + } + }) + +console.log("-".repeat(60)) +console.log( + `GitHub Total: ${githubTotal.toLocaleString()} downloads across ${releases.length} releases`, +) +console.log(`npm Total: ${npmDownloads.toLocaleString()} downloads`) +console.log(`Combined Total: ${totalDownloads.toLocaleString()} downloads`) diff --git a/tsconfig.json b/tsconfig.json index 0967ef42..65fa6c7f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1 +1,5 @@ -{} +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": {} +}