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