diff --git a/README.md b/README.md index 2c486e761..12542d60d 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,15 @@ Stuck? The fastest path is usually: ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date) +

+ + OpenAlice GitHub star history + +

+ +

+ Current GitHub stars +

## Contributors diff --git a/docs/images/star-history.svg b/docs/images/star-history.svg new file mode 100644 index 000000000..7b5f52d75 --- /dev/null +++ b/docs/images/star-history.svg @@ -0,0 +1,83 @@ + + TraderAlice/OpenAlice star history + 6,283 stars in this snapshot. 669 stars added in the latest 30-day window. Daily cumulative history from Feb 19, 2026 through Jul 28, 2026. + + TraderAlice/OpenAlice + 2026-07-28T06:56:51.503Z + daily cumulative active stargazers + + + + + + + + + + + + + + + + + + + + + + + + + OPENALICE + Star History + + + 6,283 + STARS AT SNAPSHOT + + +669 + LAST 30 DAYS + + + DAILY CUMULATIVE · THROUGH JUL 28, 2026 UTC + + + 0 + + 2k + + 4k + + 6k + + 8k + + + Feb 19 + + Mar + + Apr + + May + + Jun + + Jul + + Jul 28 + + + + diff --git a/package.json b/package.json index 5ef532aec..6f67bffb3 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "test:guardian-recovery": "tsx scripts/guardian/runtime-recovery-smoke.ts", "test:connector-service": "node scripts/connector-service-smoke.mjs", "test:connector-replay": "vitest run services/connector/src/core/io-replay.spec.ts services/connector/src/core/io-events.spec.ts services/connector/src/adapters/shared.spec.ts", + "star-history:generate": "node scripts/generate-star-history.mjs", "postinstall": "node scripts/fix-pty-perms.mjs" }, "keywords": [ diff --git a/scripts/generate-star-history.mjs b/scripts/generate-star-history.mjs new file mode 100644 index 000000000..408088885 --- /dev/null +++ b/scripts/generate-star-history.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const DAY_MS = 24 * 60 * 60 * 1000 +const DEFAULT_REPOSITORY = 'TraderAlice/OpenAlice' +const DEFAULT_OUTPUT = 'docs/images/star-history.svg' + +function startOfUtcDay(value) { + const date = new Date(value) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +function formatCount(value) { + return new Intl.NumberFormat('en-US').format(value) +} + +function formatCompactCount(value) { + if (value < 1_000) return String(value) + const thousands = value / 1_000 + return `${thousands >= 10 || Number.isInteger(thousands) ? thousands.toFixed(0) : thousands.toFixed(1)}k` +} + +function formatDate(value, includeDay = true) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + ...(includeDay ? { day: 'numeric' } : {}), + timeZone: 'UTC', + }).format(value) +} + +function formatFullDate(value) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }).format(value) +} + +function escapeXml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function niceAxis(maximum, targetIntervals = 4) { + if (maximum <= 0) return { maximum: 1, step: 1 } + const roughStep = maximum / targetIntervals + const magnitude = 10 ** Math.floor(Math.log10(roughStep)) + const normalized = roughStep / magnitude + const niceNormalized = normalized <= 1 + ? 1 + : normalized <= 2 + ? 2 + : normalized <= 2.5 + ? 2.5 + : normalized <= 5 + ? 5 + : 10 + const step = niceNormalized * magnitude + return { + maximum: Math.ceil(maximum / step) * step, + step, + } +} + +export function aggregateStarHistory(timestamps) { + const sorted = timestamps + .map((value) => new Date(value)) + .filter((value) => !Number.isNaN(value.getTime())) + .sort((left, right) => left.getTime() - right.getTime()) + + if (sorted.length === 0) { + throw new Error('[star-history] GitHub returned no valid stargazer timestamps') + } + + const firstDay = startOfUtcDay(sorted[0]) + const lastDay = startOfUtcDay(sorted.at(-1)) + const dayCount = Math.floor((lastDay - firstDay) / DAY_MS) + 1 + const points = [] + let starIndex = 0 + + for (let dayIndex = 0; dayIndex < dayCount; dayIndex += 1) { + const day = firstDay + dayIndex * DAY_MS + const nextDay = day + DAY_MS + while (starIndex < sorted.length && sorted[starIndex].getTime() < nextDay) { + starIndex += 1 + } + points.push({ date: new Date(day), count: starIndex }) + } + + return points +} + +function selectDateTicks(points) { + const ticks = [points[0]] + for (let index = 1; index < points.length - 1; index += 1) { + const date = points[index].date + if (date.getUTCDate() === 1) ticks.push(points[index]) + } + if (points.length > 1) ticks.push(points.at(-1)) + return ticks +} + +export function renderStarHistorySvg({ + points, + repository = DEFAULT_REPOSITORY, + generatedAt = new Date(), +}) { + if (!Array.isArray(points) || points.length === 0) { + throw new Error('[star-history] at least one aggregate point is required') + } + + const width = 1200 + const height = 560 + const plot = { + left: 92, + right: 1144, + top: 174, + bottom: 478, + } + const plotWidth = plot.right - plot.left + const plotHeight = plot.bottom - plot.top + const total = points.at(-1).count + const axis = niceAxis(total) + const xForIndex = (index) => { + if (points.length === 1) return plot.left + return plot.left + (index / (points.length - 1)) * plotWidth + } + const yForCount = (count) => plot.bottom - (count / axis.maximum) * plotHeight + const linePath = points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${xForIndex(index).toFixed(2)} ${yForCount(point.count).toFixed(2)}`) + .join(' ') + const areaPath = `${linePath} L ${xForIndex(points.length - 1).toFixed(2)} ${plot.bottom} L ${plot.left} ${plot.bottom} Z` + const intervalCount = Math.round(axis.maximum / axis.step) + const yTicks = Array.from({ length: intervalCount + 1 }, (_, index) => index * axis.step) + const dateTicks = selectDateTicks(points) + const pointIndexByTime = new Map(points.map((point, index) => [point.date.getTime(), index])) + const thirtyDayBaselineIndex = Math.max(0, points.length - 31) + const lastThirtyDays = total - points[thirtyDayBaselineIndex].count + const title = `${repository} star history` + const description = `${formatCount(total)} stars in this snapshot. ${formatCount(lastThirtyDays)} stars added in the latest 30-day window. Daily cumulative history from ${formatFullDate(points[0].date)} through ${formatFullDate(points.at(-1).date)}.` + + const grid = yTicks + .map((tick) => { + const y = yForCount(tick) + return ` + + ${formatCompactCount(tick)}` + }) + .join('') + + const xAxis = dateTicks + .map((point, tickIndex) => { + const index = pointIndexByTime.get(point.date.getTime()) + const x = xForIndex(index) + const isEndpoint = tickIndex === 0 || tickIndex === dateTicks.length - 1 + return ` + + ${formatDate(point.date, isEndpoint)}` + }) + .join('') + + return ` + ${escapeXml(title)} + ${escapeXml(description)} + + ${escapeXml(repository)} + ${escapeXml(generatedAt.toISOString())} + daily cumulative active stargazers + + + + + + + + + + + + + + + + + + + + + + + + + OPENALICE + Star History + + + ${formatCount(total)} + STARS AT SNAPSHOT + + +${formatCount(lastThirtyDays)} + LAST 30 DAYS + + + DAILY CUMULATIVE · THROUGH ${formatFullDate(points.at(-1).date).toUpperCase()} UTC +${grid} +${xAxis} + + + + +` +} + +export async function fetchStargazerTimestamps({ + repository = DEFAULT_REPOSITORY, + token, + fetchImpl = fetch, + onPage, +}) { + if (!token) throw new Error('[star-history] a GitHub token is required') + + const timestamps = [] + const perPage = 100 + for (let page = 1; ; page += 1) { + const url = new URL(`https://api.github.com/repos/${repository}/stargazers`) + url.searchParams.set('per_page', String(perPage)) + url.searchParams.set('page', String(page)) + const response = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github.star+json', + Authorization: `Bearer ${token}`, + 'User-Agent': 'OpenAlice-Star-History', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!response.ok) { + const detail = await response.text() + throw new Error(`[star-history] GitHub API returned ${response.status}: ${detail.slice(0, 240)}`) + } + + const pageItems = await response.json() + if (!Array.isArray(pageItems)) { + throw new Error('[star-history] GitHub API returned an unexpected response') + } + + for (const item of pageItems) { + if (typeof item?.starred_at === 'string') timestamps.push(item.starred_at) + } + onPage?.({ page, fetched: pageItems.length, total: timestamps.length }) + + if (pageItems.length < perPage) break + } + + return timestamps +} + +function resolveGitHubToken() { + const environmentToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (environmentToken) return environmentToken + + try { + return execFileSync('gh', ['auth', 'token'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + throw new Error('[star-history] set GH_TOKEN/GITHUB_TOKEN or authenticate with `gh auth login`') + } +} + +function parseArgs(argv) { + const values = { + repository: DEFAULT_REPOSITORY, + output: DEFAULT_OUTPUT, + } + + for (let index = 0; index < argv.length; index += 2) { + const key = argv[index] + const value = argv[index + 1] + if (!key?.startsWith('--') || value == null) { + throw new Error(`[star-history] invalid arguments: ${argv.join(' ')}`) + } + if (key === '--repository') values.repository = value + else if (key === '--output') values.output = value + else throw new Error(`[star-history] unknown option: ${key}`) + } + + return values +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const token = resolveGitHubToken() + const timestamps = await fetchStargazerTimestamps({ + repository: options.repository, + token, + onPage: ({ page, total }) => { + if (page === 1 || page % 10 === 0) { + console.log(`[star-history] fetched page ${page} (${formatCount(total)} timestamps)`) + } + }, + }) + const points = aggregateStarHistory(timestamps) + const svg = renderStarHistorySvg({ + points, + repository: options.repository, + }) + const outputPath = resolve(options.output) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, svg) + console.log(`[star-history] wrote ${options.output} from ${formatCount(timestamps.length)} active stargazers`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/generate-star-history.spec.ts b/scripts/generate-star-history.spec.ts new file mode 100644 index 000000000..a2f3cea2b --- /dev/null +++ b/scripts/generate-star-history.spec.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + aggregateStarHistory, + fetchStargazerTimestamps, + renderStarHistorySvg, +} from './generate-star-history.mjs' + +describe('aggregateStarHistory', () => { + it('creates a cumulative point for every UTC day without retaining user data', () => { + const points = aggregateStarHistory([ + '2026-02-21T18:00:00Z', + '2026-02-19T10:40:01Z', + '2026-02-19T20:15:00Z', + ]) + + expect(points).toEqual([ + { date: new Date('2026-02-19T00:00:00Z'), count: 2 }, + { date: new Date('2026-02-20T00:00:00Z'), count: 2 }, + { date: new Date('2026-02-21T00:00:00Z'), count: 3 }, + ]) + }) + + it('rejects empty or invalid histories', () => { + expect(() => aggregateStarHistory([])).toThrow('no valid stargazer timestamps') + expect(() => aggregateStarHistory(['not-a-date'])).toThrow('no valid stargazer timestamps') + }) +}) + +describe('fetchStargazerTimestamps', () => { + it('paginates and keeps only timestamp fields', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + starred_at: `2026-02-19T10:${String(index % 60).padStart(2, '0')}:00Z`, + user: { login: `private-user-${index}` }, + })) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => firstPage, + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ starred_at: '2026-02-20T12:00:00Z', user: { login: 'last-user' } }], + }) + + const timestamps = await fetchStargazerTimestamps({ + token: 'test-token', + fetchImpl, + }) + + expect(fetchImpl).toHaveBeenCalledTimes(2) + expect(timestamps).toHaveLength(101) + expect(timestamps.at(-1)).toBe('2026-02-20T12:00:00Z') + expect(JSON.stringify(timestamps)).not.toContain('private-user') + }) +}) + +describe('renderStarHistorySvg', () => { + it('renders an accessible self-contained chart without stargazer identities', () => { + const points = aggregateStarHistory([ + '2026-02-19T10:40:01Z', + '2026-02-20T12:00:00Z', + '2026-03-20T12:00:00Z', + ]) + const svg = renderStarHistorySvg({ + points, + generatedAt: new Date('2026-03-20T13:00:00Z'), + }) + + expect(svg).toContain('TraderAlice/OpenAlice star history') + expect(svg).toContain('') + expect(svg).toContain('daily cumulative active stargazers') + expect(svg).toContain('2026-03-20T13:00:00.000Z') + expect(svg).not.toContain('login') + expect(svg).not.toContain('starred_at') + }) +})