DEV Community

Aris Husanu
Aris Husanu

Posted on

Tracking Personal Velocity: An Intro to the Jira API

Intro

Performance Review season, it comes every year without fail. I often find myself attempting to come up with smart goals, and usually fall back to something along the lines of increase my velocity. This year I really wanted to get some qualitative insights into my velocity, so I could make a Specific, Measurable, Achievable, Relevant, and Time-Bound goal (smart, huh?). In this post I will walk through the process I created, along with a project set up idea using the Jira REST API.

Requirements

  • A Jira Cloud account
  • Node

Project Setup

Step one is to create a folder to house our project.

mkdir personal-velocity

When finished the directory should look like:

personal-velocity/
├── script.js
├── TOKEN
Enter fullscreen mode Exit fullscreen mode

API TOKEN

Next we need to create a Jira API Token. This token will allow us to access our Jira accounts from a script without using our password. The key here is when we go to our token, we have to WRITE IT DOWN. Jira will only show it once. After that, it is inaccessible.

  1. Login and navigate to https://id.atlassian.com/manage-profile/security/api-tokens
  2. Click Create API token Click Create API token
  3. You will be prompted to give the token a label, this is up to you. Give it a label and click Create
  4. Your token will be presented next, make sure to COPY IT AND PLACE IT SOMEWHERE SAFE BEFORE CLOSING THE MODAL! Copy your token
  5. Now that you have the token saved, create a new file at the root of the project folder called TOKEN, and paste the token into it.
cd personal-velocity
touch TOKEN
Enter fullscreen mode Exit fullscreen mode

Setup Node script

In the project root, create a new file called script.js
touch script.js

Add the following to the script:

const fs = require('fs');
const path = require('path');

async function main() {
    const token = fs.readFileSync(path.join(__dirname, 'TOKEN'), 'utf-8');
    console.log(token);
}

if (module === require.main) {
    main().then(_ => {
        console.log('Script finished successfully');
    })
    .catch(e => {
        console.error('Script failed', e);
    });
}
Enter fullscreen mode Exit fullscreen mode

You can run this with the following:

node script.js

It will print out your token.

Type data structures

Lets take a detour from the logic and fill in some types that we expect the data to be in. We'll use JSDOC so a transpiler is not needed.

Add the following snippet to the top of the script, just below the imports

/**
* @typedef IssueFields
* @prop {number} customfield_10100 Story Points
* @prop {string} summary Jira Summary
* @prop {{name: string}[]} customfield_10007 List of Sprints
*
* @typedef Issue
* @prop {IssueFields} fields Object of Fields
* @prop {string} key Jira ID
*/
Enter fullscreen mode Exit fullscreen mode

The first type is an object that holds all the meat of a Jira issue, the summary, story points, and the sprints that the story was open in.

NOTE: story points and sprints are custom fields, so check with your Jira instance to make sure you have the correct names.

The next type is the wrapper for the Jira issue. It contains the Jira ID (IE PROJ-123) and the object that we typed above called fields

Fetch Data

Next, use the token to fetch the data from the Jira API. Lets create a getData function. We will get the list of Storys and Spikes that have been completed and are assigned to the current user (the one who made the token).
For this we will use the /search request which you can find more info on here

The request is formatted like so:

<https://[YOUR> DOMAIN].atlassian.net/rest/api/3/search?jql=[JQL QUERY]

Make sure to sub in your Jira subdomain.

We can use any JQL statement that is valid in the web UI! (as long as it's url encoded).
We can also specify some filters. In our case, the fields (Summary, Sprints, and Story points), the issues types, such as Story and Spike, and the status. We will focus solely on completed issues, and of course, the assignee!

The Jira API tends to limit the results so we will include some logic to check if the total number of issues is higher than the issues we received. We will also add startAt param, so that we can page through, and request all of the results.

/**
 * @param {string} email Jira Email address
 * @param {string} token Jira API Token
 * @returns {Promise<Issue[]>}
*/

async function getData(email, token, startAt) {
    const buffer = Buffer.from(`${email}:${token}`);
    const authStr = buffer.toString('base64');
    const options = {
        headers: {
            'Content-Type': 'application/json',
            Authorization: `Basic ${authStr}`
        }
    };
    const reqPromise = new Promise(((resolve, reject) => {
        try {
            let data = [];
            var req = https.get('<https://COMPANY-DOMAIN.atlassian.net/rest/api/3/search'>
                + '?fields=customfield_10100,customfield_10007,summary'  // select fields
                + '&jql=type%20IN%20%28%22Story%22%2C%22Spike%22%29%20%20' // Story and spike
                    + 'AND%20status%20IN%20%28%22Closed%22%2C%22Completed%22%29%20' // status completed/closed
                    + 'AND%20assignee%20%3D%20currentUser%28%29%20'  // current user
                    + 'ORDER%20BY%20created%20DESC'
                + '&startAt=' + startAt
                +'&maxResults=100', options, (res) => {
                res.on('data', (d) => {
                    data.push(d);
                });
                res.on('end', () => {
                    resolve(JSON.parse(Buffer.concat(data).toString()));
                });
                res.on('error', (err) => {
                    reject(err);
                });
            });
            req.on('error', (e) => {
                req.end();
                reject(e);
            });
            req.on('end', () => {
                const d = JSON.parse(Buffer.concat(data).toString());
                console.warn(e);
                resolve(d);
            })
        } catch (e) {
            req.end();
            reject(e);
        }
    }));
    try {
        const data = await reqPromise;
        /** @type Issue[] */
        const issues = data.issues;
        const hasMore = (startAt + issues.length) < data.total;
        return {issues, hasMore};
    } catch (e) {
        console.warn(e);
        throw e
    }
}
Enter fullscreen mode Exit fullscreen mode

We are using the node http API so the following needs to be added to the imports:

const https = require('https');

The fetch API could greatly simplify this function, but http keeps this script compatible with older version of node.

From the main menu we can call our new function and see what the data looks like

async function main() {
    const token = fs.readFileSync(path.join(__dirname, 'TOKEN'), 'utf-8');
    const data = await getData('first.last@company.com', token, 0);  // use your Jira email
    console.log(data);
}
Enter fullscreen mode Exit fullscreen mode

Run the script like above with node script.js

If the data looks good, lets make sure we get all of the pages of data by updating our main function to:

async function main() {
    const email = 'first.last@company.com';
    const token = fs.readFileSync(path.join(__dirname, 'TOKEN'), 'utf-8');
    let hasMore = true;
    let startAt = 0;
    let issues = [];
    while (hasMore) {
        const data = await getData(email, token, startAt);
        issues = issues.concat(data.issues);
        hasMore = data.hasMore;
        startAt += data.issues.length;
    }
}
Enter fullscreen mode Exit fullscreen mode

Summarize the data

The first step in summarizing the data is coming up with a key that represents a year to attribute the sprint to. The payload returns data on the sprint, including the name. Teams that I have been on name their sprints as follows:

<TEAM> <YEAR> <SPRINT_NUMBER> <DATE_RANGE>

I will use a regex to pull the year out of the name, like so:

const re = /.*(20\\d\\d)/;

This regex wil find and capture a string that starts with 20 and contains 2 digits following.

This regex will remain the same and it's best to only initialize once so we will define it at the top of the script.

Next, define a summarize function as follows:

/**
*
* @param {Issue[]} issues
*/
function summarize(issues) {

}
Enter fullscreen mode Exit fullscreen mode

Add some local variables to keep track of data as we run through the list of issues

/**@type Set<string> */
const uniqueSprints = new Set();
/**@type object.<string, number> */
const yearToTotalPoints = {};
/**@type object.<string, number> */
const yearToNumSprints = {};
Enter fullscreen mode Exit fullscreen mode

Now lets loop through each issue, and skip through any that don't have points or are not associated with any sprint

for (const story of issues) {
    if (!story.fields.customfield_10100 || !story.fields.customfield_10007) {
        continue;
    }
}
Enter fullscreen mode Exit fullscreen mode

REMEMBER: These field names should match the custom field names for story points and sprint

While still looping through the issues, lets add all associated sprints into our set of sprints. We will also extract the year from the last sprint that the story was associated with and add the points if this story to the running total for that year.

    for (sprint of story.fields.customfield_10007) {
        uniqueSprints.add(sprint.name);
    }


    const match = re.exec(story.fields.customfield_10007[story.fields.customfield_10007.length - 1].name);
    const year = match[1];
    const total = yearToTotalPoints[year] || 0;
    yearToTotalPoints[year] = total + story.fields.customfield_10100;
Enter fullscreen mode Exit fullscreen mode

Now we have a mapping of year to the number of story points completed in that year, as well as a set of sprint names. Since it’s a set, it can be iterated over as a unique list. Lets do that:

for (const sprint of uniqueSprints) {
    const match = re.exec(sprint);
    const year = match[1];
    yearToNumSprints[year] = (yearToNumSprints[year] || 0) + 1;
}
Enter fullscreen mode Exit fullscreen mode

We iterate the list of unique sprints, and for each, we again use our regex to retrieve the year that sprint took place. When we are done we will be able to see which sprints, or more importantly how many sprints were in each year.

Finally we can loop through the years in our mappings and we will be able to display:

  • The year
  • The number of sprints
  • The number of story points completed
  • The average story points per sprint, with some simple division
for (const year of Object.keys(yearToTotalPoints)) {
    console.log(`${year} - ${yearToTotalPoints[year]} points in ${yearToNumSprints[year]} sprints -- ${yearToTotalPoints[year] / yearToNumSprints[year]} points per sprint`)
}
Enter fullscreen mode Exit fullscreen mode

Now summarize() is finished, lets add that last piece of the puzzle to our main function:

async function main() {
    const email = 'first.last@company.com';
    const token = fs.readFileSync(path.join(__dirname, 'TOKEN'), 'utf-8');
    let hasMore = true;
    let startAt = 0;
    let issues = [];
    while (hasMore) {
        const data = await getData(email, token, startAt);
        issues = issues.concat(data.issues);
        hasMore = data.hasMore;
        startAt += data.issues.length;
    }
    await summarize(issues);
}
Enter fullscreen mode Exit fullscreen mode

Make sure to use the email associated with your Jira account, run node script.js
And boom, you can see your personal velocity per year!

TLDR;

  • Obtain an API token from Jira, put it in a file called TOKEN next to the script
  • Create a regex to grab the year form the sprint names, put it at the top level called re
  • Find the names of the custom fields for Sprints and Story points, sub them in in the script
  • Sub your company domain and email address in the script

Complete script:

const fs = require('fs');
const path = require('path');
const https = require('https');

const re = /.*(20\d\d)/;


/**
 * @typedef IssueFields
 * @prop {number} customfield_10100 Story Points
 * @prop {string} summary Jira Summary
 * @prop {{name: string}[]} customfield_10007 List of Sprints
 *
 * @typedef Issue
 * @prop {IssueFields} fields Object of Fields
 * @prop {string} key Jira ID
*/

async function main() {
    const email = 'first.last@company.com';
    const token = fs.readFileSync(path.join(__dirname, 'TOKEN'), 'utf-8');
    let hasMore = true;
    let startAt = 0;
    let issues = [];
    while (hasMore) {
        const data = await getData(email, token, startAt);
        issues = issues.concat(data.issues);
        hasMore = data.hasMore;
        startAt += data.issues.length;
    }
    await summarize(issues);
}

/**
* @param {string} email Jira Email address
* @param {string} token Jira API Token
* @returns {Promise<Issue[]>}
*/
async function getData(email, token, startAt) {
    const buffer = Buffer.from(`${email}:${token}`);
    const authStr = buffer.toString('base64');
    const options = {
        headers: {
            'Content-Type': 'application/json',
            Authorization: `Basic ${authStr}`
        }
    };
    const reqPromise = new Promise(((resolve, reject) => {
        try {
            let data = [];
            var req = https.get('<https://COMPANY-DOMAIN.atlassian.net/rest/api/3/search'>
                + '?fields=customfield_10100,customfield_10007,summary'  // select fields
                + '&jql=type%20IN%20%28%22Story%22%2C%22Spike%22%29%20%20' // Story and spike
                + 'AND%20status%20IN%20%28%22Closed%22%2C%22Completed%22%29%20' // status completed/closed
                + 'AND%20assignee%20%3D%20currentUser%28%29%20'  // current user                + 'ORDER%20BY%20created%20DESC'
                + '&startAt=' + startAt
                + '&maxResults=100', options, (res) => {
                    res.on('data', (d) => {
                        data.push(d);
                    });
                    res.on('end', () => {
                        resolve(JSON.parse(Buffer.concat(data).toString()));
                    });
                    res.on('error', (err) => {
                        reject(err);
                    });
                });
            req.on('error', (e) => {
                req.end();
                reject(e);
            });
            req.on('end', () => {
                const d = JSON.parse(Buffer.concat(data).toString());
                console.warn(e);
                resolve(d);
            })
        } catch (e) {
            // req.end();
            reject(e);
        }
    }));
    try {
        const data = await reqPromise;
        /** @type Issue[] */
        const issues = data.issues;
        const hasMore = (startAt + issues.length) < data.total;
        return { issues, hasMore };
    } catch (e) {
        console.warn(e);
        throw e;
    }
}

/**
*
* @param {Issue[]} issues
*/
function summarize(issues) {
    /**@type Set<string> */
    const uniqueSprints = new Set();
    /**@type object.<string, number> */
    const yearToTotalPoints = {};
    /**@type object.<string, number> */
    const yearToNumSprints = {};

    for (const story of issues) {
        if (!story.fields.customfield_10100 || !story.fields.customfield_10007) {
            continue;
        }

        for (sprint of story.fields.customfield_10007) {
            uniqueSprints.add(sprint.name);
        }


        const match = re.exec(story.fields.customfield_10007[story.fields.customfield_10007.length - 1].name);
        const year = match[1];
        const total = yearToTotalPoints[year] || 0;
        yearToTotalPoints[year] = total + story.fields.customfield_10100;
    }

    for (const sprint of uniqueSprints) {
        const match = re.exec(sprint);
        const year = match[1];
        yearToNumSprints[year] = (yearToNumSprints[year] || 0) + 1;
    }

    for (const year of Object.keys(yearToTotalPoints)) {
        console.log(`${year} - ${yearToTotalPoints[year]} points in ${yearToNumSprints[year]} sprints -- ${yearToTotalPoints[year] / yearToNumSprints[year]} points per sprint`)
    }
}



if (module === require.main) {
    main().then(_ => {
        console.log('Script finished successfully');
    }).catch(e => {
        console.error('Script failed', e);
    });
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)