Create Azure DevOps Work Item Action
If you’re managing backlogs in Azure Boards but using GitHub Actions for CI/CD, you may have scenarios where you want to create Work Items from an Action.
Azure Boards is a mature product for managing backlogs. Many teams are using Boards even while hosting code in GitHub, and using GitHub Actions for CI/CD.
There may be scenarios where you want to create a work item on Azure Boards from within an Action:
- when a Pull Request is created
- when particular tests fail
- when a deployment is commencing or completing
Before we look at one of these scenarios, let’s have a look at how I created an Action that can create an Azure DevOps Work Item.
Azure DevOps Work Item Creation Action
tldr; just go to the Action in the markeplace to start using it in your workflows!
Setting up a Skeleton
I really prefer developing Actions with TypeScript, so I found the TypeScript Action template repo and created a repo from the template. This set up a skeleton Action that I could work from. I then opened the Action in a CodeSpace and I was developing! I switched from npm
to yarn
but that’s not something you have to do.
Coding the Action
Next I added the azure-devops-node-api so that I could easily interact with the Azure DevOps REST API. In the main.ts file, I use core.getInput()
to parse the inputs (which I specify in the action.yml file). I then just call the createWorkItem()
method in a separate work-item-functions.ts file.
Authentication
The client library makes authenticating fairly easy, assuming you have an org name and a Personal Access Token (PAT). Here’s the method I created to authenticate and get a WorkItemTrackingClient
, which has methods for interacting with work items:
// file: 'work-item-functions.ts'
async function getWiClient(
token: string,
orgName: string
): Promise<IWorkItemTrackingApi> {
const orgUrl = `https://dev.azure.com/${orgName}`;
core.debug(`Connecting to ${orgUrl}`);
const authHandler = azdev.getPersonalAccessTokenHandler(token);
const connection = new azdev.WebApi(orgUrl, authHandler);
core.info(`Connected successfully to ${orgUrl}!`);
return connection.getWorkItemTrackingApi();
}
Notes:
- We first instantiate an
AuthenticationHandler
, in this case, a PAT handler, passing in the token - Then we instanticate the connection, passing in the org URL and the handler
- Finally, we get and return a
WorkItemTrackingClient
Creating a Work Item
Once we have a WorkItemTrackingClient
, we can manipulate work items in Azure DevOps. Let’s look at the method that creates a work item:
// file: 'work-item-functions.ts'
export async function createWorkItem(
token: string,
orgName: string,
project: string,
workItemInfo: IWorkItemInfo
): Promise<number> {
core.debug('Try get work item client');
const wiClient = await getWiClient(token, orgName);
core.debug('Got work item client');
const patchDoc = [
{op: 'add', path: '/fields/System.Title', value: workItemInfo.title},
{
op: 'add',
path: '/fields/System.Description',
value: workItemInfo.description
}
] as JsonPatchDocument;
if (workItemInfo.areaPath !== '') {
(patchDoc as any[]).push({
op: 'add',
path: '/fields/System.AreaPath',
value: workItemInfo.areaPath
});
}
if (workItemInfo.iterationPath !== '') {
(patchDoc as any[]).push({
op: 'add',
path: '/fields/System.IterationPath',
value: workItemInfo.iterationPath
});
}
core.debug('Calling create work item...');
const workItem = await wiClient.createWorkItem(
null,
patchDoc,
project,
workItemInfo.type
);
if (workItem?.id === undefined) {
throw new Error('Work item was not created');
}
return workItem.id;
}
Notes:
- First we get the
WorkItemTrackingClient
from the method we created earlier - Next, we construct a
JsonPatchDocument
which containsoperations
- in this case, alladd
s. This is how we create the field values for the new work item. - Finally, we call
createWorkItem
on theWorkItemTrackingClient
to create the work item. We then return theid
of the new work item.
Tying It All Together
When invoked, the Action will execute the main.ts
file, in which we just extract the input args and invoke the methods we created earlier:
// file: 'main.ts'
import * as core from '@actions/core';
import {createWorkItem} from './work-item-functions';
async function run(): Promise<void> {
try {
const token: string = core.getInput('token');
const orgName: string = core.getInput('orgName');
const project: string = core.getInput('project');
const type: string = core.getInput('type');
const title: string = core.getInput('title');
const description: string = core.getInput('description');
const areaPath: string = core.getInput('areaPath');
const iterationPath: string = core.getInput('iterationPath');
core.debug(`orgName: ${orgName}`);
core.debug(`project: ${project}`);
core.debug(`type: ${type}`);
core.debug(`title: ${title}`);
core.debug(`description: ${description}`);
core.debug(`areaPath: ${areaPath}`);
core.debug(`iterationPath: ${iterationPath}`);
core.debug('Creating new work item...');
const newId = await createWorkItem(token, orgName, project, {
type,
title,
description,
areaPath,
iterationPath
});
core.info(`Created work item [${title}] with id ${newId}`);
core.setOutput('workItemId', newId);
} catch (error) {
core.setFailed((error as any)?.message);
}
}
run();
Publishing the Action
I said that the Action invokes main.ts
- this isn’t entirely correct. If you look at the action.yml file, you’ll see the following metadata:
# file: 'action.yml'
runs:
using: 'node12'
main: 'dist/index.js'
You’ll see that the main
property is set to dist/index.js
. This file is generated by packaging the Action. This is set up for you already if you create the Action from the template repo like I did. Looking in pacakge.json in the scripts
section, we see the following:
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "eslint src/**/*.ts",
"package": "ncc build --source-map --license licenses.txt",
"all": "yarn run build && yarn run format && yarn run lint && yarn run package"
},
Note the all
script: it runs the build
script for transpiling, then runs format
and lint
for formatting and linting and finally runs the package
command. The package
command invokes ncc
to package the Action and make it ready for publication. After these commands have run, the TypeScript has been transpiled, formatted and linted into the dist
folder.
Note: For now, I’m running this manually before committing, but ideally I should have an Action that will run this command on
push
and commit the generatedjs
files in thedist
folder automatically - that way I’ll never be out of sync!
Creating a PR Boards Work Item
Let’s imagine that you want to create a work item whenever a PR is created. This is actually quite easy now that we have the az-create-work-item
Action:
# file: 'pr.yml'
on:
pull_request:
types: [opened]
jobs:
create-work-item:
runs-on: ubuntu-latest
steps:
- run: |
prNum=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
echo "::set-env name=prNum::$prNum"
displayName: Exract PR number
- uses: colindembovsky/az-create-work-item@v1.0.0
with:
token: ${{ secrets.AZDO_TOKEN }}
orgName: myorg
project: myproject
type: User Story
title: PR ${{ env.prNum }} in repository ${{ github.repository }}
description: '<div>A Pull Request was created <a href="https://github.com/${{ env.repository }}/pulls/${{ env.prNum }}">here</a>.</div>
Notes:
- You can specify “sub-events” for the
pull_request
trigger. In this case, we filter down to theopened
type. - We execute two steps: the first extracts the PR number from the
GITHUB_EVENT_PATH
metadata. - The second step invokes the
az-create-work-item
Action to create the work item. - For this to work, we need to provide an Azure DevOps PAT and we store it as a repository secret called
AZDO_TOKEN
.
Conclusion
Creating Azure DevOps work items by using the az-create-work-item
Action is fairly simple. Many teams are using both GitHub and Azure DevOps, and commonly this involves planning and tracking in Azure Boards. This Action allows teams to easily create work items in Azure Boards from Actions.
Happy building!