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

File diff suppressed because it is too large Load Diff

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,112 +55,209 @@ 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) {
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);
/**
* 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 revertedCommitIndex = this.relevantChanges.findIndex(
(c) => c.shortHash && revertedHash.startsWith(c.shortHash)
);
if (revertedCommitIndex !== -1) {
this.relevantChanges.splice(revertedCommitIndex, 1);
this.relevantChanges.splice(i, 1);
i--;
break;
}
}
}
}
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)
);
}
}
}
}
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 shouldRenderEmptyEntry(): boolean {
return (
this.relevantChanges.length === 0 &&
this.breakingChanges.length === 0 &&
!this.hasDependencyBumps()
);
}
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();
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 '';
}
const typeGroups: Record<string, ChangelogChange[]> = groupBy(
relevantChanges,
'type'
);
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}`;
}
markdownLines.push(
'',
createVersionTitle(releaseVersion, changelogRenderOptions),
''
);
protected renderChangesByType(): string[] {
const markdownLines: string[] = [];
const typeGroups = this.groupChangesByType();
const changeTypes = this.conventionalCommitsConfig.types;
for (const type of Object.keys(changeTypes)) {
const group = typeGroups[type];
@ -204,37 +265,41 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
continue;
}
markdownLines.push('', '### ' + changeTypes[type].changelog.title, '');
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();
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) {
const line = formatChange(
change,
changelogRenderOptions,
isVersionPlans,
repoData
);
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) {
const breakingChangeExplanation = extractBreakingChangeExplanation(
change.body
);
breakingChanges.push(
if (change.isBreaking && !this.isVersionPlans) {
const breakingChangeExplanation =
this.extractBreakingChangeExplanation(change.body);
this.breakingChanges.push(
breakingChangeExplanation
? `- ${
change.scope ? `**${change.scope.trim()}:** ` : ''
@ -245,123 +310,68 @@ const defaultChangelogRenderer: ChangelogRenderer = async ({
}
}
}
} else {
// project level changelog
relevantChanges = relevantChanges.filter(
(c) =>
c.affectedProjects &&
(c.affectedProjects === '*' || c.affectedProjects.includes(project))
);
// Generating for a named project, but that project has no relevant changes in the current set of commits, exit early
if (relevantChanges.length === 0 && breakingChanges.length === 0) {
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
});
} else if (entryWhenNoChanges) {
markdownLines.push(
'',
`${createVersionTitle(
releaseVersion,
changelogRenderOptions
)}\n\n${entryWhenNoChanges}`,
''
);
}
return markdownLines.join('\n').trim();
}
markdownLines.push(
'',
createVersionTitle(releaseVersion, changelogRenderOptions),
''
);
const typeGroups: 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) {
continue;
}
markdownLines.push('', `### ${changeTypes[type].changelog.title}`, '');
const changesInChronologicalOrder = group.reverse();
for (const change of changesInChronologicalOrder) {
const line = formatChange(
change,
changelogRenderOptions,
isVersionPlans,
repoData
);
markdownLines.push(line + '\n');
if (change.isBreaking) {
const breakingChangeExplanation = extractBreakingChangeExplanation(
change.body
);
breakingChanges.push(
breakingChangeExplanation
? `- ${
change.scope ? `**${change.scope.trim()}:** ` : ''
}${breakingChangeExplanation}`
: line
);
}
}
}
return markdownLines;
}
if (breakingChanges.length > 0) {
markdownLines.push('', '### ⚠️ Breaking Changes', '', ...breakingChanges);
protected hasBreakingChanges(): boolean {
return this.breakingChanges.length > 0;
}
if (dependencyBumps?.length) {
applyAdditionalDependencyBumps({
markdownLines,
dependencyBumps,
releaseVersion,
changelogRenderOptions,
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);
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<any, { data?: { user?: { username: string } } }>(
`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;
}
return markdownLines.join('\n').trim();
};
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');
}
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('');
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;
}
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;
}
const breakingChangeIdentifier = 'BREAKING CHANGE:';
const startIndex = message.indexOf(breakingChangeIdentifier);
if (startIndex === -1) {
return null;
}
const startOfBreakingChange = startIndex + breakingChangeIdentifier.length;
const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange);
if (endOfBreakingChange === -1) {
return message.substring(startOfBreakingChange).trim();
}
return message.substring(startOfBreakingChange, endOfBreakingChange).trim();
}
protected formatName(name = ''): string {
return name
.split(' ')
.map((p) => p.trim())
.join(' ');
}
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 {
let description = change.description;
let extraLines = [];
let extraLinesStr = '';
if (description.includes('\n')) {
[description, ...extraLines] = description.split('\n');
// Align the extra lines with the start of the description for better readability
const indentation = ' ';
extraLinesStr = extraLines
.filter((l) => l.trim().length > 0)
.map((l) => `${indentation}${l}`)
.join('\n');
}
/**
* In version plans changelogs:
* - don't repeat the breaking change icon
* - don't render the scope
*/
let changeLine =
'- ' +
(!isVersionPlans && change.isBreaking ? '⚠️ ' : '') +
(!isVersionPlans && change.scope ? `**${change.scope.trim()}:** ` : '') +
description;
if (repoData && changelogRenderOptions.commitReferences) {
changeLine += formatReferences(change.githubReferences, repoData);
}
if (extraLinesStr) {
changeLine += '\n\n' + extraLinesStr;
}
return changeLine;
}
/**
* It is common to add further information about a breaking change in the commit body,
* and it is naturally that information that should be included in the BREAKING CHANGES
* section of changelog, rather than repeating the commit title/description.
*/
function extractBreakingChangeExplanation(message: string): string | null {
if (!message) {
return null;
}
const breakingChangeIdentifier = 'BREAKING CHANGE:';
const startIndex = message.indexOf(breakingChangeIdentifier);
if (startIndex === -1) {
// "BREAKING CHANGE:" not found in the message
return null;
}
const startOfBreakingChange = startIndex + breakingChangeIdentifier.length;
const endOfBreakingChange = message.indexOf('\n', startOfBreakingChange);
if (endOfBreakingChange === -1) {
// No newline character found, extract till the end of the message
return message.substring(startOfBreakingChange).trim();
}
// Extract and return the breaking change message
return message.substring(startOfBreakingChange, endOfBreakingChange).trim();
}
function createVersionTitle(
version: 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})`;
}
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;
}