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
		
	
			
		
			
				
	
	
		
			166 lines
		
	
	
		
			6.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			166 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 {promptConfirm} from '../../utils/console';
 | |
| import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
 | |
| import {GitCommandError} from '../../utils/git/git-client';
 | |
| 
 | |
| import {MergeConfigWithRemote} from './config';
 | |
| import {PullRequestFailure} from './failures';
 | |
| import {getCaretakerNotePromptMessage, getTargettedBranchesConfirmationPromptMessage} from './messages';
 | |
| 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,
 | |
|   USER_ABORTED,
 | |
|   GITHUB_ERROR,
 | |
| }
 | |
| 
 | |
| /** Result of a pull request merge. */
 | |
| export interface MergeResult {
 | |
|   /** Overall status of the merge. */
 | |
|   status: MergeStatus;
 | |
|   /** List of pull request failures. */
 | |
|   failure?: PullRequestFailure;
 | |
| }
 | |
| 
 | |
| export interface PullRequestMergeTaskFlags {
 | |
|   branchPrompt: boolean;
 | |
| }
 | |
| 
 | |
| const defaultPullRequestMergeTaskFlags: PullRequestMergeTaskFlags = {
 | |
|   branchPrompt: true,
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * 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 {
 | |
|   private flags: PullRequestMergeTaskFlags;
 | |
| 
 | |
|   constructor(
 | |
|       public config: MergeConfigWithRemote, public git: AuthenticatedGitClient,
 | |
|       flags: Partial<PullRequestMergeTaskFlags>) {
 | |
|     // Update flags property with the provided flags values as patches to the default flag values.
 | |
|     this.flags = {...defaultPullRequestMergeTaskFlags, ...flags};
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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> {
 | |
|     // Check whether the given Github token has sufficient permissions for writing
 | |
|     // to the configured repository. If the repository is not private, only the
 | |
|     // reduced `public_repo` OAuth scope is sufficient for performing merges.
 | |
|     const hasOauthScopes = await this.git.hasOauthScopes((scopes, missing) => {
 | |
|       if (!scopes.includes('repo')) {
 | |
|         if (this.config.remote.private) {
 | |
|           missing.push('repo');
 | |
|         } else if (!scopes.includes('public_repo')) {
 | |
|           missing.push('public_repo');
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // Pull requests can modify Github action workflow files. In such cases Github requires us to
 | |
|       // push with a token that has the `workflow` oauth scope set. To avoid errors when the
 | |
|       // caretaker intends to merge such PRs, we ensure the scope is always set on the token before
 | |
|       // the merge process starts.
 | |
|       // https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes
 | |
|       if (!scopes.includes('workflow')) {
 | |
|         missing.push('workflow');
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     if (hasOauthScopes !== true) {
 | |
|       return {
 | |
|         status: MergeStatus.GITHUB_ERROR,
 | |
|         failure: PullRequestFailure.insufficientPermissionsToMerge(hasOauthScopes.error)
 | |
|       };
 | |
|     }
 | |
| 
 | |
|     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};
 | |
|     }
 | |
| 
 | |
| 
 | |
|     if (this.flags.branchPrompt &&
 | |
|         !await promptConfirm(getTargettedBranchesConfirmationPromptMessage(pullRequest))) {
 | |
|       return {status: MergeStatus.USER_ABORTED};
 | |
|     }
 | |
| 
 | |
| 
 | |
|     // If the pull request has a caretaker note applied, raise awareness by prompting
 | |
|     // the caretaker. The caretaker can then decide to proceed or abort the merge.
 | |
|     if (pullRequest.hasCaretakerNote &&
 | |
|         !await promptConfirm(getCaretakerNotePromptMessage(pullRequest))) {
 | |
|       return {status: MergeStatus.USER_ABORTED};
 | |
|     }
 | |
| 
 | |
|     const strategy = this.config.githubApiMerge ?
 | |
|         new GithubApiMergeStrategy(this.git, this.config.githubApiMerge) :
 | |
|         new AutosquashMergeStrategy(this.git);
 | |
| 
 | |
|     // Branch or revision that is currently checked out so that we can switch back to
 | |
|     // it once the pull request has been merged.
 | |
|     let previousBranchOrRevision: 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 {
 | |
|       previousBranchOrRevision = this.git.getCurrentBranchOrRevision();
 | |
| 
 | |
|       // 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', previousBranchOrRevision]);
 | |
| 
 | |
|       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 (previousBranchOrRevision !== null) {
 | |
|         this.git.runGraceful(['checkout', '-f', previousBranchOrRevision]);
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| }
 |