diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml index 159910ee..e8310bf5 100644 --- a/.github/workflows/contributors.yml +++ b/.github/workflows/contributors.yml @@ -8,7 +8,7 @@ on: jobs: contributors: runs-on: ubuntu-latest - timeout-minutes: 12 + timeout-minutes: 5 permissions: contents: write pull-requests: write @@ -36,12 +36,12 @@ jobs: run: | CHECK_OUTPUT=$(npm run contributors:check 2>&1) echo "$CHECK_OUTPUT" - + if echo "$CHECK_OUTPUT" | grep -q "Missing contributors"; then echo "Missing contributors detected, generating report..." mkdir -p reports npm run contributors:report - + if [ -f reports/contributor-report.md ]; then cat reports/contributor-report.md >> $GITHUB_STEP_SUMMARY fi diff --git a/README.md b/README.md index d22fa5cf..bb87f895 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # 🤖 Awesome GitHub Copilot Customizations -[![Powered by Awesome Copilot](https://img.shields.io/badge/Powered_by-Awesome_Copilot-blue?logo=githubcopilot)](https://aka.ms/awesome-github-copilot?style=flat-square) [![GitHub contributors from allcontributors.org](https://img.shields.io/github/all-contributors/github/awesome-copilot?style=flat-square&color=ee8449)](#contributors-) +[![Powered by Awesome Copilot](https://img.shields.io/badge/Powered_by-Awesome_Copilot-blue?logo=githubcopilot)](https://aka.ms/awesome-github-copilot) [![GitHub contributors from allcontributors.org](https://img.shields.io/github/all-contributors/github/awesome-copilot?color=ee8449)](#contributors-) A community created collection of custom agents, prompts, and instructions to supercharge your GitHub Copilot experience across different domains, languages, and use cases. diff --git a/eng/add-missing-contributors.mjs b/eng/add-missing-contributors.mjs index dc8911c5..c9b03321 100644 --- a/eng/add-missing-contributors.mjs +++ b/eng/add-missing-contributors.mjs @@ -8,6 +8,7 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { getContributionTypes, getMissingContributors, @@ -282,7 +283,7 @@ const printSummaryReport = (results) => { console.log('\n' + '='.repeat(50)); }; -if (process.argv[1] === (new URL(import.meta.url)).pathname) { +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { try { const results = await main(); printSummaryReport(results); diff --git a/eng/contributor-report.mjs b/eng/contributor-report.mjs index 9b4cdc5f..7f44f8a2 100644 --- a/eng/contributor-report.mjs +++ b/eng/contributor-report.mjs @@ -5,6 +5,7 @@ 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 @@ -59,11 +60,14 @@ export const TYPE_PATTERNS = { ], maintenance: [ 'package*.json', - '*.config.js', + '*config*', 'tsconfig*.json' ], code: [ - '**/*.{js,ts,mjs,cjs}', + '**/*.js', + '**/*.ts', + '**/*.mjs', + '**/*.cjs', '**/*.py' ] }; @@ -78,17 +82,25 @@ const globCache = new Map(); */ export const globToRegExp = (pattern) => { const DOUBLE_WILDCARD_PLACEHOLDER = '§§DOUBLE§§'; - const replacements = [ - { pattern: /\\/g, replacement: '/' }, - { pattern: /\./g, replacement: String.raw`\.` }, - { pattern: /\*\*/g, replacement: DOUBLE_WILDCARD_PLACEHOLDER }, - { pattern: /\*/g, replacement: '[^/]*' }, - { pattern: new RegExp(DOUBLE_WILDCARD_PLACEHOLDER, 'g'), replacement: '.*' }, - { pattern: /\?/g, replacement: '.' }, - { pattern: /\//g, replacement: String.raw`\/` } - ]; - const normalized = replacements.reduce((acc, { pattern, replacement }) => acc.replace(pattern, replacement), String(pattern)); + // 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}$`); }; @@ -113,7 +125,7 @@ export const matchGlob = (filePath, pattern) => { return false; } - const normalized = filePath.replace(/\\/g, '/'); + const normalized = filePath.replaceAll('\\', '/'); return regexp.test(normalized); }; @@ -133,7 +145,7 @@ export const isAutoGeneratedFile = (filePath) => { * @returns {string|null} */ export const getFileContributionType = (filePath) => { - const normalized = filePath.replace(/\\/g, '/'); + const normalized = filePath.replaceAll('\\', '/'); for (const [type, patterns] of Object.entries(TYPE_PATTERNS)) { if (patterns.some((pattern) => matchGlob(normalized, pattern))) { @@ -267,17 +279,33 @@ export const getMissingContributors = () => { * @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 match = upstreamUrl.match(/github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/); - if (match) return `${match[1]}/${match[2]}`; + const repo = parseRepoFromRemoteUrl(upstreamUrl); + if (repo) return repo; } } catch (e) { - console.debug('upstream not found, trying origin'); + console.debug('upstream not found, trying origin', e?.message || e); } try { @@ -285,22 +313,15 @@ const getGitHubRepo = () => { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); - const match = originUrl.match(/github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/); - if (match) return `${match[1]}/${match[2]}`; + const repo = parseRepoFromRemoteUrl(originUrl); + if (repo) return repo; } catch (e) { - console.debug('origin not found, using default'); + console.debug('origin not found, using default', e?.message || e); } return 'github/awesome-copilot'; }; -const CONTRIBUTION_TYPE_MAP = { - 'instructions': { symbol: '🧭', description: 'The big AI prompt recipes (Copilot instruction sets)' }, - 'prompts': { symbol: '⌨️', description: 'One-shot or reusable user-level prompts' }, - 'agents': { symbol: '🎭', description: 'Defined Copilot personalities / roles' }, - 'collections': { symbol: '🎁', description: 'Bundled thematic sets (e.g., "Copilot for Docs")' } -}; - /** * Fetch merged PRs for a GitHub username using the GH CLI and filter files. * @param {string} username @@ -445,12 +466,14 @@ export const generateMarkdownReport = (reports, missingCount = 0) => { const lines = []; - lines.push('# Missing Contributors Report'); - lines.push(''); - lines.push(`Generated (ISO): ${nowIso}`); - lines.push(''); - lines.push(`Missing contributors: ${missingCount}`); - lines.push(''); + lines.push( + '# Missing Contributors Report', + '', + `Generated (ISO): ${nowIso}`, + '', + `Missing contributors: ${missingCount}`, + '' + ); for (const report of reports) { lines.push(`## @${report.username}`); @@ -464,13 +487,15 @@ export const generateMarkdownReport = (reports, missingCount = 0) => { }); if (prs.length === 0) { - lines.push(''); - lines.push('_No eligible PRs found._'); - lines.push(''); - lines.push(`Alternate CLI: \`npx all-contributors add ${report.username} ${computeTypesArg(report)}\``); - lines.push(''); - lines.push('---'); - lines.push(''); + lines.push( + '', + '_No eligible PRs found._', + '', + `Alternate CLI: \`npx all-contributors add ${report.username} ${computeTypesArg(report)}\``, + '', + '---', + '' + ); continue; } @@ -483,101 +508,30 @@ export const generateMarkdownReport = (reports, missingCount = 0) => { const url = String(pr.prUrl ?? ''); const comment = `@all-contributors please add @${report.username} for ${prTypesArg}`; - // PR line - lines.push(`[#${pr.prNumber}](${url}) ${title}`); - // fenced single-line comment snippet for this PR (plaintext as requested) - lines.push('```plaintext'); - lines.push(comment); - lines.push('```'); + lines.push( + `[#${pr.prNumber}](${url}) ${title}`, + '```plaintext', + comment, + '```' + ); } - lines.push(''); - lines.push('### Alternate CLI Command'); - lines.push(''); - lines.push('```bash'); - lines.push(`npx all-contributors add ${report.username} ${computeTypesArg(report)}`); - lines.push('```'); - lines.push(''); - lines.push('---'); - lines.push(''); + lines.push( + '', + '### Alternate CLI Command', + '', + '```bash', + `npx all-contributors add ${report.username} ${computeTypesArg(report)}`, + '```', + '', + '---', + '' + ); } return `${lines.join('\n')}\n`; }; -/** - * Check whether a PR already contains an all-contributors bot comment. - * @param {number} prNumber - * @returns {boolean} - */ -export const hasExistingAllContributorsComment = (prNumber) => { - try { - const repo = getGitHubRepo(); - const json = execSync(`gh pr view ${prNumber} --repo ${repo} --json comments`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: DEFAULT_CMD_TIMEOUT - }); - - const data = JSON.parse(json); - const comments = data?.comments?.nodes || data?.comments || []; - return comments.some((comment) => comment?.body?.includes(`@all-contributors`)); - } catch (error) { - console.warn(`⚠️ Unable to inspect comments for PR #${prNumber}: ${error.message}`); - return false; - } -}; - -/** - * Post a comment to a PR using the GH CLI. - * @param {number} prNumber - * @param {string} body - * @returns {boolean} - */ -export const postCommentOnPr = (prNumber, body) => { - try { - const repo = getGitHubRepo(); - execSync(`gh pr comment ${prNumber} --repo ${repo} --body "${body.replace(/"/g, '\\"')}"`, { - encoding: 'utf8', - stdio: ['pipe', 'inherit', 'inherit'], - timeout: DEFAULT_CMD_TIMEOUT - }); - - console.log(`💬 Posted recommendation comment on PR #${prNumber}`); - return true; - } catch (error) { - console.warn(`⚠️ Failed to post comment on PR #${prNumber}: ${error.message}`); - return false; - } -}; - -/** - * Post suggested all-contributors comments to PRs for a collection of reports. - * @param {Array} reports - */ -export const autoAddCommentsToReports = (reports) => { - for (const report of reports) { - for (const pr of report.prs) { - if (hasExistingAllContributorsComment(pr.prNumber)) { - console.log(`💬 Skipping PR #${pr.prNumber} for @${report.username} — comment already present`); - continue; - } - - const types = pr.contributionTypes.map(t => '`' + t + '`').join(', '); - const commentLines = [ - `Thanks for the contribution @${report.username}!`, - '', - `We detected contribution categories for this PR: ${types || '`code`'}.`, - '', - `@all-contributors please add @${report.username} for ${pr.contributionTypes.join(', ')}` - ]; - - const body = commentLines.join('\n'); - postCommentOnPr(pr.prNumber, body); - } - } -}; - const main = () => { try { // gh CLI can use either its own authenticated session or token env vars. @@ -587,7 +541,6 @@ const main = () => { } const args = new Set(process.argv.slice(2)); - const autoAdd = args.has('--auto-add-pr-comments'); const includeAllFiles = args.has('--include-all-pr-files'); const contributors = getMissingContributors(); @@ -605,16 +558,12 @@ const main = () => { console.log(`Report saved to: ${outputPath}`); - if (autoAdd) { - autoAddCommentsToReports(reports); - } - } catch (error) { console.error('Error generating report:', error); process.exit(1); } }; -if (process.argv[1] === (new URL(import.meta.url)).pathname) { +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { main(); } diff --git a/eng/utils/graceful-shutdown.mjs b/eng/utils/graceful-shutdown.mjs index 036f33f2..6ebeece4 100644 --- a/eng/utils/graceful-shutdown.mjs +++ b/eng/utils/graceful-shutdown.mjs @@ -26,8 +26,9 @@ export const setupGracefulShutdown = (name, { exitCode = 1 } = {}) => { try { process.exit(exitCode); } catch (e) { - // process.exit may not be desirable in some test harnesses; swallow errors - console.warn(`${name}: process.exit failed:`, e?.message); + // If process.exit is stubbed or overridden (e.g. in tests), surface the failure. + console.error(`${name}: process.exit failed:`, e?.message || e); + throw e; } };