|
| 1 | +import { NextResponse } from 'next/server'; |
| 2 | +import { unstable_cache } from 'next/cache'; |
| 3 | + |
| 4 | +export const revalidate = 86400; // Cache response for 24 hours (releases don't change often) |
| 5 | + |
| 6 | +function extractContributors(text: string): string[] { |
| 7 | + const mentionRegex = /@([a-zA-Z0-9-]+)/g; |
| 8 | + const matches = text ? text.match(mentionRegex) : null; |
| 9 | + if (!matches) return []; |
| 10 | + |
| 11 | + // Clean up matches, remove duplicates, and filter out common bots/keywords |
| 12 | + const uniqueUsers = Array.from(new Set(matches.map(m => m.substring(1)))); // remove @ |
| 13 | + const banned = ['dependabot', 'github-actions', 'channel', 'here', 'all']; // common non-user mentions |
| 14 | + |
| 15 | + return uniqueUsers.filter(u => !u.includes('[bot]') && !banned.includes(u.toLowerCase())); |
| 16 | +} |
| 17 | + |
| 18 | +interface ContributorDetails { |
| 19 | + login: string; |
| 20 | + name?: string; |
| 21 | + avatar_url: string; |
| 22 | + html_url: string; |
| 23 | + bio?: string; |
| 24 | + location?: string; |
| 25 | + company?: string; |
| 26 | +} |
| 27 | + |
| 28 | +// Persistently cache user details forever (or until manually invalidated) |
| 29 | +// This fetches the "real name" and other details from GitHub User API |
| 30 | +const getContributorDetails = unstable_cache( |
| 31 | + async (login: string): Promise<ContributorDetails | null> => { |
| 32 | + try { |
| 33 | + console.log(`[API] Fetching user details for ${login}...`); |
| 34 | + const headers: Record<string, string> = process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}; |
| 35 | + const res = await fetch(`https://api.github.com/users/${login}`, { headers }); |
| 36 | + |
| 37 | + if (!res.ok) { |
| 38 | + if (res.status === 404) return null; |
| 39 | + throw new Error(`GitHub User API Error: ${res.status}`); |
| 40 | + } |
| 41 | + |
| 42 | + const data = await res.json(); |
| 43 | + return { |
| 44 | + login: data.login, |
| 45 | + name: data.name, |
| 46 | + avatar_url: data.avatar_url, |
| 47 | + html_url: data.html_url, |
| 48 | + bio: data.bio, |
| 49 | + location: data.location, |
| 50 | + company: data.company |
| 51 | + }; |
| 52 | + } catch (e) { |
| 53 | + console.error(`[API] Failed to fetch user ${login}:`, e); |
| 54 | + // Fallback to basic details if API fails |
| 55 | + return { |
| 56 | + login, |
| 57 | + avatar_url: `https://github.com/${login}.png`, |
| 58 | + html_url: `https://github.com/${login}` |
| 59 | + }; |
| 60 | + } |
| 61 | + }, |
| 62 | + ['github-user-details'], // Cache key namespace |
| 63 | + { |
| 64 | + revalidate: false, // Cache forever (never revalidate automatically) |
| 65 | + tags: ['contributors'] |
| 66 | + } |
| 67 | +); |
| 68 | + |
| 69 | +export async function GET() { |
| 70 | + try { |
| 71 | + console.log('[API] Fetching releases from GitHub...'); |
| 72 | + const headers: Record<string, string> = process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}; |
| 73 | + |
| 74 | + const releasesRes = await fetch('https://api.github.com/repos/margelo/react-native-quick-crypto/releases?per_page=10', { |
| 75 | + headers, |
| 76 | + next: { revalidate: 86400 } |
| 77 | + }); |
| 78 | + |
| 79 | + if (!releasesRes.ok) { |
| 80 | + console.error('[API] GitHub Releases fetch failed:', releasesRes.status, releasesRes.statusText); |
| 81 | + throw new Error(`GitHub API Error: ${releasesRes.status} ${releasesRes.statusText}`); |
| 82 | + } |
| 83 | + |
| 84 | + const releases = await releasesRes.json(); |
| 85 | + if (!Array.isArray(releases)) return NextResponse.json([]); |
| 86 | + |
| 87 | + // Process releases in parallel to fetch contributors |
| 88 | + const enhanced = await Promise.all(releases.map(async (release: any, index: number) => { |
| 89 | + const previousTag = releases[index + 1]?.tag_name; |
| 90 | + const contributorsMap = new Map<string, { login: string, commits: number }>(); |
| 91 | + |
| 92 | + // 1. Text mentions (legacy method, but good for shoutouts) |
| 93 | + extractContributors(release.body).forEach(user => { |
| 94 | + contributorsMap.set(user, { login: user, commits: 0 }); |
| 95 | + }); |
| 96 | + |
| 97 | + // 2. Compare API (if previous tag exists) to get actual committers |
| 98 | + if (previousTag) { |
| 99 | + try { |
| 100 | + const compareUrl = `https://api.github.com/repos/margelo/react-native-quick-crypto/compare/${previousTag}...${release.tag_name}`; |
| 101 | + const compareRes = await fetch(compareUrl, { headers, next: { revalidate: 86400 } }); |
| 102 | + |
| 103 | + if (compareRes.ok) { |
| 104 | + const data = await compareRes.json(); |
| 105 | + if (data.commits && Array.isArray(data.commits)) { |
| 106 | + data.commits.forEach((commit: any) => { |
| 107 | + if (commit.author && commit.author.login) { |
| 108 | + if (!commit.author.login.includes('[bot]')) { |
| 109 | + const login = commit.author.login; |
| 110 | + const current = contributorsMap.get(login) || { login, commits: 0 }; |
| 111 | + current.commits++; |
| 112 | + contributorsMap.set(login, current); |
| 113 | + } |
| 114 | + } |
| 115 | + }); |
| 116 | + } |
| 117 | + } else if (compareRes.status === 403 && process.env.NODE_ENV === 'development') { |
| 118 | + // Mock data for development when rate limited |
| 119 | + console.warn(`[API] Rate limited. Using mock contributors for ${release.tag_name}.`); |
| 120 | + ['mrousavy', 'szymonkapala', 'ospfranco'].forEach(login => { |
| 121 | + contributorsMap.set(login, { login, commits: Math.floor(Math.random() * 5) + 1 }); |
| 122 | + }); |
| 123 | + } else { |
| 124 | + console.warn(`[API] Compare fetch failed for ${previousTag}...${release.tag_name}: ${compareRes.status}`); |
| 125 | + } |
| 126 | + } catch (e) { |
| 127 | + console.error('[API] Compare fetch error:', e); |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + // 3. Hydrate with full user details (Names, Bios, etc.) using persistent cache |
| 132 | + const hydratedContributors = await Promise.all( |
| 133 | + Array.from(contributorsMap.values()).map(async (c) => { |
| 134 | + const details = await getContributorDetails(c.login); |
| 135 | + return { |
| 136 | + ...details, |
| 137 | + commits: c.commits |
| 138 | + }; |
| 139 | + }) |
| 140 | + ); |
| 141 | + |
| 142 | + // Sort: High commits first, then by name |
| 143 | + const sortedContributors = hydratedContributors |
| 144 | + .filter(c => c !== null) |
| 145 | + .sort((a: any, b: any) => b.commits - a.commits); |
| 146 | + |
| 147 | + return { |
| 148 | + ...release, |
| 149 | + contributors: sortedContributors |
| 150 | + }; |
| 151 | + })); |
| 152 | + |
| 153 | + return NextResponse.json(enhanced); |
| 154 | + } catch (error) { |
| 155 | + console.error('[API] Handler error:', error); |
| 156 | + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); |
| 157 | + } |
| 158 | +} |
0 commit comments