[CI] Refactor auto-label workflow: modular architecture, CODEOWNERS automation, and performance improvements (#9860)

This commit is contained in:
Jesse Hills 2025-07-24 23:18:29 +12:00 committed by GitHub
parent 1344103086
commit ba1de5feff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -23,24 +23,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.2.2 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<<EOF" >> $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 - name: Generate a token
id: generate-token id: generate-token
uses: actions/create-github-app-token@v2 uses: actions/create-github-app-token@v2
@ -55,24 +37,15 @@ jobs:
script: | script: |
const fs = require('fs'); const fs = require('fs');
const { owner, repo } = context.repo; // Constants
const pr_number = context.issue.number; const SMALL_PR_THRESHOLD = parseInt('${{ env.SMALL_PR_THRESHOLD }}');
const MAX_LABELS = parseInt('${{ env.MAX_LABELS }}');
// Hidden marker to identify bot comments from this workflow const TOO_BIG_THRESHOLD = parseInt('${{ env.TOO_BIG_THRESHOLD }}');
const BOT_COMMENT_MARKER = '<!-- auto-label-pr-bot -->'; const BOT_COMMENT_MARKER = '<!-- auto-label-pr-bot -->';
const CODEOWNERS_MARKER = '<!-- codeowners-request -->';
const TOO_BIG_MARKER = '<!-- too-big-request -->';
// Get current labels const MANAGED_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-component',
'new-platform', 'new-platform',
'new-target-platform', 'new-target-platform',
@ -86,172 +59,147 @@ jobs:
'has-tests', 'has-tests',
'needs-tests', 'needs-tests',
'needs-docs', 'needs-docs',
'needs-codeowners',
'too-big', 'too-big',
'labeller-recheck' 'labeller-recheck'
].includes(label) ];
);
console.log('Current labels:', currentLabels.join(', ')); const DOCS_PR_PATTERNS = [
console.log('Managed labels:', managedLabels.join(', ')); /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
/esphome\/esphome-docs#\d+/
];
// Get changed files // Global state
const changedFiles = `${{ steps.changes.outputs.files }}`.split('\n').filter(f => f.length > 0); const { owner, repo } = context.repo;
const totalChanges = parseInt('${{ steps.changes.outputs.total_changes }}') || 0; const pr_number = context.issue.number;
console.log('Changed files:', changedFiles.length); // Get current labels and PR data
console.log('Total changes:', totalChanges); const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({
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, owner,
repo, repo,
issue_number: pr_number, issue_number: pr_number
labels: finalLabels
}); });
} const currentLabels = currentLabelsData.map(label => label.name);
const managedLabels = currentLabels.filter(label =>
// Remove old managed labels that are no longer needed label.startsWith('component: ') || MANAGED_LABELS.includes(label)
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({ const { data: prFiles } = await github.rest.pulls.listFiles({
owner, owner,
repo, repo,
pull_number: pr_number pull_number: pr_number
}); });
// Calculate data from PR files
const changedFiles = prFiles.map(file => file.filename);
const totalChanges = prFiles.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0);
console.log('Current labels:', currentLabels.join(', '));
console.log('Changed files:', changedFiles.length);
console.log('Total changes:', totalChanges);
// 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');
}
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); const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename);
// Calculate changes excluding root tests directory for too-big calculation
const testChanges = prFiles
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0);
const nonTestChanges = totalChanges - testChanges;
console.log(`Test changes: ${testChanges}, Non-test changes: ${nonTestChanges}`);
// Strategy: New Component detection
for (const file of addedFiles) { for (const file of addedFiles) {
// Check for new component files: esphome/components/{component}/__init__.py
const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/);
if (componentMatch) { if (componentMatch) {
try { try {
// Read the content directly from the filesystem since we have it checked out
const content = fs.readFileSync(file, 'utf8'); const content = fs.readFileSync(file, 'utf8');
// Strategy: New Target Platform detection
if (content.includes('IS_TARGET_PLATFORM = True')) { if (content.includes('IS_TARGET_PLATFORM = True')) {
labels.add('new-target-platform'); labels.add('new-target-platform');
} }
labels.add('new-component');
} catch (error) { } catch (error) {
console.log(`Failed to read content of ${file}:`, error.message); 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');
} }
labels.add('new-component');
} }
} }
// Strategy: New Platform detection 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) { for (const file of addedFiles) {
// Check for new platform files: esphome/components/{component}/{platform}.py
const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/);
if (platformFileMatch) { if (platformFileMatch) {
const [, component, platform] = platformFileMatch; const [, component, platform] = platformFileMatch;
if (platformComponents.includes(platform)) { if (apiData.platformComponents.includes(platform)) {
labels.add('new-platform'); labels.add('new-platform');
} }
} }
// Check for new platform files: esphome/components/{component}/{platform}/__init__.py
const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/);
if (platformDirMatch) { if (platformDirMatch) {
const [, component, platform] = platformDirMatch; const [, component, platform] = platformDirMatch;
if (platformComponents.includes(platform)) { if (apiData.platformComponents.includes(platform)) {
labels.add('new-platform'); labels.add('new-platform');
} }
} }
} }
return labels;
}
// Strategy: Core files detection
async function detectCoreChanges() {
const labels = new Set();
const coreFiles = changedFiles.filter(file => const coreFiles = changedFiles.filter(file =>
file.startsWith('esphome/core/') || file.startsWith('esphome/core/') ||
(file.startsWith('esphome/') && file.split('/').length === 2) (file.startsWith('esphome/') && file.split('/').length === 2)
@ -261,12 +209,32 @@ jobs:
labels.add('core'); labels.add('core');
} }
// Strategy: Small PR detection return labels;
if (totalChanges <= smallPrThreshold) { }
// Strategy: PR size detection
async function detectPRSize() {
const labels = new Set();
const testChanges = prFiles
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0);
const nonTestChanges = totalChanges - testChanges;
if (totalChanges <= SMALL_PR_THRESHOLD) {
labels.add('small-pr'); labels.add('small-pr');
} }
if (nonTestChanges > TOO_BIG_THRESHOLD) {
labels.add('too-big');
}
return labels;
}
// Strategy: Dashboard changes // Strategy: Dashboard changes
async function detectDashboardChanges() {
const labels = new Set();
const dashboardFiles = changedFiles.filter(file => const dashboardFiles = changedFiles.filter(file =>
file.startsWith('esphome/dashboard/') || file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/') file.startsWith('esphome/components/dashboard_import/')
@ -276,7 +244,12 @@ jobs:
labels.add('dashboard'); labels.add('dashboard');
} }
return labels;
}
// Strategy: GitHub Actions changes // Strategy: GitHub Actions changes
async function detectGitHubActionsChanges() {
const labels = new Set();
const githubActionsFiles = changedFiles.filter(file => const githubActionsFiles = changedFiles.filter(file =>
file.startsWith('.github/workflows/') file.startsWith('.github/workflows/')
); );
@ -285,9 +258,14 @@ jobs:
labels.add('github-actions'); labels.add('github-actions');
} }
// Strategy: Code Owner detection return labels;
}
// Strategy: Code owner detection
async function detectCodeOwner() {
const labels = new Set();
try { try {
// Fetch CODEOWNERS file from the repository (in case it was changed in this PR)
const { data: codeownersFile } = await github.rest.repos.getContent({ const { data: codeownersFile } = await github.rest.repos.getContent({
owner, owner,
repo, repo,
@ -297,14 +275,10 @@ jobs:
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
const prAuthor = context.payload.pull_request.user.login; const prAuthor = context.payload.pull_request.user.login;
// Parse CODEOWNERS file
const codeownersLines = codeownersContent.split('\n') const codeownersLines = codeownersContent.split('\n')
.map(line => line.trim()) .map(line => line.trim())
.filter(line => line && !line.startsWith('#')); .filter(line => line && !line.startsWith('#'));
let isCodeOwner = false;
// Precompile CODEOWNERS patterns into regex objects
const codeownersRegexes = codeownersLines.map(line => { const codeownersRegexes = codeownersLines.map(line => {
const parts = line.split(/\s+/); const parts = line.split(/\s+/);
const pattern = parts[0]; const pattern = parts[0];
@ -312,17 +286,14 @@ jobs:
let regex; let regex;
if (pattern.endsWith('*')) { if (pattern.endsWith('*')) {
// Directory pattern like "esphome/components/api/*"
const dir = pattern.slice(0, -1); const dir = pattern.slice(0, -1);
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
} else if (pattern.includes('*')) { } else if (pattern.includes('*')) {
// Glob pattern
const regexPattern = pattern const regexPattern = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\\*/g, '.*'); .replace(/\\*/g, '.*');
regex = new RegExp(`^${regexPattern}$`); regex = new RegExp(`^${regexPattern}$`);
} else { } else {
// Exact match
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
} }
@ -331,109 +302,147 @@ jobs:
for (const file of changedFiles) { for (const file of changedFiles) {
for (const { regex, owners } of codeownersRegexes) { for (const { regex, owners } of codeownersRegexes) {
if (regex.test(file)) { if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
// 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'); labels.add('by-code-owner');
return labels;
}
}
} }
} catch (error) { } catch (error) {
console.log('Failed to read or parse CODEOWNERS file:', error.message); console.log('Failed to read or parse CODEOWNERS file:', error.message);
} }
return labels;
}
// Strategy: Test detection // Strategy: Test detection
const testFiles = changedFiles.filter(file => async function detectTests() {
file.startsWith('tests/') const labels = new Set();
); const testFiles = changedFiles.filter(file => file.startsWith('tests/'));
if (testFiles.length > 0) { if (testFiles.length > 0) {
labels.add('has-tests'); 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')) { 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('has-tests')) {
labels.add('needs-tests'); labels.add('needs-tests');
} }
}
// Strategy: Documentation check for new components/platforms // Check for missing docs
if (labels.has('new-component') || labels.has('new-platform')) { if (allLabels.has('new-component') || allLabels.has('new-platform')) {
const prBody = context.payload.pull_request.body || ''; const prBody = context.payload.pull_request.body || '';
const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
// 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) { if (!hasDocsLink) {
labels.add('needs-docs'); labels.add('needs-docs');
} }
} }
// Convert Set to Array // Check for missing CODEOWNERS
let finalLabels = Array.from(labels); if (allLabels.has('new-component')) {
const codeownersModified = prFiles.some(file =>
file.filename === 'CODEOWNERS' &&
(file.status === 'modified' || file.status === 'added') &&
(file.additions || 0) > 0
);
console.log('Computed labels:', finalLabels.join(', ')); if (!codeownersModified) {
labels.add('needs-codeowners');
}
}
// Check if PR has mega-pr label return labels;
const isMegaPR = currentLabels.includes('mega-pr'); }
// Check if PR is too big (either too many labels or too many line changes) // Generate review messages
const tooManyLabels = finalLabels.length > maxLabels; function generateReviewMessages(finalLabels) {
const tooManyChanges = nonTestChanges > tooBigThreshold; const messages = [];
const prAuthor = context.payload.pull_request.user.login;
if ((tooManyLabels || tooManyChanges) && !isMegaPR) { // Too big message
const originalLength = finalLabels.length; if (finalLabels.includes('too-big')) {
console.log(`PR is too big - Labels: ${originalLength}, Changes: ${totalChanges} (non-test: ${nonTestChanges})`); const testChanges = prFiles
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0);
const nonTestChanges = totalChanges - testChanges;
const tooManyLabels = finalLabels.length > 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 ${finalLabels.length} different components/areas.`;
} else if (tooManyLabels) {
message += `This PR affects ${finalLabels.length} 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) {
const reviewMessages = generateReviewMessages(finalLabels);
const hasReviewableLabels = finalLabels.some(label =>
['too-big', 'needs-codeowners'].includes(label)
);
// Get all reviews on this PR to check for existing bot reviews
const { data: reviews } = await github.rest.pulls.listReviews({ const { data: reviews } = await github.rest.pulls.listReviews({
owner, owner,
repo, repo,
pull_number: pr_number pull_number: pr_number
}); });
// Check if there's already an active bot review requesting changes const botReviews = reviews.filter(review =>
const existingBotReview = reviews.find(review =>
review.user.type === 'Bot' && review.user.type === 'Bot' &&
review.state === 'CHANGES_REQUESTED' && review.state === 'CHANGES_REQUESTED' &&
review.body && review.body.includes(BOT_COMMENT_MARKER) review.body && review.body.includes(BOT_COMMENT_MARKER)
); );
// If too big due to line changes only, keep original labels and add too-big if (hasReviewableLabels) {
// If too big due to too many labels, replace with just too-big const reviewBody = `${BOT_COMMENT_MARKER}\n\n${reviewMessages.join('\n\n---\n\n')}`;
if (tooManyChanges && !tooManyLabels) {
finalLabels.push('too-big');
} else {
finalLabels = ['too-big'];
}
// Only create a new review if there isn't already an active bot review if (botReviews.length > 0) {
if (!existingBotReview) { // Update existing review
// Create appropriate review message await github.rest.pulls.updateReview({
let reviewBody; owner,
if (tooManyLabels && tooManyChanges) { repo,
reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests) 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.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; pull_number: pr_number,
} else if (tooManyLabels) { review_id: botReviews[0].id,
reviewBody = `${BOT_COMMENT_MARKER}\nThis 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.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; body: reviewBody
});
console.log('Updated existing bot review');
} else { } else {
reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests). Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; // Create new review
}
// Request changes on the PR
await github.rest.pulls.createReview({ await github.rest.pulls.createReview({
owner, owner,
repo, repo,
@ -441,32 +450,10 @@ jobs:
body: reviewBody, body: reviewBody,
event: 'REQUEST_CHANGES' event: 'REQUEST_CHANGES'
}); });
console.log('Created new "too big" review requesting changes'); console.log('Created new bot review');
} else {
console.log('Skipping review creation - existing bot review already requesting changes');
} }
} else { } else if (botReviews.length > 0) {
// Check if PR was previously too big but is now acceptable // Dismiss existing reviews
const wasPreviouslyTooBig = currentLabels.includes('too-big');
if (wasPreviouslyTooBig || isMegaPR) {
console.log('PR is no longer too big or has mega-pr label - dismissing bot reviews');
// Get all reviews on this PR to find reviews to dismiss
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: pr_number
});
// Find bot reviews that requested changes
const botReviews = reviews.filter(review =>
review.user.type === 'Bot' &&
review.state === 'CHANGES_REQUESTED' &&
review.body && review.body.includes(BOT_COMMENT_MARKER)
);
// Dismiss bot reviews
for (const review of botReviews) { for (const review of botReviews) {
try { try {
await github.rest.pulls.dismissReview({ await github.rest.pulls.dismissReview({
@ -474,11 +461,9 @@ jobs:
repo, repo,
pull_number: pr_number, pull_number: pr_number,
review_id: review.id, review_id: review.id,
message: isMegaPR ? message: 'Review dismissed: All requirements have been met'
'Review dismissed: mega-pr label was added' :
'Review dismissed: PR size is now acceptable'
}); });
console.log(`Dismissed review ${review.id}`); console.log(`Dismissed bot review ${review.id}`);
} catch (error) { } catch (error) {
console.log(`Failed to dismiss review ${review.id}:`, error.message); console.log(`Failed to dismiss review ${review.id}:`, error.message);
} }
@ -486,7 +471,106 @@ jobs:
} }
} }
// Add new labels // Main execution
const apiData = await fetchApiData();
const baseRef = context.payload.pull_request.base.ref;
// Early exit for non-dev branches
if (baseRef !== 'dev') {
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
] = await Promise.all([
detectMergeBranch(),
detectComponentPlatforms(apiData),
detectNewComponents(),
detectNewPlatforms(apiData),
detectCoreChanges(),
detectPRSize(),
detectDashboardChanges(),
detectGitHubActionsChanges(),
detectCodeOwner(),
detectTests()
]);
// Combine all labels
const allLabels = new Set([
...branchLabels,
...componentLabels,
...newComponentLabels,
...newPlatformLabels,
...coreLabels,
...sizeLabels,
...dashboardLabels,
...actionsLabels,
...codeOwnerLabels,
...testLabels
]);
// 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);
// Handle too many labels
const isMegaPR = currentLabels.includes('mega-pr');
const tooManyLabels = finalLabels.length > MAX_LABELS;
if (tooManyLabels && !isMegaPR && !finalLabels.includes('too-big')) {
finalLabels = ['too-big'];
}
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews
await handleReviews(finalLabels);
// Apply labels
if (finalLabels.length > 0) { if (finalLabels.length > 0) {
console.log(`Adding labels: ${finalLabels.join(', ')}`); console.log(`Adding labels: ${finalLabels.join(', ')}`);
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
@ -497,11 +581,8 @@ jobs:
}); });
} }
// Remove old managed labels that are no longer needed // Remove old managed labels
const labelsToRemove = managedLabels.filter(label => const labelsToRemove = managedLabels.filter(label => !finalLabels.includes(label));
!finalLabels.includes(label)
);
for (const label of labelsToRemove) { for (const label of labelsToRemove) {
console.log(`Removing label: ${label}`); console.log(`Removing label: ${label}`);
try { try {