diff --git a/.github/workflows/ping-code-owners.yml b/.github/workflows/ping-code-owners.yml new file mode 100644 index 00000000000000..b5e2026541d853 --- /dev/null +++ b/.github/workflows/ping-code-owners.yml @@ -0,0 +1,34 @@ +name: Ping Code Owners + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + contents: read + +jobs: + ping-owners: + if: github.repository == 'nodejs/node' + runs-on: ubuntu-slim + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Use Node.js lts/* + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + + - name: Install codeowners-utils + run: npm install --no-save codeowners-utils + + - name: Ping code owners + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: node tools/actions/ping-owners.mjs \ No newline at end of file diff --git a/tools/actions/ping-owners.mjs b/tools/actions/ping-owners.mjs new file mode 100644 index 00000000000000..14def29245e231 --- /dev/null +++ b/tools/actions/ping-owners.mjs @@ -0,0 +1,72 @@ +import { matchPattern, parse } from 'codeowners-utils'; +import { readFileSync } from 'node:fs'; + +const { GITHUB_TOKEN, PR_NUMBER, REPO_OWNER, REPO_NAME } = process.env; + +async function githubRequest(path, options = {}) { + const response = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + 'Authorization': `Bearer ${GITHUB_TOKEN}`, + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +async function getChangedFiles() { + const files = []; + let page = 1; + while (true) { + const data = await githubRequest( + `/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${PR_NUMBER}/files?per_page=100&page=${page}`, + ); + files.push(...data.map((f) => f.filename)); + if (data.length < 100) break; + page++; + } + return files; +} + +export function getOwnersForPaths(codeownersContent, changedFiles) { + const definitions = parse(codeownersContent); + let ownersForPaths = []; + + for (const { pattern, owners } of definitions) { + for (const file of changedFiles) { + if (matchPattern(file, pattern)) { + ownersForPaths = ownersForPaths.concat(owners); + } + } + } + + return ownersForPaths.filter((v, i) => ownersForPaths.indexOf(v) === i).sort(); +} + +export function getCommentBody(owners) { + return `Review requested:\n\n${owners.map((i) => `- [ ] ${i}`).join('\n')}`; +} + +async function pingOwners() { + const changedFiles = await getChangedFiles(); + const codeownersContent = readFileSync('.github/CODEOWNERS', 'utf8'); + const owners = getOwnersForPaths(codeownersContent, changedFiles); + if (owners.length === 0) return; + await githubRequest( + `/repos/${REPO_OWNER}/${REPO_NAME}/issues/${PR_NUMBER}/comments`, + { + method: 'POST', + body: JSON.stringify({ body: getCommentBody(owners) }), + }, + ); +} + +pingOwners().catch((err) => { + console.error(err); + process.exit(1); +});