Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

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.
![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