release-please
Version: 
generate release PRs based on the conventionalcommits.org spec
480 lines • 23.6 kB
JavaScript
"use strict";
// 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.
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseStrategy = void 0;
const manifest_1 = require("../manifest");
const default_1 = require("../versioning-strategies/default");
const default_2 = require("../changelog-notes/default");
const version_1 = require("../version");
const tag_name_1 = require("../util/tag-name");
const logger_1 = require("../util/logger");
const pull_request_title_1 = require("../util/pull-request-title");
const branch_name_1 = require("../util/branch-name");
const pull_request_body_1 = require("../util/pull-request-body");
const composite_1 = require("../updaters/composite");
const generic_1 = require("../updaters/generic");
const generic_json_1 = require("../updaters/generic-json");
const generic_xml_1 = require("../updaters/generic-xml");
const pom_xml_1 = require("../updaters/java/pom-xml");
const generic_yaml_1 = require("../updaters/generic-yaml");
const generic_toml_1 = require("../updaters/generic-toml");
const DEFAULT_CHANGELOG_PATH = 'CHANGELOG.md';
/**
 * A strategy is responsible for determining which files are
 * necessary to update in a release pull request.
 */
class BaseStrategy {
    constructor(options) {
        var _a, _b, _c;
        this.logger = (_a = options.logger) !== null && _a !== void 0 ? _a : logger_1.logger;
        this.path = options.path || manifest_1.ROOT_PROJECT_PATH;
        this.github = options.github;
        this.packageName = options.packageName;
        this.component =
            options.component || this.normalizeComponent(this.packageName);
        this.versioningStrategy =
            options.versioningStrategy ||
                new default_1.DefaultVersioningStrategy({ logger: this.logger });
        this.targetBranch = options.targetBranch;
        this.repository = options.github.repository;
        this.changelogPath = options.changelogPath || DEFAULT_CHANGELOG_PATH;
        this.changelogHost = options.changelogHost;
        this.changelogSections = options.changelogSections;
        this.tagSeparator = options.tagSeparator;
        this.skipGitHubRelease = options.skipGitHubRelease || false;
        this.releaseAs = options.releaseAs;
        this.changelogNotes =
            options.changelogNotes || new default_2.DefaultChangelogNotes(options);
        this.includeComponentInTag = (_b = options.includeComponentInTag) !== null && _b !== void 0 ? _b : true;
        this.includeVInTag = (_c = options.includeVInTag) !== null && _c !== void 0 ? _c : true;
        this.pullRequestTitlePattern = options.pullRequestTitlePattern;
        this.pullRequestHeader = options.pullRequestHeader;
        this.pullRequestFooter = options.pullRequestFooter;
        this.componentNoSpace = options.componentNoSpace;
        this.extraFiles = options.extraFiles || [];
        this.initialVersion = options.initialVersion;
        this.extraLabels = options.extraLabels || [];
        this.dateFormat = options.dateFormat || generic_1.DEFAULT_DATE_FORMAT;
    }
    /**
     * Return the component for this strategy. This may be a computed field.
     * @returns {string}
     */
    async getComponent() {
        if (!this.includeComponentInTag) {
            return '';
        }
        return this.component || (await this.getDefaultComponent());
    }
    async getDefaultComponent() {
        var _a;
        return this.normalizeComponent((_a = this.packageName) !== null && _a !== void 0 ? _a : (await this.getDefaultPackageName()));
    }
    async getBranchComponent() {
        return this.component || (await this.getDefaultComponent());
    }
    async getPackageName() {
        var _a;
        return (_a = this.packageName) !== null && _a !== void 0 ? _a : (await this.getDefaultPackageName());
    }
    async getDefaultPackageName() {
        var _a;
        return (_a = this.packageName) !== null && _a !== void 0 ? _a : '';
    }
    normalizeComponent(component) {
        if (!component) {
            return '';
        }
        return component;
    }
    /**
     * Override this method to post process commits
     * @param {ConventionalCommit[]} commits parsed commits
     * @returns {ConventionalCommit[]} modified commits
     */
    async postProcessCommits(commits) {
        return commits;
    }
    async buildReleaseNotes(conventionalCommits, newVersion, newVersionTag, latestRelease, commits) {
        var _a;
        return await this.changelogNotes.buildNotes(conventionalCommits, {
            host: this.changelogHost,
            owner: this.repository.owner,
            repository: this.repository.repo,
            version: newVersion.toString(),
            previousTag: (_a = latestRelease === null || latestRelease === void 0 ? void 0 : latestRelease.tag) === null || _a === void 0 ? void 0 : _a.toString(),
            currentTag: newVersionTag.toString(),
            targetBranch: this.targetBranch,
            changelogSections: this.changelogSections,
            commits: commits,
        });
    }
    async buildPullRequestBody(component, newVersion, releaseNotesBody, _conventionalCommits, _latestRelease, pullRequestHeader, pullRequestFooter) {
        return new pull_request_body_1.PullRequestBody([
            {
                component,
                version: newVersion,
                notes: releaseNotesBody,
            },
        ], {
            header: pullRequestHeader,
            footer: pullRequestFooter,
        });
    }
    /**
     * 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, latestRelease, draft, labels = [], bumpOnlyOptions) {
        var _a;
        const conventionalCommits = await this.postProcessCommits(commits);
        this.logger.info(`Considering: ${conventionalCommits.length} commits`);
        if (!bumpOnlyOptions && conventionalCommits.length === 0) {
            this.logger.info(`No commits for path: ${this.path}, skipping`);
            return undefined;
        }
        const newVersion = (_a = bumpOnlyOptions === null || bumpOnlyOptions === void 0 ? void 0 : bumpOnlyOptions.newVersion) !== null && _a !== void 0 ? _a : (await this.buildNewVersion(conventionalCommits, latestRelease));
        const versionsMap = await this.updateVersionsMap(await this.buildVersionsMap(conventionalCommits), conventionalCommits, newVersion);
        const component = await this.getComponent();
        this.logger.debug('component:', component);
        const newVersionTag = new tag_name_1.TagName(newVersion, this.includeComponentInTag ? component : undefined, this.tagSeparator, this.includeVInTag);
        this.logger.debug('pull request title pattern:', this.pullRequestTitlePattern);
        this.logger.debug('componentNoSpace:', this.componentNoSpace);
        const pullRequestTitle = pull_request_title_1.PullRequestTitle.ofComponentTargetBranchVersion(component || '', this.targetBranch, newVersion, this.pullRequestTitlePattern, this.componentNoSpace);
        const branchComponent = await this.getBranchComponent();
        const branchName = branchComponent
            ? branch_name_1.BranchName.ofComponentTargetBranch(branchComponent, this.targetBranch)
            : branch_name_1.BranchName.ofTargetBranch(this.targetBranch);
        const releaseNotesBody = await this.buildReleaseNotes(conventionalCommits, newVersion, newVersionTag, latestRelease, commits);
        if (!bumpOnlyOptions && this.changelogEmpty(releaseNotesBody)) {
            this.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 === null || latestRelease === void 0 ? void 0 : latestRelease.tag.version,
            commits: conventionalCommits,
        });
        const updatesWithExtras = (0, composite_1.mergeUpdates)(updates.concat(...(await this.extraFileUpdates(newVersion, versionsMap, this.dateFormat))));
        const pullRequestBody = await this.buildPullRequestBody(component, newVersion, releaseNotesBody, conventionalCommits, latestRelease, this.pullRequestHeader, this.pullRequestFooter);
        return {
            title: pullRequestTitle,
            body: pullRequestBody,
            updates: updatesWithExtras,
            labels: [...labels, ...this.extraLabels],
            headRefName: branchName.toString(),
            version: newVersion,
            draft: draft !== null && draft !== void 0 ? draft : false,
        };
    }
    // Helper to convert extra files with globs to the file paths to add
    async extraFilePaths(extraFile) {
        if (typeof extraFile !== 'object') {
            return [extraFile];
        }
        if (!extraFile.glob) {
            return [extraFile.path];
        }
        if (extraFile.path.startsWith('/')) {
            // glob is relative to root, strip the leading `/` for glob matching
            // and re-add the leading `/` to make the file relative to the root
            return (await this.github.findFilesByGlobAndRef(extraFile.path.slice(1), this.targetBranch)).map(file => `/${file}`);
        }
        else if (this.path === manifest_1.ROOT_PROJECT_PATH) {
            // root component, ignore path prefix
            return this.github.findFilesByGlobAndRef(extraFile.path, this.targetBranch);
        }
        else {
            // glob is relative to current path
            return this.github.findFilesByGlobAndRef(extraFile.path, this.targetBranch, this.path);
        }
    }
    async extraFileUpdates(version, versionsMap, dateFormat) {
        const extraFileUpdates = [];
        for (const extraFile of this.extraFiles) {
            if (typeof extraFile === 'object') {
                const paths = await this.extraFilePaths(extraFile);
                for (const path of paths) {
                    switch (extraFile.type) {
                        case 'generic':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new generic_1.Generic({
                                    version,
                                    versionsMap,
                                    dateFormat: dateFormat,
                                }),
                            });
                            break;
                        case 'json':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new generic_json_1.GenericJson(extraFile.jsonpath, version),
                            });
                            break;
                        case 'yaml':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new generic_yaml_1.GenericYaml(extraFile.jsonpath, version),
                            });
                            break;
                        case 'toml':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new generic_toml_1.GenericToml(extraFile.jsonpath, version),
                            });
                            break;
                        case 'xml':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new generic_xml_1.GenericXml(extraFile.xpath, version),
                            });
                            break;
                        case 'pom':
                            extraFileUpdates.push({
                                path: this.addPath(path),
                                createIfMissing: false,
                                updater: new pom_xml_1.PomXml(version),
                            });
                            break;
                        default:
                            throw new Error(`unsupported extraFile type: ${extraFile.type}`);
                    }
                }
            }
            else if (extraFile.endsWith('.json')) {
                extraFileUpdates.push({
                    path: this.addPath(extraFile),
                    createIfMissing: false,
                    updater: new composite_1.CompositeUpdater(new generic_json_1.GenericJson('$.version', version), new generic_1.Generic({ version, versionsMap, dateFormat: dateFormat })),
                });
            }
            else if (extraFile.endsWith('.yaml') || extraFile.endsWith('.yml')) {
                extraFileUpdates.push({
                    path: this.addPath(extraFile),
                    createIfMissing: false,
                    updater: new composite_1.CompositeUpdater(new generic_yaml_1.GenericYaml('$.version', version), new generic_1.Generic({ version, versionsMap, dateFormat: dateFormat })),
                });
            }
            else if (extraFile.endsWith('.toml')) {
                extraFileUpdates.push({
                    path: this.addPath(extraFile),
                    createIfMissing: false,
                    updater: new composite_1.CompositeUpdater(new generic_toml_1.GenericToml('$.version', version), new generic_1.Generic({ version, versionsMap, dateFormat: dateFormat })),
                });
            }
            else if (extraFile.endsWith('.xml')) {
                extraFileUpdates.push({
                    path: this.addPath(extraFile),
                    createIfMissing: false,
                    updater: new composite_1.CompositeUpdater(
                    // Updates "version" element that is a child of the root element.
                    new generic_xml_1.GenericXml('/*/version', version), new generic_1.Generic({ version, versionsMap, dateFormat: dateFormat })),
                });
            }
            else {
                extraFileUpdates.push({
                    path: this.addPath(extraFile),
                    createIfMissing: false,
                    updater: new generic_1.Generic({ version, versionsMap, dateFormat: dateFormat }),
                });
            }
        }
        return extraFileUpdates;
    }
    changelogEmpty(changelogEntry) {
        return changelogEntry.split('\n').length <= 1;
    }
    async updateVersionsMap(versionsMap, conventionalCommits, _newVersion) {
        for (const [component, version] of versionsMap.entries()) {
            versionsMap.set(component, await this.versioningStrategy.bump(version, conventionalCommits));
        }
        return versionsMap;
    }
    async buildNewVersion(conventionalCommits, latestRelease) {
        if (this.releaseAs) {
            this.logger.warn(`Setting version for ${this.path} from release-as configuration`);
            return version_1.Version.parse(this.releaseAs);
        }
        const releaseAsCommit = conventionalCommits.find(conventionalCommit => conventionalCommit.notes.find(note => note.title === 'RELEASE AS'));
        if (releaseAsCommit) {
            const note = releaseAsCommit.notes.find(note => note.title === 'RELEASE AS');
            if (note) {
                return version_1.Version.parse(note.text);
            }
        }
        if (latestRelease) {
            return await this.versioningStrategy.bump(latestRelease.tag.version, conventionalCommits);
        }
        return this.initialReleaseVersion();
    }
    async buildVersionsMap(_conventionalCommits) {
        return new Map();
    }
    async parsePullRequestBody(pullRequestBody) {
        return pull_request_body_1.PullRequestBody.parse(pullRequestBody, this.logger);
    }
    /**
     * Given a merged pull request, build the candidate release.
     * @param {PullRequest} mergedPullRequest The merged release pull request.
     * @returns {Release} The candidate release.
     * @deprecated Use buildReleases() instead.
     */
    async buildRelease(mergedPullRequest, options) {
        var _a;
        if (this.skipGitHubRelease) {
            this.logger.info('Release skipped from strategy config');
            return;
        }
        if (!mergedPullRequest.sha) {
            this.logger.error('Pull request should have been merged');
            return;
        }
        const mergedTitlePattern = (_a = options === null || options === void 0 ? void 0 : options.groupPullRequestTitlePattern) !== null && _a !== void 0 ? _a : manifest_1.MANIFEST_PULL_REQUEST_TITLE_PATTERN;
        const pullRequestTitle = pull_request_title_1.PullRequestTitle.parse(mergedPullRequest.title, this.pullRequestTitlePattern, this.componentNoSpace, this.logger) ||
            pull_request_title_1.PullRequestTitle.parse(mergedPullRequest.title, mergedTitlePattern, this.componentNoSpace, this.logger);
        if (!pullRequestTitle) {
            this.logger.error(`Bad pull request title: '${mergedPullRequest.title}'`);
            return;
        }
        const branchName = branch_name_1.BranchName.parse(mergedPullRequest.headBranchName, this.logger);
        if (!branchName) {
            this.logger.error(`Bad branch name: ${mergedPullRequest.headBranchName}`);
            return;
        }
        const pullRequestBody = await this.parsePullRequestBody(mergedPullRequest.body);
        if (!pullRequestBody) {
            this.logger.error('Could not parse pull request body as a release PR');
            return;
        }
        const component = await this.getComponent();
        let releaseData;
        if (pullRequestBody.releaseData.length === 1 &&
            !pullRequestBody.releaseData[0].component) {
            const branchComponent = await this.getBranchComponent();
            // standalone release PR, ensure the components match
            if (this.normalizeComponent(branchName.component) !==
                this.normalizeComponent(branchComponent)) {
                this.logger.warn(`PR component: ${branchName.component} does not match configured component: ${branchComponent}`);
                return;
            }
            releaseData = pullRequestBody.releaseData[0];
        }
        else {
            // manifest release with multiple components - find the release notes
            // for the component to see if it was included in this release (parsed
            // from the release pull request body)
            releaseData = pullRequestBody.releaseData.find(datum => {
                return (this.normalizeComponent(datum.component) ===
                    this.normalizeComponent(component));
            });
            if (!releaseData && pullRequestBody.releaseData.length > 0) {
                this.logger.info(`Pull request contains releases, but not for component: ${component}`);
                return;
            }
        }
        const notes = releaseData === null || releaseData === void 0 ? void 0 : releaseData.notes;
        if (notes === undefined) {
            this.logger.warn('Failed to find release notes');
        }
        let version = pullRequestTitle.getVersion();
        if (!version ||
            (pullRequestBody.releaseData.length > 1 && (releaseData === null || releaseData === void 0 ? void 0 : releaseData.version))) {
            // prioritize pull-request body version for multi-component releases
            version = releaseData === null || releaseData === void 0 ? void 0 : releaseData.version;
        }
        if (!version) {
            this.logger.error('Pull request should have included version');
            return;
        }
        if (!this.isPublishedVersion(version)) {
            this.logger.warn(`Skipping non-published version: ${version.toString()}`);
            return;
        }
        const tag = new tag_name_1.TagName(version, this.includeComponentInTag ? component : undefined, this.tagSeparator, this.includeVInTag);
        const releaseName = component && this.includeComponentInTag
            ? `${component}: v${version.toString()}`
            : `v${version.toString()}`;
        return {
            name: releaseName,
            tag,
            notes: notes || '',
            sha: mergedPullRequest.sha,
        };
    }
    /**
     * Given a merged pull request, build the candidate releases.
     * @param {PullRequest} mergedPullRequest The merged release pull request.
     * @returns {Release} The candidate release.
     */
    async buildReleases(mergedPullRequest, options) {
        const release = await this.buildRelease(mergedPullRequest, options);
        if (release) {
            return [release];
        }
        return [];
    }
    isPublishedVersion(_version) {
        return true;
    }
    /**
     * Override this to handle the initial version of a new library.
     */
    initialReleaseVersion() {
        if (this.initialVersion) {
            return version_1.Version.parse(this.initialVersion);
        }
        return version_1.Version.parse('1.0.0');
    }
    /**
     * Adds a given file path to the strategy path.
     * @param {string} file Desired file path.
     * @returns {string} The file relative to the strategy.
     * @throws {Error} If the file path contains relative pathing characters, i.e. ../, ~/
     */
    addPath(file) {
        // There is no strategy path to join, the strategy is at the root, or the
        // file is at the root (denoted by a leading slash or tilde)
        if (!this.path || this.path === manifest_1.ROOT_PROJECT_PATH || file.startsWith('/')) {
            file = file.replace(/^\/+/, '');
        }
        // Otherwise, the file is relative to the strategy path
        else {
            file = `${this.path.replace(/\/+$/, '')}/${file}`;
        }
        // Ensure the file path does not escape the workspace
        if (/((^|\/)\.{1,2}|^~|^\/*)+\//.test(file)) {
            throw new Error(`illegal pathing characters in path: ${file}`);
        }
        // Strip any trailing slashes and return
        return file.replace(/\/+$/, '');
    }
}
exports.BaseStrategy = BaseStrategy;
//# sourceMappingURL=base.js.map