/** * GitHubStarsService — fetches stargazer counts from the GitHub public API. * * - Batch interface: accepts repository URLs, returns map of URL → stars * - In-memory cache with 1-hour TTL * - Concurrency-limited to 5 parallel requests * - Silent failure: 404, timeout, rate-limit → entry skipped */ import https from 'node:https'; import { createLogger } from '@shared/utils/logger'; import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers'; const logger = createLogger('Extensions:GitHubStars'); // ── Constants ────────────────────────────────────────────────────────────── const CACHE_TTL_MS = 60 * 60_000; // 1 hour const HTTP_TIMEOUT_MS = 10_000; // 10 seconds const MAX_CONCURRENCY = 5; const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB) // ── Cache entry ──────────────────────────────────────────────────────────── interface CacheEntry { stars: number; fetchedAt: number; } // ── Service ──────────────────────────────────────────────────────────────── export class GitHubStarsService { private cache = new Map(); /** * Fetch GitHub stars for a batch of repository URLs. * Returns `Record` — only includes URLs with valid results. */ async fetchStars(repositoryUrls: string[]): Promise> { const result: Record = {}; const tasks: Array<{ url: string; owner: string; repo: string }> = []; for (const url of repositoryUrls) { const parsed = parseGitHubOwnerRepo(url); if (!parsed) continue; const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase(); const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { result[url] = cached.stars; continue; } tasks.push({ url, owner: parsed.owner, repo: parsed.repo }); } if (tasks.length === 0) return result; // Fetch with concurrency limit const outcomes = await this.withConcurrencyLimit( tasks.map(({ url, owner, repo }) => async () => { const stars = await this.fetchSingle(owner, repo); if (stars != null) { const cacheKey = `${owner}/${repo}`.toLowerCase(); this.cache.set(cacheKey, { stars, fetchedAt: Date.now() }); result[url] = stars; } }), MAX_CONCURRENCY ); // Log any failures for debugging (already silent to caller) const failures = outcomes.filter((o) => o === 'error').length; if (failures > 0) { logger.debug( `GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed` ); } return result; } /** * Fetch stargazer count for a single repo. Returns null on any error. */ private async fetchSingle(owner: string, repo: string): Promise { try { const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; const { statusCode, body } = await githubGet(url); if (statusCode !== 200) { if (statusCode === 403 || statusCode === 429) { logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`); } return null; } const data = JSON.parse(body) as { stargazers_count?: number }; return typeof data.stargazers_count === 'number' ? data.stargazers_count : null; } catch { return null; } } /** * Run async tasks with a concurrency limit. */ private async withConcurrencyLimit( tasks: Array<() => Promise>, limit: number ): Promise> { const results: Array<'ok' | 'error'> = []; let index = 0; const run = async (): Promise => { while (index < tasks.length) { const i = index++; try { await tasks[i](); results[i] = 'ok'; } catch { results[i] = 'error'; } } }; const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run()); await Promise.all(workers); return results; } } // ── HTTP helper (GitHub-specific) ────────────────────────────────────────── function githubGet(url: string): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { let settled = false; const settleResolve = (v: { statusCode: number; body: string }): void => { if (!settled) { settled = true; resolve(v); } }; const settleReject = (e: Error): void => { if (!settled) { settled = true; reject(e); } }; const req = https.get( url, { headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'claude-devtools', }, }, (res) => { const status = res.statusCode ?? 0; const chunks: Buffer[] = []; let totalSize = 0; res.on('data', (c: Buffer) => { totalSize += c.length; if (totalSize > MAX_BODY_SIZE) { res.destroy(new Error('Response too large')); return; } chunks.push(c); }); res.on('end', () => settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') }) ); res.on('error', settleReject); } ); req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`))); req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e)))); }); }