From 67f65a9d2581b70300c609b665e5d582d296eedf Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Thu, 3 Jun 2021 15:59:20 +0200 Subject: [PATCH] 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 https://github.com/angular/angular/commit/3b434ed94d9ed067e5d999c064ae5f12b3cb175c 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 --- dev-infra/build-worker.js | 256 ++++------- dev-infra/caretaker/check/base.ts | 7 +- dev-infra/caretaker/check/g3.spec.ts | 2 +- dev-infra/caretaker/check/g3.ts | 2 +- dev-infra/caretaker/check/github.spec.ts | 4 +- .../validate-file/validate-file.ts | 4 +- dev-infra/format/cli.ts | 8 +- dev-infra/format/formatters/base-formatter.ts | 4 +- dev-infra/ng-dev.js | 409 +++++++++--------- dev-infra/ngbot/verify.ts | 4 +- .../check-target-branches.ts | 4 +- dev-infra/pr/common/checkout-pr.ts | 6 +- dev-infra/pr/discover-new-conflicts/index.ts | 7 +- dev-infra/pr/merge/index.ts | 6 +- dev-infra/pr/merge/pull-request.ts | 8 +- dev-infra/pr/merge/strategies/api-merge.ts | 5 +- dev-infra/pr/merge/strategies/strategy.ts | 4 +- dev-infra/pr/merge/task.ts | 5 +- dev-infra/pr/rebase/index.ts | 6 +- dev-infra/pullapprove/verify.ts | 4 +- dev-infra/release/notes/cli.ts | 4 +- dev-infra/release/notes/release-notes.ts | 4 +- dev-infra/release/publish/actions.ts | 6 +- dev-infra/release/publish/cli.ts | 4 +- dev-infra/release/publish/index.ts | 6 +- .../release/publish/pull-request-state.ts | 9 +- dev-infra/release/publish/test/test-utils.ts | 4 +- dev-infra/release/stamping/env-stamp.ts | 4 +- dev-infra/utils/config.ts | 6 +- dev-infra/utils/console.ts | 4 +- .../utils/git/authenticated-git-client.ts | 127 ++++++ dev-infra/utils/git/git-client.ts | 239 ++++++++++ dev-infra/utils/git/github-urls.ts | 5 +- dev-infra/utils/git/github-yargs.ts | 8 +- dev-infra/utils/git/github.ts | 54 +-- dev-infra/utils/git/index.ts | 345 --------------- dev-infra/utils/github.ts | 7 +- dev-infra/utils/testing/virtual-git-client.ts | 29 +- 38 files changed, 770 insertions(+), 850 deletions(-) create mode 100644 dev-infra/utils/git/authenticated-git-client.ts create mode 100644 dev-infra/utils/git/git-client.ts delete mode 100644 dev-infra/utils/git/index.ts diff --git a/dev-infra/build-worker.js b/dev-infra/build-worker.js index 18f635a8ef..b67a0c4746 100644 --- a/dev-infra/build-worker.js +++ b/dev-infra/build-worker.js @@ -3,7 +3,7 @@ var tslib = require('tslib'); var fs = require('fs'); var path = require('path'); -var chalk = require('chalk'); +require('chalk'); require('inquirer'); var child_process = require('child_process'); var semver = require('semver'); @@ -54,61 +54,50 @@ var GithubApiRequestError = /** @class */ (function (_super) { } return GithubApiRequestError; }(Error)); -/** Error for failed Github API requests. */ -var GithubGraphqlClientError = /** @class */ (function (_super) { - tslib.__extends(GithubGraphqlClientError, _super); - function GithubGraphqlClientError() { - return _super !== null && _super.apply(this, arguments) || this; - } - return GithubGraphqlClientError; -}(Error)); -/** - * A Github client for interacting with the Github APIs. - * - * Additionally, provides convenience methods for actions which require multiple requests, or - * would provide value from memoized style responses. - **/ +/** A Github client for interacting with the Github APIs. */ var GithubClient = /** @class */ (function () { - /** - * @param token The github authentication token for Github Rest and Graphql API requests. - */ - function GithubClient(token) { - this.token = token; - /** The graphql instance with authentication set during construction. */ - this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + this.token } }); - /** The Octokit instance actually performing API requests. */ - this._octokit = new rest.Octokit({ auth: this.token }); + function GithubClient(_octokitOptions) { + this._octokitOptions = _octokitOptions; + /** The octokit instance actually performing API requests. */ + this._octokit = new rest.Octokit(this._octokitOptions); this.pulls = this._octokit.pulls; this.repos = this._octokit.repos; this.issues = this._octokit.issues; this.git = this._octokit.git; this.paginate = this._octokit.paginate; this.rateLimit = this._octokit.rateLimit; - this._octokit.hook.error('request', function (error) { - // Wrap API errors in a known error class. This allows us to - // expect Github API errors better and in a non-ambiguous way. - throw new GithubApiRequestError(error.status, error.message); - }); + } + return GithubClient; +}()); +/** + * Extension of the `GithubClient` that provides utilities which are specific + * to authenticated instances. + */ +var AuthenticatedGithubClient = /** @class */ (function (_super) { + tslib.__extends(AuthenticatedGithubClient, _super); + function AuthenticatedGithubClient(_token) { + var _this = + // Set the token for the octokit instance. + _super.call(this, { auth: _token }) || this; + _this._token = _token; + /** The graphql instance with authentication set during construction. */ + _this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + _this._token } }); + return _this; } /** Perform a query using Github's Graphql API. */ - GithubClient.prototype.graphql = function (queryObject, params) { + AuthenticatedGithubClient.prototype.graphql = function (queryObject, params) { if (params === void 0) { params = {}; } return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__generator(this, function (_a) { switch (_a.label) { - case 0: - if (this.token === undefined) { - throw new GithubGraphqlClientError('Cannot query via graphql without an authentication token set, use the authenticated ' + - '`GitClient` by calling `GitClient.getAuthenticatedInstance()`.'); - } - return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject).toString(), params)]; + case 0: return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject).toString(), params)]; case 1: return [2 /*return*/, (_a.sent())]; } }); }); }; - return GithubClient; -}()); + return AuthenticatedGithubClient; +}(GithubClient)); /** * @license @@ -117,10 +106,6 @@ var GithubClient = /** @class */ (function () { * 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 */ -/** URL to the Github page where personal access tokens can be managed. */ -var GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; -/** URL to the Github page where personal access tokens can be generated. */ -var GITHUB_TOKEN_GENERATE_URL = 'https://github.com/settings/tokens/new'; /** Adds the provided token to the given Github HTTPs remote url. */ function addTokenToGitHttpsUrl(githubHttpsUrl, token) { var url$1 = new url.URL(githubHttpsUrl); @@ -154,80 +139,30 @@ var GitCommandError = /** @class */ (function (_super) { // 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.call(this, "Command failed: git " + client.omitGithubTokenFromMessage(args.join(' '))) || this; + _super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this; _this.args = args; return _this; } return GitCommandError; }(Error)); -/** - * 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. - **/ +/** Class that can be used to perform Git interactions with a given remote. **/ var GitClient = /** @class */ (function () { - /** - * @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. - */ - function GitClient(githubToken, config, baseDir) { - this.githubToken = githubToken; - /** The OAuth scopes available for the provided Github token. */ - this._cachedOauthScopes = null; - /** - * Regular expression that matches the provided Github token. Used for - * sanitizing the token from Git child process output. - */ - this._githubTokenRegex = null; - /** Instance of the Github octokit API. */ - this.github = new GithubClient(this.githubToken); - this.baseDir = baseDir || this.determineBaseDir(); - this.config = config || getConfig(this.baseDir); + function GitClient( + /** The full path to the root of the repository base. */ + baseDir, + /** The configuration, containing the github specific configuration. */ + config) { + if (baseDir === void 0) { baseDir = determineRepoBaseDirFromCwd(); } + if (config === void 0) { config = getConfig(baseDir); } + this.baseDir = baseDir; + this.config = config; + /** Short-hand for accessing the default remote configuration. */ this.remoteConfig = this.config.github; + /** Octokit request parameters object for targeting the configured remote. */ 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'); - } + /** Instance of the Github client. */ + this.github = new GithubClient(); } - /** - * Static method to get the singleton instance of the unauthorized GitClient, creating it if it - * has not yet been created. - */ - GitClient.getInstance = function () { - 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. - */ - GitClient.getAuthenticatedInstance = function () { - if (!GitClient.authenticated) { - throw Error('The authenticated GitClient has not yet been generated.'); - } - return GitClient.authenticated; - }; - /** Build the authenticated GitClient instance. */ - GitClient.authenticateWithToken = function (token) { - if (GitClient.authenticated) { - throw Error('Cannot generate new authenticated GitClient after one has already been generated.'); - } - GitClient.authenticated = new GitClient(token); - }; - /** Set the verbose logging state of the GitClient class. */ - GitClient.setVerboseLoggingState = function (verbose) { - this.verboseLogging = verbose; - }; /** Executes the given git command. Throws if the command fails. */ GitClient.prototype.run = function (args, options) { var result = this.runGraceful(args, options); @@ -252,13 +187,14 @@ var GitClient = /** @class */ (function () { throw new DryRunError(); } // To improve the debugging experience in case something fails, we print all executed Git - // commands at the DEBUG level to better understand the git actions occuring. Verbose logging, + // commands at the DEBUG level to better understand the git actions occurring. Verbose logging, // always logging at the INFO level, can be enabled either by setting the verboseLogging // property on the GitClient class or the options object provided to the method. var printFn = (GitClient.verboseLogging || options.verboseLogging) ? info : debug; - // 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(' '))); + // Note that we sanitize the command before printing it to the console. We do not want to + // print an access 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.sanitizeConsoleOutput(args.join(' '))); var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ 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. @@ -267,13 +203,13 @@ var GitClient = /** @class */ (function () { // 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)); + process.stderr.write(this.sanitizeConsoleOutput(result.stderr)); } return result; }; /** Git URL that resolves to the configured repository. */ GitClient.prototype.getRepoGitUrl = function () { - return getRepositoryGitUrl(this.remoteConfig, this.githubToken); + return getRepositoryGitUrl(this.remoteConfig); }; /** Whether the given branch contains the specified SHA. */ GitClient.prototype.hasCommit = function (branchName, sha) { @@ -294,15 +230,6 @@ var GitClient = /** @class */ (function () { GitClient.prototype.hasUncommittedChanges = function () { return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; }; - /** Sanitizes a given message by omitting the provided Github token if present. */ - GitClient.prototype.omitGithubTokenFromMessage = function (value) { - // 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 @@ -331,7 +258,7 @@ var GitClient = /** @class */ (function () { } return new semver.SemVer(latestTag, semVerOptions); }; - /** Retrieve a list of all files in the repostitory changed since the provided shaOrRef. */ + /** Retrieve a list of all files in the repository changed since the provided shaOrRef. */ GitClient.prototype.allChangesFilesSince = function (shaOrRef) { if (shaOrRef === void 0) { shaOrRef = 'HEAD'; } return Array.from(new Set(tslib.__spreadArray(tslib.__spreadArray([], tslib.__read(gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])))), tslib.__read(gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])))))); @@ -340,71 +267,38 @@ var GitClient = /** @class */ (function () { GitClient.prototype.allStagedFiles = function () { return gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged'])); }; - /** Retrieve a list of all files tracked in the repostitory. */ + /** Retrieve a list of all files tracked in the repository. */ GitClient.prototype.allFiles = function () { return gitOutputAsArray(this.runGraceful(['ls-files'])); }; /** - * Assert the GitClient instance is using a token with permissions for the all of the - * provided OAuth scopes. + * Sanitizes the given console message. This method can be overridden by + * derived classes. e.g. to sanitize access tokens from Git commands. */ - GitClient.prototype.hasOauthScopes = function (testFn) { - return tslib.__awaiter(this, void 0, void 0, function () { - var scopes, missingScopes, error; - return tslib.__generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, this.getAuthScopesForToken()]; - case 1: - scopes = _a.sent(); - missingScopes = []; - // 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 [2 /*return*/, true]; - } - 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 [2 /*return*/, { error: error }]; - } - }); - }); + GitClient.prototype.sanitizeConsoleOutput = function (value) { + return value; + }; + /** Set the verbose logging state of all git client instances. */ + GitClient.setVerboseLoggingState = function (verbose) { + GitClient.verboseLogging = verbose; }; /** - * Retrieve the OAuth scopes for the loaded Github token. - **/ - GitClient.prototype.getAuthScopesForToken = function () { - // If the OAuth scopes have already been loaded, return the Promise containing them. - if (this._cachedOauthScopes !== null) { - return this._cachedOauthScopes; + * Static method to get the singleton instance of the `GitClient`, creating it + * if it has not yet been created. + */ + GitClient.get = function () { + if (!this._unauthenticatedInstance) { + GitClient._unauthenticatedInstance = new GitClient(); } - // 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(function (_response) { - var response = _response; - var scopes = response.headers['x-oauth-scopes'] || ''; - return scopes.split(',').map(function (scope) { return scope.trim(); }); - }); - }; - GitClient.prototype.determineBaseDir = function () { - var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; - 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(); + return GitClient._unauthenticatedInstance; }; /** Whether verbose logging of Git actions should be used. */ GitClient.verboseLogging = false; return GitClient; }()); /** - * 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 + * Takes the output from `run` and `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 @@ -413,6 +307,17 @@ var GitClient = /** @class */ (function () { function gitOutputAsArray(gitCommandResult) { return gitCommandResult.stdout.split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); } +/** Determines the repository base directory from the current working directory. */ +function determineRepoBaseDirFromCwd() { + // TODO(devversion): Replace with common spawn sync utility once available. + var _a = child_process.spawnSync('git', ['rev-parse --show-toplevel'], { shell: true, stdio: 'pipe', encoding: 'utf8' }), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + 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" + + ("" + stderr)); + } + return stdout.trim(); +} /** * @license @@ -421,7 +326,6 @@ function gitOutputAsArray(gitCommandResult) { * 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 */ -var yellow = chalk.yellow; /** * Supported levels for logging functions. * @@ -552,7 +456,7 @@ var cachedConfig = null; function getConfig(baseDir) { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { - baseDir = baseDir || GitClient.getInstance().baseDir; + baseDir = baseDir || GitClient.get().baseDir; // The full path to the configuration file. var configPath = path.join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. diff --git a/dev-infra/caretaker/check/base.ts b/dev-infra/caretaker/check/base.ts index e60634b007..61ca5bbccb 100644 --- a/dev-infra/caretaker/check/base.ts +++ b/dev-infra/caretaker/check/base.ts @@ -5,14 +5,15 @@ * 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 {NgDevConfig} from '../../utils/config'; -import {GitClient} from '../../utils/git/index'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {CaretakerConfig} from '../config'; /** The BaseModule to extend modules for caretaker checks from. */ export abstract class BaseModule { - /** The singleton instance of the GitClient. */ - protected git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + protected git = AuthenticatedGitClient.get(); /** The data for the module. */ readonly data = this.retrieveData(); diff --git a/dev-infra/caretaker/check/g3.spec.ts b/dev-infra/caretaker/check/g3.spec.ts index ab7b63c520..bd3483d485 100644 --- a/dev-infra/caretaker/check/g3.spec.ts +++ b/dev-infra/caretaker/check/g3.spec.ts @@ -9,7 +9,7 @@ import {SpawnSyncReturns} from 'child_process'; import * as console from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing'; import {G3Module, G3StatsData} from './g3'; diff --git a/dev-infra/caretaker/check/g3.ts b/dev-infra/caretaker/check/g3.ts index 94ec261505..faefc9bd9f 100644 --- a/dev-infra/caretaker/check/g3.ts +++ b/dev-infra/caretaker/check/g3.ts @@ -10,7 +10,7 @@ import {existsSync, readFileSync} from 'fs'; import * as multimatch from 'multimatch'; import {join} from 'path'; import {parse as parseYaml} from 'yaml'; -import {bold, debug, error, info} from '../../utils/console'; +import {bold, debug, info} from '../../utils/console'; import {BaseModule} from './base'; diff --git a/dev-infra/caretaker/check/github.spec.ts b/dev-infra/caretaker/check/github.spec.ts index 9dfff39975..08af371533 100644 --- a/dev-infra/caretaker/check/github.spec.ts +++ b/dev-infra/caretaker/check/github.spec.ts @@ -7,7 +7,7 @@ * found in the LICENSE file at https://angular.io/license */ import * as console from '../../utils/console'; -import {GithubClient} from '../../utils/git/github'; +import {AuthenticatedGithubClient, GithubClient} from '../../utils/git/github'; import {installVirtualGitClientSpies, mockNgDevConfig} from '../../utils/testing'; import {GithubQueriesModule} from './github'; @@ -18,7 +18,7 @@ describe('GithubQueriesModule', () => { let infoGroupSpy: jasmine.Spy; beforeEach(() => { - githubApiSpy = spyOn(GithubClient.prototype, 'graphql') + githubApiSpy = spyOn(AuthenticatedGithubClient.prototype, 'graphql') .and.throwError( 'The graphql query response must always be manually defined in a test.'); installVirtualGitClientSpies(); diff --git a/dev-infra/commit-message/validate-file/validate-file.ts b/dev-infra/commit-message/validate-file/validate-file.ts index 5656cc5c16..31358459dd 100644 --- a/dev-infra/commit-message/validate-file/validate-file.ts +++ b/dev-infra/commit-message/validate-file/validate-file.ts @@ -9,14 +9,14 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import {error, green, info, log, red, yellow} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {deleteCommitMessageDraft, saveCommitMessageDraft} from '../restore-commit-message/commit-message-draft'; import {printValidationErrors, validateCommitMessage} from '../validate'; /** Validate commit message at the provided file path. */ export function validateFile(filePath: string, isErrorMode: boolean) { - const git = GitClient.getInstance(); + const git = GitClient.get(); const commitMessage = readFileSync(resolve(git.baseDir, filePath), 'utf8'); const {valid, errors} = validateCommitMessage(commitMessage); if (valid) { diff --git a/dev-infra/format/cli.ts b/dev-infra/format/cli.ts index cf1e44ee87..47261b58ae 100644 --- a/dev-infra/format/cli.ts +++ b/dev-infra/format/cli.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import * as yargs from 'yargs'; -import {GitClient} from '../utils/git/index'; +import {GitClient} from '../utils/git/git-client'; import {checkFiles, formatFiles} from './format'; @@ -24,7 +24,7 @@ export function buildFormatParser(localYargs: yargs.Argv) { 'all', 'Run the formatter on all files in the repository', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; - const allFiles = GitClient.getInstance().allFiles(); + const allFiles = GitClient.get().allFiles(); executionCmd(allFiles); }) .command( @@ -33,14 +33,14 @@ export function buildFormatParser(localYargs: yargs.Argv) { ({shaOrRef, check}) => { const sha = shaOrRef || 'master'; const executionCmd = check ? checkFiles : formatFiles; - const allChangedFilesSince = GitClient.getInstance().allChangesFilesSince(sha); + const allChangedFilesSince = GitClient.get().allChangesFilesSince(sha); executionCmd(allChangedFilesSince); }) .command( 'staged', 'Run the formatter on all staged files', args => args, ({check}) => { const executionCmd = check ? checkFiles : formatFiles; - const allStagedFiles = GitClient.getInstance().allStagedFiles(); + const allStagedFiles = GitClient.get().allStagedFiles(); executionCmd(allStagedFiles); }) .command( diff --git a/dev-infra/format/formatters/base-formatter.ts b/dev-infra/format/formatters/base-formatter.ts index 7b6381a54b..24ba0fa516 100644 --- a/dev-infra/format/formatters/base-formatter.ts +++ b/dev-infra/format/formatters/base-formatter.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {FormatConfig} from '../config'; // A callback to determine if the formatter run found a failure in formatting. @@ -25,7 +25,7 @@ interface FormatterActionMetadata { * The base class for formatters to run against provided files. */ export abstract class Formatter { - protected git = GitClient.getInstance(); + protected git = GitClient.get(); /** * The name of the formatter, this is used for identification in logging and for enabling and * configuring the formatter in the config. diff --git a/dev-infra/ng-dev.js b/dev-infra/ng-dev.js index 21795f21bf..5bb1d3044a 100755 --- a/dev-infra/ng-dev.js +++ b/dev-infra/ng-dev.js @@ -71,7 +71,7 @@ var userConfig = null; function getConfig(baseDir) { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { - baseDir = baseDir || GitClient.getInstance().baseDir; + baseDir = baseDir || GitClient.get().baseDir; // The full path to the configuration file. var configPath = path.join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. @@ -164,7 +164,7 @@ function assertNoErrors(errors) { function getUserConfig() { // If the global config is not defined, load it from the file system. if (userConfig === null) { - var git = GitClient.getInstance(); + var git = GitClient.get(); // The full path to the configuration file. var configPath = path.join(git.baseDir, USER_CONFIG_FILE_PATH); // Set the global config object. @@ -217,61 +217,50 @@ var GithubApiRequestError = /** @class */ (function (_super) { } return GithubApiRequestError; }(Error)); -/** Error for failed Github API requests. */ -var GithubGraphqlClientError = /** @class */ (function (_super) { - tslib.__extends(GithubGraphqlClientError, _super); - function GithubGraphqlClientError() { - return _super !== null && _super.apply(this, arguments) || this; - } - return GithubGraphqlClientError; -}(Error)); -/** - * A Github client for interacting with the Github APIs. - * - * Additionally, provides convenience methods for actions which require multiple requests, or - * would provide value from memoized style responses. - **/ +/** A Github client for interacting with the Github APIs. */ var GithubClient = /** @class */ (function () { - /** - * @param token The github authentication token for Github Rest and Graphql API requests. - */ - function GithubClient(token) { - this.token = token; - /** The graphql instance with authentication set during construction. */ - this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + this.token } }); - /** The Octokit instance actually performing API requests. */ - this._octokit = new rest.Octokit({ auth: this.token }); + function GithubClient(_octokitOptions) { + this._octokitOptions = _octokitOptions; + /** The octokit instance actually performing API requests. */ + this._octokit = new rest.Octokit(this._octokitOptions); this.pulls = this._octokit.pulls; this.repos = this._octokit.repos; this.issues = this._octokit.issues; this.git = this._octokit.git; this.paginate = this._octokit.paginate; this.rateLimit = this._octokit.rateLimit; - this._octokit.hook.error('request', function (error) { - // Wrap API errors in a known error class. This allows us to - // expect Github API errors better and in a non-ambiguous way. - throw new GithubApiRequestError(error.status, error.message); - }); + } + return GithubClient; +}()); +/** + * Extension of the `GithubClient` that provides utilities which are specific + * to authenticated instances. + */ +var AuthenticatedGithubClient = /** @class */ (function (_super) { + tslib.__extends(AuthenticatedGithubClient, _super); + function AuthenticatedGithubClient(_token) { + var _this = + // Set the token for the octokit instance. + _super.call(this, { auth: _token }) || this; + _this._token = _token; + /** The graphql instance with authentication set during construction. */ + _this._graphql = graphql.graphql.defaults({ headers: { authorization: "token " + _this._token } }); + return _this; } /** Perform a query using Github's Graphql API. */ - GithubClient.prototype.graphql = function (queryObject, params) { + AuthenticatedGithubClient.prototype.graphql = function (queryObject, params) { if (params === void 0) { params = {}; } return tslib.__awaiter(this, void 0, void 0, function () { return tslib.__generator(this, function (_a) { switch (_a.label) { - case 0: - if (this.token === undefined) { - throw new GithubGraphqlClientError('Cannot query via graphql without an authentication token set, use the authenticated ' + - '`GitClient` by calling `GitClient.getAuthenticatedInstance()`.'); - } - return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject).toString(), params)]; + case 0: return [4 /*yield*/, this._graphql(typedGraphqlify.query(queryObject).toString(), params)]; case 1: return [2 /*return*/, (_a.sent())]; } }); }); }; - return GithubClient; -}()); + return AuthenticatedGithubClient; +}(GithubClient)); /** * @license @@ -322,80 +311,30 @@ var GitCommandError = /** @class */ (function (_super) { // 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.call(this, "Command failed: git " + client.omitGithubTokenFromMessage(args.join(' '))) || this; + _super.call(this, "Command failed: git " + client.sanitizeConsoleOutput(args.join(' '))) || this; _this.args = args; return _this; } return GitCommandError; }(Error)); -/** - * 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. - **/ +/** Class that can be used to perform Git interactions with a given remote. **/ var GitClient = /** @class */ (function () { - /** - * @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. - */ - function GitClient(githubToken, config, baseDir) { - this.githubToken = githubToken; - /** The OAuth scopes available for the provided Github token. */ - this._cachedOauthScopes = null; - /** - * Regular expression that matches the provided Github token. Used for - * sanitizing the token from Git child process output. - */ - this._githubTokenRegex = null; - /** Instance of the Github octokit API. */ - this.github = new GithubClient(this.githubToken); - this.baseDir = baseDir || this.determineBaseDir(); - this.config = config || getConfig(this.baseDir); + function GitClient( + /** The full path to the root of the repository base. */ + baseDir, + /** The configuration, containing the github specific configuration. */ + config) { + if (baseDir === void 0) { baseDir = determineRepoBaseDirFromCwd(); } + if (config === void 0) { config = getConfig(baseDir); } + this.baseDir = baseDir; + this.config = config; + /** Short-hand for accessing the default remote configuration. */ this.remoteConfig = this.config.github; + /** Octokit request parameters object for targeting the configured remote. */ 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'); - } + /** Instance of the Github client. */ + this.github = new GithubClient(); } - /** - * Static method to get the singleton instance of the unauthorized GitClient, creating it if it - * has not yet been created. - */ - GitClient.getInstance = function () { - 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. - */ - GitClient.getAuthenticatedInstance = function () { - if (!GitClient.authenticated) { - throw Error('The authenticated GitClient has not yet been generated.'); - } - return GitClient.authenticated; - }; - /** Build the authenticated GitClient instance. */ - GitClient.authenticateWithToken = function (token) { - if (GitClient.authenticated) { - throw Error('Cannot generate new authenticated GitClient after one has already been generated.'); - } - GitClient.authenticated = new GitClient(token); - }; - /** Set the verbose logging state of the GitClient class. */ - GitClient.setVerboseLoggingState = function (verbose) { - this.verboseLogging = verbose; - }; /** Executes the given git command. Throws if the command fails. */ GitClient.prototype.run = function (args, options) { var result = this.runGraceful(args, options); @@ -420,13 +359,14 @@ var GitClient = /** @class */ (function () { throw new DryRunError(); } // To improve the debugging experience in case something fails, we print all executed Git - // commands at the DEBUG level to better understand the git actions occuring. Verbose logging, + // commands at the DEBUG level to better understand the git actions occurring. Verbose logging, // always logging at the INFO level, can be enabled either by setting the verboseLogging // property on the GitClient class or the options object provided to the method. var printFn = (GitClient.verboseLogging || options.verboseLogging) ? info : debug; - // 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(' '))); + // Note that we sanitize the command before printing it to the console. We do not want to + // print an access 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.sanitizeConsoleOutput(args.join(' '))); var result = child_process.spawnSync('git', args, tslib.__assign(tslib.__assign({ 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. @@ -435,13 +375,13 @@ var GitClient = /** @class */ (function () { // 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)); + process.stderr.write(this.sanitizeConsoleOutput(result.stderr)); } return result; }; /** Git URL that resolves to the configured repository. */ GitClient.prototype.getRepoGitUrl = function () { - return getRepositoryGitUrl(this.remoteConfig, this.githubToken); + return getRepositoryGitUrl(this.remoteConfig); }; /** Whether the given branch contains the specified SHA. */ GitClient.prototype.hasCommit = function (branchName, sha) { @@ -462,15 +402,6 @@ var GitClient = /** @class */ (function () { GitClient.prototype.hasUncommittedChanges = function () { return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0; }; - /** Sanitizes a given message by omitting the provided Github token if present. */ - GitClient.prototype.omitGithubTokenFromMessage = function (value) { - // 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 @@ -499,7 +430,7 @@ var GitClient = /** @class */ (function () { } return new semver.SemVer(latestTag, semVerOptions); }; - /** Retrieve a list of all files in the repostitory changed since the provided shaOrRef. */ + /** Retrieve a list of all files in the repository changed since the provided shaOrRef. */ GitClient.prototype.allChangesFilesSince = function (shaOrRef) { if (shaOrRef === void 0) { shaOrRef = 'HEAD'; } return Array.from(new Set(tslib.__spreadArray(tslib.__spreadArray([], tslib.__read(gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=d', shaOrRef])))), tslib.__read(gitOutputAsArray(this.runGraceful(['ls-files', '--others', '--exclude-standard'])))))); @@ -508,71 +439,38 @@ var GitClient = /** @class */ (function () { GitClient.prototype.allStagedFiles = function () { return gitOutputAsArray(this.runGraceful(['diff', '--name-only', '--diff-filter=ACM', '--staged'])); }; - /** Retrieve a list of all files tracked in the repostitory. */ + /** Retrieve a list of all files tracked in the repository. */ GitClient.prototype.allFiles = function () { return gitOutputAsArray(this.runGraceful(['ls-files'])); }; /** - * Assert the GitClient instance is using a token with permissions for the all of the - * provided OAuth scopes. + * Sanitizes the given console message. This method can be overridden by + * derived classes. e.g. to sanitize access tokens from Git commands. */ - GitClient.prototype.hasOauthScopes = function (testFn) { - return tslib.__awaiter(this, void 0, void 0, function () { - var scopes, missingScopes, error; - return tslib.__generator(this, function (_a) { - switch (_a.label) { - case 0: return [4 /*yield*/, this.getAuthScopesForToken()]; - case 1: - scopes = _a.sent(); - missingScopes = []; - // 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 [2 /*return*/, true]; - } - 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 [2 /*return*/, { error: error }]; - } - }); - }); + GitClient.prototype.sanitizeConsoleOutput = function (value) { + return value; + }; + /** Set the verbose logging state of all git client instances. */ + GitClient.setVerboseLoggingState = function (verbose) { + GitClient.verboseLogging = verbose; }; /** - * Retrieve the OAuth scopes for the loaded Github token. - **/ - GitClient.prototype.getAuthScopesForToken = function () { - // If the OAuth scopes have already been loaded, return the Promise containing them. - if (this._cachedOauthScopes !== null) { - return this._cachedOauthScopes; + * Static method to get the singleton instance of the `GitClient`, creating it + * if it has not yet been created. + */ + GitClient.get = function () { + if (!this._unauthenticatedInstance) { + GitClient._unauthenticatedInstance = new GitClient(); } - // 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(function (_response) { - var response = _response; - var scopes = response.headers['x-oauth-scopes'] || ''; - return scopes.split(',').map(function (scope) { return scope.trim(); }); - }); - }; - GitClient.prototype.determineBaseDir = function () { - var _a = this.runGraceful(['rev-parse', '--show-toplevel']), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; - 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(); + return GitClient._unauthenticatedInstance; }; /** Whether verbose logging of Git actions should be used. */ GitClient.verboseLogging = false; return GitClient; }()); /** - * 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 + * Takes the output from `run` and `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 @@ -581,6 +479,17 @@ var GitClient = /** @class */ (function () { function gitOutputAsArray(gitCommandResult) { return gitCommandResult.stdout.split('\n').map(function (x) { return x.trim(); }).filter(function (x) { return !!x; }); } +/** Determines the repository base directory from the current working directory. */ +function determineRepoBaseDirFromCwd() { + // TODO(devversion): Replace with common spawn sync utility once available. + var _a = child_process.spawnSync('git', ['rev-parse --show-toplevel'], { shell: true, stdio: 'pipe', encoding: 'utf8' }), stdout = _a.stdout, stderr = _a.stderr, status = _a.status; + 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" + + ("" + stderr)); + } + return stdout.trim(); +} /** * @license @@ -726,7 +635,7 @@ function captureLogOutputForCommand(argv) { if (FILE_LOGGING_ENABLED) { throw Error('`captureLogOutputForCommand` cannot be called multiple times'); } - var git = GitClient.getInstance(); + var git = GitClient.get(); /** The date time used for timestamping when the command was invoked. */ var now = new Date(); /** Header line to separate command runs in log files. */ @@ -763,6 +672,103 @@ function printToLogFile(logLevel) { LOGGED_TEXT += text.join(' ').split('\n').map(function (l) { return logLevelText + " " + l + "\n"; }).join(''); } +/** + * Extension of the `GitClient` with additional utilities which are useful for + * authenticated Git client instances. + */ +var AuthenticatedGitClient = /** @class */ (function (_super) { + tslib.__extends(AuthenticatedGitClient, _super); + function AuthenticatedGitClient(githubToken, baseDir, config) { + var _this = _super.call(this, baseDir, config) || this; + _this.githubToken = githubToken; + /** + * Regular expression that matches the provided Github token. Used for + * sanitizing the token from Git child process output. + */ + _this._githubTokenRegex = new RegExp(_this.githubToken, 'g'); + /** The OAuth scopes available for the provided Github token. */ + _this._cachedOauthScopes = null; + /** Instance of an authenticated github client. */ + _this.github = new AuthenticatedGithubClient(_this.githubToken); + return _this; + } + /** Sanitizes a given message by omitting the provided Github token if present. */ + AuthenticatedGitClient.prototype.sanitizeConsoleOutput = function (value) { + return value.replace(this._githubTokenRegex, ''); + }; + /** Git URL that resolves to the configured repository. */ + AuthenticatedGitClient.prototype.getRepoGitUrl = function () { + return getRepositoryGitUrl(this.remoteConfig, this.githubToken); + }; + /** + * Assert the GitClient instance is using a token with permissions for the all of the + * provided OAuth scopes. + */ + AuthenticatedGitClient.prototype.hasOauthScopes = function (testFn) { + return tslib.__awaiter(this, void 0, void 0, function () { + var scopes, missingScopes, error; + return tslib.__generator(this, function (_a) { + switch (_a.label) { + case 0: return [4 /*yield*/, this._fetchAuthScopesForToken()]; + case 1: + scopes = _a.sent(); + missingScopes = []; + // 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 [2 /*return*/, true]; + } + 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 [2 /*return*/, { error: error }]; + } + }); + }); + }; + /** Fetch the OAuth scopes for the loaded Github token. */ + AuthenticatedGitClient.prototype._fetchAuthScopesForToken = function () { + // 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(function (_response) { + var response = _response; + var scopes = response.headers['x-oauth-scopes']; + // If no token is provided, or if the Github client is authenticated incorrectly, + // the `x-oauth-scopes` response header is not set. We error in such cases as it + // signifies a faulty of the + if (scopes === undefined) { + throw Error('Unable to retrieve OAuth scopes for token provided to Git client.'); + } + return scopes.split(',').map(function (scope) { return scope.trim(); }).filter(function (scope) { return scope !== ''; }); + }); + }; + /** + * Static method to get the singleton instance of the `AuthenticatedGitClient`, + * creating it if it has not yet been created. + */ + AuthenticatedGitClient.get = function () { + if (!AuthenticatedGitClient._authenticatedInstance) { + throw new Error('No instance of `AuthenticatedGitClient` has been set up yet.'); + } + return AuthenticatedGitClient._authenticatedInstance; + }; + /** Configures an authenticated git client. */ + AuthenticatedGitClient.configure = function (token) { + if (AuthenticatedGitClient._authenticatedInstance) { + throw Error('Unable to configure `AuthenticatedGitClient` as it has been configured already.'); + } + AuthenticatedGitClient._authenticatedInstance = new AuthenticatedGitClient(token); + }; + return AuthenticatedGitClient; +}(GitClient)); + /** * @license * Copyright Google LLC All Rights Reserved. @@ -774,7 +780,7 @@ function printToLogFile(logLevel) { function addGithubTokenOption(yargs) { return yargs // 'github-token' is casted to 'githubToken' to properly set up typings to reflect the key in - // the Argv object being camelCase rather than kebob case due to the `camel-case-expansion` + // the Argv object being camelCase rather than kebab case due to the `camel-case-expansion` // config: https://github.com/yargs/yargs-parser#camel-case-expansion .option('github-token', { type: 'string', @@ -788,10 +794,10 @@ function addGithubTokenOption(yargs) { process.exit(1); } try { - GitClient.getAuthenticatedInstance(); + AuthenticatedGitClient.get(); } catch (_a) { - GitClient.authenticateWithToken(githubToken); + AuthenticatedGitClient.configure(githubToken); } return githubToken; }, @@ -1128,12 +1134,19 @@ function getLtsNpmDistTagOfMajor(major) { return `v${major}-lts`; } +/** + * @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 + */ /** The BaseModule to extend modules for caretaker checks from. */ class BaseModule { constructor(config) { this.config = config; - /** The singleton instance of the GitClient. */ - this.git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + this.git = AuthenticatedGitClient.get(); /** The data for the module. */ this.data = this.retrieveData(); } @@ -2053,7 +2066,7 @@ function printValidationErrors(errors, print = error) { */ /** Validate commit message at the provided file path. */ function validateFile(filePath, isErrorMode) { - const git = GitClient.getInstance(); + const git = GitClient.get(); const commitMessage = fs.readFileSync(path.resolve(git.baseDir, filePath), 'utf8'); const { valid, errors } = validateCommitMessage(commitMessage); if (valid) { @@ -2308,7 +2321,7 @@ function checkFormatterConfig(key, config, errors) { class Formatter { constructor(config) { this.config = config; - this.git = GitClient.getInstance(); + this.git = GitClient.get(); } /** * Retrieve the command to execute the provided action, including both the binary @@ -2684,18 +2697,18 @@ function buildFormatParser(localYargs) { }) .command('all', 'Run the formatter on all files in the repository', args => args, ({ check }) => { const executionCmd = check ? checkFiles : formatFiles; - const allFiles = GitClient.getInstance().allFiles(); + const allFiles = GitClient.get().allFiles(); executionCmd(allFiles); }) .command('changed [shaOrRef]', 'Run the formatter on files changed since the provided sha/ref', args => args.positional('shaOrRef', { type: 'string' }), ({ shaOrRef, check }) => { const sha = shaOrRef || 'master'; const executionCmd = check ? checkFiles : formatFiles; - const allChangedFilesSince = GitClient.getInstance().allChangesFilesSince(sha); + const allChangedFilesSince = GitClient.get().allChangesFilesSince(sha); executionCmd(allChangedFilesSince); }) .command('staged', 'Run the formatter on all staged files', args => args, ({ check }) => { const executionCmd = check ? checkFiles : formatFiles; - const allStagedFiles = GitClient.getInstance().allStagedFiles(); + const allStagedFiles = GitClient.get().allStagedFiles(); executionCmd(allStagedFiles); }) .command('files ', 'Run the formatter on provided files', args => args.positional('files', { array: true, type: 'string' }), ({ check, files }) => { @@ -2712,7 +2725,7 @@ function buildFormatParser(localYargs) { * found in the LICENSE file at https://angular.io/license */ function verify() { - const git = GitClient.getInstance(); + const git = GitClient.get(); /** Full path to NgBot config file */ const NGBOT_CONFIG_YAML_PATH = path.resolve(git.baseDir, '.github/angular-robot.yml'); /** The NgBot config file */ @@ -2902,7 +2915,7 @@ function getTargetBranchesForPr(prNumber) { /** Repo owner and name for the github repository. */ const { owner, name: repo } = config.github; /** The singleton instance of the GitClient. */ - const git = GitClient.getInstance(); + const git = GitClient.get(); /** The validated merge config. */ const { config: mergeConfig, errors } = yield loadAndValidateConfig(config, git.github); if (errors !== undefined) { @@ -3102,8 +3115,8 @@ class MaintainerModifyAccessError extends Error { */ function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) { return tslib.__awaiter(this, void 0, void 0, function* () { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); // In order to preserve local changes, checkouts cannot occur if local changes are present in the // git environment. Checked before retrieving the PR to fail fast. if (git.hasUncommittedChanges()) { @@ -3242,8 +3255,8 @@ const tempWorkingBranch = '__NgDevRepoBaseAfterChange__'; /** Checks if the provided PR will cause new conflicts in other pending PRs. */ function discoverNewConflictsForPr(newPrNumber, updatedAfter) { return tslib.__awaiter(this, void 0, void 0, function* () { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); // If there are any local changes in the current repository state, the // check cannot run as it needs to move between branches. if (git.hasUncommittedChanges()) { @@ -4477,7 +4490,7 @@ function createPullRequestMergeTask(flags) { switch (_b.label) { case 0: devInfraConfig = getConfig(); - git = GitClient.getAuthenticatedInstance(); + git = AuthenticatedGitClient.get(); return [4 /*yield*/, loadAndValidateConfig(devInfraConfig, git.github)]; case 1: _a = _b.sent(), config = _a.config, errors = _a.errors; @@ -4576,8 +4589,8 @@ const PR_SCHEMA$3 = { */ function rebasePr(prNumber, githubToken, config = getConfig()) { return tslib.__awaiter(this, void 0, void 0, function* () { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); if (git.hasUncommittedChanges()) { error('Cannot perform rebase of PR with local changes.'); process.exit(1); @@ -5015,7 +5028,7 @@ function getGroupsFromYaml(pullApproveYamlRaw) { * found in the LICENSE file at https://angular.io/license */ function verify$1() { - const git = GitClient.getInstance(); + const git = GitClient.get(); /** Full path to PullApprove config file */ const PULL_APPROVE_YAML_PATH = path.resolve(git.baseDir, '.pullapprove.yml'); /** All tracked files in the repository. */ @@ -5524,7 +5537,7 @@ class ReleaseNotes { this.startingRef = startingRef; this.endingRef = endingRef; /** An instance of GitClient. */ - this.git = GitClient.getInstance(); + this.git = GitClient.get(); /** A promise resolving to a list of Commits since the latest semver tag on the branch. */ this.commits = this.getCommitsInRange(this.startingRef, this.endingRef); /** The configuration for release notes. */ @@ -5630,7 +5643,7 @@ function handler$8({ releaseVersion, from, to, outFile, type }) { return tslib.__awaiter(this, void 0, void 0, function* () { // Since `yargs` evaluates defaults even if a value as been provided, if no value is provided to // the handler, the latest semver tag on the branch is used. - from = from || GitClient.getInstance().getLatestSemverTag().format(); + from = from || GitClient.get().getLatestSemverTag().format(); /** The ReleaseNotes instance to generate release notes. */ const releaseNotes = yield ReleaseNotes.fromRange(releaseVersion, from, to); /** The requested release notes entry. */ @@ -7085,8 +7098,8 @@ class ReleaseTool { this._config = _config; this._github = _github; this._projectRoot = _projectRoot; - /** The singleton instance of the GitClient. */ - this._git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + this._git = AuthenticatedGitClient.get(); /** The previous git commit to return back to after the release tool runs. */ this.previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision(); } @@ -7279,7 +7292,7 @@ function builder$9(argv) { /** Yargs command handler for staging a release. */ function handler$9() { return tslib.__awaiter(this, void 0, void 0, function* () { - const git = GitClient.getInstance(); + const git = GitClient.get(); const config = getConfig(); const releaseConfig = getReleaseConfig(config); const projectDir = git.baseDir; @@ -7409,7 +7422,7 @@ function hasLocalChanges() { */ function getSCMVersion(mode) { if (mode === 'release') { - const git = GitClient.getInstance(); + const git = GitClient.get(); const packageJsonPath = path.join(git.baseDir, 'package.json'); const { version } = require(packageJsonPath); return version; diff --git a/dev-infra/ngbot/verify.ts b/dev-infra/ngbot/verify.ts index 79286e4626..af9e559efc 100644 --- a/dev-infra/ngbot/verify.ts +++ b/dev-infra/ngbot/verify.ts @@ -10,10 +10,10 @@ import {resolve} from 'path'; import {parse as parseYaml} from 'yaml'; import {error, green, info, red} from '../utils/console'; -import {GitClient} from '../utils/git/index'; +import {GitClient} from '../utils/git/git-client'; export function verify() { - const git = GitClient.getInstance(); + const git = GitClient.get(); /** Full path to NgBot config file */ const NGBOT_CONFIG_YAML_PATH = resolve(git.baseDir, '.github/angular-robot.yml'); diff --git a/dev-infra/pr/check-target-branches/check-target-branches.ts b/dev-infra/pr/check-target-branches/check-target-branches.ts index 8b2a15cf9b..ae6b0283ab 100644 --- a/dev-infra/pr/check-target-branches/check-target-branches.ts +++ b/dev-infra/pr/check-target-branches/check-target-branches.ts @@ -8,7 +8,7 @@ import {getConfig} from '../../utils/config'; import {error, info, red} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {loadAndValidateConfig, TargetLabel} from '../merge/config'; import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest, InvalidTargetLabelError} from '../merge/target-label'; @@ -18,7 +18,7 @@ export async function getTargetBranchesForPr(prNumber: number) { /** Repo owner and name for the github repository. */ const {owner, name: repo} = config.github; /** The singleton instance of the GitClient. */ - const git = GitClient.getInstance(); + const git = GitClient.get(); /** The validated merge config. */ const {config: mergeConfig, errors} = await loadAndValidateConfig(config, git.github); if (errors !== undefined) { diff --git a/dev-infra/pr/common/checkout-pr.ts b/dev-infra/pr/common/checkout-pr.ts index 57098d9f19..10d1d6cd59 100644 --- a/dev-infra/pr/common/checkout-pr.ts +++ b/dev-infra/pr/common/checkout-pr.ts @@ -9,8 +9,8 @@ import {types as graphqlTypes} from 'typed-graphqlify'; import {info} from '../../utils/console'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; -import {GitClient} from '../../utils/git/index'; import {getPr} from '../../utils/github'; /* Graphql schema for the response body for a pending PR. */ @@ -62,8 +62,8 @@ export interface PullRequestCheckoutOptions { */ export async function checkOutPullRequestLocally( prNumber: number, githubToken: string, opts: PullRequestCheckoutOptions = {}) { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); // In order to preserve local changes, checkouts cannot occur if local changes are present in the // git environment. Checked before retrieving the PR to fail fast. diff --git a/dev-infra/pr/discover-new-conflicts/index.ts b/dev-infra/pr/discover-new-conflicts/index.ts index 95ecd95960..b164155566 100644 --- a/dev-infra/pr/discover-new-conflicts/index.ts +++ b/dev-infra/pr/discover-new-conflicts/index.ts @@ -10,7 +10,8 @@ import {Bar} from 'cli-progress'; import {types as graphqlTypes} from 'typed-graphqlify'; import {error, info} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; +import {GitClient} from '../../utils/git/git-client'; import {getPendingPrs} from '../../utils/github'; import {exec} from '../../utils/shelljs'; @@ -53,8 +54,8 @@ const tempWorkingBranch = '__NgDevRepoBaseAfterChange__'; /** Checks if the provided PR will cause new conflicts in other pending PRs. */ export async function discoverNewConflictsForPr(newPrNumber: number, updatedAfter: number) { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); // If there are any local changes in the current repository state, the // check cannot run as it needs to move between branches. if (git.hasUncommittedChanges()) { diff --git a/dev-infra/pr/merge/index.ts b/dev-infra/pr/merge/index.ts index 49bbdba254..76feb3db69 100644 --- a/dev-infra/pr/merge/index.ts +++ b/dev-infra/pr/merge/index.ts @@ -9,9 +9,9 @@ import {getConfig} from '../../utils/config'; import {error, green, info, promptConfirm, red, yellow} from '../../utils/console'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {GithubApiRequestError} from '../../utils/git/github'; import {GITHUB_TOKEN_GENERATE_URL} from '../../utils/git/github-urls'; -import {GitClient} from '../../utils/git/index'; import {loadAndValidateConfig, MergeConfigWithRemote} from './config'; import {MergeResult, MergeStatus, PullRequestMergeTask, PullRequestMergeTaskFlags} from './task'; @@ -126,8 +126,8 @@ export async function mergePullRequest(prNumber: number, flags: PullRequestMerge */ async function createPullRequestMergeTask(flags: PullRequestMergeTaskFlags) { const devInfraConfig = getConfig(); - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); const {config, errors} = await loadAndValidateConfig(devInfraConfig, git.github); if (errors) { diff --git a/dev-infra/pr/merge/pull-request.ts b/dev-infra/pr/merge/pull-request.ts index ec8e7110e8..3137e425f1 100644 --- a/dev-infra/pr/merge/pull-request.ts +++ b/dev-infra/pr/merge/pull-request.ts @@ -7,13 +7,13 @@ */ import {params, types as graphqlTypes} from 'typed-graphqlify'; + import {Commit, parseCommitMessage} from '../../commit-message/parse'; import {red, warn} from '../../utils/console'; - -import {GitClient} from '../../utils/git/index'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {getPr} from '../../utils/github'; -import {MergeConfig, TargetLabel} from './config'; +import {MergeConfig, TargetLabel} from './config'; import {PullRequestFailure} from './failures'; import {matchesPattern} from './string-pattern'; import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest, InvalidTargetBranchError, InvalidTargetLabelError} from './target-label'; @@ -168,7 +168,7 @@ type RawPullRequest = typeof PR_SCHEMA; /** Fetches a pull request from Github. Returns null if an error occurred. */ async function fetchPullRequestFromGithub( - git: GitClient, prNumber: number): Promise { + git: AuthenticatedGitClient, prNumber: number): Promise { try { return await getPr(PR_SCHEMA, prNumber, git); } catch (e) { diff --git a/dev-infra/pr/merge/strategies/api-merge.ts b/dev-infra/pr/merge/strategies/api-merge.ts index e66aa8d309..2141e6d343 100644 --- a/dev-infra/pr/merge/strategies/api-merge.ts +++ b/dev-infra/pr/merge/strategies/api-merge.ts @@ -10,7 +10,8 @@ import {Octokit} from '@octokit/rest'; import {prompt} from 'inquirer'; import {parseCommitMessage} from '../../../commit-message/parse'; -import {GitClient} from '../../../utils/git/index'; +import {AuthenticatedGitClient} from '../../../utils/git/authenticated-git-client'; +import {GitClient} from '../../../utils/git/git-client'; import {GithubApiMergeMethod} from '../config'; import {PullRequestFailure} from '../failures'; import {PullRequest} from '../pull-request'; @@ -37,7 +38,7 @@ const COMMIT_HEADER_SEPARATOR = '\n\n'; * 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) { + constructor(git: AuthenticatedGitClient, private _config: GithubApiMergeStrategyConfig) { super(git); } diff --git a/dev-infra/pr/merge/strategies/strategy.ts b/dev-infra/pr/merge/strategies/strategy.ts index c5cd98c523..0011b5b2e3 100644 --- a/dev-infra/pr/merge/strategies/strategy.ts +++ b/dev-infra/pr/merge/strategies/strategy.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {GitClient} from '../../../utils/git/index'; +import {AuthenticatedGitClient} from '../../../utils/git/authenticated-git-client'; import {PullRequestFailure} from '../failures'; import {PullRequest} from '../pull-request'; @@ -22,7 +22,7 @@ export const TEMP_PR_HEAD_BRANCH = 'merge_pr_head'; * merges it into the determined target branches. */ export abstract class MergeStrategy { - constructor(protected git: GitClient) {} + constructor(protected git: AuthenticatedGitClient) {} /** * Prepares a merge of the given pull request. The strategy by default will diff --git a/dev-infra/pr/merge/task.ts b/dev-infra/pr/merge/task.ts index 759bd8b942..2d6ec9c66f 100644 --- a/dev-infra/pr/merge/task.ts +++ b/dev-infra/pr/merge/task.ts @@ -7,7 +7,8 @@ */ import {promptConfirm} from '../../utils/console'; -import {GitClient, GitCommandError} from '../../utils/git/index'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; +import {GitCommandError} from '../../utils/git/git-client'; import {MergeConfigWithRemote} from './config'; import {PullRequestFailure} from './failures'; @@ -51,7 +52,7 @@ export class PullRequestMergeTask { private flags: PullRequestMergeTaskFlags; constructor( - public config: MergeConfigWithRemote, public git: GitClient, + public config: MergeConfigWithRemote, public git: AuthenticatedGitClient, flags: Partial) { // Update flags property with the provided flags values as patches to the default flag values. this.flags = {...defaultPullRequestMergeTaskFlags, ...flags}; diff --git a/dev-infra/pr/rebase/index.ts b/dev-infra/pr/rebase/index.ts index f5889f992c..b9dbd8517f 100644 --- a/dev-infra/pr/rebase/index.ts +++ b/dev-infra/pr/rebase/index.ts @@ -12,8 +12,8 @@ import {Commit} from '../../commit-message/parse'; import {getCommitsInRange} from '../../commit-message/utils'; import {getConfig, NgDevConfig} from '../../utils/config'; import {error, info, promptConfirm} from '../../utils/console'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {addTokenToGitHttpsUrl} from '../../utils/git/github-urls'; -import {GitClient} from '../../utils/git/index'; import {getPr} from '../../utils/github'; /* Graphql schema for the response body for each pending PR. */ @@ -44,8 +44,8 @@ const PR_SCHEMA = { */ export async function rebasePr( prNumber: number, githubToken: string, config: Pick = getConfig()) { - /** The singleton instance of the GitClient. */ - const git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + const git = AuthenticatedGitClient.get(); if (git.hasUncommittedChanges()) { error('Cannot perform rebase of PR with local changes.'); process.exit(1); diff --git a/dev-infra/pullapprove/verify.ts b/dev-infra/pullapprove/verify.ts index a78d0c572e..6b67a9a8f8 100644 --- a/dev-infra/pullapprove/verify.ts +++ b/dev-infra/pullapprove/verify.ts @@ -9,12 +9,12 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import {debug, info} from '../utils/console'; -import {GitClient} from '../utils/git/index'; +import {GitClient} from '../utils/git/git-client'; import {logGroup, logHeader} from './logging'; import {getGroupsFromYaml} from './parse-yaml'; export function verify() { - const git = GitClient.getInstance(); + const git = GitClient.get(); /** Full path to PullApprove config file */ const PULL_APPROVE_YAML_PATH = resolve(git.baseDir, '.pullapprove.yml'); /** All tracked files in the repository. */ diff --git a/dev-infra/release/notes/cli.ts b/dev-infra/release/notes/cli.ts index bc14aeec63..022a25cfc4 100644 --- a/dev-infra/release/notes/cli.ts +++ b/dev-infra/release/notes/cli.ts @@ -12,7 +12,7 @@ import {SemVer} from 'semver'; import {Arguments, Argv, CommandModule} from 'yargs'; import {debug, info} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {ReleaseNotes} from './release-notes'; @@ -58,7 +58,7 @@ function builder(argv: Argv): Argv { async function handler({releaseVersion, from, to, outFile, type}: Arguments) { // Since `yargs` evaluates defaults even if a value as been provided, if no value is provided to // the handler, the latest semver tag on the branch is used. - from = from || GitClient.getInstance().getLatestSemverTag().format(); + from = from || GitClient.get().getLatestSemverTag().format(); /** The ReleaseNotes instance to generate release notes. */ const releaseNotes = await ReleaseNotes.fromRange(releaseVersion, from, to); diff --git a/dev-infra/release/notes/release-notes.ts b/dev-infra/release/notes/release-notes.ts index 68ab306f1f..74b46db024 100644 --- a/dev-infra/release/notes/release-notes.ts +++ b/dev-infra/release/notes/release-notes.ts @@ -11,7 +11,7 @@ import {CommitFromGitLog} from '../../commit-message/parse'; import {getCommitsInRange} from '../../commit-message/utils'; import {promptInput} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {DevInfraReleaseConfig, getReleaseConfig, ReleaseNotesConfig} from '../config/index'; import {RenderContext} from './context'; @@ -25,7 +25,7 @@ export class ReleaseNotes { } /** An instance of GitClient. */ - private git = GitClient.getInstance(); + private git = GitClient.get(); /** The RenderContext to be used during rendering. */ private renderContext: RenderContext|undefined; /** The title to use for the release. */ diff --git a/dev-infra/release/publish/actions.ts b/dev-infra/release/publish/actions.ts index 121b64493d..2d94339a25 100644 --- a/dev-infra/release/publish/actions.ts +++ b/dev-infra/release/publish/actions.ts @@ -12,8 +12,8 @@ import {join} from 'path'; import * as semver from 'semver'; import {debug, error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {getListCommitsInBranchUrl, getRepositoryGitUrl} from '../../utils/git/github-urls'; -import {GitClient} from '../../utils/git/index'; import {BuiltPackage, ReleaseConfig} from '../config/index'; import {ReleaseNotes} from '../notes/release-notes'; import {NpmDistTag} from '../versioning'; @@ -50,7 +50,7 @@ export interface ReleaseActionConstructor; /** Constructs a release action. */ - new(...args: [ActiveReleaseTrains, GitClient, ReleaseConfig, string]): T; + new(...args: [ActiveReleaseTrains, AuthenticatedGitClient, ReleaseConfig, string]): T; } /** @@ -77,7 +77,7 @@ export abstract class ReleaseAction { private _cachedForkRepo: GithubRepo|null = null; constructor( - protected active: ActiveReleaseTrains, protected git: GitClient, + protected active: ActiveReleaseTrains, protected git: AuthenticatedGitClient, protected config: ReleaseConfig, protected projectDir: string) {} /** Updates the version in the project top-level `package.json` file. */ diff --git a/dev-infra/release/publish/cli.ts b/dev-infra/release/publish/cli.ts index 3d1af58459..5a6ec8363d 100644 --- a/dev-infra/release/publish/cli.ts +++ b/dev-infra/release/publish/cli.ts @@ -10,8 +10,8 @@ import {Arguments, Argv, CommandModule} from 'yargs'; import {getConfig} from '../../utils/config'; import {error, green, info, red, yellow} from '../../utils/console'; +import {GitClient} from '../../utils/git/git-client'; import {addGithubTokenOption} from '../../utils/git/github-yargs'; -import {GitClient} from '../../utils/git/index'; import {getReleaseConfig} from '../config/index'; import {CompletionState, ReleaseTool} from './index'; @@ -28,7 +28,7 @@ function builder(argv: Argv): Argv { /** Yargs command handler for staging a release. */ async function handler() { - const git = GitClient.getInstance(); + const git = GitClient.get(); const config = getConfig(); const releaseConfig = getReleaseConfig(config); const projectDir = git.baseDir; diff --git a/dev-infra/release/publish/index.ts b/dev-infra/release/publish/index.ts index e4ad00de2e..e3bc250a28 100644 --- a/dev-infra/release/publish/index.ts +++ b/dev-infra/release/publish/index.ts @@ -11,7 +11,7 @@ import {ListChoiceOptions, prompt} from 'inquirer'; import {spawnWithDebugOutput} from '../../utils/child-process'; import {GithubConfig} from '../../utils/config'; import {debug, error, info, log, promptConfirm, red, yellow} from '../../utils/console'; -import {GitClient} from '../../utils/git/index'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client'; import {ReleaseConfig} from '../config/index'; import {ActiveReleaseTrains, fetchActiveReleaseTrains, nextBranchName} from '../versioning/active-release-trains'; import {npmIsLoggedIn, npmLogin, npmLogout} from '../versioning/npm-publish'; @@ -29,8 +29,8 @@ export enum CompletionState { } export class ReleaseTool { - /** The singleton instance of the GitClient. */ - private _git = GitClient.getAuthenticatedInstance(); + /** The singleton instance of the authenticated git client. */ + private _git = AuthenticatedGitClient.get(); /** The previous git commit to return back to after the release tool runs. */ private previousGitBranchOrRevision = this._git.getCurrentBranchOrRevision(); diff --git a/dev-infra/release/publish/pull-request-state.ts b/dev-infra/release/publish/pull-request-state.ts index 38cb227302..85f423124d 100644 --- a/dev-infra/release/publish/pull-request-state.ts +++ b/dev-infra/release/publish/pull-request-state.ts @@ -7,7 +7,7 @@ */ import {Octokit} from '@octokit/rest'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; /** Thirty seconds in milliseconds. */ const THIRTY_SECONDS_IN_MS = 30000; @@ -16,8 +16,7 @@ const THIRTY_SECONDS_IN_MS = 30000; export type PullRequestState = 'merged'|'closed'|'open'; /** Gets whether a given pull request has been merged. */ -export async function getPullRequestState( - api: GitClient, id: number): Promise { +export async function getPullRequestState(api: GitClient, id: number): Promise { const {data} = await api.github.pulls.get({...api.remoteParams, pull_number: id}); if (data.merged) { return 'merged'; @@ -39,7 +38,7 @@ export async function getPullRequestState( * the merge is not fast-forward, Github does not consider the PR as merged and instead * shows the PR as closed. See for example: https://github.com/angular/angular/pull/37918. */ -async function isPullRequestClosedWithAssociatedCommit(api: GitClient, id: number) { +async function isPullRequestClosedWithAssociatedCommit(api: GitClient, id: number) { const request = api.github.issues.listEvents.endpoint.merge({...api.remoteParams, issue_number: id}); const events: Octokit.IssuesListEventsResponse = await api.github.paginate(request); @@ -73,7 +72,7 @@ async function isPullRequestClosedWithAssociatedCommit(api: GitClient, } /** Checks whether the specified commit is closing the given pull request. */ -async function isCommitClosingPullRequest(api: GitClient, sha: string, id: number) { +async function isCommitClosingPullRequest(api: GitClient, sha: string, id: number) { const {data} = await api.github.repos.getCommit({...api.remoteParams, ref: sha}); // Matches the closing keyword supported in commit messages. See: // https://docs.github.com/en/enterprise/2.16/user/github/managing-your-work-on-github/closing-issues-using-keywords. diff --git a/dev-infra/release/publish/test/test-utils.ts b/dev-infra/release/publish/test/test-utils.ts index a2aa6f51a9..43a65eb232 100644 --- a/dev-infra/release/publish/test/test-utils.ts +++ b/dev-infra/release/publish/test/test-utils.ts @@ -34,7 +34,7 @@ export const testTmpDir: string = process.env['TEST_TMPDIR']!; /** Interface describing a test release action. */ export interface TestReleaseAction { instance: T; - gitClient: VirtualGitClient; + gitClient: VirtualGitClient; repo: GithubTestingRepo; fork: GithubTestingRepo; testTmpDir: string; @@ -45,7 +45,7 @@ export interface TestReleaseAction { /** Gets necessary test mocks for running a release action. */ export function getTestingMocksForReleaseAction() { const githubConfig = {owner: 'angular', name: 'dev-infra-test'}; - const gitClient = VirtualGitClient.getAuthenticatedInstance({github: githubConfig}); + const gitClient = VirtualGitClient.createInstance({github: githubConfig}); const releaseConfig: ReleaseConfig = { npmPackages: [ '@angular/pkg1', diff --git a/dev-infra/release/stamping/env-stamp.ts b/dev-infra/release/stamping/env-stamp.ts index 695d395711..45d5221275 100644 --- a/dev-infra/release/stamping/env-stamp.ts +++ b/dev-infra/release/stamping/env-stamp.ts @@ -7,7 +7,7 @@ */ import {join} from 'path'; -import {GitClient} from '../../utils/git/index'; +import {GitClient} from '../../utils/git/git-client'; import {exec as _exec} from '../../utils/shelljs'; @@ -51,7 +51,7 @@ function hasLocalChanges() { */ function getSCMVersion(mode: EnvStampMode) { if (mode === 'release') { - const git = GitClient.getInstance(); + const git = GitClient.get(); const packageJsonPath = join(git.baseDir, 'package.json'); const {version} = require(packageJsonPath); return version; diff --git a/dev-infra/utils/config.ts b/dev-infra/utils/config.ts index c0287de5b4..ca9b25e14f 100644 --- a/dev-infra/utils/config.ts +++ b/dev-infra/utils/config.ts @@ -10,7 +10,7 @@ import {existsSync} from 'fs'; import {dirname, join} from 'path'; import {debug, error} from './console'; -import {GitClient} from './git/index'; +import {GitClient} from './git/git-client'; import {isTsNodeAvailable} from './ts-node'; /** Configuration for Git client interactions. */ @@ -69,7 +69,7 @@ export function getConfig(baseDir?: string): NgDevConfig; export function getConfig(baseDir?: string): NgDevConfig { // If the global config is not defined, load it from the file system. if (cachedConfig === null) { - baseDir = baseDir || GitClient.getInstance().baseDir; + baseDir = baseDir || GitClient.get().baseDir; // The full path to the configuration file. const configPath = join(baseDir, CONFIG_FILE_PATH); // Read the configuration and validate it before caching it for the future. @@ -154,7 +154,7 @@ export function assertNoErrors(errors: string[]) { export function getUserConfig() { // If the global config is not defined, load it from the file system. if (userConfig === null) { - const git = GitClient.getInstance(); + const git = GitClient.get(); // The full path to the configuration file. const configPath = join(git.baseDir, USER_CONFIG_FILE_PATH); // Set the global config object. diff --git a/dev-infra/utils/console.ts b/dev-infra/utils/console.ts index b6d9035178..b24f722f75 100644 --- a/dev-infra/utils/console.ts +++ b/dev-infra/utils/console.ts @@ -12,7 +12,7 @@ import {prompt} from 'inquirer'; import {join} from 'path'; import {Arguments} from 'yargs'; -import {GitClient} from './git/index'; +import {GitClient} from './git/git-client'; /** Reexport of chalk colors for convenient access. */ export const red = chalk.red; @@ -144,7 +144,7 @@ export function captureLogOutputForCommand(argv: Arguments) { throw Error('`captureLogOutputForCommand` cannot be called multiple times'); } - const git = GitClient.getInstance(); + const git = GitClient.get(); /** The date time used for timestamping when the command was invoked. */ const now = new Date(); /** Header line to separate command runs in log files. */ diff --git a/dev-infra/utils/git/authenticated-git-client.ts b/dev-infra/utils/git/authenticated-git-client.ts new file mode 100644 index 0000000000..f9bcfbe688 --- /dev/null +++ b/dev-infra/utils/git/authenticated-git-client.ts @@ -0,0 +1,127 @@ +/** + * @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 {Octokit} from '@octokit/rest'; + +import {NgDevConfig} from '../config'; +import {yellow} from '../console'; + +import {GitClient} from './git-client'; +import {AuthenticatedGithubClient} 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|undefined}; +}; + +/** Describes a function that can be used to test for given Github OAuth scopes. */ +export type OAuthScopeTestFunction = (scopes: string[], missing: string[]) => void; + +/** + * Extension of the `GitClient` with additional utilities which are useful for + * authenticated Git client instances. + */ +export class AuthenticatedGitClient extends GitClient { + /** + * Regular expression that matches the provided Github token. Used for + * sanitizing the token from Git child process output. + */ + private readonly _githubTokenRegex: RegExp = new RegExp(this.githubToken, 'g'); + + /** The OAuth scopes available for the provided Github token. */ + private _cachedOauthScopes: Promise|null = null; + + /** Instance of an authenticated github client. */ + readonly github = new AuthenticatedGithubClient(this.githubToken); + + protected constructor(readonly githubToken: string, baseDir?: string, config?: NgDevConfig) { + super(baseDir, config); + } + + /** Sanitizes a given message by omitting the provided Github token if present. */ + sanitizeConsoleOutput(value: string): string { + return value.replace(this._githubTokenRegex, ''); + } + + /** Git URL that resolves to the configured repository. */ + getRepoGitUrl() { + return getRepositoryGitUrl(this.remoteConfig, this.githubToken); + } + + /** + * 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._fetchAuthScopesForToken(); + 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; + } + + // Pre-constructed 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}; + } + + /** Fetch the OAuth scopes for the loaded Github token. */ + private _fetchAuthScopesForToken() { + // 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 = response.headers['x-oauth-scopes']; + + // If no token is provided, or if the Github client is authenticated incorrectly, + // the `x-oauth-scopes` response header is not set. We error in such cases as it + // signifies a faulty of the + if (scopes === undefined) { + throw Error('Unable to retrieve OAuth scopes for token provided to Git client.'); + } + + return scopes.split(',').map(scope => scope.trim()).filter(scope => scope !== ''); + }); + } + + /** The singleton instance of the `AuthenticatedGitClient`. */ + private static _authenticatedInstance: AuthenticatedGitClient; + + /** + * Static method to get the singleton instance of the `AuthenticatedGitClient`, + * creating it if it has not yet been created. + */ + static get(): AuthenticatedGitClient { + if (!AuthenticatedGitClient._authenticatedInstance) { + throw new Error('No instance of `AuthenticatedGitClient` has been set up yet.'); + } + return AuthenticatedGitClient._authenticatedInstance; + } + + /** Configures an authenticated git client. */ + static configure(token: string): void { + if (AuthenticatedGitClient._authenticatedInstance) { + throw Error( + 'Unable to configure `AuthenticatedGitClient` as it has been configured already.'); + } + AuthenticatedGitClient._authenticatedInstance = new AuthenticatedGitClient(token); + } +} diff --git a/dev-infra/utils/git/git-client.ts b/dev-infra/utils/git/git-client.ts new file mode 100644 index 0000000000..2faef84805 --- /dev/null +++ b/dev-infra/utils/git/git-client.ts @@ -0,0 +1,239 @@ +/** + * @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 {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; +import {Options as SemVerOptions, parse, SemVer} from 'semver'; + +import {spawnWithDebugOutput} from '../child-process'; +import {getConfig, GithubConfig, NgDevConfig} from '../config'; +import {debug, info} from '../console'; +import {DryRunError, isDryRun} from '../dry-run'; + +import {GithubClient} from './github'; +import {getRepositoryGitUrl} from './github-urls'; + +/** 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.sanitizeConsoleOutput(args.join(' '))}`); + } +} + +/** The options available for the `GitClient``run` and `runGraceful` methods. */ +type GitCommandRunOptions = SpawnSyncOptions&{ + verboseLogging?: boolean; +}; + +/** Class that can be used to perform Git interactions with a given remote. **/ +export class GitClient { + /** Short-hand for accessing the default remote configuration. */ + readonly remoteConfig: GithubConfig = this.config.github; + + /** Octokit request parameters object for targeting the configured remote. */ + readonly remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name}; + + /** Instance of the Github client. */ + readonly github = new GithubClient(); + + constructor( + /** The full path to the root of the repository base. */ + readonly baseDir = determineRepoBaseDirFromCwd(), + /** The configuration, containing the github specific configuration. */ + readonly config = getConfig(baseDir)) {} + + /** Executes the given git command. Throws if the command fails. */ + run(args: string[], options?: GitCommandRunOptions): 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: GitCommandRunOptions = {}): 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 at the DEBUG level to better understand the git actions occurring. Verbose logging, + // always logging at the INFO level, can be enabled either by setting the verboseLogging + // property on the GitClient class or the options object provided to the method. + const printFn = (GitClient.verboseLogging || options.verboseLogging) ? info : debug; + // Note that we sanitize the command before printing it to the console. We do not want to + // print an access 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.sanitizeConsoleOutput(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.sanitizeConsoleOutput(result.stderr)); + } + + return result; + } + + /** Git URL that resolves to the configured repository. */ + getRepoGitUrl() { + return getRepositoryGitUrl(this.remoteConfig); + } + + /** 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; + } + + /** + * 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); + } + + /** Retrieve a list of all files in the repository 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 repository. */ + allFiles(): string[] { + return gitOutputAsArray(this.runGraceful(['ls-files'])); + } + + /** + * Sanitizes the given console message. This method can be overridden by + * derived classes. e.g. to sanitize access tokens from Git commands. + */ + sanitizeConsoleOutput(value: string) { + return value; + } + + /** Whether verbose logging of Git actions should be used. */ + private static verboseLogging = false; + + /** The singleton instance of the unauthenticated `GitClient`. */ + private static _unauthenticatedInstance: GitClient; + + /** Set the verbose logging state of all git client instances. */ + static setVerboseLoggingState(verbose: boolean) { + GitClient.verboseLogging = verbose; + } + + /** + * Static method to get the singleton instance of the `GitClient`, creating it + * if it has not yet been created. + */ + static get(): GitClient { + if (!this._unauthenticatedInstance) { + GitClient._unauthenticatedInstance = new GitClient(); + } + return GitClient._unauthenticatedInstance; + } +} + +/** + * Takes the output from `run` and `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); +} + +/** Determines the repository base directory from the current working directory. */ +function determineRepoBaseDirFromCwd() { + // TODO(devversion): Replace with common spawn sync utility once available. + const {stdout, stderr, status} = spawnSync( + 'git', ['rev-parse --show-toplevel'], {shell: true, stdio: 'pipe', encoding: 'utf8'}); + 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` + + `${stderr}`); + } + return stdout.trim(); +} diff --git a/dev-infra/utils/git/github-urls.ts b/dev-infra/utils/git/github-urls.ts index d6fc7bcd4f..bce1e08fba 100644 --- a/dev-infra/utils/git/github-urls.ts +++ b/dev-infra/utils/git/github-urls.ts @@ -8,8 +8,9 @@ import {URL} from 'url'; + import {GithubConfig} from '../config'; -import {GitClient} from './index'; +import {GitClient} from './git-client'; /** URL to the Github page where personal access tokens can be managed. */ export const GITHUB_TOKEN_SETTINGS_URL = 'https://github.com/settings/tokens'; @@ -37,6 +38,6 @@ export function getRepositoryGitUrl(config: GithubConfig, githubToken?: string): } /** Gets a Github URL that refers to a list of recent commits within a specified branch. */ -export function getListCommitsInBranchUrl({remoteParams}: GitClient, branchName: string) { +export function getListCommitsInBranchUrl({remoteParams}: GitClient, branchName: string) { return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`; } diff --git a/dev-infra/utils/git/github-yargs.ts b/dev-infra/utils/git/github-yargs.ts index 751d35f5db..606d66bdd0 100644 --- a/dev-infra/utils/git/github-yargs.ts +++ b/dev-infra/utils/git/github-yargs.ts @@ -10,8 +10,8 @@ import {Argv} from 'yargs'; import {error, red, yellow} from '../console'; +import {AuthenticatedGitClient} from './authenticated-git-client'; import {GITHUB_TOKEN_GENERATE_URL} from './github-urls'; -import {GitClient} from './index'; export type ArgvWithGithubToken = Argv<{githubToken: string}>; @@ -19,7 +19,7 @@ export type ArgvWithGithubToken = Argv<{githubToken: string}>; export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken { return yargs // 'github-token' is casted to 'githubToken' to properly set up typings to reflect the key in - // the Argv object being camelCase rather than kebob case due to the `camel-case-expansion` + // the Argv object being camelCase rather than kebab case due to the `camel-case-expansion` // config: https://github.com/yargs/yargs-parser#camel-case-expansion .option('github-token' as 'githubToken', { type: 'string', @@ -33,9 +33,9 @@ export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken { process.exit(1); } try { - GitClient.getAuthenticatedInstance(); + AuthenticatedGitClient.get(); } catch { - GitClient.authenticateWithToken(githubToken); + AuthenticatedGitClient.configure(githubToken); } return githubToken; }, diff --git a/dev-infra/utils/git/github.ts b/dev-infra/utils/git/github.ts index 7b26a26406..fdbc4351be 100644 --- a/dev-infra/utils/git/github.ts +++ b/dev-infra/utils/git/github.ts @@ -32,46 +32,36 @@ export class GithubApiRequestError extends Error { } } -/** Error for failed Github API requests. */ -export class GithubGraphqlClientError extends Error {} +/** A Github client for interacting with the Github APIs. */ +export class GithubClient { + /** The octokit instance actually performing API requests. */ + private _octokit = new Octokit(this._octokitOptions); + + readonly pulls = this._octokit.pulls; + readonly repos = this._octokit.repos; + readonly issues = this._octokit.issues; + readonly git = this._octokit.git; + readonly paginate = this._octokit.paginate; + readonly rateLimit = this._octokit.rateLimit; + + constructor(private _octokitOptions?: Octokit.Options) {} +} /** - * A Github client for interacting with the Github APIs. - * - * Additionally, provides convenience methods for actions which require multiple requests, or - * would provide value from memoized style responses. - **/ -export class GithubClient { + * Extension of the `GithubClient` that provides utilities which are specific + * to authenticated instances. + */ +export class AuthenticatedGithubClient extends GithubClient { /** The graphql instance with authentication set during construction. */ - private _graphql = graphql.defaults({headers: {authorization: `token ${this.token}`}}); - /** The Octokit instance actually performing API requests. */ - private _octokit = new Octokit({auth: this.token}); + private _graphql = graphql.defaults({headers: {authorization: `token ${this._token}`}}); - /** - * @param token The github authentication token for Github Rest and Graphql API requests. - */ - constructor(private token?: string) { - this._octokit.hook.error('request', error => { - // Wrap API errors in a known error class. This allows us to - // expect Github API errors better and in a non-ambiguous way. - throw new GithubApiRequestError(error.status, error.message); - }); + constructor(private _token: string) { + // Set the token for the octokit instance. + super({auth: _token}); } /** Perform a query using Github's Graphql API. */ async graphql(queryObject: T, params: RequestParameters = {}) { - if (this.token === undefined) { - throw new GithubGraphqlClientError( - 'Cannot query via graphql without an authentication token set, use the authenticated ' + - '`GitClient` by calling `GitClient.getAuthenticatedInstance()`.'); - } return (await this._graphql(query(queryObject).toString(), params)) as T; } - - pulls = this._octokit.pulls; - repos = this._octokit.repos; - issues = this._octokit.issues; - git = this._octokit.git; - paginate = this._octokit.paginate; - rateLimit = this._octokit.rateLimit; } diff --git a/dev-infra/utils/git/index.ts b/dev-infra/utils/git/index.ts deleted file mode 100644 index c428da2c5f..0000000000 --- a/dev-infra/utils/git/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * @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 {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(' '))}`); - } -} - -/** The options available for `GitClient`'s `run` and `runGraceful` methods. */ -type GitClientRunOptions = SpawnSyncOptions&{ - verboseLogging?: boolean; -}; - -/** - * 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); - } - - /** Set the verbose logging state of the GitClient class. */ - static setVerboseLoggingState(verbose: boolean) { - this.verboseLogging = verbose; - } - - /** Whether verbose logging of Git actions should be used. */ - private static verboseLogging = false; - /** The configuration, containing the github specific configuration. */ - private config: NgDevConfig; - /** 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'); - } - } - - /** Executes the given git command. Throws if the command fails. */ - run(args: string[], options?: GitClientRunOptions): 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: GitClientRunOptions = {}): 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 at the DEBUG level to better understand the git actions occuring. Verbose logging, - // always logging at the INFO level, can be enabled either by setting the verboseLogging - // property on the GitClient class or the options object provided to the method. - const printFn = (GitClient.verboseLogging || options.verboseLogging) ? info : debug; - // 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; - } - - /** 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); - } - - /** 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() { - 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}`); - } - 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); -} diff --git a/dev-infra/utils/github.ts b/dev-infra/utils/github.ts index 62e3580f46..9df744378e 100644 --- a/dev-infra/utils/github.ts +++ b/dev-infra/utils/github.ts @@ -7,11 +7,12 @@ */ import {params, types} from 'typed-graphqlify'; +import {AuthenticatedGitClient} from './git/authenticated-git-client'; -import {GitClient} from './git/index'; /** Get a PR from github */ -export async function getPr(prSchema: PrSchema, prNumber: number, git: GitClient) { +export async function getPr( + prSchema: PrSchema, prNumber: number, git: AuthenticatedGitClient) { /** The owner and name of the repository */ const {owner, name} = git.remoteConfig; /** The Graphql query object to get a the PR */ @@ -32,7 +33,7 @@ export async function getPr(prSchema: PrSchema, prNumber: number, git: } /** Get all pending PRs from github */ -export async function getPendingPrs(prSchema: PrSchema, git: GitClient) { +export async function getPendingPrs(prSchema: PrSchema, git: AuthenticatedGitClient) { /** The owner and name of the repository */ const {owner, name} = git.remoteConfig; /** The Graphql query object to get a page of pending PRs */ diff --git a/dev-infra/utils/testing/virtual-git-client.ts b/dev-infra/utils/testing/virtual-git-client.ts index 37d59b446a..363a53bee9 100644 --- a/dev-infra/utils/testing/virtual-git-client.ts +++ b/dev-infra/utils/testing/virtual-git-client.ts @@ -11,7 +11,8 @@ import * as parseArgs from 'minimist'; import {SemVer} from 'semver'; import {NgDevConfig} from '../config'; -import {GitClient} from '../git/index'; +import {AuthenticatedGitClient} from '../git/authenticated-git-client'; +import {GitClient} from '../git/git-client'; /** * Temporary directory which will be used as project directory in tests. Note that @@ -59,19 +60,9 @@ export interface Commit { * Virtual git client that mocks Git commands and keeps track of the repository state * in memory. This allows for convenient test assertions with Git interactions. */ -export class VirtualGitClient extends GitClient { - static getInstance(config = mockNgDevConfig, tmpDir = testTmpDir): VirtualGitClient { - return new VirtualGitClient(undefined, config, tmpDir); - } - - static getAuthenticatedInstance(config = mockNgDevConfig, tmpDir = testTmpDir): - VirtualGitClient { - return new VirtualGitClient('abc123', config, tmpDir); - } - - private constructor(token: Authenticated extends true? string: undefined, config: NgDevConfig, - tmpDir: string) { - super(token, config, tmpDir); +export class VirtualGitClient extends AuthenticatedGitClient { + static createInstance(config = mockNgDevConfig, tmpDir = testTmpDir): VirtualGitClient { + return new VirtualGitClient('abc123', tmpDir, config); } /** Current Git HEAD that has been previously fetched. */ @@ -83,7 +74,6 @@ export class VirtualGitClient extends GitClient extends GitClient