How to Automate Merge Requests with Node.js and Jira
A quick guide to speed up your MR or PR workflow with a simple Node.js script
Published on
Nov 30, 2024
Read time
7 min read
Introduction
At my company, we follow a strict workflow for merging code into our main branch. Every merge request should be associated with a Jira ticket (e.g. ABC-1234
). It should have a branch in the format feature/abc-1234-description
. And every title should follow the format Feature ABC-1234: Description
.
Then, we have to assign ourselves to the ticket, request reviews—usually from the same people—and add a label, which is usually the name of the team responsible for the feature.
To date, I have raised almost 2,000 work merge requests, mostly following this exact same process. If it takes me roughly a minute to do all of this manually, this means almost 33 hours of my life have been spent on this task alone. The time spent doing this by the whole team would be even more significant.
Let’s automate this!
This guide uses GitLab as the Git repository manager via the glab
CLI, but it should be straightforward to adapt it to GitHub using the gh
CLI.
Project Setup
Ensure you have Node.js installed, then create a new project and install the following dependencies:
npm init -y npm install axios dotenv shelljs simple-git
We'll use axios
to interact with the Jira API, dotenv
to load environment variables, shelljs
to run shell commands, and simple-git
to interact with Git.
We’ll use the glab
CLI to interact with GitLab. You can install it by following the instructions on the official website. The main installation command is via Homebrew:
brew install glab
If you’re using GitHub, you can use the gh
CLI instead. Install it by following the instructions here, or via Homebrew:
brew install gh
Environment Variables
Create a .env
file in the root of your project with the following variables:
JIRA_BASE_URL=https://example.atlassian.net JIRA_API_TOKEN= JIRA_USER_EMAIL=hello@example.com GITLAB_REMOTE=origin GITLAB_ASSIGNEE=your-username GITLAB_REVIEWERS=reviewer1,reviewer2 GITLAB_LABELS=label1,label2 DEFAULT_TICKET_CODE=ABC-
A lot of these variables are configuration values rather than secrets, but for a simple script like this, it’s easier to manage them all in one place.
Dependencies
Import the required dependencies at the top of your script.
const axios = require("axios");
const shell = require("shelljs");
const simpleGit = require("simple-git");
require("dotenv").config({
path: __dirname + "/.env",
});
We need to define the path to the .env
file using __dirname
because the script will be run from a different directory.
Arguments
We’ll pass the Jira ticket code as an arguments to the script. We’ll also add a default value for the ticket code, since most of my tickets start with the same code.
// Extract the ticket ID from the command-line arguments
const [, , inputTicketId] = process.argv;
// Ensure the ticket ID is provided
if (!inputTicketId) {
console.error("Error: You must provide a Jira ticket ID as an argument.");
process.exit(1);
}
// Convert the ticket ID to uppercase
let ticketId = inputTicketId.trim().toUpperCase();
// Prepend the default ticket code if necessary
if (!ticketId.includes("-")) {
const defaultTicketCode = process.env.DEFAULT_TICKET_CODE;
if (!defaultTicketCode) {
console.error(
"Error: DEFAULT_TICKET_CODE environment variable is not set."
);
process.exit(1);
}
ticketId = `${defaultTicketCode}${ticketId}`;
}
// Log the resolved ticket ID
console.info("Resolved Ticket ID:", ticketId);
So eventually, we can run the script like this:
node . ABC-1234
Or like this:
node . 1234
And the script will automatically convert it to ABC-1234
.
If the script is run without any arguments, or the default ticket code is missing, it will log an error message and exit.
Jira API
Next, we’ll create a helper function to fetch the Jira ticket title and type.
My company uses a slightly different naming convention from Jira for some of the ticket types, so here’s a quick mapping function to convert from Jira’s internal naming scheme to ours:
function mapIssueType(type) {
const issueTypeMap = {
task: "Feature",
"sub-task": "Task",
idea: "Spike",
story: "Feature",
bug: "Bugfix",
};
return issueTypeMap[type.toLowerCase()] || "Feature";
}
We can now fetch the ticket details using the Jira API.
async function fetchJiraTicket(ticketId) {
const url = `${process.env.JIRA_BASE_URL}/rest/api/2/issue/${ticketId}`;
const auth = Buffer.from(
`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`
).toString("base64");
try {
const response = await axios.get(url, {
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
},
});
return {
ticketTitle: response.data.fields.summary,
ticketType: mapIssueType(response.data.fields.issuetype.name),
};
} catch (error) {
console.error(
"Error fetching Jira ticket:",
error.response?.data || error.message
);
process.exit(1);
}
}
If this fails, we’ll also need to exit the script, since we can’t proceed without these details.
Branch Name
The last helper function we need is to generate the branch name based on the ticket information.
function formatBranchName(ticketId, title, type) {
const sanitizedTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-$/, "");
return `${type.toLowerCase()}/${ticketId.toLowerCase()}-${sanitizedTitle}`;
}
Let’s use the helpers we’ve created in a main
function.
async function main() {
// Set the working directory to the current execution directory
const workingDir = process.cwd();
console.info(`Working directory: ${workingDir}\n`);
// Fetch Jira ticket details
const { ticketTitle, ticketType } = await fetchJiraTicket(ticketId);
console.info(`Fetched Jira ticket: ${ticketId}`);
console.info(`Title: "${ticketTitle}"`);
console.info(`Type: "${ticketType}"\n`);
// Format branch name and commit message
const branchName = formatBranchName(ticketId, ticketTitle, ticketType);
const commitMessage = `${ticketType} ${ticketId.toUpperCase()}: ${ticketTitle}`;
console.info(`Branch name: ${branchName}`);
console.info(`Commit message: ${commitMessage}\n`);
}
main();
Note that we’re setting the working directory to the current execution directory. This is important because we’ll be running this script from different directories, and we want to make sure we’re always in the right place.
Git Commands
We’re ready to use the simple-git
library to create a new branch and commit the changes. If a particular Git command fails, we’ll log an error message, but we won’t exit the script, since we maybe we have already created the branch or committed the changes and we still want to proceed.
// Initialize Git in the current working directory
const git = simpleGit({ baseDir: workingDir });
// Create and switch to the new branch
try {
const branches = await git.branchLocal();
if (branches.all.includes(branchName)) {
console.info(`Branch "${branchName}" already exists. Checking it out...`);
await git.checkout(branchName);
} else {
console.info(`Branch "${branchName}" does not exist. Creating it...`);
await git.checkoutLocalBranch(branchName);
}
} catch (error) {
console.error("Error checking or creating branch:", error.message);
}
// Commit changes
try {
await git.commit(commitMessage);
console.info(`Committed changes: ${commitMessage}`);
} catch (error) {
console.error("Error committing changes:", error.message);
}
// Push branch to GitLab
try {
await git.push(process.env.GITLAB_REMOTE, branchName);
console.info(`Pushed branch to GitLab: ${branchName}`);
} catch (error) {
console.error("Error pushing branch:", error.message);
}
The three main steps are:
- Check if the branch already exists. If it does, check it out. Otherwise, create it.
- Commit the changes with the commit message we generated.
- Push the branch to GitLab.
GitLab Commands
Finally, we’ll use the glab
CLI to assign the ticket to ourselves, request reviews, and add labels.
We need to turn our comma-separated list of reviewers and labels into arrays.
const reviewersOption = process.env.GITLAB_REVIEWERS?.split(",")
.map((r) => `--reviewer ${r}`)
.join(" ");
const labelsOption = process.env.GITLAB_LABELS?.split(",")
.map((label) => `--label "${label}"`)
.join(" ");
Then, all that’s left is to run the glab
commands.
const mrCommand = `glab mr create --fill --target-branch develop --title "${commitMessage}" --assignee ${process.env.GITLAB_ASSIGNEE} ${reviewersOption} ${labelsOption} --web`;
console.info(mrCommand);
if (shell.exec(mrCommand).code !== 0) {
console.error("Error creating merge request.");
process.exit(1);
}
console.info("Merge request created successfully!");
The --fill
option will automatically fill in the merge request description with the commit message. The --web
option will open the merge request in your browser, so you can add any additional details or changes.
If you are using GitHub, you can replace the glab
command with a gh
command—making sure to adjust the options accordingly!
That’s everything we need for the script, so here’s the full code:
const axios = require("axios");
const shell = require("shelljs");
const simpleGit = require("simple-git");
require("dotenv").config({
path: __dirname + "/.env",
});
// Input: Jira Ticket ID
// Extract the ticket ID from the command-line arguments
const [, , inputTicketId] = process.argv;
// Ensure the ticket ID is provided
if (!inputTicketId) {
console.error("Error: You must provide a Jira ticket ID as an argument.");
process.exit(1);
}
// Convert the ticket ID to uppercase
let ticketId = inputTicketId.trim().toUpperCase();
// Prepend the default ticket code if necessary
if (!ticketId.includes("-")) {
const defaultTicketCode = process.env.DEFAULT_TICKET_CODE;
if (!defaultTicketCode) {
console.error(
"Error: DEFAULT_TICKET_CODE environment variable is not set."
);
process.exit(1);
}
ticketId = `${defaultTicketCode}${ticketId}`;
}
// Helper function to fetch Jira ticket details
async function fetchJiraTicket(ticketId) {
const url = `${process.env.JIRA_BASE_URL}/rest/api/2/issue/${ticketId}`;
const auth = Buffer.from(
`${process.env.JIRA_USER_EMAIL}:${process.env.JIRA_API_TOKEN}`
).toString("base64");
try {
const response = await axios.get(url, {
headers: {
Authorization: `Basic ${auth}`,
"Content-Type": "application/json",
},
});
return {
ticketTitle: response.data.fields.summary,
ticketType: mapIssueType(response.data.fields.issuetype.name),
};
} catch (error) {
console.error(
"Error fetching Jira ticket:",
error.response?.data || error.message
);
process.exit(1);
}
}
function mapIssueType(type) {
const issueTypeMap = {
task: "Feature",
"sub-task": "Task",
idea: "Spike",
story: "Feature",
bug: "Bugfix",
};
return issueTypeMap[type.toLowerCase()] || "Feature";
}
// Helper function to format branch name
function formatBranchName(ticketId, title, type) {
const sanitizedTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-$/, "");
return `${type.toLowerCase()}/${ticketId.toLowerCase()}-${sanitizedTitle}`;
}
// Main function
async function main() {
// Set the working directory to the current execution directory
const workingDir = process.cwd();
console.info(`Working directory: ${workingDir}\n`);
// Fetch Jira ticket details
const { ticketTitle, ticketType } = await fetchJiraTicket(ticketId);
console.info(`Fetched Jira ticket: ${ticketId}`);
console.info(`Title: "${ticketTitle}"`);
console.info(`Type: "${ticketType}"\n`);
// Format branch name and commit message
const branchName = formatBranchName(ticketId, ticketTitle, ticketType);
const commitMessage = `${ticketType} ${ticketId.toUpperCase()}: ${ticketTitle}`;
console.info(`Branch name: ${branchName}`);
console.info(`Commit message: ${commitMessage}\n`);
// Initialize Git in the current working directory
const git = simpleGit({ baseDir: workingDir });
// Create and switch to the new branch
try {
const branches = await git.branchLocal();
if (branches.all.includes(branchName)) {
console.info(`Branch "${branchName}" already exists. Checking it out...`);
await git.checkout(branchName);
} else {
console.info(`Branch "${branchName}" does not exist. Creating it...`);
await git.checkoutLocalBranch(branchName);
}
} catch (error) {
console.error("Error checking or creating branch:", error.message);
}
// Commit changes
try {
await git.commit(commitMessage);
console.info(`Committed changes: ${commitMessage}`);
} catch (error) {
console.error("Error committing changes:", error.message);
}
// Push branch to GitLab
try {
await git.push(process.env.GITLAB_REMOTE, branchName);
console.info(`Pushed branch to GitLab: ${branchName}`);
} catch (error) {
console.error("Error pushing branch:", error.message);
}
// Create a merge request using `glab`
const reviewersOption = process.env.GITLAB_REVIEWERS.split(",")
.map((r) => `--reviewer ${r}`)
.join(" ");
const labelsOption = process.env.GITLAB_LABELS.split(",")
.map((label) => `--label "${label}"`)
.join(" ");
const mrCommand = `glab mr create --fill --target-branch develop --title "${commitMessage}" --assignee ${process.env.GITLAB_ASSIGNEE} ${reviewersOption} ${labelsOption} --web`;
console.info(mrCommand);
if (shell.exec(mrCommand).code !== 0) {
console.error("Error creating merge request.");
process.exit(1);
}
console.info("Merge request created successfully!");
}
// Run the script
main();
Making the Script Executable
Finally, make the script executable by running the following script:
chmod +x ~/scripts/my-project-name/index.js
I use Zsh as my shell, so I created a function in my .zshrc
file to run the script from anywhere:
mr() { node ~/scripts/my-project-name/index.js "$@" }
Now I can run the script from anywhere in my terminal:
mr ABC-1234
Or even without the ticket code:
mr 1234
And that’s it! You’ve automated the process of creating a new branch, committing changes, and creating a merge request with GitLab or GitHub.
If you want to take it a step further, you could add the ability to do a dry run, where the script would output the commands it would run without actually executing. Or consider combining with AI to add descriptions to your merge requests automatically.
I hope you found this guide helpful—and enjoy the time you’ve saved!
Related articles
You might also enjoy...
Automate Your Release Notes with AI
How to save time every week using GitLab, OpenAI, and Node.js
11 min read
How to Create a Super Minimal MDX Writing Experience
Learn to create a custom MDX experience so you can focus on writing without worrying about boilerplate or repetition
12 min read
I Fixed Error Handling in JavaScript
How to steal better strategies from Rust and Go—and enforce them with ESLint
14 min read