/** * @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 {Options as SemVerOptions, parse, SemVer} from 'semver'; import {getConfig, GithubConfig, NgDevConfig} from '../config'; import {debug, info, yellow} from '../console'; import {DryRunError, isDryRun} from '../dry-run'; 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&{ 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 { /************************************************* * Singleton definition and configuration. * *************************************************/ /** The singleton instance of the authenticated GitClient. */ private static authenticated: GitClient; /** The singleton instance of the unauthenticated GitClient. */ private static unauthenticated: GitClient; /** * Static method to get the singleton instance of the unauthorized GitClient, creating it if it * has not yet been created. */ static getInstance() { if (!GitClient.unauthenticated) { GitClient.unauthenticated = new GitClient(undefined); } return GitClient.unauthenticated; } /** * Static method to get the singleton instance of the authenticated GitClient if it has been * generated. */ static getAuthenticatedInstance() { if (!GitClient.authenticated) { throw Error('The authenticated GitClient has not yet been generated.'); } return GitClient.authenticated; } /** Build the authenticated GitClient instance. */ static authenticateWithToken(token: string) { if (GitClient.authenticated) { throw Error( 'Cannot generate new authenticated GitClient after one has already been generated.'); } GitClient.authenticated = new GitClient(token); } /** The configuration, containing the github specific configuration. */ private config: NgDevConfig; /** Whether verbose logging of Git actions should be used. */ private verboseLogging = true; /** The OAuth scopes available for the provided Github token. */ private _cachedOauthScopes: Promise|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; /** Short-hand for accessing the default remote configuration. */ remoteConfig: GithubConfig; /** Octokit request parameters object for targeting the configured remote. */ remoteParams: {owner: string, repo: string}; /** Instance of the Github octokit API. */ github = new GithubClient(this.githubToken); /** The full path to the root of the repository base. */ baseDir: string; /** * @param githubToken The github token used for authentication, if provided. * @param _config The configuration, containing the github specific configuration. * @param baseDir The full path to the root of the repository base. */ protected constructor(public githubToken: Authenticated extends true? string: undefined, config?: NgDevConfig, baseDir?: string) { this.baseDir = baseDir || this.determineBaseDir(); this.config = config || getConfig(this.baseDir); this.remoteConfig = this.config.github; this.remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name}; // 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 (typeof githubToken === 'string') { this._githubTokenRegex = new RegExp(githubToken, 'g'); } } /** Set the verbose logging state of the GitClient instance. */ setVerboseLoggingState(verbose: boolean): this { this.verboseLogging = verbose; return this; } /** Executes the given git command. Throws if the command fails. */ run(args: string[], options?: SpawnSyncOptions): Omit, '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, '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 { /** The git command to be run. */ const gitCommand = args[0]; if (isDryRun() && gitCommand === 'push') { debug(`"git push" is not able to be run in dryRun mode.`); throw new DryRunError(); } // 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 = (!this.verboseLogging || 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.baseDir, 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; } /** Git URL that resolves to the configured repository. */ getRepoGitUrl() { return getRepositoryGitUrl(this.remoteConfig, this.githubToken); } /** 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, ''); } /** * 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; } /** Gets the latest git tag on the current branch that matches SemVer. */ getLatestSemverTag(): SemVer { const semVerOptions: SemVerOptions = {loose: true}; const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n'); const latestTag = tags.find((tag: string) => parse(tag, semVerOptions)); if (latestTag === undefined) { throw new Error( `Unable to find a SemVer matching tag on "${this.getCurrentBranchOrRevision()}"`); } return new SemVer(latestTag, semVerOptions); } /** Gets the path of the directory for the repository base. */ getBaseDir(): string { const previousVerboseLoggingState = this.verboseLogging; this.setVerboseLoggingState(false); const {stdout, stderr, status} = this.runGraceful(['rev-parse', '--show-toplevel']); this.setVerboseLoggingState(previousVerboseLoggingState); if (status !== 0) { throw Error( `Unable to find the path to the base directory of the repository.\n` + `Was the command run from inside of the repo?\n\n` + `ERROR:\n ${stderr}`); } return stdout.trim(); } /** Retrieve a list of all files in the repostitory changed since the provided shaOrRef. */ allChangesFilesSince(shaOrRef = 'HEAD'): string[] { return Array.from(new Set([ ...gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])), ...gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])), ])); } /** Retrieve a list of all files currently staged in the repostitory. */ allStagedFiles(): string[] { return gitOutputAsArray( this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged'])); } /** Retrieve a list of all files tracked in the repostitory. */ allFiles(): string[] { return gitOutputAsArray(this.runGraceful(['ls-files'])); } /** * Assert the GitClient instance is using a token with permissions for the all of the * provided OAuth scopes. */ async hasOauthScopes(testFn: OAuthScopeTestFunction): Promise { 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 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()); }); } private determineBaseDir() { this.setVerboseLoggingState(false); const {stdout, stderr, status} = this.runGraceful(['rev-parse', '--show-toplevel']); if (status !== 0) { throw Error( `Unable to find the path to the base directory of the repository.\n` + `Was the command run from inside of the repo?\n\n` + `ERROR:\n ${stderr}`); } this.setVerboseLoggingState(true); return stdout.trim(); } } /** * Takes the output from `GitClient.run` and `GitClient.runGraceful` and returns an array of strings * for each new line. Git commands typically return multiple output values for a command a set of * strings separated by new lines. * * Note: This is specifically created as a locally available function for usage as convenience * utility within `GitClient`'s methods to create outputs as array. */ function gitOutputAsArray(gitCommandResult: SpawnSyncReturns): string[] { return gitCommandResult.stdout.split('\n').map(x => x.trim()).filter(x => !!x); }