/** * Generate human-readable reports about missing contributors. * This module queries merged PRs via 'gh' and produces a markdown report. */ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { setupGracefulShutdown } from './utils/graceful-shutdown.mjs'; const DEFAULT_CMD_TIMEOUT = 30_000; // 30s setupGracefulShutdown('contributor-report'); /** * Patterns that represent generated files; contributors should not be credited * for these files because they are not substantive authored content. */ export const AUTO_GENERATED_PATTERNS = [ 'README.md', 'README.*.md', 'collections/*.md', 'collections/*.collection.md', 'docs/README.*.md', 'docs/*.generated.md' ]; /** * File globs used to infer contribution types from file paths. */ export const TYPE_PATTERNS = { instructions: [ 'instructions/*.instructions.md' ], prompts: [ 'prompts/*.prompt.md' ], agents: [ 'chatmodes/*.chatmode.md', 'agents/*.agent.md' ], skills: [ 'skills/' ], collections: [ 'collections/*.collection.yml' ], doc: [ 'docs/**/*.md', '.github/**/*.md', 'CONTRIBUTING.md', 'SECURITY.md', 'SUPPORT.md', 'LICENSE.md', 'CHANGELOG.md', '*.md' ], infra: [ '.github/workflows/**/*.yml', '.github/workflows/**/*.yaml', '**/*.yml', '**/*.yaml' ], maintenance: [ 'package*.json', '*config*', 'tsconfig*.json' ], code: [ '**/*.js', '**/*.ts', '**/*.mjs', '**/*.cjs', '**/*.py' ] }; const globCache = new Map(); /** * Convert a simple glob (with *, **) to a RegExp. * This is intentionally small and deterministic for our repo patterns. * @param {string} pattern * @returns {RegExp} */ export const globToRegExp = (pattern) => { const DOUBLE_WILDCARD_PLACEHOLDER = 'Β§Β§DOUBLEΒ§Β§'; // Escape all regex-special characters except glob wildcards (*, ?, /), // then translate glob syntax to regex. // Note: This function intentionally supports only a small subset of glob syntax. const regexSpecials = /[.+^${}()|[\]\\]/g; let normalized = String(pattern); // Normalize Windows-style separators to POSIX-style for matching. normalized = normalized.replaceAll('\\', '/'); // Escape regex metacharacters so they are treated literally. normalized = normalized.replaceAll(regexSpecials, (match) => `\\${match}`); // Handle glob wildcards. normalized = normalized.replaceAll('**', DOUBLE_WILDCARD_PLACEHOLDER); normalized = normalized.replaceAll('*', '[^/]*'); normalized = normalized.replaceAll(DOUBLE_WILDCARD_PLACEHOLDER, '.*'); normalized = normalized.replaceAll('?', '.'); return new RegExp(`^${normalized}$`); }; /** * Test whether a file path matches a glob pattern. * @param {string} filePath * @param {string} pattern * @returns {boolean} */ export const matchGlob = (filePath, pattern) => { if (!globCache.has(pattern)) { try { globCache.set(pattern, globToRegExp(pattern)); } catch { globCache.set(pattern, null); } } const regexp = globCache.get(pattern); if (!regexp) { return false; } const normalized = filePath.replaceAll('\\', '/'); return regexp.test(normalized); }; /** * Return true if the given path matches one of the known auto-generated patterns. * @param {string} filePath * @returns {boolean} */ export const isAutoGeneratedFile = (filePath) => { return AUTO_GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern)); }; /** * Infer a contribution type string (e.g. 'prompts', 'agents', 'doc') for a file path. * Returns null if no specific type matched. * @param {string} filePath * @returns {string|null} */ export const getFileContributionType = (filePath) => { const normalized = filePath.replaceAll('\\', '/'); for (const [type, patterns] of Object.entries(TYPE_PATTERNS)) { if (patterns.some((pattern) => matchGlob(normalized, pattern))) { return type; } } return null; }; /** * Derive a comma-separated list of contribution type identifiers from a list of files. * Auto-generated files are ignored. Returns '' when no files to process. * @param {string[]} files * @returns {string} */ export const getContributionTypes = (files) => { const types = new Set(); let processed = 0; for (const file of files) { if (isAutoGeneratedFile(file)) { continue; } processed += 1; const type = getFileContributionType(file); if (type) { types.add(type); } } if (processed === 0) { return ''; } if (types.size === 0) { types.add('code'); } return Array.from(types).sort((a, b) => a.localeCompare(b)).join(','); }; /** * Check .all-contributors output to discover missing contributors. * This is the canonical implementation used by contributor tooling. * @returns {string[]} */ export const getMissingContributors = () => { try { console.log('πŸ” Checking for missing contributors...'); const configPath = path.join(process.cwd(), '.all-contributorsrc'); const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); const ignoreEntries = config.ignoreList || config.ignore || []; const ignoreSet = new Set(ignoreEntries.map((entry) => entry.toLowerCase())); if (ignoreSet.size > 0) { console.log(`πŸ“‹ Loaded ignore list: ${Array.from(ignoreSet).join(', ')}`); } const output = execSync('npx all-contributors check', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: DEFAULT_CMD_TIMEOUT }); const lines = output.split('\n'); const headerLineIndex = lines.findIndex(line => line.includes('Missing contributors in .all-contributorsrc:') ); if (headerLineIndex === -1) { console.log('βœ… No missing contributors found'); return []; } let contributorsLine = ''; for (let i = headerLineIndex + 1; i < lines.length; i++) { const line = lines[i].trim(); if (line.includes('Unknown contributors') || line.includes('✨')) { break; } if (line && !line.startsWith('β ™') && !line.startsWith('✨')) { contributorsLine = line; break; } } if (!contributorsLine) { console.log('βœ… No missing contributors found'); return []; } const allUsernames = contributorsLine .split(',') .map(username => username.trim()) .filter(username => username.length > 0); const filteredUsernames = allUsernames.filter(username => { const lowerUsername = username.toLowerCase(); if (ignoreSet.has(lowerUsername)) { console.log(`⏭️ FILTERED: ${username} is in ignore list`); return false; } return true; }); console.log(`πŸ“‹ Found ${filteredUsernames.length} missing contributors after filtering: ${filteredUsernames.join(', ')}`); return filteredUsernames; } catch (error) { const stderr = String(error?.stderr ?? ''); const stdout = String(error?.stdout ?? ''); const details = [stderr, stdout, String(error?.message ?? '')].join('\n'); // Never print token values. Just print actionable guidance. if (details.toLowerCase().includes('bad credentials') || details.includes('401')) { console.error('❌ all-contributors authentication failed (Bad credentials / 401).'); console.error('πŸ’‘ Set a valid token in PRIVATE_TOKEN (all-contributors-cli) and/or GH_TOKEN (gh CLI).'); console.error('πŸ’‘ In GitHub Actions, you can usually use: secrets.GITHUB_TOKEN'); throw new Error('contributors:check failed due to invalid credentials'); } console.error('❌ Error checking for missing contributors:', String(error?.message ?? error)); if (details.trim()) { console.error('--- all-contributors output (truncated) ---'); console.error(details.slice(0, 2000)); console.error('--- end output ---'); } if (String(error?.message ?? '').includes('command not found') || String(error?.message ?? '').includes('not recognized')) { console.error('πŸ’‘ Make sure all-contributors-cli is installed: npm install all-contributors-cli'); } throw error; } }; // --- REPORT GENERATION LOGIC --- /** * Get the current GitHub repository in owner/repo format. * Tries upstream first, then origin. * @returns {string} */ const getGitHubRepo = () => { const parseRepoFromRemoteUrl = (remoteUrl) => { const url = String(remoteUrl || '').trim(); if (!url) return null; // Supports: // - git@github.com:owner/repo.git // - ssh://git@github.com/owner/repo.git // - https://github.com/owner/repo.git // - https://github.com/owner/repo const regex = /github\.com[/:]([^/]+)\/([^/?#]+?)(?:\.git)?(?:[/?#]|$)/; const match = regex.exec(url); if (!match) return null; return `${match[1]}/${match[2]}`; }; try { const upstreamUrl = execSync('git config --get remote.upstream.url', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); if (upstreamUrl) { const repo = parseRepoFromRemoteUrl(upstreamUrl); if (repo) return repo; } } catch (e) { console.debug('upstream not found, trying origin', e?.message || e); } try { const originUrl = execSync('git config --get remote.origin.url', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); const repo = parseRepoFromRemoteUrl(originUrl); if (repo) return repo; } catch (e) { console.debug('origin not found, using default', e?.message || e); } return 'github/awesome-copilot'; }; /** * Fetch merged PRs for a GitHub username using the GH CLI and filter files. * @param {string} username * @param {{includeAllFiles?:boolean}} [opts] * @returns {Array} Array of PR objects */ export const fetchContributorMergedPrs = (username, { includeAllFiles = false } = {}) => { try { const repo = getGitHubRepo(); const result = execSync( `gh pr list --repo ${repo} --state merged --author ${username} --json number,title,mergedAt,files,url --limit 100`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: DEFAULT_CMD_TIMEOUT } ); const prs = JSON.parse(result); if (includeAllFiles) { return prs; } return prs.filter(pr => { const hasNonConfigFiles = pr.files.some(file => !isAutoGeneratedFile(file.path) ); return hasNonConfigFiles; }); } catch (error) { console.error(`Failed to fetch PRs for ${username}:`, error.message); return []; } }; /** * Convert a PR object into a normalized report entry with types and file details. * @param {{login:string}} contributor * @param {object} pr * @param {{includeAllFiles?:boolean}} [opts] * @returns {object|null} */ const generatePRReport = (contributor, pr, { includeAllFiles = false } = {}) => { const types = new Set(); const fileDetails = []; for (const file of pr.files) { if (!file?.path) { continue; } // Include generated files only if includeAllFiles is true if (!includeAllFiles && isAutoGeneratedFile(file.path)) { continue; } const type = getFileContributionType(file.path) || 'ideas'; if (type) { types.add(type); } fileDetails.push({ path: file.path, type: type || 'unknown', additions: file.additions, deletions: file.deletions }); } // If no non-filtered files contributed to types, and we're not asked for all files, skip this PR if (types.size === 0 && !includeAllFiles) { return null; } // Fallback to 'code' if no types detected if (types.size === 0) { types.add('code'); } const typeList = Array.from(types); return { prNumber: pr.number, prTitle: pr.title, prUrl: pr.url, mergedAt: pr.mergedAt, contributionTypes: typeList, files: fileDetails, commentSnippet: `@all-contributors please add @${contributor.login} for ${typeList.join(', ')}` }; }; /** * Build a contributor report by inspecting merged PRs and mapping files to types. * Returns null when no relevant PRs were found (unless includeAllFiles is true). * @param {string} username * @param {{includeAllFiles?:boolean}} [opts] * @returns {object|null} */ export const generateContributorReport = (username, { includeAllFiles = false } = {}) => { console.log(`Inspecting ${username}...`); const prs = fetchContributorMergedPrs(username, { includeAllFiles }); const prReports = prs .map(pr => generatePRReport({ login: username }, pr, { includeAllFiles })) .filter(report => report !== null); // If no relevant PR reports and not explicitly including all files, skip the contributor entirely if (prReports.length === 0 && !includeAllFiles) { return null; } return { username, totalPRs: prs.length, prs: prReports }; }; /** * Render a set of contributor reports as markdown for human review. * @param {Array} reports * @param {number} missingCount - number of missing contributors detected * @returns {string} */ export const generateMarkdownReport = (reports, missingCount = 0) => { if (!missingCount) { return 'No missing contributors detected.\n'; } const nowIso = new Date().toISOString(); const computeTypesArg = (report) => { const typeSet = new Set(); for (const pr of report.prs || []) { for (const type of pr.contributionTypes || []) { if (type) { typeSet.add(type); } } } const types = Array.from(typeSet).sort((a, b) => a.localeCompare(b)); return types.length > 0 ? types.join(',') : 'code'; }; const lines = []; lines.push( '# Missing Contributors Report', '', `Generated (ISO): ${nowIso}`, '', `Missing contributors: ${missingCount}`, '' ); for (const report of reports) { lines.push(`## @${report.username}`); const prs = Array.from(report.prs || []).sort((a, b) => { // Prefer most recent PRs first. const aTime = a.mergedAt ? Date.parse(a.mergedAt) : 0; const bTime = b.mergedAt ? Date.parse(b.mergedAt) : 0; if (aTime !== bTime) return bTime - aTime; return (b.prNumber ?? 0) - (a.prNumber ?? 0); }); if (prs.length === 0) { lines.push( '', '_No eligible PRs found._', '', `Alternate CLI: \`npx all-contributors add ${report.username} ${computeTypesArg(report)}\``, '', '---', '' ); continue; } lines.push(''); for (const pr of prs) { const prTypes = (pr.contributionTypes || []).filter(Boolean); const prTypesArg = prTypes.length > 0 ? prTypes.join(', ') : 'code'; const title = String(pr.prTitle ?? ''); const url = String(pr.prUrl ?? ''); const comment = `@all-contributors please add @${report.username} for ${prTypesArg}`; lines.push( `[#${pr.prNumber}](${url}) ${title}`, '```plaintext', comment, '```' ); } lines.push( '', '### Alternate CLI Command', '', '```bash', `npx all-contributors add ${report.username} ${computeTypesArg(report)}`, '```', '', '---', '' ); } return `${lines.join('\n')}\n`; }; const main = () => { try { // gh CLI can use either its own authenticated session or token env vars. // In CI, we commonly receive a token via PRIVATE_TOKEN. if (process.env.PRIVATE_TOKEN && !process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) { process.env.GITHUB_TOKEN = process.env.PRIVATE_TOKEN; } // gh prefers GH_TOKEN; if we only have GITHUB_TOKEN, make GH_TOKEN explicit. if (process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) { process.env.GH_TOKEN = process.env.GITHUB_TOKEN; } const args = new Set(process.argv.slice(2)); const includeAllFiles = args.has('--include-all-pr-files'); const contributors = getMissingContributors(); console.log(`Inspecting ${contributors.length} missing contributors...\n`); const reports = []; for (const contributor of contributors) { const report = generateContributorReport(contributor, { includeAllFiles }); reports.push(report || { username: contributor, totalPRs: 0, prs: [] }); } const markdown = generateMarkdownReport(reports, contributors.length); const outputPath = path.join(process.cwd(), 'reports', 'contributor-report.md'); fs.writeFileSync(outputPath, markdown); console.log(`Report saved to: ${outputPath}`); } catch (error) { console.error('Error generating report:', error); process.exit(1); } }; if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { main(); }