276 lines
8.8 KiB
TypeScript

import type { GitCommit } from '../src/command-line/release/utils/git';
import {
RepoSlug,
formatReferences,
} from '../src/command-line/release/utils/github';
// axios types and values don't seem to match
import _axios = require('axios');
const axios = _axios as any as typeof _axios['default'];
/**
* The ChangelogRenderOptions are specific to each ChangelogRenderer implementation, and are taken
* from the user's nx.json configuration and passed as is into the ChangelogRenderer function.
*/
export type ChangelogRenderOptions = Record<string, unknown>;
/**
* A ChangelogRenderer function takes in the extracted commits and other relevant metadata
* and returns a string, or a Promise of a string of changelog contents (usually markdown).
*
* @param {Object} config The configuration object for the ChangelogRenderer
* @param {GitCommit[]} config.commits The collection of extracted commits to generate a changelog for
* @param {string} config.releaseVersion The version that is being released
* @param {string | null} config.project The name of specific project to generate a changelog for, or `null` if the overall workspace changelog
* @param {string | false} config.entryWhenNoChanges The (already interpolated) string to use as the changelog entry when there are no changes, or `false` if no entry should be generated
* @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation
*/
export type ChangelogRenderer = (config: {
commits: GitCommit[];
releaseVersion: string;
project: string | null;
entryWhenNoChanges: string | false;
changelogRenderOptions: DefaultChangelogRenderOptions;
repoSlug?: RepoSlug;
}) => Promise<string> | string;
/**
* The specific options available to the default implementation of the ChangelogRenderer that nx exports
* for the common case.
*/
export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions {
/**
* Whether or not the commit authors should be added to the bottom of the changelog in a "Thank You"
* section. Defaults to true.
*/
includeAuthors?: boolean;
}
/**
* The default ChangelogRenderer implementation that nx exports for the common case of generating markdown
* from the given commits and other metadata.
*/
const defaultChangelogRenderer: ChangelogRenderer = async ({
commits,
releaseVersion,
project,
entryWhenNoChanges,
changelogRenderOptions,
repoSlug,
}): Promise<string> => {
const markdownLines: string[] = [];
const breakingChanges = [];
const commitTypes = {
feat: { title: '🚀 Features' },
perf: { title: '🔥 Performance' },
fix: { title: '🩹 Fixes' },
refactor: { title: '💅 Refactors' },
docs: { title: '📖 Documentation' },
build: { title: '📦 Build' },
types: { title: '🌊 Types' },
chore: { title: '🏡 Chore' },
examples: { title: '🏀 Examples' },
test: { title: '✅ Tests' },
style: { title: '🎨 Styles' },
ci: { title: '🤖 CI' },
};
// workspace root level changelog
if (project === null) {
// No changes for the workspace
if (commits.length === 0) {
if (entryWhenNoChanges) {
markdownLines.push(
'',
`## ${releaseVersion}\n\n${entryWhenNoChanges}`,
''
);
}
return markdownLines.join('\n').trim();
}
const typeGroups = groupBy(commits, 'type');
markdownLines.push('', `## ${releaseVersion}`, '');
for (const type of Object.keys(commitTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdownLines.push('', '### ' + commitTypes[type].title, '');
/**
* In order to make the final changelog most readable, we organize commits as follows:
* - By scope, where scopes are in alphabetical order (commits with no scope are listed first)
* - Within a particular scope grouping, we list commits in chronological order
*/
const commitsInChronologicalOrder = group.reverse();
const commitsGroupedByScope = groupBy(
commitsInChronologicalOrder,
'scope'
);
const scopesSortedAlphabetically = Object.keys(
commitsGroupedByScope
).sort();
for (const scope of scopesSortedAlphabetically) {
const commits = commitsGroupedByScope[scope];
for (const commit of commits) {
const line = formatCommit(commit, repoSlug);
markdownLines.push(line);
if (commit.isBreaking) {
breakingChanges.push(line);
}
}
}
}
} else {
// project level changelog
const scopeGroups = groupBy(commits, 'scope');
// Generating for a named project, but that project has no changes in the current set of commits, exit early
if (!scopeGroups[project]) {
if (entryWhenNoChanges) {
markdownLines.push(
'',
`## ${releaseVersion}\n\n${entryWhenNoChanges}`,
''
);
}
return markdownLines.join('\n').trim();
}
markdownLines.push('', `## ${releaseVersion}`, '');
const typeGroups = groupBy(scopeGroups[project], 'type');
for (const type of Object.keys(commitTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdownLines.push('', `### ${commitTypes[type].title}`, '');
const commitsInChronologicalOrder = group.reverse();
for (const commit of commitsInChronologicalOrder) {
const line = formatCommit(commit, repoSlug);
markdownLines.push(line + '\n');
if (commit.isBreaking) {
breakingChanges.push(line);
}
}
}
}
if (breakingChanges.length > 0) {
markdownLines.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges);
}
if (changelogRenderOptions.includeAuthors) {
const _authors = new Map<string, { email: Set<string>; github?: string }>();
for (const commit of commits) {
if (!commit.author) {
continue;
}
const name = formatName(commit.author.name);
if (!name || name.includes('[bot]')) {
continue;
}
if (_authors.has(name)) {
const entry = _authors.get(name);
entry.email.add(commit.author.email);
} else {
_authors.set(name, { email: new Set([commit.author.email]) });
}
}
// Try to map authors to github usernames
if (repoSlug) {
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<any, { data?: { user?: { username: string } } }>(
`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();
};
export default defaultChangelogRenderer;
function formatName(name = '') {
return name
.split(' ')
.map((p) => p.trim())
.join(' ');
}
function groupBy(items: any[], key: string) {
const groups = {};
for (const item of items) {
groups[item[key]] = groups[item[key]] || [];
groups[item[key]].push(item);
}
return groups;
}
function formatCommit(commit: GitCommit, repoSlug?: RepoSlug): string {
let commitLine =
'- ' +
(commit.scope ? `**${commit.scope.trim()}:** ` : '') +
(commit.isBreaking ? '⚠️ ' : '') +
commit.description;
if (repoSlug) {
commitLine += formatReferences(commit.references, repoSlug);
}
return commitLine;
}