diff --git a/src/factory.ts b/src/factory.ts index 3d9091e5b..460d921b2 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Strategy, StrategyOptions} from './strategy'; +import {Strategy} from './strategy'; import {Go} from './strategies/go'; import {GoYoshi} from './strategies/go-yoshi'; import {JavaYoshi} from './strategies/java-yoshi'; @@ -43,6 +43,7 @@ import {CargoWorkspace} from './plugins/cargo-workspace'; import {ChangelogNotes, ChangelogSection} from './changelog-notes'; import {GitHubChangelogNotes} from './changelog-notes/github'; import {DefaultChangelogNotes} from './changelog-notes/default'; +import {BaseStrategyOptions} from './strategies/base'; // Factory shared by GitHub Action and CLI for creating Release PRs // and GitHub Releases: @@ -71,7 +72,7 @@ const allReleaseTypes = [ 'terraform-module', ] as const; export type ReleaseType = typeof allReleaseTypes[number]; -type ReleaseBuilder = (options: StrategyOptions) => Strategy; +type ReleaseBuilder = (options: BaseStrategyOptions) => Strategy; type Releasers = Record; const releasers: Releasers = { go: options => new Go(options), diff --git a/src/strategies/base.ts b/src/strategies/base.ts new file mode 100644 index 000000000..dbbde6b95 --- /dev/null +++ b/src/strategies/base.ts @@ -0,0 +1,402 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Strategy} from '../strategy'; +import {GitHub} from '../github'; +import {VersioningStrategy} from '../versioning-strategy'; +import {Repository} from '../repository'; +import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; +import { + ROOT_PROJECT_PATH, + MANIFEST_PULL_REQUEST_TITLE_PATTERN, +} from '../manifest'; +import {DefaultVersioningStrategy} from '../versioning-strategies/default'; +import {DefaultChangelogNotes} from '../changelog-notes/default'; +import {Update} from '../update'; +import {ConventionalCommit, Commit, parseConventionalCommits} from '../commit'; +import {Version, VersionsMap} from '../version'; +import {TagName} from '../util/tag-name'; +import {Release} from '../release'; +import {ReleasePullRequest} from '../release-pull-request'; +import {logger} from '../util/logger'; +import {PullRequestTitle} from '../util/pull-request-title'; +import {BranchName} from '../util/branch-name'; +import {PullRequestBody} from '../util/pull-request-body'; +import {PullRequest} from '../pull-request'; + +const DEFAULT_CHANGELOG_PATH = 'CHANGELOG.md'; + +export interface BuildUpdatesOptions { + changelogEntry: string; + newVersion: Version; + versionsMap: VersionsMap; + latestVersion?: Version; +} +export interface BaseStrategyOptions { + path?: string; + bumpMinorPreMajor?: boolean; + bumpPatchForMinorPreMajor?: boolean; + github: GitHub; + component?: string; + packageName?: string; + versioningStrategy?: VersioningStrategy; + targetBranch: string; + changelogPath?: string; + changelogSections?: ChangelogSection[]; + commitPartial?: string; + headerPartial?: string; + mainTemplate?: string; + tagSeparator?: string; + skipGitHubRelease?: boolean; + releaseAs?: string; + changelogNotes?: ChangelogNotes; + includeComponentInTag?: boolean; + pullRequestTitlePattern?: string; +} + +/** + * A strategy is responsible for determining which files are + * necessary to update in a release pull request. + */ +export abstract class BaseStrategy implements Strategy { + readonly path: string; + protected github: GitHub; + protected component?: string; + protected packageName?: string; + readonly versioningStrategy: VersioningStrategy; + protected targetBranch: string; + protected repository: Repository; + protected changelogPath: string; + protected tagSeparator?: string; + private skipGitHubRelease: boolean; + private releaseAs?: string; + private includeComponentInTag: boolean; + private pullRequestTitlePattern?: string; + + readonly changelogNotes: ChangelogNotes; + + // CHANGELOG configuration + protected changelogSections?: ChangelogSection[]; + + constructor(options: BaseStrategyOptions) { + this.path = options.path || ROOT_PROJECT_PATH; + this.github = options.github; + this.packageName = options.packageName; + this.component = + options.component || this.normalizeComponent(this.packageName); + this.versioningStrategy = + options.versioningStrategy || new DefaultVersioningStrategy({}); + this.targetBranch = options.targetBranch; + this.repository = options.github.repository; + this.changelogPath = options.changelogPath || DEFAULT_CHANGELOG_PATH; + this.changelogSections = options.changelogSections; + this.tagSeparator = options.tagSeparator; + this.skipGitHubRelease = options.skipGitHubRelease || false; + this.releaseAs = options.releaseAs; + this.changelogNotes = + options.changelogNotes || new DefaultChangelogNotes(options); + this.includeComponentInTag = options.includeComponentInTag ?? true; + this.pullRequestTitlePattern = options.pullRequestTitlePattern; + } + + /** + * Specify the files necessary to update in a release pull request. + * @param {BuildUpdatesOptions} options + */ + protected abstract async buildUpdates( + options: BuildUpdatesOptions + ): Promise; + + /** + * Return the component for this strategy. This may be a computed field. + * @returns {string} + */ + async getComponent(): Promise { + if (!this.includeComponentInTag) { + return ''; + } + return this.component || (await this.getDefaultComponent()); + } + + async getDefaultComponent(): Promise { + return this.normalizeComponent(await this.getDefaultPackageName()); + } + + async getDefaultPackageName(): Promise { + return ''; + } + + protected normalizeComponent(component: string | undefined): string { + if (!component) { + return ''; + } + return component; + } + + /** + * Override this method to post process commits + * @param {ConventionalCommit[]} commits parsed commits + * @returns {ConventionalCommit[]} modified commits + */ + protected postProcessCommits( + commits: ConventionalCommit[] + ): ConventionalCommit[] { + return commits; + } + + protected async buildReleaseNotes( + conventionalCommits: ConventionalCommit[], + newVersion: Version, + newVersionTag: TagName, + latestRelease?: Release + ): Promise { + return await this.changelogNotes.buildNotes(conventionalCommits, { + owner: this.repository.owner, + repository: this.repository.repo, + version: newVersion.toString(), + previousTag: latestRelease?.tag?.toString(), + currentTag: newVersionTag.toString(), + targetBranch: this.targetBranch, + changelogSections: this.changelogSections, + }); + } + + /** + * Builds a candidate release pull request + * @param {Commit[]} commits Raw commits to consider for this release. + * @param {Release} latestRelease Optional. The last release for this + * component if available. + * @param {boolean} draft Optional. Whether or not to create the pull + * request as a draft. Defaults to `false`. + * @returns {ReleasePullRequest | undefined} The release pull request to + * open for this path/component. Returns undefined if we should not + * open a pull request. + */ + async buildReleasePullRequest( + commits: Commit[], + latestRelease?: Release, + draft?: boolean, + labels: string[] = [] + ): Promise { + const conventionalCommits = this.postProcessCommits( + parseConventionalCommits(commits) + ); + + const newVersion = await this.buildNewVersion( + conventionalCommits, + latestRelease + ); + const versionsMap = await this.updateVersionsMap( + await this.buildVersionsMap(conventionalCommits), + conventionalCommits + ); + const component = await this.getComponent(); + logger.debug('component:', component); + + const newVersionTag = new TagName( + newVersion, + this.includeComponentInTag ? component : undefined, + this.tagSeparator + ); + logger.warn('pull request title pattern:', this.pullRequestTitlePattern); + const pullRequestTitle = PullRequestTitle.ofComponentTargetBranchVersion( + component || '', + this.targetBranch, + newVersion, + this.pullRequestTitlePattern + ); + const branchName = component + ? BranchName.ofComponentTargetBranch(component, this.targetBranch) + : BranchName.ofTargetBranch(this.targetBranch); + const releaseNotesBody = await this.buildReleaseNotes( + conventionalCommits, + newVersion, + newVersionTag, + latestRelease + ); + if (this.changelogEmpty(releaseNotesBody)) { + logger.info( + `No user facing commits found since ${ + latestRelease ? latestRelease.sha : 'beginning of time' + } - skipping` + ); + return undefined; + } + const updates = await this.buildUpdates({ + changelogEntry: releaseNotesBody, + newVersion, + versionsMap, + latestVersion: latestRelease?.tag.version, + }); + const pullRequestBody = new PullRequestBody([ + { + component, + version: newVersion, + notes: releaseNotesBody, + }, + ]); + + return { + title: pullRequestTitle, + body: pullRequestBody, + updates, + labels, + headRefName: branchName.toString(), + version: newVersion, + draft: draft ?? false, + }; + } + + protected changelogEmpty(changelogEntry: string): boolean { + return changelogEntry.split('\n').length <= 1; + } + + protected async updateVersionsMap( + versionsMap: VersionsMap, + conventionalCommits: ConventionalCommit[] + ): Promise { + for (const versionKey of versionsMap.keys()) { + const version = versionsMap.get(versionKey); + if (!version) { + logger.warn(`didn't find version for ${versionKey}`); + continue; + } + const newVersion = await this.versioningStrategy.bump( + version, + conventionalCommits + ); + versionsMap.set(versionKey, newVersion); + } + return versionsMap; + } + + protected async buildNewVersion( + conventionalCommits: ConventionalCommit[], + latestRelease?: Release + ): Promise { + if (this.releaseAs) { + logger.warn( + `Setting version for ${this.path} from release-as configuration` + ); + return Version.parse(this.releaseAs); + } else if (latestRelease) { + return await this.versioningStrategy.bump( + latestRelease.tag.version, + conventionalCommits + ); + } else { + return this.initialReleaseVersion(); + } + } + + protected async buildVersionsMap( + _conventionalCommits: ConventionalCommit[] + ): Promise { + return new Map(); + } + + /** + * Given a merged pull request, build the candidate release. + * @param {PullRequest} mergedPullRequest The merged release pull request. + * @returns {Release} The candidate release. + */ + async buildRelease( + mergedPullRequest: PullRequest + ): Promise { + if (this.skipGitHubRelease) { + logger.info('Release skipped from strategy config'); + return; + } + if (!mergedPullRequest.sha) { + logger.error('Pull request should have been merged'); + return; + } + + const pullRequestTitle = + PullRequestTitle.parse( + mergedPullRequest.title, + this.pullRequestTitlePattern + ) || + PullRequestTitle.parse( + mergedPullRequest.title, + MANIFEST_PULL_REQUEST_TITLE_PATTERN + ); + if (!pullRequestTitle) { + logger.error(`Bad pull request title: '${mergedPullRequest.title}'`); + return; + } + const branchName = BranchName.parse(mergedPullRequest.headBranchName); + if (!branchName) { + logger.error(`Bad branch name: ${mergedPullRequest.headBranchName}`); + return; + } + const pullRequestBody = PullRequestBody.parse(mergedPullRequest.body); + if (!pullRequestBody) { + logger.error('Could not parse pull request body as a release PR'); + return; + } + const component = await this.getComponent(); + logger.info('component:', component); + const releaseData = + pullRequestBody.releaseData.length === 1 && + !pullRequestBody.releaseData[0].component + ? pullRequestBody.releaseData[0] + : pullRequestBody.releaseData.find(releaseData => { + return ( + this.normalizeComponent(releaseData.component) === + this.normalizeComponent(component) + ); + }); + const notes = releaseData?.notes; + if (notes === undefined) { + logger.warn('Failed to find release notes'); + } + const version = pullRequestTitle.getVersion() || releaseData?.version; + if (!version) { + logger.error('Pull request should have included version'); + return; + } + + const tag = new TagName( + version, + this.includeComponentInTag ? component : undefined, + this.tagSeparator + ); + return { + tag, + notes: notes || '', + sha: mergedPullRequest.sha, + }; + } + + /** + * Override this to handle the initial version of a new library. + */ + protected initialReleaseVersion(): Version { + return Version.parse('1.0.0'); + } + + protected addPath(file: string) { + if (this.path === ROOT_PROJECT_PATH) { + return file; + } + file = file.replace(/^[/\\]/, ''); + if (this.path === undefined) { + return file; + } else { + const path = this.path.replace(/[/\\]$/, ''); + return `${path}/${file}`; + } + } +} diff --git a/src/strategies/dart.ts b/src/strategies/dart.ts index bb179254f..76678dbc3 100644 --- a/src/strategies/dart.ts +++ b/src/strategies/dart.ts @@ -18,11 +18,11 @@ import * as yaml from 'js-yaml'; // pubspec import {PubspecYaml} from '../updaters/dart/pubspec-yaml'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {GitHubFileContents} from '../github'; import {Update} from '../update'; -export class Dart extends Strategy { +export class Dart extends BaseStrategy { private pubspecYmlContents?: GitHubFileContents; protected async buildUpdates( diff --git a/src/strategies/elixir.ts b/src/strategies/elixir.ts index 2cdbe8980..366bb8af1 100644 --- a/src/strategies/elixir.ts +++ b/src/strategies/elixir.ts @@ -16,10 +16,10 @@ import {Changelog} from '../updaters/changelog'; // mix.exs support import {ElixirMixExs} from '../updaters/elixir/elixir-mix-exs'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; -export class Elixir extends Strategy { +export class Elixir extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/go-yoshi.ts b/src/strategies/go-yoshi.ts index 907f117a2..dfd39e83c 100644 --- a/src/strategies/go-yoshi.ts +++ b/src/strategies/go-yoshi.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Strategy, BuildUpdatesOptions, StrategyOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {Update} from '../update'; import {Changelog} from '../updaters/changelog'; import {ConventionalCommit} from '../commit'; @@ -37,8 +37,8 @@ const IGNORED_SUB_MODULES = new Set([ const REGEN_PR_REGEX = /.*auto-regenerate.*/; const REGEN_ISSUE_REGEX = /(?.*)\(#(?.*)\)(\n|$)/; -export class GoYoshi extends Strategy { - constructor(options: StrategyOptions) { +export class GoYoshi extends BaseStrategy { + constructor(options: BaseStrategyOptions) { options.changelogPath = options.changelogPath ?? 'CHANGES.md'; super(options); } diff --git a/src/strategies/go.ts b/src/strategies/go.ts index 6a3a949dc..e90a19b6c 100644 --- a/src/strategies/go.ts +++ b/src/strategies/go.ts @@ -14,10 +14,10 @@ // Generic import {Changelog} from '../updaters/changelog'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; -export class Go extends Strategy { +export class Go extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/helm.ts b/src/strategies/helm.ts index cc043abe6..89256bb25 100644 --- a/src/strategies/helm.ts +++ b/src/strategies/helm.ts @@ -19,10 +19,10 @@ import {Changelog} from '../updaters/changelog'; import * as yaml from 'js-yaml'; // helm import {ChartYaml} from '../updaters/helm/chart-yaml'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; -export class Helm extends Strategy { +export class Helm extends BaseStrategy { private chartYmlContents?: GitHubFileContents; protected async buildUpdates( diff --git a/src/strategies/java-yoshi.ts b/src/strategies/java-yoshi.ts index b0608cdae..9e7a6afd5 100644 --- a/src/strategies/java-yoshi.ts +++ b/src/strategies/java-yoshi.ts @@ -16,7 +16,7 @@ import {Update} from '../update'; import {VersionsManifest} from '../updaters/java/versions-manifest'; import {Version, VersionsMap} from '../version'; import {JavaUpdate} from '../updaters/java/java-update'; -import {Strategy, StrategyOptions, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {Changelog} from '../updaters/changelog'; import {GitHubFileContents} from '../github'; import {JavaSnapshot} from '../versioning-strategies/java-snapshot'; @@ -47,11 +47,11 @@ const CHANGELOG_SECTIONS = [ {type: 'ci', section: 'Continuous Integration', hidden: true}, ]; -interface JavaStrategyOptions extends StrategyOptions { +interface JavaStrategyOptions extends BaseStrategyOptions { extraFiles?: string[]; } -export class JavaYoshi extends Strategy { +export class JavaYoshi extends BaseStrategy { readonly extraFiles: string[]; private versionsContent?: GitHubFileContents; private snapshotVersioning: VersioningStrategy; diff --git a/src/strategies/krm-blueprint.ts b/src/strategies/krm-blueprint.ts index d031e2717..94961328b 100644 --- a/src/strategies/krm-blueprint.ts +++ b/src/strategies/krm-blueprint.ts @@ -18,7 +18,7 @@ import {GitHubFileContents} from '../github'; import {Changelog} from '../updaters/changelog'; // KRM specific. import {KRMBlueprintVersion} from '../updaters/krm/krm-blueprint-version'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; import {VersionsMap, Version} from '../version'; @@ -26,7 +26,7 @@ const KRMBlueprintAttribAnnotation = 'cnrm.cloud.google.com/blueprint'; const hasKRMBlueprintAttrib = (content: string) => content.includes(KRMBlueprintAttribAnnotation); -export class KRMBlueprint extends Strategy { +export class KRMBlueprint extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/node.ts b/src/strategies/node.ts index fd4fa0f8c..d0c8e2d81 100644 --- a/src/strategies/node.ts +++ b/src/strategies/node.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; import {PackageLockJson} from '../updaters/node/package-lock-json'; import {SamplesPackageJson} from '../updaters/node/samples-package-json'; @@ -20,7 +20,7 @@ import {Changelog} from '../updaters/changelog'; import {PackageJson} from '../updaters/node/package-json'; import {GitHubFileContents} from '../github'; -export class Node extends Strategy { +export class Node extends BaseStrategy { private pkgJsonContents?: GitHubFileContents; protected async buildUpdates( diff --git a/src/strategies/ocaml.ts b/src/strategies/ocaml.ts index 09ecf3a72..8e7fff31a 100644 --- a/src/strategies/ocaml.ts +++ b/src/strategies/ocaml.ts @@ -20,12 +20,12 @@ import {Changelog} from '../updaters/changelog'; import {Opam} from '../updaters/ocaml/opam'; import {EsyJson} from '../updaters/ocaml/esy-json'; import {DuneProject} from '../updaters/ocaml/dune-project'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; const notEsyLock = (path: string) => !path.startsWith('esy.lock'); -export class OCaml extends Strategy { +export class OCaml extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/php-yoshi.ts b/src/strategies/php-yoshi.ts index 48a92fff8..bf4bf15f5 100644 --- a/src/strategies/php-yoshi.ts +++ b/src/strategies/php-yoshi.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Strategy, BuildUpdatesOptions, StrategyOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {Update} from '../update'; import {Changelog} from '../updaters/changelog'; import {RootComposerUpdatePackages} from '../updaters/php/root-composer-update-packages'; @@ -57,8 +57,8 @@ interface ComponentInfo { composer: ComposerJson; } -export class PHPYoshi extends Strategy { - constructor(options: StrategyOptions) { +export class PHPYoshi extends BaseStrategy { + constructor(options: BaseStrategyOptions) { super({ ...options, changelogSections: CHANGELOG_SECTIONS, diff --git a/src/strategies/php.ts b/src/strategies/php.ts index 8db450373..9136aa9a9 100644 --- a/src/strategies/php.ts +++ b/src/strategies/php.ts @@ -16,7 +16,7 @@ import {Changelog} from '../updaters/changelog'; // PHP Specific. import {RootComposerUpdatePackages} from '../updaters/php/root-composer-update-packages'; -import {Strategy, BuildUpdatesOptions, StrategyOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {Update} from '../update'; import {VersionsMap} from '../version'; @@ -34,8 +34,8 @@ const CHANGELOG_SECTIONS = [ {type: 'ci', section: 'Continuous Integration', hidden: true}, ]; -export class PHP extends Strategy { - constructor(options: StrategyOptions) { +export class PHP extends BaseStrategy { + constructor(options: BaseStrategyOptions) { options.changelogSections = options.changelogSections ?? CHANGELOG_SECTIONS; super(options); } diff --git a/src/strategies/python.ts b/src/strategies/python.ts index 85da1b61a..1c449f401 100644 --- a/src/strategies/python.ts +++ b/src/strategies/python.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; import {Changelog} from '../updaters/changelog'; import {Version} from '../version'; @@ -26,7 +26,7 @@ import { import {logger} from '../util/logger'; import {PythonFileWithVersion} from '../updaters/python/python-file-with-version'; -export class Python extends Strategy { +export class Python extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/ruby-yoshi.ts b/src/strategies/ruby-yoshi.ts index 3d9029acd..8b55b4f2b 100644 --- a/src/strategies/ruby-yoshi.ts +++ b/src/strategies/ruby-yoshi.ts @@ -19,7 +19,7 @@ import {Changelog} from '../updaters/changelog'; // RubyYoshi import {VersionRB} from '../updaters/ruby/version-rb'; -import {StrategyOptions, Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {ConventionalCommit} from '../commit'; import {Update} from '../update'; import {readFileSync} from 'fs'; @@ -43,11 +43,11 @@ const CHANGELOG_SECTIONS = [ {type: 'ci', section: 'Continuous Integration', hidden: true}, ]; -interface RubyYoshiStrategyOptions extends StrategyOptions { +interface RubyYoshiStrategyOptions extends BaseStrategyOptions { versionFile?: string; } -export class RubyYoshi extends Strategy { +export class RubyYoshi extends BaseStrategy { readonly versionFile: string; constructor(options: RubyYoshiStrategyOptions) { super({ diff --git a/src/strategies/ruby.ts b/src/strategies/ruby.ts index 431eb3cd7..d8633910d 100644 --- a/src/strategies/ruby.ts +++ b/src/strategies/ruby.ts @@ -19,15 +19,15 @@ import {Changelog} from '../updaters/changelog'; // Ruby import {VersionRB} from '../updaters/ruby/version-rb'; -import {StrategyOptions, Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions, BaseStrategyOptions} from './base'; import {ConventionalCommit} from '../commit'; import {Update} from '../update'; -interface RubyStrategyOptions extends StrategyOptions { +interface RubyStrategyOptions extends BaseStrategyOptions { versionFile?: string; } -export class Ruby extends Strategy { +export class Ruby extends BaseStrategy { readonly versionFile: string; constructor(options: RubyStrategyOptions) { super(options); diff --git a/src/strategies/rust.ts b/src/strategies/rust.ts index 96355a1d8..84967cabd 100644 --- a/src/strategies/rust.ts +++ b/src/strategies/rust.ts @@ -21,11 +21,11 @@ import {CargoToml} from '../updaters/rust/cargo-toml'; import {CargoLock} from '../updaters/rust/cargo-lock'; import {CargoManifest, parseCargoManifest} from '../updaters/rust/common'; import {logger} from '../util/logger'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {VersionsMap, Version} from '../version'; import {Update} from '../update'; -export class Rust extends Strategy { +export class Rust extends BaseStrategy { private packageManifest?: CargoManifest | null; private workspaceManifest?: CargoManifest | null; diff --git a/src/strategies/simple.ts b/src/strategies/simple.ts index 5a2920776..a46e2adcb 100644 --- a/src/strategies/simple.ts +++ b/src/strategies/simple.ts @@ -15,11 +15,11 @@ // Generic import {Changelog} from '../updaters/changelog'; // version.txt support -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; import {DefaultUpdater} from '../updaters/default'; -export class Simple extends Strategy { +export class Simple extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategies/terraform-module.ts b/src/strategies/terraform-module.ts index d0122d62d..8eb47efec 100644 --- a/src/strategies/terraform-module.ts +++ b/src/strategies/terraform-module.ts @@ -17,11 +17,11 @@ import {Changelog} from '../updaters/changelog'; // Terraform specific. import {ReadMe} from '../updaters/terraform/readme'; import {ModuleVersion} from '../updaters/terraform/module-version'; -import {Strategy, BuildUpdatesOptions} from '../strategy'; +import {BaseStrategy, BuildUpdatesOptions} from './base'; import {Update} from '../update'; import {Version} from '../version'; -export class TerraformModule extends Strategy { +export class TerraformModule extends BaseStrategy { protected async buildUpdates( options: BuildUpdatesOptions ): Promise { diff --git a/src/strategy.ts b/src/strategy.ts index 5d9775a9c..bce6aa48c 100644 --- a/src/strategy.ts +++ b/src/strategy.ts @@ -14,159 +14,19 @@ import {ReleasePullRequest} from './release-pull-request'; import {Release} from './release'; -import {GitHub} from './github'; -import {Version, VersionsMap} from './version'; -import {parseConventionalCommits, Commit, ConventionalCommit} from './commit'; -import {VersioningStrategy} from './versioning-strategy'; -import {DefaultVersioningStrategy} from './versioning-strategies/default'; -import {PullRequestTitle} from './util/pull-request-title'; -import {ChangelogNotes, ChangelogSection} from './changelog-notes'; -import {Update} from './update'; -import {Repository} from './repository'; import {PullRequest} from './pull-request'; -import {BranchName} from './util/branch-name'; -import {TagName} from './util/tag-name'; -import {logger} from './util/logger'; -import { - MANIFEST_PULL_REQUEST_TITLE_PATTERN, - ROOT_PROJECT_PATH, -} from './manifest'; -import {PullRequestBody} from './util/pull-request-body'; -import {DefaultChangelogNotes} from './changelog-notes/default'; - -const DEFAULT_CHANGELOG_PATH = 'CHANGELOG.md'; - -export interface BuildUpdatesOptions { - changelogEntry: string; - newVersion: Version; - versionsMap: VersionsMap; - latestVersion?: Version; -} -export interface StrategyOptions { - path?: string; - bumpMinorPreMajor?: boolean; - bumpPatchForMinorPreMajor?: boolean; - github: GitHub; - component?: string; - packageName?: string; - versioningStrategy?: VersioningStrategy; - targetBranch: string; - changelogPath?: string; - changelogSections?: ChangelogSection[]; - commitPartial?: string; - headerPartial?: string; - mainTemplate?: string; - tagSeparator?: string; - skipGitHubRelease?: boolean; - releaseAs?: string; - changelogNotes?: ChangelogNotes; - includeComponentInTag?: boolean; - pullRequestTitlePattern?: string; -} +import {Commit} from './commit'; +import {VersioningStrategy} from './versioning-strategy'; +import {ChangelogNotes} from './changelog-notes'; /** * A strategy is responsible for determining which files are * necessary to update in a release pull request. */ -export abstract class Strategy { +export interface Strategy { + readonly changelogNotes: ChangelogNotes; readonly path: string; - protected github: GitHub; - readonly component?: string; - protected packageName?: string; readonly versioningStrategy: VersioningStrategy; - protected targetBranch: string; - protected repository: Repository; - readonly changelogPath: string; - protected tagSeparator?: string; - private skipGitHubRelease: boolean; - private releaseAs?: string; - private includeComponentInTag: boolean; - private pullRequestTitlePattern?: string; - - readonly changelogNotes: ChangelogNotes; - - // CHANGELOG configuration - protected changelogSections?: ChangelogSection[]; - - constructor(options: StrategyOptions) { - this.path = options.path || ROOT_PROJECT_PATH; - this.github = options.github; - this.packageName = options.packageName; - this.component = - options.component || this.normalizeComponent(this.packageName); - this.versioningStrategy = - options.versioningStrategy || new DefaultVersioningStrategy({}); - this.targetBranch = options.targetBranch; - this.repository = options.github.repository; - this.changelogPath = options.changelogPath || DEFAULT_CHANGELOG_PATH; - this.changelogSections = options.changelogSections; - this.tagSeparator = options.tagSeparator; - this.skipGitHubRelease = options.skipGitHubRelease || false; - this.releaseAs = options.releaseAs; - this.changelogNotes = - options.changelogNotes || new DefaultChangelogNotes(options); - this.includeComponentInTag = options.includeComponentInTag ?? true; - this.pullRequestTitlePattern = options.pullRequestTitlePattern; - } - - /** - * Specify the files necessary to update in a release pull request. - * @param {BuildUpdatesOptions} options - */ - protected abstract async buildUpdates( - options: BuildUpdatesOptions - ): Promise; - - async getComponent(): Promise { - if (!this.includeComponentInTag) { - return ''; - } - return this.component || (await this.getDefaultComponent()); - } - - async getDefaultComponent(): Promise { - return this.normalizeComponent(await this.getDefaultPackageName()); - } - - async getDefaultPackageName(): Promise { - return ''; - } - - protected normalizeComponent(component: string | undefined): string { - if (!component) { - return ''; - } - return component; - } - - /** - * Override this method to post process commits - * @param {ConventionalCommit[]} commits parsed commits - * @returns {ConventionalCommit[]} modified commits - */ - protected postProcessCommits( - commits: ConventionalCommit[] - ): ConventionalCommit[] { - return commits; - } - - protected async buildReleaseNotes( - conventionalCommits: ConventionalCommit[], - newVersion: Version, - newVersionTag: TagName, - latestRelease?: Release - ): Promise { - return await this.changelogNotes.buildNotes(conventionalCommits, { - owner: this.repository.owner, - repository: this.repository.repo, - version: newVersion.toString(), - previousTag: latestRelease?.tag?.toString(), - currentTag: newVersionTag.toString(), - targetBranch: this.targetBranch, - changelogSections: this.changelogSections, - }); - } - /** * Builds a candidate release pull request * @param {Commit[]} commits Raw commits to consider for this release. @@ -178,220 +38,23 @@ export abstract class Strategy { * open for this path/component. Returns undefined if we should not * open a pull request. */ - async buildReleasePullRequest( + buildReleasePullRequest( commits: Commit[], latestRelease?: Release, draft?: boolean, - labels: string[] = [] - ): Promise { - const conventionalCommits = this.postProcessCommits( - parseConventionalCommits(commits) - ); - - const newVersion = await this.buildNewVersion( - conventionalCommits, - latestRelease - ); - const versionsMap = await this.updateVersionsMap( - await this.buildVersionsMap(conventionalCommits), - conventionalCommits - ); - const component = await this.getComponent(); - logger.debug('component:', component); - - const newVersionTag = new TagName( - newVersion, - this.includeComponentInTag ? component : undefined, - this.tagSeparator - ); - logger.warn('pull request title pattern:', this.pullRequestTitlePattern); - const pullRequestTitle = PullRequestTitle.ofComponentTargetBranchVersion( - component || '', - this.targetBranch, - newVersion, - this.pullRequestTitlePattern - ); - const branchName = component - ? BranchName.ofComponentTargetBranch(component, this.targetBranch) - : BranchName.ofTargetBranch(this.targetBranch); - const releaseNotesBody = await this.buildReleaseNotes( - conventionalCommits, - newVersion, - newVersionTag, - latestRelease - ); - if (this.changelogEmpty(releaseNotesBody)) { - logger.info( - `No user facing commits found since ${ - latestRelease ? latestRelease.sha : 'beginning of time' - } - skipping` - ); - return undefined; - } - const updates = await this.buildUpdates({ - changelogEntry: releaseNotesBody, - newVersion, - versionsMap, - latestVersion: latestRelease?.tag.version, - }); - const pullRequestBody = new PullRequestBody([ - { - component, - version: newVersion, - notes: releaseNotesBody, - }, - ]); - - return { - title: pullRequestTitle, - body: pullRequestBody, - updates, - labels, - headRefName: branchName.toString(), - version: newVersion, - draft: draft ?? false, - }; - } - - protected changelogEmpty(changelogEntry: string): boolean { - return changelogEntry.split('\n').length <= 1; - } - - protected async updateVersionsMap( - versionsMap: VersionsMap, - conventionalCommits: ConventionalCommit[] - ): Promise { - for (const versionKey of versionsMap.keys()) { - const version = versionsMap.get(versionKey); - if (!version) { - logger.warn(`didn't find version for ${versionKey}`); - continue; - } - const newVersion = await this.versioningStrategy.bump( - version, - conventionalCommits - ); - versionsMap.set(versionKey, newVersion); - } - return versionsMap; - } - - protected async buildNewVersion( - conventionalCommits: ConventionalCommit[], - latestRelease?: Release - ): Promise { - if (this.releaseAs) { - logger.warn( - `Setting version for ${this.path} from release-as configuration` - ); - return Version.parse(this.releaseAs); - } else if (latestRelease) { - return await this.versioningStrategy.bump( - latestRelease.tag.version, - conventionalCommits - ); - } else { - return this.initialReleaseVersion(); - } - } - - protected async buildVersionsMap( - _conventionalCommits: ConventionalCommit[] - ): Promise { - return new Map(); - } + labels?: string[] + ): Promise; /** * Given a merged pull request, build the candidate release. * @param {PullRequest} mergedPullRequest The merged release pull request. * @returns {Release} The candidate release. */ - async buildRelease( - mergedPullRequest: PullRequest - ): Promise { - if (this.skipGitHubRelease) { - logger.info('Release skipped from strategy config'); - return; - } - if (!mergedPullRequest.sha) { - logger.error('Pull request should have been merged'); - return; - } - - const pullRequestTitle = - PullRequestTitle.parse( - mergedPullRequest.title, - this.pullRequestTitlePattern - ) || - PullRequestTitle.parse( - mergedPullRequest.title, - MANIFEST_PULL_REQUEST_TITLE_PATTERN - ); - if (!pullRequestTitle) { - logger.error(`Bad pull request title: '${mergedPullRequest.title}'`); - return; - } - const branchName = BranchName.parse(mergedPullRequest.headBranchName); - if (!branchName) { - logger.error(`Bad branch name: ${mergedPullRequest.headBranchName}`); - return; - } - const pullRequestBody = PullRequestBody.parse(mergedPullRequest.body); - if (!pullRequestBody) { - logger.error('Could not parse pull request body as a release PR'); - return; - } - const component = await this.getComponent(); - logger.info('component:', component); - const releaseData = - pullRequestBody.releaseData.length === 1 && - !pullRequestBody.releaseData[0].component - ? pullRequestBody.releaseData[0] - : pullRequestBody.releaseData.find(releaseData => { - return ( - this.normalizeComponent(releaseData.component) === - this.normalizeComponent(component) - ); - }); - const notes = releaseData?.notes; - if (notes === undefined) { - logger.warn('Failed to find release notes'); - } - const version = pullRequestTitle.getVersion() || releaseData?.version; - if (!version) { - logger.error('Pull request should have included version'); - return; - } - - const tag = new TagName( - version, - this.includeComponentInTag ? component : undefined, - this.tagSeparator - ); - return { - tag, - notes: notes || '', - sha: mergedPullRequest.sha, - }; - } + buildRelease(mergedPullRequest: PullRequest): Promise; /** - * Override this to handle the initial version of a new library. + * Return the component for this strategy. This may be a computed field. + * @returns {string} */ - protected initialReleaseVersion(): Version { - return Version.parse('1.0.0'); - } - - protected addPath(file: string) { - if (this.path === ROOT_PROJECT_PATH) { - return file; - } - file = file.replace(/^[/\\]/, ''); - if (this.path === undefined) { - return file; - } else { - const path = this.path.replace(/[/\\]$/, ''); - return `${path}/${file}`; - } - } + getComponent(): Promise; } diff --git a/test/factory.ts b/test/factory.ts index 6daee7eb3..6a3706cbb 100644 --- a/test/factory.ts +++ b/test/factory.ts @@ -44,13 +44,14 @@ describe('factory', () => { releaseType: 'simple', }); expect(strategy).instanceof(Simple); + expect(strategy.versioningStrategy).instanceof(DefaultVersioningStrategy); const versioningStrategy = strategy.versioningStrategy as DefaultVersioningStrategy; expect(versioningStrategy.bumpMinorPreMajor).to.be.false; expect(versioningStrategy.bumpPatchForMinorPreMajor).to.be.false; expect(strategy.path).to.eql('.'); - expect(strategy.component).not.ok; + expect(await strategy.getComponent()).not.ok; expect(strategy.changelogNotes).instanceof(DefaultChangelogNotes); }); it('should build a with configuration', async () => { diff --git a/test/strategies/base.ts b/test/strategies/base.ts index 2ec952a7b..6cdfb68b6 100644 --- a/test/strategies/base.ts +++ b/test/strategies/base.ts @@ -15,14 +15,14 @@ import {describe, it, afterEach, beforeEach} from 'mocha'; import * as sinon from 'sinon'; import {expect} from 'chai'; -import {Strategy} from '../../src/strategy'; +import {BaseStrategy} from '../../src/strategies/base'; import {Update} from '../../src/update'; import {GitHub} from '../../src/github'; import {PullRequestBody} from '../../src/util/pull-request-body'; const sandbox = sinon.createSandbox(); -class TestStrategy extends Strategy { +class TestStrategy extends BaseStrategy { async buildUpdates(): Promise { return []; }