diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml new file mode 100644 index 0000000000..ddf5698211 --- /dev/null +++ b/.github/workflows/codeowner-review-request.yml @@ -0,0 +1,264 @@ +# This workflow automatically requests reviews from codeowners when: +# 1. A PR is opened, reopened, or synchronized (updated) +# 2. A PR is marked as ready for review +# +# It reads the CODEOWNERS file and matches all changed files in the PR against +# the codeowner patterns, then requests reviews from the appropriate owners +# while avoiding duplicate requests for users who have already been requested +# or have already reviewed the PR. + +name: Request Codeowner Reviews + +on: + # Needs to be pull_request_target to get write permissions + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + +permissions: + pull-requests: write + contents: read + +jobs: + request-codeowner-reviews: + name: Run + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Request reviews from component codeowners + uses: actions/github-script@v7.0.1 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr_number = context.payload.pull_request.number; + + console.log(`Processing PR #${pr_number} for codeowner review requests`); + + try { + // Get the list of changed files in this PR + const { data: files } = await github.rest.pulls.listFiles({ + owner, + repo, + pull_number: pr_number + }); + + const changedFiles = files.map(file => file.filename); + console.log(`Found ${changedFiles.length} changed files`); + + if (changedFiles.length === 0) { + console.log('No changed files found, skipping codeowner review requests'); + return; + } + + // Fetch CODEOWNERS file from root + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner, + repo, + path: 'CODEOWNERS', + ref: context.payload.pull_request.base.sha + }); + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); + + // Parse CODEOWNERS file to extract all patterns and their owners + const codeownersLines = codeownersContent.split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); + + const codeownersPatterns = []; + + // Convert CODEOWNERS pattern to regex (robust glob handling) + function globToRegex(pattern) { + // Escape regex special characters except for glob wildcards + let regexStr = pattern + .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars + .replace(/\*\*/g, '.*') // globstar + .replace(/\*/g, '[^/]*') // single star + .replace(/\?/g, '.'); // question mark + return new RegExp('^' + regexStr + '$'); + } + + // Helper function to create comment body + function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) { + const reviewerMentions = reviewersList.map(r => `@${r}`); + const teamMentions = teamsList.map(t => `@${owner}/${t}`); + const allMentions = [...reviewerMentions, ...teamMentions].join(', '); + + if (isSuccessful) { + return `👋 Hi there! I've automatically requested reviews from codeowners based on the files changed in this PR.\n\n${allMentions} - You've been requested to review this PR as codeowner(s) of ${matchedFileCount} file(s) that were modified. Thanks for your time! 🙏`; + } else { + return `👋 Hi there! This PR modifies ${matchedFileCount} file(s) with codeowners.\n\n${allMentions} - As codeowner(s) of the affected files, your review would be appreciated! 🙏\n\n_Note: Automatic review request may have failed, but you're still welcome to review._`; + } + } + + for (const line of codeownersLines) { + const parts = line.split(/\s+/); + if (parts.length < 2) continue; + + const pattern = parts[0]; + const owners = parts.slice(1); + + // Use robust glob-to-regex conversion + const regex = globToRegex(pattern); + codeownersPatterns.push({ pattern, regex, owners }); + } + + console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`); + + // Match changed files against CODEOWNERS patterns + const matchedOwners = new Set(); + const matchedTeams = new Set(); + const fileMatches = new Map(); // Track which files matched which patterns + + for (const file of changedFiles) { + for (const { pattern, regex, owners } of codeownersPatterns) { + if (regex.test(file)) { + console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`); + + if (!fileMatches.has(file)) { + fileMatches.set(file, []); + } + fileMatches.get(file).push({ pattern, owners }); + + // Add owners to the appropriate set (remove @ prefix) + for (const owner of owners) { + const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner; + if (cleanOwner.includes('/')) { + // Team mention (org/team-name) + const teamName = cleanOwner.split('/')[1]; + matchedTeams.add(teamName); + } else { + // Individual user + matchedOwners.add(cleanOwner); + } + } + } + } + } + + if (matchedOwners.size === 0 && matchedTeams.size === 0) { + console.log('No codeowners found for any changed files'); + return; + } + + // Remove the PR author from reviewers + const prAuthor = context.payload.pull_request.user.login; + matchedOwners.delete(prAuthor); + + // Get current reviewers to avoid duplicate requests (but still mention them) + const { data: prData } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr_number + }); + + const currentReviewers = new Set(); + const currentTeams = new Set(); + + if (prData.requested_reviewers) { + prData.requested_reviewers.forEach(reviewer => { + currentReviewers.add(reviewer.login); + }); + } + + if (prData.requested_teams) { + prData.requested_teams.forEach(team => { + currentTeams.add(team.slug); + }); + } + + // Check for completed reviews to avoid re-requesting users who have already reviewed + const { data: reviews } = await github.rest.pulls.listReviews({ + owner, + repo, + pull_number: pr_number + }); + + const reviewedUsers = new Set(); + reviews.forEach(review => { + reviewedUsers.add(review.user.login); + }); + + // Remove only users who have already submitted reviews (not just requested reviewers) + reviewedUsers.forEach(reviewer => { + matchedOwners.delete(reviewer); + }); + + // For teams, we'll still remove already requested teams to avoid API errors + currentTeams.forEach(team => { + matchedTeams.delete(team); + }); + + const reviewersList = Array.from(matchedOwners); + const teamsList = Array.from(matchedTeams); + + if (reviewersList.length === 0 && teamsList.length === 0) { + console.log('No eligible reviewers found (all may already be requested or reviewed)'); + return; + } + + const totalReviewers = reviewersList.length + teamsList.length; + console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`); + + // Request reviews + try { + const requestParams = { + owner, + repo, + pull_number: pr_number + }; + + // Filter out users who are already requested reviewers for the API call + const newReviewers = reviewersList.filter(reviewer => !currentReviewers.has(reviewer)); + const newTeams = teamsList.filter(team => !currentTeams.has(team)); + + if (newReviewers.length > 0) { + requestParams.reviewers = newReviewers; + } + + if (newTeams.length > 0) { + requestParams.team_reviewers = newTeams; + } + + // Only make the API call if there are new reviewers to request + if (newReviewers.length > 0 || newTeams.length > 0) { + await github.rest.pulls.requestReviewers(requestParams); + console.log(`Successfully requested reviews from ${newReviewers.length} new users and ${newTeams.length} new teams`); + } else { + console.log('All codeowners are already requested reviewers or have reviewed'); + } + + // Add a comment to the PR mentioning what happened (include all matched codeowners) + const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: commentBody + }); + } catch (error) { + if (error.status === 422) { + console.log('Some reviewers may already be requested or unavailable:', error.message); + + // Try to add a comment even if review request failed + const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false); + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body: commentBody + }); + } catch (commentError) { + console.log('Failed to add comment:', commentError.message); + } + } else { + throw error; + } + } + + } catch (error) { + console.log('Failed to process codeowner review requests:', error.message); + console.error(error); + }