From 724bf188229095d1d4ee9f4589feae5ff91da8d2 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 7 Jan 2026 14:11:06 +1000 Subject: [PATCH] feat: add GitHub stars syncing to metrics sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add github.ts to npm-client with functions to fetch GitHub stars - Add parseGitHubUrl to handle various GitHub URL formats - Update metrics sync endpoint to fetch and store GitHub stars - GitHub stars now factor into tool quality score calculation - Optional GITHUB_TOKEN env var for higher API rate limits Closes #8 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/src/app/api/sync/metrics/route.ts | 11 +- packages/npm-client/package.json | 1 + packages/npm-client/src/github.ts | 139 +++++++++++++++++++++ packages/npm-client/src/index.ts | 8 ++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 packages/npm-client/src/github.ts diff --git a/apps/web/src/app/api/sync/metrics/route.ts b/apps/web/src/app/api/sync/metrics/route.ts index 39bf41f..f4f7861 100644 --- a/apps/web/src/app/api/sync/metrics/route.ts +++ b/apps/web/src/app/api/sync/metrics/route.ts @@ -1,5 +1,5 @@ import { prisma } from '@tpmjs/db'; -import { fetchDownloadStats } from '@tpmjs/npm-client'; +import { fetchDownloadStats, fetchGitHubStarsFromRepository } from '@tpmjs/npm-client'; import { type NextRequest, NextResponse } from 'next/server'; import { env } from '~/env'; @@ -44,12 +44,17 @@ export async function POST(request: NextRequest) { // Fetch download stats from NPM (package-level metric) const downloads = await fetchDownloadStats(pkg.npmPackageName); + // Fetch GitHub stars if repository is available + const githubStars = await fetchGitHubStarsFromRepository( + pkg.npmRepository as { type?: string; url?: string } | string | null + ); + // Update package metrics await prisma.package.update({ where: { id: pkg.id }, data: { npmDownloadsLastMonth: downloads, - // githubStars would be updated here if we had GitHub API integration + githubStars, }, }); @@ -58,7 +63,7 @@ export async function POST(request: NextRequest) { const qualityScore = calculateQualityScore({ tier: pkg.tier, // Tier is at package level downloads, // Package downloads - githubStars: pkg.githubStars || 0, // Package stars + githubStars, // Use freshly fetched stars hasParameters: !!tool.parameters, hasReturns: !!tool.returns, hasAiAgent: !!tool.aiAgent, diff --git a/packages/npm-client/package.json b/packages/npm-client/package.json index 6604b34..4871a93 100644 --- a/packages/npm-client/package.json +++ b/packages/npm-client/package.json @@ -11,6 +11,7 @@ "./search": "./src/search.ts", "./package": "./src/package.ts", "./stats": "./src/stats.ts", + "./github": "./src/github.ts", "./rate-limiter": "./src/rate-limiter.ts" }, "scripts": { diff --git a/packages/npm-client/src/github.ts b/packages/npm-client/src/github.ts new file mode 100644 index 0000000..c7a6961 --- /dev/null +++ b/packages/npm-client/src/github.ts @@ -0,0 +1,139 @@ +/** + * GitHub Repository Statistics Client + * Fetches star counts from GitHub API + */ + +import { z } from 'zod'; + +const GITHUB_API_URL = 'https://api.github.com'; + +/** + * Schema for GitHub repository response + */ +const GitHubRepoSchema = z.object({ + stargazers_count: z.number(), + full_name: z.string(), +}); + +export type GitHubRepoResponse = z.infer; + +/** + * Parses a GitHub URL or repository string to extract owner and repo + * Supports formats: + * - https://github.com/owner/repo + * - https://github.com/owner/repo.git + * - git://github.com/owner/repo.git + * - git+https://github.com/owner/repo.git + * - github:owner/repo + * - { url: "..." } object format + */ +export function parseGitHubUrl( + repository: string | { type?: string; url?: string } | null | undefined +): { owner: string; repo: string } | null { + if (!repository) { + return null; + } + + let url: string; + + if (typeof repository === 'object') { + if (!repository.url) { + return null; + } + url = repository.url; + } else { + url = repository; + } + + // Handle github: shorthand + if (url.startsWith('github:')) { + const parts = url.replace('github:', '').split('/'); + const owner = parts[0]; + const repo = parts[1]; + if (owner && repo) { + return { owner, repo: repo.replace('.git', '') }; + } + return null; + } + + // Handle various GitHub URL formats + const githubRegex = /github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:\/|$)/i; + const match = url.match(githubRegex); + + if (match?.[1] && match[2]) { + return { owner: match[1], repo: match[2] }; + } + + return null; +} + +/** + * Fetches star count for a GitHub repository + * @param owner - Repository owner (user or organization) + * @param repo - Repository name + * @returns Star count, or 0 if not found or on error + */ +export async function fetchGitHubStars(owner: string, repo: string): Promise { + const url = `${GITHUB_API_URL}/repos/${owner}/${repo}`; + + try { + const headers: Record = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'tpmjs-registry', + }; + + // Add GitHub token if available for higher rate limits + const token = process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { headers }); + + if (response.status === 404) { + // Repository not found + return 0; + } + + if (response.status === 403) { + // Rate limited + console.warn('GitHub API rate limited'); + return 0; + } + + if (!response.ok) { + console.error(`GitHub API error: ${response.status} ${response.statusText}`); + return 0; + } + + const data = await response.json(); + const parsed = GitHubRepoSchema.safeParse(data); + + if (!parsed.success) { + console.error('Failed to parse GitHub response:', parsed.error); + return 0; + } + + return parsed.data.stargazers_count; + } catch (error) { + console.error(`Failed to fetch GitHub stars for ${owner}/${repo}:`, error); + return 0; + } +} + +/** + * Fetches star count from a repository URL or object + * @param repository - Repository URL string or object with url property + * @returns Star count, or 0 if not a GitHub repo or on error + */ +export async function fetchGitHubStarsFromRepository( + repository: string | { type?: string; url?: string } | null | undefined +): Promise { + const parsed = parseGitHubUrl(repository); + + if (!parsed) { + return 0; + } + + return fetchGitHubStars(parsed.owner, parsed.repo); +} diff --git a/packages/npm-client/src/index.ts b/packages/npm-client/src/index.ts index a6fed3e..770d46e 100644 --- a/packages/npm-client/src/index.ts +++ b/packages/npm-client/src/index.ts @@ -36,5 +36,13 @@ export { // Download statistics export { fetchDownloadStats, fetchBulkDownloadStats, type DownloadsResponse } from './stats'; +// GitHub statistics +export { + fetchGitHubStars, + fetchGitHubStarsFromRepository, + parseGitHubUrl, + type GitHubRepoResponse, +} from './github'; + // Rate limiting export { delay, RateLimiter, npmRateLimiter, retryWithBackoff } from './rate-limiter';