@bioinsights/release-it-gitlab-mr-changelog
Version:
A release-it plugin that generates a changelog from GitLab merge requests using conventional-changelog format
414 lines (341 loc) • 14.1 kB
JavaScript
const {EOL} = require('os');
const fs = require('fs');
const {Plugin} = require('release-it');
const path = require('path');
const https = require('https');
class GitlabMergeRequestChangelog extends Plugin {
constructor(...args) {
super(...args);
if (this.prompt) {
this.registerPrompts({
infile: {
type: 'input',
message: 'Changelog file:',
default: 'CHANGELOG.md',
validate: Boolean
}
});
}
}
async init() {
const {infile} = this.options;
if (infile) {
try {
fs.accessSync(infile);
} catch (err) {
this.log.warn(`The changelog file (${infile}) does not exist yet and will be created.`);
}
}
}
async getChangelog(latestVersion) {
if (!latestVersion) {
latestVersion = '0.0.0';
}
const {version} = this.getContext();
if (!version) {
this.setContext({version: latestVersion});
}
return this.generateChangelog();
}
async fetchReleases() {
const GITLAB_TOKEN = process.env.GITLAB_TOKEN;
const CI_PROJECT_ID = process.env.CI_PROJECT_ID;
const CI_API_V4_URL = process.env.CI_API_V4_URL;
// Check if required environment variables are provided
if (!GITLAB_TOKEN || !CI_PROJECT_ID || !CI_API_V4_URL) {
this.log.warn('Required environment variables are missing for fetching releases');
return [];
}
const GITLAB_API_URL = `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases`;
try {
return await this.fetchAllReleases(GITLAB_API_URL, GITLAB_TOKEN);
} catch (error) {
this.log.warn('Could not fetch releases from GitLab API:', error.message);
return [];
}
}
async fetchAllReleases(apiUrl, token) {
let allReleases = [];
let page = 1;
let hasMorePages = true;
// Parameters for the API request
const params = new URLSearchParams({
per_page: 100,
order_by: 'released_at',
sort: 'desc'
});
while (hasMorePages) {
const url = `${apiUrl}?${params.toString()}&page=${page}`;
const result = await this.fetchPage(url, token);
if (result.mergeRequests.length === 0) {
hasMorePages = false;
} else {
allReleases = [...allReleases, ...result.mergeRequests];
if (page >= result.totalPages) {
hasMorePages = false;
} else {
page++;
}
}
}
return allReleases;
}
async generateChangelog() {
const {version} = this.getContext();
const {gitlab, tagName, previousTag} = this.config.getContext();
const origin = this.options.origin || process.env.CI_API_V4_URL;
// Fetch merge requests and releases
const mrsData = await this.readMergeRequests();
const releases = await this.fetchReleases();
// Get current and previous release dates
const currentReleaseDate = new Date();
let previousReleaseDate = new Date(0); // Default to epoch if no previous release
if (releases.length > 0) {
// Sort releases by date (newest first)
const sortedReleases = releases.sort((a, b) =>
new Date(b.released_at) - new Date(a.released_at)
);
// If there's at least one previous release, use its date
// We want the second most recent release as our previous date
// (or the first one if there's only one release)
if (sortedReleases.length > 1) {
previousReleaseDate = new Date(sortedReleases[1].released_at);
} else if (sortedReleases.length === 1) {
// If there's only one release, use its date as the previous date
// This will include MRs merged after this date
previousReleaseDate = new Date(sortedReleases[0].released_at);
}
}
// Filter merge requests between the previous release date and current release date
// If there's only one release, include merge requests merged before it
const earliestRelease = releases.length > 0 ?
releases.sort((a, b) => new Date(a.released_at) - new Date(b.released_at))[0] : null;
const earliestReleaseDate = earliestRelease ? new Date(earliestRelease.released_at) : null;
const filteredMRs = mrsData.filter(mr => {
const mergedAt = new Date(mr.merged_at);
// If this is the earliest release and MR was merged before it, include it
if (earliestReleaseDate &&
previousReleaseDate.getTime() === 0 &&
mergedAt <= earliestReleaseDate) {
return mr.state === 'merged';
}
// Otherwise use normal date range filtering
return mr.state === 'merged' &&
mergedAt > previousReleaseDate &&
mergedAt <= currentReleaseDate;
});
// Group merge requests by type (features, bug fixes, etc.)
const groupedMRs = this.groupMergeRequests(filteredMRs);
// Generate changelog content
const formattedDate = new Date().toISOString().split('T')[0];
let changelog = gitlab.releaseName
? `## ${gitlab.releaseName}`
: `## ${version} (${formattedDate})`;
// Add features section
if (groupedMRs.features.length > 0) {
changelog += `${EOL}${EOL}### Features${EOL}${EOL}`;
groupedMRs.features.forEach(mr => {
// Extract only the content inside parentheses
const match = mr.title.match(/^\w+\(([^)]+)\)/);
const displayTitle = match && match[1] ? match[1] : mr.title;
changelog += `* ${displayTitle} ([!${mr.iid}](${mr.web_url}))${EOL}`;
});
}
// Add bug fixes section
if (groupedMRs.bugfixes.length > 0) {
changelog += `${EOL}### Bug Fixes${EOL}${EOL}`;
groupedMRs.bugfixes.forEach(mr => {
// Extract only the content inside parentheses
const match = mr.title.match(/^\w+\(([^)]+)\)/);
const displayTitle = match && match[1] ? match[1] : mr.title;
changelog += `* ${displayTitle} ([!${mr.iid}](${mr.web_url}))${EOL}`;
});
}
// Add other changes section
if (groupedMRs.other.length > 0) {
changelog += `${EOL}### Other Changes${EOL}${EOL}`;
groupedMRs.other.forEach(mr => {
// Extract only the content inside parentheses
const match = mr.title.match(/^\w+\(([^)]+)\)/);
const displayTitle = match && match[1] ? match[1] : mr.title;
changelog += `* ${displayTitle} ([!${mr.iid}](${mr.web_url}))${EOL}`;
});
}
return changelog;
}
async readMergeRequests() {
const {gitlab} = this.config.getContext();
const GITLAB_TOKEN = process.env[gitlab.tokenRef || 'GITLAB_TOKEN'];
const CI_PROJECT_ID = process.env.CI_PROJECT_ID;
const CI_API_V4_URL = process.env.CI_API_V4_URL;
// Check if required environment variables are provided
if (!GITLAB_TOKEN) {
this.log.warn('GITLAB_TOKEN environment variable is required');
return [];
}
if (!CI_PROJECT_ID) {
this.log.warn('CI_PROJECT_ID environment variable is required');
return [];
}
if (!CI_API_V4_URL) {
this.log.warn('CI_API_V4_URL environment variable is required');
return [];
}
const GITLAB_API_URL = `${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests`;
try {
return await this.fetchAllMergeRequests(GITLAB_API_URL, GITLAB_TOKEN);
} catch (error) {
this.log.warn('Could not fetch merge requests from GitLab API:', error.message);
return [];
}
}
async fetchAllMergeRequests(apiUrl, token) {
let allMergeRequests = [];
let page = 1;
let hasMorePages = true;
// Parameters for the API request
const params = new URLSearchParams();
while (hasMorePages) {
const url = `${apiUrl}?${params.toString()}&page=${page}`;
const result = await this.fetchPage(url, token);
if (result.mergeRequests.length === 0) {
hasMorePages = false;
} else {
allMergeRequests = [...allMergeRequests, ...result.mergeRequests];
if (page >= result.totalPages) {
hasMorePages = false;
} else {
page++;
}
}
}
return allMergeRequests;
}
async fetchPage(url, token) {
const {gitlab} = this.config.getContext();
return new Promise((resolve, reject) => {
const options = {
headers: {
[gitlab.tokenHeader]: token
}
};
https.get(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const responseData = JSON.parse(data);
// Check if the response is an array
const mergeRequests = Array.isArray(responseData) ? responseData : [];
// Check if we have more pages
const totalPages = parseInt(res.headers['x-total-pages'] || '1');
resolve({
mergeRequests,
totalPages,
currentPage: parseInt(res.headers['x-page'] || '1')
});
} catch (error) {
// If there's an error parsing the response, return an empty array
resolve({
mergeRequests: [],
totalPages: 1,
currentPage: 1
});
}
});
}).on('error', (err) => {
// If there's a network error, return an empty array instead of rejecting
this.log.warn(`Error fetching from API: ${err.message}`);
resolve({
mergeRequests: [],
totalPages: 1,
currentPage: 1
});
});
});
}
filterMergeRequests(mergeRequests) {
const {latestTag} = this.config.getContext();
// If no latestTag, return all merged MRs
if (!latestTag) {
return mergeRequests.filter(mr => mr.state === 'merged');
}
// Get the date of the latest tag
const latestTagDate = this.getTagDate(latestTag);
// Filter MRs that were merged after the latest tag date
return mergeRequests.filter(mr => {
return mr.state === 'merged' && new Date(mr.merged_at) > latestTagDate;
});
}
getTagDate(tagName) {
try {
// Try to get the tag date from git
const result = this.exec(`git log -1 --format=%ai ${tagName}`, {options: {silent: true}});
return new Date(result);
} catch (error) {
// If tag doesn't exist, return a date far in the past
return new Date(0);
}
}
groupMergeRequests(mergeRequests) {
const groups = {
features: [],
bugfixes: [],
other: []
};
mergeRequests.forEach(mr => {
const title = mr.title.toLowerCase();
if (title.startsWith('feat') || title.includes('feature') || mr.labels.includes('enhancement') || mr.labels.includes('Enhancement') || mr.labels.includes('Feature')) {
groups.features.push(mr);
} else if (title.startsWith('fix') || title.includes('bug') || mr.labels.includes('Bug fix') || mr.labels.includes('bug_fix')) {
groups.bugfixes.push(mr);
} else {
groups.other.push(mr);
}
});
return groups;
}
async writeChangelog() {
const {gitlab} = this.config.getContext();
const {infile, header = '# Changelog'} = this.options;
let {changelog} = this.config.getContext();
const headerWithEOL = header.split(/\r\n|\r|\n/g).join(EOL);
let hasInfile = false;
try {
fs.accessSync(infile);
hasInfile = true;
} catch (err) {
this.debug(err);
}
let previousChangelog = '';
try {
if (hasInfile) {
previousChangelog = fs.readFileSync(infile, 'utf8');
previousChangelog = previousChangelog.replace(headerWithEOL, '');
}
} catch (err) {
this.debug(err);
}
fs.writeFileSync(
infile,
headerWithEOL +
(changelog ? EOL + EOL + changelog.trim() : '') +
(previousChangelog ? EOL + previousChangelog.trim() : '') +
EOL
);
if (!hasInfile) {
await this.exec(`git add ${infile}`);
}
}
async beforeRelease() {
const {infile} = this.options;
const {isDryRun} = this.config;
this.log.exec(`Writing changelog to ${infile}`, isDryRun);
if (infile && !isDryRun) {
await this.writeChangelog();
}
}
}
module.exports = GitlabMergeRequestChangelog;