diff --git a/docs/generated/cli/release.md b/docs/generated/cli/release.md index 0bf995fc09d11..27318bcefeb38 100644 --- a/docs/generated/cli/release.md +++ b/docs/generated/cli/release.md @@ -101,6 +101,22 @@ nx release changelog [version] #### Options +##### createRelease + +Type: `string` + +Choices: [github] + +Create a release for the given version on a supported source control service provider, such as Github. + +##### file + +Type: `string` + +Default: `CHANGELOG.md` + +The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md. + ##### from Type: `string` diff --git a/docs/generated/packages/nx/documents/release.md b/docs/generated/packages/nx/documents/release.md index 0bf995fc09d11..27318bcefeb38 100644 --- a/docs/generated/packages/nx/documents/release.md +++ b/docs/generated/packages/nx/documents/release.md @@ -101,6 +101,22 @@ nx release changelog [version] #### Options +##### createRelease + +Type: `string` + +Choices: [github] + +Create a release for the given version on a supported source control service provider, such as Github. + +##### file + +Type: `string` + +Default: `CHANGELOG.md` + +The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md. + ##### from Type: `string` diff --git a/e2e/release/src/private-js-packages.test.ts b/e2e/release/src/private-js-packages.test.ts index 0b061f5689282..64b3e88afce3b 100644 --- a/e2e/release/src/private-js-packages.test.ts +++ b/e2e/release/src/private-js-packages.test.ts @@ -18,6 +18,7 @@ expect.addSnapshotSerializer({ .replaceAll('/private/', '') .replaceAll(/public-pkg-\d+/g, '{public-project-name}') .replaceAll(/private-pkg\d+/g, '{private-project-name}') + .replaceAll(/\s\/{private-project-name}/g, ' {private-project-name}') .replaceAll( /integrity:\s*.*/g, 'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' @@ -147,7 +148,7 @@ describe('nx release - private JS packages', () => { > nx run {private-project-name}:nx-release-publish - Skipping package "@proj/{private-project-name}" from project "{private-project-name}", because it has \`"private": true\` in /{private-project-name}/package.json + Skipping package "@proj/{private-project-name}" from project "{private-project-name}", because it has \`"private": true\` in {private-project-name}/package.json > nx run {public-project-name}:nx-release-publish diff --git a/e2e/release/src/release.test.ts b/e2e/release/src/release.test.ts index 7b6e8c167cc0e..7341ee28df1d3 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -1,9 +1,13 @@ import { NxJsonConfiguration } from '@nx/devkit'; import { cleanupProject, + createFile, + exists, killProcessAndPorts, newProject, + readFile, runCLI, + runCommandAsync, runCommandUntil, uniq, updateJson, @@ -66,6 +70,18 @@ describe('nx release', () => { afterAll(() => cleanupProject()); it('should version and publish multiple related npm packages with zero config', async () => { + // Normalize git committer information so it is deterministic in snapshots + await runCommandAsync(`git config user.email "test@test.com"`); + await runCommandAsync(`git config user.name "Test"`); + // Create a baseline version tag + await runCommandAsync(`git tag v0.0.0`); + + // Add an example feature so that we can generate a CHANGELOG.md for it + createFile('an-awesome-new-thing.js', 'console.log("Hello world!");'); + await runCommandAsync( + `git add --all && git commit -m "feat: an awesome new feature"` + ); + const versionOutput = runCLI(`release version 999.9.9`); /** @@ -100,6 +116,42 @@ describe('nx release', () => { ).length ).toEqual(1); + // Generate a changelog for the new version + expect(exists('CHANGELOG.md')).toEqual(false); + + const changelogOutput = runCLI(`release changelog 999.9.9`); + expect(changelogOutput).toMatchInlineSnapshot(` + + > NX Generating a CHANGELOG.md entry for v999.9.9 + + + + ## v999.9.9 + + + + + + ### 🚀 Features + + + + - an awesome new feature + + + + ### ❤️ Thank You + + + + - Test + + + `); + + expect(readFile('CHANGELOG.md')).toMatchInlineSnapshot(` + ## v999.9.9 + + + ### 🚀 Features + + - an awesome new feature + + ### ❤️ Thank You + + - Test + `); + // This is the verdaccio instance that the e2e tests themselves are working from const e2eRegistryUrl = execSync('npm config get registry') .toString() diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 51735d51ba1c3..9f8e4b25efe08 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -1,21 +1,31 @@ import * as chalk from 'chalk'; import { readFileSync, writeFileSync } from 'node:fs'; +import { prerelease } from 'semver'; import { dirSync } from 'tmp'; -import { joinPathFragments, logger, output } from '../../devkit-exports'; +import { FsTree } from '../../generators/tree'; +import { logger } from '../../utils/logger'; +import { output } from '../../utils/output'; +import { joinPathFragments } from '../../utils/path'; +import { workspaceRoot } from '../../utils/workspace-root'; import { ChangelogOptions } from './command-object'; -import { getGitDiff, getLastGitTag, parseCommits } from './utils/git'; +import { + GitCommit, + getGitDiff, + getLastGitTag, + parseCommits, +} from './utils/git'; import { GithubRelease, GithubRequestConfig, + RepoSlug, createOrUpdateGithubRelease, - generateMarkdown, - getGitHubRemote, + getGitHubRepoSlug, getGithubReleaseByTag, resolveGithubToken, } from './utils/github'; import { launchEditor } from './utils/launch-editor'; -import { printDiff } from './utils/print-diff'; -import { prerelease } from 'semver'; +import { generateMarkdown, parseChangelogMarkdown } from './utils/markdown'; +import { printChanges, printDiff } from './utils/print-changes'; export async function changelogHandler(args: ChangelogOptions): Promise { /** @@ -28,20 +38,32 @@ export async function changelogHandler(args: ChangelogOptions): Promise { ? args.version : `${tagVersionPrefix}${args.version}`; - const githubRemote = getGitHubRemote(args.gitRemote); - const token = await resolveGithubToken(); - const githubRequestConfig: GithubRequestConfig = { - repo: githubRemote, - token, - }; + // We are either creating/previewing a changelog file, a Github release, or both + let logTitle = args.dryRun ? 'Previewing a ' : 'Generating a '; + switch (true) { + case args.file !== false && args.createRelease === 'github': + logTitle += `${args.file} entry and a Github release for ${chalk.white( + releaseVersion + )}`; + break; + case args.file !== false: + logTitle += `${args.file} entry for ${chalk.white(releaseVersion)}`; + break; + case args.createRelease === 'github': + logTitle += `Github release for ${chalk.white(releaseVersion)}`; + } + + output.log({ + title: logTitle, + }); const from = args.from || (await getLastGitTag()); if (!from) { - throw new Error( - `Could not determine the previous git tag, please provide and explicit reference using --from` - ); + output.error({ + title: `Unable to determine the previous git tag, please provide an explicit git reference using --from`, + }); + process.exit(1); } - const to = args.to; const rawCommits = await getGitDiff(from, args.to); // Parse as conventional commits @@ -55,118 +77,185 @@ export async function changelogHandler(args: ChangelogOptions): Promise { return false; }); - const initialMarkdown = await generateMarkdown( + const githubRepoSlug = + args.createRelease === 'github' + ? getGitHubRepoSlug(args.gitRemote) + : undefined; + + const finalMarkdown = await resolveFinalMarkdown( + args, commits, releaseVersion, - githubRequestConfig + githubRepoSlug ); - let finalMarkdown = initialMarkdown; - /** - * If interactive mode, make the markdown available for the user to modify in their editor of choice, - * in a similar style to git interactive rebases/merges. + * The exact logic we use for printing the summary/diff to the user is dependent upon whether they are creating + * a CHANGELOG.md file, a Github release, or both. */ - if (args.interactive) { - const tmpDir = dirSync().name; - const changelogPath = joinPathFragments(tmpDir, 'c.md'); - writeFileSync(changelogPath, initialMarkdown); - await launchEditor(changelogPath); - finalMarkdown = readFileSync(changelogPath, 'utf-8'); + let printSummary = () => {}; + const noDiffInChangelogMessage = chalk.yellow( + `NOTE: There was no diff detected for the changelog entry. Maybe you intended to pass alternative git references via --from and --to?` + ); + + if (args.file !== false) { + const tree = new FsTree(workspaceRoot, args.verbose); + + let rootChangelogContents = tree.read(args.file)?.toString() ?? ''; + if (rootChangelogContents) { + const changelogReleases = parseChangelogMarkdown( + rootChangelogContents, + args.tagVersionPrefix + ).releases; + + const existingVersionToUpdate = changelogReleases.find( + (r) => `${tagVersionPrefix}${r.version}` === releaseVersion + ); + if (existingVersionToUpdate) { + rootChangelogContents = rootChangelogContents.replace( + `## ${releaseVersion}\n\n\n${existingVersionToUpdate.body}`, + finalMarkdown + ); + } else { + // No existing version, simply prepend the new release to the top of the file + rootChangelogContents = `${finalMarkdown}\n\n${rootChangelogContents}`; + } + } else { + // No existing changelog contents, simply create a new one using the generated markdown + rootChangelogContents = finalMarkdown; + } + + tree.write(args.file, rootChangelogContents); + + printSummary = () => + printChanges(tree, !!args.dryRun, 3, false, noDiffInChangelogMessage); } - let existingGithubReleaseForVersion: GithubRelease; - try { - existingGithubReleaseForVersion = await getGithubReleaseByTag( - githubRequestConfig, - releaseVersion - ); - } catch (err) { - if (err.response?.status === 401) { + if (args.createRelease === 'github') { + if (!githubRepoSlug) { output.error({ - title: `Unable to resolve data via the Github API. You can use any of the following options to resolve this:`, + title: `Unable to create a Github release because the Github repo slug could not be determined.`, bodyLines: [ - '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid Github token with `repo` scope', - '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', + `Please ensure you have a valid Github remote configured. You can run \`git remote -v\` to list your current remotes.`, ], }); process.exit(1); } - if (err.response?.status === 404) { - // No existing release found, this is fine - } else { - // Rethrow unknown errors for now - throw err; - } - } - const changesRangeText = - to === 'HEAD' ? `since ${from}` : `between ${from} and ${to}`; + const token = await resolveGithubToken(); + const githubRequestConfig: GithubRequestConfig = { + repo: githubRepoSlug, + token, + }; - if (existingGithubReleaseForVersion) { - output.log({ - title: `Found existing Github release for ${chalk.white( + let existingGithubReleaseForVersion: GithubRelease; + try { + existingGithubReleaseForVersion = await getGithubReleaseByTag( + githubRequestConfig, releaseVersion - )}, regenerating with changes ${chalk.cyan(changesRangeText)}`, - }); - } else { - output.log({ - title: `Creating a new Github release for ${chalk.white( - releaseVersion - )}, including changes ${chalk.cyan(changesRangeText)}`, - }); + ); + } catch (err) { + if (err.response?.status === 401) { + output.error({ + title: `Unable to resolve data via the Github API. You can use any of the following options to resolve this:`, + bodyLines: [ + '- Set the `GITHUB_TOKEN` or `GH_TOKEN` environment variable to a valid Github token with `repo` scope', + '- Have an active session via the official gh CLI tool (https://cli.github.com) in your current terminal', + ], + }); + process.exit(1); + } + if (err.response?.status === 404) { + // No existing release found, this is fine + } else { + // Rethrow unknown errors for now + throw err; + } + } + + let existingPrintSummaryFn = printSummary; + printSummary = () => { + const logTitle = `https://github.com/${githubRepoSlug}/releases/tag/${releaseVersion}`; + if (existingGithubReleaseForVersion) { + console.error( + `${chalk.white('UPDATE')} ${logTitle}${ + args.dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } else { + console.error( + `${chalk.green('CREATE')} ${logTitle}${ + args.dryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + } + // Only print the diff here if we are not already going to be printing changes from the Tree + if (args.file === false) { + console.log(''); + printDiff( + existingGithubReleaseForVersion + ? existingGithubReleaseForVersion.body + : '', + finalMarkdown, + 3, + noDiffInChangelogMessage + ); + } + existingPrintSummaryFn(); + }; + + if (!args.dryRun) { + await createOrUpdateGithubRelease( + githubRequestConfig, + { + version: releaseVersion, + body: finalMarkdown, + prerelease: isPrerelease( + releaseVersion.replace(args.tagVersionPrefix, '') + ), + }, + existingGithubReleaseForVersion + ); + } } - printReleaseLog( - releaseVersion, - githubRemote, - args.dryRun, - finalMarkdown, - existingGithubReleaseForVersion - ); + printSummary(); if (args.dryRun) { logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); - } else { - await createOrUpdateGithubRelease( - githubRequestConfig, - { - version: releaseVersion, - body: finalMarkdown, - prerelease: isPrerelease( - releaseVersion.replace(args.tagVersionPrefix, '') - ), - }, - existingGithubReleaseForVersion - ); } process.exit(0); } -function printReleaseLog( +/** + * Based on the commits available, and some optional additional user modifications, + * generate the final markdown for the changelog which will be used for a CHANGELOG.md + * file and/or a Github release. + */ +async function resolveFinalMarkdown( + args: ChangelogOptions, + commits: GitCommit[], releaseVersion: string, - githubRemote: string, - isDryRun: boolean, - finalMarkdown: string, - existingGithubReleaseForVersion?: GithubRelease -) { - const logTitle = `https://github.com/${githubRemote}/releases/tag/${releaseVersion}`; - if (existingGithubReleaseForVersion) { - console.error( - `${chalk.white('UPDATE')} ${logTitle}${ - isDryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); - } else { - console.error( - `${chalk.green('CREATE')} ${logTitle}${ - isDryRun ? chalk.keyword('orange')(' [dry-run]') : '' - }` - ); + githubRepoSlug?: RepoSlug +): Promise { + let markdown = await generateMarkdown( + commits, + releaseVersion, + githubRepoSlug + ); + /** + * If interactive mode, make the markdown available for the user to modify in their editor of choice, + * in a similar style to git interactive rebases/merges. + */ + if (args.interactive) { + const tmpDir = dirSync().name; + const changelogPath = joinPathFragments(tmpDir, 'c.md'); + writeFileSync(changelogPath, markdown); + await launchEditor(changelogPath); + markdown = readFileSync(changelogPath, 'utf-8'); } - console.log(''); - printDiff('', finalMarkdown); + return markdown; } function isPrerelease(version: string): boolean { diff --git a/packages/nx/src/command-line/release/command-object.ts b/packages/nx/src/command-line/release/command-object.ts index 9ffe806391fa3..a8126cfaa7475 100644 --- a/packages/nx/src/command-line/release/command-object.ts +++ b/packages/nx/src/command-line/release/command-object.ts @@ -27,6 +27,8 @@ export type ChangelogOptions = NxReleaseArgs & { interactive?: boolean; gitRemote?: string; tagVersionPrefix?: string; + createRelease?: string; + file?: string | false; }; export type PublishOptions = NxReleaseArgs & @@ -158,10 +160,33 @@ const changelogCommand: CommandModule = { 'Prefix to apply to the version when creating the Github release tag', default: 'v', }) + .option('createRelease', { + describe: + 'Create a release for the given version on a supported source control service provider, such as Github.', + type: 'string', + choices: ['github'], + }) + .option('file', { + type: 'string', + description: + 'The name of the file to write the changelog to. It can also be set to `false` to disable file generation. Defaults to CHANGELOG.md.', + default: 'CHANGELOG.md', + coerce: (file) => { + if (file === 'false') { + return false; + } + return file; + }, + }) .check((argv) => { if (!argv.version) { throw new Error('A target version must be specified'); } + if (argv.file === false && argv.createRelease !== 'github') { + throw new Error( + 'The --file option can only be set to false when --create-release is set to github.' + ); + } return true; }), handler: (args) => diff --git a/packages/nx/src/command-line/release/utils/github.ts b/packages/nx/src/command-line/release/utils/github.ts index c9fc4a365c1bb..512bef90aaf5c 100644 --- a/packages/nx/src/command-line/release/utils/github.ts +++ b/packages/nx/src/command-line/release/utils/github.ts @@ -8,12 +8,14 @@ import { execSync } from 'node:child_process'; import { existsSync, promises as fsp } from 'node:fs'; import { homedir } from 'node:os'; import { joinPathFragments, output } from '../../../devkit-exports'; -import { GitCommit, Reference } from './git'; +import { Reference } from './git'; // axios types and values don't seem to match import _axios = require('axios'); const axios = _axios as any as typeof _axios['default']; +export type RepoSlug = `${string}/${string}`; + export interface GithubRequestConfig { repo: string; token: string | null; @@ -28,10 +30,11 @@ export interface GithubRelease { prerelease?: boolean; } -export function getGitHubRemote(remoteName = 'origin') { +export function getGitHubRepoSlug(remoteName = 'origin'): RepoSlug { try { const remoteUrl = execSync(`git remote get-url ${remoteName}`, { encoding: 'utf8', + stdio: 'pipe', }).trim(); // Extract the 'user/repo' part from the URL @@ -39,14 +42,13 @@ export function getGitHubRemote(remoteName = 'origin') { const match = remoteUrl.match(regex); if (match && match[1]) { - return match[1]; + return match[1] as RepoSlug; } else { throw new Error( `Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}` ); } } catch (error) { - console.error('Error getting GitHub remote:', error.message); return null; } } @@ -84,127 +86,9 @@ export async function createOrUpdateGithubRelease( '\n' ); }); - } else { - output.success({ - title: `Successfully ${ - existingGithubReleaseForVersion ? 'updated' : 'created' - } release ${chalk.bold(release.version)} on Github:`, - bodyLines: [result.url], - }); } } -// TODO: allow this to be configurable via config in a future release -export async function generateMarkdown( - commits: GitCommit[], - releaseVersion: string, - githubRequestConfig: GithubRequestConfig -) { - const typeGroups = groupBy(commits, 'type'); - - const markdown: 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' }, - }; - - // Version Title - markdown.push('', `## ${releaseVersion}`, ''); - - for (const type of Object.keys(commitTypes)) { - const group = typeGroups[type]; - if (!group || group.length === 0) { - continue; - } - - markdown.push('', '### ' + commitTypes[type].title, ''); - for (const commit of group.reverse()) { - const line = formatCommit(commit, githubRequestConfig); - markdown.push(line); - if (commit.isBreaking) { - breakingChanges.push(line); - } - } - } - - if (breakingChanges.length > 0) { - markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); - } - - const _authors = new Map; 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 - 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 @ - if (email.endsWith('@users.noreply.github.com')) { - meta.github = email.split('@')[0]; - 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) { - markdown.push( - '', - '### ' + '❤️ Thank You', - '', - ...authors.map((i) => { - const _email = [...i.email].find( - (e) => !e.includes('noreply.github.com') - ); - const email = _email ? `<${_email}>` : ''; - const github = i.github ? `@${i.github}` : ''; - return `- ${i.name} ${github || email}`; - }) - ); - } - - return markdown.join('\n').trim(); -} - async function syncGithubRelease( githubRequestConfig: GithubRequestConfig, release: { version: string; body: string }, @@ -330,62 +214,27 @@ const providerToRefSpec: Record< github: { 'pull-request': 'pull', hash: 'commit', issue: 'issues' }, }; -function formatReference( - ref: Reference, - githubRequestConfig: GithubRequestConfig -) { +function formatReference(ref: Reference, repoSlug: `${string}/${string}`) { const refSpec = providerToRefSpec['github']; - return `[${ref.value}](https://github.com/${githubRequestConfig.repo}/${ + return `[${ref.value}](https://github.com/${repoSlug}/${ refSpec[ref.type] }/${ref.value.replace(/^#/, '')})`; } -export function formatCommit( - commit: GitCommit, - githubRequestConfig: GithubRequestConfig -) { - return ( - '- ' + - (commit.scope ? `**${commit.scope.trim()}:** ` : '') + - (commit.isBreaking ? '⚠️ ' : '') + - commit.description + - formatReferences(commit.references, githubRequestConfig) - ); -} - -function formatReferences( - references: Reference[], - githubRequestConfig: GithubRequestConfig -) { +export function formatReferences(references: Reference[], repoSlug: RepoSlug) { const pr = references.filter((ref) => ref.type === 'pull-request'); const issue = references.filter((ref) => ref.type === 'issue'); if (pr.length > 0 || issue.length > 0) { return ( ' (' + [...pr, ...issue] - .map((ref) => formatReference(ref, githubRequestConfig)) + .map((ref) => formatReference(ref, repoSlug)) .join(', ') + ')' ); } if (references.length > 0) { - return ' (' + formatReference(references[0], githubRequestConfig) + ')'; + return ' (' + formatReference(references[0], repoSlug) + ')'; } return ''; } - -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; -} diff --git a/packages/nx/src/command-line/release/utils/markdown.spec.ts b/packages/nx/src/command-line/release/utils/markdown.spec.ts new file mode 100644 index 0000000000000..013ac62c061a4 --- /dev/null +++ b/packages/nx/src/command-line/release/utils/markdown.spec.ts @@ -0,0 +1,299 @@ +import { GitCommit } from './git'; +import { parseChangelogMarkdown, generateMarkdown } from './markdown'; + +describe('markdown utils', () => { + describe('generateMarkdown()', () => { + it('should generate markdown for commits organized by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { + const commits: GitCommit[] = [ + { + message: 'fix: all packages fixed', + shortHash: '4130f65', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'all packages fixed', + type: 'fix', + scope: '', + references: [ + { + value: '4130f65', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-b): and another new capability', + shortHash: '7dc5ec3', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'and another new capability', + type: 'feat', + scope: 'pkg-b', + references: [ + { + value: '7dc5ec3', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-a): new hotness', + shortHash: 'd7a58a2', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'new hotness', + type: 'feat', + scope: 'pkg-a', + references: [ + { + value: 'd7a58a2', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'feat(pkg-b): brand new thing', + shortHash: 'feace4a', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'brand new thing', + type: 'feat', + scope: 'pkg-b', + references: [ + { + value: 'feace4a', + type: 'hash', + }, + ], + isBreaking: false, + }, + { + message: 'fix(pkg-a): squashing bugs', + shortHash: '6301405', + author: { + name: 'James Henry', + email: 'jh@example.com', + }, + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + description: 'squashing bugs', + type: 'fix', + scope: 'pkg-a', + references: [ + { + value: '6301405', + type: 'hash', + }, + ], + isBreaking: false, + }, + ]; + + expect(await generateMarkdown(commits, 'v1.1.0')).toMatchInlineSnapshot(` + "## v1.1.0 + + + ### 🚀 Features + + - **pkg-a:** new hotness + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability + + ### 🩹 Fixes + + - all packages fixed + - **pkg-a:** squashing bugs + + ### ❤️ Thank You + + - James Henry" + `); + }); + }); + + describe('parseChangelogMarkdown()', () => { + it('should extract the versions from the given markdown', () => { + const markdown = ` +## v0.0.3 + + +### 🩹 Fixes + +- **baz:** bugfix for baz + +### ❤️ Thank You + +- James Henry + +## v0.0.2 + + +### 🚀 Features + +- **foo:** some feature in foo + +### 🩹 Fixes + +- **bar:** some bugfix in bar + +### ❤️ Thank You + +- James Henry + `; + expect(parseChangelogMarkdown(markdown, 'v')).toMatchInlineSnapshot(` + { + "releases": [ + { + "body": "### 🩹 Fixes + + - **baz:** bugfix for baz + + ### ❤️ Thank You + + - James Henry", + "version": "0.0.3", + }, + { + "body": "### 🚀 Features + + - **foo:** some feature in foo + + ### 🩹 Fixes + + - **bar:** some bugfix in bar + + ### ❤️ Thank You + + - James Henry", + "version": "0.0.2", + }, + ], + } + `); + }); + + it('should work for custom tagVersionPrefix values', () => { + expect( + // Empty string - no prefix + parseChangelogMarkdown( + ` +## 0.0.3 + + +### 🩹 Fixes + +- **baz:** bugfix for baz + +## 0.0.2 + + +### 🚀 Features + +- **foo:** some feature in foo + +`, + '' + ) + ).toMatchInlineSnapshot(` + { + "releases": [ + { + "body": "### 🩹 Fixes + + - **baz:** bugfix for baz", + "version": "0.0.3", + }, + { + "body": "### 🚀 Features + + - **foo:** some feature in foo", + "version": "0.0.2", + }, + ], + } + `); + + expect( + parseChangelogMarkdown( + ` +## v.0.0.3 + + +### 🩹 Fixes + +- **baz:** bugfix for baz + +## v.0.0.2 + + +### 🚀 Features + +- **foo:** some feature in foo + + `, + 'v.' // multi-character, and including regex special character + ) + ).toMatchInlineSnapshot(` + { + "releases": [ + { + "body": "### 🩹 Fixes + + - **baz:** bugfix for baz", + "version": "0.0.3", + }, + { + "body": "### 🚀 Features + + - **foo:** some feature in foo", + "version": "0.0.2", + }, + ], + } + `); + }); + }); +}); diff --git a/packages/nx/src/command-line/release/utils/markdown.ts b/packages/nx/src/command-line/release/utils/markdown.ts new file mode 100644 index 0000000000000..73e215feb2bf4 --- /dev/null +++ b/packages/nx/src/command-line/release/utils/markdown.ts @@ -0,0 +1,202 @@ +import { GitCommit } from './git'; +import { RepoSlug, formatReferences } from './github'; + +// axios types and values don't seem to match +import _axios = require('axios'); +const axios = _axios as any as typeof _axios['default']; + +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; +} + +// TODO: allow this to be configurable via config in a future release +export async function generateMarkdown( + commits: GitCommit[], + releaseVersion: string, + repoSlug?: RepoSlug +) { + const typeGroups = groupBy(commits, 'type'); + + const markdown: 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' }, + }; + + // Version Title + markdown.push('', `## ${releaseVersion}`, ''); + + for (const type of Object.keys(commitTypes)) { + const group = typeGroups[type]; + if (!group || group.length === 0) { + continue; + } + + markdown.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); + markdown.push(line); + if (commit.isBreaking) { + breakingChanges.push(line); + } + } + } + } + + if (breakingChanges.length > 0) { + markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges); + } + + const _authors = new Map; 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 @ + if (email.endsWith('@users.noreply.github.com')) { + meta.github = email.split('@')[0]; + 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) { + markdown.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 markdown.join('\n').trim(); +} + +function escapeRegExp(string: string) { + return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + +export function parseChangelogMarkdown( + contents: string, + tagVersionPrefix: any +) { + const escapedTagVersionPrefix = escapeRegExp(tagVersionPrefix); + + const CHANGELOG_RELEASE_HEAD_RE = new RegExp( + '^#{2,}\\s+' + escapedTagVersionPrefix + '(\\d+\\.\\d+\\.\\d+)', + 'gm' + ); + + const headings = [...contents.matchAll(CHANGELOG_RELEASE_HEAD_RE)]; + const releases: { version?: string; body: string }[] = []; + + for (let i = 0; i < headings.length; i++) { + const heading = headings[i]; + const nextHeading = headings[i + 1]; + const version = heading[1]; + + const release = { + version: version, + body: contents + .slice( + heading.index + heading[0].length, + nextHeading ? nextHeading.index : contents.length + ) + .trim(), + }; + releases.push(release); + } + + return { + releases, + }; +} diff --git a/packages/nx/src/command-line/release/utils/print-changes.ts b/packages/nx/src/command-line/release/utils/print-changes.ts new file mode 100644 index 0000000000000..876b375dc0800 --- /dev/null +++ b/packages/nx/src/command-line/release/utils/print-changes.ts @@ -0,0 +1,96 @@ +import * as chalk from 'chalk'; +import { diff } from 'jest-diff'; +import { readFileSync } from 'node:fs'; +import { + joinPathFragments, + logger, + workspaceRoot, +} from '../../../devkit-exports'; +import { Tree, flushChanges } from '../../../generators/tree'; + +// jest-diff does not export this constant +const NO_DIFF_MESSAGE = 'Compared values have no visual difference.'; + +export function printDiff( + before: string, + after: string, + contextLines = 1, + noDiffMessage = NO_DIFF_MESSAGE +) { + const diffOutput = diff(before, after, { + omitAnnotationLines: true, + contextLines, + expand: false, + aColor: chalk.red, + bColor: chalk.green, + patchColor: (s) => '', + }); + // It is not an exact match because of the color codes + if (diffOutput.includes(NO_DIFF_MESSAGE)) { + console.log(noDiffMessage); + } else { + console.log(diffOutput); + } + console.log(''); +} + +export function printChanges( + tree: Tree, + isDryRun: boolean, + diffContextLines = 1, + shouldPrintDryRunMessage = true, + noDiffMessage?: string +) { + const changes = tree.listChanges(); + + console.log(''); + + if (changes.length === 0 && noDiffMessage) { + console.log(noDiffMessage); + return; + } + + // Print the changes + changes.forEach((f) => { + if (f.type === 'CREATE') { + console.error( + `${chalk.green('CREATE')} ${f.path}${ + isDryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + printDiff( + '', + f.content?.toString() || '', + diffContextLines, + noDiffMessage + ); + } else if (f.type === 'UPDATE') { + console.error( + `${chalk.white('UPDATE')} ${f.path}${ + isDryRun ? chalk.keyword('orange')(' [dry-run]') : '' + }` + ); + const currentContentsOnDisk = readFileSync( + joinPathFragments(tree.root, f.path) + ).toString(); + printDiff( + currentContentsOnDisk, + f.content?.toString() || '', + diffContextLines, + noDiffMessage + ); + } else if (f.type === 'DELETE') { + throw new Error( + 'Unexpected DELETE change, please report this as an issue' + ); + } + }); + + if (!isDryRun) { + flushChanges(workspaceRoot, changes); + } + + if (isDryRun && shouldPrintDryRunMessage) { + logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); + } +} diff --git a/packages/nx/src/command-line/release/utils/print-diff.ts b/packages/nx/src/command-line/release/utils/print-diff.ts deleted file mode 100644 index c2e5947d1de3d..0000000000000 --- a/packages/nx/src/command-line/release/utils/print-diff.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as chalk from 'chalk'; -import { diff } from 'jest-diff'; - -export function printDiff(before: string, after: string) { - console.error( - diff(before, after, { - omitAnnotationLines: true, - contextLines: 1, - expand: false, - aColor: chalk.red, - bColor: chalk.green, - patchColor: (s) => '', - }) - ); - console.log(''); -} diff --git a/packages/nx/src/command-line/release/version.ts b/packages/nx/src/command-line/release/version.ts index 005d8debb5353..7a185e7ea9e96 100644 --- a/packages/nx/src/command-line/release/version.ts +++ b/packages/nx/src/command-line/release/version.ts @@ -33,7 +33,7 @@ import { createReleaseGroups, handleCreateReleaseGroupsError, } from './config/create-release-groups'; -import { printDiff } from './utils/print-diff'; +import { printDiff } from './utils/print-changes'; import { isRelativeVersionKeyword } from './utils/semver'; // Reexport for use in plugin release-version generator implementations