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