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';
  }
}
```
This commit is contained in:
James Henry 2024-10-02 22:20:23 +04:00 committed by GitHub
parent fe01c61635
commit 2c0994ac87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1113 additions and 1182 deletions

View File

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

View File

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

View File

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

View File

@ -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.`

View File

@ -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: {
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: {
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: {
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: {
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: {
authors: [
{
name: 'James Henry',
email: 'jh@example.com',
},
],
body: '"\n\nM\tpackages/pkg-a/src/index.ts\n',
description: 'squashing bugs',
type: 'fix',
@ -132,23 +139,24 @@ describe('defaultChangelogRenderer()', () => {
},
];
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,
const renderer = new DefaultChangelogRenderer({
changes,
releaseVersion: 'v1.1.0',
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
### 🚀 Features
- **pkg-a:** new hotness
@ -167,22 +175,22 @@ describe('defaultChangelogRenderer()', () => {
});
it('should not generate a Thank You section when changelogRenderOptions.authors is false', async () => {
const markdown = await defaultChangelogRenderer({
projectGraph,
const renderer = new DefaultChangelogRenderer({
changes,
// Major version, should use single # for generated heading
releaseVersion: 'v1.0.0',
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
- **pkg-a:** new hotness
@ -200,10 +208,10 @@ describe('defaultChangelogRenderer()', () => {
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',
changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const,
isVersionPlans: false,
changelogRenderOptions: {
authors: true,
},
@ -211,77 +219,66 @@ describe('defaultChangelogRenderer()', () => {
};
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
project: 'pkg-a',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **pkg-a:** new hotness
### 🩹 Fixes
- all packages fixed
- **pkg-a:** squashing bugs
### Thank You
- James Henry"
`);
expect(
await defaultChangelogRenderer({
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
- **pkg-a:** new hotness
### 🩹 Fixes
- all packages fixed
- **pkg-a:** squashing bugs"
`);
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
project: 'pkg-b',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **pkg-b:** brand new thing
- **pkg-b:** and another new capability
- **pkg-b:** brand new thing
### 🩹 Fixes
- all packages fixed
### Thank You
- James Henry"
@ -292,10 +289,12 @@ describe('defaultChangelogRenderer()', () => {
const changes: ChangelogChange[] = [
{
shortHash: '4130f65',
author: {
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',
@ -312,10 +311,12 @@ describe('defaultChangelogRenderer()', () => {
},
{
shortHash: '7dc5ec3',
author: {
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',
@ -332,10 +333,12 @@ describe('defaultChangelogRenderer()', () => {
},
{
shortHash: 'd7a58a2',
author: {
authors: [
{
name: 'Author 3',
email: 'author-3@example.com',
},
],
body: '"\n\nM\tpackages/pkg-a/src/index.ts\n"',
description: 'new hotness',
type: 'feat',
@ -352,10 +355,12 @@ describe('defaultChangelogRenderer()', () => {
},
{
shortHash: 'feace4a',
author: {
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',
@ -372,10 +377,12 @@ describe('defaultChangelogRenderer()', () => {
},
{
shortHash: '6301405',
author: {
authors: [
{
name: 'Author 5',
email: 'author-5@example.com',
},
],
body: '"\n\nM\tpackages/pkg-a/src/index.ts\n',
description: 'squashing bugs',
type: 'fix',
@ -393,10 +400,10 @@ describe('defaultChangelogRenderer()', () => {
];
const otherOpts = {
projectGraph,
changes,
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const,
isVersionPlans: false,
changelogRenderOptions: {
authors: true,
},
@ -404,26 +411,22 @@ describe('defaultChangelogRenderer()', () => {
};
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
project: 'pkg-a',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **pkg-a:** new hotness
### 🩹 Fixes
- all packages fixed
- **pkg-a:** squashing bugs
### Thank You
- Author 1
@ -432,26 +435,22 @@ describe('defaultChangelogRenderer()', () => {
`);
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
project: 'pkg-b',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **pkg-b:** brand new thing
- **pkg-b:** and another new capability
- **pkg-b:** brand new thing
### 🩹 Fixes
- all packages fixed
### Thank You
- Author 1
@ -464,10 +463,10 @@ describe('defaultChangelogRenderer()', () => {
describe('entryWhenNoChanges', () => {
it('should respect the entryWhenNoChanges option for the workspace changelog', async () => {
const otherOpts = {
projectGraph,
changes: [],
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: null, // workspace changelog
isVersionPlans: false,
changelogRenderOptions: {
authors: true,
},
@ -475,10 +474,10 @@ describe('defaultChangelogRenderer()', () => {
};
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
entryWhenNoChanges: 'Nothing at all!',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
@ -486,19 +485,19 @@ describe('defaultChangelogRenderer()', () => {
`);
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
entryWhenNoChanges: false, // should not create an entry
})
}).render()
).toMatchInlineSnapshot(`""`);
});
it('should respect the entryWhenNoChanges option for project changelogs', async () => {
const otherOpts = {
projectGraph,
changes: [],
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: 'pkg-a',
isVersionPlans: false,
changelogRenderOptions: {
authors: true,
},
@ -506,10 +505,10 @@ describe('defaultChangelogRenderer()', () => {
};
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
entryWhenNoChanges: 'Nothing at all!',
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
@ -517,10 +516,10 @@ describe('defaultChangelogRenderer()', () => {
`);
expect(
await defaultChangelogRenderer({
await new DefaultChangelogRenderer({
...otherOpts,
entryWhenNoChanges: false, // should not create an entry
})
}).render()
).toMatchInlineSnapshot(`""`);
});
});
@ -530,10 +529,12 @@ describe('defaultChangelogRenderer()', () => {
const changesWithOnlyRevert: ChangelogChange[] = [
{
shortHash: '6528e88aa',
author: {
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)"',
@ -555,22 +556,21 @@ describe('defaultChangelogRenderer()', () => {
},
];
const markdown = await defaultChangelogRenderer({
projectGraph,
const markdown = await new DefaultChangelogRenderer({
changes: changesWithOnlyRevert,
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: null,
isVersionPlans: false,
entryWhenNoChanges: false,
changelogRenderOptions: {
authors: true,
},
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
});
}).render();
expect(markdown).toMatchInlineSnapshot(`
"## v1.1.0
### Revert
- **release:** Revert "fix(release): do not update dependents when they already use "*" (#20607)"
@ -585,10 +585,12 @@ describe('defaultChangelogRenderer()', () => {
const changesWithRevertAndOriginal: ChangelogChange[] = [
{
shortHash: '6528e88aa',
author: {
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)"',
@ -610,10 +612,12 @@ describe('defaultChangelogRenderer()', () => {
},
{
shortHash: '6d68236d4',
author: {
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',
@ -634,17 +638,17 @@ describe('defaultChangelogRenderer()', () => {
},
];
const markdown = await defaultChangelogRenderer({
projectGraph,
const markdown = await new DefaultChangelogRenderer({
changes: changesWithRevertAndOriginal,
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: null,
isVersionPlans: false,
entryWhenNoChanges: false,
changelogRenderOptions: {
authors: true,
},
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
});
}).render();
expect(markdown).toMatchInlineSnapshot(`""`);
});
@ -654,10 +658,12 @@ describe('defaultChangelogRenderer()', () => {
it('should work for breaking changes with just the ! and no explanation', async () => {
const breakingChangeWithExplanation: ChangelogChange = {
shortHash: '54f2f6ed1',
author: {
authors: [
{
name: 'James Henry',
email: 'jh@example.com',
},
],
body:
'M\tpackages/rxjs/src/internal/observable/dom/WebSocketSubject.ts\n' +
'"',
@ -670,22 +676,21 @@ describe('defaultChangelogRenderer()', () => {
affectedProjects: ['rxjs'],
};
const markdown = await defaultChangelogRenderer({
projectGraph,
const markdown = await new DefaultChangelogRenderer({
changes: [breakingChangeWithExplanation],
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: null,
isVersionPlans: false,
entryWhenNoChanges: false,
changelogRenderOptions: {
authors: true,
},
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
});
}).render();
expect(markdown).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **WebSocketSubject:** no longer extends \`Subject\`.
@ -703,10 +708,12 @@ describe('defaultChangelogRenderer()', () => {
it('should extract the explanation of a breaking change and render it preferentially', async () => {
const breakingChangeWithExplanation: ChangelogChange = {
shortHash: '54f2f6ed1',
author: {
authors: [
{
name: 'James Henry',
email: 'jh@example.com',
},
],
body:
'BREAKING CHANGE: `WebSocketSubject` is no longer `instanceof Subject`. Check for `instanceof WebSocketSubject` instead.\n' +
'"\n' +
@ -722,22 +729,21 @@ describe('defaultChangelogRenderer()', () => {
affectedProjects: ['rxjs'],
};
const markdown = await defaultChangelogRenderer({
projectGraph,
const markdown = await new DefaultChangelogRenderer({
changes: [breakingChangeWithExplanation],
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
project: null,
isVersionPlans: false,
entryWhenNoChanges: false,
changelogRenderOptions: {
authors: true,
},
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
});
}).render();
expect(markdown).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **WebSocketSubject:** no longer extends \`Subject\`.
@ -756,14 +762,14 @@ describe('defaultChangelogRenderer()', () => {
describe('dependency bumps', () => {
it('should render the dependency bumps in addition to the changes', async () => {
expect(
await defaultChangelogRenderer({
projectGraph,
await new DefaultChangelogRenderer({
changes,
releaseVersion: 'v1.1.0',
changelogEntryVersion: 'v1.1.0',
entryWhenNoChanges: false as const,
changelogRenderOptions: {
authors: true,
},
isVersionPlans: false,
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
project: 'pkg-a',
dependencyBumps: [
@ -772,28 +778,23 @@ describe('defaultChangelogRenderer()', () => {
newVersion: '2.0.0',
},
],
})
}).render()
).toMatchInlineSnapshot(`
"## v1.1.0
### 🚀 Features
- **pkg-a:** new hotness
### 🩹 Fixes
- all packages fixed
- **pkg-a:** squashing bugs
### 🧱 Updated Dependencies
- Updated pkg-b to 2.0.0
### Thank You
- James Henry"
@ -802,15 +803,15 @@ describe('defaultChangelogRenderer()', () => {
it('should render the dependency bumps and release version title even when there are no changes', async () => {
expect(
await defaultChangelogRenderer({
projectGraph,
await new DefaultChangelogRenderer({
changes: [],
releaseVersion: 'v3.1.0',
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: [
@ -819,15 +820,57 @@ describe('defaultChangelogRenderer()', () => {
newVersion: '4.0.0',
},
],
})
}).render()
).toMatchInlineSnapshot(`
"## v3.1.0
### 🧱 Updated Dependencies
- Updated pkg-b to 4.0.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,
},
conventionalCommitsConfig: DEFAULT_CONVENTIONAL_COMMITS_CONFIG,
});
const markdown = await renderer.render();
expect(markdown).toMatchInlineSnapshot(`
"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
### Thank You
- James Henry"
`);
});
});
});

View File

@ -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<string, unknown>;
/**
* 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> | 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<string> => {
const markdownLines: string[] = [];
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[];
// 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) {
/**
* 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 = [];
}
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))
);
}
async render(): Promise<string> {
const sections: string[][] = [];
this.preprocessChanges();
if (this.shouldRenderEmptyEntry()) {
return this.renderEmptyEntry();
}
sections.push([this.renderVersionTitle()]);
const changesByType = this.renderChangesByType();
if (changesByType.length > 0) {
sections.push(changesByType);
}
if (this.hasBreakingChanges()) {
sections.push(this.renderBreakingChanges());
}
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 revertedCommit = changes.find(
const revertedCommitIndex = this.relevantChanges.findIndex(
(c) => c.shortHash && revertedHash.startsWith(c.shortHash)
);
if (revertedCommit) {
changes.splice(changes.indexOf(revertedCommit), 1);
changes.splice(changes.indexOf(change), 1);
if (revertedCommitIndex !== -1) {
this.relevantChanges.splice(revertedCommitIndex, 1);
this.relevantChanges.splice(i, 1);
i--;
break;
}
}
}
}
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 = {
if (this.isVersionPlans) {
this.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);
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);
}
}
}
const changeTypes = conventionalCommitsConfig.types;
// workspace root level changelog
if (project === null) {
// No changes for the workspace
if (relevantChanges.length === 0 && breakingChanges.length === 0) {
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
});
} else if (entryWhenNoChanges) {
markdownLines.push(
'',
`${createVersionTitle(
releaseVersion,
changelogRenderOptions
)}\n\n${entryWhenNoChanges}`,
''
);
}
return markdownLines.join('\n').trim();
}
const typeGroups: Record<string, ChangelogChange[]> = groupBy(
relevantChanges,
'type'
);
markdownLines.push(
'',
createVersionTitle(releaseVersion, changelogRenderOptions),
''
);
for (const type of Object.keys(changeTypes)) {
const group = typeGroups[type];
if (!group || group.length === 0) {
continue;
}
markdownLines.push('', '### ' + changeTypes[type].changelog.title, '');
/**
* In order to make the final changelog most readable, we organize changes as follows:
* - By scope, where scopes are in alphabetical order (changes with no scope are listed first)
* - Within a particular scope grouping, we list changes in chronological order
*/
const changesInChronologicalOrder = group.reverse();
const changesGroupedByScope: Record<string, ChangelogChange[]> = groupBy(
changesInChronologicalOrder,
'scope'
);
const scopesSortedAlphabetically = Object.keys(
changesGroupedByScope
).sort();
for (const scope of scopesSortedAlphabetically) {
const changes = changesGroupedByScope[scope];
for (const change of changes) {
const line = formatChange(
change,
changelogRenderOptions,
isVersionPlans,
repoData
);
markdownLines.push(line);
} else {
for (const change of this.relevantChanges) {
if (change.isBreaking) {
const breakingChangeExplanation = extractBreakingChangeExplanation(
change.body
);
breakingChanges.push(
const breakingChangeExplanation =
this.extractBreakingChangeExplanation(change.body);
this.breakingChanges.push(
breakingChangeExplanation
? `- ${
change.scope ? `**${change.scope.trim()}:** ` : ''
}${breakingChangeExplanation}`
: line
: this.formatChange(change)
);
}
}
}
}
} 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(
protected shouldRenderEmptyEntry(): boolean {
return (
this.relevantChanges.length === 0 &&
this.breakingChanges.length === 0 &&
!this.hasDependencyBumps()
);
}
protected renderEmptyEntry(): string {
if (this.hasDependencyBumps()) {
return [
this.renderVersionTitle(),
'',
`${createVersionTitle(
releaseVersion,
changelogRenderOptions
)}\n\n${entryWhenNoChanges}`,
''
);
...this.renderDependencyBumps(),
].join('\n');
} else if (this.entryWhenNoChanges) {
return `${this.renderVersionTitle()}\n\n${this.entryWhenNoChanges}`;
}
return markdownLines.join('\n').trim();
return '';
}
markdownLines.push(
'',
createVersionTitle(releaseVersion, changelogRenderOptions),
''
);
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<string, ChangelogChange[]> = 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,20 +267,39 @@ 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
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
);
markdownLines.push(line + '\n');
if (change.isBreaking) {
const breakingChangeExplanation = extractBreakingChangeExplanation(
change.body
);
breakingChanges.push(
}
}
}
} 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()}:** ` : ''
@ -319,49 +311,67 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
}
}
if (breakingChanges.length > 0) {
markdownLines.push('', '### ⚠️ Breaking Changes', '', ...breakingChanges);
return markdownLines;
}
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
protected hasBreakingChanges(): boolean {
return this.breakingChanges.length > 0;
}
protected renderBreakingChanges(): string[] {
const uniqueBreakingChanges = Array.from(new Set(this.breakingChanges));
return ['### ⚠️ Breaking Changes', '', ...uniqueBreakingChanges];
}
protected hasDependencyBumps(): boolean {
return this.dependencyBumps && this.dependencyBumps.length > 0;
}
protected renderDependencyBumps(): string[] {
const markdownLines = ['', '### 🧱 Updated Dependencies', ''];
this.dependencyBumps.forEach(({ dependencyName, newVersion }) => {
markdownLines.push(`- Updated ${dependencyName} to ${newVersion}`);
});
return markdownLines;
}
if (changelogRenderOptions.authors) {
protected shouldRenderAuthors(): boolean {
return this.changelogRenderOptions.authors;
}
protected async renderAuthors(): Promise<string[]> {
const markdownLines: string[] = [];
const _authors = new Map<string, { email: Set<string>; 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);
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(change.author.email);
entry.email.add(author.email);
} else {
_authors.set(name, { email: new Set([change.author.email]) });
_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<any, { data?: { user?: { username: string } } }>(
`https://ungh.cc/users/find/${email}`
@ -397,77 +406,23 @@ 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;
}
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('');
}
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;
}
function formatChange(
change: ChangelogChange,
changelogRenderOptions: DefaultChangelogRenderOptions,
isVersionPlans: boolean,
repoData?: GithubRepoData
): string {
protected formatChange(change: ChangelogChange): 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)
@ -475,31 +430,44 @@ function formatChange(
.join('\n');
}
/**
* In version plans changelogs:
* - don't repeat the breaking change icon
* - don't render the scope
*/
let changeLine =
'- ' +
(!isVersionPlans && change.isBreaking ? '⚠️ ' : '') +
(!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') +
(!this.isVersionPlans && change.isBreaking ? '⚠️ ' : '') +
(!this.isVersionPlans && change.scope
? `**${change.scope.trim()}:** `
: '') +
description;
if (repoData && changelogRenderOptions.commitReferences) {
changeLine += formatReferences(change.githubReferences, repoData);
if (this.repoData && this.changelogRenderOptions.commitReferences) {
changeLine += formatReferences(change.githubReferences, this.repoData);
}
if (extraLinesStr) {
changeLine += '\n\n' + extraLinesStr;
}
return changeLine;
}
}
/**
* It is common to add further information about a breaking change in the commit body,
* and it is naturally that information that should be included in the BREAKING CHANGES
* section of changelog, rather than repeating the commit title/description.
*/
function extractBreakingChangeExplanation(message: string): string | null {
protected groupChangesByType(): Record<string, ChangelogChange[]> {
const typeGroups: Record<string, ChangelogChange[]> = {};
for (const change of this.relevantChanges) {
typeGroups[change.type] = typeGroups[change.type] || [];
typeGroups[change.type].push(change);
}
return typeGroups;
}
protected groupChangesByScope(
changes: ChangelogChange[]
): Record<string, ChangelogChange[]> {
const scopeGroups: Record<string, ChangelogChange[]> = {};
for (const change of changes) {
const scope = change.scope || '';
scopeGroups[scope] = scopeGroups[scope] || [];
scopeGroups[scope].push(change);
}
return scopeGroups;
}
protected extractBreakingChangeExplanation(message: string): string | null {
if (!message) {
return null;
}
@ -508,7 +476,6 @@ function extractBreakingChangeExplanation(message: string): string | null {
const startIndex = message.indexOf(breakingChangeIdentifier);
if (startIndex === -1) {
// "BREAKING CHANGE:" not found in the message
return null;
}
@ -516,28 +483,16 @@ function extractBreakingChangeExplanation(message: string): string | null {
const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange);
if (endOfBreakingChange === -1) {
// No newline character found, extract till the end of the message
return message.substring(startOfBreakingChange).trim();
}
// Extract and return the breaking change message
return message.substring(startOfBreakingChange, endOfBreakingChange).trim();
}
}
function createVersionTitle(
version: 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})`;
protected formatName(name = ''): string {
return name
.split(' ')
.map((p) => p.trim())
.join(' ');
}
if (isMajorVersion) {
return `# ${version}${maybeDateStr}`;
}
return `## ${version}${maybeDateStr}`;
}

View File

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

View File

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