angular/dev-infra/pr/merge/defaults/integration.spec.ts
Paul Gschwendtner ebc0e46501 refactor(dev-infra): improve error message for unexpected version branches (#38622)
Currently the merge script default branch configuration throws an error
if an unexpected version branch is discovered. The error right now
assumes to much knowledge of the logic and the document outlining
the release trains conceptually.

We change it to something more easy to understand that doesn't require
full understanding of the versioning/labeling/branching document that
has been created for the Angular organization.

PR Close #38622
2020-08-31 09:29:58 -07:00

470 lines
20 KiB
TypeScript

/**
* @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<TargetLabel[]> {
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<string[]|null> {
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 version-branch more recent than "next" is discovered', async () => {
interceptBranchVersionRequest('master', '11.2.0-next.0');
interceptBranchVersionRequest('11.3.x', '11.3.0-next.0');
interceptBranchVersionRequest('11.1.x', '11.1.5');
interceptBranchesListRequest(['11.1.x', '11.3.x']);
await expectAsync(getBranchesForLabel('target: lts', '10.2.x'))
.toBeRejectedWithError(
'Discovered unexpected version-branch "11.3.x" for a release-train that is ' +
'more recent than the release-train currently in the "master" branch. Please ' +
'either delete the branch if created by accident, or update the outdated version ' +
'in the next branch (master).');
});
it('should error if branch is matching with release-train in the "next" branch', 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 "11.2.x" for a release-train that is already ' +
'active in the "master" branch. Please either delete the branch if created by ' +
'accident, or update the version in the next branch (master).');
});
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']);
});
});
});
});