Photo by Mari Lezhava
I've been playing around and using GitHub Actions for some time now and so far it has been a good experience. I also started contributing here and there to some community-built actions and that gave me a chance to deep dive into them.
To my positive surprise, it's actually very simple to work with them. Kudos to GitHub for building a very accessible framework!
With that in mind I decided to build a simple action that I could try and use at work. The idea is to build an action to check that a Pull Request has at least one label assigned.
TL;DR: check out the code at https://github.com/emmenko/action-verify-pr-labels
A GitHub Action is technically just a JavaScript package and thus we need:
package.json
, to list the necessary dependencies and the package main file.action.yml
, to describe the metadata of the action.index.js
file.To facilitate the implementation of an action, GitHub provides different helper packages. For the action we are going to build, we need the @actions/core package and the @actions/github package.
{"name": "action-verify-pr-labels","version": "1.0.0","license": "MIT","dependencies": {"@actions/core": "1.2.4","@actions/github": "2.2.0"}}
Next, create the action.yml
metadata file:
name: 'Verify Pull Request Labels'description: 'Verify that the Pull Request has at least one valid label'inputs:github-token:description: 'Token provided by GitHub'required: trueruns:using: 'node12'main: 'index.js'
Notice that we describe what inputs the action supports. Inputs are key-value parameters that you would pass to the job step with the with
option.
In our case, we require the GitHub token for making HTTP API requests using the Oktokit SDK from the @actions/github package.
The last thing to do is to write the actual code, in the index.js
file.
We expect the action to roughly do the following:
Since we're going to perform asynchronous operations, we need to prepare the code to deal with Promises. I personally like the async/await
syntax, so I'm going to use that.
const core = require('@actions/core');(async () => {try {// TODO: our implementation} catch (error) {await core.setFailed(error.stack || error.message);}})();
NOTE: we're using an immediately-invoked function expression (IIFE) to use the
async/await
syntax. There is a new JavaScript syntax proposal to use a top-levelawait
. Until this is natively supported, we're going to use the workaround with the IIFE.
The code snippet above is just a basic scaffolding for running some asynchronous JavaScript code and handle possible errors. In case there is an error, we're using the core.setFailed
helper function to make the action fail.
The next thing to do is to prepare all the necessary information from the GitHub Action workflow.
const core = require('@actions/core');const github = require('@actions/github');(async () => {try {const owner = github.context.repo.owner;const repo = github.context.repo.repo;const ref = github.context.ref;const gitHubToken = core.getInput('github-token', { required: true });const octokit = new GitHub(gitHubToken);// TODO: our implementation} catch (error) {await core.setFailed(error.stack || error.message);}})();
Here we're getting some information from the github.context
object, the GitHub token from the input parameter github-token
, and we're initializing the Oktokit SDK client to make HTTP API requests.
The next step is to get the Pull Request labels. To do that, we need to know the Pull Request number.
const core = require('@actions/core');const github = require('@actions/github');const getPullRequestNumber = (ref) => {core.debug(`Parsing ref: ${ref}`);// This assumes that the ref is in the form of `refs/pull/:prNumber/merge`const prNumber = ref.replace(/refs\/pull\/(\d+)\/merge/, '$1');return parseInt(prNumber, 10);};(async () => {try {const owner = github.context.repo.owner;const repo = github.context.repo.repo;const ref = github.context.ref;const prNumber = github.context.issue.number || getPullRequestNumber(ref);const gitHubToken = core.getInput('github-token', { required: true });const octokit = new GitHub(gitHubToken);const getPrLabels = async (prNumber) => {const { data } = await octokit.pulls.get({pull_number: prNumber,owner,repo,});if (data.length === 0) {throw new Error(`No Pull Requests found for ${prNumber} (${ref}).`);}return data.labels.map((label) => label.name);};const prLabels = await getPrLabels(prNumber);core.debug(`Found PR labels: ${prLabels.toString()}`);// TODO: our implementation} catch (error) {await core.setFailed(error.stack || error.message);}})();
The Pull Request number can be derived by the issue.number
(depending on how the action is triggered) or by inspecting the Pull Request ref
.
With the list of potentially assigned labels, we can now start to implement the logic for the requirements that we initially defined.
Let's start with the basics. If the Pull Request has labels assigned, we exit the process and don't do anything. If not, we create a "Request Changes" review, with a friendly message.
const core = require('@actions/core');const github = require('@actions/github');const getPullRequestNumber = (ref) => {core.debug(`Parsing ref: ${ref}`);// This assumes that the ref is in the form of `refs/pull/:prNumber/merge`const prNumber = ref.replace(/refs\/pull\/(\d+)\/merge/, '$1');return parseInt(prNumber, 10);};(async () => {try {const owner = github.context.repo.owner;const repo = github.context.repo.repo;const ref = github.context.ref;const prNumber = github.context.issue.number || getPullRequestNumber(ref);const gitHubToken = core.getInput('github-token', { required: true });const octokit = new GitHub(gitHubToken);const getPrLabels = async (prNumber) => {const { data } = await octokit.pulls.get({pull_number: prNumber,owner,repo,});if (data.length === 0) {throw new Error(`No Pull Requests found for ${prNumber} (${ref}).`);}return data.labels.map((label) => label.name);};const prLabels = await getPrLabels(prNumber);core.debug(`Found PR labels: ${prLabels.toString()}`);if (prLabels.length > 0) {core.info(`Pull Request has at least a label. All good!`);return}const reviewMessage = `👋 Hi,this is a reminder message for maintainers to assign a proper label to this Pull Request.The bot will dismiss the review as soon as at least one label has been assigned to the Pull Request.Thanks.`;await octokit.pulls.createReview({owner,repo,pull_number: prNumber,body: reviewMessage,event: 'REQUEST_CHANGES',});} catch (error) {await core.setFailed(error.stack || error.message);}})();
If the Pull Request has at least one label assigned and there was previously a change request, we need to dismiss the stale review.
To do so, we get the status of the last review from the action bot and check if it's not been DISMISSED
.
const core = require('@actions/core');const github = require('@actions/github');const getPullRequestNumber = (ref) => {core.debug(`Parsing ref: ${ref}`);// This assumes that the ref is in the form of `refs/pull/:prNumber/merge`const prNumber = ref.replace(/refs\/pull\/(\d+)\/merge/, '$1');return parseInt(prNumber, 10);};(async () => {try {const owner = github.context.repo.owner;const repo = github.context.repo.repo;const ref = github.context.ref;const prNumber = github.context.issue.number || getPullRequestNumber(ref);const gitHubToken = core.getInput('github-token', { required: true });const octokit = new GitHub(gitHubToken);const getPrLabels = async (prNumber) => {const { data } = await octokit.pulls.get({pull_number: prNumber,owner,repo,});if (data.length === 0) {throw new Error(`No Pull Requests found for ${prNumber} (${ref}).`);}return data.labels.map((label) => label.name);};const prLabels = await getPrLabels(prNumber);core.debug(`Found PR labels: ${prLabels.toString()}`);const reviews = await octokit.pulls.listReviews({owner,repo,pull_number: prNumber,});const allReviewsFromActionsBot = reviews.data.filter((review) => review.user.login === 'github-actions[bot]');const lastReviewFromActionsBot =allReviewsFromActionsBot.length > 0 &&allReviewsFromActionsBot[allReviewsFromActionsBot.length - 1];core.debug(`Last review from actions bot: ${JSON.stringify(lastReviewFromActionsBot)}`);if (prLabels.length > 0) {core.info(`Pull Request has at least a label. All good!`);if (lastReviewFromActionsBot &&lastReviewFromActionsBot.state !== 'DISMISSED') {await octokit.pulls.dismissReview({owner,repo,pull_number: prNumber,review_id: lastReviewFromActionsBot.id,message: 'All good!',});}return}const reviewMessage = `👋 Hi,this is a reminder message for maintainers to assign a proper label to this Pull Request.The bot will dismiss the review as soon as at least one label has been assigned to the Pull Request.Thanks.`;await octokit.pulls.createReview({owner,repo,pull_number: prNumber,body: reviewMessage,event: 'REQUEST_CHANGES',});} catch (error) {await core.setFailed(error.stack || error.message);}})();
As a last improvement, we can check if the last review from the action bot is a CHANGES_REQUESTED
, in which case we can exit and avoid requesting yet another change.
const core = require('@actions/core');const github = require('@actions/github');const getPullRequestNumber = (ref) => {core.debug(`Parsing ref: ${ref}`);// This assumes that the ref is in the form of `refs/pull/:prNumber/merge`const prNumber = ref.replace(/refs\/pull\/(\d+)\/merge/, '$1');return parseInt(prNumber, 10);};(async () => {try {const owner = github.context.repo.owner;const repo = github.context.repo.repo;const ref = github.context.ref;const prNumber = github.context.issue.number || getPullRequestNumber(ref);const gitHubToken = core.getInput('github-token', { required: true });const octokit = new GitHub(gitHubToken);const getPrLabels = async (prNumber) => {const { data } = await octokit.pulls.get({pull_number: prNumber,owner,repo,});if (data.length === 0) {throw new Error(`No Pull Requests found for ${prNumber} (${ref}).`);}return data.labels.map((label) => label.name);};const prLabels = await getPrLabels(prNumber);core.debug(`Found PR labels: ${prLabels.toString()}`);const reviews = await octokit.pulls.listReviews({owner,repo,pull_number: prNumber,});const allReviewsFromActionsBot = reviews.data.filter((review) => review.user.login === 'github-actions[bot]');const lastReviewFromActionsBot =allReviewsFromActionsBot.length > 0 &&allReviewsFromActionsBot[allReviewsFromActionsBot.length - 1];core.debug(`Last review from actions bot: ${JSON.stringify(lastReviewFromActionsBot)}`);if (prLabels.length > 0) {core.info(`Pull Request has at least a label. All good!`);if (lastReviewFromActionsBot &&lastReviewFromActionsBot.state !== 'DISMISSED') {await octokit.pulls.dismissReview({owner,repo,pull_number: prNumber,review_id: lastReviewFromActionsBot.id,message: 'All good!',});}return}if (lastReviewFromActionsBot &&lastReviewFromActionsBot.state === 'CHANGES_REQUESTED') {core.info(`Skipping REQUEST_CHANGES review`);return;}const reviewMessage = `👋 Hi,this is a reminder message for maintainers to assign a proper label to this Pull Request.The bot will dismiss the review as soon as at least one label has been assigned to the Pull Request.Thanks.`;await octokit.pulls.createReview({owner,repo,pull_number: prNumber,body: reviewMessage,event: 'REQUEST_CHANGES',});} catch (error) {await core.setFailed(error.stack || error.message);}})();
Now that we finished implementing our GitHub Action, we can test it in the same repository.
Create a .github/workflow/main.yml
workflow file:
name: Verify Pull Request Labelson:pull_request:types: [ready_for_review, review_requested, labeled, unlabeled]jobs:verify_pr_labels:if: github.event.pull_request.draft == falseruns-on: ubuntu-lateststeps:- name: Checkoutuses: actions/checkout@v2- name: Install github actions dependenciesuses: bahmutov/npm-install@v1.1.0- name: Verify Pull Request Labelsuses: ./with:github-token: '${{ secrets.GITHUB_TOKEN }}'
When you open the Pull Request (depending on the type of triggers), the action should run and request changes. After assigning a label, the action runs again and dismisses the stale review.
At this point the GitHub Action is done. People can use it by referencing the GitHub repository:
- name: Verify Pull Request Labelsuses: emmenko/action-verify-pr-labels@masterwith:github-token: '${{ secrets.GITHUB_TOKEN }}'
Additionally, you can choose to publish the action to the GitHub Marketplace and tag a git version.
Developing GitHub Actions is not too hard. At the end of the day is "just JavaScript", and GitHub provides an accessible framework to make it as simple as possible.
Hopefully this article gives you the motivation and the confidence to try it out for yourself to build amazing actions.