Automate Your Release Notes with AI
How to save time every week using GitLab, OpenAI, and Node.js
Published on
Nov 16, 2024
Read time
11 min read
Introduction
Writing release notes is a weekly ritual for my team and me. While it’s a great way to reflect on our progress, aspects of the process can feel repetitive and time-consuming. So, I decided to try automating it using AI.
The result? A smoother workflow and a time savings of 15-20 minutes each week – an improvement that may seem small, but that compounds into hours saved over time. I still check and tweak the notes manually, but now it’s a quicker and more enjoyable job, focused on where I can add value.
In this article, I’ll show you how we implemented this solution for GitLab. Whether you use GitLab, GitHub, or another development platform, you can adapt the process to fit your tools. For the AI component, I’ll demonstrate using OpenAI’s API, and we’ll build the solution with Node.js and TypeScript — my go-to stack for development.
Setting up the project
Let’s start a new npm project with npm init and then install the following dependencies:
npm i date-fns dotenv openai npm i --save-dev ts-node-dev typescript
I'm trying to be minimal with my web dependencies, so aside from openai
and typescript
, which are necessary for this tech stack, I've chosen only a few extras:
- We'll need some date logic that would be much harder to roll by hand, so
date-fns
will save us time. dotenv
is pretty-much the industry standard for managing environment variables, so we'll grab that too.- And
ts-node-dev
provides a very convenient way to develop a TypeScript project.
Next, let’s add a few standard scripts to our package.json file:
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"develop": "ts-node-dev src/index.ts",
"develop:watch": "ts-node-dev --respawn src/index.ts"
}
For TypeScript support, I’ll reach for a minimal tsconfig.json file that’s a go-to for many of my TypeScript side projects:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
And here are the environment variables we'll need inside our .env
file:
GITLAB_TOKEN= OPEN_AI_ORG= OPEN_AI_KEY=
Finally, let's add a main function inside src/index.ts
and call it with an argument of process.argv.slice(2)
which will allow us to send arguments from the terminal:
dotenv.config();
const main = async (args: string[] = []): Promise<void> => {
console.log("Running!)
};
main(process.argv.slice(2));
That’s everything for the setup, so let’s get coding!
Supporting command-line arguments
I’ll start by defining the arguments we’ll want from the user. For my case, these are:
label
—the label for a particular merge request (MR) in GitLab. At my company, each team has their own label, so that makes it easier to group release notes by team.day
andtime
—we need to know the date after which we should include MRs for our release notes. I am typically getting MRs merged after a particular release that I know happened to, say, Wednesday at 4pm. So it'd be easier for me to provide--day=wed --t=16:00
than to work out the exact date myself. (Note that, if you create release notes less often than weekly, this won't work, and you'll need to support a way of adding a specific date.)context
—some additional instructions for the AI prompt. I found it useful to guide the AI on which groups the release notes should be organised into.
Let’s make a helper to extract the arguments from the args array. We could use a library for this, but it’s straightforward to come up with our own function.
Sometimes it's useful to start with how we want to call our function, before we actually write it. In this case, I want to easily extract the arguments above (or a one-letter shorthand, such as l
for label
), and I want to provide an optional fallback value. Something like this:
export type DayName = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
const label = getArgumentValue(args, ["label", "l"]);
const day = getArgumentValue<DayName>(args, ["day", "d"], "wed");
const time = getArgumentValue(args, ["time", "t"], "16:00");
const context = getArgumentValue(args, ["context", "c"]);
Here's a function that provides exactly that. Note the use of the generic type T
which allows us to provide more specific types for things like DayName .
export function getArgumentValue<T extends string>(
args: string[],
keys: string | string[],
fallback: string = ""
): T {
const keysArray = Array.isArray(keys) ? keys : [keys];
return (args
.find((arg) => {
for (const key of keysArray) {
const found = arg.startsWith(`--${key}=`);
if (found) {
return true;
}
}
})
?.split("=")[1] || fallback) as T;
}
Finding the correct date
Our day and time arguments are convenient for users, but it’ll take some logic to convert those into a date we can add to our fetch request. Let’s create a helper to turn the provided day and time into a full Date that we can use in our fetch request to GitLab.
import { subDays, set, isAfter } from "date-fns";
import { DayName } from "../types";
export function getMostRecentDateByDayName(
dayName: DayName,
time: string,
date: Date = new Date()
) {
const normalisedDayName = dayName.trim().slice(0, 3).toLowerCase() as DayName;
const daysOfWeek: Record<DayName, number> = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
};
const targetDay = daysOfWeek[normalisedDayName];
if (targetDay === undefined) {
throw new Error("Invalid day name provided");
}
// Parse the provided time string (e.g., "16:00") and set it to the current date
const [hours, minutes = 0] = time.split(":").map(Number);
if (isNaN(hours) || isNaN(minutes)) {
throw new Error("Invalid time format provided");
}
const dateAtGivenTime = set(date, {
hours,
minutes,
seconds: 0,
milliseconds: 0,
});
// If the current time is before the given time, move the date back by one day
if (!isAfter(date, dateAtGivenTime)) {
date = subDays(date, 1);
}
// Find the most recent target day from the adjusted date
const currentDay = date.getUTCDay();
const daysSinceTarget = (currentDay - targetDay + 7) % 7; // Ensures positive modulo result
const mostRecentDay = subDays(date, daysSinceTarget);
return set(mostRecentDay, {
hours,
minutes,
seconds: 0,
milliseconds: 0,
});
}
First, we normalise the provided day name – trimming off any spaces, slicing the first three characters, and converting them to lowercase. We check that the day is valid, then split the time into hours and minutes and check those are valid too. Lastly, we work out the most recent datetime in the past when it was the given day and time.
Here's a look at our main function with the latest changes, plus a console.info
log to help us check that we've got the right day.
import { getArgumentValue, getMostRecentDateByDayName } from "./helpers";
import { format } from "date-fns";
import { DayName } from "./types";
import dotenv from "dotenv";
dotenv.config();
const main = async (args: string[] = []): Promise<void> => {
const label = getArgumentValue(args, ["label", "l"]);
const day = getArgumentValue<DayName>(args, ["day", "d"], "Wednesday");
const time = getArgumentValue(args, ["time", "t"], "16:00");
const context = getArgumentValue(args, ["context", "c"]);
const updatedAfter = getMostRecentDateByDayName(day, time);
console.info(
`\n\nFetching merge requests for label "${label}" merged after ${format(
updatedAfter,
"EEEE do MMMM 'at' h:mma"
)}...`
);
};
main(process.argv.slice(2));
It’s time to fetch the data from GitLab!
Fetching the merge requests from GitLab
I'm imagining that this project could be extended, so instead of creating more helper functions, let's create a dedicated GitLabService
class. Since I'm trying to avoid using an HTTP library, instead we can create a quick fetchJson
method that uses the Node.js fetch API.
import { GitLabMergeRequestResponse } from "../../types";
import { isAfter } from "date-fns";
export class GitLabService {
private async fetchJson<T>(url: string, options: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`Request failed with status code ${response.status}`);
}
return response.json();
}
public async getMergeRequests({
label,
updatedAfter,
}: {
label: string;
updatedAfter: Date;
}): Promise<GitLabMergeRequestResponse[]> {
const url = `https://gitlab.com/api/v4/groups/yu-life/merge_requests?scope=all&state=merged&labels=${encodeURIComponent(
label
)}&updated_after=${updatedAfter?.toISOString()}`;
const options: RequestInit = {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.GITLAB_TOKEN}`,
},
};
const data = await this.fetchJson<GitLabMergeRequestResponse[]>(
url,
options
);
return (
data
.map((mr) => ({
title: mr.title,
description: mr.description,
created_at: mr.created_at,
updated_at: mr.updated_at,
merged_at: mr.merged_at,
source_branch: mr.source_branch,
labels: mr.labels,
web_url: mr.web_url,
author: mr.author,
}))
// additional filter by merged_at, as we can only filter by updated_after in the GET request
.filter((mr) => isAfter(new Date(mr.merged_at), updatedAfter))
);
}
}
Once I had worked out what the query was returning, I could also create a GitLabMergeRequestResponse
type, including only the fields that are useful for our purpose.
export interface GitLabMergeRequestResponse {
title: string;
description: string;
created_at: string;
updated_at: string;
merged_at: string;
source_branch: string;
labels: string[];
web_url: string;
author: {
id: number;
username: string;
name: string;
state: string;
locked: boolean;
avatar_url: string;
web_url: string;
};
}
We'll soon create a service for OpenAI, so let's organise these in a Services
class, where we can create a single instance of each one:
import { GitLabService } from "./gitLab";
import dotenv from "dotenv";
dotenv.config();
class Services {
public gitLab: GitLabService;
constructor() {
this.gitLab = new GitLabService();
}
}
export const services = new Services();
Back in main , we can test this function by adding a way to log the fetched MRs to the console.
import { services } from "./services";
import { getArgumentValue, getMostRecentDateByDayName } from "./helpers";
import { format } from "date-fns";
import { DayName } from "./types";
import dotenv from "dotenv";
dotenv.config();
const main = async (args: string[] = []): Promise<void> => {
const isVerboseMode = process.env.VERBOSE === "true";
const label = getArgumentValue(args, ["label", "l"]);
const day = getArgumentValue<DayName>(args, ["day", "d"], "Wednesday");
const time = getArgumentValue(args, ["time", "t"], "16:00");
const context = getArgumentValue(args, ["context", "c"]);
const updatedAfter = getMostRecentDateByDayName(day, time);
console.info(
`\n\nFetching merge requests for label "${label}" merged after ${format(
updatedAfter,
"EEEE do MMMM 'at' h:mma"
)}...`
);
if (context && isVerboseMode) {
console.log(`The AI will be instructed: "${context}"`);
}
const mergeRequests = await services.gitLab.getMergeRequests({
label,
updatedAfter,
});
console.info(`Sending ${mergeRequests.length} merge requests to OpenAI...\n`);
let i = 1;
for (const mergeRequest of mergeRequests) {
console.info(`${i}. ${mergeRequest.title}`);
console.info(
` By ${mergeRequest.author.name} (${
mergeRequest.author.username
}) - merged at ${format(
new Date(mergeRequest.merged_at),
"HH:mm:ss 'on' yyyy-MM-dd"
)}`
);
console.info(` ${mergeRequest.web_url}\n`);
i++;
}
};
main(process.argv.slice(2));
If everything’s working, you should see a list of relevant MRs, with useful information like the title and description that the AI can use to get a better understanding of the work!
Streaming a response from OpenAI
We can now work on sending the MRs to OpenAI and processing the response stream. We don’t want to waste AI tokens by providing unnecessary fields, so let’s create a new type for only the subset of fields that are important.
export type PickedGitLabMergeRequest = Pick<
GitLabMergeRequestResponse,
"title" | "description";
Then, let’s create a service to handle the AI functionality.
import path from "path";
import fs from "fs";
import OpenAI from "openai";
import { PickedGitLabMergeRequest } from "../../types";
export class OpenAIService {
private openai: OpenAI;
constructor() {
this.openai = new OpenAI({
apiKey: process.env.OPEN_AI_KEY,
organization: process.env.OPEN_AI_ORG,
});
}
public async generateReleaseNotesStream(
mergeRequests: PickedGitLabMergeRequest[],
label: string,
updatedAfter: Date,
context: string = ""
) {
// TODO
}
}
Before filling in the generateReleaseNotesStream
, let's create a prompt. These prompts can become quite long and messy, so I prefer to organise them in their own files. Here's what I'm using right now:
import { PickedGitLabMergeRequest } from "../../types";
export const getReleaseNotesPrompt = (
mergeRequests: PickedGitLabMergeRequest[],
context: string = ""
) => `You are a helpful assistant designed to turn GitLab merge requests into concise, readable release notes suitable for a general audience. Each release note should:
- Be a single bullet point, at most one sentence long.
- Be concise and easy to understand, avoiding overly formal language, technical jargon, or excessive details.
- Start with an imperative verb (e.g. "Add", "Fix", "Update", "Remove") that begins with a capital letter but without any special styling.
- Begin with a relevant and unique emoji that matches the type of work done (e.g. 🐛 for bug fixes, ✨ for features, 🛠️ for chores). Use a variety of emojis. Once used, an emoji should NOT be repeated.
- Use British English spelling and grammar.
- End with a full stop.
Each release note should emphasise key updates and improvements. Use the provided merge request data to create clear, reader-friendly release notes. Do not expand acronyms or abbreviations if you are not sure what they mean.
Please organise the release notes in categories.
The end result will be pasted into Slack, which uses a restricted subset of Markdown, so use a format that Slack can read. For example, use:
- Single backticks for inline code
- Single asterisks for bold text (NOT double asterisks)
${
context
? "Here is some additional context from the user: \n\n" + context + "\n\n"
: ""
}
Here are the merge requests for this release in JSON format:
${JSON.stringify(mergeRequests)}
`;
I recommend that you adapt this to your needs. We post our release notes into Slack, so some of the prompt is dedicated to explaining the Slack formatting. Many of you will want to remove this line: "Use British English spelling and grammar". Likewise, the emojis might be a bit too informal for you, but they're a must for us!
Now we can build out the generateReleaseNotesStream
method. I went for streaming simply because it feels nicer and more responsive to use, but it does add a little complexity to the code. If you're happy with a short wait, you can go without a simplify things here a little.
export class OpenAIService {
// other methods...
public async generateReleaseNotesStream(
mergeRequests: PickedGitLabMergeRequest[],
label: string,
updatedAfter: Date,
context: string = ""
) {
const result = await this.openai.chat.completions.create({
model: "chatgpt-4o-latest",
stream: true,
messages: [
{
role: "system",
content: getReleaseNotesPrompt(mergeRequests, context),
},
],
});
if (!fs.existsSync("./temp")) {
fs.mkdirSync("./temp");
}
const filePath = path.join("./temp/release_notes.txt");
const writeStream = fs.createWriteStream(filePath, { flags: "a" });
writeStream.write(
`\n\n----- Release Notes for label "${label}" merged after ${format(
updatedAfter,
"EEEE do MMMM 'at' h:mma"
)} (generated at ${format(new Date(), "yyyy-MM-dd HH:mm:ss")}) -----\n\n`
);
let fullContent = "";
for await (const chunk of result) {
const content = chunk.choices[0]?.delta?.content || "";
process.stdout.write(content);
writeStream.write(content);
fullContent += content;
}
writeStream.end();
return fullContent;
}
}
The AI's response will be streamed to the terminal and to a file in the temp
folder, which we'll use for debugging and to keep a record of older outputs from the AI.
Once done, don't forget to add the OpenAIService
to the parent Services
class:
import { GitLabService } from "./gitLab";
import { OpenAIService } from "./openAI";
import { SlackService } from "./slack";
import dotenv from "dotenv";
dotenv.config();
class Services {
public gitLab: GitLabService;
public openAI: OpenAIService;
public slack: SlackService;
constructor() {
this.gitLab = new GitLabService();
this.openAI = new OpenAIService();
this.slack = new SlackService();
}
}
export const services = new Services();
Bringing it all together
We’re nearly ready to bring everything together. I want to copy the output to the clipboard and again, in the spirit of avoiding libraries where possible, I have rolled my own solution:
import { spawn } from "child_process";
import os from "os";
export function copyToClipboard(data: string) {
const platform = os.platform();
let proc;
if (platform === "darwin") {
proc = spawn("pbcopy"); // macOS
} else if (platform === "win32") {
proc = spawn("clip"); // Windows
} else {
console.error("Clipboard copy is not supported on this platform.");
}
if (proc) {
proc.stdin.write(data);
proc.stdin.end();
}
}
Let’s get back to main and join up the pieces.
import { services } from "./services";
import {
copyToClipboard,
getArgumentValue,
getMostRecentDateByDayName,
} from "./helpers";
import { format } from "date-fns";
import { DayName } from "./types";
import dotenv from "dotenv";
dotenv.config();
const main = async (args: string[] = []): Promise<void> => {
const label = getArgumentValue(args, ["label", "l"]);
const day = getArgumentValue<DayName>(args, ["day", "d"], "Wednesday");
const time = getArgumentValue(args, ["time", "t"], "16:00");
const context = getArgumentValue(args, ["context", "c"]);
const updatedAfter = getMostRecentDateByDayName(day, time);
console.info(
`\n\nFetching merge requests for label "${label}" merged after ${format(
updatedAfter,
"EEEE do MMMM 'at' h:mma"
)}...`
);
const mergeRequests = await services.gitLab.getMergeRequests({
label,
updatedAfter,
});
console.info(`Sending ${mergeRequests.length} merge requests to OpenAI...\n`);
let i = 1;
for (const mergeRequest of mergeRequests) {
console.info(`${i}. ${mergeRequest.title}`);
console.info(
` By ${mergeRequest.author.name} (${
mergeRequest.author.username
}) - merged at ${format(
new Date(mergeRequest.merged_at),
"HH:mm:ss 'on' yyyy-MM-dd"
)}`
);
console.info(` ${mergeRequest.web_url}\n`);
i++;
}
const result = await services.openAI.generateReleaseNotesStream(
mergeRequests.map((mr) => ({
title: mr.title,
description: mr.description,
})),
label,
updatedAfter,
context
);
if (result) {
copyToClipboard(result);
console.info(`\n\nRelease notes copied to clipboard!`);
console.info(`Or see them at ./temp/release_notes.txt\n\n`);
}
};
main(process.argv.slice(2));
And that’s a wrap. It’s always worth having a manual step to check and tweak release notes at the end, but I find that the system above saves me a lot of time and I can just do a bit of tweaking at the end.
Some ideas for extending the project include using a Slack command to create the release notes or allowing users to pass specific dates if they need release notes from more than a week ago. If you’re working at a large scale, you may need to add support for pagination. But I hope this has given you a useful idea and some helpful boilerplate to use as a starting point for your own implementation. Happy coding!
Related articles
You might also enjoy...
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
How to Easily Support ESM and CJS in Your TypeScript Library
A simple example that works for standalone npm libraries and monorepos
5 min read