e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
400 lines
20 KiB
YAML
400 lines
20 KiB
YAML
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 = `<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z" fill="none" stroke="${COLORS.text}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1" fill="none" stroke="${COLORS.text}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
const GITHUB_LOGO = `<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" fill="${COLORS.textMuted}"/>`;
|
|
const NPM_LOGO = `<path d="M0,0 H24 V24 H0 Z M4.5,4.5 V19.5 H12 V7.5 H16.5 V19.5 H19.5 V4.5 Z" fill="${COLORS.textMuted}" fill-rule="evenodd"/>`;
|
|
|
|
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 `
|
|
<clipPath id="${clipId}">
|
|
<rect x="${chartLeft}" y="${chartTop}" width="${innerW}" height="${innerH + 1}"/>
|
|
</clipPath>
|
|
<rect x="${cardX}" y="${cardY}" width="${cardWidth}" height="${cardHeight}" fill="#111111" rx="12"/>
|
|
<g transform="translate(${cardX + 12}, ${cardY + 10}) scale(0.6)">
|
|
${ASSISTANT_UI_LOGO}
|
|
</g>
|
|
<text x="${cardX + 38}" y="${cardY + 24}" fill="${COLORS.text}" font-family="system-ui,-apple-system,sans-serif" font-size="14" font-weight="600">${metricName}</text>
|
|
<text x="${cardX + cardWidth - chartPadding.right}" y="${cardY + 26}" fill="${COLORS.text}" font-family="system-ui,sans-serif" font-size="13" font-weight="600" text-anchor="end">${subtitle}</text>
|
|
${subtitle2 ? `<text x="${cardX + cardWidth - chartPadding.right}" y="${cardY + 40}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="10" text-anchor="end">${subtitle2}</text>` : ''}
|
|
<rect x="${chartLeft + 4}" y="${chartTop + 4}" width="${packageName.length * 6 + 32}" height="20" fill="#222222" rx="4" opacity="0.9"/>
|
|
<g transform="translate(${chartLeft + 8}, ${chartTop + 6}) scale(0.65)">
|
|
${logoSvg}
|
|
</g>
|
|
<text x="${chartLeft + 28}" y="${chartTop + 18}" fill="${COLORS.textMuted}" font-family="system-ui,-apple-system,sans-serif" font-size="10">${packageName}</text>
|
|
${yVals.slice(1).map(v => {
|
|
const y = chartTop + innerH - (v / niceMax) * innerH;
|
|
return `<line x1="${chartLeft}" y1="${y.toFixed(1)}" x2="${chartRight}" y2="${y.toFixed(1)}" stroke="${COLORS.grid}" stroke-width="1" stroke-dasharray="2,4"/>`;
|
|
}).join('')}
|
|
<g clip-path="url(#${clipId})">
|
|
<path d="${areaPath}" fill="url(#${gradId})"/>
|
|
<path d="${line}" fill="none" stroke="${COLORS.primary}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.3" filter="blur(3px)"/>
|
|
<path d="${line}" fill="none" stroke="${COLORS.primary}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</g>
|
|
${yVals.map(v => {
|
|
const y = chartTop + innerH - (v / niceMax) * innerH;
|
|
return `<text x="${chartLeft - 6}" y="${(y + 3).toFixed(1)}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="9" text-anchor="end">${formatNumber(v)}</text>`;
|
|
}).join('')}
|
|
${yAxisLabel ? `<text x="${chartLeft - 35}" y="${chartTop + innerH / 2}" fill="${COLORS.textMuted}" font-family="system-ui,-apple-system,sans-serif" font-size="11" text-anchor="middle" dominant-baseline="middle" transform="rotate(-90, ${chartLeft - 35}, ${chartTop + innerH / 2})">${yAxisLabel}</text>` : ''}
|
|
${xLabels.map(({ x, label }) => {
|
|
return `<text x="${x.toFixed(1)}" y="${cardY + cardHeight - 10}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="9" text-anchor="middle">${label}</text>`;
|
|
}).join('')}
|
|
`;
|
|
}
|
|
|
|
const cardY = margin;
|
|
const card1X = margin;
|
|
const card2X = margin + cardWidth + gap;
|
|
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
<defs>
|
|
<linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" stop-color="${COLORS.primary}" stop-opacity="0.2"/>
|
|
<stop offset="50%" stop-color="${COLORS.primary}" stop-opacity="0.08"/>
|
|
<stop offset="100%" stop-color="${COLORS.primary}" stop-opacity="0"/>
|
|
</linearGradient>
|
|
</defs>
|
|
${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')}
|
|
</svg>`;
|
|
}
|
|
|
|
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.
|
|
"
|
|
|
|
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
|