name: Auto Label PR on: # Runs only on pull_request_target due to having access to a App token. # This means PRs from forks will not be able to alter this workflow to get the tokens pull_request_target: types: [labeled, opened, reopened, synchronize, edited] permissions: pull-requests: write contents: read env: SMALL_PR_THRESHOLD: 30 MAX_LABELS: 15 TOO_BIG_THRESHOLD: 1000 COMPONENT_LABEL_THRESHOLD: 10 jobs: label: runs-on: ubuntu-latest if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Generate a token id: generate-token uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | const fs = require('fs'); // Constants const SMALL_PR_THRESHOLD = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); const MAX_LABELS = parseInt('${{ env.MAX_LABELS }}'); const TOO_BIG_THRESHOLD = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); const COMPONENT_LABEL_THRESHOLD = parseInt('${{ env.COMPONENT_LABEL_THRESHOLD }}'); const BOT_COMMENT_MARKER = ''; const CODEOWNERS_MARKER = ''; const TOO_BIG_MARKER = ''; const MANAGED_LABELS = [ 'new-component', 'new-platform', 'new-target-platform', 'merging-to-release', 'merging-to-beta', 'chained-pr', 'core', 'small-pr', 'dashboard', 'github-actions', 'by-code-owner', 'has-tests', 'needs-tests', 'needs-docs', 'needs-codeowners', 'too-big', 'labeller-recheck', 'bugfix', 'new-feature', 'breaking-change', 'code-quality' ]; const DOCS_PR_PATTERNS = [ /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ ]; // Global state const { owner, repo } = context.repo; const pr_number = context.issue.number; // Get current labels and PR data const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: pr_number }); const currentLabels = currentLabelsData.map(label => label.name); const managedLabels = currentLabels.filter(label => label.startsWith('component: ') || MANAGED_LABELS.includes(label) ); // Check for mega-PR early - if present, skip most automatic labeling const isMegaPR = currentLabels.includes('mega-pr'); // Get all PR files with automatic pagination const prFiles = await github.paginate( github.rest.pulls.listFiles, { owner, repo, pull_number: pr_number } ); // Calculate data from PR files const changedFiles = prFiles.map(file => file.filename); const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); const totalChanges = totalAdditions + totalDeletions; console.log('Current labels:', currentLabels.join(', ')); console.log('Changed files:', changedFiles.length); console.log('Total changes:', totalChanges); if (isMegaPR) { console.log('Mega-PR detected - applying limited labeling logic'); } // Fetch API data async function fetchApiData() { try { const response = await fetch('https://data.esphome.io/components.json'); const componentsData = await response.json(); return { targetPlatforms: componentsData.target_platforms || [], platformComponents: componentsData.platform_components || [] }; } catch (error) { console.log('Failed to fetch components data from API:', error.message); return { targetPlatforms: [], platformComponents: [] }; } } // Strategy: Merge branch detection async function detectMergeBranch() { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; if (baseRef === 'release') { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); } else if (baseRef !== 'dev') { labels.add('chained-pr'); } return labels; } // Strategy: Component and platform labeling async function detectComponentPlatforms(apiData) { const labels = new Set(); const componentRegex = /^esphome\/components\/([^\/]+)\//; const targetPlatformRegex = new RegExp(`^esphome\/components\/(${apiData.targetPlatforms.join('|')})/`); for (const file of changedFiles) { const componentMatch = file.match(componentRegex); if (componentMatch) { labels.add(`component: ${componentMatch[1]}`); } const platformMatch = file.match(targetPlatformRegex); if (platformMatch) { labels.add(`platform: ${platformMatch[1]}`); } } return labels; } // Strategy: New component detection async function detectNewComponents() { const labels = new Set(); const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); for (const file of addedFiles) { const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); if (componentMatch) { try { const content = fs.readFileSync(file, 'utf8'); if (content.includes('IS_TARGET_PLATFORM = True')) { labels.add('new-target-platform'); } } catch (error) { console.log(`Failed to read content of ${file}:`, error.message); } labels.add('new-component'); } } return labels; } // Strategy: New platform detection async function detectNewPlatforms(apiData) { const labels = new Set(); const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); for (const file of addedFiles) { const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); if (platformFileMatch) { const [, component, platform] = platformFileMatch; if (apiData.platformComponents.includes(platform)) { labels.add('new-platform'); } } const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); if (platformDirMatch) { const [, component, platform] = platformDirMatch; if (apiData.platformComponents.includes(platform)) { labels.add('new-platform'); } } } return labels; } // Strategy: Core files detection async function detectCoreChanges() { const labels = new Set(); const coreFiles = changedFiles.filter(file => file.startsWith('esphome/core/') || (file.startsWith('esphome/') && file.split('/').length === 2) ); if (coreFiles.length > 0) { labels.add('core'); } return labels; } // Strategy: PR size detection async function detectPRSize() { const labels = new Set(); if (totalChanges <= SMALL_PR_THRESHOLD) { labels.add('small-pr'); return labels; } const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); const testDeletions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { labels.add('too-big'); } return labels; } // Strategy: Dashboard changes async function detectDashboardChanges() { const labels = new Set(); const dashboardFiles = changedFiles.filter(file => file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); if (dashboardFiles.length > 0) { labels.add('dashboard'); } return labels; } // Strategy: GitHub Actions changes async function detectGitHubActionsChanges() { const labels = new Set(); const githubActionsFiles = changedFiles.filter(file => file.startsWith('.github/workflows/') ); if (githubActionsFiles.length > 0) { labels.add('github-actions'); } return labels; } // Strategy: Code owner detection async function detectCodeOwner() { const labels = new Set(); try { const { data: codeownersFile } = await github.rest.repos.getContent({ owner, repo, path: 'CODEOWNERS', }); const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); 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 }; }); 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; } } } } catch (error) { console.log('Failed to read or parse CODEOWNERS file:', error.message); } return labels; } // Strategy: Test detection async function detectTests() { const labels = new Set(); const testFiles = changedFiles.filter(file => file.startsWith('tests/')); if (testFiles.length > 0) { labels.add('has-tests'); } return labels; } // Strategy: PR Template Checkbox detection async function detectPRTemplateCheckboxes() { const labels = new Set(); const prBody = context.payload.pull_request.body || ''; console.log('Checking PR template checkboxes...'); // Check for checked checkboxes in the "Types of changes" section const checkboxPatterns = [ { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } ]; for (const { pattern, label } of checkboxPatterns) { if (pattern.test(prBody)) { console.log(`Found checked checkbox for: ${label}`); labels.add(label); } } return labels; } // Strategy: Requirements detection async function detectRequirements(allLabels) { const labels = new Set(); // Check for missing tests if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { labels.add('needs-tests'); } // Check for missing docs if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) { const prBody = context.payload.pull_request.body || ''; const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); if (!hasDocsLink) { labels.add('needs-docs'); } } // Check for missing CODEOWNERS if (allLabels.has('new-component')) { const codeownersModified = prFiles.some(file => file.filename === 'CODEOWNERS' && (file.status === 'modified' || file.status === 'added') && (file.additions || 0) > 0 ); if (!codeownersModified) { labels.add('needs-codeowners'); } } return labels; } // Generate review messages function generateReviewMessages(finalLabels, originalLabelCount) { const messages = []; const prAuthor = context.payload.pull_request.user.login; // Too big message if (finalLabels.includes('too-big')) { const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); const testDeletions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); const tooManyLabels = originalLabelCount > MAX_LABELS; const tooManyChanges = nonTestChanges > TOO_BIG_THRESHOLD; let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; if (tooManyLabels && tooManyChanges) { message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`; } else if (tooManyLabels) { message += `This PR affects ${originalLabelCount} different components/areas.`; } else { message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`; } message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`; message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`; messages.push(message); } // CODEOWNERS message if (finalLabels.includes('needs-codeowners')) { const message = `${CODEOWNERS_MARKER}\n### 👥 Code Ownership\n\n` + `Hey there @${prAuthor},\n` + `Thanks for submitting this pull request! Can you add yourself as a codeowner for this integration? ` + `This way we can notify you if a bug report for this integration is reported.\n\n` + `In \`__init__.py\` of the integration, please add:\n\n` + `\`\`\`python\nCODEOWNERS = ["@${prAuthor}"]\n\`\`\`\n\n` + `And run \`script/build_codeowners.py\``; messages.push(message); } return messages; } // Handle reviews async function handleReviews(finalLabels, originalLabelCount) { const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount); const hasReviewableLabels = finalLabels.some(label => ['too-big', 'needs-codeowners'].includes(label) ); const { data: reviews } = await github.rest.pulls.listReviews({ owner, repo, pull_number: pr_number }); const botReviews = reviews.filter(review => review.user.type === 'Bot' && review.state === 'CHANGES_REQUESTED' && review.body && review.body.includes(BOT_COMMENT_MARKER) ); if (hasReviewableLabels) { const reviewBody = `${BOT_COMMENT_MARKER}\n\n${reviewMessages.join('\n\n---\n\n')}`; if (botReviews.length > 0) { // Update existing review await github.rest.pulls.updateReview({ owner, repo, pull_number: pr_number, review_id: botReviews[0].id, body: reviewBody }); console.log('Updated existing bot review'); } else { // Create new review await github.rest.pulls.createReview({ owner, repo, pull_number: pr_number, body: reviewBody, event: 'REQUEST_CHANGES' }); console.log('Created new bot review'); } } else if (botReviews.length > 0) { // Dismiss existing reviews for (const review of botReviews) { try { await github.rest.pulls.dismissReview({ owner, repo, pull_number: pr_number, review_id: review.id, message: 'Review dismissed: All requirements have been met' }); console.log(`Dismissed bot review ${review.id}`); } catch (error) { console.log(`Failed to dismiss review ${review.id}:`, error.message); } } } } // Main execution const apiData = await fetchApiData(); const baseRef = context.payload.pull_request.base.ref; // Early exit for release and beta branches only if (baseRef === 'release' || baseRef === 'beta') { const branchLabels = await detectMergeBranch(); const finalLabels = Array.from(branchLabels); console.log('Computed labels (merge branch only):', finalLabels.join(', ')); // Apply labels if (finalLabels.length > 0) { await github.rest.issues.addLabels({ owner, repo, issue_number: pr_number, labels: finalLabels }); } // Remove old managed labels const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); for (const label of labelsToRemove) { try { await github.rest.issues.removeLabel({ owner, repo, issue_number: pr_number, name: label }); } catch (error) { console.log(`Failed to remove label ${label}:`, error.message); } } return; } // Run all strategies const [ branchLabels, componentLabels, newComponentLabels, newPlatformLabels, coreLabels, sizeLabels, dashboardLabels, actionsLabels, codeOwnerLabels, testLabels, checkboxLabels ] = await Promise.all([ detectMergeBranch(), detectComponentPlatforms(apiData), detectNewComponents(), detectNewPlatforms(apiData), detectCoreChanges(), detectPRSize(), detectDashboardChanges(), detectGitHubActionsChanges(), detectCodeOwner(), detectTests(), detectPRTemplateCheckboxes() ]); // Combine all labels const allLabels = new Set([ ...branchLabels, ...componentLabels, ...newComponentLabels, ...newPlatformLabels, ...coreLabels, ...sizeLabels, ...dashboardLabels, ...actionsLabels, ...codeOwnerLabels, ...testLabels, ...checkboxLabels ]); // Detect requirements based on all other labels const requirementLabels = await detectRequirements(allLabels); for (const label of requirementLabels) { allLabels.add(label); } let finalLabels = Array.from(allLabels); // For mega-PRs, exclude component labels if there are too many if (isMegaPR) { const componentLabels = finalLabels.filter(label => label.startsWith('component: ')); if (componentLabels.length > COMPONENT_LABEL_THRESHOLD) { finalLabels = finalLabels.filter(label => !label.startsWith('component: ')); console.log(`Mega-PR detected - excluding ${componentLabels.length} component labels (threshold: ${COMPONENT_LABEL_THRESHOLD})`); } } // Handle too many labels (only for non-mega PRs) const tooManyLabels = finalLabels.length > MAX_LABELS; const originalLabelCount = finalLabels.length; if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) { finalLabels = ['too-big']; } console.log('Computed labels:', finalLabels.join(', ')); // Handle reviews await handleReviews(finalLabels, originalLabelCount); // Apply labels if (finalLabels.length > 0) { console.log(`Adding labels: ${finalLabels.join(', ')}`); await github.rest.issues.addLabels({ owner, repo, issue_number: pr_number, labels: finalLabels }); } // Remove old managed labels const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label)); for (const label of labelsToRemove) { console.log(`Removing label: ${label}`); try { await github.rest.issues.removeLabel({ owner, repo, issue_number: pr_number, name: label }); } catch (error) { console.log(`Failed to remove label ${label}:`, error.message); } }