From 2c0994ac87cc7d292ae5d9afa2950956b110606a Mon Sep 17 00:00:00 2001 From: James Henry Date: Wed, 2 Oct 2024 22:20:23 +0400 Subject: [PATCH] feat(release)!: rewrite ChangelogRenderer to a class API and remove deprecated config (#28229) BREAKING CHANGE In Nx v19, implementing a custom changelog renderer would involve a lot of work on the user side. They would need to create an additional function making every property available in its declaration and then call the underlying default one and customize the final string (or reimplement the whole thing). E.g. ```js const changelogRenderer = async ({ projectGraph, commits, releaseVersion, project, entryWhenNoChanges, changelogRenderOptions, repoSlug, conventionalCommitsConfig, changes, }) => { const defaultChangelog = await defaultChangelogRenderer({ projectGraph, commits, releaseVersion, project, entryWhenNoChanges, changelogRenderOptions, repoSlug, conventionalCommitsConfig, changes, }); // ...Do custom stuff and return final string... }; module.exports = changelogRenderer; ``` In Nx v20, changelog renderer are classes. The DefaultChangelogRenderer can therefore easily and granularly be extended and customized, and the config does not need to be redeclared on the user side at all. We will improve things even further in this area, but this breaking change is an important stepping stone. E.g. for manipulating the final string equivalent to the previous example: ```js module.exports = class CustomChangelogRenderer extends ( DefaultChangelogRenderer ) { async render() { const defaultChangelogEntry = await super.render(); // ...Do custom stuff and return final string... } }; ``` E.g. for customizing just how titles get rendered: ```js class CustomChangelogRenderer extends DefaultChangelogRenderer { renderVersionTitle(): string { return 'Custom Version Title'; } } ``` --- e2e/release/src/circular-dependencies.test.ts | 6 - .../src/conventional-commits-config.test.ts | 7 - e2e/release/src/release.test.ts | 4 +- e2e/release/src/version-plans.test.ts | 54 +- .../release/changelog-renderer/index.spec.ts | 1245 +++++++++-------- .../nx/release/changelog-renderer/index.ts | 707 +++++----- .../nx/src/command-line/release/changelog.ts | 70 +- .../utils/resolve-changelog-renderer.ts | 10 +- 8 files changed, 1017 insertions(+), 1086 deletions(-) diff --git a/e2e/release/src/circular-dependencies.test.ts b/e2e/release/src/circular-dependencies.test.ts index 153bc5e75362d..225c81355012a 100644 --- a/e2e/release/src/circular-dependencies.test.ts +++ b/e2e/release/src/circular-dependencies.test.ts @@ -544,7 +544,6 @@ describe('nx release circular dependencies', () => { + # 2.0.0 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 2.0.0 @@ -556,7 +555,6 @@ describe('nx release circular dependencies', () => { + # 2.0.0 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 2.0.0 @@ -867,7 +865,6 @@ describe('nx release circular dependencies', () => { + # 2.0.0 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 2.0.0 @@ -879,7 +876,6 @@ describe('nx release circular dependencies', () => { + # 2.0.0 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 2.0.0 @@ -1054,7 +1050,6 @@ describe('nx release circular dependencies', () => { + # 2.0.0 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 1.0.1 @@ -1066,7 +1061,6 @@ describe('nx release circular dependencies', () => { + ## 1.0.1 (YYYY-MM-DD) + - + + ### 🧱 Updated Dependencies + + - Updated {project-name} to 2.0.0 diff --git a/e2e/release/src/conventional-commits-config.test.ts b/e2e/release/src/conventional-commits-config.test.ts index 1f081101bbae2..458b25dcfc327 100644 --- a/e2e/release/src/conventional-commits-config.test.ts +++ b/e2e/release/src/conventional-commits-config.test.ts @@ -364,12 +364,10 @@ describe('nx release conventional commits config', () => { expect(pkg1Changelog).toMatchInlineSnapshot(` # 1.0.0 (YYYY-MM-DD) - ### Custom Type - ⚠️ **{project-name}:** this is a breaking change - ### ⚠️ Breaking Changes - ⚠️ **{project-name}:** this is a breaking change @@ -379,7 +377,6 @@ describe('nx release conventional commits config', () => { expect(pkg2Changelog).toMatchInlineSnapshot(` # 1.0.0 (YYYY-MM-DD) - ### Custom Type - **{project-name}:** this is a custom type @@ -389,7 +386,6 @@ describe('nx release conventional commits config', () => { expect(pkg3Changelog).toMatchInlineSnapshot(` # 1.0.0 (YYYY-MM-DD) - ### Custom Docs Header - this is a doc @@ -408,7 +404,6 @@ describe('nx release conventional commits config', () => { expect(pkg5Changelog).toMatchInlineSnapshot(` # 1.0.0 (YYYY-MM-DD) - ### 🔥 Performance - this is a performance improvement @@ -418,12 +413,10 @@ describe('nx release conventional commits config', () => { expect(pkg6Changelog).toMatchInlineSnapshot(` # 1.0.0 (YYYY-MM-DD) - ### 💅 Refactors - this is refactor - ### 📦 Build - this is a build diff --git a/e2e/release/src/release.test.ts b/e2e/release/src/release.test.ts index fd9f3f7dd7855..80ec1cc7fd157 100644 --- a/e2e/release/src/release.test.ts +++ b/e2e/release/src/release.test.ts @@ -129,7 +129,6 @@ describe('nx release', () => { + ## 999.9.9 (YYYY-MM-DD) + - + + ### 🚀 Features + + - an awesome new feature ([{COMMIT_SHA}](https://github.com/nrwl/fake-repo/commit/{COMMIT_SHA})) @@ -150,7 +149,6 @@ describe('nx release', () => { expect(readFile('CHANGELOG.md')).toMatchInlineSnapshot(` ## 999.9.9 (YYYY-MM-DD) - ### 🚀 Features - an awesome new feature ([{COMMIT_SHA}](https://github.com/nrwl/fake-repo/commit/{COMMIT_SHA})) @@ -666,7 +664,7 @@ describe('nx release', () => { + ## 999.9.9 (YYYY-MM-DD) - + ### 🚀 Features NX Previewing an entry in {project-name}/CHANGELOG.md for v1000.0.0-next.0 diff --git a/e2e/release/src/version-plans.test.ts b/e2e/release/src/version-plans.test.ts index c94bf7a72df09..5a533c5da1d83 100644 --- a/e2e/release/src/version-plans.test.ts +++ b/e2e/release/src/version-plans.test.ts @@ -174,12 +174,10 @@ Here is another line in the message. + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release. + -+ + ### ❤️ Thank You + + - Test` @@ -190,12 +188,10 @@ Here is another line in the message. + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release. + -+ + ### ❤️ Thank You + + - Test` @@ -206,14 +202,12 @@ Here is another line in the message. + ## 0.0.1 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update the independent packages with a patch, preminor, and prerelease. + + Here is another line in the message. + -+ + ### ❤️ Thank You + + - Test` @@ -225,14 +219,12 @@ Here is another line in the message. + ## 0.1.0-0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the independent packages with a patch, preminor, and prerelease. + + Here is another line in the message. + -+ + ### ❤️ Thank You + + - Test` @@ -244,14 +236,12 @@ Here is another line in the message. + ## 0.0.1-0 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update the independent packages with a patch, preminor, and prerelease. + + Here is another line in the message. + -+ + ### ❤️ Thank You + + - Test` @@ -323,17 +313,14 @@ Update packages in both groups with a mix #2 + ## 0.2.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update packages in both groups with a mix #1 + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -345,12 +332,10 @@ Update packages in both groups with a mix #2 + ## 0.2.0 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test @@ -363,12 +348,10 @@ Update packages in both groups with a mix #2 + ## 0.0.2 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #1 + -+ + ### ❤️ Thank You + + - Test` @@ -381,12 +364,10 @@ Update packages in both groups with a mix #2 + ## 0.2.0-0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -399,12 +380,10 @@ Update packages in both groups with a mix #2 + ## 0.0.1 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -511,12 +490,16 @@ const yargs = require('yargs'); verbose: options.verbose, }); - // The returned number value from releasePublish will be zero if all projects are published successfully, non-zero if not - const publishStatus = await releasePublish({ + const publishProjectsResult = await releasePublish({ dryRun: options.dryRun, verbose: options.verbose, }); - process.exit(publishStatus); + // Derive an overall exit code from the publish projects result + process.exit( + Object.values(publishProjectsResult).every((result) => result.code === 0) + ? 0 + : 1 + ); })(); ` ); @@ -562,12 +545,10 @@ const yargs = require('yargs'); + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release. + -+ + ### ❤️ Thank You + + - Test` @@ -578,12 +559,10 @@ const yargs = require('yargs'); + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release. + -+ + ### ❤️ Thank You + + - Test` @@ -594,12 +573,10 @@ const yargs = require('yargs'); + ## 0.0.1 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update the independent packages with a patch, preminor, and prerelease. + -+ + ### ❤️ Thank You + + - Test` @@ -611,12 +588,10 @@ const yargs = require('yargs'); + ## 0.1.0-0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the independent packages with a patch, preminor, and prerelease. + -+ + ### ❤️ Thank You + + - Test` @@ -628,12 +603,10 @@ const yargs = require('yargs'); + ## 0.0.1-0 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update the independent packages with a patch, preminor, and prerelease. + -+ + ### ❤️ Thank You + + - Test` @@ -708,17 +681,14 @@ Update packages in both groups with a mix #2 + ## 0.2.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update packages in both groups with a mix #1 + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -730,12 +700,10 @@ Update packages in both groups with a mix #2 + ## 0.2.0 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test @@ -748,12 +716,10 @@ Update packages in both groups with a mix #2 + ## 0.0.2 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #1 + -+ + ### ❤️ Thank You + + - Test` @@ -766,12 +732,10 @@ Update packages in both groups with a mix #2 + ## 0.2.0-0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -784,12 +748,10 @@ Update packages in both groups with a mix #2 + ## 0.0.1 (YYYY-MM-DD) + -+ + ### 🩹 Fixes + + - Update packages in both groups with a mix #2 + -+ + ### ❤️ Thank You + + - Test` @@ -850,7 +812,6 @@ Update packages in both groups with a mix #2 + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release.` @@ -861,7 +822,6 @@ Update packages in both groups with a mix #2 + ## 0.1.0 (YYYY-MM-DD) + -+ + ### 🚀 Features + + - Update the fixed packages with a minor release.` diff --git a/packages/nx/release/changelog-renderer/index.spec.ts b/packages/nx/release/changelog-renderer/index.spec.ts index 2f33a089dde5f..e1c04b8a80013 100644 --- a/packages/nx/release/changelog-renderer/index.spec.ts +++ b/packages/nx/release/changelog-renderer/index.spec.ts @@ -1,6 +1,6 @@ import type { ChangelogChange } from '../../src/command-line/release/changelog'; import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits'; -import defaultChangelogRenderer from './index'; +import DefaultChangelogRenderer from './index'; jest.mock('../../src/project-graph/file-map-utils', () => ({ createFileMapUsingProjectGraph: jest.fn().mockImplementation(() => { @@ -25,17 +25,16 @@ jest.mock('../../src/project-graph/file-map-utils', () => ({ }), })); -describe('defaultChangelogRenderer()', () => { - const projectGraph = { - nodes: {}, - } as any; +describe('ChangelogRenderer', () => { const changes: ChangelogChange[] = [ { shortHash: '4130f65', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', description: 'all packages fixed', type: 'fix', @@ -52,10 +51,12 @@ describe('defaultChangelogRenderer()', () => { }, { shortHash: '7dc5ec3', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', description: 'and another new capability', type: 'feat', @@ -72,10 +73,12 @@ describe('defaultChangelogRenderer()', () => { }, { shortHash: 'd7a58a2', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', description: 'new hotness', type: 'feat', @@ -92,10 +95,12 @@ describe('defaultChangelogRenderer()', () => { }, { shortHash: 'feace4a', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', description: 'brand new thing', type: 'feat', @@ -112,10 +117,12 @@ describe('defaultChangelogRenderer()', () => { }, { shortHash: '6301405', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', description: 'squashing bugs', type: 'fix', @@ -132,601 +139,712 @@ describe('defaultChangelogRenderer()', () => { }, ]; - describe('workspaceChangelog', () => { - it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes, - releaseVersion: 'v1.1.0', - project: null, - entryWhenNoChanges: false, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }); - expect(markdown).toMatchInlineSnapshot(` - "## v1.1.0 - - - ### 🚀 Features + describe('DefaultChangelogRenderer', () => { + describe('workspaceChangelog', () => { + it('should generate markdown for all projects by organizing commits by type, then grouped by scope within the type (sorted alphabetically), then chronologically within the scope group', async () => { + const renderer = new DefaultChangelogRenderer({ + changes, + changelogEntryVersion: 'v1.1.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }); + const markdown = await renderer.render(); + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 - - **pkg-a:** new hotness - - **pkg-b:** brand new thing - - **pkg-b:** and another new capability + ### 🚀 Features - ### 🩹 Fixes + - **pkg-a:** new hotness + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability - - all packages fixed - - **pkg-a:** squashing bugs + ### 🩹 Fixes - ### ❤️ Thank You + - all packages fixed + - **pkg-a:** squashing bugs - - James Henry" - `); - }); + ### ❤️ Thank You - it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => { - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes, - // Major version, should use single # for generated heading - releaseVersion: 'v1.0.0', - project: null, - entryWhenNoChanges: false, - changelogRenderOptions: { - authors: false, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + - James Henry" + `); }); - expect(markdown).toMatchInlineSnapshot(` - "# v1.0.0 + it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => { + const renderer = new DefaultChangelogRenderer({ + changes, + // Major version, should use single # for generated heading + changelogEntryVersion: 'v1.0.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: false, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }); + const markdown = await renderer.render(); + expect(markdown).toMatchInlineSnapshot(` + "# v1.0.0 - ### 🚀 Features + ### 🚀 Features - - **pkg-a:** new hotness - - **pkg-b:** brand new thing - - **pkg-b:** and another new capability + - **pkg-a:** new hotness + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability - ### 🩹 Fixes + ### 🩹 Fixes - - all packages fixed - - **pkg-a:** squashing bugs" - `); + - all packages fixed + - **pkg-a:** squashing bugs" + `); + }); }); - }); - - describe('project level configs', () => { - it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => { - const otherOpts = { - projectGraph, - changes, - releaseVersion: 'v1.1.0', - entryWhenNoChanges: false as const, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }; - - expect( - await defaultChangelogRenderer({ - ...otherOpts, - project: 'pkg-a', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 - - - ### 🚀 Features - - - **pkg-a:** new hotness - - - ### 🩹 Fixes - - - all packages fixed - - - **pkg-a:** squashing bugs - - ### ❤️ Thank You - - - James Henry" - `); - - expect( - await defaultChangelogRenderer({ - ...otherOpts, - project: 'pkg-a', - // test that the authors option is being respected for project changelogs and therefore no Thank You section exists + describe('project level configs', () => { + it('should generate markdown for the given project by organizing commits by type, then chronologically', async () => { + const otherOpts = { + changes, + changelogEntryVersion: 'v1.1.0', + entryWhenNoChanges: false as const, + isVersionPlans: false, changelogRenderOptions: { - authors: false, + authors: true, }, - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }; + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-a', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 - ### 🚀 Features + ### 🚀 Features - - **pkg-a:** new hotness + - **pkg-a:** new hotness + ### 🩹 Fixes - ### 🩹 Fixes + - all packages fixed + - **pkg-a:** squashing bugs - - all packages fixed + ### ❤️ Thank You - - **pkg-a:** squashing bugs" - `); + - James Henry" + `); - expect( - await defaultChangelogRenderer({ - ...otherOpts, - project: 'pkg-b', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-a', + // test that the authors option is being respected for project changelogs and therefore no Thank You section exists + changelogRenderOptions: { + authors: false, + }, + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 + ### 🚀 Features - ### 🚀 Features + - **pkg-a:** new hotness - - **pkg-b:** brand new thing + ### 🩹 Fixes - - **pkg-b:** and another new capability + - all packages fixed + - **pkg-a:** squashing bugs" + `); + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-b', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 - ### 🩹 Fixes + ### 🚀 Features - - all packages fixed + - **pkg-b:** and another new capability + - **pkg-b:** brand new thing + ### 🩹 Fixes - ### ❤️ Thank You + - all packages fixed - - James Henry" - `); - }); + ### ❤️ Thank You - it('should only include authors relevant to the specific project', async () => { - const changes: ChangelogChange[] = [ - { - shortHash: '4130f65', - author: { - name: 'Author 1', - email: 'author-1@example.com', + - James Henry" + `); + }); + + it('should only include authors relevant to the specific project', async () => { + const changes: ChangelogChange[] = [ + { + shortHash: '4130f65', + authors: [ + { + name: 'Author 1', + email: 'author-1@example.com', + }, + ], + body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', + description: 'all packages fixed', + type: 'fix', + scope: '', + githubReferences: [ + { + value: '4130f65', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['pkg-a', 'pkg-b'], }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\nM\tpackages/pkg-b/src/index.ts\n"', - description: 'all packages fixed', - type: 'fix', - scope: '', - githubReferences: [ - { - value: '4130f65', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: [], - affectedProjects: ['pkg-a', 'pkg-b'], - }, - { - shortHash: '7dc5ec3', - author: { - name: 'Author 2', - email: 'author-2@example.com', + { + shortHash: '7dc5ec3', + authors: [ + { + name: 'Author 2', + email: 'author-2@example.com', + }, + ], + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + description: 'and another new capability', + type: 'feat', + scope: 'pkg-b', + githubReferences: [ + { + value: '7dc5ec3', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['pkg-b'], }, - body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', - description: 'and another new capability', - type: 'feat', - scope: 'pkg-b', - githubReferences: [ - { - value: '7dc5ec3', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: [], - affectedProjects: ['pkg-b'], - }, - { - shortHash: 'd7a58a2', - author: { - name: 'Author 3', - email: 'author-3@example.com', + { + shortHash: 'd7a58a2', + authors: [ + { + name: 'Author 3', + email: 'author-3@example.com', + }, + ], + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', + description: 'new hotness', + type: 'feat', + scope: 'pkg-a', + githubReferences: [ + { + value: 'd7a58a2', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['pkg-a'], }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"', - description: 'new hotness', - type: 'feat', - scope: 'pkg-a', - githubReferences: [ - { - value: 'd7a58a2', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: [], - affectedProjects: ['pkg-a'], - }, - { - shortHash: 'feace4a', - author: { - name: 'Author 4', - email: 'author-4@example.com', + { + shortHash: 'feace4a', + authors: [ + { + name: 'Author 4', + email: 'author-4@example.com', + }, + ], + body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', + description: 'brand new thing', + type: 'feat', + scope: 'pkg-b', + githubReferences: [ + { + value: 'feace4a', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['pkg-b'], }, - body: '"\n\nM\tpackages/pkg-b/src/index.ts\n"', - description: 'brand new thing', - type: 'feat', - scope: 'pkg-b', - githubReferences: [ - { - value: 'feace4a', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: [], - affectedProjects: ['pkg-b'], - }, - { - shortHash: '6301405', - author: { - name: 'Author 5', - email: 'author-5@example.com', + { + shortHash: '6301405', + authors: [ + { + name: 'Author 5', + email: 'author-5@example.com', + }, + ], + body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', + description: 'squashing bugs', + type: 'fix', + scope: 'pkg-a', + githubReferences: [ + { + value: '6301405', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['pkg-a'], }, - body: '"\n\nM\tpackages/pkg-a/src/index.ts\n', - description: 'squashing bugs', - type: 'fix', - scope: 'pkg-a', - githubReferences: [ - { - value: '6301405', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: [], - affectedProjects: ['pkg-a'], - }, - ]; - - const otherOpts = { - projectGraph, - changes, - releaseVersion: 'v1.1.0', - entryWhenNoChanges: false as const, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }; - - expect( - await defaultChangelogRenderer({ - ...otherOpts, - project: 'pkg-a', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 - - - ### 🚀 Features + ]; - - **pkg-a:** new hotness + const otherOpts = { + changes, + changelogEntryVersion: 'v1.1.0', + entryWhenNoChanges: false as const, + isVersionPlans: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }; + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-a', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 - ### 🩹 Fixes + ### 🚀 Features - - all packages fixed + - **pkg-a:** new hotness - - **pkg-a:** squashing bugs + ### 🩹 Fixes + - all packages fixed + - **pkg-a:** squashing bugs - ### ❤️ Thank You + ### ❤️ Thank You - - Author 1 - - Author 3 - - Author 5" - `); + - Author 1 + - Author 3 + - Author 5" + `); - expect( - await defaultChangelogRenderer({ - ...otherOpts, - project: 'pkg-b', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + project: 'pkg-b', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 + ### 🚀 Features - ### 🚀 Features + - **pkg-b:** and another new capability + - **pkg-b:** brand new thing - - **pkg-b:** brand new thing + ### 🩹 Fixes - - **pkg-b:** and another new capability + - all packages fixed + ### ❤️ Thank You - ### 🩹 Fixes - - - all packages fixed - + - Author 1 + - Author 2 + - Author 4" + `); + }); + }); - ### ❤️ Thank You + describe('entryWhenNoChanges', () => { + it('should respect the entryWhenNoChanges option for the workspace changelog', async () => { + const otherOpts = { + changes: [], + changelogEntryVersion: 'v1.1.0', + project: null, // workspace changelog + isVersionPlans: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }; + + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: 'Nothing at all!', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 + + Nothing at all!" + `); + + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: false, // should not create an entry + }).render() + ).toMatchInlineSnapshot(`""`); + }); - - Author 1 - - Author 2 - - Author 4" - `); + it('should respect the entryWhenNoChanges option for project changelogs', async () => { + const otherOpts = { + changes: [], + changelogEntryVersion: 'v1.1.0', + project: 'pkg-a', + isVersionPlans: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }; + + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: 'Nothing at all!', + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 + + Nothing at all!" + `); + + expect( + await new DefaultChangelogRenderer({ + ...otherOpts, + entryWhenNoChanges: false, // should not create an entry + }).render() + ).toMatchInlineSnapshot(`""`); + }); }); - }); - describe('entryWhenNoChanges', () => { - it('should respect the entryWhenNoChanges option for the workspace changelog', async () => { - const otherOpts = { - projectGraph, - changes: [], - releaseVersion: 'v1.1.0', - project: null, // workspace changelog - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }; + describe('revert commits', () => { + it('should generate a Revert section for the changelog if the reverted commit is not part of the same release', async () => { + const changesWithOnlyRevert: ChangelogChange[] = [ + { + shortHash: '6528e88aa', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', + description: + 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', + type: 'revert', + scope: 'release', + githubReferences: [ + { + type: 'pull-request', + value: '#20607', + }, + { + value: '6528e88aa', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], + affectedProjects: ['js'], + }, + ]; + + const markdown = await new DefaultChangelogRenderer({ + changes: changesWithOnlyRevert, + changelogEntryVersion: 'v1.1.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }).render(); - expect( - await defaultChangelogRenderer({ - ...otherOpts, - entryWhenNoChanges: 'Nothing at all!', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 - Nothing at all!" - `); + ### ⏪ Revert - expect( - await defaultChangelogRenderer({ - ...otherOpts, - entryWhenNoChanges: false, // should not create an entry - }) - ).toMatchInlineSnapshot(`""`); - }); + - **release:** Revert "fix(release): do not update dependents when they already use "*" (#20607)" - it('should respect the entryWhenNoChanges option for project changelogs', async () => { - const otherOpts = { - projectGraph, - changes: [], - releaseVersion: 'v1.1.0', - project: 'pkg-a', - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }; + ### ❤️ Thank You - expect( - await defaultChangelogRenderer({ - ...otherOpts, - entryWhenNoChanges: 'Nothing at all!', - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 + - James Henry" + `); + }); - Nothing at all!" - `); + it('should strip both the original commit and its revert if they are both included in the current range of commits', async () => { + const changesWithRevertAndOriginal: ChangelogChange[] = [ + { + shortHash: '6528e88aa', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', + description: + 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', + type: 'revert', + scope: 'release', + githubReferences: [ + { + type: 'pull-request', + value: '#20607', + }, + { + value: '6528e88aa', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], + affectedProjects: ['js'], + }, + { + shortHash: '6d68236d4', + authors: [ + { + name: 'James Henry', + email: 'jh@example.com', + }, + ], + body: '"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', + description: 'do not update dependents when they already use "*"', + type: 'fix', + scope: 'release', + githubReferences: [ + { + type: 'pull-request', + value: '#20607', + }, + { + value: '6d68236d4', + type: 'hash', + }, + ], + isBreaking: false, + revertedHashes: [], + affectedProjects: ['js'], + }, + ]; + + const markdown = await new DefaultChangelogRenderer({ + changes: changesWithRevertAndOriginal, + changelogEntryVersion: 'v1.1.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }).render(); - expect( - await defaultChangelogRenderer({ - ...otherOpts, - entryWhenNoChanges: false, // should not create an entry - }) - ).toMatchInlineSnapshot(`""`); + expect(markdown).toMatchInlineSnapshot(`""`); + }); }); - }); - describe('revert commits', () => { - it('should generate a Revert section for the changelog if the reverted commit is not part of the same release', async () => { - const changesWithOnlyRevert: ChangelogChange[] = [ - { - shortHash: '6528e88aa', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - description: - 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', - type: 'revert', - scope: 'release', - githubReferences: [ - { - type: 'pull-request', - value: '#20607', - }, + describe('breaking changes', () => { + it('should work for breaking changes with just the ! and no explanation', async () => { + const breakingChangeWithExplanation: ChangelogChange = { + shortHash: '54f2f6ed1', + authors: [ { - value: '6528e88aa', - type: 'hash', + name: 'James Henry', + email: 'jh@example.com', }, ], - isBreaking: false, - revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], - affectedProjects: ['js'], - }, - ]; + body: + 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + + '"', + description: 'no longer extends `Subject`.', + type: 'feat', + scope: 'WebSocketSubject', + githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], + isBreaking: true, + revertedHashes: [], + affectedProjects: ['rxjs'], + }; + + const markdown = await new DefaultChangelogRenderer({ + changes: [breakingChangeWithExplanation], + changelogEntryVersion: 'v1.1.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }).render(); - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes: changesWithOnlyRevert, - releaseVersion: 'v1.1.0', - project: null, - entryWhenNoChanges: false, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }); + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 - expect(markdown).toMatchInlineSnapshot(` - "## v1.1.0 + ### 🚀 Features + - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. - ### ⏪ Revert + ### ⚠️ Breaking Changes - - **release:** Revert "fix(release): do not update dependents when they already use "*" (#20607)" + - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. - ### ❤️ Thank You + ### ❤️ Thank You - - James Henry" - `); - }); + - James Henry" + `); + }); - it('should strip both the original commit and its revert if they are both included in the current range of commits', async () => { - const changesWithRevertAndOriginal: ChangelogChange[] = [ - { - shortHash: '6528e88aa', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: 'This reverts commit 6d68236d467812aba4557a2bc7f667157de80fdb.\n"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - description: - 'Revert "fix(release): do not update dependents when they already use "*" (#20607)"', - type: 'revert', - scope: 'release', - githubReferences: [ - { - type: 'pull-request', - value: '#20607', - }, - { - value: '6528e88aa', - type: 'hash', - }, - ], - isBreaking: false, - revertedHashes: ['6d68236d467812aba4557a2bc7f667157de80fdb'], - affectedProjects: ['js'], - }, - { - shortHash: '6d68236d4', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: '"\n\nM\tpackages/js/src/generators/release-version/release-version.spec.ts\nM\tpackages/js/src/generators/release-version/release-version.ts\n', - description: 'do not update dependents when they already use "*"', - type: 'fix', - scope: 'release', - githubReferences: [ + it('should extract the explanation of a breaking change and render it preferentially', async () => { + const breakingChangeWithExplanation: ChangelogChange = { + shortHash: '54f2f6ed1', + authors: [ { - type: 'pull-request', - value: '#20607', - }, - { - value: '6d68236d4', - type: 'hash', + name: 'James Henry', + email: 'jh@example.com', }, ], - isBreaking: false, + body: + 'BREAKING CHANGE: `WebSocketSubject` is no longer `instanceof Subject`. Check for `instanceof WebSocketSubject` instead.\n' + + '"\n' + + '\n' + + 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + + '"', + description: 'no longer extends `Subject`.', + type: 'feat', + scope: 'WebSocketSubject', + githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], + isBreaking: true, revertedHashes: [], - affectedProjects: ['js'], - }, - ]; + affectedProjects: ['rxjs'], + }; + + const markdown = await new DefaultChangelogRenderer({ + changes: [breakingChangeWithExplanation], + changelogEntryVersion: 'v1.1.0', + project: null, + isVersionPlans: false, + entryWhenNoChanges: false, + changelogRenderOptions: { + authors: true, + }, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + }).render(); - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes: changesWithRevertAndOriginal, - releaseVersion: 'v1.1.0', - project: null, - entryWhenNoChanges: false, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - }); + expect(markdown).toMatchInlineSnapshot(` + "## v1.1.0 - expect(markdown).toMatchInlineSnapshot(`""`); - }); - }); + ### 🚀 Features - describe('breaking changes', () => { - it('should work for breaking changes with just the ! and no explanation', async () => { - const breakingChangeWithExplanation: ChangelogChange = { - shortHash: '54f2f6ed1', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: - 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + - '"', - description: 'no longer extends `Subject`.', - type: 'feat', - scope: 'WebSocketSubject', - githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], - isBreaking: true, - revertedHashes: [], - affectedProjects: ['rxjs'], - }; - - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes: [breakingChangeWithExplanation], - releaseVersion: 'v1.1.0', - project: null, - entryWhenNoChanges: false, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. + + ### ⚠️ Breaking Changes + + - **WebSocketSubject:** \`WebSocketSubject\` is no longer \`instanceof Subject\`. Check for \`instanceof WebSocketSubject\` instead. + + ### ❤️ Thank You + + - James Henry" + `); }); + }); - expect(markdown).toMatchInlineSnapshot(` - "## v1.1.0 + describe('dependency bumps', () => { + it('should render the dependency bumps in addition to the changes', async () => { + expect( + await new DefaultChangelogRenderer({ + changes, + changelogEntryVersion: 'v1.1.0', + entryWhenNoChanges: false as const, + changelogRenderOptions: { + authors: true, + }, + isVersionPlans: false, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + project: 'pkg-a', + dependencyBumps: [ + { + dependencyName: 'pkg-b', + newVersion: '2.0.0', + }, + ], + }).render() + ).toMatchInlineSnapshot(` + "## v1.1.0 + ### 🚀 Features - ### 🚀 Features + - **pkg-a:** new hotness - - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. + ### 🩹 Fixes - ### ⚠️ Breaking Changes + - all packages fixed + - **pkg-a:** squashing bugs - - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. + ### 🧱 Updated Dependencies - ### ❤️ Thank You + - Updated pkg-b to 2.0.0 - - James Henry" - `); + ### ❤️ Thank You + + - James Henry" + `); + }); + + it('should render the dependency bumps and release version title even when there are no changes', async () => { + expect( + await new DefaultChangelogRenderer({ + changes: [], + changelogEntryVersion: 'v3.1.0', + entryWhenNoChanges: + 'should not be printed because we have dependency bumps', + changelogRenderOptions: { + authors: true, + }, + isVersionPlans: false, + conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, + project: 'pkg-a', + dependencyBumps: [ + { + dependencyName: 'pkg-b', + newVersion: '4.0.0', + }, + ], + }).render() + ).toMatchInlineSnapshot(` + "## v3.1.0 + + ### 🧱 Updated Dependencies + + - Updated pkg-b to 4.0.0" + `); + }); }); + }); - it('should extract the explanation of a breaking change and render it preferentially', async () => { - const breakingChangeWithExplanation: ChangelogChange = { - shortHash: '54f2f6ed1', - author: { - name: 'James Henry', - email: 'jh@example.com', - }, - body: - 'BREAKING CHANGE: `WebSocketSubject` is no longer `instanceof Subject`. Check for `instanceof WebSocketSubject` instead.\n' + - '"\n' + - '\n' + - 'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' + - '"', - description: 'no longer extends `Subject`.', - type: 'feat', - scope: 'WebSocketSubject', - githubReferences: [{ value: '54f2f6ed1', type: 'hash' }], - isBreaking: true, - revertedHashes: [], - affectedProjects: ['rxjs'], - }; - - const markdown = await defaultChangelogRenderer({ - projectGraph, - changes: [breakingChangeWithExplanation], - releaseVersion: 'v1.1.0', + describe('Custom ChangelogRenderer', () => { + it('should be possible to override individual methods of the DefaultChangelogRenderer', async () => { + class CustomChangelogRenderer extends DefaultChangelogRenderer { + public renderVersionTitle(): string { + return 'Custom Version Title'; + } + } + + const renderer = new CustomChangelogRenderer({ + changes, + changelogEntryVersion: 'v1.1.0', project: null, + isVersionPlans: false, entryWhenNoChanges: false, changelogRenderOptions: { authors: true, @@ -734,100 +852,25 @@ describe('defaultChangelogRenderer()', () => { conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, }); + const markdown = await renderer.render(); expect(markdown).toMatchInlineSnapshot(` - "## v1.1.0 - - - ### 🚀 Features - - - ⚠️ **WebSocketSubject:** no longer extends \`Subject\`. - - ### ⚠️ Breaking Changes - - - **WebSocketSubject:** \`WebSocketSubject\` is no longer \`instanceof Subject\`. Check for \`instanceof WebSocketSubject\` instead. - - ### ❤️ Thank You - - - James Henry" - `); - }); - }); - - describe('dependency bumps', () => { - it('should render the dependency bumps in addition to the changes', async () => { - expect( - await defaultChangelogRenderer({ - projectGraph, - changes, - releaseVersion: 'v1.1.0', - entryWhenNoChanges: false as const, - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - project: 'pkg-a', - dependencyBumps: [ - { - dependencyName: 'pkg-b', - newVersion: '2.0.0', - }, - ], - }) - ).toMatchInlineSnapshot(` - "## v1.1.0 - + "Custom Version Title ### 🚀 Features - **pkg-a:** new hotness - + - **pkg-b:** brand new thing + - **pkg-b:** and another new capability ### 🩹 Fixes - all packages fixed - - **pkg-a:** squashing bugs - - ### 🧱 Updated Dependencies - - - Updated pkg-b to 2.0.0 - - ### ❤️ Thank You - James Henry" `); }); - - it('should render the dependency bumps and release version title even when there are no changes', async () => { - expect( - await defaultChangelogRenderer({ - projectGraph, - changes: [], - releaseVersion: 'v3.1.0', - entryWhenNoChanges: - 'should not be printed because we have dependency bumps', - changelogRenderOptions: { - authors: true, - }, - conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG, - project: 'pkg-a', - dependencyBumps: [ - { - dependencyName: 'pkg-b', - newVersion: '4.0.0', - }, - ], - }) - ).toMatchInlineSnapshot(` - "## v3.1.0 - - - ### 🧱 Updated Dependencies - - - Updated pkg-b to 4.0.0" - `); - }); }); }); diff --git a/packages/nx/release/changelog-renderer/index.ts b/packages/nx/release/changelog-renderer/index.ts index b1eaf6667a91d..e4d9b91714daf 100644 --- a/packages/nx/release/changelog-renderer/index.ts +++ b/packages/nx/release/changelog-renderer/index.ts @@ -2,13 +2,10 @@ import { major } from 'semver'; import { ChangelogChange } from '../../src/command-line/release/changelog'; import { NxReleaseConfig } from '../../src/command-line/release/config/config'; import { DEFAULT_CONVENTIONAL_COMMITS_CONFIG } from '../../src/command-line/release/config/conventional-commits'; -import { GitCommit } from '../../src/command-line/release/utils/git'; import { GithubRepoData, - RepoSlug, formatReferences, } from '../../src/command-line/release/utils/github'; -import type { ProjectGraph } from '../../src/config/project-graph'; // axios types and values don't seem to match import _axios = require('axios'); @@ -21,7 +18,7 @@ const axios = _axios as any as (typeof _axios)['default']; export type ChangelogRenderOptions = Record; /** - * When versioning projects independently and enabling `"updateDependents": "always"`, there could + * When versioning projects independently and enabling `"updateDependents": "auto"`, there could * be additional dependency bump information that is not captured in the commit data, but that nevertheless * should be included in the rendered changelog. */ @@ -30,39 +27,6 @@ export type DependencyBump = { newVersion: string; }; -/** - * 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 {ProjectGraph} config.projectGraph The project graph for the workspace - * @param {GitCommit[]} config.commits DEPRECATED [Use 'config.changes' instead] - The collection of extracted commits to generate a changelog for - * @param {ChangelogChange[]} config.changes The collection of changes to show in the changelog - * @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 - * @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the commit data - * @param {GithubRepoData} config.repoData Resolved data for the current GitHub repository - */ -export type ChangelogRenderer = (config: { - projectGraph: ProjectGraph; - // TODO(v20): remove 'commits' and make 'changes' required - commits?: GitCommit[]; - changes?: ChangelogChange[]; - releaseVersion: string; - project: string | null; - entryWhenNoChanges: string | false; - changelogRenderOptions: DefaultChangelogRenderOptions; - dependencyBumps?: DependencyBump[]; - // TODO(v20): remove repoSlug in favour of repoData - repoSlug?: RepoSlug; - repoData?: GithubRepoData; - // TODO(v20): Evaluate if there is a cleaner way to configure this when breaking changes are allowed - // null if version plans are being used to generate the changelog - conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null; -}) => Promise | string; - /** * The specific options available to the default implementation of the ChangelogRenderer that nx exports * for the common case. @@ -91,201 +55,210 @@ export interface DefaultChangelogRenderOptions extends ChangelogRenderOptions { versionTitleDate?: 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 ({ - projectGraph, - changes, - releaseVersion, - project, - entryWhenNoChanges, - changelogRenderOptions, - dependencyBumps, - repoSlug, - conventionalCommitsConfig, - repoData, -}): Promise => { - const markdownLines: string[] = []; - - // 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); - } - } - } +export default class DefaultChangelogRenderer { + protected changes: ChangelogChange[]; + protected changelogEntryVersion: string; + protected project: string | null; + protected entryWhenNoChanges: string | false; + protected changelogRenderOptions: DefaultChangelogRenderOptions; + protected isVersionPlans: boolean; + protected dependencyBumps?: DependencyBump[]; + protected repoData?: GithubRepoData; + protected conventionalCommitsConfig: + | NxReleaseConfig['conventionalCommits'] + | null; + protected relevantChanges: ChangelogChange[]; + protected breakingChanges: string[]; + protected additionalChangesForAuthorsSection: ChangelogChange[]; + + /** + * A ChangelogRenderer class takes in the determined changes 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 {ChangelogChange[]} config.changes The collection of changes to show in the changelog + * @param {string} config.changelogEntryVersion The version for which we are rendering the current changelog entry + * @param {string | null} config.project The name of specific project to generate a changelog entry 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 {boolean} config.isVersionPlans Whether or not Nx release version plans are the source of truth for the changelog entry + * @param {ChangelogRenderOptions} config.changelogRenderOptions The options specific to the ChangelogRenderer implementation + * @param {DependencyBump[]} config.dependencyBumps Optional list of additional dependency bumps that occurred as part of the release, outside of the change data + * @param {GithubRepoData} config.repoData Resolved data for the current GitHub repository + * @param {NxReleaseConfig['conventionalCommits'] | null} config.conventionalCommitsConfig The configuration for conventional commits, or null if version plans are being used + */ + constructor(config: { + changes: ChangelogChange[]; + changelogEntryVersion: string; + project: string | null; + entryWhenNoChanges: string | false; + isVersionPlans: boolean; + changelogRenderOptions: DefaultChangelogRenderOptions; + dependencyBumps?: DependencyBump[]; + repoData?: GithubRepoData; + conventionalCommitsConfig: NxReleaseConfig['conventionalCommits'] | null; + }) { + this.changes = this.filterChanges(config.changes, config.project); + this.changelogEntryVersion = config.changelogEntryVersion; + this.project = config.project; + this.entryWhenNoChanges = config.entryWhenNoChanges; + this.isVersionPlans = config.isVersionPlans; + this.changelogRenderOptions = config.changelogRenderOptions; + this.dependencyBumps = config.dependencyBumps; + this.repoData = config.repoData; + this.conventionalCommitsConfig = config.conventionalCommitsConfig; + + this.relevantChanges = []; + this.breakingChanges = []; + this.additionalChangesForAuthorsSection = []; } - 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: DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.feat, - fix: 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); - } + protected filterChanges( + changes: ChangelogChange[], + project: string | null + ): ChangelogChange[] { + if (project === null) { + return changes; } + return changes.filter( + (c) => + c.affectedProjects && + (c.affectedProjects === '*' || c.affectedProjects.includes(project)) + ); } - 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(); + async render(): Promise { + const sections: string[][] = []; + + this.preprocessChanges(); + + if (this.shouldRenderEmptyEntry()) { + return this.renderEmptyEntry(); } - const typeGroups: Record = groupBy( - relevantChanges, - 'type' - ); + sections.push([this.renderVersionTitle()]); - markdownLines.push( - '', - createVersionTitle(releaseVersion, changelogRenderOptions), - '' - ); + const changesByType = this.renderChangesByType(); + if (changesByType.length > 0) { + sections.push(changesByType); + } - for (const type of Object.keys(changeTypes)) { - const group = typeGroups[type]; - if (!group || group.length === 0) { - continue; - } + if (this.hasBreakingChanges()) { + sections.push(this.renderBreakingChanges()); + } - 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: Record = 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 + if (this.hasDependencyBumps()) { + sections.push(this.renderDependencyBumps()); + } + + if (this.shouldRenderAuthors()) { + sections.push(await this.renderAuthors()); + } + + // Join sections with double newlines, and trim any extra whitespace + return sections + .filter((section) => section.length > 0) + .map((section) => section.join('\n').trim()) + .join('\n\n') + .trim(); + } + + protected preprocessChanges(): void { + this.relevantChanges = [...this.changes]; + this.breakingChanges = []; + this.additionalChangesForAuthorsSection = []; + + // Filter out reverted changes + for (let i = this.relevantChanges.length - 1; i >= 0; i--) { + const change = this.relevantChanges[i]; + if (change.type === 'revert' && change.revertedHashes) { + for (const revertedHash of change.revertedHashes) { + const revertedCommitIndex = this.relevantChanges.findIndex( + (c) => c.shortHash && revertedHash.startsWith(c.shortHash) ); - markdownLines.push(line); - if (change.isBreaking) { - const breakingChangeExplanation = extractBreakingChangeExplanation( - change.body - ); - breakingChanges.push( - breakingChangeExplanation - ? `- ${ - change.scope ? `**${change.scope.trim()}:** ` : '' - }${breakingChangeExplanation}` - : line - ); + if (revertedCommitIndex !== -1) { + this.relevantChanges.splice(revertedCommitIndex, 1); + this.relevantChanges.splice(i, 1); + i--; + break; } } } } - } 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}`, - '' - ); + if (this.isVersionPlans) { + this.conventionalCommitsConfig = { + types: { + feat: DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.feat, + fix: DEFAULT_CONVENTIONAL_COMMITS_CONFIG.types.fix, + }, + }; + + for (let i = this.relevantChanges.length - 1; i >= 0; i--) { + if (this.relevantChanges[i].isBreaking) { + const change = this.relevantChanges[i]; + this.additionalChangesForAuthorsSection.push(change); + const line = this.formatChange(change); + this.breakingChanges.push(line); + this.relevantChanges.splice(i, 1); + } + } + } else { + for (const change of this.relevantChanges) { + if (change.isBreaking) { + const breakingChangeExplanation = + this.extractBreakingChangeExplanation(change.body); + this.breakingChanges.push( + breakingChangeExplanation + ? `- ${ + change.scope ? `**${change.scope.trim()}:** ` : '' + }${breakingChangeExplanation}` + : this.formatChange(change) + ); + } } - return markdownLines.join('\n').trim(); } + } - markdownLines.push( - '', - createVersionTitle(releaseVersion, changelogRenderOptions), - '' + protected shouldRenderEmptyEntry(): boolean { + return ( + this.relevantChanges.length === 0 && + this.breakingChanges.length === 0 && + !this.hasDependencyBumps() ); + } + + protected renderEmptyEntry(): string { + if (this.hasDependencyBumps()) { + return [ + this.renderVersionTitle(), + '', + ...this.renderDependencyBumps(), + ].join('\n'); + } else if (this.entryWhenNoChanges) { + return `${this.renderVersionTitle()}\n\n${this.entryWhenNoChanges}`; + } + return ''; + } + + protected renderVersionTitle(): string { + const isMajorVersion = + `${major(this.changelogEntryVersion)}.0.0` === + this.changelogEntryVersion.replace(/^v/, ''); + let maybeDateStr = ''; + if (this.changelogRenderOptions.versionTitleDate) { + const dateStr = new Date().toISOString().slice(0, 10); + maybeDateStr = ` (${dateStr})`; + } + return isMajorVersion + ? `# ${this.changelogEntryVersion}${maybeDateStr}` + : `## ${this.changelogEntryVersion}${maybeDateStr}`; + } + + protected renderChangesByType(): string[] { + const markdownLines: string[] = []; + const typeGroups = this.groupChangesByType(); + const changeTypes = this.conventionalCommitsConfig.types; - const typeGroups: Record = 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) { @@ -294,74 +267,111 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ 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 (this.project === null) { + const changesGroupedByScope = this.groupChangesByScope(group); + const scopesSortedAlphabetically = Object.keys( + changesGroupedByScope + ).sort(); + + for (const scope of scopesSortedAlphabetically) { + const changes = changesGroupedByScope[scope]; + for (const change of changes.reverse()) { + const line = this.formatChange(change); + markdownLines.push(line); + if (change.isBreaking && !this.isVersionPlans) { + const breakingChangeExplanation = + this.extractBreakingChangeExplanation(change.body); + this.breakingChanges.push( + breakingChangeExplanation + ? `- ${ + change.scope ? `**${change.scope.trim()}:** ` : '' + }${breakingChangeExplanation}` + : line + ); + } + } + } + } else { + // For project-specific changelogs, maintain the original order + for (const change of group) { + const line = this.formatChange(change); + markdownLines.push(line); + if (change.isBreaking && !this.isVersionPlans) { + const breakingChangeExplanation = + this.extractBreakingChangeExplanation(change.body); + this.breakingChanges.push( + breakingChangeExplanation + ? `- ${ + change.scope ? `**${change.scope.trim()}:** ` : '' + }${breakingChangeExplanation}` + : line + ); + } } } } + + return markdownLines; + } + + protected hasBreakingChanges(): boolean { + return this.breakingChanges.length > 0; + } + + protected renderBreakingChanges(): string[] { + const uniqueBreakingChanges = Array.from(new Set(this.breakingChanges)); + return ['### ⚠️ Breaking Changes', '', ...uniqueBreakingChanges]; } - if (breakingChanges.length > 0) { - markdownLines.push('', '### ⚠️ Breaking Changes', '', ...breakingChanges); + protected hasDependencyBumps(): boolean { + return this.dependencyBumps && this.dependencyBumps.length > 0; } - if (dependencyBumps?.length) { - applyAdditionalDependencyBumps({ - markdownLines, - dependencyBumps, - releaseVersion, - changelogRenderOptions, + protected renderDependencyBumps(): string[] { + const markdownLines = ['', '### 🧱 Updated Dependencies', '']; + this.dependencyBumps.forEach(({ dependencyName, newVersion }) => { + markdownLines.push(`- Updated ${dependencyName} to ${newVersion}`); }); + return markdownLines; + } + + protected shouldRenderAuthors(): boolean { + return this.changelogRenderOptions.authors; } - if (changelogRenderOptions.authors) { + protected async renderAuthors(): Promise { + const markdownLines: string[] = []; const _authors = new Map; github?: string }>(); for (const change of [ - ...relevantChanges, - ...additionalChangesForAuthorsSection, + ...this.relevantChanges, + ...this.additionalChangesForAuthorsSection, ]) { - if (!change.author) { + if (!change.authors) { 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]) }); + for (const author of change.authors) { + const name = this.formatName(author.name); + if (!name || name.includes('[bot]')) { + continue; + } + if (_authors.has(name)) { + const entry = _authors.get(name); + entry.email.add(author.email); + } else { + _authors.set(name, { email: new Set([author.email]) }); + } } } - // Try to map authors to github usernames - if (repoData && changelogRenderOptions.mapAuthorsToGitHubUsernames) { + if ( + this.repoData && + this.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$/ @@ -371,7 +381,6 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ break; } } - // Look up any other emails against the ungh.cc API const { data } = await axios .get( `https://ungh.cc/users/find/${email}` @@ -397,147 +406,93 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({ '### ' + '❤️ 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 applyAdditionalDependencyBumps({ - markdownLines, - dependencyBumps, - releaseVersion, - changelogRenderOptions, -}: { - markdownLines: string[]; - dependencyBumps: DependencyBump[]; - releaseVersion: string; - changelogRenderOptions: DefaultChangelogRenderOptions; -}) { - if (markdownLines.length === 0) { - markdownLines.push( - '', - `${createVersionTitle(releaseVersion, changelogRenderOptions)}\n`, - '' - ); - } else { - markdownLines.push(''); + return markdownLines; } - 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: any[], key: string) { - const groups = {}; - for (const item of items) { - groups[item[key]] = groups[item[key]] || []; - groups[item[key]].push(item); - } - return groups; -} + protected formatChange(change: ChangelogChange): string { + let description = change.description; + let extraLines = []; + let extraLinesStr = ''; + if (description.includes('\n')) { + [description, ...extraLines] = description.split('\n'); + const indentation = ' '; + extraLinesStr = extraLines + .filter((l) => l.trim().length > 0) + .map((l) => `${indentation}${l}`) + .join('\n'); + } -function formatChange( - change: ChangelogChange, - changelogRenderOptions: DefaultChangelogRenderOptions, - isVersionPlans: boolean, - repoData?: GithubRepoData -): string { - 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'); + let changeLine = + '- ' + + (!this.isVersionPlans && change.isBreaking ? '⚠️ ' : '') + + (!this.isVersionPlans && change.scope + ? `**${change.scope.trim()}:** ` + : '') + + description; + if (this.repoData && this.changelogRenderOptions.commitReferences) { + changeLine += formatReferences(change.githubReferences, this.repoData); + } + if (extraLinesStr) { + changeLine += '\n\n' + extraLinesStr; + } + return changeLine; } - /** - * 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 += formatReferences(change.githubReferences, repoData); - } - if (extraLinesStr) { - changeLine += '\n\n' + extraLinesStr; + protected groupChangesByType(): Record { + const typeGroups: Record = {}; + for (const change of this.relevantChanges) { + typeGroups[change.type] = typeGroups[change.type] || []; + typeGroups[change.type].push(change); + } + return typeGroups; } - 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: string): string | null { - if (!message) { - return null; + protected groupChangesByScope( + changes: ChangelogChange[] + ): Record { + const scopeGroups: Record = {}; + for (const change of changes) { + const scope = change.scope || ''; + scopeGroups[scope] = scopeGroups[scope] || []; + scopeGroups[scope].push(change); + } + return scopeGroups; } - const breakingChangeIdentifier = 'BREAKING CHANGE:'; - const startIndex = message.indexOf(breakingChangeIdentifier); + protected extractBreakingChangeExplanation(message: string): string | null { + if (!message) { + return null; + } - if (startIndex === -1) { - // "BREAKING CHANGE:" not found in the message - return null; - } + const breakingChangeIdentifier = 'BREAKING CHANGE:'; + const startIndex = message.indexOf(breakingChangeIdentifier); - const startOfBreakingChange = startIndex + breakingChangeIdentifier.length; - const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange); + if (startIndex === -1) { + return null; + } - if (endOfBreakingChange === -1) { - // No newline character found, extract till the end of the message - return message.substring(startOfBreakingChange).trim(); - } + const startOfBreakingChange = startIndex + breakingChangeIdentifier.length; + const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange); - // Extract and return the breaking change message - return message.substring(startOfBreakingChange, endOfBreakingChange).trim(); -} + if (endOfBreakingChange === -1) { + return message.substring(startOfBreakingChange).trim(); + } -function createVersionTitle( - version: string, - changelogRenderOptions: DefaultChangelogRenderOptions -) { - // Normalize by removing any leading `v` during comparison - const isMajorVersion = `${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})`; + return message.substring(startOfBreakingChange, endOfBreakingChange).trim(); } - if (isMajorVersion) { - return `# ${version}${maybeDateStr}`; + + protected formatName(name = ''): string { + return name + .split(' ') + .map((p) => p.trim()) + .join(' '); } - return `## ${version}${maybeDateStr}`; } diff --git a/packages/nx/src/command-line/release/changelog.ts b/packages/nx/src/command-line/release/changelog.ts index 6f23caf9b7c7c..fd97f74adb1b3 100644 --- a/packages/nx/src/command-line/release/changelog.ts +++ b/packages/nx/src/command-line/release/changelog.ts @@ -96,8 +96,7 @@ export interface ChangelogChange { body?: string; isBreaking?: boolean; githubReferences?: Reference[]; - // TODO(v20): This should be an array of one or more authors (Co-authored-by is supported at the commit level and should have been supported here) - author?: { name: string; email: string }; + authors?: { name: string; email: string }[]; shortHash?: string; revertedHashes?: string[]; } @@ -303,7 +302,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: '', isBreaking: releaseType.isBreaking, githubReferences, - author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [author], affectedProjects: '*', } : vp.triggeredByProjects.map((project) => { @@ -314,7 +314,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: '', isBreaking: releaseType.isBreaking, githubReferences, - author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [author], affectedProjects: [project], }; }); @@ -362,7 +363,7 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: c.body, isBreaking: c.isBreaking, githubReferences: c.references, - author: c.author, + authors: [c.author], shortHash: c.shortHash, revertedHashes: c.revertedHashes, affectedProjects: '*', @@ -515,13 +516,14 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { const releaseType = versionPlanSemverReleaseTypeToChangelogType(bumpForProject); let githubReferences = []; - let author = undefined; + let authors = []; const parsedCommit = vp.commit ? parseGitCommit(vp.commit, true) : null; if (parsedCommit) { githubReferences = parsedCommit.references; - author = parsedCommit.author; + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors = [parsedCommit.author]; } return { type: releaseType.type, @@ -531,8 +533,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { isBreaking: releaseType.isBreaking, affectedProjects: Object.keys(vp.projectVersionBumps), githubReferences, - author, - }; + authors, + } as ChangelogChange; }) .filter(Boolean); } else { @@ -589,7 +591,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: c.body, isBreaking: c.isBreaking, githubReferences: c.references, - author: c.author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [c.author], shortHash: c.shortHash, revertedHashes: c.revertedHashes, affectedProjects: commitChangesNonProjectFiles( @@ -606,18 +609,12 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { const projectChangelogs = await generateChangelogForProjects({ tree, args, - projectGraph, changes, projectsVersionData, releaseGroup, projects: [project], nxReleaseConfig, projectToAdditionalDependencyBumps, - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - commits: filterHiddenCommits( - commits, - nxReleaseConfig.conventionalCommits - ), }); let hasPushed = false; @@ -688,7 +685,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: '', isBreaking: releaseType.isBreaking, githubReferences, - author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [author], affectedProjects: '*', } : vp.triggeredByProjects.map((project) => { @@ -699,7 +697,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: '', isBreaking: releaseType.isBreaking, githubReferences, - author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [author], affectedProjects: [project], }; }); @@ -745,7 +744,8 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { body: c.body, isBreaking: c.isBreaking, githubReferences: c.references, - author: c.author, + // TODO(JamesHenry): Implement support for Co-authored-by and adding multiple authors + authors: [c.author], shortHash: c.shortHash, revertedHashes: c.revertedHashes, affectedProjects: commitChangesNonProjectFiles( @@ -762,18 +762,12 @@ export function createAPI(overrideReleaseConfig: NxReleaseConfiguration) { const projectChangelogs = await generateChangelogForProjects({ tree, args, - projectGraph, changes, projectsVersionData, releaseGroup, projects: projectNodes, nxReleaseConfig, projectToAdditionalDependencyBumps, - // TODO: remove this after the changelog renderer is refactored to remove coupling with git commits - commits: filterHiddenCommits( - commits, - nxReleaseConfig.conventionalCommits - ), }); let hasPushed = false; @@ -1094,7 +1088,7 @@ async function generateChangelogForWorkspace({ const dryRun = !!args.dryRun; const gitRemote = args.gitRemote; - const changelogRenderer = resolveChangelogRenderer(config.renderer); + const ChangelogRendererClass = resolveChangelogRenderer(config.renderer); let interpolatedTreePath = config.file || ''; if (interpolatedTreePath) { @@ -1121,18 +1115,17 @@ async function generateChangelogForWorkspace({ const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease); - let contents = await changelogRenderer({ - projectGraph, + const changelogRenderer = new ChangelogRendererClass({ changes, - commits, - releaseVersion: releaseVersion.rawVersion, + changelogEntryVersion: releaseVersion.rawVersion, project: null, - repoSlug: githubRepoData?.slug, + isVersionPlans: false, repoData: githubRepoData, entryWhenNoChanges: config.entryWhenNoChanges, changelogRenderOptions: config.renderOptions, conventionalCommitsConfig: nxReleaseConfig.conventionalCommits, }); + let contents = await changelogRenderer.render(); /** * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, @@ -1191,9 +1184,7 @@ async function generateChangelogForWorkspace({ async function generateChangelogForProjects({ tree, args, - projectGraph, changes, - commits, projectsVersionData, releaseGroup, projects, @@ -1202,9 +1193,7 @@ async function generateChangelogForProjects({ }: { tree: Tree; args: ChangelogOptions; - projectGraph: ProjectGraph; changes: ChangelogChange[]; - commits: GitCommit[]; projectsVersionData: VersionData; releaseGroup: ReleaseGroupWithName; projects: ProjectGraphProjectNode[]; @@ -1223,7 +1212,7 @@ async function generateChangelogForProjects({ const dryRun = !!args.dryRun; const gitRemote = args.gitRemote; - const changelogRenderer = resolveChangelogRenderer(config.renderer); + const ChangelogRendererClass = resolveChangelogRenderer(config.renderer); const projectChangelogs: NxReleaseChangelogResult['projectChangelogs'] = {}; @@ -1262,13 +1251,10 @@ async function generateChangelogForProjects({ const githubRepoData = getGitHubRepoData(gitRemote, config.createRelease); - let contents = await changelogRenderer({ - projectGraph, + const changelogRenderer = new ChangelogRendererClass({ changes, - commits, - releaseVersion: releaseVersion.rawVersion, + changelogEntryVersion: releaseVersion.rawVersion, project: project.name, - repoSlug: githubRepoData?.slug, repoData: githubRepoData, entryWhenNoChanges: typeof config.entryWhenNoChanges === 'string' @@ -1279,11 +1265,13 @@ async function generateChangelogForProjects({ }) : false, changelogRenderOptions: config.renderOptions, + isVersionPlans: !!releaseGroup.versionPlans, conventionalCommitsConfig: releaseGroup.versionPlans ? null : nxReleaseConfig.conventionalCommits, dependencyBumps: projectToAdditionalDependencyBumps.get(project.name), }); + let contents = await changelogRenderer.render(); /** * If interactive mode, make the changelog contents available for the user to modify in their editor of choice, diff --git a/packages/nx/src/command-line/release/utils/resolve-changelog-renderer.ts b/packages/nx/src/command-line/release/utils/resolve-changelog-renderer.ts index 0f9ea95e1fa55..43d1c73ee0989 100644 --- a/packages/nx/src/command-line/release/utils/resolve-changelog-renderer.ts +++ b/packages/nx/src/command-line/release/utils/resolve-changelog-renderer.ts @@ -1,4 +1,4 @@ -import type { ChangelogRenderer } from '../../../../release/changelog-renderer'; +import type ChangelogRenderer from '../../../../release/changelog-renderer'; import { registerTsProject } from '../../../plugins/js/utils/register'; import { getRootTsConfigPath } from '../../../plugins/js/utils/typescript'; import { interpolate } from '../../../tasks-runner/utils'; @@ -6,13 +6,13 @@ import { workspaceRoot } from '../../../utils/workspace-root'; export function resolveChangelogRenderer( changelogRendererPath: string -): ChangelogRenderer { +): typeof ChangelogRenderer { const interpolatedChangelogRendererPath = interpolate(changelogRendererPath, { workspaceRoot, }); // Try and load the provided (or default) changelog renderer - let changelogRenderer: ChangelogRenderer; + let ChangelogRendererClass: typeof ChangelogRenderer; let cleanupTranspiler = () => {}; try { const rootTsconfigPath = getRootTsConfigPath(); @@ -20,11 +20,11 @@ export function resolveChangelogRenderer( cleanupTranspiler = registerTsProject(rootTsconfigPath); } const r = require(interpolatedChangelogRendererPath); - changelogRenderer = r.default || r; + ChangelogRendererClass = r.default || r; } catch (err) { throw err; } finally { cleanupTranspiler(); } - return changelogRenderer; + return ChangelogRendererClass; }