diff --git a/.github/labeler.yml b/.github/labeler.yml index 88336e8aea2..278cc551a54 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -17,6 +17,9 @@ # specific language governing permissions and limitations # under the License. # +# Note: NuttX PR Labeler only supports a subset of the +# `actions/labeler` syntax: `changed-files` and +# `any-glob-to-any-file`. See .github/workflows/labeler.yml # add arch labels diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 1c38d38da6e..bf1c459ec46 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,34 +12,139 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# This workflow will fetch the updated PR filenames, compute the Size Label +# and Arch Labels, then save the PR Labels into a PR Artifact. The +# PR Labels will be written to the PR inside the "workflow_run" trigger +# (pr_labeler.yml), because this "pull_request" trigger has read-only +# permission. Don't use "pull_request_target", it's unsafe. +# See https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=321719166#GitHubActionsSecurity-Buildstriggeredwithpull_request_target name: "Pull Request Labeler" on: - - pull_request_target + - pull_request jobs: labeler: permissions: contents: read - pull-requests: write - issues: write + pull-requests: read + issues: read runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v6 + # Checkout one file from our trusted source: .github/labeler.yml + # Never checkout and execute any untrusted code from the PR. + - name: Checkout labeler config + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: apache/nuttx-apps + ref: master + path: labeler + fetch-depth: 1 + persist-credentials: false + sparse-checkout: .github/labeler.yml + sparse-checkout-cone-mode: false - - name: Assign labels based on paths - uses: actions/labeler@main + # Fetch the updated PR filenames. Compute the Size Label and Arch Labels. + - name: Compute PR labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - sync-labels: true + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pull_number = context.issue.number; + + // Fetch the array of updated PR filenames: + // { status: 'added', filename: 'arch/arm/test.txt', additions: 3, deletions: 0, changes: 3 } + // { status: 'removed', filename: 'Documentation/legacy_README.md', additions: 0, deletions: 2531, changes: 2531 } + // { status: 'modified', filename: 'Documentation/security.rst', additions: 1, deletions: 0, changes: 1 } + const listFilesOptions = github.rest.pulls.listFiles + .endpoint.merge({ owner, repo, pull_number }); + const listFilesResponse = await github.paginate(listFilesOptions); + + // Sum up the number of lines changed + const sizeFiles = listFilesResponse + .filter(f => (f.status != 'removed')); // Ignore deleted files + var linesChanged = 0; + for (const file of sizeFiles) { + linesChanged += file.changes; + } + console.log({ linesChanged }); + + // Compute the Size Label + const sizeLabel = + (linesChanged <= 10) ? 'Size: XS' + : (linesChanged <= 100) ? 'Size: S' + : (linesChanged <= 500) ? 'Size: M' + : (linesChanged <= 1000) ? 'Size: L' + : 'Size: XL'; + var prLabels = [ sizeLabel ]; + + // Parse the Arch Label Patterns in .github/labeler.yml. Condense into: + // "Arch: arm": + // - any-glob-to-any-file: 'arch/arm/**' + // - any-glob-to-any-file: ... + const fs = require('fs'); + const config = fs.readFileSync('labeler/.github/labeler.yml', 'utf8') + .split('\n') // Split by newline + .map(s => s.trim()) // Remove leading and trailing spaces + .filter(s => (s != '')) // Remove empty lines + .filter(s => !s.startsWith('#')) // Remove comments + .filter(s => !s.startsWith('- changed-files:')); // Remove "changed-files" + + // Convert the Arch Label Patterns from config to archLabels. + // archLabels will contain the mappings for Arch Label and Filename Pattern: + // { label: "Arch: arm", pattern: "arch/arm/.*" }, + // { label: "Arch: arm64", pattern: "arch/arm64/.*" }, ... + var archLabels = []; + var label = ""; + for (const c of config) { + // Get the Arch Label + if (c.startsWith('"')) { // "Arch: arm": + label = c.split('"')[1]; // Arch: arm + + } else if (c.startsWith('- any-glob-to-any-file:')) { // - any-glob-to-any-file: 'arch/arm/**' + // Convert the Glob Pattern to Regex Pattern + const pattern = c.split("'")[1] // arch/arm/** + .split('.').join('\\.') // . becomes \. + .split('*').join('[^/]*') // * becomes [^/]* + .split('[^/]*[^/]*').join('.*'); // ** becomes .* + archLabels.push({ + label, // Arch: arm + pattern: '^' + pattern + '$' // Match the Line Start and Line End + }); + + } else { + // We don't support all rules of `actions/labeler` + throw new Error('.github/labeler.yml should contain only changed-files and any-glob-to-any-file, not: ' + c); + } + } + + // Search the filenames for matching Arch Labels + for (const archLabel of archLabels) { + if (prLabels.includes(archLabel.label)) { + break; + } + for (const file of listFilesResponse) { + const re = new RegExp(archLabel.pattern); + const match = re.test(file.filename); + if (match && !prLabels.includes(archLabel.label)) { + prLabels.push(archLabel.label); + break; + } + } + } + console.log({ prLabels }); + + // Save the PR Number and PR Labels into a PR Artifact + // e.g. 'Size: XS\nArch: avr\n' + const dir = 'pr'; + fs.mkdirSync(dir); + fs.writeFileSync(dir + '/pr-id.txt', pull_number + '\n'); + fs.writeFileSync(dir + '/pr-labels.txt', prLabels.join('\n') + '\n'); - - name: Assign labels based on the PR's size - uses: codelytv/pr-size-labeler@v1.10.3 + # Upload the PR Artifact as pr.zip + - name: Upload PR artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ignore_file_deletions: true - xs_label: 'Size: XS' - s_label: 'Size: S' - m_label: 'Size: M' - l_label: 'Size: L' - xl_label: 'Size: XL' + name: pr + path: pr/ diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml new file mode 100644 index 00000000000..f8af53e0501 --- /dev/null +++ b/.github/workflows/pr_labeler.yml @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This workflow will fetch the PR Labels from the PR Artifact, and write +# the PR Labels into the PR. The workflow is called after the +# "pull_request" trigger (labeler.yml). This "workflow_run" trigger uses a +# GitHub Token with Write Permission, so we must never run any untrusted +# code from the PR, and we must always extract and use the PR Artifact +# safely. See https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=321719166#GitHubActionsSecurity-Buildstriggeredwithworkflow_run +name: "Set Pull Request Labels" +on: + workflow_run: + workflows: ["Pull Request Labeler"] + types: + - completed + +jobs: + pr_labeler: + permissions: + contents: read + pull-requests: write + issues: write + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + # Download the PR Artifact, containing PR Number and PR Labels + - name: Download PR artifact + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{ github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr" + })[0]; + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data)); + + # Unzip the PR Artifact + - name: Unzip PR artifact + run: unzip pr.zip + + # Write the PR Labels into the PR + - name: Write PR labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const fs = require('fs'); + + // Read the PR Number and PR Labels from the PR Artifact + // e.g. 'Size: XS\nArch: avr\n' + const issue_number = Number(fs.readFileSync('pr-id.txt')); + const labels = fs.readFileSync('pr-labels.txt', 'utf8') + .split('\n') // Split by newline + .filter(s => (s != '')); // Remove empty lines + console.log({ issue_number, labels }); + + // Write the PR Labels into the PR + // e.g. [ 'Size: XS', 'Arch: avr' ] + await github.rest.issues.setLabels({ + owner, + repo, + issue_number, + labels + });