From 0122e16e2ccf4cd190f88e25b1da180a0f63b3cc Mon Sep 17 00:00:00 2001 From: Ian Bassi Date: Thu, 16 Apr 2026 01:43:52 -0300 Subject: [PATCH] PR LabelBot: allow users to tag their PRs (#13182) * Add PR label bot workflow Co-Authored-By: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> * LabelBot add to bot add-label --------- Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com> --- .github/workflows/pr-label-bot.yml | 267 +++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 .github/workflows/pr-label-bot.yml diff --git a/.github/workflows/pr-label-bot.yml b/.github/workflows/pr-label-bot.yml new file mode 100644 index 0000000000..7c16c7517c --- /dev/null +++ b/.github/workflows/pr-label-bot.yml @@ -0,0 +1,267 @@ +name: PR Label Bot + +on: + pull_request_target: + types: + - opened + - reopened + issue_comment: + types: + - created + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + request-label: + if: github.event_name == 'pull_request_target' + permissions: + contents: read + pull-requests: write + issues: write + runs-on: ubuntu-latest + steps: + - name: Ask PR author for label + uses: actions/github-script@v7 + with: + script: | + function isPermissionDenied(error) { + return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || ''); + } + + const allowedLabels = [ + 'bug-fix', + 'enhancement', + 'Localization', + 'profile', + 'QoL', + 'UI/UX', + 'dependencies' + ]; + const pr = context.payload.pull_request; + const labelsList = `${allowedLabels + .slice(0, -1) + .map((label) => `\`${label}\``) + .join(', ')} and \`${allowedLabels[allowedLabels.length - 1]}\`.`; + const examplesText = [ + '```', + '/bot add-label bug-fix', + '```', + '```', + '/bot add-label bug-fix, UI/UX', + '```', + '```', + '/bot remove-label bug-fix, UI/UX', + '```' + ].join('\n'); + + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: + `Hi @${pr.user.login}, you can manage the labels for this PR by \`/bot add-label\` and \`/bot remove-label\`\n\n` + + `Allowed labels are:\n${labelsList}\n\n` + + `Examples:\n${examplesText}` + }); + } catch (error) { + if (isPermissionDenied(error)) { + core.warning( + 'Skipping PR comment because token cannot write. Enable Actions write permissions, ' + + 'or run with a token that has issues:write and pull_requests:write.' + ); + return; + } + + throw error; + } + + apply-label: + if: github.event_name == 'issue_comment' + permissions: + contents: read + pull-requests: write + issues: write + runs-on: ubuntu-latest + steps: + - name: Apply label command from PR author + uses: actions/github-script@v7 + with: + script: | + function isPermissionDenied(error) { + return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || ''); + } + + const allowedLabels = [ + 'bug-fix', + 'enhancement', + 'Localization', + 'profile', + 'QoL', + 'UI/UX', + 'dependencies' + ]; + + const issue = context.payload.issue; + if (!issue.pull_request) { + core.info('Ignoring comment that is not on a pull request.'); + return; + } + + const body = (context.payload.comment.body || '').trim(); + const commandLine = body + .split('\n') + .map((line) => line.trim()) + .find((line) => /^\/bot\s+(add-label|remove-label)(?:\s*:\s*|\s+)/i.test(line)); + + if (!commandLine) { + core.info('No /bot add-label or /bot remove-label command found.'); + return; + } + + const commandMatch = commandLine.match(/^\/bot\s+(add-label|remove-label)(?:\s*:\s*|\s+)(.+)\s*$/i); + if (!commandMatch) { + core.info('Label command format is invalid.'); + return; + } + + const action = commandMatch[1].toLowerCase() === 'add-label' ? 'add' : 'remove'; + let labelsExpr = (commandMatch[2] || '').trim(); + if (!labelsExpr) { + core.info('Label command is missing label name.'); + return; + } + + if (labelsExpr.startsWith('[') && labelsExpr.endsWith(']')) { + labelsExpr = labelsExpr.slice(1, -1).trim(); + } + + const requestedRawLabels = labelsExpr + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (!requestedRawLabels.length) { + core.info('No labels were provided in the command.'); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number + }); + + const commenter = context.payload.comment.user.login; + if (commenter !== pr.user.login) { + core.info('Ignoring command because commenter is not the PR author.'); + return; + } + + const labelsByLower = new Map( + allowedLabels.map((label) => [label.toLowerCase(), label]) + ); + + const resolvedLabels = []; + const invalidLabels = []; + + for (const rawLabel of requestedRawLabels) { + const resolved = labelsByLower.get(rawLabel.toLowerCase()); + if (resolved) { + resolvedLabels.push(resolved); + } else { + invalidLabels.push(rawLabel); + } + } + + if (invalidLabels.length) { + const allowedText = allowedLabels.map((label) => `\`${label}\``).join(', '); + const invalidText = invalidLabels.map((label) => `\`${label}\``).join(', '); + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: + `@${commenter} invalid label(s): ${invalidText}.\n\n` + + `Allowed labels: ${allowedText}\n\n` + + `Use:\n` + + `- \`/bot add-label label\`\n` + + `- \`/bot remove-label label\`\n` + + `- \`/bot add-label label1, label2\`` + }); + } catch (error) { + if (isPermissionDenied(error)) { + core.warning('Cannot post invalid-label feedback because token cannot write comments.'); + return; + } + + throw error; + } + return; + } + + const uniqueRequestedLabels = [...new Set(resolvedLabels)]; + + if (action === 'add') { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: uniqueRequestedLabels + }); + } catch (error) { + if (isPermissionDenied(error)) { + core.warning( + 'Cannot add labels because token cannot write. Enable Actions write permissions, ' + + 'or run with a token that has issues:write and pull_requests:write.' + ); + return; + } + + throw error; + } + + core.info(`Added labels: ${uniqueRequestedLabels.join(', ')}`); + return; + } + + let removedCount = 0; + try { + for (const label of uniqueRequestedLabels) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + removedCount += 1; + } catch (error) { + if (isPermissionDenied(error)) { + core.warning( + 'Cannot remove labels because token cannot write. Enable Actions write permissions, ' + + 'or run with a token that has issues:write and pull_requests:write.' + ); + return; + } + + if (error.status === 404) { + core.info(`Label is not currently applied: ${label}`); + continue; + } + + throw error; + } + } + + core.info(`Removed labels count: ${removedCount}`); + } catch (error) { + throw error; + } + + core.info(`Processed label command: ${action} ${uniqueRequestedLabels.join(', ')}`);