nx
Version:
316 lines (315 loc) • 14.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const semver_1 = require("semver");
const conventional_commits_1 = require("../../src/command-line/release/config/conventional-commits");
const github_1 = require("../../src/command-line/release/utils/github");
// axios types and values don't seem to match
const _axios = require("axios");
const axios = _axios;
/**
* The default ChangelogRenderer implementation that nx exports for the common case of generating markdown
* from the given commits and other metadata.
*/
const defaultChangelogRenderer = async ({ projectGraph, changes, releaseVersion, project, entryWhenNoChanges, changelogRenderOptions, dependencyBumps, repoSlug, conventionalCommitsConfig, repoData, }) => {
const markdownLines = [];
// If the current range of changes contains both a commit and its revert, we strip them both from the final list. Changes from version plans are unaffected, as they have no hashes.
for (const change of changes) {
if (change.type === 'revert' && change.revertedHashes) {
for (const revertedHash of change.revertedHashes) {
const revertedCommit = changes.find((c) => c.shortHash && revertedHash.startsWith(c.shortHash));
if (revertedCommit) {
changes.splice(changes.indexOf(revertedCommit), 1);
changes.splice(changes.indexOf(change), 1);
}
}
}
}
let relevantChanges = changes;
const breakingChanges = [];
// For now to keep the interface of the changelog renderer non-breaking for v19 releases we have a somewhat indirect check for whether or not we are generating a changelog for version plans
const isVersionPlans = !conventionalCommitsConfig;
// Only applicable for version plans
const additionalChangesForAuthorsSection = [];
// Provide a default configuration for version plans to allow most of the subsequent logic to work in the same way it would for conventional commits
// NOTE: The one exception is breaking/major changes, where we do not follow the same structure and instead only show the changes once
if (isVersionPlans) {
conventionalCommitsConfig = {
types: {
feat: conventional_commits_1.DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.feat,
fix: conventional_commits_1.DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.fix,
},
};
// Trim down "relevant changes" to only include non-breaking ones so that we can render them differently under version plans,
// but keep track of the changes for the purposes of the authors section
// TODO(v20): Clean this abstraction up as part of the larger overall refactor of changelog rendering
for (let i = 0; i < relevantChanges.length; i++) {
if (relevantChanges[i].isBreaking) {
const change = relevantChanges[i];
additionalChangesForAuthorsSection.push(change);
const line = formatChange(change, changelogRenderOptions, isVersionPlans, repoData);
breakingChanges.push(line);
relevantChanges.splice(i, 1);
}
}
}
const changeTypes = conventionalCommitsConfig.types;
// workspace root level changelog
if (project === null) {
// No changes for the workspace
if (relevantChanges.length === 0 && breakingChanges.length === 0) {
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
});
}
else if (entryWhenNoChanges) {
markdownLines.push('', `${createVersionTitle(releaseVersion, changelogRenderOptions)}\n\n${entryWhenNoChanges}`, '');
}
return markdownLines.join('\n').trim();
}
const typeGroups = groupBy(relevantChanges, 'type');
markdownLines.push('', createVersionTitle(releaseVersion, changelogRenderOptions), '');
for (const type of Object.keys(changeTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdownLines.push('', '### ' + changeTypes[type].changelog.title, '');
/**
* In order to make the final changelog most readable, we organize changes as follows:
* - By scope, where scopes are in alphabetical order (changes with no scope are listed first)
* - Within a particular scope grouping, we list changes in chronological order
*/
const changesInChronologicalOrder = group.reverse();
const changesGroupedByScope = groupBy(changesInChronologicalOrder, 'scope');
const scopesSortedAlphabetically = Object.keys(changesGroupedByScope).sort();
for (const scope of scopesSortedAlphabetically) {
const changes = changesGroupedByScope[scope];
for (const change of changes) {
const line = formatChange(change, changelogRenderOptions, isVersionPlans, repoData);
markdownLines.push(line);
if (change.isBreaking) {
const breakingChangeExplanation = extractBreakingChangeExplanation(change.body);
breakingChanges.push(breakingChangeExplanation
? `- ${change.scope ? `**${change.scope.trim()}:** ` : ''}${breakingChangeExplanation}`
: line);
}
}
}
}
}
else {
// project level changelog
relevantChanges = relevantChanges.filter((c) => c.affectedProjects &&
(c.affectedProjects === '*' || c.affectedProjects.includes(project)));
// Generating for a named project, but that project has no relevant changes in the current set of commits, exit early
if (relevantChanges.length === 0 && breakingChanges.length === 0) {
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
});
}
else if (entryWhenNoChanges) {
markdownLines.push('', `${createVersionTitle(releaseVersion, changelogRenderOptions)}\n\n${entryWhenNoChanges}`, '');
}
return markdownLines.join('\n').trim();
}
markdownLines.push('', createVersionTitle(releaseVersion, changelogRenderOptions), '');
const typeGroups = groupBy(
// Sort the relevant changes to have the unscoped changes first, before grouping by type
relevantChanges.sort((a, b) => (b.scope ? 1 : 0) - (a.scope ? 1 : 0)), 'type');
for (const type of Object.keys(changeTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdownLines.push('', `### ${changeTypes[type].changelog.title}`, '');
const changesInChronologicalOrder = group.reverse();
for (const change of changesInChronologicalOrder) {
const line = formatChange(change, changelogRenderOptions, isVersionPlans, repoData);
markdownLines.push(line + '\n');
if (change.isBreaking) {
const breakingChangeExplanation = extractBreakingChangeExplanation(change.body);
breakingChanges.push(breakingChangeExplanation
? `- ${change.scope ? `**${change.scope.trim()}:** ` : ''}${breakingChangeExplanation}`
: line);
}
}
}
}
if (breakingChanges.length > 0) {
markdownLines.push('', '### ⚠️ Breaking Changes', '', ...breakingChanges);
}
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
});
}
if (changelogRenderOptions.authors) {
const _authors = new Map();
for (const change of [
...relevantChanges,
...additionalChangesForAuthorsSection,
]) {
if (!change.author) {
continue;
}
const name = formatName(change.author.name);
if (!name || name.includes('[bot]')) {
continue;
}
if (_authors.has(name)) {
const entry = _authors.get(name);
entry.email.add(change.author.email);
}
else {
_authors.set(name, { email: new Set([change.author.email]) });
}
}
// Try to map authors to github usernames
if (repoData && changelogRenderOptions.mapAuthorsToGitHubUsernames) {
await Promise.all([..._authors.keys()].map(async (authorName) => {
const meta = _authors.get(authorName);
for (const email of meta.email) {
// For these pseudo-anonymized emails we can just extract the Github username from before the @
// It could either be in the format: username@ or github_id+username@
if (email.endsWith('@users.noreply.github.com')) {
const match = email.match(/^(\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
if (match && match[2]) {
meta.github = match[2];
break;
}
}
// Look up any other emails against the ungh.cc API
const { data } = await axios
.get(`https://ungh.cc/users/find/${email}`)
.catch(() => ({ data: { user: null } }));
if (data?.user) {
meta.github = data.user.username;
break;
}
}
}));
}
const authors = [..._authors.entries()].map((e) => ({
name: e[0],
...e[1],
}));
if (authors.length > 0) {
markdownLines.push('', '### ' + '❤️ Thank You', '', ...authors
// Sort the contributors by name
.sort((a, b) => a.name.localeCompare(b.name))
.map((i) => {
// Tag the author's Github username if we were able to resolve it so that Github adds them as a contributor
const github = i.github ? ` @${i.github}` : '';
return `- ${i.name}${github}`;
}));
}
}
return markdownLines.join('\n').trim();
};
exports.default = defaultChangelogRenderer;
function applyAdditionalDependencyBumps({ markdownLines, dependencyBumps, releaseVersion, changelogRenderOptions, }) {
if (markdownLines.length === 0) {
markdownLines.push('', `${createVersionTitle(releaseVersion, changelogRenderOptions)}\n`, '');
}
else {
markdownLines.push('');
}
markdownLines.push('### 🧱 Updated Dependencies\n');
dependencyBumps.forEach(({ dependencyName, newVersion }) => {
markdownLines.push(`- Updated ${dependencyName} to ${newVersion}`);
});
markdownLines.push('');
}
function formatName(name = '') {
return name
.split(' ')
.map((p) => p.trim())
.join(' ');
}
function groupBy(items, key) {
const groups = {};
for (const item of items) {
groups[item[key]] = groups[item[key]] || [];
groups[item[key]].push(item);
}
return groups;
}
function formatChange(change, changelogRenderOptions, isVersionPlans, repoData) {
let description = change.description;
let extraLines = [];
let extraLinesStr = '';
if (description.includes('\n')) {
[description, ...extraLines] = description.split('\n');
// Align the extra lines with the start of the description for better readability
const indentation = ' ';
extraLinesStr = extraLines
.filter((l) => l.trim().length > 0)
.map((l) => `${indentation}${l}`)
.join('\n');
}
/**
* In version plans changelogs:
* - don't repeat the breaking change icon
* - don't render the scope
*/
let changeLine = '- ' +
(!isVersionPlans && change.isBreaking ? '⚠️ ' : '') +
(!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') +
description;
if (repoData && changelogRenderOptions.commitReferences) {
changeLine += (0, github_1.formatReferences)(change.githubReferences, repoData);
}
if (extraLinesStr) {
changeLine += '\n\n' + extraLinesStr;
}
return changeLine;
}
/**
* It is common to add further information about a breaking change in the commit body,
* and it is naturally that information that should be included in the BREAKING CHANGES
* section of changelog, rather than repeating the commit title/description.
*/
function extractBreakingChangeExplanation(message) {
if (!message) {
return null;
}
const breakingChangeIdentifier = 'BREAKING CHANGE:';
const startIndex = message.indexOf(breakingChangeIdentifier);
if (startIndex === -1) {
// "BREAKING CHANGE:" not found in the message
return null;
}
const startOfBreakingChange = startIndex + breakingChangeIdentifier.length;
const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange);
if (endOfBreakingChange === -1) {
// No newline character found, extract till the end of the message
return message.substring(startOfBreakingChange).trim();
}
// Extract and return the breaking change message
return message.substring(startOfBreakingChange, endOfBreakingChange).trim();
}
function createVersionTitle(version, changelogRenderOptions) {
// Normalize by removing any leading `v` during comparison
const isMajorVersion = `${(0, semver_1.major)(version)}.0.0` === version.replace(/^v/, '');
let maybeDateStr = '';
if (changelogRenderOptions.versionTitleDate) {
// YYYY-MM-DD
const dateStr = new Date().toISOString().slice(0, 10);
maybeDateStr = ` (${dateStr})`;
}
if (isMajorVersion) {
return `# ${version}${maybeDateStr}`;
}
return `## ${version}${maybeDateStr}`;
}