mirror of
https://github.com/esphome/esphome.git
synced 2026-09-14 00:28:39 +00:00
[github-actions] Add code-owner-approved label workflow
Add a workflow that automatically manages a code-owner-approved label on PRs when component-specific codeowners submit approvals. This helps maintainers prioritize PRs that have domain-expert sign-off. The workflow triggers on pull_request_review events and recalculates the label based on all current reviews. Only individual component codeowners count - the catch-all @esphome/core team is excluded. Uses last-match-wins semantics for CODEOWNERS pattern matching. Also extracts shared CODEOWNERS parsing logic into a reusable module at .github/scripts/codeowners.js and updates codeowner-review-request and auto-label-pr workflows to use it.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# This workflow adds/removes a 'code-owner-approved' label when a
|
||||
# component-specific codeowner approves (or dismisses) a PR.
|
||||
# This helps maintainers prioritize PRs that have codeowner sign-off.
|
||||
#
|
||||
# Only component-specific codeowners count — the catch-all @esphome/core
|
||||
# team is excluded so the label reflects domain-expert approval.
|
||||
|
||||
name: Codeowner Approved Label
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codeowner-approved:
|
||||
name: Run
|
||||
if: ${{ github.repository == 'esphome/esphome' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check codeowner approval and update label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { fetchCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
const LABEL_NAME = 'code-owner-approved';
|
||||
|
||||
console.log(`Processing PR #${pr_number} for codeowner approval label`);
|
||||
|
||||
try {
|
||||
// Get the list of changed files in this PR (with pagination)
|
||||
const prFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
const changedFiles = prFiles.map(file => file.filename);
|
||||
console.log(`Found ${changedFiles.length} changed files`);
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No changed files found, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch and parse CODEOWNERS from base branch
|
||||
const codeownersPatterns = await fetchCodeowners(
|
||||
github, owner, repo,
|
||||
context.payload.pull_request.base.sha
|
||||
);
|
||||
|
||||
// Get effective owners using last-match-wins semantics
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
|
||||
// Only keep individual component-specific codeowners (exclude teams)
|
||||
const componentCodeowners = effective.users;
|
||||
|
||||
console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
|
||||
|
||||
if (componentCodeowners.size === 0) {
|
||||
console.log('No component-specific codeowners found for changed files');
|
||||
// Remove label if present since there are no component codeowners
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr_number,
|
||||
name: LABEL_NAME
|
||||
});
|
||||
console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
console.log(`Failed to remove label: ${error.message}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all reviews on the PR
|
||||
const reviews = await github.paginate(
|
||||
github.rest.pulls.listReviews,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
// Get the latest review per user (reviews are returned chronologically)
|
||||
const latestReviewByUser = new Map();
|
||||
for (const review of reviews) {
|
||||
// Skip bot reviews and comment-only reviews
|
||||
if (!review.user || review.state === 'COMMENTED') continue;
|
||||
latestReviewByUser.set(review.user.login, review);
|
||||
}
|
||||
|
||||
// Check if any component-specific codeowner has an active approval
|
||||
let hasCodeownerApproval = false;
|
||||
for (const [login, review] of latestReviewByUser) {
|
||||
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
|
||||
console.log(`Codeowner '${login}' has approved`);
|
||||
hasCodeownerApproval = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels to check if label is already present
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr_number
|
||||
});
|
||||
const hasLabel = currentLabels.some(label => label.name === LABEL_NAME);
|
||||
|
||||
if (hasCodeownerApproval && !hasLabel) {
|
||||
// Add the label
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr_number,
|
||||
labels: [LABEL_NAME]
|
||||
});
|
||||
console.log(`Added '${LABEL_NAME}' label`);
|
||||
} else if (!hasCodeownerApproval && hasLabel) {
|
||||
// Remove the label
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr_number,
|
||||
name: LABEL_NAME
|
||||
});
|
||||
console.log(`Removed '${LABEL_NAME}' label`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
console.log(`Failed to remove label: ${error.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('Failed to process codeowner approval label:', error.message);
|
||||
console.error(error);
|
||||
}
|
||||
@@ -24,10 +24,15 @@ jobs:
|
||||
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Request reviews from component codeowners
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { fetchCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
@@ -53,32 +58,13 @@ jobs:
|
||||
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');
|
||||
// Fetch and parse CODEOWNERS file from base branch
|
||||
const codeownersPatterns = await fetchCodeowners(
|
||||
github, owner, repo,
|
||||
context.payload.pull_request.base.sha
|
||||
);
|
||||
|
||||
// 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 + '$');
|
||||
}
|
||||
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
||||
|
||||
// Helper function to create comment body
|
||||
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
|
||||
@@ -93,47 +79,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
const matchedOwners = effective.users;
|
||||
const matchedTeams = effective.teams;
|
||||
|
||||
// Count matched files for the comment
|
||||
let matchedFileCount = 0;
|
||||
for (const file of changedFiles) {
|
||||
for (const { pattern, regex, owners } of codeownersPatterns) {
|
||||
for (const { regex } 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);
|
||||
}
|
||||
}
|
||||
matchedFileCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +204,7 @@ jobs:
|
||||
}
|
||||
|
||||
const totalReviewers = reviewersList.length + teamsList.length;
|
||||
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
|
||||
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`);
|
||||
|
||||
// Request reviews
|
||||
try {
|
||||
@@ -279,7 +236,7 @@ jobs:
|
||||
|
||||
// Only add a comment if there are new codeowners to mention (not previously pinged)
|
||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -297,7 +254,7 @@ jobs:
|
||||
|
||||
// Only try to add a comment if there are new codeowners to mention
|
||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
|
||||
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
Reference in New Issue
Block a user