mirror of
https://github.com/esphome/esphome.git
synced 2025-09-02 03:12:20 +01:00
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
653 lines
25 KiB
YAML
653 lines
25 KiB
YAML
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@v5.0.0
|
|
|
|
- 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');
|
|
|
|
// 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 = '<!-- auto-label-pr-bot -->';
|
|
const CODEOWNERS_MARKER = '<!-- codeowners-request -->';
|
|
const TOO_BIG_MARKER = '<!-- too-big-request -->';
|
|
|
|
const MANAGED_LABELS = [
|
|
'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',
|
|
'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 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);
|
|
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');
|
|
}
|
|
|
|
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();
|
|
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');
|
|
}
|
|
|
|
// 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('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) {
|
|
const messages = [];
|
|
const prAuthor = context.payload.pull_request.user.login;
|
|
|
|
// Too big message
|
|
if (finalLabels.includes('too-big')) {
|
|
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)
|
|
);
|
|
|
|
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 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,
|
|
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;
|
|
|
|
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) {
|
|
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);
|
|
}
|
|
}
|