name: Update README Traction Chart on: schedule: - cron: "0 14 * * 1" # Monday 8 AM Central workflow_dispatch: permissions: contents: write pull-requests: write jobs: generate-chart: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 - name: Install dependencies run: | mkdir -p /tmp/sharp-deps cd /tmp/sharp-deps npm init -y > /dev/null npm install sharp - name: Generate chart env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_PATH: /tmp/sharp-deps/node_modules run: | node << 'SCRIPT' const sharp = require('sharp'); const fs = require('fs'); const PACKAGE_NAME = '@assistant-ui/react'; const GITHUB_REPO = 'assistant-ui/assistant-ui'; const OUTPUT_PATH = '.github/assets/traction.png'; const COLORS = { primary: '#ffffff', text: '#ffffff', textMuted: '#888888', grid: '#333333' }; const ASSISTANT_UI_LOGO = ``; const GITHUB_LOGO = ``; const NPM_LOGO = ``; function getStartDate() { const d = new Date(); d.setMonth(d.getMonth() - 18); return d.toISOString().split('T')[0]; } const START_DATE = getStartDate(); async function fetchNpmStats(packageName) { const end = new Date().toISOString().split('T')[0]; const url = `https://npm-stat.com/api/download-counts?package=${encodeURIComponent(packageName)}&from=${START_DATE}&until=${end}`; const response = await fetch(url); if (!response.ok) throw new Error(`npm stats fetch failed: ${response.status}`); const data = await response.json(); return data[packageName] || {}; } async function fetchCurrentStars(repo) { const [owner, name] = repo.split('/'); const headers = { 'Accept': 'application/vnd.github.v3+json' }; if (process.env.GITHUB_TOKEN) { headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; } const response = await fetch(`https://api.github.com/repos/${owner}/${name}`, { headers }); if (!response.ok) throw new Error(`GitHub repo fetch failed: ${response.status}`); const data = await response.json(); return data.stargazers_count; } async function fetchStarHistory(repo, currentStars) { const [owner, name] = repo.split('/'); const stars = []; let page = 1; const headers = { 'Accept': 'application/vnd.github.star+json' }; if (process.env.GITHUB_TOKEN) { headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`; } // Paginate through all stars (no hard limit) with delays to avoid rate limits while (true) { if (page > 1) await new Promise(r => setTimeout(r, 100)); let response = await fetch( `https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`, { headers } ); if (response.status === 403 || response.status === 429) { console.warn(`Rate limited on page ${page}, waiting 3 minutes before retry...`); await new Promise(r => setTimeout(r, 180000)); response = await fetch( `https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`, { headers } ); } if (!response.ok) { console.warn(`Stopped at page ${page}: HTTP ${response.status} (fetched ${stars.length} stars)`); break; } const data = await response.json(); if (data.length === 0) break; stars.push(...data.map(s => s.starred_at)); console.log(`Fetched page ${page}, ${stars.length} stars total`); if (data.length < 100) break; page++; } const startDate = new Date(START_DATE); const starsBeforeStart = stars.filter(d => new Date(d) < startDate).length; const weeks = {}; for (const date of stars) { const d = new Date(date); if (d < startDate) continue; const weekStart = getWeekStart(d.toISOString().split('T')[0]); weeks[weekStart] = (weeks[weekStart] || 0) + 1; } const result = []; const now = new Date(); const currentWeekStart = getWeekStart(now.toISOString().split('T')[0]); let d = new Date(START_DATE); const dayOfWeek = d.getDay(); const daysToMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek); d.setDate(d.getDate() + daysToMonday); let cumulative = starsBeforeStart; while (d <= now) { const key = d.toISOString().split('T')[0]; if (key >= currentWeekStart) break; cumulative += (weeks[key] || 0); result.push({ date: key, value: cumulative }); d.setDate(d.getDate() + 7); } result.push({ date: now.toISOString().split('T')[0], value: currentStars }); return result; } function getWeekStart(date) { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); d.setDate(diff); return d.toISOString().split('T')[0]; } function aggregateByWeek(dailyData) { const sorted = Object.entries(dailyData) .filter(([, count]) => count > 0) .sort(([a], [b]) => a.localeCompare(b)); if (sorted.length < 7) return sorted.map(([date, value]) => ({ date, value })); const result = []; for (let i = 3; i < sorted.length - 4; i += 3) { let sum = 0; for (let j = i - 3; j <= i + 3; j++) { sum += sorted[j][1]; } result.push({ date: sorted[i][0], value: sum }); } return result; } function smoothData(data) { if (data.length < 3) return data; const avg = []; for (let i = 0; i < data.length; i++) { const s = Math.max(0, i - 2), e = Math.min(data.length, i + 3); const w = data.slice(s, e).map(d => d.value); avg.push({ date: data[i].date, value: w.reduce((a, b) => a + b, 0) / w.length }); } const out = [avg[0]]; let h = avg[0].value; for (let i = 1; i < avg.length; i++) { const p = out[i - 1].value, c = avg[i].value; h = c > h ? c : h * 0.99; const v = c > p ? p * 0.3 + c * 0.7 : c < p * 0.8 ? p * 0.85 + h * 0.1 + c * 0.05 : p * 0.95 + c * 0.05; out.push({ date: avg[i].date, value: Math.round(v) }); } return out; } function getMonthlyRate(dailyData) { const entries = Object.entries(dailyData).sort(([a], [b]) => a.localeCompare(b)); const recent = entries.slice(-60); if (recent.length < 30) return Math.round(recent.reduce((s, [, v]) => s + v, 0) / recent.length * 30); let max = 0; for (let i = 0; i <= recent.length - 30; i++) { const sum = recent.slice(i, i + 30).reduce((s, [, v]) => s + v, 0); if (sum > max) max = sum; } return max; } function formatNumber(num) { if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; if (num >= 1000) return (num / 1000).toFixed(0) + 'K'; return num.toString(); } function smoothPath(pts) { if (pts.length < 2) return ''; if (pts.length === 2) return `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} L ${pts[1].x.toFixed(1)} ${pts[1].y.toFixed(1)}`; let path = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[Math.max(0, i - 1)]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[Math.min(pts.length - 1, i + 2)]; const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`; } return path; } function generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads) { const width = 800; const height = 380; const margin = 20; const gap = 24; const cardWidth = (width - margin * 2 - gap) / 2; const cardHeight = height - margin * 2; const chartPadding = { top: 50, right: 20, bottom: 35, left: 45 }; const innerW = cardWidth - chartPadding.left - chartPadding.right; const innerH = cardHeight - chartPadding.top - chartPadding.bottom; function chartCard(data, cardX, cardY, packageName, metricName, subtitle, gradId, clipId, logoSvg, subtitle2 = null, yAxisLabel = null) { if (!data.length) return ''; const maxVal = Math.max(...data.map(d => d.value)); const chartLeft = cardX + chartPadding.left; const chartRight = cardX + cardWidth - chartPadding.right; const chartTop = cardY + chartPadding.top; const chartBottom = cardY + chartPadding.top + innerH; const niceStep = (max) => { const rough = max / 6; const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); const normalized = rough / magnitude; let nice; if (normalized <= 1) nice = 1; else if (normalized <= 2) nice = 2; else if (normalized <= 5) nice = 5; else nice = 10; return nice * magnitude; }; const step = niceStep(maxVal); const niceMax = Math.ceil(maxVal / step) * step; const yVals = []; for (let v = 0; v <= niceMax; v += step) { yVals.push(v); } const pts = data.map((d, i) => ({ x: chartLeft + (data.length > 1 ? (i / (data.length - 1)) * innerW : innerW / 2), y: chartTop + innerH - (d.value / niceMax) * innerH })); const line = smoothPath(pts); const areaPath = line + ` L ${pts[pts.length - 1].x.toFixed(1)} ${chartBottom} L ${chartLeft} ${chartBottom} Z`; const xLabels = []; const numLabels = 8; const labelStep = Math.max(1, Math.floor(data.length / (numLabels - 1))); let lastYear = null; for (let labelNum = 0; labelNum < numLabels - 1; labelNum++) { const i = Math.min(labelNum * labelStep, data.length - 2); const x = pts[i].x; const d = new Date(data[i].date); const year = d.getFullYear(); const month = d.toLocaleDateString('en-US', { month: 'short' }); let label; if (year !== lastYear && lastYear !== null) { label = String(year); } else { label = month; } lastYear = year; xLabels.push({ x, label }); } xLabels.push({ x: pts[data.length - 1].x, label: 'Today' }); return ` ${ASSISTANT_UI_LOGO} ${metricName} ${subtitle} ${subtitle2 ? `${subtitle2}` : ''} ${logoSvg} ${packageName} ${yVals.slice(1).map(v => { const y = chartTop + innerH - (v / niceMax) * innerH; return ``; }).join('')} ${yVals.map(v => { const y = chartTop + innerH - (v / niceMax) * innerH; return `${formatNumber(v)}`; }).join('')} ${yAxisLabel ? `${yAxisLabel}` : ''} ${xLabels.map(({ x, label }) => { return `${label}`; }).join('')} `; } const cardY = margin; const card1X = margin; const card2X = margin + cardWidth + gap; return ` ${chartCard(starsData, card1X, cardY, 'assistant-ui/assistant-ui', ' GitHub Stars', formatNumber(currentStars) + ' total', 'grad', 'clip1', GITHUB_LOGO, null, 'Total')} ${chartCard(downloadsData, card2X, cardY, '@assistant-ui/react', ' npm Downloads', formatNumber(monthlyDownloads) + '/month', 'grad', 'clip2', NPM_LOGO, formatNumber(totalDownloads) + ' total', 'Weekly')} `; } async function main() { const [dailyData, stars] = await Promise.all([fetchNpmStats(PACKAGE_NAME), fetchCurrentStars(GITHUB_REPO)]); const monthlyDownloads = getMonthlyRate(dailyData); const totalDownloads = Object.values(dailyData).reduce((sum, v) => sum + v, 0); const downloadsData = smoothData(aggregateByWeek(dailyData)).slice(0, -1); const currentStars = stars || 0; const starsData = currentStars ? await fetchStarHistory(GITHUB_REPO, currentStars) : []; const svg = generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads); const png = await sharp(Buffer.from(svg), { density: 300 }).png().toBuffer(); fs.mkdirSync('.github/assets', { recursive: true }); fs.writeFileSync(OUTPUT_PATH, png); console.log('Done'); } main().catch(err => { console.error(err.message); process.exit(1); }); SCRIPT - name: Commit and push changes run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add .github/assets/traction.png if git diff --staged --quiet; then echo "No changes to commit" exit 0 fi git checkout -B update-traction-chart git commit -m "Update traction chart [skip ci]" git push -f origin update-traction-chart - name: Create or update Pull Request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | PREVIEW_URL="https://raw.githubusercontent.com/${{ github.repository }}/update-traction-chart/.github/assets/traction.png" BODY="Automated weekly update of README traction chart. ![Traction Chart](${PREVIEW_URL})" existing_pr=$(gh pr list --head update-traction-chart --json number --jq '.[0].number' || echo "") if [ -n "$existing_pr" ]; then gh pr edit "$existing_pr" --body "$BODY" echo "Updated PR #$existing_pr" else gh pr create \ --title "Update README traction chart" \ --body "$BODY" \ --head update-traction-chart \ --base main fi