GitHub Actions: build your own JavaScript action — part 3

Build your own GitHub Action to automatically add PRs to specific columns on a GitHub Project board. This is part 3 of a series of posts on the topic.

Let’s keep working on our GitHub Action! In the first parts of our series, we’ve discovered how to automatically add labels to an issue. Let’s explore some of the other things we can do with the Octokit client.

This post is part of the GitHub Actions: build your own JavaScript action series.

If you’re not following this blog yet, sign up here to get an email as soon as part 4 comes out:

GitHub has recently released a new version of their project management tools. The new Projects interface includes a lot of features that can be useful for teams planning product updates, but also for individuals looking for a work planner and reporting tool.

Along with the new Projects interface, GitHub also offered ways to interact with projects via the GitHub API. That’s just what we need!

We will create a triage task that will run on every Pull Request (PR), and add it to a specific project board. We will then add the PR to a specific column on the board depending on its status.

We’ll rely on GitHub’s GraphQL API to implement this. Thanks for the Octokit client, we can run octokit.graphql from our GitHub Action to make changes to projects. You can read more about this here:

Caveat: this will only work with the new GitHub Projects. Old projects, now called “classic”, use a different API that will be deprecated on October 1, 2022.

Set up our action to monitor Pull Requests

Before we get started with the API, let’s go back to the Triage action we built in the past posts. We’ll make 4 changes:

1. Set up a new token that Octokit can use to interact with project boards.

In Part 1, we talked about the GITHUB_TOKEN secret, automatically added by GitHub and used by our action to authenticate our requests to the GitHub API. This has served us well so far. However, that secret does not have the permissions needed to programmatically push to project boards.

We’ll create our own access token here. with the repo and project scopes. Make note of that token, and paste it in a new secret in Settings > Secrets > Actions in our GitHub repository. We’ll name that secret TRIAGE_PROJECTS_TOKEN:

We can then add that token to our action:

  1. We’ll pass it in the workflow, in .github/workflows/triage.yml:
- name: Run action
  uses: ./.github/actions/triage/
  with:
  github_token: ${{ secrets.GITHUB_TOKEN }}
  triage_projects_token: ${{ secrets.TRIAGE_PROJECTS_TOKEN }}

2. We’ll mention that new option in the action’s action.yml file (.github/actions/triage/action.yml):

name: "Issue Triage"
description: "Automatically add labels on newly opened issues"
inputs:
  github_token:
    description: "GitHub access token"
    required: true
    default: ${{ github.token }}
  triage_projects_token:
    description: "Triage Projects access token"
    required: false
    default: ""
runs:
  using: node16
  main: "dist/index.js"

2. Provide our GitHub Project to the repository

We want Pull Requests to be added to a specific project board; let’s let our action know which one. Just like above, we’ll add a new parameter to our action, triage_projects_board, and pass it to our workflow:

- name: Run action
  uses: ./.github/actions/triage/
  with:
  github_token: ${{ secrets.GITHUB_TOKEN }}
  triage_projects_token: ${{ secrets.TRIAGE_PROJECTS_TOKEN }}
  triage_projects_board: https://github.com/users/jeherve/projects/2/

(Obviously you’ll want to fill in your own project board URL there, not mine)

name: "Issue Triage"
description: "Automatically add labels on newly opened issues"
inputs:
  github_token:
    description: "GitHub access token"
    required: true
    default: ${{ github.token }}
  triage_projects_token:
    description: "Triage Projects access token"
    required: false
    default: ""
  triage_projects_board:
    description: "Triage Projects board URL"
    required: false
    default: ""
runs:
  using: node16
  main: "dist/index.js"

3. Listen for Pull Request events

Until now, our action workflow only listened to issue events. Let’s edit .github/workflows/triage.yml to start listening to Pull Requests events. In this case, we’ll listen for pull_request_target, which will run in the context of the base of the pull request, rather than in the context of the merge commit (the head of the pull request, which could be any code submitted in a PR by a GitHub contributor).

name: Triage
on:
  issues: # For auto-triage of issues.
    types: [opened]
  pull_request_target: # For triaging PRs into project boards.
    types: [opened,converted_to_draft,ready_for_review]

4. Add logic to manage this event in our existing action

In Part 1 of our series, we had added logic to listen to issues events:

// Get info about the event.
const { payload, eventName } = context;

debug( `Received event = '${ eventName }', action = '${ payload.action }'` );

// We only want to proceed if this is a newly opened issue.
if ( eventName === 'issues' && payload.action === 'opened' ) {
	...
}

We can now extend that logic to support Pull Requests:

// Get info about the event.
const { payload, eventName } = context;

debug( `Received event = '${ eventName }', action = '${ payload.action }'` );

// Let's monitor changes to Pull Requests.
const projectToken = getInput( 'triage_projects_token' );

if ( eventName === 'pull_request_target' && projectToken !== '' ) {
	debug( `Triage: now processing a change to a Pull Request` );

	// For this task, we need octokit to have extra permissions not provided by the default GitHub token.
	// Let's create a new octokit instance using our own custom token.
	const projectOctokit = new getOctokit( projectToken );
	await triagePrToProject( payload, projectOctokit );
}

// We only want to proceed if this is a newly opened issue.
if ( eventName === 'issues' && payload.action === 'opened' ) {
	// Our existing logic processing issues here.
}

As you can see, I opted to extract the Pull Request triage handling into its own function, triagePullRequest, to keep things cleaner. We’ll move that function to a new file, .github/actions/triage/src/triage-pr-to-project.js, and bring in that function to our main file.

In that function, we’ll start by adding some logging:

/**
 * Handle automatic triage of Pull Requests into a Github Project board.
 *
 * @param {WebhookPayloadPullRequest} payload - The payload from the Github Action.
 * @param {GitHub}                    octokit - Initialized Octokit REST client.
 * @returns {Promise<void>}
 */
async function triagePrToProject( payload, octokit ) {
	// Extra data from the event, to use in API requests.
	const {
		pull_request: { number, draft, node_id },
	} = payload;
	const isDraft = !! draft;

	const projectBoardLink = getInput( 'triage_projects_board' );
	if ( ! projectBoardLink ) {
		setFailed( 'Triage: No project board link provided. Cannot triage to a board' );
		return;
	}

	// If a PR is opened but not ready for review yet, add it to the In Progress column.
	if ( isDraft ) {
		debug( `Triage: Pull Request #${ number } is a draft. Add it to the In Progress column.` );
		return;
	}

	// If the PR is ready for review, let's add it to the Needs Review column.
	debug(
		`Triage: Pull Request #${ number } is ready for review. Add it to the Needs Review column.`
	);
	
	return;
}

You can already push that code, and check your action’s logs when you open a new Pull Request!

Gather information about our project board

The first thing we’ll want to do is to gather information about our project board. To be able to interact with that board via the API, we’ll need 2 things:

  1. The Project’s Node ID, used to push things to the board.
  2. Details about the different fields (columns) available on the board, so we can move our Pull Request card around on the board.

Using the GraphQL API, we can fetch that information based on the GitHub project URL we passed to the action.

/**
 * Get Information about a project board.
 *
 * @param {GitHub} octokit          - Initialized Octokit REST client.
 * @param {string} projectBoardLink - The link to the project board.
 * @returns {Promise<Object>} - Project board information.
 */
async function getProjectDetails( octokit, projectBoardLink ) {
	const projectRegex = /^(?:https:\/\/)?github\.com\/(?<ownerType>orgs|users)\/(?<ownerName>[^/]+)\/projects\/(?<projectNumber>\d+)/;
	const matches = projectBoardLink.match( projectRegex );
	if ( ! matches ) {
		debug( `Triage: Invalid project board link provided. Cannot triage to a board` );
		return {};
	}

	const {
		groups: { ownerType, ownerName, projectNumber },
	} = matches;

	const projectInfo = {
		ownerType: ownerType === 'orgs' ? 'organization' : 'user', // GitHub API requests require 'organization' or 'user'.
		ownerName,
		projectNumber: parseInt( projectNumber, 10 ),
	};

	// First, use the GraphQL API to request the project's node ID,
	// as well as info about the first 20 fields for that project.
	const projectDetails = await octokit.graphql(
		`query getProject($ownerName: String!, $projectNumber: Int!) {
			${ projectInfo.ownerType }(login: $ownerName) {
				projectV2(number: $projectNumber) {
					id
					fields(first:20) {
						nodes {
							... on ProjectV2Field {
								id
								name
							}
							... on ProjectV2SingleSelectField {
								id
								name
								options {
									id
									name
								}
							}
						}
					}
				}
			}
		}`,
		{
			ownerName: projectInfo.ownerName,
			projectNumber: projectInfo.projectNumber,
		}
	);

	// Extract the project node ID.
	const projectNodeId = projectDetails[ projectInfo.ownerType ]?.projectV2.id;
	if ( projectNodeId ) {
		projectInfo.projectNodeId = projectNodeId; // Project board node ID. String.
	}

	// Extract the ID of the Status field.
	const statusField = projectDetails[ projectInfo.ownerType ]?.projectV2.fields.nodes.find(
		field => field.name === 'Status'
	);
	if ( statusField ) {
		projectInfo.status = statusField; // Info about our status column (id as well as possible values).
	}

	return projectInfo;
}

Let’s break it down a bit:

  1. We start by extracting information from the project board URL: the type of owner (an organization or an individual), the name of the owner, and the number of the board.
  2. We then make our first GraphQL request. If you’re not familiar with it, you can use the GitHub GraphQL API Explorer to play with it a bit.
  3. In that request, we use the ProjectV2 object to get the project’s node ID, as well as a list of the first 20 fields and their constraints in the project.

In our example, we’ll focus on a “Status” field. It’s a fairly basic example.

You can set up your own custom fields in GitHub projects, so you could adapt this example to sort Pull Requests by priority, by the team assigned to them, by the component they touch, and more!

After receiving the API response, we extract the project’s node ID as well as information about the status field: what values are possible (“In Progress”, “Needs Review”, and “Done” in our example), and the ID of each value.

We build a new object, projectInfo, with all that information.

Push Pull Requests to the project board

Once we have information about both the Pull Request and the project board, we can add our PR to the board with another GraphQL request. To update a project, we must use a mutation. In this case, we’ll use the addProjectV2ItemById mutation, using the project’s node ID we just extracted, as well as the Pull Request’s node_id we can get from the event payload.

/**
 * Add PR to our project board.
 *
 * @param {GitHub} octokit     - Initialized Octokit REST client.
 * @param {Object} projectInfo - Info about our project board.
 * @param {string} node_id     - The node_id of the Pull Request.
 * @returns {Promise<string>} - Info about the project item id that was created.
 */
async function addPrToBoard( octokit, projectInfo, node_id ) {
	const { projectNodeId } = projectInfo;

	// Add our PR to that project board.
	const projectItemDetails = await octokit.graphql(
		`mutation addIssueToProject($input: AddProjectV2ItemByIdInput!) {
			addProjectV2ItemById(input: $input) {
				item {
					id
				}
			}
		}`,
		{
			input: {
				projectId: projectNodeId,
				contentId: node_id,
			},
		}
	);

	const projectItemId = projectItemDetails.addProjectV2ItemById.item.id;
	if ( ! projectItemId ) {
		debug( `Triage: Failed to add PR to project board.` );
		return '';
	}

	debug( `Triage: Added PR to project board.` );

	return projectItemId;
}

We now have 2 new functions, getProjectDetails and addPrToBoard. Let’s edit our existing main triagePrToProject function to use those 2 functions:

/**
 * Handle automatic triage of Pull Requests into a Github Project board.
 *
 * @param {WebhookPayloadPullRequest} payload - The payload from the Github Action.
 * @param {GitHub}                    octokit - Initialized Octokit REST client.
 * @returns {Promise<void>}
 */
async function triagePrToProject( payload, octokit ) {
	// Extra data from the event, to use in API requests.
	const {
		pull_request: { number, draft, node_id },
	} = payload;
	const isDraft = !! draft;

	const projectBoardLink = getInput( 'triage_projects_board' );
	if ( ! projectBoardLink ) {
		setFailed( 'Triage: No project board link provided. Cannot triage to a board' );
		return;
	}

	// Get details about our project board, to use in our requests.
	const projectInfo = await getProjectDetails( octokit, projectBoardLink );
	if ( Object.keys( projectInfo ).length === 0 || ! projectInfo.projectNodeId ) {
		setFailed( 'Triage: we cannot fetch info about our project board. Cannot triage to a board' );
		return;
	}

	// Add our Pull Request to the project board.
	const projectItemId = await addPrToBoard( octokit, projectInfo, node_id );
	if ( ! projectItemId ) {
		setFailed( 'Triage: failed to add PR to project board' );
		return;
	}

	// If we have no info about the status column, stop.
	if ( ! projectInfo.status ) {
		debug( `Triage: No status column found in project board.` );
		return;
	}

	// If a PR is opened but not ready for review yet, add it to the In Progress column.
	if ( isDraft ) {
		debug( `Triage: Pull Request #${ number } is a draft. Add it to the In Progress column.` );
		return;
	}

	// If the PR is ready for review, let's add it to the Needs Review column.
	debug(
		`Triage: Pull Request #${ number } is ready for review. Add it to the Needs Review column.`
	);
	return;
}

We’ve done the first part of the work! Whenever PRs are opened, they’re automatically added to our Project board.

Let’s move onto the next part, and move the PRs to a specific column on our board:

  • If the PR is still a draft, add it to the “In Progress” status column.
  • Once it’s been marked as ready for review, move it to the “Needs Review” column.

Assign our Pull Request to a specific column on our Project Board

We’ll do this through one more GraphQL request, this time using the updateProjectV2ItemFieldValue mutation. To move the card to a specific column, that mutation needs the following information:

  • The project’s Node ID; we extracted it earlier.
  • The card (i.e. our PR)’s ID; we got that as a response from the API when we created the card with our PR on the board earlier.
  • The field ID, i.e. the ID of the field we want to sort by. We’re interested in the “Status” field here, and we got the “Status” field ID earlier when requesting information about the project.
  • The ID of the value of the field. We know the value can be “In Progress”, “Needs Review”, or “Done”. We can get the ID matching each field name from the “Status” field information we got from the API request.

Here is how our function will look like:

/**
 * Set custom fields for a project item.
 *
 * @param {GitHub} octokit       - Initialized Octokit REST client.
 * @param {Object} projectInfo   - Info about our project board.
 * @param {string} projectItemId - The ID of the project item.
 * @param {string} statusText    - Status of our PR (must match an existing column in the project board).
 * @returns {Promise<string>} - The new project item id.
 */
async function setPriorityField( octokit, projectInfo, projectItemId, statusText ) {
	const {
		projectNodeId, // Project board node ID.
		status: {
			id: statusFieldId, // ID of the status field.
			options,
		},
	} = projectInfo;

	// Find the ID of the status option that matches our PR status.
	const statusOptionId = options.find( option => option.name === statusText ).id;
	if ( ! statusOptionId ) {
		debug(
			`Triage: Status ${ statusText } does not exist as a column option in the project board.`
		);
		return '';
	}

	const projectNewItemDetails = await octokit.graphql(
		`mutation ( $input: UpdateProjectV2ItemFieldValueInput! ) {
			set_status: updateProjectV2ItemFieldValue( input: $input ) {
				projectV2Item {
					id
				}
			}
		}`,
		{
			input: {
				projectId: projectNodeId,
				itemId: projectItemId,
				fieldId: statusFieldId,
				value: {
					singleSelectOptionId: statusOptionId,
				},
			},
		}
	);

	const newProjectItemId = projectNewItemDetails.set_status.projectV2Item.id;
	if ( ! newProjectItemId ) {
		debug( `Triage: Failed to set the "${ statusText }" status for this project item.` );
		return '';
	}

	debug( `Triage: Project item ${ newProjectItemId } was moved to "${ statusText }" status.` );

	return newProjectItemId; // New Project item ID (what we just edited). String.
}

You’ll notice that my function accepts a statusText parameter. That will be the name of the status (either “In Progress” or “Needs Review”) where we’ll want to move the PR, based of whether the PR is in draft or ready for review.

Let’s look at the final version of the triagePrToProject function, using our new setPriorityField function to move the PRs to different columns:

/**
 * Handle automatic triage of Pull Requests into a Github Project board.
 *
 * @param {WebhookPayloadPullRequest} payload - The payload from the Github Action.
 * @param {GitHub}                    octokit - Initialized Octokit REST client.
 * @returns {Promise<void>}
 */
async function triagePrToProject( payload, octokit ) {
	// Extra data from the event, to use in API requests.
	const {
		pull_request: { number, draft, node_id },
	} = payload;
	const isDraft = !! draft;

	const projectBoardLink = getInput( 'triage_projects_board' );
	if ( ! projectBoardLink ) {
		setFailed( 'Triage: No project board link provided. Cannot triage to a board' );
		return;
	}

	// Get details about our project board, to use in our requests.
	const projectInfo = await getProjectDetails( octokit, projectBoardLink );
	if ( Object.keys( projectInfo ).length === 0 || ! projectInfo.projectNodeId ) {
		setFailed( 'Triage: we cannot fetch info about our project board. Cannot triage to a board' );
		return;
	}

	// Add our Pull Request to the project board.
	const projectItemId = await addPrToBoard( octokit, projectInfo, node_id );
	if ( ! projectItemId ) {
		setFailed( 'Triage: failed to add PR to project board' );
		return;
	}

	// If we have no info about the status column, stop.
	if ( ! projectInfo.status ) {
		debug( `Triage: No status column found in project board.` );
		return;
	}

	// If a PR is opened but not ready for review yet, add it to the In Progress column.
	if ( isDraft ) {
		debug( `Triage: Pull Request #${ number } is a draft. Add it to the In Progress column.` );
		await setPriorityField( octokit, projectInfo, projectItemId, 'In Progress' );
		return;
	}

	// If the PR is ready for review, let's add it to the Needs Review column.
	debug(
		`Triage: Pull Request #${ number } is ready for review. Add it to the Needs Review column.`
	);
	await setPriorityField( octokit, projectInfo, projectItemId, 'Needs Review' );
	return;
}

Tie it all together

If you’d like to see how the end result looks like, you can see the workflow in action in this repository:

Of course, this was only an example. I would recommend that you play with GitHub projects a bit; you may find lots of useful tools and possible processes for your teams and your work, and you can then automate a lot of it via GitHub Actions.

You can read more on this topic on the GitHub blog and in their docs:


We’re done with GitHub Projects for now, but don’t worry, this series will continue. In my next post, I’ll dive into some of the other things we can do with our GitHub Action!

This post is part of the GitHub Actions: build your own JavaScript action series.

If you’re not following this blog yet, sign up here to get an email as soon as part 4 comes out:

Leave a Reply

Your email address will not be published. Required fields are marked *