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
226 lines
9.8 KiB
TypeScript
226 lines
9.8 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 {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;
|
|
}
|
|
}
|