UNPKG

@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
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;