feat: add GitHub stars syncing to metrics sync

- 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 <noreply@anthropic.com>
This commit is contained in:
Ajax Davis 2026-01-07 14:11:06 +10:00
parent a4cfea5cc3
commit 724bf18822
4 changed files with 156 additions and 3 deletions

View file

@ -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,

View file

@ -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": {

View file

@ -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<typeof GitHubRepoSchema>;
/**
* 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<number> {
const url = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
try {
const headers: Record<string, string> = {
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<number> {
const parsed = parseGitHubUrl(repository);
if (!parsed) {
return 0;
}
return fetchGitHubStars(parsed.owner, parsed.repo);
}

View file

@ -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';