Some usages of the `GitClient` are better served by suppressing the logging of lines that express what commands are being run. Many usages of `GitClient` are contained within tools which are best served by keeping the output clean as mostly read actions are occurring. PR Close #39474
		
			
				
	
	
		
			220 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			220 lines
		
	
	
		
			9.4 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 * as Octokit from '@octokit/rest';
 | |
| import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
 | |
| 
 | |
| import {getConfig, getRepoBaseDir, NgDevConfig} from '../config';
 | |
| import {debug, info, yellow} from '../console';
 | |
| import {GithubClient} from './github';
 | |
| import {getRepositoryGitUrl, GITHUB_TOKEN_GENERATE_URL, GITHUB_TOKEN_SETTINGS_URL} from './github-urls';
 | |
| 
 | |
| /** Github response type extended to include the `x-oauth-scopes` headers presence. */
 | |
| type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
 | |
|   headers: {'x-oauth-scopes': string};
 | |
| };
 | |
| 
 | |
| /** Describes a function that can be used to test for given Github OAuth scopes. */
 | |
| export type OAuthScopeTestFunction = (scopes: string[], missing: string[]) => void;
 | |
| 
 | |
| /** 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(' '))}`);
 | |
|   }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Common client for performing Git interactions with a given remote.
 | |
|  *
 | |
|  * Takes in two optional arguments:
 | |
|  *   `githubToken`: the token used for authentication in Github interactions, by default empty
 | |
|  *     allowing readonly actions.
 | |
|  *   `config`: The dev-infra configuration containing information about the remote. By default
 | |
|  *     the dev-infra configuration is loaded with its Github configuration.
 | |
|  **/
 | |
| export class GitClient {
 | |
|   /** Whether verbose logging of Git actions should be used. */
 | |
|   static LOG_COMMANDS = true;
 | |
|   /** Short-hand for accessing the default remote configuration. */
 | |
|   remoteConfig = this._config.github;
 | |
|   /** Octokit request parameters object for targeting the configured remote. */
 | |
|   remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
 | |
|   /** Git URL that resolves to the configured repository. */
 | |
|   repoGitUrl = getRepositoryGitUrl(this.remoteConfig, this.githubToken);
 | |
|   /** Instance of the authenticated Github octokit API. */
 | |
|   github = new GithubClient(this.githubToken);
 | |
| 
 | |
|   /** The OAuth scopes available for the provided Github token. */
 | |
|   private _cachedOauthScopes: Promise<string[]>|null = null;
 | |
|   /**
 | |
|    * Regular expression that matches the provided Github token. Used for
 | |
|    * sanitizing the token from Git child process output.
 | |
|    */
 | |
|   private _githubTokenRegex: RegExp|null = null;
 | |
| 
 | |
|   constructor(
 | |
|       public githubToken?: string, private _config: Pick<NgDevConfig, 'github'> = getConfig(),
 | |
|       private _projectRoot = getRepoBaseDir()) {
 | |
|     // If a token has been specified (and is not empty), pass it to the Octokit API and
 | |
|     // also create a regular expression that can be used for sanitizing Git command output
 | |
|     // so that it does not print the token accidentally.
 | |
|     if (githubToken != null) {
 | |
|       this._githubTokenRegex = new RegExp(githubToken, 'g');
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /** 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
 | |
|    * info failed commands.
 | |
|    */
 | |
|   runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
 | |
|     // To improve the debugging experience in case something fails, we print all executed Git
 | |
|     // commands to better understand the git actions occuring. Depending on the command being
 | |
|     // executed, this debugging information should be logged at different logging levels.
 | |
|     const printFn = (!GitClient.LOG_COMMANDS || options.stdio === 'ignore') ? debug : info;
 | |
|     // Note that we do not want to print the token if it is contained in the command. It's common
 | |
|     // to share errors with others if the tool failed, and we do not want to leak tokens.
 | |
|     printFn('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 or revision. */
 | |
|   getCurrentBranchOrRevision(): string {
 | |
|     const branchName = this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
 | |
|     // If no branch name could be resolved. i.e. `HEAD` has been returned, then Git
 | |
|     // is currently in a detached state. In those cases, we just want to return the
 | |
|     // currently checked out revision/SHA.
 | |
|     if (branchName === 'HEAD') {
 | |
|       return this.run(['rev-parse', 'HEAD']).stdout.trim();
 | |
|     }
 | |
|     return branchName;
 | |
|   }
 | |
| 
 | |
|   /** Gets whether the current Git repository has uncommitted changes. */
 | |
|   hasUncommittedChanges(): boolean {
 | |
|     return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
 | |
|   }
 | |
| 
 | |
|   /** Whether the repo has any local changes. */
 | |
|   hasLocalChanges(): 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 {
 | |
|     // If no token has been defined (i.e. no token regex), we just return the
 | |
|     // value as is. There is no secret value that needs to be omitted.
 | |
|     if (this._githubTokenRegex === null) {
 | |
|       return value;
 | |
|     }
 | |
|     return value.replace(this._githubTokenRegex, '<TOKEN>');
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Checks out a requested branch or revision, optionally cleaning the state of the repository
 | |
|    * before attempting the checking. Returns a boolean indicating whether the branch or revision
 | |
|    * was cleanly checked out.
 | |
|    */
 | |
|   checkout(branchOrRevision: string, cleanState: boolean): boolean {
 | |
|     if (cleanState) {
 | |
|       // Abort any outstanding ams.
 | |
|       this.runGraceful(['am', '--abort'], {stdio: 'ignore'});
 | |
|       // Abort any outstanding cherry-picks.
 | |
|       this.runGraceful(['cherry-pick', '--abort'], {stdio: 'ignore'});
 | |
|       // Abort any outstanding rebases.
 | |
|       this.runGraceful(['rebase', '--abort'], {stdio: 'ignore'});
 | |
|       // Clear any changes in the current repo.
 | |
|       this.runGraceful(['reset', '--hard'], {stdio: 'ignore'});
 | |
|     }
 | |
|     return this.runGraceful(['checkout', branchOrRevision], {stdio: 'ignore'}).status === 0;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Assert the GitClient instance is using a token with permissions for the all of the
 | |
|    * provided OAuth scopes.
 | |
|    */
 | |
|   async hasOauthScopes(testFn: OAuthScopeTestFunction): Promise<true|{error: string}> {
 | |
|     const scopes = await this.getAuthScopesForToken();
 | |
|     const missingScopes: string[] = [];
 | |
|     // Test Github OAuth scopes and collect missing ones.
 | |
|     testFn(scopes, missingScopes);
 | |
|     // If no missing scopes are found, return true to indicate all OAuth Scopes are available.
 | |
|     if (missingScopes.length === 0) {
 | |
|       return true;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Preconstructed error message to log to the user, providing missing scopes and
 | |
|      * remediation instructions.
 | |
|      **/
 | |
|     const error =
 | |
|         `The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
 | |
|         `${yellow(missingScopes.join(', '))}\n\n` +
 | |
|         `Update the token in use at:\n` +
 | |
|         `  ${GITHUB_TOKEN_SETTINGS_URL}\n\n` +
 | |
|         `Alternatively, a new token can be created at: ${GITHUB_TOKEN_GENERATE_URL}\n`;
 | |
| 
 | |
|     return {error};
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Retrieve the OAuth scopes for the loaded Github token.
 | |
|    **/
 | |
|   private getAuthScopesForToken() {
 | |
|     // If the OAuth scopes have already been loaded, return the Promise containing them.
 | |
|     if (this._cachedOauthScopes !== null) {
 | |
|       return this._cachedOauthScopes;
 | |
|     }
 | |
|     // OAuth scopes are loaded via the /rate_limit endpoint to prevent
 | |
|     // usage of a request against that rate_limit for this lookup.
 | |
|     return this._cachedOauthScopes = this.github.rateLimit.get().then(_response => {
 | |
|       const response = _response as RateLimitResponseWithOAuthScopeHeader;
 | |
|       const scopes: string = response.headers['x-oauth-scopes'] || '';
 | |
|       return scopes.split(',').map(scope => scope.trim());
 | |
|     });
 | |
|   }
 | |
| }
 |