forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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)