mirror of
https://github.com/esphome/esphome.git
synced 2025-07-28 14:16:40 +00:00
[CI] Add codeowners mention workflow (#9651)
This commit is contained in:
parent
e189add8a3
commit
afc48812fa
264
.github/workflows/codeowner-review-request.yml
vendored
Normal file
264
.github/workflows/codeowner-review-request.yml
vendored
Normal file
@ -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);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user