Skip to content

Commit

Permalink
feat(ng-dev/release): support prepending new release note entries to …
Browse files Browse the repository at this point in the history
…the changelog (#204)

Support prepending the release note entries to the changelog.md file. Additionally, we
try to run the formatter on the changelog file to ensure that if formatting is required
for the file it is completed.

Additionally, updating the `ng-dev release notes` command to leverage the newly created
`prependEntryToChangelog` method.

PR Close #204
  • Loading branch information
josephperrott authored and devversion committed Sep 9, 2021
1 parent 75f95e8 commit 8747538
Show file tree
Hide file tree
Showing 8 changed files with 2,918 additions and 80 deletions.
1 change: 1 addition & 0 deletions ng-dev/release/notes/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ts_library(
],
deps = [
"//ng-dev/commit-message",
"//ng-dev/format",
"//ng-dev/release/config",
"//ng-dev/release/versioning",
"//ng-dev/utils",
Expand Down
41 changes: 20 additions & 21 deletions ng-dev/release/notes/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {writeFileSync} from 'fs';
import {join} from 'path';
import {SemVer} from 'semver';
import {Arguments, Argv, CommandModule} from 'yargs';

Expand All @@ -16,16 +13,16 @@ import {info} from '../../utils/console';
import {ReleaseNotes} from './release-notes';

/** Command line options for building a release. */
export interface ReleaseNotesOptions {
export interface Options {
from: string;
to: string;
outFile?: string;
updateChangelog: boolean;
releaseVersion: SemVer;
type: 'github-release' | 'changelog';
}

/** Yargs command builder for configuring the `ng-dev release build` command. */
function builder(argv: Argv): Argv<ReleaseNotesOptions> {
function builder(argv: Argv): Argv<Options> {
return argv
.option('releaseVersion', {
type: 'string',
Expand All @@ -48,33 +45,35 @@ function builder(argv: Argv): Argv<ReleaseNotesOptions> {
choices: ['github-release', 'changelog'] as const,
default: 'changelog' as const,
})
.option('outFile', {
type: 'string',
description: 'File location to write the generated release notes to',
coerce: (filePath?: string) => (filePath ? join(process.cwd(), filePath) : undefined),
.option('updateChangelog', {
type: 'boolean',
default: false,
description: 'Whether to update the changelog with the newly created entry',
});
}

/** Yargs command handler for generating release notes. */
async function handler({releaseVersion, from, to, outFile, type}: Arguments<ReleaseNotesOptions>) {
async function handler({releaseVersion, from, to, updateChangelog, type}: Arguments<Options>) {
/** The ReleaseNotes instance to generate release notes. */
const releaseNotes = await ReleaseNotes.forRange(releaseVersion, from, to);

if (updateChangelog) {
await releaseNotes.prependEntryToChangelog();
info(`Added release notes for "${releaseVersion}" to the changelog`);
return;
}

/** The requested release notes entry. */
const releaseNotesEntry = await (type === 'changelog'
? releaseNotes.getChangelogEntry()
: releaseNotes.getGithubReleaseEntry());
const releaseNotesEntry =
type === 'changelog'
? await releaseNotes.getChangelogEntry()
: await releaseNotes.getGithubReleaseEntry();

if (outFile) {
writeFileSync(outFile, releaseNotesEntry);
info(`Generated release notes for "${releaseVersion}" written to ${outFile}`);
} else {
process.stdout.write(releaseNotesEntry);
}
process.stdout.write(releaseNotesEntry);
}

/** CLI command module for generating release notes. */
export const ReleaseNotesCommandModule: CommandModule<{}, ReleaseNotesOptions> = {
export const ReleaseNotesCommandModule: CommandModule<{}, Options> = {
builder,
handler,
command: 'notes',
Expand Down
66 changes: 45 additions & 21 deletions ng-dev/release/notes/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,43 @@ import * as semver from 'semver';
import {CommitFromGitLog} from '../../commit-message/parse';

import {promptInput} from '../../utils/console';
import {formatFiles} from '../../format/format';
import {GitClient} from '../../utils/git/git-client';
import {assertValidReleaseConfig, ReleaseNotesConfig} from '../config/index';
import {assertValidReleaseConfig, ReleaseConfig, ReleaseNotesConfig} from '../config/index';
import {RenderContext} from './context';

import changelogTemplate from './templates/changelog';
import githubReleaseTemplate from './templates/github-release';
import {getCommitsForRangeWithDeduping} from './commits/get-commits-in-range';
import {getConfig} from '../../utils/config';
import {existsSync, readFileSync, writeFileSync} from 'fs';
import {join} from 'path';
import {assertValidFormatConfig} from '../../format/config';

/** Release note generation. */
export class ReleaseNotes {
static async forRange(version: semver.SemVer, baseRef: string, headRef: string) {
const client = GitClient.get();
const commits = getCommitsForRangeWithDeduping(client, baseRef, headRef);
return new ReleaseNotes(version, commits);
const git = GitClient.get();
const commits = getCommitsForRangeWithDeduping(git, baseRef, headRef);
return new ReleaseNotes(version, commits, git);
}

/** An instance of GitClient. */
private git = GitClient.get();
/** The RenderContext to be used during rendering. */
private renderContext: RenderContext | undefined;
/** The title to use for the release. */
private title: string | false | undefined;
/** The configuration for release notes. */
private config: ReleaseNotesConfig = this.getReleaseConfig().releaseNotes ?? {};
/** The configuration ng-dev. */
private config: {release: ReleaseConfig} = getConfig([assertValidReleaseConfig]);
/** The configuration for the release notes. */
private get notesConfig() {
return this.config.release.releaseNotes || {};
}

protected constructor(public version: semver.SemVer, private commits: CommitFromGitLog[]) {}
protected constructor(
public version: semver.SemVer,
private commits: CommitFromGitLog[],
private git: GitClient,
) {}

/** Retrieve the release note generated for a Github Release. */
async getGithubReleaseEntry(): Promise<string> {
Expand All @@ -50,6 +60,28 @@ export class ReleaseNotes {
return render(changelogTemplate, await this.generateRenderContext(), {rmWhitespace: true});
}

/** Prepends the generated release note to the CHANGELOG file. */
async prependEntryToChangelog() {
/** The fully path to the changelog file. */
const filePath = join(this.git.baseDir, 'CHANGELOG.md');
/** The changelog contents in the current changelog. */
let changelog = '';
if (existsSync(filePath)) {
changelog = readFileSync(filePath, {encoding: 'utf8'});
}
/** The new changelog entry to add to the changelog. */
const entry = await this.getChangelogEntry();

writeFileSync(filePath, `${entry}\n\n${changelog}`);

try {
assertValidFormatConfig(this.config);
await formatFiles([filePath]);
} catch {
// If the formatting is either unavailable or fails, continue on with the unformatted result.
}
}

/** Retrieve the number of commits included in the release notes after filtering and deduping. */
async getCommitCountInReleaseNotes() {
const context = await this.generateRenderContext();
Expand All @@ -70,7 +102,7 @@ export class ReleaseNotes {
*/
async promptForReleaseTitle() {
if (this.title === undefined) {
if (this.config.useReleaseTitle) {
if (this.notesConfig.useReleaseTitle) {
this.title = await promptInput('Please provide a title for the release:');
} else {
this.title = false;
Expand All @@ -86,20 +118,12 @@ export class ReleaseNotes {
commits: this.commits,
github: this.git.remoteConfig,
version: this.version.format(),
groupOrder: this.config.groupOrder,
hiddenScopes: this.config.hiddenScopes,
categorizeCommit: this.config.categorizeCommit,
groupOrder: this.notesConfig.groupOrder,
hiddenScopes: this.notesConfig.hiddenScopes,
categorizeCommit: this.notesConfig.categorizeCommit,
title: await this.promptForReleaseTitle(),
});
}
return this.renderContext;
}

// This method is used for access to the utility functions while allowing them
// to be overwritten in subclasses during testing.
protected getReleaseConfig() {
const config = getConfig();
assertValidReleaseConfig(config);
return config.release;
}
}
9 changes: 5 additions & 4 deletions ng-dev/release/publish/test/common.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import {getMockGitClient} from './test-utils/git-client-mock';
import {CommitFromGitLog, parseCommitFromGitLog} from '../../../commit-message/parse';
import {SandboxGitRepo} from './test-utils/sandbox-testing';
import { GitClient } from '../../../utils/git/git-client';

describe('common release action logic', () => {
const baseReleaseTrains: ActiveReleaseTrains = {
Expand Down Expand Up @@ -142,7 +143,7 @@ describe('common release action logic', () => {
});

it('should link to the changelog in the release entry if notes are too large', async () => {
const {repo, instance} = setupReleaseActionForTesting(TestAction, baseReleaseTrains);
const {repo, instance, gitClient} = setupReleaseActionForTesting(TestAction, baseReleaseTrains);
const {version, branchName} = baseReleaseTrains.latest;
const tagName = version.format();
const testCommit = parseCommitFromGitLog(Buffer.from('fix(test): test'));
Expand All @@ -157,7 +158,7 @@ describe('common release action logic', () => {
testCommit.subject = exceedingText;

spyOn(ReleaseNotes, 'forRange').and.callFake(
async () => new MockReleaseNotes(version, [testCommit]),
async () => new MockReleaseNotes(version, [testCommit], gitClient)
);

repo
Expand Down Expand Up @@ -246,8 +247,8 @@ describe('common release action logic', () => {

/** Mock class for `ReleaseNotes` which accepts a list of in-memory commit objects. */
class MockReleaseNotes extends ReleaseNotes {
constructor(version: SemVer, commits: CommitFromGitLog[]) {
super(version, commits);
constructor(version: SemVer, commits: CommitFromGitLog[], git: GitClient) {
super(version, commits, git);
}
}

Expand Down
42 changes: 37 additions & 5 deletions ng-dev/release/publish/test/release-notes/generation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
*/

import {installSandboxGitClient, SandboxGitClient} from '../test-utils/sandbox-git-client';
import {mkdirSync, rmdirSync} from 'fs';
import {readFileSync, writeFileSync} from 'fs';
import {prepareTempDirectory, testTmpDir} from '../test-utils/action-mocks';
import {getMockGitClient} from '../test-utils/git-client-mock';
import {GithubConfig} from '../../../../utils/config';
import {GithubConfig, setConfig} from '../../../../utils/config';
import {SandboxGitRepo} from '../test-utils/sandbox-testing';
import {ReleaseNotes} from '../../../notes/release-notes';
import {ReleaseConfig} from '../../../config';
import {changelogPattern, parse} from '../test-utils/test-utils';
import { buildDateStamp } from '../../../notes/context';
import { dedent } from '../../../../utils/testing/dedent';

describe('release notes generation', () => {
let releaseConfig: ReleaseConfig;
Expand All @@ -28,12 +30,10 @@ describe('release notes generation', () => {

releaseConfig = {npmPackages: [], buildPackages: async () => []};
githubConfig = {owner: 'angular', name: 'dev-infra-test', mainBranchName: 'main'};
setConfig({github: githubConfig, release: releaseConfig});
client = getMockGitClient(githubConfig, /* useSandboxGitClient */ true);

installSandboxGitClient(client);

// Ensure the `ReleaseNotes` class picks up the fake release config for testing.
spyOn(ReleaseNotes.prototype as any, 'getReleaseConfig').and.callFake(() => releaseConfig);
});

describe('changelog', () => {
Expand Down Expand Up @@ -436,4 +436,36 @@ describe('release notes generation', () => {

expect(await releaseNotes.getCommitCountInReleaseNotes()).toBe(4);
});

describe('updates the changelog file', () => {
it('prepending the entry', async () => {
writeFileSync(`${testTmpDir}/CHANGELOG.md`, '<Previous Changelog Entries>');

const sandboxRepo = SandboxGitRepo.withInitialCommit(githubConfig)
.createTagForHead('startTag')
.commit('fix(ng-dev): commit *1', 1);

const fullSha = sandboxRepo.getShaForCommitId(1, 'long');
const shortSha = sandboxRepo.getShaForCommitId(1, 'short');

const releaseNotes = await ReleaseNotes.forRange(parse('13.0.0'), 'startTag', 'HEAD');
await releaseNotes.prependEntryToChangelog();

const changelog = readFileSync(`${testTmpDir}/CHANGELOG.md`, 'utf8');

expect(changelog).toBe(dedent`
<a name="13.0.0"></a>
# 13.0.0 (${buildDateStamp()})
### ng-dev
| Commit | Type | Description |
| -- | -- | -- |
| [${shortSha}](https://github.com/angular/dev-infra-test/commit/${fullSha}) | fix | commit *1 |
## Special Thanks
Angular Robot
<Previous Changelog Entries>`.trim()
);
});
});
});
8 changes: 4 additions & 4 deletions ng-dev/release/publish/test/test-utils/action-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ import * as externalCommands from '../../external-commands';
import * as console from '../../../../utils/console';

import {ReleaseAction} from '../../actions';
import {GithubConfig} from '../../../../utils/config';
import {GithubConfig, setConfig} from '../../../../utils/config';
import {ReleaseConfig} from '../../../config';
import {installVirtualGitClientSpies, VirtualGitClient} from '../../../../utils/testing';
import {installSandboxGitClient} from './sandbox-git-client';
import {ReleaseNotes} from '../../../notes/release-notes';
import {getMockGitClient} from './git-client-mock';

/**
Expand Down Expand Up @@ -69,6 +68,9 @@ export function setupMocksForReleaseAction<T extends boolean>(
// to persist between tests if the sandbox git client is used.
prepareTempDirectory();

// Set the configuration to be used throughout the spec.
setConfig({github: githubConfig, release: releaseConfig});

// Fake confirm any prompts. We do not want to make any changelog edits and
// just proceed with the release action.
spyOn(console, 'promptConfirm').and.resolveTo(true);
Expand All @@ -81,8 +83,6 @@ export function setupMocksForReleaseAction<T extends boolean>(
testReleasePackages.map((name) => ({name, outputPath: `${testTmpDir}/dist/${name}`})),
);

spyOn(ReleaseNotes.prototype as any, 'getReleaseConfig').and.returnValue(releaseConfig);

// Fake checking the package versions since we don't actually create NPM
// package output that can be tested.
spyOn(ReleaseAction.prototype, '_verifyPackageVersions' as any).and.resolveTo();
Expand Down
17 changes: 13 additions & 4 deletions ng-dev/release/publish/test/test-utils/sandbox-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,23 @@ export class SandboxGitRepo {
}

/** Cherry-picks a commit into the current branch. */
cherryPick(commitId: number) {
cherryPick(commitId: number): this {
runGitInTmpDir(['cherry-pick', '--allow-empty', this.getShaForCommitId(commitId)]);
return this;
}

/** Retrieve the sha for the commit. */
getShaForCommitId(commitId: number, type: 'long'|'short' = 'long'): string {
const commitSha = this._commitShaById.get(commitId);

if (commitSha === undefined) {
throw Error('Unable to cherry-pick. Unknown commit id.');
throw Error('Unable to get determine SHA due to an unknown commit id.');
}

runGitInTmpDir(['cherry-pick', '--allow-empty', commitSha]);
return this;
if (type === 'short') {
return runGitInTmpDir(['rev-parse', '--short', commitSha]);
}

return commitSha;
}
}
Loading

0 comments on commit 8747538

Please sign in to comment.