Paul Gschwendtner 67f65a9d25 refactor(dev-infra): improve type-safety of git client utility (#42468)
Currently the `GitClient` accepts a generic parameter for determining
whether the `githubToken` should be set or not. This worked fine so far
in terms of distinguishing between an authenticated and
non-authenticated git client instance, but if we intend to conditionally
show methods only for authenticated instances, the generic parameter
is not suitable.

This commit splits up the `GitClient` into two classes. One for
the base logic without any authorization, and a second class that
extends the base logic with authentication logic. i.e. the
`AuthenticatedGitClient`. This allows us to have specific methods only
for the authenticated instance. e.g.

  * `hasOauthScopes` has been moved to only exist for authenticated
    instances.
  * the GraphQL functionality within `gitClient.github` is not
    accessible for non-authenticated instances. GraphQL API requires
    authentication as per Github.

The initial motiviation for this was that we want to throw if
`hasOAuthScopes` is called without the Octokit instance having
a token configured. This should help avoiding issues as within
3b434ed94d
that prevented the caretaker process momentarily.

Additionally, the Git client has moved from `index.ts` to
`git-client.ts` for better discoverability in the codebase.

PR Close #42468
2021-06-03 14:34:33 -07:00

142 lines
6.1 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 {AuthenticatedGitClient} from '../../../utils/git/authenticated-git-client';
import {PullRequestFailure} from '../failures';
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: AuthenticatedGitClient) {}
/**
* 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,
linkToOriginalCommits?: 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');
}
if (options.linkToOriginalCommits) {
// We add `-x` when cherry-picking as that will allow us to easily jump to original
// commits for cherry-picked commits. With that flag set, Git will automatically append
// the original SHA/revision to the commit message. e.g. `(cherry picked from commit <..>)`.
// https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt--x.
cherryPickArgs.push('-x');
}
// 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', '-q', '-f', this.git.getRepoGitUrl(), ...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.getRepoGitUrl(), ...pushRefspecs]);
}
}