diff --git a/dev-infra/pr/merge/BUILD.bazel b/dev-infra/pr/merge/BUILD.bazel index e17c742eef..ccfb9cb507 100644 --- a/dev-infra/pr/merge/BUILD.bazel +++ b/dev-infra/pr/merge/BUILD.bazel @@ -1,8 +1,12 @@ load("@npm_bazel_typescript//:index.bzl", "ts_library") +load("//tools:defaults.bzl", "jasmine_node_test") ts_library( name = "merge", - srcs = glob(["**/*.ts"]), + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), module_name = "@angular/dev-infra-private/pr/merge", visibility = ["//dev-infra:__subpackages__"], deps = [ @@ -11,8 +15,37 @@ ts_library( "@npm//@octokit/rest", "@npm//@types/inquirer", "@npm//@types/node", + "@npm//@types/node-fetch", "@npm//@types/semver", "@npm//@types/yargs", "@npm//chalk", ], ) + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":merge", + "//dev-infra/utils", + "@npm//@types/jasmine", + "@npm//@types/node", + "@npm//@types/node-fetch", + "@npm//nock", + ], +) + +jasmine_node_test( + name = "test", + # Disable the Bazel patched module resolution. It always loads ".mjs" files first. This + # breaks NodeJS execution for "node-fetch" as it uses experimental modules which are not + # enabled in NodeJS. TODO: Remove this with rules_nodejs 3.x where patching is optional. + # https://github.com/bazelbuild/rules_nodejs/commit/7d070ffadf9c3b41711382a4737b995f987c14fa. + args = ["--nobazel_patch_module_resolver"], + deps = [ + ":test_lib", + "@npm//node-fetch", + "@npm//semver", + ], +) diff --git a/dev-infra/pr/merge/defaults/branches.ts b/dev-infra/pr/merge/defaults/branches.ts new file mode 100644 index 0000000000..0f40a2d0a2 --- /dev/null +++ b/dev-infra/pr/merge/defaults/branches.ts @@ -0,0 +1,212 @@ +/** + * @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 * as semver from 'semver'; +import {GithubClient} from '../../../utils/git/github'; + +/** Type describing a Github repository with corresponding API client. */ +export interface GithubRepo { + /** API client that can access the repository. */ + api: GithubClient; + /** Owner login of the repository. */ + owner: string; + /** Name of the repository. */ + repo: string; + /** + * NPM package representing this repository. Angular repositories usually contain + * multiple packages in a monorepo scheme, but packages commonly are released with + * the same versions. This means that a single package can be used for querying + * NPM about previously published versions (e.g. to determine active LTS versions). + * */ + npmPackageName: string; +} + +/** Type describing a version-branch. */ +export interface VersionBranch { + /** Name of the branch in Git. e.g. `10.0.x`. */ + name: string; + /** + * Parsed SemVer version for the version-branch. Version branches technically do + * not follow the SemVer format, but we can have representative SemVer versions + * that can be used for comparisons, sorting and other checks. + */ + parsed: semver.SemVer; +} + +/** Branch name for the `next` branch. */ +export const nextBranchName = 'master'; + +/** Regular expression that matches version-branches for a release-train. */ +const releaseTrainBranchNameRegex = /(\d+)\.(\d+)\.x/; + +/** + * Fetches the active release train and its branches for the specified major version. i.e. + * the latest active release-train branch name is resolved and an optional version-branch for + * a currently active feature-freeze/release-candidate release-train. + */ +export async function fetchActiveReleaseTrainBranches( + repo: GithubRepo, nextVersion: semver.SemVer): Promise<{ + /** + * Name of the currently active release-candidate branch. Null if no + * feature-freeze/release-candidate is currently active. + */ + releaseCandidateBranch: string | null, + /** Name of the latest non-prerelease version branch (i.e. the patch branch). */ + latestVersionBranch: string +}> { + const majorVersionsToConsider: number[] = []; + let expectedReleaseCandidateMajor: number; + + // If the `next` branch (i.e. `master` branch) is for an upcoming major version, we know + // that there is no patch branch or feature-freeze/release-candidate branch for this major + // digit. If the current `next` version is the first minor of a major version, we know that + // the feature-freeze/release-candidate branch can only be the actual major branch. The + // patch branch is based on that, either the actual major branch or the last minor from the + // preceding major version. In all other cases, the patch branch and feature-freeze or + // release-candidate branch are part of the same major version. Consider the following: + // + // CASE 1. next: 11.0.0-next.0: patch and feature-freeze/release-candidate can only be + // most recent `10.<>.x` branches. The FF/RC branch can only be the last-minor of v10. + // CASE 2. next: 11.1.0-next.0: patch can be either `11.0.x` or last-minor in v10 based + // on whether there is a feature-freeze/release-candidate branch (=> `11.0.x`). + // CASE 3. next: 10.6.0-next.0: patch can be either `10.5.x` or `10.4.x` based on whether + // there is a feature-freeze/release-candidate branch (=> `10.5.x`) + if (nextVersion.minor === 0) { + expectedReleaseCandidateMajor = nextVersion.major - 1; + majorVersionsToConsider.push(nextVersion.major - 1); + } else if (nextVersion.minor === 1) { + expectedReleaseCandidateMajor = nextVersion.major; + majorVersionsToConsider.push(nextVersion.major, nextVersion.major - 1); + } else { + expectedReleaseCandidateMajor = nextVersion.major; + majorVersionsToConsider.push(nextVersion.major); + } + + // Collect all version-branches that should be considered for the latest version-branch, + // or the feature-freeze/release-candidate. + const branches = (await getBranchesForMajorVersions(repo, majorVersionsToConsider)); + const {latestVersionBranch, releaseCandidateBranch} = + await findActiveVersionBranches(repo, nextVersion, branches, expectedReleaseCandidateMajor); + + if (latestVersionBranch === null) { + throw Error( + `Unable to determine the latest release-train. The following branches ` + + `have been considered: [${branches.join(', ')}]`); + } + + return {releaseCandidateBranch, latestVersionBranch}; +} + +/** Gets the version of a given branch by reading the `package.json` upstream. */ +export async function getVersionOfBranch( + repo: GithubRepo, branchName: string): Promise { + const {data} = + await repo.api.repos.getContents({...repo, path: '/package.json', ref: branchName}); + const {version} = JSON.parse(Buffer.from(data.content, 'base64').toString()); + const parsedVersion = semver.parse(version); + if (parsedVersion === null) { + throw Error(`Invalid version detected in following branch: ${branchName}.`); + } + return parsedVersion; +} + +/** Whether the given branch corresponds to a release-train branch. */ +export function isReleaseTrainBranch(branchName: string): boolean { + return releaseTrainBranchNameRegex.test(branchName); +} + +/** + * Converts a given version-branch into a SemVer version that can be used with SemVer + * utilities. e.g. to determine semantic order, extract major digit, compare. + * + * For example `10.0.x` will become `10.0.0` in SemVer. The patch digit is not + * relevant but needed for parsing. SemVer does not allow `x` as patch digit. + */ +export function getVersionForReleaseTrainBranch(branchName: string): semver.SemVer|null { + // Convert a given version-branch into a SemVer version that can be used + // with the SemVer utilities. i.e. to determine semantic order. + return semver.parse(branchName.replace(releaseTrainBranchNameRegex, '$1.$2.0')); +} + +/** + * Gets the version branches for the specified major versions in descending + * order. i.e. latest version branches first. + */ +export async function getBranchesForMajorVersions( + repo: GithubRepo, majorVersions: number[]): Promise { + const {data: branchData} = await repo.api.repos.listBranches({...repo, protected: true}); + const branches: VersionBranch[] = []; + + for (const {name} of branchData) { + if (!isReleaseTrainBranch(name)) { + continue; + } + // Convert the version-branch into a SemVer version that can be used with the + // SemVer utilities. e.g. to determine semantic order, compare versions. + const parsed = getVersionForReleaseTrainBranch(name); + // Collect all version-branches that match the specified major versions. + if (parsed !== null && majorVersions.includes(parsed.major)) { + branches.push({name, parsed}); + } + } + + // Sort captured version-branches in descending order. + return branches.sort((a, b) => semver.rcompare(a.parsed, b.parsed)); +} + +export async function findActiveVersionBranches( + repo: GithubRepo, nextVersion: semver.SemVer, branches: VersionBranch[], + expectedReleaseCandidateMajor: number): Promise<{ + latestVersionBranch: string | null, + releaseCandidateBranch: string | null, +}> { + let latestVersionBranch: string|null = null; + let releaseCandidateBranch: string|null = null; + + // Iterate through the captured branches and find the latest non-prerelease branch and a + // potential release candidate branch. From the collected branches we iterate descending + // order (most recent semantic version-branch first). The first branch is either the latest + // active version branch (i.e. patch) or a feature-freeze/release-candidate branch. A FF/RC + // branch cannot be older than the latest active version-branch, so we stop iterating once + // we found such a branch. Otherwise, if we found a FF/RC branch, we continue looking for the + // next version-branch as that one is supposed to be the latest active version-branch. If it + // is not, then an error will be thrown due to two FF/RC branches existing at the same time. + for (const {name, parsed} of branches) { + // It can happen that version branches that are more recent than the version in the next + // branch (i.e. `master`) have been created. We could ignore such branches silently, but + // it might actually be symptomatic for an outdated version in the `next` branch, or an + // accidentally created branch by the caretaker. In either way we want to raise awareness. + if (semver.gte(parsed, nextVersion)) { + throw Error( + `Discovered unexpected version-branch that is representing a minor ` + + `version more recent than the one in the "${nextBranchName}" branch. Consider ` + + `deleting the branch, or check if the version in "${nextBranchName}" is outdated.`); + } + + const version = await getVersionOfBranch(repo, name); + const isPrerelease = version.prerelease[0] === 'rc' || version.prerelease[0] === 'next'; + if (isPrerelease) { + if (releaseCandidateBranch !== null) { + throw Error( + `Unable to determine latest release-train. Found two consecutive ` + + `branches in feature-freeze/release-candidate phase. Did not expect both "${name}" ` + + `and "${releaseCandidateBranch}" to be in feature-freeze/release-candidate mode.`); + } else if (version.major !== expectedReleaseCandidateMajor) { + throw Error( + `Discovered unexpected old feature-freeze/release-candidate branch. Expected no ` + + `version-branch in feature-freeze/release-candidate mode for v${version.major}.`); + } + releaseCandidateBranch = name; + } else { + latestVersionBranch = name; + break; + } + } + + return {releaseCandidateBranch, latestVersionBranch}; +} diff --git a/dev-infra/pr/merge/defaults/index.ts b/dev-infra/pr/merge/defaults/index.ts new file mode 100644 index 0000000000..b633d82825 --- /dev/null +++ b/dev-infra/pr/merge/defaults/index.ts @@ -0,0 +1,11 @@ +/** + * @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 + */ + +export * from './labels'; +export * from './branches'; +export * from './lts-branch'; diff --git a/dev-infra/pr/merge/defaults/integration.spec.ts b/dev-infra/pr/merge/defaults/integration.spec.ts new file mode 100644 index 0000000000..d085422e90 --- /dev/null +++ b/dev-infra/pr/merge/defaults/integration.spec.ts @@ -0,0 +1,455 @@ +/** + * @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 * as nock from 'nock'; +import * as nodeFetch from 'node-fetch'; + +import {GithubConfig} from '../../../utils/config'; +import * as console from '../../../utils/console'; +import {GithubClient} from '../../../utils/git/github'; +import {TargetLabel} from '../config'; +import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from '../target-label'; + +import {getDefaultTargetLabelConfiguration} from './index'; + +const API_ENDPOINT = `https://api.github.com`; + +describe('default target labels', () => { + let api: GithubClient; + let config: GithubConfig; + let npmPackageName: string; + + beforeEach(() => { + api = new GithubClient(); + config = {owner: 'angular', name: 'dev-infra-test'}; + npmPackageName = '@angular/dev-infra-test-pkg'; + + // The label determination will print warn messages. These should not be + // printed to the console, so we turn `console.warn` into a spy. + spyOn(console, 'warn'); + }); + + afterEach(() => nock.cleanAll()); + + async function computeTargetLabels(): Promise { + return getDefaultTargetLabelConfiguration(api, config, npmPackageName); + } + + function getRepoApiRequestUrl(): string { + return `${API_ENDPOINT}/repos/${config.owner}/${config.name}`; + } + + /** + * Mocks a branch `package.json` version API request. + * https://docs.github.com/en/rest/reference/repos#get-repository-content. + */ + function interceptBranchVersionRequest(branchName: string, version: string) { + nock(getRepoApiRequestUrl()) + .get('/contents//package.json') + .query(params => params.ref === branchName) + .reply(200, {content: Buffer.from(JSON.stringify({version})).toString('base64')}); + } + + /** Fakes a prompt confirm question with the given value. */ + function fakePromptConfirmValue(returnValue: boolean) { + spyOn(console, 'promptConfirm').and.resolveTo(returnValue); + } + + /** Fakes a NPM package query API request. */ + function fakeNpmPackageQueryRequest(data: unknown) { + // Note: We only need to mock the `json` function for a `Response`. Types + // would expect us to mock more functions, so we need to cast to `any`. + spyOn(nodeFetch, 'default').and.resolveTo({json: async () => data} as any); + } + + /** + * Mocks a repository branch list API request. + * https://docs.github.com/en/rest/reference/repos#list-branches. + */ + function interceptBranchesListRequest(branches: string[]) { + nock(getRepoApiRequestUrl()) + .get('/branches') + .query(true) + .reply(200, branches.map(name => ({name}))); + } + + async function getBranchesForLabel( + name: string, githubTargetBranch = 'master', labels?: TargetLabel[]): Promise { + if (labels === undefined) { + labels = await computeTargetLabels(); + } + const label = getTargetLabelFromPullRequest({labels}, [name]); + if (label === null) { + return null; + } + return await getBranchesFromTargetLabel(label, githubTargetBranch); + } + + it('should detect "master" as branch for target: minor', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.2.x']); + + expect(await getBranchesForLabel('target: minor')).toEqual(['master']); + }); + + it('should error if non version-branch is targeted with "target: lts"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.2.x']); + + await expectAsync(getBranchesForLabel('target: lts', 'master')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'PR cannot be merged as it does not target a long-term support branch: "master"' + })); + }); + + it('should error if patch branch is targeted with "target: lts"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.2.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'PR cannot be merged with "target: lts" into patch branch. Consider changing the ' + + 'label to "target: patch" if this is intentional.' + })); + }); + + it('should error if feature-freeze branch is targeted with "target: lts"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.0-next.0'); + interceptBranchVersionRequest('10.1.x', '10.1.0'); + interceptBranchesListRequest(['10.1.x', '10.2.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'PR cannot be merged with "target: lts" into feature-freeze/release-candidate branch. ' + + 'Consider changing the label to "target: rc" if this is intentional.' + })); + }); + + it('should error if release-candidate branch is targeted with "target: lts"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0'); + interceptBranchVersionRequest('10.1.x', '10.1.0'); + interceptBranchesListRequest(['10.1.x', '10.2.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'PR cannot be merged with "target: lts" into feature-freeze/release-candidate branch. ' + + 'Consider changing the label to "target: rc" if this is intentional.' + })); + }); + + it('should error if branch targeted with "target: lts" is no longer active', async () => { + interceptBranchVersionRequest('master', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchVersionRequest('10.5.x', '10.5.1'); + interceptBranchesListRequest(['10.5.x', '11.0.x']); + + // We support forcibly proceeding with merging if a given branch previously was in LTS mode + // but no longer is (after a period of time). In this test, we are not forcibly proceeding. + fakePromptConfirmValue(false); + fakeNpmPackageQueryRequest({ + 'dist-tags': { + 'v10-lts': '10.5.1', + }, + 'time': { + // v10 has been released at the given specified date. We pick a date that + // guarantees that the version is no longer considered as active LTS version. + '10.0.0': new Date(1912, 5, 23), + } + }); + + await expectAsync(getBranchesForLabel('target: lts', '10.5.x')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'Long-term supported ended for v10 on 12/23/1913. Pull request cannot be merged ' + + 'into the 10.5.x branch.' + })); + }); + + it('should error if branch targeted with "target: lts" is not latest LTS for given major', + async () => { + interceptBranchVersionRequest('master', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchVersionRequest('10.5.x', '10.5.1'); + interceptBranchVersionRequest('10.4.x', '10.4.4'); + interceptBranchesListRequest(['10.4.x', '10.5.x', '11.0.x']); + + fakeNpmPackageQueryRequest({ + 'dist-tags': { + 'v10-lts': '10.5.1', + } + }); + + await expectAsync(getBranchesForLabel('target: lts', '10.4.x')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'Not using last-minor branch for v10 LTS version. PR should be updated to ' + + 'target: 10.5.x' + })); + }); + + it('should error if branch targeted with "target: lts" is not a major version with LTS', + async () => { + interceptBranchVersionRequest('master', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchVersionRequest('10.5.x', '10.5.1'); + interceptBranchesListRequest(['10.5.x', '11.0.x']); + + fakeNpmPackageQueryRequest({'dist-tags': {}}); + + await expectAsync(getBranchesForLabel('target: lts', '10.5.x')) + .toBeRejectedWith( + jasmine.objectContaining({failureMessage: 'No LTS version tagged for v10 in NPM.'})); + }); + + it('should allow forcibly proceeding with merge if branch targeted with "target: lts" is no ' + + 'longer active', + async () => { + interceptBranchVersionRequest('master', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchVersionRequest('10.5.x', '10.5.1'); + interceptBranchesListRequest(['10.5.x', '11.0.x']); + + // We support forcibly proceeding with merging if a given branch previously was in LTS mode + // but no longer is (after a period of time). In this test, we are forcibly proceeding and + // expect the Github target branch to be picked up as branch for the `target: lts` label. + fakePromptConfirmValue(true); + fakeNpmPackageQueryRequest({ + 'dist-tags': { + 'v10-lts': '10.5.1', + }, + 'time': { + // v10 has been released at the given specified date. We pick a date that + // guarantees that the version is no longer considered as active LTS version. + '10.0.0': new Date(1912, 5, 23), + } + }); + + expect(await getBranchesForLabel('target: lts', '10.5.x')).toEqual(['10.5.x']); + }); + + it('should use target branch for "target: lts" if it matches an active LTS branch', async () => { + interceptBranchVersionRequest('master', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchVersionRequest('10.5.x', '10.5.1'); + interceptBranchesListRequest(['10.5.x', '11.0.x']); + + spyOn(require('node-fetch'), 'default').and.callFake(() => ({ + json: () => ({ + 'dist-tags': { + 'v10-lts': '10.5.1', + }, + 'time': { + '10.0.0': new Date().toISOString(), + } + }), + })); + + expect(await getBranchesForLabel('target: lts', '10.5.x')).toEqual(['10.5.x']); + }); + + it('should error if no active branch for given major version could be found', async () => { + interceptBranchVersionRequest('master', '12.0.0-next.0'); + interceptBranchesListRequest(['9.0.x', '9.1.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWithError( + 'Unable to determine the latest release-train. The following branches have ' + + 'been considered: []'); + }); + + it('should error if invalid version is set for version-branch', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.x'); + interceptBranchesListRequest(['11.1.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWithError('Invalid version detected in following branch: 11.1.x.'); + }); + + it('should error if branch more recent than version in "next" branch is found', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.2.x', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.5'); + interceptBranchesListRequest(['11.1.x', '11.2.x']); + + await expectAsync(getBranchesForLabel('target: lts', '10.2.x')) + .toBeRejectedWithError( + 'Discovered unexpected version-branch that is representing a minor version more ' + + 'recent than the one in the "master" branch. Consider deleting the branch, or check ' + + 'if the version in "master" is outdated.'); + }); + + it('should allow merging PR only into patch branch with "target: patch"', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0'); + interceptBranchesListRequest(['11.1.x']); + + expect(await getBranchesForLabel('target: patch', '11.1.x')).toEqual(['11.1.x']); + }); + + describe('next: major release', () => { + it('should detect "master" as branch for target: major', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.2.x']); + + expect(await getBranchesForLabel('target: major')).toEqual(['master']); + }); + + describe('without active release-candidate', () => { + it('should detect last-minor from previous major as branch for target: patch', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']); + + expect(await getBranchesForLabel('target: patch')).toEqual(['master', '10.2.x']); + }); + + it('should error if "target: rc" is applied', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.4'); + interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']); + + await expectAsync(getBranchesForLabel('target: rc')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'No active feature-freeze/release-candidate branch. Unable to merge ' + + 'pull request using "target: rc" label.' + })); + }); + }); + + describe('with active release-candidate', () => { + it('should detect most recent non-prerelease minor branch from previous major for ' + + 'target: patch', + async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0'); + interceptBranchVersionRequest('10.1.x', '10.2.3'); + interceptBranchesListRequest(['10.1.x', '10.2.x']); + + // Pull requests should also be merged into the RC and `next` (i.e. `master`) branch. + expect(await getBranchesForLabel('target: patch')).toEqual([ + 'master', '10.1.x', '10.2.x' + ]); + }); + + it('should detect release-candidate branch for "target: rc"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.0-rc.0'); + interceptBranchVersionRequest('10.1.x', '10.1.0'); + interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']); + + expect(await getBranchesForLabel('target: rc')).toEqual(['master', '10.2.x']); + }); + + it('should detect feature-freeze branch with "target: rc"', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.2.x', '10.2.0-next.0'); + interceptBranchVersionRequest('10.1.x', '10.1.0'); + interceptBranchesListRequest(['10.0.x', '10.1.x', '10.2.x']); + + expect(await getBranchesForLabel('target: rc')).toEqual(['master', '10.2.x']); + }); + + it('should error if multiple consecutive release-candidate branches are found', async () => { + interceptBranchVersionRequest('master', '11.0.0-next.0'); + interceptBranchVersionRequest('10.4.x', '10.4.0-next.0'); + interceptBranchVersionRequest('10.3.x', '10.4.0-rc.5'); + interceptBranchesListRequest(['10.3.x', '10.4.x']); + + await expectAsync(getBranchesForLabel('target: patch')) + .toBeRejectedWithError( + 'Unable to determine latest release-train. Found two consecutive ' + + 'branches in feature-freeze/release-candidate phase. Did not expect both ' + + '"10.3.x" and "10.4.x" to be in feature-freeze/release-candidate mode.'); + }); + }); + }); + + describe('next: minor release', () => { + it('should error if "target: major" is applied', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.4'); + interceptBranchesListRequest(['11.1.x']); + + await expectAsync(getBranchesForLabel('target: major')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'Unable to merge pull request. The "master" branch will be released as ' + + 'a minor version.', + })); + }); + + describe('without active release-candidate', () => { + it('should detect last-minor from previous major as branch for target: patch', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0'); + interceptBranchesListRequest(['11.1.x']); + + expect(await getBranchesForLabel('target: patch')).toEqual(['master', '11.1.x']); + }); + + it('should error if "target: rc" is applied', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0'); + interceptBranchesListRequest(['11.1.x']); + + await expectAsync(getBranchesForLabel('target: rc')) + .toBeRejectedWith(jasmine.objectContaining({ + failureMessage: + 'No active feature-freeze/release-candidate branch. Unable to merge pull ' + + 'request using "target: rc" label.' + })); + }); + }); + + describe('with active release-candidate', () => { + it('should detect most recent non-prerelease minor branch from previous major for ' + + 'target: patch', + async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0-rc.0'); + interceptBranchVersionRequest('11.0.x', '11.0.0'); + interceptBranchesListRequest(['11.0.x', '11.1.x']); + + // Pull requests should also be merged into the RC and `next` (i.e. `master`) branch. + expect(await getBranchesForLabel('target: patch')).toEqual([ + 'master', '11.0.x', '11.1.x' + ]); + }); + + it('should detect release-candidate branch for "target: rc"', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0-rc.0'); + interceptBranchVersionRequest('11.0.x', '10.0.0'); + interceptBranchesListRequest(['11.0.x', '11.1.x']); + + expect(await getBranchesForLabel('target: rc')).toEqual(['master', '11.1.x']); + }); + + it('should detect feature-freeze branch with "target: rc"', async () => { + interceptBranchVersionRequest('master', '11.2.0-next.0'); + interceptBranchVersionRequest('11.1.x', '11.1.0-next.0'); + interceptBranchVersionRequest('11.0.x', '10.0.0'); + interceptBranchesListRequest(['11.0.x', '11.1.x']); + + expect(await getBranchesForLabel('target: rc')).toEqual(['master', '11.1.x']); + }); + }); + }); +}); diff --git a/dev-infra/pr/merge/defaults/labels.ts b/dev-infra/pr/merge/defaults/labels.ts new file mode 100644 index 0000000000..1ef039bdb9 --- /dev/null +++ b/dev-infra/pr/merge/defaults/labels.ts @@ -0,0 +1,124 @@ +/** + * @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 {GithubConfig} from '../../../utils/config'; +import {GithubClient} from '../../../utils/git/github'; +import {TargetLabel} from '../config'; +import {InvalidTargetBranchError, InvalidTargetLabelError} from '../target-label'; + +import {fetchActiveReleaseTrainBranches, getVersionOfBranch, GithubRepo, isReleaseTrainBranch, nextBranchName} from './branches'; +import {assertActiveLtsBranch} from './lts-branch'; + +/** + * Gets a label configuration for the merge tooling that reflects the default Angular + * organization-wide labeling and branching semantics as outlined in the specification. + * + * https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU + */ +export async function getDefaultTargetLabelConfiguration( + api: GithubClient, github: GithubConfig, npmPackageName: string): Promise { + const repo: GithubRepo = {owner: github.owner, repo: github.name, api, npmPackageName}; + const nextVersion = await getVersionOfBranch(repo, nextBranchName); + const hasNextMajorTrain = nextVersion.minor === 0; + const {latestVersionBranch, releaseCandidateBranch} = + await fetchActiveReleaseTrainBranches(repo, nextVersion); + + return [ + { + pattern: 'target: major', + branches: () => { + // If `next` is currently not designated to be a major version, we do not + // allow merging of PRs with `target: major`. + if (!hasNextMajorTrain) { + throw new InvalidTargetLabelError( + `Unable to merge pull request. The "${nextBranchName}" branch will be ` + + `released as a minor version.`); + } + return [nextBranchName]; + }, + }, + { + pattern: 'target: minor', + // Changes labeled with `target: minor` are merged most commonly into the next branch + // (i.e. `master`). In rare cases of an exceptional minor version while being already + // on a major release train, this would need to be overridden manually. + // TODO: Consider handling this automatically by checking if the NPM version matches + // the last-minor. If not, then an exceptional minor might be in progress. See: + // https://docs.google.com/document/d/197kVillDwx-RZtSVOBtPb4BBIAw0E9RT3q3v6DZkykU/edit#heading=h.h7o5pjq6yqd0 + branches: [nextBranchName], + }, + { + pattern: 'target: patch', + branches: githubTargetBranch => { + // If a PR is targeting the latest active version-branch through the Github UI, + // and is also labeled with `target: patch`, then we merge it directly into the + // branch without doing any cherry-picking. This is useful if a PR could not be + // applied cleanly, and a separate PR for the patch branch has been created. + if (githubTargetBranch === latestVersionBranch) { + return [latestVersionBranch]; + } + // Otherwise, patch changes are always merged into the next and patch branch. + const branches = [nextBranchName, latestVersionBranch]; + // Additionally, if there is a release-candidate/feature-freeze release-train + // currently active, also merge the PR into that version-branch. + if (releaseCandidateBranch !== null) { + branches.push(releaseCandidateBranch); + } + return branches; + } + }, + { + pattern: 'target: rc', + branches: githubTargetBranch => { + // The `target: rc` label cannot be applied if there is no active feature-freeze + // or release-candidate release train. + if (releaseCandidateBranch === null) { + throw new InvalidTargetLabelError( + `No active feature-freeze/release-candidate branch. ` + + `Unable to merge pull request using "target: rc" label.`); + } + // If the PR is targeting the active release-candidate/feature-freeze version branch + // directly through the Github UI and has the `target: rc` label applied, merge it + // only into the release candidate branch. This is useful if a PR did not apply cleanly + // into the release-candidate/feature-freeze branch, and a separate PR has been created. + if (githubTargetBranch === releaseCandidateBranch) { + return [releaseCandidateBranch]; + } + // Otherwise, merge into the next and active release-candidate/feature-freeze branch. + return [nextBranchName, releaseCandidateBranch]; + }, + }, + { + // LTS changes are rare enough that we won't worry about cherry-picking changes into all + // active LTS branches for PRs created against any other branch. Instead, PR authors need + // to manually create separate PRs for desired LTS branches. Additionally, active LT branches + // commonly diverge quickly. This makes cherry-picking not an option for LTS changes. + pattern: 'target: lts', + branches: async githubTargetBranch => { + if (!isReleaseTrainBranch(githubTargetBranch)) { + throw new InvalidTargetBranchError( + `PR cannot be merged as it does not target a long-term support ` + + `branch: "${githubTargetBranch}"`); + } + if (githubTargetBranch === latestVersionBranch) { + throw new InvalidTargetBranchError( + `PR cannot be merged with "target: lts" into patch branch. ` + + `Consider changing the label to "target: patch" if this is intentional.`); + } + if (githubTargetBranch === releaseCandidateBranch && releaseCandidateBranch !== null) { + throw new InvalidTargetBranchError( + `PR cannot be merged with "target: lts" into feature-freeze/release-candidate ` + + `branch. Consider changing the label to "target: rc" if this is intentional.`); + } + // Assert that the selected branch is an active LTS branch. + await assertActiveLtsBranch(repo, githubTargetBranch); + return [githubTargetBranch]; + }, + }, + ]; +} diff --git a/dev-infra/pr/merge/defaults/lts-branch.ts b/dev-infra/pr/merge/defaults/lts-branch.ts new file mode 100644 index 0000000000..5db07f8ad4 --- /dev/null +++ b/dev-infra/pr/merge/defaults/lts-branch.ts @@ -0,0 +1,80 @@ +/** + * @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 fetch from 'node-fetch'; +import * as semver from 'semver'; + +import {promptConfirm, red, warn, yellow} from '../../../utils/console'; +import {InvalidTargetBranchError} from '../target-label'; + +import {getVersionOfBranch, GithubRepo} from './branches'; + +/** + * Number of months a major version in Angular is actively supported. See: + * https://angular.io/guide/releases#support-policy-and-schedule. + */ +const majorActiveSupportDuration = 6; + +/** + * Number of months a major version has active long-term support. See: + * https://angular.io/guide/releases#support-policy-and-schedule. + */ +const majorActiveTermSupportDuration = 12; + +/** + * Asserts that the given branch corresponds to an active LTS version-branch that can receive + * backported fixes. Throws an error if LTS expired or an invalid branch is selected. + */ +export async function assertActiveLtsBranch(repo: GithubRepo, branchName: string) { + const version = await getVersionOfBranch(repo, branchName); + const {'dist-tags': distTags, time} = + await (await fetch(`https://registry.npmjs.org/${repo.npmPackageName}`)).json(); + + // LTS versions should be tagged in NPM in the following format: `v{major}-lts`. + const ltsVersion = semver.parse(distTags[`v${version.major}-lts`]); + + // Ensure that there is a LTS version tagged for the given version-branch major. e.g. + // if the version branch is `9.2.x` then we want to make sure that there is a LTS + // version tagged in NPM for `v9`, following the `v{major}-lts` tag convention. + if (ltsVersion === null) { + throw new InvalidTargetBranchError(`No LTS version tagged for v${version.major} in NPM.`); + } + + // Ensure that the correct branch is used for the LTS version. We do not want to merge + // changes to older minor version branches that do not reflect the current LTS version. + if (branchName !== `${ltsVersion.major}.${ltsVersion.minor}.x`) { + throw new InvalidTargetBranchError( + `Not using last-minor branch for v${version.major} LTS version. PR ` + + `should be updated to target: ${ltsVersion.major}.${ltsVersion.minor}.x`); + } + + const today = new Date(); + const releaseDate = new Date(time[`${version.major}.0.0`]); + const ltsEndDate = new Date( + releaseDate.getFullYear(), + releaseDate.getMonth() + majorActiveSupportDuration + majorActiveTermSupportDuration, + releaseDate.getDate(), releaseDate.getHours(), releaseDate.getMinutes(), + releaseDate.getSeconds(), releaseDate.getMilliseconds()); + + // Check if LTS has already expired for the targeted major version. If so, we do not + // allow the merge as per our LTS guarantees. Can be forcibly overridden if desired. + // See: https://angular.io/guide/releases#support-policy-and-schedule. + if (today > ltsEndDate) { + const ltsEndDateText = ltsEndDate.toLocaleDateString(); + warn(red(`Long-term support ended for v${version.major} on ${ltsEndDateText}.`)); + warn(yellow( + `Merging of pull requests for this major is generally not ` + + `desired, but can be forcibly ignored.`)); + if (await promptConfirm('Do you want to forcibly proceed with merging?')) { + return; + } + throw new InvalidTargetBranchError( + `Long-term supported ended for v${version.major} on ${ltsEndDateText}. ` + + `Pull request cannot be merged into the ${branchName} branch.`); + } +} diff --git a/dev-infra/pr/merge/determine-merge-branches.ts b/dev-infra/pr/merge/determine-merge-branches.ts deleted file mode 100644 index bf4a479caa..0000000000 --- a/dev-infra/pr/merge/determine-merge-branches.ts +++ /dev/null @@ -1,68 +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 * as semver from 'semver'; -import {exec} from '../../utils/shelljs'; - -/** - * Helper function that can be used to determine merge branches based on a given - * project version. The function determines merge branches primarily through the - * specified version, but falls back to consulting the NPM registry when needed. - * - * Consulting the NPM registry for determining the patch branch may slow down merging, - * so whenever possible, the branches are determined statically based on the current - * version. In some cases, consulting the NPM registry is inevitable because for major - * pre-releases, we cannot determine the latest stable minor version from the current - * pre-release version. - */ -export function determineMergeBranches( - currentVersion: string, npmPackageName: string): {minor: string, patch: string} { - const projectVersion = semver.parse(currentVersion); - if (projectVersion === null) { - throw Error('Cannot parse version set in project "package.json" file.'); - } - const {major, minor, patch, prerelease} = projectVersion; - const isMajor = minor === 0 && patch === 0; - const isMinor = minor !== 0 && patch === 0; - - // If there is no prerelease, then we compute patch and minor branches based - // on the current version major and minor. - if (prerelease.length === 0) { - return {minor: `${major}.x`, patch: `${major}.${minor}.x`}; - } - - // If current version is set to a minor prerelease, we can compute the merge branches - // statically. e.g. if we are set to `9.3.0-next.0`, then our merge branches should - // be set to `9.x` and `9.2.x`. - if (isMinor) { - return {minor: `${major}.x`, patch: `${major}.${minor - 1}.x`}; - } else if (!isMajor) { - throw Error('Unexpected version. Cannot have prerelease for patch version.'); - } - - // If we are set to a major prerelease, we cannot statically determine the stable patch - // branch (as the latest minor segment is unknown). We determine it by looking in the NPM - // registry for the latest stable release that will tell us about the current minor segment. - // e.g. if the current major is `v10.0.0-next.0`, then we need to look for the latest release. - // Let's say this is `v9.2.6`. Our patch branch will then be called `9.2.x`. - const latestVersion = exec(`yarn -s info ${npmPackageName} dist-tags.latest`).trim(); - if (!latestVersion) { - throw Error('Could not determine version of latest release.'); - } - const expectedMajor = major - 1; - const parsedLatestVersion = semver.parse(latestVersion); - if (parsedLatestVersion === null) { - throw Error(`Could not parse latest version from NPM registry: ${latestVersion}`); - } else if (parsedLatestVersion.major !== expectedMajor) { - throw Error( - `Expected latest release to have major version: v${expectedMajor}, ` + - `but got: v${latestVersion}`); - } - - return {patch: `${expectedMajor}.${parsedLatestVersion.minor}.x`, minor: `${expectedMajor}.x`}; -} diff --git a/dev-infra/pr/merge/target-label.ts b/dev-infra/pr/merge/target-label.ts index ba43cecc1f..3a83fc4e89 100644 --- a/dev-infra/pr/merge/target-label.ts +++ b/dev-infra/pr/merge/target-label.ts @@ -26,8 +26,8 @@ export class InvalidTargetLabelError { } /** Gets the target label from the specified pull request labels. */ -export function getTargetLabelFromPullRequest(config: MergeConfig, labels: string[]): TargetLabel| - null { +export function getTargetLabelFromPullRequest( + config: Pick, labels: string[]): TargetLabel|null { for (const label of labels) { const match = config.labels.find(({pattern}) => matchesPattern(label, pattern)); if (match !== undefined) { diff --git a/dev-infra/tmpl-package.json b/dev-infra/tmpl-package.json index 5ff632c496..0b3048a665 100644 --- a/dev-infra/tmpl-package.json +++ b/dev-infra/tmpl-package.json @@ -19,6 +19,7 @@ "inquirer": "", "minimatch": "", "multimatch": "", + "node-fetch": "", "node-uuid": "", "semver": "", "shelljs": "", diff --git a/package.json b/package.json index 880cea43c5..350be9de2d 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/jasminewd2": "^2.0.8", "@types/minimist": "^1.2.0", "@types/node": "^12.11.1", + "@types/node-fetch": "^2.5.7", "@types/selenium-webdriver": "3.0.7", "@types/semver": "^6.0.2", "@types/shelljs": "^0.8.6", @@ -127,6 +128,7 @@ "materialize-css": "1.0.0", "minimatch": "^3.0.4", "minimist": "1.2.0", + "node-fetch": "^2.6.0", "node-uuid": "1.4.8", "nodejs-websocket": "^1.7.2", "protractor": "^5.4.2", @@ -185,6 +187,7 @@ "madge": "^3.6.0", "multimatch": "^4.0.0", "mutation-observer": "^1.0.3", + "nock": "^13.0.3", "rewire": "2.5.2", "sauce-connect": "https://saucelabs.com/downloads/sc-4.5.1-linux.tar.gz", "semver": "^6.3.0", diff --git a/yarn.lock b/yarn.lock index 9155224971..c234a1cc6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2243,6 +2243,14 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= +"@types/node-fetch@^2.5.7": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" + integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== + dependencies: + "@types/node" "*" + form-data "^3.0.0" + "@types/node@*", "@types/node@>= 8": version "13.11.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b" @@ -4418,7 +4426,7 @@ colors@~1.2.1: resolved "https://registry.yarnpkg.com/colors/-/colors-1.2.5.tgz#89c7ad9a374bc030df8013241f68136ed8835afc" integrity sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg== -combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6: +combined-stream@^1.0.5, combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.5, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -6792,6 +6800,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.0.0.tgz#6f0aebadcc5da16c13e1ecc11137d85f9b883b25" @@ -10742,6 +10759,16 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^13.0.3: + version "13.0.3" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.0.3.tgz#9f81f04499af6a87f9c419a023920b623d715110" + integrity sha512-hDscKS5chEfyEiF8J1syz8mkkH6Wetp04ECAAPNdL5k6e6WmRgx9FZZNnCrjePNdykgiiPXORBcXbNmMzFOP5w== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash.set "^4.3.2" + propagate "^2.0.0" + node-emoji@^1.4.1: version "1.10.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da" @@ -12306,6 +12333,11 @@ promzard@0.3.0: dependencies: read "1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + protobufjs@6.8.8: version "6.8.8" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"