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
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.
- Login and navigate to
https://id.atlassian.com/manage-profile/security/api-tokens
- Click Create API token
- You will be prompted to give the token a label, this is up to you. Give it a label and click Create
- Your token will be presented next, make sure to COPY IT AND PLACE IT SOMEWHERE SAFE BEFORE CLOSING THE MODAL!
- 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
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);
});
}
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
*/
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
}
}
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);
}
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;
}
}
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) {
}
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 = {};
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;
}
}
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;
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;
}
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`)
}
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);
}
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);
});
}
Top comments (0)