[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:
J. Nick Koston
2026-03-02 15:45:08 -10:00
parent c77241940b
commit ed63f51fcb
4 changed files with 317 additions and 103 deletions
+11 -37
View File
@@ -7,6 +7,7 @@ const {
hasDashboardChanges,
hasGitHubActionsChanges,
} = require('../detect-tags');
const { fetchCodeowners, getEffectiveOwners } = require('../codeowners');
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
@@ -151,48 +152,21 @@ async function detectCodeOwner(github, context, changedFiles) {
const { owner, repo } = context.repo;
try {
const { data: codeownersFile } = await github.rest.repos.getContent({
owner,
repo,
path: 'CODEOWNERS',
});
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
const codeownersPatterns = await fetchCodeowners(github, owner, repo);
const prAuthor = context.payload.pull_request.user.login;
const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
const codeownersRegexes = codeownersLines.map(line => {
const parts = line.split(/\s+/);
const pattern = parts[0];
const owners = parts.slice(1);
let regex;
if (pattern.endsWith('*')) {
const dir = pattern.slice(0, -1);
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
} else if (pattern.includes('*')) {
// First escape all regex special chars except *, then replace * with .*
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
regex = new RegExp(`^${regexPattern}$`);
} else {
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
}
return { regex, owners };
});
// Check if PR author is a codeowner of any changed file (last-match-wins)
for (const file of changedFiles) {
for (const { regex, owners } of codeownersRegexes) {
if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
labels.add('by-code-owner');
return labels;
let effectiveOwners = null;
for (const { regex, owners } of codeownersPatterns) {
if (regex.test(file)) {
effectiveOwners = owners;
}
}
if (effectiveOwners && effectiveOwners.some(o => o === `@${prAuthor}`)) {
labels.add('by-code-owner');
return labels;
}
}
} catch (error) {
console.log('Failed to read or parse CODEOWNERS file:', error.message);