feat(dev-infra): move merge script over from components repo (#37138)
Moves the merge script from the components repository over to the shared dev-infra package. The merge script has been orginally built for all Angular repositories, but we just kept it in the components repo temporarily to test it. Since everything went well on the components side, we now move the script over and integrate it into the dev-infra package. PR Close #37138
This commit is contained in:
parent
22d80a6780
commit
318e9372c9
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @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 {getAngularDevConfig} from '../../utils/config';
|
||||
|
||||
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 MergeConfig {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Loads and validates the merge configuration. */
|
||||
export function loadAndValidateConfig(): {config?: MergeConfig, errors?: string[]} {
|
||||
const config = getAngularDevConfig<'merge', MergeConfig>().merge;
|
||||
if (config === undefined) {
|
||||
return {
|
||||
errors: ['No merge configuration found. Set the `merge` configuration.']
|
||||
}
|
||||
}
|
||||
const errors = validateConfig(config);
|
||||
if (errors.length) {
|
||||
return {errors};
|
||||
}
|
||||
return {config};
|
||||
}
|
||||
|
||||
/** Validates the specified configuration. Returns a list of failure messages. */
|
||||
function validateConfig(config: MergeConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* @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.`);
|
||||
}
|
||||
|
||||
static insufficientPermissionsToMerge() {
|
||||
return new this(
|
||||
`Insufficient Github API permissions to merge pull request. Please ` +
|
||||
`ensure that your auth token has write access.`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @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 Octokit from '@octokit/rest';
|
||||
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
||||
import {MergeConfig} from './config';
|
||||
|
||||
/** Error for failed Github API requests. */
|
||||
export class GithubApiRequestError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Error for failed Git commands. */
|
||||
export class GitCommandError extends Error {
|
||||
constructor(client: GitClient, public args: string[]) {
|
||||
// Errors are not guaranteed to be caught. To ensure that we don't
|
||||
// accidentally leak the Github token that might be used in a command,
|
||||
// we sanitize the command that will be part of the error message.
|
||||
super(`Command failed: git ${client.omitGithubTokenFromMessage(args.join(' '))}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class GitClient {
|
||||
/** Short-hand for accessing the repository configuration. */
|
||||
repoConfig = this._config.repository;
|
||||
/** Octokit request parameters object for targeting the configured repository. */
|
||||
repoParams = {owner: this.repoConfig.user, repo: this.repoConfig.name};
|
||||
/** URL that resolves to the configured repository. */
|
||||
repoGitUrl = this.repoConfig.useSsh ?
|
||||
`git@github.com:${this.repoConfig.user}/${this.repoConfig.name}.git` :
|
||||
`https://${this._githubToken}@github.com/${this.repoConfig.user}/${this.repoConfig.name}.git`;
|
||||
/** Instance of the authenticated Github octokit API. */
|
||||
api: Octokit;
|
||||
|
||||
/** Regular expression that matches the provided Github token. */
|
||||
private _tokenRegex = new RegExp(this._githubToken, 'g');
|
||||
|
||||
constructor(
|
||||
private _projectRoot: string, private _githubToken: string, private _config: MergeConfig) {
|
||||
this.api = new Octokit({auth: _githubToken});
|
||||
this.api.hook.error('request', error => {
|
||||
// Wrap API errors in a known error class. This allows us to
|
||||
// expect Github API errors better and in a non-ambiguous way.
|
||||
throw new GithubApiRequestError(error.status, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Executes the given git command. Throws if the command fails. */
|
||||
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
|
||||
const result = this.runGraceful(args, options);
|
||||
if (result.status !== 0) {
|
||||
throw new GitCommandError(this, args);
|
||||
}
|
||||
// Omit `status` from the type so that it's obvious that the status is never
|
||||
// non-zero as explained in the method description.
|
||||
return result as Omit<SpawnSyncReturns<string>, 'status'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a given Git command process. Does not throw if the command fails. Additionally,
|
||||
* if there is any stderr output, the output will be printed. This makes it easier to
|
||||
* debug failed commands.
|
||||
*/
|
||||
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
|
||||
// To improve the debugging experience in case something fails, we print all executed
|
||||
// Git commands. Note that we do not want to print the token if is contained in the
|
||||
// command. It's common to share errors with others if the tool failed.
|
||||
console.info('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
|
||||
|
||||
const result = spawnSync('git', args, {
|
||||
cwd: this._projectRoot,
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
// Encoding is always `utf8` and not overridable. This ensures that this method
|
||||
// always returns `string` as output instead of buffers.
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.stderr !== null) {
|
||||
// Git sometimes prints the command if it failed. This means that it could
|
||||
// potentially leak the Github token used for accessing the remote. To avoid
|
||||
// printing a token, we sanitize the string before printing the stderr output.
|
||||
process.stderr.write(this.omitGithubTokenFromMessage(result.stderr));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether the given branch contains the specified SHA. */
|
||||
hasCommit(branchName: string, sha: string): boolean {
|
||||
return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
|
||||
}
|
||||
|
||||
/** Gets the currently checked out branch. */
|
||||
getCurrentBranch(): string {
|
||||
return this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
|
||||
}
|
||||
|
||||
/** Gets whether the current Git repository has uncommitted changes. */
|
||||
hasUncommittedChanges(): boolean {
|
||||
return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
|
||||
}
|
||||
|
||||
/** Sanitizes a given message by omitting the provided Github token if present. */
|
||||
omitGithubTokenFromMessage(value: string): string {
|
||||
return value.replace(this._tokenRegex, '<TOKEN>');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* @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 {MergeResult, MergeStatus, PullRequestMergeTask} from './task';
|
||||
import {GithubApiRequestError} from './git';
|
||||
import chalk from 'chalk';
|
||||
import {promptConfirm} from './console';
|
||||
import {loadAndValidateConfig} from './config';
|
||||
import {getRepoBaseDir} from '../../utils/config';
|
||||
|
||||
/** URL to the Github page where personal access tokens can be generated. */
|
||||
export const GITHUB_TOKEN_GENERATE_URL = `https://github.com/settings/tokens`;
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function mergePullRequest(prNumber: number, githubToken: string) {
|
||||
const projectRoot = getRepoBaseDir();
|
||||
const {config, errors} = loadAndValidateConfig();
|
||||
|
||||
if (errors) {
|
||||
console.error(chalk.red('Invalid configuration:'));
|
||||
errors.forEach(desc => console.error(chalk.yellow(` - ${desc}`)));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const api = new PullRequestMergeTask(projectRoot, 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.
|
||||
if (!await performMerge(false)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Performs the merge and returns whether it was successful or not. */
|
||||
async function performMerge(ignoreFatalErrors: boolean): Promise<boolean> {
|
||||
try {
|
||||
const result = await api.merge(prNumber, ignoreFatalErrors);
|
||||
return await handleMergeResult(result, ignoreFatalErrors);
|
||||
} catch (e) {
|
||||
// Catch errors to the Github API for invalid requests. We want to
|
||||
// exit the script with a better explanation of the error.
|
||||
if (e instanceof GithubApiRequestError && e.status === 401) {
|
||||
console.error(chalk.red('Github API request failed. ' + e.message));
|
||||
console.error(chalk.yellow('Please ensure that your provided token is valid.'));
|
||||
console.error(chalk.yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`));
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
return performMerge(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}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* @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 Octokit from '@octokit/rest';
|
||||
|
||||
import {PullRequestFailure} from './failures';
|
||||
import {GitClient} from './git';
|
||||
import {matchesPattern} from './string-pattern';
|
||||
import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from './target-label';
|
||||
import {PullRequestMergeTask} from './task';
|
||||
|
||||
/** Interface that describes a pull request. */
|
||||
export interface PullRequest {
|
||||
/** Number of the pull request. */
|
||||
prNumber: number;
|
||||
/** Title of the pull request. */
|
||||
title: string;
|
||||
/** Labels applied to the pull request. */
|
||||
labels: string[];
|
||||
/** List of branches this PR should be merged into. */
|
||||
targetBranches: string[];
|
||||
/** Branch that the PR targets in the Github UI. */
|
||||
githubTargetBranch: string;
|
||||
/** Count of commits in this pull request. */
|
||||
commitCount: number;
|
||||
/** Optional SHA that this pull request needs to be based on. */
|
||||
requiredBaseSha?: string;
|
||||
/** Whether the pull request commit message fixup. */
|
||||
needsCommitMessageFixup: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates the specified pull request against the given configuration.
|
||||
* If the pull requests fails, a pull request failure is returned.
|
||||
*/
|
||||
export async function loadAndValidatePullRequest(
|
||||
{git, config}: PullRequestMergeTask, prNumber: number,
|
||||
ignoreNonFatalFailures = false): Promise<PullRequest|PullRequestFailure> {
|
||||
const prData = await fetchPullRequestFromGithub(git, prNumber);
|
||||
|
||||
if (prData === null) {
|
||||
return PullRequestFailure.notFound();
|
||||
}
|
||||
|
||||
const labels = prData.labels.map(l => l.name);
|
||||
|
||||
if (!labels.some(name => matchesPattern(name, config.mergeReadyLabel))) {
|
||||
return PullRequestFailure.notMergeReady();
|
||||
}
|
||||
if (!labels.some(name => matchesPattern(name, config.claSignedLabel))) {
|
||||
return PullRequestFailure.claUnsigned();
|
||||
}
|
||||
|
||||
const targetLabel = getTargetLabelFromPullRequest(config, labels);
|
||||
if (targetLabel === null) {
|
||||
return PullRequestFailure.noTargetLabel();
|
||||
}
|
||||
|
||||
const {data: {state}} =
|
||||
await git.api.repos.getCombinedStatusForRef({...git.repoParams, ref: prData.head.sha});
|
||||
|
||||
if (state === 'failure' && !ignoreNonFatalFailures) {
|
||||
return PullRequestFailure.failingCiJobs();
|
||||
}
|
||||
if (state === 'pending' && !ignoreNonFatalFailures) {
|
||||
return PullRequestFailure.pendingCiJobs();
|
||||
}
|
||||
|
||||
const githubTargetBranch = prData.base.ref;
|
||||
const requiredBaseSha =
|
||||
config.requiredBaseCommits && config.requiredBaseCommits[githubTargetBranch];
|
||||
const needsCommitMessageFixup = !!config.commitMessageFixupLabel &&
|
||||
labels.some(name => matchesPattern(name, config.commitMessageFixupLabel));
|
||||
|
||||
return {
|
||||
prNumber,
|
||||
labels,
|
||||
requiredBaseSha,
|
||||
githubTargetBranch,
|
||||
needsCommitMessageFixup,
|
||||
title: prData.title,
|
||||
targetBranches: getBranchesFromTargetLabel(targetLabel, githubTargetBranch),
|
||||
commitCount: prData.commits,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fetches a pull request from Github. Returns null if an error occurred. */
|
||||
async function fetchPullRequestFromGithub(
|
||||
git: GitClient, prNumber: number): Promise<Octokit.PullsGetResponse|null> {
|
||||
try {
|
||||
const result = await git.api.pulls.get({...git.repoParams, pull_number: prNumber});
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
// If the pull request could not be found, we want to return `null` so
|
||||
// that the error can be handled gracefully.
|
||||
if (e.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the specified value resolves to a pull request. */
|
||||
export function isPullRequest(v: PullRequestFailure|PullRequest): v is PullRequest {
|
||||
return (v as PullRequest).targetBranches !== undefined;
|
||||
}
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* @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 {PullsListCommitsResponse, PullsMergeParams} from '@octokit/rest';
|
||||
import {prompt} from 'inquirer';
|
||||
|
||||
import {parseCommitMessage} from '../../../commit-message/validate';
|
||||
import {GithubApiMergeMethod} from '../config';
|
||||
import {PullRequestFailure} from '../failures';
|
||||
import {GitClient} from '../git';
|
||||
import {PullRequest} from '../pull-request';
|
||||
import {matchesPattern} from '../string-pattern';
|
||||
|
||||
import {MergeStrategy, TEMP_PR_HEAD_BRANCH} from './strategy';
|
||||
|
||||
/** Configuration for the Github API merge strategy. */
|
||||
export interface GithubApiMergeStrategyConfig {
|
||||
/** Default method used for merging pull requests */
|
||||
default: GithubApiMergeMethod;
|
||||
/** Labels which specify a different merge method than the default. */
|
||||
labels?: {pattern: string, method: GithubApiMergeMethod}[];
|
||||
}
|
||||
|
||||
/** Separator between commit message header and body. */
|
||||
const COMMIT_HEADER_SEPARATOR = '\n\n';
|
||||
|
||||
/**
|
||||
* Merge strategy that primarily leverages the Github API. The strategy merges a given
|
||||
* pull request into a target branch using the API. This ensures that Github displays
|
||||
* the pull request as merged. The merged commits are then cherry-picked into the remaining
|
||||
* target branches using the local Git instance. The benefit is that the Github merged state
|
||||
* is properly set, but a notable downside is that PRs cannot use fixup or squash commits.
|
||||
*/
|
||||
export class GithubApiMergeStrategy extends MergeStrategy {
|
||||
constructor(git: GitClient, private _config: GithubApiMergeStrategyConfig) {
|
||||
super(git);
|
||||
}
|
||||
|
||||
async merge(pullRequest: PullRequest): Promise<PullRequestFailure|null> {
|
||||
const {githubTargetBranch, prNumber, targetBranches, requiredBaseSha, needsCommitMessageFixup} =
|
||||
pullRequest;
|
||||
// If the pull request does not have its base branch set to any determined target
|
||||
// branch, we cannot merge using the API.
|
||||
if (targetBranches.every(t => t !== githubTargetBranch)) {
|
||||
return PullRequestFailure.mismatchingTargetBranch(targetBranches);
|
||||
}
|
||||
|
||||
// In cases where a required base commit is specified for this pull request, check if
|
||||
// the pull request contains the given commit. If not, return a pull request failure.
|
||||
// This check is useful for enforcing that PRs are rebased on top of a given commit.
|
||||
// e.g. a commit that changes the code ownership validation. PRs which are not rebased
|
||||
// could bypass new codeowner ship rules.
|
||||
if (requiredBaseSha && !this.git.hasCommit(TEMP_PR_HEAD_BRANCH, requiredBaseSha)) {
|
||||
return PullRequestFailure.unsatisfiedBaseSha();
|
||||
}
|
||||
|
||||
const method = this._getMergeActionFromPullRequest(pullRequest);
|
||||
const cherryPickTargetBranches = targetBranches.filter(b => b !== githubTargetBranch);
|
||||
|
||||
// First cherry-pick the PR into all local target branches in dry-run mode. This is
|
||||
// purely for testing so that we can figure out whether the PR can be cherry-picked
|
||||
// into the other target branches. We don't want to merge the PR through the API, and
|
||||
// then run into cherry-pick conflicts after the initial merge already completed.
|
||||
const failure = await this._checkMergability(pullRequest, cherryPickTargetBranches);
|
||||
|
||||
// If the PR could not be cherry-picked into all target branches locally, we know it can't
|
||||
// be done through the Github API either. We abort merging and pass-through the failure.
|
||||
if (failure !== null) {
|
||||
return failure;
|
||||
}
|
||||
|
||||
const mergeOptions: PullsMergeParams = {
|
||||
pull_number: prNumber,
|
||||
merge_method: method,
|
||||
...this.git.repoParams,
|
||||
};
|
||||
|
||||
if (needsCommitMessageFixup) {
|
||||
// Commit message fixup does not work with other merge methods as the Github API only
|
||||
// allows commit message modifications for squash merging.
|
||||
if (method !== 'squash') {
|
||||
return PullRequestFailure.unableToFixupCommitMessageSquashOnly();
|
||||
}
|
||||
await this._promptCommitMessageEdit(pullRequest, mergeOptions);
|
||||
}
|
||||
|
||||
let mergeStatusCode: number;
|
||||
let targetSha: string;
|
||||
|
||||
try {
|
||||
// Merge the pull request using the Github API into the selected base branch.
|
||||
const result = await this.git.api.pulls.merge(mergeOptions);
|
||||
|
||||
mergeStatusCode = result.status;
|
||||
targetSha = result.data.sha;
|
||||
} catch (e) {
|
||||
// Note: Github usually returns `404` as status code if the API request uses a
|
||||
// token with insufficient permissions. Github does this because it doesn't want
|
||||
// to leak whether a repository exists or not. In our case we expect a certain
|
||||
// repository to exist, so we always treat this as a permission failure.
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
return PullRequestFailure.insufficientPermissionsToMerge();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// https://developer.github.com/v3/pulls/#response-if-merge-cannot-be-performed
|
||||
// Pull request cannot be merged due to merge conflicts.
|
||||
if (mergeStatusCode === 405) {
|
||||
return PullRequestFailure.mergeConflicts([githubTargetBranch]);
|
||||
}
|
||||
if (mergeStatusCode !== 200) {
|
||||
return PullRequestFailure.unknownMergeError();
|
||||
}
|
||||
|
||||
// If the PR does not need to be merged into any other target branches,
|
||||
// we exit here as we already completed the merge.
|
||||
if (!cherryPickTargetBranches.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh the target branch the PR has been merged into through the API. We need
|
||||
// to re-fetch as otherwise we cannot cherry-pick the new commits into the remaining
|
||||
// target branches.
|
||||
this.fetchTargetBranches([githubTargetBranch]);
|
||||
|
||||
// Number of commits that have landed in the target branch. This could vary from
|
||||
// the count of commits in the PR due to squashing.
|
||||
const targetCommitsCount = method === 'squash' ? 1 : pullRequest.commitCount;
|
||||
|
||||
// Cherry pick the merged commits into the remaining target branches.
|
||||
const failedBranches = await this.cherryPickIntoTargetBranches(
|
||||
`${targetSha}~${targetCommitsCount}..${targetSha}`, cherryPickTargetBranches);
|
||||
|
||||
// We already checked whether the PR can be cherry-picked into the target branches,
|
||||
// but in case the cherry-pick somehow fails, we still handle the conflicts here. The
|
||||
// commits created through the Github API could be different (i.e. through squash).
|
||||
if (failedBranches.length) {
|
||||
return PullRequestFailure.mergeConflicts(failedBranches);
|
||||
}
|
||||
|
||||
this.pushTargetBranchesUpstream(cherryPickTargetBranches);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user for the commit message changes. Unlike as in the autosquash merge
|
||||
* strategy, we cannot start an interactive rebase because we merge using the Github API.
|
||||
* The Github API only allows modifications to PR title and body for squash merges.
|
||||
*/
|
||||
async _promptCommitMessageEdit(pullRequest: PullRequest, mergeOptions: PullsMergeParams) {
|
||||
const commitMessage = await this._getDefaultSquashCommitMessage(pullRequest);
|
||||
const {result} = await prompt<{result: string}>({
|
||||
type: 'editor',
|
||||
name: 'result',
|
||||
message: 'Please update the commit message',
|
||||
default: commitMessage,
|
||||
});
|
||||
|
||||
// Split the new message into title and message. This is necessary because the
|
||||
// Github API expects title and message to be passed separately.
|
||||
const [newTitle, ...newMessage] = result.split(COMMIT_HEADER_SEPARATOR);
|
||||
|
||||
// Update the merge options so that the changes are reflected in there.
|
||||
mergeOptions.commit_title = `${newTitle} (#${pullRequest.prNumber})`;
|
||||
mergeOptions.commit_message = newMessage.join(COMMIT_HEADER_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a commit message for the given pull request. Github by default concatenates
|
||||
* multiple commit messages if a PR is merged in squash mode. We try to replicate this
|
||||
* behavior here so that we have a default commit message that can be fixed up.
|
||||
*/
|
||||
private async _getDefaultSquashCommitMessage(pullRequest: PullRequest): Promise<string> {
|
||||
const commits = (await this._getPullRequestCommitMessages(pullRequest))
|
||||
.map(message => ({message, parsed: parseCommitMessage(message)}));
|
||||
const messageBase = `${pullRequest.title}${COMMIT_HEADER_SEPARATOR}`;
|
||||
if (commits.length <= 1) {
|
||||
return `${messageBase}${commits[0].parsed.body}`;
|
||||
}
|
||||
const joinedMessages = commits.map(c => `* ${c.message}`).join(COMMIT_HEADER_SEPARATOR);
|
||||
return `${messageBase}${joinedMessages}`;
|
||||
}
|
||||
|
||||
/** Gets all commit messages of commits in the pull request. */
|
||||
private async _getPullRequestCommitMessages({prNumber}: PullRequest) {
|
||||
const request = this.git.api.pulls.listCommits.endpoint.merge(
|
||||
{...this.git.repoParams, pull_number: prNumber});
|
||||
const allCommits: PullsListCommitsResponse = await this.git.api.paginate(request);
|
||||
return allCommits.map(({commit}) => commit.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given pull request could be merged into its target branches.
|
||||
* @returns A pull request failure if it the PR could not be merged.
|
||||
*/
|
||||
private async _checkMergability(pullRequest: PullRequest, targetBranches: string[]):
|
||||
Promise<null|PullRequestFailure> {
|
||||
const revisionRange = this.getPullRequestRevisionRange(pullRequest);
|
||||
const failedBranches =
|
||||
this.cherryPickIntoTargetBranches(revisionRange, targetBranches, {dryRun: true});
|
||||
|
||||
if (failedBranches.length) {
|
||||
return PullRequestFailure.mergeConflicts(failedBranches);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Determines the merge action from the given pull request. */
|
||||
private _getMergeActionFromPullRequest({labels}: PullRequest): GithubApiMergeMethod {
|
||||
if (this._config.labels) {
|
||||
const matchingLabel =
|
||||
this._config.labels.find(({pattern}) => labels.some(l => matchesPattern(l, pattern)));
|
||||
if (matchingLabel !== undefined) {
|
||||
return matchingLabel.method;
|
||||
}
|
||||
}
|
||||
return this._config.default;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import {join} from 'path';
|
||||
import {PullRequestFailure} from '../failures';
|
||||
import {PullRequest} from '../pull-request';
|
||||
import {MergeStrategy, TEMP_PR_HEAD_BRANCH} from './strategy';
|
||||
|
||||
/** Path to the commit message filter script. Git expects this paths to use forward slashes. */
|
||||
const MSG_FILTER_SCRIPT = join(__dirname, './commit-message-filter.js').replace(/\\/g, '/');
|
||||
|
||||
/**
|
||||
* Merge strategy that does not use the Github API for merging. Instead, it fetches
|
||||
* all target branches and the PR locally. The PR is then cherry-picked with autosquash
|
||||
* enabled into the target branches. The benefit is the support for fixup and squash commits.
|
||||
* A notable downside though is that Github does not show the PR as `Merged` due to non
|
||||
* fast-forward merges
|
||||
*/
|
||||
export class AutosquashMergeStrategy extends MergeStrategy {
|
||||
/**
|
||||
* Merges the specified pull request into the target branches and pushes the target
|
||||
* branches upstream. This method requires the temporary target branches to be fetched
|
||||
* already as we don't want to fetch the target branches per pull request merge. This
|
||||
* would causes unnecessary multiple fetch requests when multiple PRs are merged.
|
||||
* @throws {GitCommandError} An unknown Git command error occurred that is not
|
||||
* specific to the pull request merge.
|
||||
* @returns A pull request failure or null in case of success.
|
||||
*/
|
||||
async merge(pullRequest: PullRequest): Promise<PullRequestFailure|null> {
|
||||
const {prNumber, targetBranches, requiredBaseSha, needsCommitMessageFixup} = pullRequest;
|
||||
// In case a required base is specified for this pull request, check if the pull
|
||||
// request contains the given commit. If not, return a pull request failure. This
|
||||
// check is useful for enforcing that PRs are rebased on top of a given commit. e.g.
|
||||
// a commit that changes the codeowner ship validation. PRs which are not rebased
|
||||
// could bypass new codeowner ship rules.
|
||||
if (requiredBaseSha && !this.git.hasCommit(TEMP_PR_HEAD_BRANCH, requiredBaseSha)) {
|
||||
return PullRequestFailure.unsatisfiedBaseSha();
|
||||
}
|
||||
|
||||
// Git revision range that matches the pull request commits.
|
||||
const revisionRange = this.getPullRequestRevisionRange(pullRequest);
|
||||
// Git revision for the first commit the pull request is based on.
|
||||
const baseRevision = this.getPullRequestBaseRevision(pullRequest);
|
||||
|
||||
// By default, we rebase the pull request so that fixup or squash commits are
|
||||
// automatically collapsed. Optionally, if a commit message fixup is needed, we
|
||||
// make this an interactive rebase so that commits can be selectively modified
|
||||
// before the merge completes.
|
||||
const branchBeforeRebase = this.git.getCurrentBranch();
|
||||
const rebaseArgs = ['--autosquash', baseRevision, TEMP_PR_HEAD_BRANCH];
|
||||
if (needsCommitMessageFixup) {
|
||||
this.git.run(['rebase', '--interactive', ...rebaseArgs], {stdio: 'inherit'});
|
||||
} else {
|
||||
this.git.run(['rebase', ...rebaseArgs]);
|
||||
}
|
||||
|
||||
// Update pull requests commits to reference the pull request. This matches what
|
||||
// Github does when pull requests are merged through the Web UI. The motivation is
|
||||
// that it should be easy to determine which pull request contained a given commit.
|
||||
// **Note**: The filter-branch command relies on the working tree, so we want to make
|
||||
// sure that we are on the initial branch where the merge script has been run.
|
||||
this.git.run(['checkout', '-f', branchBeforeRebase]);
|
||||
this.git.run(
|
||||
['filter-branch', '-f', '--msg-filter', `${MSG_FILTER_SCRIPT} ${prNumber}`, revisionRange]);
|
||||
|
||||
// Cherry-pick the pull request into all determined target branches.
|
||||
const failedBranches = this.cherryPickIntoTargetBranches(revisionRange, targetBranches);
|
||||
|
||||
if (failedBranches.length) {
|
||||
return PullRequestFailure.mergeConflicts(failedBranches);
|
||||
}
|
||||
|
||||
this.pushTargetBranchesUpstream(targetBranches);
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script that can be passed as commit message filter to `git filter-branch --msg-filter`.
|
||||
* The script rewrites commit messages to contain a Github instruction to close the
|
||||
* corresponding pull request. For more details. See: https://git.io/Jv64r.
|
||||
*/
|
||||
|
||||
if (require.main === module) {
|
||||
const [prNumber] = process.argv.slice(2);
|
||||
if (!prNumber) {
|
||||
console.error('No pull request number specified.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let commitMessage = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('readable', () => {
|
||||
const chunk = process.stdin.read();
|
||||
if (chunk !== null) {
|
||||
commitMessage += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
console.info(rewriteCommitMessage(commitMessage, prNumber));
|
||||
});
|
||||
}
|
||||
|
||||
function rewriteCommitMessage(message, prNumber) {
|
||||
const lines = message.split(/\n/);
|
||||
// Add the pull request number to the commit message title. This matches what
|
||||
// Github does when PRs are merged on the web through the `Squash and Merge` button.
|
||||
lines[0] += ` (#${prNumber})`;
|
||||
// Push a new line that instructs Github to close the specified pull request.
|
||||
lines.push(`PR Close #${prNumber}`);
|
||||
return lines.join('\n');
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* @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 {PullRequestFailure} from '../failures';
|
||||
import {GitClient} from '../git';
|
||||
import {PullRequest} from '../pull-request';
|
||||
|
||||
/**
|
||||
* Name of a temporary branch that contains the head of a currently-processed PR. Note
|
||||
* that a branch name should be used that most likely does not conflict with other local
|
||||
* development branches.
|
||||
*/
|
||||
export const TEMP_PR_HEAD_BRANCH = 'merge_pr_head';
|
||||
|
||||
/**
|
||||
* Base class for merge strategies. A merge strategy accepts a pull request and
|
||||
* merges it into the determined target branches.
|
||||
*/
|
||||
export abstract class MergeStrategy {
|
||||
constructor(protected git: GitClient) {}
|
||||
|
||||
/**
|
||||
* Prepares a merge of the given pull request. The strategy by default will
|
||||
* fetch all target branches and the pull request into local temporary branches.
|
||||
*/
|
||||
async prepare(pullRequest: PullRequest) {
|
||||
this.fetchTargetBranches(
|
||||
pullRequest.targetBranches, `pull/${pullRequest.prNumber}/head:${TEMP_PR_HEAD_BRANCH}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the merge of the given pull request. This needs to be implemented
|
||||
* by individual merge strategies.
|
||||
*/
|
||||
abstract merge(pullRequest: PullRequest): Promise<null|PullRequestFailure>;
|
||||
|
||||
/** Cleans up the pull request merge. e.g. deleting temporary local branches. */
|
||||
async cleanup(pullRequest: PullRequest) {
|
||||
// Delete all temporary target branches.
|
||||
pullRequest.targetBranches.forEach(
|
||||
branchName => this.git.run(['branch', '-D', this.getLocalTargetBranchName(branchName)]));
|
||||
|
||||
// Delete temporary branch for the pull request head.
|
||||
this.git.run(['branch', '-D', TEMP_PR_HEAD_BRANCH]);
|
||||
}
|
||||
|
||||
/** Gets the revision range for all commits in the given pull request. */
|
||||
protected getPullRequestRevisionRange(pullRequest: PullRequest): string {
|
||||
return `${this.getPullRequestBaseRevision(pullRequest)}..${TEMP_PR_HEAD_BRANCH}`;
|
||||
}
|
||||
|
||||
/** Gets the base revision of a pull request. i.e. the commit the PR is based on. */
|
||||
protected getPullRequestBaseRevision(pullRequest: PullRequest): string {
|
||||
return `${TEMP_PR_HEAD_BRANCH}~${pullRequest.commitCount}`;
|
||||
}
|
||||
|
||||
/** Gets a deterministic local branch name for a given branch. */
|
||||
protected getLocalTargetBranchName(targetBranch: string): string {
|
||||
return `merge_pr_target_${targetBranch.replace(/\//g, '_')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherry-picks the given revision range into the specified target branches.
|
||||
* @returns A list of branches for which the revisions could not be cherry-picked into.
|
||||
*/
|
||||
protected cherryPickIntoTargetBranches(revisionRange: string, targetBranches: string[], options: {
|
||||
dryRun?: boolean
|
||||
} = {}) {
|
||||
const cherryPickArgs = [revisionRange];
|
||||
const failedBranches: string[] = [];
|
||||
|
||||
if (options.dryRun) {
|
||||
// https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt---no-commit
|
||||
// This causes `git cherry-pick` to not generate any commits. Instead, the changes are
|
||||
// applied directly in the working tree. This allow us to easily discard the changes
|
||||
// for dry-run purposes.
|
||||
cherryPickArgs.push('--no-commit');
|
||||
}
|
||||
|
||||
// Cherry-pick the refspec into all determined target branches.
|
||||
for (const branchName of targetBranches) {
|
||||
const localTargetBranch = this.getLocalTargetBranchName(branchName);
|
||||
// Checkout the local target branch.
|
||||
this.git.run(['checkout', localTargetBranch]);
|
||||
// Cherry-pick the refspec into the target branch.
|
||||
if (this.git.runGraceful(['cherry-pick', ...cherryPickArgs]).status !== 0) {
|
||||
// Abort the failed cherry-pick. We do this because Git persists the failed
|
||||
// cherry-pick state globally in the repository. This could prevent future
|
||||
// pull request merges as a Git thinks a cherry-pick is still in progress.
|
||||
this.git.runGraceful(['cherry-pick', '--abort']);
|
||||
failedBranches.push(branchName);
|
||||
}
|
||||
// If we run with dry run mode, we reset the local target branch so that all dry-run
|
||||
// cherry-pick changes are discard. Changes are applied to the working tree and index.
|
||||
if (options.dryRun) {
|
||||
this.git.run(['reset', '--hard', 'HEAD']);
|
||||
}
|
||||
}
|
||||
return failedBranches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the given target branches. Also accepts a list of additional refspecs that
|
||||
* should be fetched. This is helpful as multiple slow fetches could be avoided.
|
||||
*/
|
||||
protected fetchTargetBranches(names: string[], ...extraRefspecs: string[]) {
|
||||
const fetchRefspecs = names.map(targetBranch => {
|
||||
const localTargetBranch = this.getLocalTargetBranchName(targetBranch);
|
||||
return `refs/heads/${targetBranch}:${localTargetBranch}`;
|
||||
});
|
||||
// Fetch all target branches with a single command. We don't want to fetch them
|
||||
// individually as that could cause an unnecessary slow-down.
|
||||
this.git.run(['fetch', '-f', this.git.repoGitUrl, ...fetchRefspecs, ...extraRefspecs]);
|
||||
}
|
||||
|
||||
/** Pushes the given target branches upstream. */
|
||||
protected pushTargetBranchesUpstream(names: string[]) {
|
||||
const pushRefspecs = names.map(targetBranch => {
|
||||
const localTargetBranch = this.getLocalTargetBranchName(targetBranch);
|
||||
return `${localTargetBranch}:refs/heads/${targetBranch}`;
|
||||
});
|
||||
// Push all target branches with a single command if we don't run in dry-run mode.
|
||||
// We don't want to push them individually as that could cause an unnecessary slow-down.
|
||||
this.git.run(['push', this.git.repoGitUrl, ...pushRefspecs]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/** Checks whether the specified value matches the given pattern. */
|
||||
export function matchesPattern(value: string, pattern: RegExp|string): boolean {
|
||||
return typeof pattern === 'string' ? value === pattern : pattern.test(value);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* @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 {MergeConfig, TargetLabel} from './config';
|
||||
import {matchesPattern} from './string-pattern';
|
||||
|
||||
/** Gets the target label from the specified pull request labels. */
|
||||
export function getTargetLabelFromPullRequest(config: MergeConfig, labels: string[]): TargetLabel|
|
||||
null {
|
||||
for (const label of labels) {
|
||||
const match = config.labels.find(({pattern}) => matchesPattern(label, pattern));
|
||||
if (match !== undefined) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Gets the branches from the specified target label. */
|
||||
export function getBranchesFromTargetLabel(
|
||||
label: TargetLabel, githubTargetBranch: string): string[] {
|
||||
return typeof label.branches === 'function' ? label.branches(githubTargetBranch) : label.branches;
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* @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 {MergeConfig} from './config';
|
||||
import {PullRequestFailure} from './failures';
|
||||
import {GitClient, GitCommandError} from './git';
|
||||
import {isPullRequest, loadAndValidatePullRequest,} from './pull-request';
|
||||
import {GithubApiMergeStrategy} from './strategies/api-merge';
|
||||
import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
|
||||
|
||||
/** Describes the status of a pull request merge. */
|
||||
export const enum MergeStatus {
|
||||
UNKNOWN_GIT_ERROR,
|
||||
DIRTY_WORKING_DIR,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
/** Result of a pull request merge. */
|
||||
export interface MergeResult {
|
||||
/** Overall status of the merge. */
|
||||
status: MergeStatus;
|
||||
/** List of pull request failures. */
|
||||
failure?: PullRequestFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class that accepts a merge script configuration and Github token. It provides
|
||||
* a programmatic interface for merging multiple pull requests based on their
|
||||
* labels that have been resolved through the merge script configuration.
|
||||
*/
|
||||
export class PullRequestMergeTask {
|
||||
/** Git client that can be used to execute Git commands. */
|
||||
git = new GitClient(this.projectRoot, this._githubToken, this.config);
|
||||
|
||||
constructor(
|
||||
public projectRoot: string, public config: MergeConfig, private _githubToken: string) {}
|
||||
|
||||
/**
|
||||
* Merges the given pull request and pushes it upstream.
|
||||
* @param prNumber Pull request that should be merged.
|
||||
* @param force Whether non-critical pull request failures should be ignored.
|
||||
*/
|
||||
async merge(prNumber: number, force = false): Promise<MergeResult> {
|
||||
if (this.git.hasUncommittedChanges()) {
|
||||
return {status: MergeStatus.DIRTY_WORKING_DIR};
|
||||
}
|
||||
|
||||
const pullRequest = await loadAndValidatePullRequest(this, prNumber, force);
|
||||
|
||||
if (!isPullRequest(pullRequest)) {
|
||||
return {status: MergeStatus.FAILED, failure: pullRequest};
|
||||
}
|
||||
|
||||
const strategy = this.config.githubApiMerge ?
|
||||
new GithubApiMergeStrategy(this.git, this.config.githubApiMerge) :
|
||||
new AutosquashMergeStrategy(this.git);
|
||||
|
||||
// Branch that is currently checked out so that we can switch back to it once
|
||||
// the pull request has been merged.
|
||||
let previousBranch: null|string = null;
|
||||
|
||||
// The following block runs Git commands as child processes. These Git commands can fail.
|
||||
// We want to capture these command errors and return an appropriate merge request status.
|
||||
try {
|
||||
previousBranch = this.git.getCurrentBranch();
|
||||
|
||||
// Run preparations for the merge (e.g. fetching branches).
|
||||
await strategy.prepare(pullRequest);
|
||||
|
||||
// Perform the merge and capture potential failures.
|
||||
const failure = await strategy.merge(pullRequest);
|
||||
if (failure !== null) {
|
||||
return {status: MergeStatus.FAILED, failure};
|
||||
}
|
||||
|
||||
// Switch back to the previous branch. We need to do this before deleting the temporary
|
||||
// branches because we cannot delete branches which are currently checked out.
|
||||
this.git.run(['checkout', '-f', previousBranch]);
|
||||
|
||||
await strategy.cleanup(pullRequest);
|
||||
|
||||
// Return a successful merge status.
|
||||
return {status: MergeStatus.SUCCESS};
|
||||
} catch (e) {
|
||||
// Catch all git command errors and return a merge result w/ git error status code.
|
||||
// Other unknown errors which aren't caused by a git command are re-thrown.
|
||||
if (e instanceof GitCommandError) {
|
||||
return {status: MergeStatus.UNKNOWN_GIT_ERROR};
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
// Always try to restore the branch if possible. We don't want to leave
|
||||
// the repository in a different state than before.
|
||||
if (previousBranch !== null) {
|
||||
this.git.runGraceful(['checkout', '-f', previousBranch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -58,6 +58,7 @@
|
|||
"@bazel/terser": "1.6.0",
|
||||
"@bazel/typescript": "1.6.0",
|
||||
"@microsoft/api-extractor": "7.7.11",
|
||||
"@octokit/rest": "16.28.7",
|
||||
"@schematics/angular": "9.1.0",
|
||||
"@types/angular": "^1.6.47",
|
||||
"@types/babel__core": "^7.1.6",
|
||||
|
|
99
yarn.lock
99
yarn.lock
|
@ -1185,6 +1185,15 @@
|
|||
is-plain-object "^3.0.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/endpoint@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.1.tgz#16d5c0e7a83e3a644d1ddbe8cded6c3d038d31d7"
|
||||
integrity sha512-pOPHaSz57SFT/m3R5P8MUu4wLPszokn5pXcB/pzavLTQf2jbU+6iayTvzaY6/BiotuRS0qyEUkx3QglT4U958A==
|
||||
dependencies:
|
||||
"@octokit/types" "^2.11.1"
|
||||
is-plain-object "^3.0.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/graphql@^4.3.1":
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.3.1.tgz#9ee840e04ed2906c7d6763807632de84cdecf418"
|
||||
|
@ -1194,6 +1203,15 @@
|
|||
"@octokit/types" "^2.0.0"
|
||||
universal-user-agent "^4.0.0"
|
||||
|
||||
"@octokit/request-error@^1.0.2":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
|
||||
integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==
|
||||
dependencies:
|
||||
"@octokit/types" "^2.0.0"
|
||||
deprecation "^2.0.0"
|
||||
once "^1.4.0"
|
||||
|
||||
"@octokit/request-error@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.0.0.tgz#94ca7293373654400fbb2995f377f9473e00834b"
|
||||
|
@ -1203,6 +1221,20 @@
|
|||
deprecation "^2.0.0"
|
||||
once "^1.4.0"
|
||||
|
||||
"@octokit/request@^5.0.0":
|
||||
version "5.4.2"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.2.tgz#74f8e5bbd39dc738a1b127629791f8ad1b3193ee"
|
||||
integrity sha512-zKdnGuQ2TQ2vFk9VU8awFT4+EYf92Z/v3OlzRaSh4RIP0H6cvW1BFPXq4XYvNez+TPQjqN+0uSkCYnMFFhcFrw==
|
||||
dependencies:
|
||||
"@octokit/endpoint" "^6.0.1"
|
||||
"@octokit/request-error" "^2.0.0"
|
||||
"@octokit/types" "^2.11.1"
|
||||
deprecation "^2.0.0"
|
||||
is-plain-object "^3.0.0"
|
||||
node-fetch "^2.3.0"
|
||||
once "^1.4.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/request@^5.3.0":
|
||||
version "5.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.3.4.tgz#fbc950bf785d59da3b0399fc6d042c8cf52e2905"
|
||||
|
@ -1217,6 +1249,25 @@
|
|||
once "^1.4.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/rest@16.28.7":
|
||||
version "16.28.7"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.28.7.tgz#a2c2db5b318da84144beba82d19c1a9dbdb1a1fa"
|
||||
integrity sha512-cznFSLEhh22XD3XeqJw51OLSfyL2fcFKUO+v2Ep9MTAFfFLS1cK1Zwd1yEgQJmJoDnj4/vv3+fGGZweG+xsbIA==
|
||||
dependencies:
|
||||
"@octokit/request" "^5.0.0"
|
||||
"@octokit/request-error" "^1.0.2"
|
||||
atob-lite "^2.0.0"
|
||||
before-after-hook "^2.0.0"
|
||||
btoa-lite "^1.0.0"
|
||||
deprecation "^2.0.0"
|
||||
lodash.get "^4.4.2"
|
||||
lodash.set "^4.3.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
octokit-pagination-methods "^1.1.0"
|
||||
once "^1.4.0"
|
||||
universal-user-agent "^3.0.0"
|
||||
url-template "^2.0.8"
|
||||
|
||||
"@octokit/types@^2.0.0":
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.5.1.tgz#22563b3bb50034bea3176eac1860340c5e812e2a"
|
||||
|
@ -1224,6 +1275,13 @@
|
|||
dependencies:
|
||||
"@types/node" ">= 8"
|
||||
|
||||
"@octokit/types@^2.11.1":
|
||||
version "2.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.15.0.tgz#b2070520207727bc6ab3a9caa1e4f60b0434bfa8"
|
||||
integrity sha512-0mnpenB8rLhBVu8VUklp38gWi+EatjvcEcLWcdProMKauSaQWWepOAybZ714sOGsEyhXPlIcHICggn8HUsCXVw==
|
||||
dependencies:
|
||||
"@types/node" ">= 8"
|
||||
|
||||
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
|
||||
|
@ -2394,6 +2452,11 @@ asynckit@^0.4.0:
|
|||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
|
||||
|
||||
atob-lite@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
|
||||
integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
|
||||
|
||||
atob@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
|
||||
|
@ -2550,6 +2613,11 @@ beeper@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809"
|
||||
integrity sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak=
|
||||
|
||||
before-after-hook@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635"
|
||||
integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==
|
||||
|
||||
better-assert@~1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
|
||||
|
@ -2848,6 +2916,11 @@ browserstacktunnel-wrapper@2.0.1:
|
|||
https-proxy-agent "^1.0.0"
|
||||
unzip "~0.1.9"
|
||||
|
||||
btoa-lite@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
|
||||
integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
|
||||
|
||||
buffer-alloc-unsafe@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
|
||||
|
@ -8735,7 +8808,7 @@ lodash.flatten@^4.4.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
|
||||
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
|
||||
|
||||
lodash.get@^4.0.0:
|
||||
lodash.get@^4.0.0, lodash.get@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
|
||||
|
@ -8845,6 +8918,11 @@ lodash.restparam@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
|
||||
integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=
|
||||
|
||||
lodash.set@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
|
||||
integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
|
||||
|
||||
lodash.snakecase@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d"
|
||||
|
@ -10152,6 +10230,11 @@ obuf@^1.0.0, obuf@^1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
|
||||
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
|
||||
|
||||
octokit-pagination-methods@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
|
||||
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
|
||||
|
||||
on-finished@^2.2.0, on-finished@~2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||
|
@ -10315,7 +10398,7 @@ os-locale@^3.0.0, os-locale@^3.1.0:
|
|||
lcid "^2.0.0"
|
||||
mem "^4.0.0"
|
||||
|
||||
os-name@^3.1.0:
|
||||
os-name@^3.0.0, os-name@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
|
||||
integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
|
||||
|
@ -14206,6 +14289,13 @@ universal-analytics@0.4.20, universal-analytics@^0.4.16:
|
|||
request "^2.88.0"
|
||||
uuid "^3.0.0"
|
||||
|
||||
universal-user-agent@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-3.0.0.tgz#4cc88d68097bffd7ac42e3b7c903e7481424b4b9"
|
||||
integrity sha512-T3siHThqoj5X0benA5H0qcDnrKGXzU8TKoX15x/tQHw1hQBvIEBHjxQ2klizYsqBOO/Q+WuxoQUihadeeqDnoA==
|
||||
dependencies:
|
||||
os-name "^3.0.0"
|
||||
|
||||
universal-user-agent@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
|
||||
|
@ -14334,6 +14424,11 @@ url-parse@^1.4.3:
|
|||
querystringify "^2.1.1"
|
||||
requires-port "^1.0.0"
|
||||
|
||||
url-template@^2.0.8:
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
|
||||
integrity sha1-/FZaPMy/93MMd19WQflVV5FDnyE=
|
||||
|
||||
url@^0.11.0:
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
|
||||
|
|
Loading…
Reference in New Issue