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:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !== '';
|
||||
|
||||
Reference in New Issue
Block a user