diff --git a/dev-infra/BUILD.bazel b/dev-infra/BUILD.bazel index b3ca536634..a45de570d1 100644 --- a/dev-infra/BUILD.bazel +++ b/dev-infra/BUILD.bazel @@ -44,6 +44,7 @@ pkg_npm( "index.bzl", "//dev-infra/bazel:files", "//dev-infra/benchmark:files", + "//dev-infra/release/publish/release-notes/templates", ], substitutions = { # angular/angular should not consume it's own packages, so we use diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index 1ce1b0340e..0ceb6c45bb 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -25,6 +25,7 @@ var cliProgress = require('cli-progress'); var os = require('os'); var minimatch = require('minimatch'); var ora = require('ora'); +require('ejs'); var glob = require('glob'); var ts = require('typescript'); @@ -5580,6 +5581,11 @@ function isCommitClosingPullRequest(api, sha, id) { * 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 */ +/** List of types to be included in the release notes. */ +const typesToIncludeInReleaseNotes = Object.values(COMMIT_TYPES) + .filter(type => type.releaseNotesLevel === ReleaseNotesLevel.Visible) + .map(type => type.name); + /** * Gets the default pattern for extracting release notes for the given version. * This pattern matches for the conventional-changelog Angular preset. diff --git a/dev-infra/release/config/index.ts b/dev-infra/release/config/index.ts index e8fb95909a..de0602f294 100644 --- a/dev-infra/release/config/index.ts +++ b/dev-infra/release/config/index.ts @@ -36,6 +36,25 @@ export interface ReleaseConfig { extractReleaseNotesPattern?: (version: semver.SemVer) => RegExp; /** The list of github labels to add to the release PRs. */ releasePrLabels?: string[]; + /** Configuration for creating release notes during publishing. */ + // TODO(josephperrott): Make releaseNotes a required attribute on the interface when tooling is + // integrated. + releaseNotes?: ReleaseNotesConfig; +} + +/** Configuration for creating release notes during publishing. */ +export interface ReleaseNotesConfig { + /** Whether to prompt for and include a release title in the generated release notes. */ + useReleaseTitle?: boolean; + /** List of commit scopes to disclude from generated release notes. */ + hiddenScopes?: string[]; + /** + * List of commit groups, either {npmScope}/{scope} or {scope}, to use for ordering. + * + * Each group for the release notes, will appear in the order provided in groupOrder and any other + * groups will appear after these groups, sorted by `Array.sort`'s default sorting order. + */ + groupOrder?: string[]; } /** Configuration for releases in the dev-infra configuration. */ diff --git a/dev-infra/release/publish/BUILD.bazel b/dev-infra/release/publish/BUILD.bazel index a2f62b7dc5..0663649a4c 100644 --- a/dev-infra/release/publish/BUILD.bazel +++ b/dev-infra/release/publish/BUILD.bazel @@ -8,15 +8,18 @@ ts_library( module_name = "@angular/dev-infra-private/release/publish", visibility = ["//dev-infra:__subpackages__"], deps = [ + "//dev-infra/commit-message", "//dev-infra/pr/merge", "//dev-infra/release/config", "//dev-infra/release/versioning", "//dev-infra/utils", "@npm//@octokit/rest", + "@npm//@types/ejs", "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/semver", "@npm//@types/yargs", + "@npm//ejs", "@npm//inquirer", "@npm//ora", "@npm//semver", diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts index af50f41a11..40b5aa1d29 100644 --- a/dev-infra/release/publish/actions.ts +++ b/dev-infra/release/publish/actions.ts @@ -14,7 +14,7 @@ import * as semver from 'semver'; import {debug, error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console'; import {getListCommitsInBranchUrl, getRepositoryGitUrl} from '../../utils/git/github-urls'; import {GitClient} from '../../utils/git/index'; -import {BuiltPackage, ReleaseConfig} from '../config'; +import {BuiltPackage, ReleaseConfig} from '../config/index'; import {ActiveReleaseTrains} from '../versioning/active-release-trains'; import {runNpmPublish} from '../versioning/npm-publish'; @@ -24,7 +24,7 @@ import {changelogPath, packageJsonPath, waitForPullRequestInterval} from './cons import {invokeBazelCleanCommand, invokeReleaseBuildCommand, invokeYarnInstallCommand} from './external-commands'; import {findOwnedForksOfRepoQuery} from './graphql-queries'; import {getPullRequestState} from './pull-request-state'; -import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes'; +import {getDefaultExtractReleaseNotesPattern, getLocalChangelogFilePath} from './release-notes/release-notes'; /** Interface describing a Github repository. */ export interface GithubRepo { diff --git a/dev-infra/release/publish/index.ts b/dev-infra/release/publish/index.ts index ea978ded8a..9ce84c3571 100644 --- a/dev-infra/release/publish/index.ts +++ b/dev-infra/release/publish/index.ts @@ -11,7 +11,7 @@ import {ListChoiceOptions, prompt} from 'inquirer'; import {GithubConfig} from '../../utils/config'; import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console'; import {GitClient} from '../../utils/git/index'; -import {ReleaseConfig} from '../config'; +import {ReleaseConfig} from '../config/index'; import {ActiveReleaseTrains, fetchActiveReleaseTrains, nextBranchName} from '../versioning/active-release-trains'; import {npmIsLoggedIn, npmLogin, npmLogout} from '../versioning/npm-publish'; import {printActiveReleaseTrains} from '../versioning/print-active-trains'; diff --git a/dev-infra/release/publish/release-notes.ts b/dev-infra/release/publish/release-notes.ts deleted file mode 100644 index c48d17a554..0000000000 --- a/dev-infra/release/publish/release-notes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * 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 {join} from 'path'; -import * as semver from 'semver'; -import {changelogPath} from './constants'; - -/** - * Gets the default pattern for extracting release notes for the given version. - * This pattern matches for the conventional-changelog Angular preset. - */ -export function getDefaultExtractReleaseNotesPattern(version: semver.SemVer): RegExp { - const escapedVersion = version.format().replace('.', '\\.'); - // TODO: Change this once we have a canonical changelog generation tool. Also update this - // based on the conventional-changelog version. They removed anchors in more recent versions. - return new RegExp(`(.*?)(?:.*?)(?: +# <%- version %><% if (title) { %> "<%- title %>"<% } %> (<%- dateStamp %>) + +<%_ +const commitsInChangelog = commits.filter(includeInReleaseNotes()); +for (const group of asCommitGroups(commitsInChangelog)) { +_%> + +### <%- group.title %> +| Commit | Description | +| -- | -- | +<%_ + for (const commit of group.commits) { +_%> +| <%- commit.shortHash %> | <%- commit.header %> | +<%_ + } +} +_%> + +<%_ +const breakingChanges = commits.filter(contains('breakingChanges')); +if (breakingChanges.length) { +_%> +## Breaking Changes + +<%_ + for (const group of asCommitGroups(breakingChanges)) { +_%> +### <%- group.title %> + +<%_ + for (const commit of group.commits) { +_%> +<%- commit.breakingChanges[0].text %> + +<%_ + } + } +} +_%> + +<%_ +const deprecations = commits.filter(contains('deprecations')); +if (deprecations.length) { +_%> +## Deprecations +<%_ + for (const group of asCommitGroups(deprecations)) { +_%> +### <%- group.title %> + +<%_ + for (const commit of group.commits) { +_%> +<%- commit.deprecations[0].text %> +<%_ + } + } +} +_%> + +<%_ +const authors = commits.filter(unique('author')).map(c => c.author).sort(); +if (authors.length === 1) { +_%> +## Special Thanks: +<%- authors[0]%> +<%_ +} +if (authors.length > 1) { +_%> +## Special Thanks: +<%- authors.slice(0, -1).join(', ') %> and <%- authors.slice(-1)[0] %> +<%_ +} +_%> diff --git a/dev-infra/release/publish/release-notes/templates/github-release.ejs b/dev-infra/release/publish/release-notes/templates/github-release.ejs new file mode 100644 index 0000000000..df335f515d --- /dev/null +++ b/dev-infra/release/publish/release-notes/templates/github-release.ejs @@ -0,0 +1,77 @@ + +# <%- version %><% if (title) { %> "<%- title %>"<% } %> (<%- dateStamp %>) + +<%_ +const commitsInChangelog = commits.filter(includeInReleaseNotes()); +for (const group of asCommitGroups(commitsInChangelog)) { +_%> + +### <%- group.title %> +| Commit | Description | +| -- | -- | +<%_ + for (const commit of group.commits) { +_%> +| <%- commit.shortHash %> | <%- commit.header %> | +<%_ + } +} +_%> + +<%_ +const breakingChanges = commits.filter(contains('breakingChanges')); +if (breakingChanges.length) { +_%> +## Breaking Changes + +<%_ + for (const group of asCommitGroups(breakingChanges)) { +_%> +### <%- group.title %> + +<%_ + for (const commit of group.commits) { +_%> +<%- commit.breakingChanges[0].text %> + +<%_ + } + } +} +_%> + +<%_ +const deprecations = commits.filter(contains('deprecations')); +if (deprecations.length) { +_%> +## Deprecations +<%_ + for (const group of asCommitGroups(deprecations)) { +_%> +### <%- group.title %> + +<%_ + for (const commit of group.commits) { +_%> +<%- commit.deprecations[0].text %> +<%_ + } + } +} +_%> + +<%_ +const authors = commits.filter(unique('author')).map(c => c.author).sort(); +if (authors.length === 1) { +_%> +## Special Thanks: +<%- authors[0]%> +<%_ +} +if (authors.length > 1) { +_%> +## Special Thanks: +<%- authors.slice(0, -1).join(', ') %> and <%- authors.slice(-1)[0] %> +<%_ +} +_%> diff --git a/dev-infra/release/publish/test/BUILD.bazel b/dev-infra/release/publish/test/BUILD.bazel index eea28bad98..b958bdb9c2 100644 --- a/dev-infra/release/publish/test/BUILD.bazel +++ b/dev-infra/release/publish/test/BUILD.bazel @@ -8,6 +8,7 @@ ts_library( ]), module_name = "@angular/dev-infra-private/release/test", deps = [ + "//dev-infra/commit-message", "//dev-infra/release/config", "//dev-infra/release/publish", "//dev-infra/release/versioning", diff --git a/dev-infra/release/publish/test/release-notes/context.spec.ts b/dev-infra/release/publish/test/release-notes/context.spec.ts new file mode 100644 index 0000000000..d1efffeca8 --- /dev/null +++ b/dev-infra/release/publish/test/release-notes/context.spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {CommitFromGitLog, parseCommitFromGitLog} from '../../../../commit-message/parse'; +import {commitMessageBuilder} from '../../../../commit-message/test-util'; +import {RenderContext, RenderContextData,} from '../../release-notes/context'; + +const defaultContextData: RenderContextData = { + commits: [], + github: { + name: 'repoName', + owner: 'repoOwner', + }, + title: false, + version: '1.2.3', +}; + +describe('RenderContext', () => { + beforeAll(() => { + jasmine.clock().install(); + }); + + it('contains a date stamp using the current date by default', async () => { + jasmine.clock().mockDate(new Date(1996, 11, 11)); + const renderContext = new RenderContext(defaultContextData); + expect(renderContext.dateStamp).toBe('1996-12-11'); + }); + + it('contains a date stamp using a provided date', async () => { + const data = {...defaultContextData, date: new Date(2000, 0, 20)}; + const renderContext = new RenderContext(data); + expect(renderContext.dateStamp).toBe('2000-01-20'); + }); + + it('filters to include only commits which have specified field', () => { + const renderContext = new RenderContext(defaultContextData); + const matchingCommits = commitsFromList(2, 15); + expect(commits.filter(renderContext.contains('breakingChanges'))).toEqual(matchingCommits); + }); + + it('filters to include only the first commit discovered with a unique value for a specified field', + () => { + const renderContext = new RenderContext(defaultContextData); + const matchingCommits = commitsFromList(0, 1, 2, 3, 4, 7, 12); + expect(commits.filter(renderContext.unique('type'))).toEqual(matchingCommits); + }); + + + describe('filters to include commits which are to be included in the release notes', () => { + it('including all scopes by default', () => { + const renderContext = new RenderContext(defaultContextData); + const matchingCommits = commitsFromList(0, 2, 5, 6, 8, 10, 11, 12, 15, 16); + expect(commits.filter(renderContext.includeInReleaseNotes())).toEqual(matchingCommits); + }); + + it('excluding hidden scopes defined in the config', () => { + const renderContext = new RenderContext({...defaultContextData, hiddenScopes: ['core']}); + const matchingCommits = commitsFromList(0, 2, 6, 8, 10, 11, 15, 16); + expect(commits.filter(renderContext.includeInReleaseNotes())).toEqual(matchingCommits); + }); + }); + + describe('organized lists of commits into groups', () => { + let devInfraCommits: CommitFromGitLog[]; + let coreCommits: CommitFromGitLog[]; + let compilerCommits: CommitFromGitLog[]; + let unorganizedCommits: CommitFromGitLog[]; + function assertOrganizedGroupsMatch( + generatedGroups: {title: string, commits: CommitFromGitLog[]}[], + providedGroups: {title: string, commits: CommitFromGitLog[]}[]) { + expect(generatedGroups.length).toBe(providedGroups.length); + generatedGroups.forEach(({title, commits}, idx) => { + expect(title).toBe(providedGroups[idx].title); + expect(commits).toEqual(jasmine.arrayWithExactContents(providedGroups[idx].commits)); + }); + } + + beforeEach(() => { + devInfraCommits = commits.filter(c => c.scope === 'dev-infra'); + coreCommits = commits.filter(c => c.scope === 'core'); + compilerCommits = commits.filter(c => c.scope === 'compiler'); + unorganizedCommits = + [...devInfraCommits, ...coreCommits, ...compilerCommits].sort(() => Math.random() - 0.5); + }); + + + + it('with default sorting', () => { + const renderContext = new RenderContext(defaultContextData); + const organizedCommits = renderContext.asCommitGroups(unorganizedCommits); + + assertOrganizedGroupsMatch(organizedCommits, [ + {title: 'compiler', commits: compilerCommits}, + {title: 'core', commits: coreCommits}, + {title: 'dev-infra', commits: devInfraCommits}, + ]); + }); + + it('sorted by the provided order in the config', () => { + const renderContext = + new RenderContext({...defaultContextData, groupOrder: ['core', 'dev-infra']}); + const organizedCommits = renderContext.asCommitGroups(unorganizedCommits); + + assertOrganizedGroupsMatch(organizedCommits, [ + {title: 'core', commits: coreCommits}, + {title: 'dev-infra', commits: devInfraCommits}, + {title: 'compiler', commits: compilerCommits}, + ]); + }); + }); + + afterAll(() => { + jasmine.clock().uninstall(); + }); +}); + + + +const buildCommitMessage = commitMessageBuilder({ + prefix: '', + type: '', + npmScope: '', + scope: '', + summary: 'This is a short summary of the change', + body: 'This is a longer description of the change', + footer: '', +}); + +function buildCommit(type: string, scope: string, withBreakingChange = false) { + const footer = withBreakingChange ? 'BREAKING CHANGE: something is broken now' : ''; + const parts = {type, scope, footer}; + return parseCommitFromGitLog(Buffer.from(buildCommitMessage(parts))); +} + + +function commitsFromList(...indexes: number[]) { + const output: CommitFromGitLog[] = []; + for (const i of indexes) { + output.push(commits[i]); + } + return output; +} + + +const commits: CommitFromGitLog[] = [ + buildCommit('fix', 'platform-browser'), + buildCommit('test', 'dev-infra'), + buildCommit('feat', 'dev-infra', true), + buildCommit('build', 'docs-infra'), + buildCommit('docs', 'router'), + buildCommit('feat', 'core'), + buildCommit('feat', 'common'), + buildCommit('refactor', 'compiler'), + buildCommit('fix', 'docs-infra'), + buildCommit('test', 'core'), + buildCommit('feat', 'compiler-cli'), + buildCommit('fix', 'dev-infra'), + buildCommit('perf', 'core'), + buildCommit('docs', 'forms'), + buildCommit('refactor', 'dev-infra'), + buildCommit('feat', 'docs-infra', true), + buildCommit('fix', 'compiler'), +]; diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json index 8d89c7228e..77ee606719 100644 --- a/dev-infra/tmpl-package.json +++ b/dev-infra/tmpl-package.json @@ -17,6 +17,7 @@ "chalk": "", "cli-progress": "", "conventional-commits-parser": "", + "ejs": "", "git-raw-commits": "", "glob": "", "inquirer": "", diff --git a/package.json b/package.json index 8317637cb3..cd5710ca97 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ "@octokit/graphql": "^4.3.1", "@types/cli-progress": "^3.4.2", "@types/conventional-commits-parser": "^3.0.1", + "@types/ejs": "^3.0.6", "@types/git-raw-commits": "^2.0.0", "@types/minimist": "^1.2.0", "@yarnpkg/lockfile": "^1.1.0", @@ -187,6 +188,7 @@ "cli-progress": "^3.7.0", "conventional-changelog": "^2.0.3", "conventional-commits-parser": "^3.2.1", + "ejs": "^3.1.6", "entities": "1.1.1", "firebase-tools": "^7.11.0", "firefox-profile": "1.0.3", diff --git a/yarn.lock b/yarn.lock index 2c63b45537..624527783f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2586,6 +2586,11 @@ dependencies: "@types/node" "*" +"@types/ejs@^3.0.6": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.6.tgz#aca442289df623bfa8e47c23961f0357847b83fe" + integrity sha512-fj1hi+ZSW0xPLrJJD+YNwIh9GZbyaIepG26E/gXvp8nCa2pYokxUYO1sK9qjGxp2g8ryZYuon7wmjpwE2cyASQ== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" @@ -3642,6 +3647,11 @@ async-settle@^1.0.0: dependencies: async-done "^1.2.2" +async@0.9.x: + version "0.9.2" + resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= + async@1.2.x: version "1.2.1" resolved "https://registry.yarnpkg.com/async/-/async-1.2.1.tgz#a4816a17cd5ff516dfa2c7698a453369b9790de0" @@ -6455,6 +6465,13 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= +ejs@^3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a" + integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw== + dependencies: + jake "^10.6.1" + electron-to-chromium@^1.3.390: version "1.3.394" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.394.tgz#50e927bb9f6a559ed21d284e7683ec5e2c784835" @@ -7118,6 +7135,13 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +filelist@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b" + integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ== + dependencies: + minimatch "^3.0.4" + filesize@^3.1.3: version "3.6.1" resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" @@ -9378,6 +9402,16 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +jake@^10.6.1: + version "10.8.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" + integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A== + dependencies: + async "0.9.x" + chalk "^2.4.2" + filelist "^1.0.1" + minimatch "^3.0.4" + jasmine-ajax@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jasmine-ajax/-/jasmine-ajax-4.0.0.tgz#7d8ba7e47e3f7e780e155fe9aa563faafa7e1a26"