Files
cv/src/lib/github.ts
Joakim Repomaa 96171576c7
Some checks failed
Build and Deploy / build (push) Failing after 19s
cleanup
2026-02-19 22:10:44 +02:00

196 lines
5.6 KiB
TypeScript

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<string, string> {
const headers: Record<string, string> = {
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<Response> {
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<Project[]> {
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<Project | null> {
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<Project[]> {
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);
}