import { filter, map, pipe, sortBy, take, uniqueBy } from 'remeda'; import type { Project } from './content/types.js'; const GITHUB_API_BASE = 'https://api.github.com'; const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = 1000; const MAX_RETRY_DELAY = 10000; const RATE_LIMIT_DELAY = 60000; const EXCLUDED_OWNERS = new Set(['everii-Group', 'hundertzehn', 'meso-unimpressed']); interface SearchItem { repository_url: string; pull_request?: { merged_at: string | null }; } interface SearchResponse { items: SearchItem[]; total_count: number; } interface RepoInfo { full_name: string; name: string; description: string | null; html_url: string; stargazers_count: number; forks_count: number; language: string | null; } function getHeaders(): Record { const headers: Record = { Accept: 'application/vnd.github.v3+json', }; const token = process.env.GITHUB_TOKEN; if (token) { headers.Authorization = `token ${token}`; } return headers; } async function fetchWithRetry(url: string, retryCount = 0): Promise { try { const response = await fetch(url, { headers: getHeaders() }); if (response.status === 429 && retryCount < MAX_RETRIES) { const retryAfter = response.headers.get('retry-after'); const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.min(RATE_LIMIT_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount)); console.warn( `Rate limited for ${url}, waiting ${delay}ms before retry ${retryCount + 1}/${MAX_RETRIES}` ); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, retryCount + 1); } return response; } catch (error) { if (retryCount < MAX_RETRIES) { const delay = Math.min(MAX_RETRY_DELAY, INITIAL_RETRY_DELAY * Math.pow(2, retryCount)); console.warn( `Network error for ${url}, retrying in ${delay}ms (${retryCount + 1}/${MAX_RETRIES}):`, error ); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWithRetry(url, retryCount + 1); } throw error; } } function handleApiError(response: Response, context: string): null { if (response.status === 403 || response.status === 429) { console.warn( `GitHub API rate limit exceeded for ${context}. Set GITHUB_TOKEN env var for higher limits.` ); } else { console.error(`GitHub API error: ${response.status} ${response.statusText}`); } return null; } function mapRepoToProject(repo: { name: string; full_name?: string; description: string | null; html_url: string; stargazers_count: number; forks_count: number; language: string | null; }): Project { return { name: repo.full_name ?? repo.name, description: repo.description ?? '', url: repo.html_url, stars: repo.stargazers_count, forks: repo.forks_count, language: repo.language ?? undefined, isFork: false, }; } export async function fetchGitHubProjects(username: string): Promise { try { const query = encodeURIComponent(`user:${username} fork:false`); const response = await fetchWithRetry( `${GITHUB_API_BASE}/search/repositories?q=${query}&sort=stars&order=desc&per_page=6` ); if (!response.ok) { handleApiError(response, 'user repos'); return []; } const data = await response.json(); return data.items.map(mapRepoToProject); } catch (error) { console.error('Error fetching GitHub projects:', error); return []; } } function getRepoOwner(repoUrl: string): string | null { const match = repoUrl.match(/\/repos\/([^\/]+)\/([^\/]+)$/); return match?.[1] ?? null; } function isNotExcluded(item: SearchItem): boolean { const owner = getRepoOwner(item.repository_url); return owner !== null && !EXCLUDED_OWNERS.has(owner); } async function fetchRepoAsProject(repoUrl: string, username: string): Promise { try { const response = await fetchWithRetry(repoUrl); if (!response.ok) { console.warn(`Could not fetch repo ${repoUrl}: ${response.status}`); return null; } const repo: RepoInfo = await response.json(); const [owner, name] = repo.full_name.split('/'); const prsUrl = `https://github.com/${owner}/${name}/pulls?q=is:pr+author:${encodeURIComponent(username)}+is:merged`; return { ...mapRepoToProject(repo), url: prsUrl }; } catch (error) { console.warn(`Error fetching repo details for ${repoUrl}:`, error); return null; } } export async function fetchContributedRepos(username: string): Promise { try { const query = encodeURIComponent(`type:pr author:${username} is:merged`); const response = await fetchWithRetry( `${GITHUB_API_BASE}/search/issues?q=${query}&per_page=100` ); if (!response.ok) { handleApiError(response, 'search'); return []; } const { total_count, items }: SearchResponse = await response.json(); if (!total_count || !items?.length) return []; const repoUrls = pipe( items, filter(isNotExcluded), uniqueBy((item) => item.repository_url), map((item) => item.repository_url) ); if (repoUrls.length === 0) return []; const projects = await Promise.all(repoUrls.map((url) => fetchRepoAsProject(url, username))); return pipe( projects, filter((p): p is Project => p !== null), sortBy([(p) => p.stars, 'desc']), take(6) ); } catch (error) { console.error('Error fetching contributed repos:', error); return []; } } export function getTopProjects(projects: Project[], limit: number): Project[] { return projects.slice(0, limit); }