[READ-ONLY] a fast, modern browser for the npm registry
at main 196 lines 5.9 kB view raw
1export type Role = 'steward' | 'maintainer' | 'contributor' 2 3export interface GitHubContributor { 4 login: string 5 id: number 6 avatar_url: string 7 html_url: string 8 contributions: number 9 role: Role 10 sponsors_url: string | null 11} 12 13type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url'> 14 15// Fallback when no GitHub token is available (e.g. preview environments). 16// Only stewards are shown as maintainers; everyone else is a contributor. 17const FALLBACK_STEWARDS = new Set(['danielroe', 'patak-dev']) 18 19interface TeamMembers { 20 steward: Set<string> 21 maintainer: Set<string> 22} 23 24async function fetchTeamMembers(token: string): Promise<TeamMembers | null> { 25 const teams: Record<keyof TeamMembers, string> = { 26 steward: 'stewards', 27 maintainer: 'maintainers', 28 } 29 30 try { 31 const result: TeamMembers = { steward: new Set(), maintainer: new Set() } 32 33 for (const [role, slug] of Object.entries(teams) as [keyof TeamMembers, string][]) { 34 const response = await fetch( 35 `https://api.github.com/orgs/npmx-dev/teams/${slug}/members?per_page=100`, 36 { 37 headers: { 38 'Accept': 'application/vnd.github.v3+json', 39 'Authorization': `Bearer ${token}`, 40 'User-Agent': 'npmx', 41 }, 42 }, 43 ) 44 45 if (!response.ok) { 46 console.warn(`Failed to fetch ${slug} team members: ${response.status}`) 47 return null 48 } 49 50 const members = (await response.json()) as { login: string }[] 51 for (const member of members) { 52 result[role].add(member.login) 53 } 54 } 55 56 return result 57 } catch (error) { 58 console.warn('Failed to fetch team members from GitHub:', error) 59 return null 60 } 61} 62 63/** 64 * Batch-query GitHub GraphQL API to check which users have sponsors enabled. 65 * Returns a Set of logins that have a sponsors listing. 66 */ 67async function fetchSponsorable(token: string, logins: string[]): Promise<Set<string>> { 68 if (logins.length === 0) return new Set() 69 70 // Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login } 71 const fragments = logins.map( 72 (login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`, 73 ) 74 const query = `{ ${fragments.join('\n')} }` 75 76 try { 77 const response = await fetch('https://api.github.com/graphql', { 78 method: 'POST', 79 headers: { 80 'Authorization': `Bearer ${token}`, 81 'Content-Type': 'application/json', 82 'User-Agent': 'npmx', 83 }, 84 body: JSON.stringify({ query }), 85 }) 86 87 if (!response.ok) { 88 console.warn(`Failed to fetch sponsors info: ${response.status}`) 89 return new Set() 90 } 91 92 const json = (await response.json()) as { 93 data?: Record<string, { login: string; hasSponsorsListing: boolean } | null> 94 } 95 96 const sponsorable = new Set<string>() 97 if (json.data) { 98 for (const user of Object.values(json.data)) { 99 if (user?.hasSponsorsListing) { 100 sponsorable.add(user.login) 101 } 102 } 103 } 104 return sponsorable 105 } catch (error) { 106 console.warn('Failed to fetch sponsors info:', error) 107 return new Set() 108 } 109} 110 111function getRoleInfo(login: string, teams: TeamMembers): { role: Role; order: number } { 112 if (teams.steward.has(login)) return { role: 'steward', order: 0 } 113 if (teams.maintainer.has(login)) return { role: 'maintainer', order: 1 } 114 return { role: 'contributor', order: 2 } 115} 116 117export default defineCachedEventHandler( 118 async (): Promise<GitHubContributor[]> => { 119 const githubToken = useRuntimeConfig().github.orgToken 120 121 // Fetch team members dynamically if token is available, otherwise use fallback 122 const teams: TeamMembers = await (async () => { 123 if (githubToken) { 124 const fetched = await fetchTeamMembers(githubToken) 125 if (fetched) return fetched 126 } 127 return { steward: FALLBACK_STEWARDS, maintainer: new Set<string>() } 128 })() 129 130 const allContributors: GitHubAPIContributor[] = [] 131 let page = 1 132 const perPage = 100 133 134 while (true) { 135 const response = await fetch( 136 `https://api.github.com/repos/npmx-dev/npmx.dev/contributors?per_page=${perPage}&page=${page}`, 137 { 138 headers: { 139 'Accept': 'application/vnd.github.v3+json', 140 'User-Agent': 'npmx', 141 ...(githubToken && { Authorization: `Bearer ${githubToken}` }), 142 }, 143 }, 144 ) 145 146 if (!response.ok) { 147 throw createError({ 148 statusCode: response.status, 149 message: 'Failed to fetch contributors', 150 }) 151 } 152 153 const contributors = (await response.json()) as GitHubAPIContributor[] 154 155 if (contributors.length === 0) { 156 break 157 } 158 159 allContributors.push(...contributors) 160 161 if (contributors.length < perPage) { 162 break 163 } 164 165 page++ 166 } 167 168 const filtered = allContributors.filter(c => !c.login.includes('[bot]')) 169 170 // Identify maintainers (stewards + maintainers) and check their sponsors status 171 const maintainerLogins = filtered 172 .filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login)) 173 .map(c => c.login) 174 175 const sponsorable = githubToken 176 ? await fetchSponsorable(githubToken, maintainerLogins) 177 : new Set<string>() 178 179 return filtered 180 .map(c => { 181 const { role, order } = getRoleInfo(c.login, teams) 182 const sponsors_url = sponsorable.has(c.login) 183 ? `https://github.com/sponsors/${c.login}` 184 : null 185 Object.assign(c, { role, order, sponsors_url }) 186 return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role } 187 }) 188 .sort((a, b) => a.order - b.order || b.contributions - a.contributions) 189 .map(({ order: _, ...rest }) => rest) 190 }, 191 { 192 maxAge: 3600, // Cache for 1 hour 193 name: 'github-contributors', 194 getKey: () => 'contributors', 195 }, 196)