[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
@@ -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);
}