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 jobs: label: runs-on: ubuntu-latest if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout uses: actions/checkout@v4.2.2 - name: Get changes id: changes env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # Get PR number pr_number="${{ github.event.pull_request.number }}" # Get list of changed files using gh CLI files=$(gh pr diff $pr_number --name-only) echo "files<> $GITHUB_OUTPUT echo "$files" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT # Get file stats (additions + deletions) using gh CLI stats=$(gh pr view $pr_number --json files --jq '.files | map(.additions + .deletions) | add') echo "total_changes=${stats:-0}" >> $GITHUB_OUTPUT - name: Generate a token id: generate-token uses: actions/create-github-app-token@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@v7.0.1 with: github-token: ${{ steps.generate-token.outputs.token }} script: | const fs = require('fs'); const { owner, repo } = context.repo; const pr_number = context.issue.number; // Get current labels const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: pr_number }); const currentLabels = currentLabelsData.map(label => label.name); // Define managed labels that this workflow controls const managedLabels = currentLabels.filter(label => label.startsWith('component: ') || [ 'new-component', 'new-platform', 'new-target-platform', 'merging-to-release', 'merging-to-beta', 'core', 'small-pr', 'dashboard', 'github-actions', 'by-code-owner', 'has-tests', 'needs-tests', 'needs-docs', 'too-big', 'labeller-recheck' ].includes(label) ); console.log('Current labels:', currentLabels.join(', ')); console.log('Managed labels:', managedLabels.join(', ')); // Get changed files const changedFiles = `${{ steps.changes.outputs.files }}`.split('\n').filter(f => f.length > 0); const totalChanges = parseInt('${{ steps.changes.outputs.total_changes }}') || 0; console.log('Changed files:', changedFiles.length); console.log('Total changes:', totalChanges); const labels = new Set(); // Fetch TARGET_PLATFORMS and PLATFORM_COMPONENTS from API let targetPlatforms = []; let platformComponents = []; try { const response = await fetch('https://data.esphome.io/components.json'); const componentsData = await response.json(); // Extract target platforms and platform components directly from API targetPlatforms = componentsData.target_platforms || []; platformComponents = componentsData.platform_components || []; console.log('Target platforms from API:', targetPlatforms.length, targetPlatforms); console.log('Platform components from API:', platformComponents.length, platformComponents); } catch (error) { console.log('Failed to fetch components data from API:', error.message); } // Get environment variables const smallPrThreshold = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); const maxLabels = parseInt('${{ env.MAX_LABELS }}'); const tooBigThreshold = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); // Strategy: Merge to release or beta branch const baseRef = context.payload.pull_request.base.ref; if (baseRef !== 'dev') { if (baseRef === 'release') { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); } // When targeting non-dev branches, only use merge warning labels const finalLabels = Array.from(labels); console.log('Computed labels (merge branch only):', finalLabels.join(', ')); // Add new 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 that are no longer needed 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); } } return; // Exit early, don't process other strategies } // Strategy: Component and Platform labeling const componentRegex = /^esphome\/components\/([^\/]+)\//; const targetPlatformRegex = new RegExp(`^esphome\/components\/(${targetPlatforms.join('|')})/`); for (const file of changedFiles) { // Check for component changes const componentMatch = file.match(componentRegex); if (componentMatch) { const component = componentMatch[1]; labels.add(`component: ${component}`); } // Check for target platform changes const platformMatch = file.match(targetPlatformRegex); if (platformMatch) { const targetPlatform = platformMatch[1]; labels.add(`platform: ${targetPlatform}`); } } // Get PR files for new component/platform detection const { data: prFiles } = await github.rest.pulls.listFiles({ owner, repo, pull_number: pr_number }); const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); // Strategy: New Component detection for (const file of addedFiles) { // Check for new component files: esphome/components/{component}/__init__.py const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); if (componentMatch) { try { // Read the content directly from the filesystem since we have it checked out const content = fs.readFileSync(file, 'utf8'); // Strategy: New Target Platform detection if (content.includes('IS_TARGET_PLATFORM = True')) { labels.add('new-target-platform'); } labels.add('new-component'); } catch (error) { console.log(`Failed to read content of ${file}:`, error.message); // Fallback: assume it's a new component if we can't read the content labels.add('new-component'); } } } // Strategy: New Platform detection for (const file of addedFiles) { // Check for new platform files: esphome/components/{component}/{platform}.py const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); if (platformFileMatch) { const [, component, platform] = platformFileMatch; if (platformComponents.includes(platform)) { labels.add('new-platform'); } } // Check for new platform files: esphome/components/{component}/{platform}/__init__.py const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); if (platformDirMatch) { const [, component, platform] = platformDirMatch; if (platformComponents.includes(platform)) { labels.add('new-platform'); } } } const coreFiles = changedFiles.filter(file => file.startsWith('esphome/core/') || (file.startsWith('esphome/') && file.split('/').length === 2) ); if (coreFiles.length > 0) { labels.add('core'); } // Strategy: Small PR detection if (totalChanges <= smallPrThreshold) { labels.add('small-pr'); } // Strategy: Dashboard changes const dashboardFiles = changedFiles.filter(file => file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); if (dashboardFiles.length > 0) { labels.add('dashboard'); } // Strategy: GitHub Actions changes const githubActionsFiles = changedFiles.filter(file => file.startsWith('.github/workflows/') ); if (githubActionsFiles.length > 0) { labels.add('github-actions'); } // Strategy: Code Owner detection try { // Fetch CODEOWNERS file from the repository (in case it was changed in this PR) 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; // Parse CODEOWNERS file const codeownersLines = codeownersContent.split('\n') .map(line => line.trim()) .filter(line => line && !line.startsWith('#')); let isCodeOwner = false; // Precompile CODEOWNERS patterns into regex objects const codeownersRegexes = codeownersLines.map(line => { const parts = line.split(/\s+/); const pattern = parts[0]; const owners = parts.slice(1); let regex; if (pattern.endsWith('*')) { // Directory pattern like "esphome/components/api/*" const dir = pattern.slice(0, -1); regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); } else if (pattern.includes('*')) { // Glob pattern const regexPattern = pattern .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(/\\*/g, '.*'); regex = new RegExp(`^${regexPattern}$`); } else { // Exact match regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); } return { regex, owners }; }); for (const file of changedFiles) { for (const { regex, owners } of codeownersRegexes) { if (regex.test(file)) { // Check if PR author is in the owners list if (owners.some(owner => owner === `@${prAuthor}`)) { isCodeOwner = true; break; } } } if (isCodeOwner) break; } if (isCodeOwner) { labels.add('by-code-owner'); } } catch (error) { console.log('Failed to read or parse CODEOWNERS file:', error.message); } // Strategy: Test detection const testFiles = changedFiles.filter(file => file.startsWith('tests/') ); if (testFiles.length > 0) { labels.add('has-tests'); } else { // Only check for needs-tests if this is a new component or new platform if (labels.has('new-component') || labels.has('new-platform')) { labels.add('needs-tests'); } } // Strategy: Documentation check for new components/platforms if (labels.has('new-component') || labels.has('new-platform')) { const prBody = context.payload.pull_request.body || ''; // Look for documentation PR links // Patterns to match: // - https://github.com/esphome/esphome-docs/pull/1234 // - esphome/esphome-docs#1234 const docsPrPatterns = [ /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ ]; const hasDocsLink = docsPrPatterns.some(pattern => pattern.test(prBody)); if (!hasDocsLink) { labels.add('needs-docs'); } } // Convert Set to Array let finalLabels = Array.from(labels); console.log('Computed labels:', finalLabels.join(', ')); // Check if PR is allowed to be too big const allowedTooBig = currentLabels.includes('mega-pr'); // Check if PR is too big (either too many labels or too many line changes) const tooManyLabels = finalLabels.length > maxLabels; const tooManyChanges = totalChanges > tooBigThreshold; if ((tooManyLabels || tooManyChanges) && !allowedTooBig) { const originalLength = finalLabels.length; console.log(`PR is too big - Labels: ${originalLength}, Changes: ${totalChanges}`); // If too big due to line changes only, keep original labels and add too-big // If too big due to too many labels, replace with just too-big if (tooManyChanges && !tooManyLabels) { finalLabels.push('too-big'); } else { finalLabels = ['too-big']; } // Create appropriate review message let reviewBody; if (tooManyLabels && tooManyChanges) { reviewBody = `This PR is too large with ${totalChanges} line changes and affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.`; } else if (tooManyLabels) { reviewBody = `This PR affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.`; } else { reviewBody = `This PR is too large with ${totalChanges} line changes. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.`; } // Request changes on the PR await github.rest.pulls.createReview({ owner, repo, pull_number: pr_number, body: reviewBody, event: 'REQUEST_CHANGES' }); } // Add new 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 that are no longer needed 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); } }