# 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 base branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.base.sha }} - 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.user.type === 'Bot' || 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.error(error); core.setFailed(`Failed to process codeowner approval label: ${error.message}`); }