From e7631cf80c07919f12d7b6f15a2a8bd23965cad1 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 30 Oct 2025 00:31:25 +0100 Subject: [PATCH] =?UTF-8?q?ci(workflows):=20add=20PR=20comment=E2=80=93dri?= =?UTF-8?q?ven=20label=20management=20with=20authorized=20users=20(#4650)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **What was changed** - Added a new `handle-label-commands` job to `.github/workflows/PR-Demo-Comment-with-react.yml` that processes PR comments containing label commands and applies label changes to the associated pull request. - Introduced an authorization list `label_changer` in `.github/config/repo_devs.json` to restrict who can manage labels via comments. - Implemented parsing of comment commands in the form: - `add::label::"Label Name"` to add a label. - `rm::label::"Label Name"` to remove a label. - The workflow reads `.github/labels.yml` to build a canonical map of allowed labels (case-insensitive matching, preserves original casing). - Only labels defined in `.github/labels.yml` can be added; removal allows best-effort even if the label is not found in the map. - After successful processing, the original command comment is deleted to keep the thread clean. - Uses the existing local action `./.github/actions/setup-bot` to authenticate as a GitHub App for applying labels. - Added runner hardening via `step-security/harden-runner@v2.13.1` with egress audit mode. - **Why the change was made** - Streamlines triage by enabling trusted maintainers to manage labels directly from PR comments without needing full UI interactions. - Ensures safety and consistency: - Only **authorized** accounts (from `label_changer`) can execute label commands. - Only **known** labels (from `.github/labels.yml`) may be added, preventing typos and drift. - Reduces noise by deleting comma --- .github/config/repo_devs.json | 5 + .../workflows/PR-Demo-Comment-with-react.yml | 146 ++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/.github/config/repo_devs.json b/.github/config/repo_devs.json index 86d43fd98..963240260 100644 --- a/.github/config/repo_devs.json +++ b/.github/config/repo_devs.json @@ -1,4 +1,9 @@ { + "label_changer": [ + "Frooodle", + "Ludy87", + "balazs-szucs" + ], "repo_devs": [ "Frooodle", "sf298", diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 8d147fea8..9327dc5fc 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -355,3 +355,149 @@ jobs: rm -f ../private.key docker-compose.yml echo "Cleanup complete." continue-on-error: true + + handle-label-commands: + if: ${{ github.event.issue.pull_request != null }} + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Check out the repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup GitHub App Bot + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Apply label commands + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const fs = require('fs'); + const path = require('path'); + + const { comment, issue } = context.payload; + const commentBody = comment?.body ?? ''; + if (!commentBody.includes('::label::')) { + core.info('No label commands detected in comment.'); + return; + } + + const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json'); + const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase()); + + const commenter = (comment?.user?.login || '').toLowerCase(); + if (!label_changer.includes(commenter)) { + core.info(`User ${commenter} is not authorized to manage labels.`); + return; + } + + const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml'); + const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8'); + + const labelNameMap = new Map(); + for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) { + const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim(); + + if (!labelName) { + continue; + } + const normalized = labelName.toLowerCase(); + if (!labelNameMap.has(normalized)) { + labelNameMap.set(normalized, labelName); + } + } + + if (!labelNameMap.size) { + core.warning('No labels could be read from .github/labels.yml; aborting label commands.'); + return; + } + + let allowedLabelNames = new Set(labelNameMap.values()); + + const labelsToAdd = new Set(); + const labelsToRemove = new Set(); + const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim; + let match; + while ((match = commandRegex.exec(commentBody)) !== null) { + core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`); + const action = match[1].toLowerCase(); + const labelName = match[3].trim(); + + if (!labelName) { + continue; + } + + const normalized = labelName.toLowerCase(); + const resolvedLabelName = labelNameMap.get(normalized); + if (action === 'add') { + if (!resolvedLabelName) { + core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`); + continue; + } + if (!allowedLabelNames.has(resolvedLabelName)) { + core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`); + continue; + } + labelsToAdd.add(resolvedLabelName); + } else if (action === 'rm') { + const labelToRemove = resolvedLabelName ?? labelName; + if (!resolvedLabelName) { + core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`); + } + labelsToRemove.add(labelToRemove); + } + } + + const addLabels = Array.from(labelsToAdd); + const removeLabels = Array.from(labelsToRemove); + + if (!addLabels.length && !removeLabels.length) { + core.info('No valid label commands found after parsing.'); + return; + } + + const issueParams = { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + }; + + if (addLabels.length) { + core.info(`Adding labels: ${addLabels.join(', ')}`); + await github.rest.issues.addLabels({ + ...issueParams, + labels: addLabels, + }); + } + + for (const labelName of removeLabels) { + core.info(`Removing label: ${labelName}`); + try { + await github.rest.issues.removeLabel({ + ...issueParams, + name: labelName, + }); + } catch (error) { + if (error.status === 404) { + core.warning(`Label "${labelName}" was not present on the pull request.`); + } else { + throw error; + } + } + } + + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + core.info('Processed label commands and deleted the comment.');