diff --git a/.github/workflows/request-reviews.yml b/.github/workflows/request-reviews.yml new file mode 100644 index 00000000000..9f3fb02352e --- /dev/null +++ b/.github/workflows/request-reviews.yml @@ -0,0 +1,21 @@ +name: Request reviews for PRs + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + pull-requests: write + +jobs: + main: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + name: Checkout default branch + - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + name: Request reviews + with: + script: require('./tools/actions/request-reviews.js')(github, context); diff --git a/tools/actions/request-reviews.js b/tools/actions/request-reviews.js new file mode 100644 index 00000000000..d4a3535534a --- /dev/null +++ b/tools/actions/request-reviews.js @@ -0,0 +1,80 @@ +'use strict'; + +const fs = require('node:fs'); + +function globToRegex(glob) { + const patterns = { + '*': '([^\\/]+)', + '**': '(.+\\/)?([^\\/]+)', + '**/': '(.+\\/)', + }; + + const escapedGlob = glob.replace(/\./g, '\\.').replace(/\*\*$/g, '(.+)'); + const regexString = '^(' + escapedGlob.replace(/\*\*\/|\*\*|\*/g, (match) => patterns[match] || '') + ')$'; + return new RegExp(regexString); +} + +class CodeOwners { + constructor(fileContent) { + this.owners = fileContent.split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const [path, ...owners] = line.split(/\s+/); + return { path: globToRegex(path), owners }; + }); + } + + getOwners(filePath) { + const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + return this.owners + .filter(({ path }) => path.test(normalizedPath)) + .flatMap(({ owners }) => owners); + } +} + +module.exports = async (client, context) => { + async function getChangedFiles(base, head) { + const { data } = await client.rest.repos.compareCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + base, + head, + }); + return data.files.map((file) => file.filename); + } + + const base = context.payload.pull_request?.base?.sha; + const head = context.payload.pull_request?.head?.sha; + + if (!base || !head) { + throw new Error('Cannot get base or head commit'); + } + const number = context.payload.pull_request.number; + + // Get CODEOWNERS + const changedFiles = await getChangedFiles(base, head); + const codeOwners = new CodeOwners(fs.readFileSync('.github/CODEOWNERS', 'utf8')); + const owners = new Set(); + changedFiles.forEach((file) => { + codeOwners.getOwners(file).forEach((owner) => owners.add(owner)); + }); + + // Get team members + const reviewers = new Set(); + + for (const owner of owners) { + const teamMembers = await client.rest.teams.listMembersInOrg({ + org: context.repo.owner, + team_slug: owner.split('/')[1], + }); + teamMembers.data.forEach((member) => reviewers.add(member.login.toLowerCase())); + } + + return client.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + reviewers: [...reviewers], + }); +};