Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: gh action to apply PR review labels #5656

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/process-pr-review-data.yml
Original file line number Diff line number Diff line change
@@ -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 }}
27 changes: 27 additions & 0 deletions .github/workflows/received-pr-review.yml
Original file line number Diff line number Diff line change
@@ -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/
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,8 @@ package-lock.json
.avt/

# Playwright
.playwright/
.playwright/

# Actions
/actions/**/node_modules
/actions/**/yarn.lock
4 changes: 4 additions & 0 deletions actions/add-review-labels/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/__mocks__/**
**/__tests__/**
**/examples/**
**/tasks/**
6 changes: 6 additions & 0 deletions actions/add-review-labels/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM node:slim

WORKDIR /usr/src/action
COPY . .
RUN yarn install --production
ENTRYPOINT ["node", "/usr/src/action/index.js"]
12 changes: 12 additions & 0 deletions actions/add-review-labels/action.yml
Original file line number Diff line number Diff line change
@@ -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'
212 changes: 212 additions & 0 deletions actions/add-review-labels/index.js
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think we really need this step, unless there’s an operational reason we want to pull the label back off. But doesn’t really hurt either.

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);
});
27 changes: 27 additions & 0 deletions actions/add-review-labels/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading