From 6d8294c2d32234f4fd300af2f963347dee25952a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 29 Jan 2026 07:42:55 +1100 Subject: [PATCH] [workflows] Refactor auto-label-pr script into modular JS (#13582) --- .github/scripts/auto-label-pr/constants.js | 36 ++ .github/scripts/auto-label-pr/detectors.js | 302 ++++++++++ .github/scripts/auto-label-pr/index.js | 179 ++++++ .github/scripts/auto-label-pr/labels.js | 41 ++ .github/scripts/auto-label-pr/reviews.js | 124 ++++ .github/workflows/auto-label-pr.yml | 632 +-------------------- 6 files changed, 684 insertions(+), 630 deletions(-) create mode 100644 .github/scripts/auto-label-pr/constants.js create mode 100644 .github/scripts/auto-label-pr/detectors.js create mode 100644 .github/scripts/auto-label-pr/index.js create mode 100644 .github/scripts/auto-label-pr/labels.js create mode 100644 .github/scripts/auto-label-pr/reviews.js diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js new file mode 100644 index 0000000000..d5e42fa1b9 --- /dev/null +++ b/.github/scripts/auto-label-pr/constants.js @@ -0,0 +1,36 @@ +// Constants and markers for PR auto-labeling +module.exports = { + BOT_COMMENT_MARKER: '', + CODEOWNERS_MARKER: '', + TOO_BIG_MARKER: '', + + 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', + 'developer-breaking-change', + 'code-quality', + ], + + DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, + /esphome\/esphome-docs#\d+/ + ] +}; diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js new file mode 100644 index 0000000000..79025988dd --- /dev/null +++ b/.github/scripts/auto-label-pr/detectors.js @@ -0,0 +1,302 @@ +const fs = require('fs'); +const { DOCS_PR_PATTERNS } = require('./constants'); + +// Strategy: Merge branch detection +async function detectMergeBranch(context) { + 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(changedFiles, 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(prFiles) { + 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(prFiles, 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(changedFiles) { + 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(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD) { + 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(changedFiles) { + 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(changedFiles) { + 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(github, context, changedFiles) { + const labels = new Set(); + 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 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(changedFiles) { + 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(context) { + 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\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-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, prFiles, context) { + 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; +} + +module.exports = { + detectMergeBranch, + detectComponentPlatforms, + detectNewComponents, + detectNewPlatforms, + detectCoreChanges, + detectPRSize, + detectDashboardChanges, + detectGitHubActionsChanges, + detectCodeOwner, + detectTests, + detectPRTemplateCheckboxes, + detectRequirements +}; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js new file mode 100644 index 0000000000..95ecfc4e33 --- /dev/null +++ b/.github/scripts/auto-label-pr/index.js @@ -0,0 +1,179 @@ +const { MANAGED_LABELS } = require('./constants'); +const { + detectMergeBranch, + detectComponentPlatforms, + detectNewComponents, + detectNewPlatforms, + detectCoreChanges, + detectPRSize, + detectDashboardChanges, + detectGitHubActionsChanges, + detectCodeOwner, + detectTests, + detectPRTemplateCheckboxes, + detectRequirements +} = require('./detectors'); +const { handleReviews } = require('./reviews'); +const { applyLabels, removeOldLabels } = require('./labels'); + +// 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: [] }; + } +} + +module.exports = async ({ github, context }) => { + // Environment variables + const SMALL_PR_THRESHOLD = parseInt(process.env.SMALL_PR_THRESHOLD); + const MAX_LABELS = parseInt(process.env.MAX_LABELS); + const TOO_BIG_THRESHOLD = parseInt(process.env.TOO_BIG_THRESHOLD); + const COMPONENT_LABEL_THRESHOLD = parseInt(process.env.COMPONENT_LABEL_THRESHOLD); + + // 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 + 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(context); + const finalLabels = Array.from(branchLabels); + + console.log('Computed labels (merge branch only):', finalLabels.join(', ')); + + // Apply labels + await applyLabels(github, context, finalLabels); + + // Remove old managed labels + await removeOldLabels(github, context, managedLabels, finalLabels); + + return; + } + + // Run all strategies + const [ + branchLabels, + componentLabels, + newComponentLabels, + newPlatformLabels, + coreLabels, + sizeLabels, + dashboardLabels, + actionsLabels, + codeOwnerLabels, + testLabels, + checkboxLabels, + ] = await Promise.all([ + detectMergeBranch(context), + detectComponentPlatforms(changedFiles, apiData), + detectNewComponents(prFiles), + detectNewPlatforms(prFiles, apiData), + detectCoreChanges(changedFiles), + detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectDashboardChanges(changedFiles), + detectGitHubActionsChanges(changedFiles), + detectCodeOwner(github, context, changedFiles), + detectTests(changedFiles), + detectPRTemplateCheckboxes(context), + ]); + + // 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, prFiles, context); + 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(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + + // Apply labels + await applyLabels(github, context, finalLabels); + + // Remove old managed labels + await removeOldLabels(github, context, managedLabels, finalLabels); +}; diff --git a/.github/scripts/auto-label-pr/labels.js b/.github/scripts/auto-label-pr/labels.js new file mode 100644 index 0000000000..2268f7ded9 --- /dev/null +++ b/.github/scripts/auto-label-pr/labels.js @@ -0,0 +1,41 @@ +// Apply labels to PR +async function applyLabels(github, context, finalLabels) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + + 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 +async function removeOldLabels(github, context, managedLabels, finalLabels) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + + 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); + } + } +} + +module.exports = { + applyLabels, + removeOldLabels +}; diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js new file mode 100644 index 0000000000..a84e9ae5aa --- /dev/null +++ b/.github/scripts/auto-label-pr/reviews.js @@ -0,0 +1,124 @@ +const { + BOT_COMMENT_MARKER, + CODEOWNERS_MARKER, + TOO_BIG_MARKER, +} = require('./constants'); + +// Generate review messages +function generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD) { + const messages = []; + + // 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(github, context, finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD) { + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + const prAuthor = context.payload.pull_request.user.login; + + const reviewMessages = generateReviewMessages(finalLabels, originalLabelCount, prFiles, totalAdditions, totalDeletions, prAuthor, MAX_LABELS, TOO_BIG_THRESHOLD); + 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); + } + } + } +} + +module.exports = { + handleReviews +}; diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index d32d8e01c2..6fcb50b70a 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -36,633 +36,5 @@ jobs: 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', - 'developer-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\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-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); - } - } + const script = require('./.github/scripts/auto-label-pr/index.js'); + await script({ github, context });