refactor(dev-infra): use a singleton for GitClient (#41515)

Creates a singleton class for GitClient rather than relying on creating an instance to
require being passed around throughout its usages.

PR Close #41515
This commit is contained in:
Joey Perrott
2021-04-08 12:34:55 -07:00
committed by Zach Arend
parent c20db69f9f
commit 9bf8e5164d
32 changed files with 439 additions and 323 deletions
+1 -1
View File
@@ -37,6 +37,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<boolean>, branchName: string) {
return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`;
}
+4
View File
@@ -7,8 +7,11 @@
*/
import {Argv} from 'yargs';
import {error, red, yellow} from '../console';
import {GITHUB_TOKEN_GENERATE_URL} from './github-urls';
import {GitClient} from './index';
export type ArgvWithGithubToken = Argv<{githubToken: string}>;
@@ -29,6 +32,7 @@ export function addGithubTokenOption(yargs: Argv): ArgvWithGithubToken {
error(yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`));
process.exit(1);
}
GitClient.authenticateWithToken(githubToken);
return githubToken;
},
})
+32 -33
View File
@@ -11,6 +11,12 @@ import * as Octokit from '@octokit/rest';
import {RequestParameters} from '@octokit/types';
import {query, types} from 'typed-graphqlify';
/**
* An object representation of a Graphql Query to be used as a response type and
* to generate a Graphql query string.
*/
export type GraphqlQueryObject = Parameters<typeof query>[1];
/** Interface describing a Github repository. */
export interface GithubRepo {
/** Owner login of the repository. */
@@ -26,6 +32,9 @@ 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.
*
@@ -33,13 +42,15 @@ export class GithubApiRequestError extends Error {
* would provide value from memoized style responses.
**/
export class GithubClient extends Octokit {
/** The Github GraphQL (v4) API. */
graphql: GithubGraphqlClient;
/** The current user based on checking against the Github API. */
private _currentUser: string|null = null;
/** The graphql instance with authentication set during construction. */
private _graphql = graphql.defaults({headers: {authorization: `token ${this.token}`}});
constructor(token?: string) {
/**
* @param token The github authentication token for Github Rest and Graphql API requests.
*/
constructor(private token?: string) {
// Pass in authentication token to base Octokit class.
super({auth: token});
@@ -49,8 +60,22 @@ export class GithubClient extends Octokit {
throw new GithubApiRequestError(error.status, error.message);
});
// Create authenticated graphql client.
this.graphql = new GithubGraphqlClient(token);
// Note: The prototype must be set explictly as Github's Octokit class is a non-standard class
// definition which adjusts the prototype chain.
// See:
// https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-extending-built-ins-like-error-array-and-map-work
// https://github.com/octokit/rest.js/blob/7b51cee4a22b6e52adcdca011f93efdffa5df998/lib/constructor.js
Object.setPrototypeOf(this, GithubClient.prototype);
}
/** Perform a query using Github's Graphql API. */
async graphql<T extends GraphqlQueryObject>(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), params)) as T;
}
/** Retrieve the login of the current user from Github. */
@@ -59,7 +84,7 @@ export class GithubClient extends Octokit {
if (this._currentUser !== null) {
return this._currentUser;
}
const result = await this.graphql.query({
const result = await this.graphql({
viewer: {
login: types.string,
}
@@ -67,29 +92,3 @@ export class GithubClient extends Octokit {
return this._currentUser = result.viewer.login;
}
}
/**
* An object representation of a GraphQL Query to be used as a response type and
* to generate a GraphQL query string.
*/
export type GraphQLQueryObject = Parameters<typeof query>[1];
/** A client for interacting with Github's GraphQL API. */
export class GithubGraphqlClient {
/** The Github GraphQL (v4) API. */
private graqhql = graphql;
constructor(token?: string) {
// Set the default headers to include authorization with the provided token for all
// graphQL calls.
if (token) {
this.graqhql = this.graqhql.defaults({headers: {authorization: `token ${token}`}});
}
}
/** Perform a query using Github's GraphQL API. */
async query<T extends GraphQLQueryObject>(queryObject: T, params: RequestParameters = {}) {
const queryString = query(queryObject);
return (await this.graqhql(queryString, params)) as T;
}
}
+72 -18
View File
@@ -10,7 +10,7 @@ import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {Options as SemVerOptions, parse, SemVer} from 'semver';
import {getConfig, getRepoBaseDir, NgDevConfig} from '../config';
import {getConfig, getRepoBaseDir} from '../config';
import {debug, info, yellow} from '../console';
import {DryRunError, isDryRun} from '../dry-run';
import {GithubClient} from './github';
@@ -26,7 +26,7 @@ export type OAuthScopeTestFunction = (scopes: string[], missing: string[]) => vo
/** Error for failed Git commands. */
export class GitCommandError extends Error {
constructor(client: GitClient, public args: string[]) {
constructor(client: GitClient<boolean>, 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.
@@ -43,18 +43,49 @@ export class GitCommandError extends Error {
* `config`: The dev-infra configuration containing information about the remote. By default
* the dev-infra configuration is loaded with its Github configuration.
**/
export class GitClient {
/** Whether verbose logging of Git actions should be used. */
static LOG_COMMANDS = true;
/** Short-hand for accessing the default remote configuration. */
remoteConfig = this._config.github;
/** Octokit request parameters object for targeting the configured remote. */
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
/** Git URL that resolves to the configured repository. */
repoGitUrl = getRepositoryGitUrl(this.remoteConfig, this.githubToken);
/** Instance of the authenticated Github octokit API. */
github = new GithubClient(this.githubToken);
export class GitClient<Authenticated extends boolean> {
/*************************************************
* Singleton definition and configuration. *
*************************************************/
/** The singleton instance of the authenticated GitClient. */
private static authenticated: GitClient<true>;
/** The singleton instance of the unauthenticated GitClient. */
private static unauthenticated: GitClient<false>;
/**
* 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);
}
/** Whether verbose logging of Git actions should be used. */
private verboseLogging = true;
/** The OAuth scopes available for the provided Github token. */
private _cachedOauthScopes: Promise<string[]>|null = null;
/**
@@ -62,18 +93,36 @@ export class GitClient {
* sanitizing the token from Git child process output.
*/
private _githubTokenRegex: RegExp|null = null;
/** Short-hand for accessing the default remote configuration. */
remoteConfig = this._config.github;
/** Octokit request parameters object for targeting the configured remote. */
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
/** Instance of the authenticated Github octokit API. */
github = new GithubClient(this.githubToken);
constructor(
public githubToken?: string, private _config: Pick<NgDevConfig, 'github'> = getConfig(),
private _projectRoot = getRepoBaseDir()) {
/**
* @param githubToken The github token used for authentication, if provided.
* @param _config The configuration, containing the github specific configuration.
* @param _projectRoot The full path to the root of the repository base.
*/
protected constructor(public githubToken:
Authenticated extends true? string: undefined,
private _config = getConfig(),
private _projectRoot = getRepoBaseDir()) {
// If a token has been specified (and is not empty), pass it to the Octokit API and
// also create a regular expression that can be used for sanitizing Git command output
// so that it does not print the token accidentally.
if (githubToken != null) {
if (typeof githubToken === 'string') {
this._githubTokenRegex = new RegExp(githubToken, 'g');
}
}
/** Set the verbose logging state of the GitClient instance. */
setVerboseLoggingState(verbose: boolean): this {
this.verboseLogging = verbose;
return this;
}
/** Executes the given git command. Throws if the command fails. */
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
const result = this.runGraceful(args, options);
@@ -102,7 +151,7 @@ export class GitClient {
// To improve the debugging experience in case something fails, we print all executed Git
// commands to better understand the git actions occuring. Depending on the command being
// executed, this debugging information should be logged at different logging levels.
const printFn = (!GitClient.LOG_COMMANDS || options.stdio === 'ignore') ? debug : info;
const printFn = (!this.verboseLogging || options.stdio === 'ignore') ? debug : info;
// Note that we do not want to print the token if it is contained in the command. It's common
// to share errors with others if the tool failed, and we do not want to leak tokens.
printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
@@ -126,6 +175,11 @@ export class GitClient {
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 !== '';