1import { Actor } from 'apify';
2import YAML from 'yaml';
3
4const DEFAULT_TIMEOUT_SECONDS = 10;
5const MAX_REPOS = 50;
6const USER_AGENT = 'GitHubFundingConfigAuditor/0.1 (+https://apify.com)';
7const MAX_FUNDING_BYTES = 200_000;
8
9
10
11const SINGLE_USERNAME_PLATFORMS = ['patreon', 'open_collective', 'ko_fi', 'liberapay', 'issuehunt', 'polar', 'buy_me_a_coffee'];
12const GITHUB_MAX_SPONSORS = 4;
13const CUSTOM_MAX_URLS = 4;
14const KNOWN_KEYS = new Set([
15 'github',
16 'patreon',
17 'open_collective',
18 'ko_fi',
19 'liberapay',
20 'issuehunt',
21 'polar',
22 'buy_me_a_coffee',
23 'community_bridge',
24 'tidelift',
25 'thanks_dev',
26 'custom',
27]);
28
29export function parseGitHubRepoUrl(rawUrl) {
30 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('Repository URL must be a non-empty string');
31
32 if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
33 throw new Error('Only HTTP(S) GitHub repository URLs are supported');
34 }
35 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
36 const url = new URL(withScheme);
37
38 if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Only HTTP(S) GitHub repository URLs are supported');
39 if (url.username || url.password) throw new Error('URLs with credentials are not allowed');
40 if (url.hostname.toLowerCase() !== 'github.com') throw new Error('Only github.com repository URLs are accepted');
41
42 const parts = url.pathname.split('/').filter(Boolean);
43 if (parts.length < 2) throw new Error('GitHub repository URL must include owner and repo');
44 const owner = parts[0];
45 const repo = parts[1].replace(/\.git$/i, '');
46
47 const safeName = /^[A-Za-z0-9_.-]+$/;
48 if (!safeName.test(owner) || !safeName.test(repo)) throw new Error('Owner/repo contains unsupported characters');
49 if (owner.startsWith('.') || repo.startsWith('.')) throw new Error('Owner/repo cannot start with a dot');
50
51 return {
52 owner,
53 repo,
54 fullName: `${owner}/${repo}`,
55 htmlUrl: `https://github.com/${owner}/${repo}`,
56 apiUrl: `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
57 };
58}
59
60function clampTimeout(input) {
61 return Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
62}
63
64async function fetchJson(url, timeoutSeconds) {
65 const controller = new AbortController();
66 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
67 try {
68 const response = await fetch(url, {
69 redirect: 'follow',
70 signal: controller.signal,
71 headers: {
72 'user-agent': USER_AGENT,
73 accept: 'application/vnd.github+json',
74 'x-github-api-version': '2022-11-28',
75 },
76 });
77 const text = await response.text();
78 let json = null;
79 try {
80 json = text ? JSON.parse(text) : null;
81 } catch {
82 json = { raw: text.slice(0, 500) };
83 }
84 return { ok: response.ok, status: response.status, json };
85 } catch (error) {
86 return { ok: false, status: null, json: null, error: error.message };
87 } finally {
88 clearTimeout(timeout);
89 }
90}
91
92async function fetchText(url, timeoutSeconds) {
93 const controller = new AbortController();
94 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
95 try {
96 const response = await fetch(url, {
97 redirect: 'follow',
98 signal: controller.signal,
99 headers: { 'user-agent': USER_AGENT },
100 });
101 const text = await response.text();
102 return { ok: response.ok, status: response.status, body: text.slice(0, MAX_FUNDING_BYTES) };
103 } catch (error) {
104 return { ok: false, status: null, body: '', error: error.message };
105 } finally {
106 clearTimeout(timeout);
107 }
108}
109
110
111
112
113
114export function validateFundingConfig(doc) {
115 const issues = [];
116 const platforms = [];
117
118 if (doc === null || doc === undefined) {
119 return { valid: false, issues: ['FUNDING.yml is empty or not a mapping'], platforms: [], recommendations: [] };
120 }
121 if (typeof doc !== 'object' || Array.isArray(doc)) {
122 return { valid: false, issues: ['FUNDING.yml root must be a YAML mapping of platform keys'], platforms: [], recommendations: [] };
123 }
124
125 const keys = Object.keys(doc);
126 if (keys.length === 0) {
127 issues.push('FUNDING.yml contains no platform keys');
128 }
129
130 for (const key of keys) {
131 const value = doc[key];
132 if (!KNOWN_KEYS.has(key)) {
133 issues.push(`Unknown/unsupported funding key: "${key}" (not in GitHub's documented platform list)`);
134 continue;
135 }
136
137 if (key === 'github') {
138 const usernames = Array.isArray(value) ? value : [value];
139 if (usernames.length === 0 || usernames.some((u) => typeof u !== 'string' || !u.trim())) {
140 issues.push('github: must be a username string or an array of username strings');
141 continue;
142 }
143 if (usernames.length > GITHUB_MAX_SPONSORS) {
144 issues.push(`github: lists ${usernames.length} sponsors; GitHub supports a maximum of ${GITHUB_MAX_SPONSORS}`);
145 }
146 platforms.push({ key, value: usernames, valid: usernames.length <= GITHUB_MAX_SPONSORS });
147 continue;
148 }
149
150 if (SINGLE_USERNAME_PLATFORMS.includes(key)) {
151 if (Array.isArray(value)) {
152 issues.push(`${key}: must be a single username string, not a list`);
153 platforms.push({ key, value, valid: false });
154 continue;
155 }
156 const valid = typeof value === 'string' && value.trim().length > 0;
157 if (!valid) issues.push(`${key}: must be a non-empty username string`);
158 platforms.push({ key, value, valid });
159 continue;
160 }
161
162 if (key === 'community_bridge') {
163 const valid = typeof value === 'string' && value.trim().length > 0;
164 if (!valid) issues.push('community_bridge: must be a non-empty project name string');
165 platforms.push({ key, value, valid });
166 continue;
167 }
168
169 if (key === 'tidelift') {
170 const valid = typeof value === 'string' && /^[^/]+\/[^/]+$/.test(value.trim());
171 if (!valid) issues.push('tidelift: must use the PLATFORM-NAME/PACKAGE-NAME syntax (e.g. npm/left-pad)');
172 platforms.push({ key, value, valid });
173 continue;
174 }
175
176 if (key === 'thanks_dev') {
177 const valid = typeof value === 'string' && /^u\/gh\/[^/]+$/.test(value.trim());
178 if (!valid) issues.push('thanks_dev: must use the u/gh/USERNAME syntax');
179 platforms.push({ key, value, valid });
180 continue;
181 }
182
183 if (key === 'custom') {
184 const urls = Array.isArray(value) ? value : [value];
185 if (urls.length === 0 || urls.some((u) => typeof u !== 'string' || !u.trim())) {
186 issues.push('custom: must be a URL string or an array of URL strings');
187 continue;
188 }
189 if (urls.length > CUSTOM_MAX_URLS) {
190 issues.push(`custom: lists ${urls.length} URLs; GitHub supports a maximum of ${CUSTOM_MAX_URLS}`);
191 }
192 const malformed = urls.filter((u) => !/^https?:\/\//i.test(u) && !/^[a-z0-9.-]+\.[a-z]{2,}/i.test(u));
193 if (malformed.length > 0) {
194 issues.push(`custom: contains entries that are not valid URLs or bare domains: ${malformed.join(', ')}`);
195 }
196 platforms.push({ key, value: urls, valid: urls.length <= CUSTOM_MAX_URLS && malformed.length === 0 });
197 continue;
198 }
199 }
200
201 const recommendations = [];
202 if (platforms.length === 0 && issues.length === 0) {
203 recommendations.push('Add at least one funding platform to enable the Sponsor button.');
204 }
205 if (platforms.some((p) => !p.valid)) {
206 recommendations.push('Fix the flagged platform entries; GitHub silently ignores malformed keys instead of erroring, so the Sponsor button may be missing entries without warning.');
207 }
208
209 return {
210 valid: issues.length === 0,
211 issues,
212 platforms,
213 recommendations,
214 };
215}
216
217function scoreAudit(fileFound, validation) {
218 if (!fileFound) return { score: 0, grade: 'F' };
219 let score = 40;
220 const validPlatformCount = validation.platforms.filter((p) => p.valid).length;
221 score += Math.min(validPlatformCount, 3) * 15;
222 if (validation.issues.length === 0) score += 15;
223 score = Math.max(0, Math.min(100, score));
224 let grade = 'F';
225 if (score >= 90) grade = 'A';
226 else if (score >= 75) grade = 'B';
227 else if (score >= 60) grade = 'C';
228 else if (score >= 40) grade = 'D';
229 return { score, grade };
230}
231
232async function fetchFundingFile(parsed, defaultBranch, timeoutSeconds) {
233 const branch = defaultBranch || 'main';
234 const candidatePaths = ['.github/FUNDING.yml', '.github/FUNDING.yaml'];
235 for (const path of candidatePaths) {
236 const rawUrl = `https://raw.githubusercontent.com/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/${encodeURIComponent(branch)}/${path}`;
237 const result = await fetchText(rawUrl, timeoutSeconds);
238 if (result.ok && result.body.trim()) {
239 return { found: true, path, rawUrl, body: result.body, status: result.status };
240 }
241 }
242 return { found: false, path: null, rawUrl: null, body: '', status: null };
243}
244
245export async function auditRepos(input) {
246 const repoUrls = Array.isArray(input.repoUrls) ? input.repoUrls : [];
247 if (repoUrls.length === 0) throw new Error('repoUrls must contain at least one GitHub repository URL');
248 if (repoUrls.length > MAX_REPOS) throw new Error(`A maximum of ${MAX_REPOS} repositories can be audited per run`);
249
250 const timeoutSeconds = clampTimeout(input);
251 const seen = new Set();
252 const outputs = [];
253
254 for (const rawUrl of repoUrls) {
255 let parsed;
256 try {
257 parsed = parseGitHubRepoUrl(rawUrl);
258 } catch (error) {
259 outputs.push({ inputUrl: rawUrl, ok: false, error: error.message });
260 continue;
261 }
262
263 if (seen.has(parsed.fullName.toLowerCase())) continue;
264 seen.add(parsed.fullName.toLowerCase());
265
266 const repoResult = await fetchJson(parsed.apiUrl, timeoutSeconds);
267 if (!repoResult.ok) {
268 outputs.push({
269 inputUrl: parsed.htmlUrl,
270 fullName: parsed.fullName,
271 ok: false,
272 status: repoResult.status,
273 error: repoResult.error || repoResult.json?.message || 'GitHub API request failed',
274 });
275 continue;
276 }
277
278 const defaultBranch = repoResult.json?.default_branch || 'main';
279 const funding = await fetchFundingFile(parsed, defaultBranch, timeoutSeconds);
280
281 let validation = { valid: false, issues: ['FUNDING.yml not found on default branch'], platforms: [], recommendations: ['Add a .github/FUNDING.yml file to enable the GitHub Sponsor button.'] };
282 let parseError = null;
283 if (funding.found) {
284 try {
285 const doc = YAML.parse(funding.body);
286 validation = validateFundingConfig(doc);
287 } catch (error) {
288 parseError = error.message;
289 validation = { valid: false, issues: [`YAML parse error: ${error.message}`], platforms: [], recommendations: ['Fix the YAML syntax in FUNDING.yml.'] };
290 }
291 }
292
293 const { score, grade } = scoreAudit(funding.found, validation);
294
295 outputs.push({
296 inputUrl: parsed.htmlUrl,
297 fullName: repoResult.json?.full_name || parsed.fullName,
298 ok: true,
299 checkedAt: new Date().toISOString(),
300 defaultBranch,
301 fundingFileFound: funding.found,
302 fundingFilePath: funding.path,
303 fundingFileUrl: funding.rawUrl,
304 parseError,
305 platforms: validation.platforms,
306 valid: validation.valid,
307 issues: validation.issues,
308 recommendations: validation.recommendations,
309 score,
310 grade,
311 });
312 }
313
314 return outputs;
315}
316
317if (process.env.NODE_ENV !== 'test') {
318 await Actor.init();
319 try {
320 const input = await Actor.getInput();
321 const results = await auditRepos(input || {});
322 for (const result of results) {
323 await Actor.pushData(result);
324 }
325 await Actor.setValue('OUTPUT', { count: results.length, results });
326 Actor.log.info('FUNDING.yml audit complete', { count: results.length });
327 } finally {
328 await Actor.exit();
329 }
330}