Skip to content

Commit

Permalink
build: create merge script w/ cherry-picking (angular#18856)
Browse files Browse the repository at this point in the history
* build: create merge script w/ cherry-picking

Creates a merge script that automatically merges pull
requests from Github and cherry-picks them into the
right publish branches based on the PR target label.

The script has been designed in an extensible and programmatically
acessible way, so that it can be used in other Angular repositories too.

The script provides infrastructure for PR merge safety checks. e.g.
it currently enforces that the CLA is signed, or that the merge ready
label is assigned, or that no CI checks are failing / pending.

* fixup! build: create merge script w/ cherry-picking

Address feedback
  • Loading branch information
devversion authored Apr 22, 2020
1 parent e512581 commit 9d31d71
Show file tree
Hide file tree
Showing 16 changed files with 1,155 additions and 2 deletions.
52 changes: 52 additions & 0 deletions merge-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module.exports = () => {
const {major, minor} = parseVersion(require('./package').version);
const patchBranch = `${major}.${minor}.x`;
const minorBranch = `${major}.x`;

return {
projectRoot: __dirname,
repository: {
user: 'angular',
name: 'components',
},
// By default, the merge script merges locally with `git cherry-pick` and autosquash.
// This has the downside of pull requests showing up as `Closed` instead of `Merged`.
// In the components repository, since we don't use fixup or squash commits, we can
// use the Github API merge strategy. That way we ensure that PRs show up as `Merged`.
githubApiMerge: {
default: 'squash',
labels: [
{pattern: 'preserve commits', method: 'rebase'}
]
},
claSignedLabel: 'cla: yes',
mergeReadyLabel: 'merge ready',
commitMessageFixupLabel: 'commit message fixup',
labels: [
{
pattern: 'target: patch',
branches: ['master', patchBranch],
},
{
pattern: 'target: minor',
branches: ['master', minorBranch],
},
{
pattern: 'target: major',
branches: ['master'],
},
{
pattern: 'target: development-branch',
// Merge PRs with the given label only into the target branch that has
// been specified through the Github UI.
branches: (target) => [target],
}
],
}
};

/** Converts a version string into an object. */
function parseVersion(version) {
const [major = 0, minor = 0, patch = 0] = version.split('.').map(segment => Number(segment));
return {major, minor, patch};
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"stylelint": "stylelint \"src/**/*.+(css|scss)\" --config .stylelintrc.json --syntax scss",
"resync-caretaker-app": "ts-node --project scripts scripts/caretaking/resync-caretaker-app-prs.ts",
"ts-circular-deps:check": "yarn -s ts-circular-deps check --config ./src/circular-deps-test.conf.js",
"ts-circular-deps:approve": "yarn -s ts-circular-deps approve --config ./src/circular-deps-test.conf.js"
"ts-circular-deps:approve": "yarn -s ts-circular-deps approve --config ./src/circular-deps-test.conf.js",
"merge": "ts-node --project scripts scripts/merge-script/cli.ts --config ./merge-config.js"
},
"version": "9.2.1",
"dependencies": {
Expand Down
137 changes: 137 additions & 0 deletions scripts/merge-script/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @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 chalk from 'chalk';
import * as minimist from 'minimist';
import {isAbsolute, resolve} from 'path';

import {Config, readAndValidateConfig} from './config';
import {promptConfirm} from './console';
import {MergeResult, MergeStatus, PullRequestMergeTask} from './index';

// Run the CLI.
main();

/**
* Entry-point for the merge script CLI. The script can be used to merge individual pull requests
* into branches based on the `PR target` labels that have been set in a configuration. The script
* aims to reduce the manual work that needs to be performed to cherry-pick a PR into multiple
* branches based on a target label.
*/
async function main() {
const {config, prNumber, force, githubToken} = parseCommandLine();
const api = new PullRequestMergeTask(config, githubToken);

// Perform the merge. Force mode can be activated through a command line flag.
// Alternatively, if the merge fails with non-fatal failures, the script
// will prompt whether it should rerun in force mode.
const mergeResult = await api.merge(prNumber, force);

// Handle the result of the merge. If failures have been reported, exit
// the process with a non-zero exit code.
if (!await handleMergeResult(mergeResult, force)) {
process.exit(1);
}

/**
* Prompts whether the specified pull request should be forcibly merged. If so, merges
* the specified pull request forcibly (ignoring non-critical failures).
* @returns Whether the specified pull request has been forcibly merged.
*/
async function promptAndPerformForceMerge(): Promise<boolean> {
if (await promptConfirm('Do you want to forcibly proceed with merging?')) {
// Perform the merge in force mode. This means that non-fatal failures
// are ignored and the merge continues.
const forceMergeResult = await api.merge(prNumber, true);
// Handle the merge result. Note that we disable the force merge prompt since
// a failed force merge will never succeed with a second force merge.
return await handleMergeResult(forceMergeResult, true);
}
return false;
}

/**
* Handles the merge result by printing console messages, exiting the process
* based on the result, or by restarting the merge if force mode has been enabled.
* @returns Whether the merge was successful or not.
*/
async function handleMergeResult(result: MergeResult, disableForceMergePrompt = false) {
const {failure, status} = result;
const canForciblyMerge = failure && failure.nonFatal;

switch (status) {
case MergeStatus.SUCCESS:
console.info(chalk.green(`Successfully merged the pull request: ${prNumber}`));
return true;
case MergeStatus.DIRTY_WORKING_DIR:
console.error(chalk.red(
`Local working repository not clean. Please make sure there are ` +
`no uncommitted changes.`));
return false;
case MergeStatus.UNKNOWN_GIT_ERROR:
console.error(chalk.red(
'An unknown Git error has been thrown. Please check the output ' +
'above for details.'));
return false;
case MergeStatus.FAILED:
console.error(chalk.yellow(`Could not merge the specified pull request.`));
console.error(chalk.red(failure!.message));
if (canForciblyMerge && !disableForceMergePrompt) {
console.info();
console.info(chalk.yellow('The pull request above failed due to non-critical errors.'));
console.info(chalk.yellow(`This error can be forcibly ignored if desired.`));
return await promptAndPerformForceMerge();
}
return false;
default:
throw Error(`Unexpected merge result: ${status}`);
}
}
}

// TODO(devversion): Use Yargs for this once the script has been moved to `angular/angular`.
/** Parses the command line and returns the passed options. */
function parseCommandLine():
{config: Config, force: boolean, prNumber: number, dryRun?: boolean, githubToken: string} {
const {config: configPath, githubToken: githubTokenArg, force, _: [prNumber]} =
minimist<any>(process.argv.slice(2), {
string: ['githubToken', 'config', 'pr'],
alias: {
'githubToken': 'github-token',
},
});

if (!configPath) {
console.error(chalk.red('No configuration file specified. Please pass the `--config` option.'));
process.exit(1);
}

if (!prNumber) {
console.error(chalk.red('No pull request specified. Please pass a pull request number.'));
process.exit(1);
}

const configFilePath = isAbsolute(configPath) ? configPath : resolve(configPath);
const {config, errors} = readAndValidateConfig(configFilePath);

if (errors) {
console.error(chalk.red('Configuration could not be read:'));
errors.forEach(desc => console.error(chalk.yellow(` * ${desc}`)));
process.exit(1);
}

const githubToken = githubTokenArg || process.env.GITHUB_TOKEN || process.env.TOKEN;
if (!githubToken) {
console.error(
chalk.red('No Github token is set. Please set the `GITHUB_TOKEN` environment variable.'));
console.error(chalk.red('Alternatively, pass the `--github-token` command line flag.'));
process.exit(1);
}

return {config: config!, prNumber, githubToken, force};
}
103 changes: 103 additions & 0 deletions scripts/merge-script/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @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 {dirname, resolve} from 'path';
import {GithubApiMergeStrategyConfig} from './strategies/api-merge';

/**
* Possible merge methods supported by the Github API.
* https://developer.github.com/v3/pulls/#merge-a-pull-request-merge-button.
*/
export type GithubApiMergeMethod = 'merge'|'squash'|'rebase';

/**
* Target labels represent Github pull requests labels. These labels instruct the merge
* script into which branches a given pull request should be merged to.
*/
export interface TargetLabel {
/** Pattern that matches the given target label. */
pattern: RegExp|string;
/**
* List of branches a pull request with this target label should be merged into.
* Can also be wrapped in a function that accepts the target branch specified in the
* Github Web UI. This is useful for supporting labels like `target: development-branch`.
*/
branches: string[]|((githubTargetBranch: string) => string[]);
}

/** Configuration for the merge script. */
export interface Config {
/** Relative path (based on the config location) to the project root. */
projectRoot: string;
/** Configuration for the upstream repository. */
repository: {user: string; name: string; useSsh?: boolean};
/** List of target labels. */
labels: TargetLabel[];
/** Required base commits for given branches. */
requiredBaseCommits?: {[branchName: string]: string};
/** Pattern that matches labels which imply a signed CLA. */
claSignedLabel: string|RegExp;
/** Pattern that matches labels which imply a merge ready pull request. */
mergeReadyLabel: string|RegExp;
/** Label which can be applied to fixup commit messages in the merge script. */
commitMessageFixupLabel: string|RegExp;
/**
* Whether pull requests should be merged using the Github API. This can be enabled
* if projects want to have their pull requests show up as `Merged` in the Github UI.
* The downside is that fixup or squash commits no longer work as the Github API does
* not support this.
*/
githubApiMerge: false | GithubApiMergeStrategyConfig;
}

/** Reads and validates the configuration file at the specified location. */
export function readAndValidateConfig(filePath: string): {config?: Config, errors?: string[]} {
// Capture errors when the configuration cannot be read. Errors will be thrown if the file
// does not exist, if the config file cannot be evaluated, or if no default export is found.
try {
const config = require(filePath)() as Config;
const errors = validateConfig(config);
if (errors.length) {
return {errors};
}
// Resolves the project root path to an absolute path based on the
// config file location.
config.projectRoot = resolve(dirname(filePath), config.projectRoot);
return {config};
} catch (e) {
return {errors: [`File could not be loaded. Error: ${e.message}`]};
}
}

/** Validates the specified configuration. Returns a list of failure messages. */
function validateConfig(config: Config): string[] {
const errors: string[] = [];
if (!config.projectRoot) {
errors.push('Missing project root.');
}
if (!config.labels) {
errors.push('No label configuration.');
} else if (!Array.isArray(config.labels)) {
errors.push('Label configuration needs to be an array.');
}
if (!config.repository) {
errors.push('No repository is configured.');
} else if (!config.repository.user || !config.repository.name) {
errors.push('Repository configuration needs to specify a `user` and repository `name`.');
}
if (!config.claSignedLabel) {
errors.push('No CLA signed label configured.');
}
if (!config.mergeReadyLabel) {
errors.push('No merge ready label configured.');
}
if (config.githubApiMerge === undefined) {
errors.push('No explicit choice of merge strategy. Please set `githubApiMerge`.');
}
return errors;
}
20 changes: 20 additions & 0 deletions scripts/merge-script/console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @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 {prompt} from 'inquirer';

/** Prompts the user with a confirmation question and a specified message. */
export async function promptConfirm(message: string, defaultValue = false): Promise<boolean> {
return (await prompt<{result: boolean}>({
type: 'confirm',
name: 'result',
message: message,
default: defaultValue,
}))
.result;
}
73 changes: 73 additions & 0 deletions scripts/merge-script/failures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @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
*/

/**
* Class that can be used to describe pull request failures. A failure
* is described through a human-readable message and a flag indicating
* whether it is non-fatal or not.
*/
export class PullRequestFailure {
constructor(
/** Human-readable message for the failure */
public message: string,
/** Whether the failure is non-fatal and can be forcibly ignored. */
public nonFatal = false) {}

static claUnsigned() {
return new this(`CLA has not been signed. Please make sure the PR author has signed the CLA.`);
}

static failingCiJobs() {
return new this(`Failing CI jobs.`, true);
}

static pendingCiJobs() {
return new this(`Pending CI jobs.`, true);
}

static notMergeReady() {
return new this(`Not marked as merge ready.`);
}

static noTargetLabel() {
return new this(`No target branch could be determined. Please ensure a target label is set.`);
}

static mismatchingTargetBranch(allowedBranches: string[]) {
return new this(
`Pull request is set to wrong base branch. Please update the PR in the Github UI ` +
`to one of the following branches: ${allowedBranches.join(', ')}.`);
}

static unsatisfiedBaseSha() {
return new this(
`Pull request has not been rebased recently and could be bypassing CI checks. ` +
`Please rebase the PR.`);
}

static mergeConflicts(failedBranches: string[]) {
return new this(
`Could not merge pull request into the following branches due to merge ` +
`conflicts: ${
failedBranches.join(', ')}. Please rebase the PR or update the target label.`);
}

static unknownMergeError() {
return new this(`Unknown merge error occurred. Please see console output above for debugging.`);
}

static unableToFixupCommitMessageSquashOnly() {
return new this(
`Unable to fixup commit message of pull request. Commit message can only be ` +
`modified if the PR is merged using squash.`);
}

static notFound() {
return new this(`Pull request could not be found upstream.`);
}
}
Loading

0 comments on commit 9d31d71

Please sign in to comment.