diff --git a/.github/workflows/process-pr-review-data.yml b/.github/workflows/process-pr-review-data.yml new file mode 100644 index 0000000000..cf55028705 --- /dev/null +++ b/.github/workflows/process-pr-review-data.yml @@ -0,0 +1,28 @@ +name: Process saved PR data and apply appropriate labels + +# We have access to repo secrets from here. If the `Received PR review` workflow has completed +# it means there has been a workflow artifact created with the data we need to apply the appropriate +# review labels from the custom action used from this workflow +on: + workflow_run: + workflows: ['Received PR review'] + types: + - completed + +jobs: + upload: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request_review' && + github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '20.x' + cache: yarn + - uses: ./actions/add-review-labels + with: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} \ No newline at end of file diff --git a/.github/workflows/received-pr-review.yml b/.github/workflows/received-pr-review.yml new file mode 100644 index 0000000000..d8ab94544c --- /dev/null +++ b/.github/workflows/received-pr-review.yml @@ -0,0 +1,27 @@ +# Save PR review data to as artifact to process in a privileged workflow that is dispatched from this one +name: Received PR review +on: [pull_request_review] +jobs: + pr_review_submitted: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + if: ${{contains(github.event_name, 'pull_request_review')}} + # First save github.event (from the unprivileged workflow) and then pass it through to the privileged action + # where we have access to repo secrets + steps: + - name: Store GitHub event in a workflow artifact + id: github_event_step + env: + JSON: ${{ toJSON(github.event) }} + run: | + mkdir -p ./pr_data + echo $JSON > ./pr_data/github.json + - name: Upload event data + id: upload_artifact + uses: actions/upload-artifact@v3 + with: + name: pr-data-to-process + path: pr_data/ diff --git a/.gitignore b/.gitignore index e666c182ba..bb1a2bab8d 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,8 @@ package-lock.json .avt/ # Playwright -.playwright/ \ No newline at end of file +.playwright/ + +# Actions +/actions/**/node_modules +/actions/**/yarn.lock diff --git a/actions/add-review-labels/.npmignore b/actions/add-review-labels/.npmignore new file mode 100644 index 0000000000..81ba1598b9 --- /dev/null +++ b/actions/add-review-labels/.npmignore @@ -0,0 +1,4 @@ +**/__mocks__/** +**/__tests__/** +**/examples/** +**/tasks/** \ No newline at end of file diff --git a/actions/add-review-labels/Dockerfile b/actions/add-review-labels/Dockerfile new file mode 100644 index 0000000000..f58ea3f469 --- /dev/null +++ b/actions/add-review-labels/Dockerfile @@ -0,0 +1,6 @@ +FROM node:slim + +WORKDIR /usr/src/action +COPY . . +RUN yarn install --production +ENTRYPOINT ["node", "/usr/src/action/index.js"] diff --git a/actions/add-review-labels/action.yml b/actions/add-review-labels/action.yml new file mode 100644 index 0000000000..16469f10c3 --- /dev/null +++ b/actions/add-review-labels/action.yml @@ -0,0 +1,12 @@ +name: Add review labels +description: A custom action that adds review labels to a Pull Request +inputs: + APP_ID: + description: GitHub app id + required: true + APP_PRIVATE_KEY: + description: GitHub app private key + required: true +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/actions/add-review-labels/index.js b/actions/add-review-labels/index.js new file mode 100644 index 0000000000..15be05ad56 --- /dev/null +++ b/actions/add-review-labels/index.js @@ -0,0 +1,212 @@ +/** + * Copyright IBM Corp. 2020, 2024 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +import github from '@actions/github'; +import core from '@actions/core'; +import { App } from 'octokit'; +import util from 'util'; +import decompress from 'decompress'; + +async function run() { + const { context } = github; + const appId = core.getInput('APP_ID', { + required: true, + }); + const privateKey = core.getInput('APP_PRIVATE_KEY', { + required: true, + }); + const app = new App({ appId, privateKey }); + const octokit = await app.getInstallationOctokit(52238220); + + const { workflow_run, repository, organization } = context.payload; + const workflowRunId = workflow_run.id; + + const { data: workflowArtifacts } = await octokit.request( + 'GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts', + { + owner: organization.login, + repo: repository.name, + run_id: workflowRunId, + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + + const matchArtifact = workflowArtifacts.artifacts.filter((artifact) => { + return artifact.name == 'pr-data-to-process'; + })[0]; + + const artifactResponse = await octokit.request( + 'GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}', + { + owner: organization.login, + repo: repository.name, + artifact_id: matchArtifact.id, + archive_format: 'zip', + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + + // Decode the array buffer from the artifact to read initial review PR data from a privileged workflow + + // Convert ArrayBuffer to Buffer + const buff = Buffer.from(artifactResponse.data); + + // Decompress the file + const files = await decompress(buff); + + // Decode the decompressed buffer + const decodedArtifact = new util.TextDecoder().decode(files[0].data); + + // Parse decoded buffer + const parsedDecodedArtifact = JSON.parse(decodedArtifact); + + const { pull_request, review } = parsedDecodedArtifact; + const { state, draft } = pull_request; + + // We only want to work with Pull Requests that are marked as open + if (state !== 'open') { + return; + } + + // We only want to work with Pull Requests that are not draft PRs + if (draft) { + return; + } + + // If the review was not an approval then we'll ignore the event + if (review && review.state !== 'approved') { + return; + } + + const { data: allReviews } = await octokit.rest.pulls.listReviews({ + owner: repository.owner.login, + repo: repository.name, + pull_number: pull_request.number, + per_page: 100, + }); + + // Get reviewer team data + const { data } = await octokit.request('GET /orgs/{org}/teams/{team_slug}', { + org: organization.login, + team_slug: 'reviewing-team', // Should be only hardcoded value (outside of the labels) needed within this action. Replace with the appropriate reviewing team that is assigned to review PRs. + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + const { members_url } = data; + + const org_id = members_url.split('organizations/').pop().split('/team')[0]; + const team_id = members_url.split('team/').pop().split('/members')[0]; + + const { data: teamMembers } = await octokit.request( + 'GET /organizations/{org_id}/team/{team_id}/members', + { + org_id, + team_id, + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + } + ); + + const additionalReviewLabel = 'status: one more review 👀'; + const readyForReviewLabel = 'status: ready for review 👀'; + + // If we find that the reviewing user is not part of the reviewing team + // then we don't want to count their review so we stop here + const reviewingUser = review.user.login; + if (!teamMembers.filter((user) => user.login === reviewingUser).length) { + return; + } + + // The `listReviews` endpoint will return all of the reviews for the pull + // request. We only care about the most recent reviews so we'll go through the + // list and get the most recent review for each reviewer + const reviewers = {}; + const reviews = []; + + // Process reviews in reverse order since they are listed from oldest to newest + for (const review of allReviews.reverse()) { + const { user } = review; + // If we've already saved a review for this user we already have the most + // recent review + if (reviewers[user.login]) { + continue; + } + + // If the author of the review not part of the reviewer team we ignore it + if (!teamMembers.filter((u) => u.login === user.login).length) { + continue; + } + + reviewers[user.login] = true; + reviews.push(review); + } + + const approved = reviews.filter((review) => { + return review.state === 'APPROVED'; + }); + + if (approved.length > 0) { + const hasReadyLabel = pull_request.labels.find((label) => { + return label.name === readyForReviewLabel; + }); + // Remove ready for review label if there is at least one approval + if (hasReadyLabel) { + await octokit.rest.issues.removeLabel({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + name: readyForReviewLabel, + }); + } + } + + if (approved.length === 1) { + const hasAdditionalReviewLabel = pull_request.labels.find((label) => { + return label.name === additionalReviewLabel; + }); + // Add the one more review label if there's at least one approval and it doesn't have the label already + if (!hasAdditionalReviewLabel) { + await octokit.rest.issues.addLabels({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + labels: [additionalReviewLabel], + }); + } + return; + } + + if (approved.length >= 2) { + const hasAdditionalReviewLabel = pull_request.labels.find((label) => { + return label.name === additionalReviewLabel; + }); + // Remove the one more review label if there are at least 2 approvals from the reviewing team + if (hasAdditionalReviewLabel) { + await octokit.rest.issues.removeLabel({ + owner: repository.owner.login, + repo: repository.name, + issue_number: pull_request.number, + name: additionalReviewLabel, + }); + } + return; + } +} + +run().catch((error) => { + console.log(error); + process.exit(1); +}); diff --git a/actions/add-review-labels/package.json b/actions/add-review-labels/package.json new file mode 100644 index 0000000000..e3d9571662 --- /dev/null +++ b/actions/add-review-labels/package.json @@ -0,0 +1,27 @@ +{ + "name": "@carbon/actions-add-review-labels", + "private": true, + "version": "0.0.0", + "license": "Apache-2.0", + "main": "index.js", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/carbon-design-system/carbon.git", + "directory": "actions/add-review-labels" + }, + "bugs": "https://github.com/carbon-design-system/carbon/issues", + "keywords": [ + "ibm", + "carbon", + "carbon-design-system", + "components", + "react" + ], + "dependencies": { + "@actions/core": "^1.2.3", + "@actions/github": "^6.0.0", + "decompress": "^4.2.1", + "octokit": "^4.0.2" + } +}