Paul Gschwendtner cb2ab26296 feat(dev-infra): merge script should link to original commit when cherry-picking with API strategy (#37889)
The merge script uses `git cherry-pick` for both the API merge strategy
and the autosquash strategy. It uses cherry-pick to push commits to
different target branches (e.g. into the `10.0.x` branch).

Those commits never point to the commits that landed in the primary
Github branch though. For the autosquash strategy the pull request number
is always included, so there is a way to go back to the source. On the other
hand though, for commits cherry-picked in the API merge strategy, the
pull request number might not always be included (due to Github's
implementation of the rebase merge method).

e.g.
27f52711c0

For those cases we'd want to link the cherry-picked commits to the
original commits so that the corresponding PR is easier to track
down. This is not needed for the autosquash strategy (as outlined
before), but it would have been good for consistency. Unfortunately
though this would rather complicate the strategy as the autosquash
strategy cherry-picks directly from the PR head, so the SHAs that
are used in the primary branch are not known.

PR Close #37889
2020-07-07 12:16:21 -07:00

232 lines
10 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 {GitClient} from '../../../utils/git';
import {GithubApiMergeMethod} from '../config';
import {PullRequestFailure} from '../failures';
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.remoteParams,
};
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.github.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, {
// Commits that have been created by the Github API do not necessarily contain
// a reference to the source pull request (unless the squash strategy is used).
// To ensure that original commits can be found when a commit is viewed in a
// target branch, we add a link to the original commits when cherry-picking.
linkToOriginalCommits: true,
});
// 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.
*/
private 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.github.pulls.listCommits.endpoint.merge(
{...this.git.remoteParams, pull_number: prNumber});
const allCommits: PullsListCommitsResponse = await this.git.github.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;
}
}