refactor(dev-infra): move GitClient to common util (#37318)

Moves GitClient from merge script into common utils for unified
method of performing git actions throughout the ng-dev toolset.

PR Close #37318
This commit is contained in:
Joey Perrott 2020-05-27 15:39:31 -07:00 committed by atscott
parent 818d93d7e9
commit 85b6c94cc6
12 changed files with 209 additions and 214 deletions

View File

@ -11,7 +11,7 @@ import {types as graphQLTypes} from 'typed-graphqlify';
import {getConfig, NgDevConfig} from '../../utils/config'; import {getConfig, NgDevConfig} from '../../utils/config';
import {error, info} from '../../utils/console'; import {error, info} from '../../utils/console';
import {getCurrentBranch, hasLocalChanges} from '../../utils/git'; import {GitClient} from '../../utils/git';
import {getPendingPrs} from '../../utils/github'; import {getPendingPrs} from '../../utils/github';
import {exec} from '../../utils/shelljs'; import {exec} from '../../utils/shelljs';
@ -55,15 +55,16 @@ const tempWorkingBranch = '__NgDevRepoBaseAfterChange__';
/** Checks if the provided PR will cause new conflicts in other pending PRs. */ /** Checks if the provided PR will cause new conflicts in other pending PRs. */
export async function discoverNewConflictsForPr( export async function discoverNewConflictsForPr(
newPrNumber: number, updatedAfter: number, config: Pick<NgDevConfig, 'github'> = getConfig()) { newPrNumber: number, updatedAfter: number, config: Pick<NgDevConfig, 'github'> = getConfig()) {
const git = new GitClient();
// If there are any local changes in the current repository state, the // If there are any local changes in the current repository state, the
// check cannot run as it needs to move between branches. // check cannot run as it needs to move between branches.
if (hasLocalChanges()) { if (git.hasLocalChanges()) {
error('Cannot run with local changes. Please make sure there are no local changes.'); error('Cannot run with local changes. Please make sure there are no local changes.');
process.exit(1); process.exit(1);
} }
/** The active github branch when the run began. */ /** The active github branch when the run began. */
const originalBranch = getCurrentBranch(); const originalBranch = git.getCurrentBranch();
/* Progress bar to indicate progress. */ /* Progress bar to indicate progress. */
const progressBar = new Bar({format: `[{bar}] ETA: {eta}s | {value}/{total}`}); const progressBar = new Bar({format: `[{bar}] ETA: {eta}s | {value}/{total}`});
/* PRs which were found to be conflicting. */ /* PRs which were found to be conflicting. */

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {getConfig, NgDevConfig} from '../../utils/config'; import {getConfig, GitClientConfig, NgDevConfig} from '../../utils/config';
import {GithubApiMergeStrategyConfig} from './strategies/api-merge'; import {GithubApiMergeStrategyConfig} from './strategies/api-merge';
@ -31,22 +31,12 @@ export interface TargetLabel {
branches: string[]|((githubTargetBranch: string) => string[]); branches: string[]|((githubTargetBranch: string) => string[]);
} }
/** Describes the remote used for merging pull requests. */
export interface MergeRemote {
/** Owner name of the repository. */
owner: string;
/** Name of the repository. */
name: string;
/** Whether SSH should be used for merging pull requests. */
useSsh?: boolean;
}
/** /**
* Configuration for the merge script with all remote options specified. The * Configuration for the merge script with all remote options specified. The
* default `MergeConfig` has does not require any of these options as defaults * default `MergeConfig` has does not require any of these options as defaults
* are provided by the common dev-infra github configuration. * are provided by the common dev-infra github configuration.
*/ */
export type MergeConfigWithRemote = MergeConfig&{remote: MergeRemote}; export type MergeConfigWithRemote = MergeConfig&{remote: GitClientConfig};
/** Configuration for the merge script. */ /** Configuration for the merge script. */
export interface MergeConfig { export interface MergeConfig {
@ -54,7 +44,7 @@ export interface MergeConfig {
* Configuration for the upstream remote. All of these options are optional as * Configuration for the upstream remote. All of these options are optional as
* defaults are provided by the common dev-infra github configuration. * defaults are provided by the common dev-infra github configuration.
*/ */
remote?: Partial<MergeRemote>; remote?: GitClientConfig;
/** List of target labels. */ /** List of target labels. */
labels: TargetLabel[]; labels: TargetLabel[];
/** Required base commits for given branches. */ /** Required base commits for given branches. */

View File

@ -1,177 +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 * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {info, yellow} from '../../utils/console';
import {MergeConfigWithRemote} from './config';
/** Github response type extended to include the `x-oauth-scopes` headers presence. */
type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
headers: {'x-oauth-scopes': string};
};
/** Error for failed Github API requests. */
export class GithubApiRequestError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
/** 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(' '))}`);
}
}
export class GitClient {
/** Short-hand for accessing the remote configuration. */
remoteConfig = this._config.remote;
/** Octokit request parameters object for targeting the configured remote. */
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
/** URL that resolves to the configured repository. */
repoGitUrl = this.remoteConfig.useSsh ?
`git@github.com:${this.remoteConfig.owner}/${this.remoteConfig.name}.git` :
`https://${this._githubToken}@github.com/${this.remoteConfig.owner}/${
this.remoteConfig.name}.git`;
/** Instance of the authenticated Github octokit API. */
api: Octokit;
/** The OAuth scopes available for the provided Github token. */
private _oauthScopes: Promise<string[]>|null = null;
/** Regular expression that matches the provided Github token. */
private _tokenRegex = new RegExp(this._githubToken, 'g');
constructor(
private _projectRoot: string, private _githubToken: string,
private _config: MergeConfigWithRemote) {
this.api = new Octokit({auth: _githubToken});
this.api.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);
});
}
/** Executes the given git command. Throws if the command fails. */
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
const result = this.runGraceful(args, options);
if (result.status !== 0) {
throw new GitCommandError(this, args);
}
// Omit `status` from the type so that it's obvious that the status is never
// non-zero as explained in the method description.
return result as Omit<SpawnSyncReturns<string>, 'status'>;
}
/**
* Spawns a given Git command process. Does not throw if the command fails. Additionally,
* if there is any stderr output, the output will be printed. This makes it easier to
* debug failed commands.
*/
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
// To improve the debugging experience in case something fails, we print all executed
// Git commands. Note that we do not want to print the token if is contained in the
// command. It's common to share errors with others if the tool failed.
info('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
const result = spawnSync('git', args, {
cwd: this._projectRoot,
stdio: 'pipe',
...options,
// Encoding is always `utf8` and not overridable. This ensures that this method
// always returns `string` as output instead of buffers.
encoding: 'utf8',
});
if (result.stderr !== null) {
// Git sometimes prints the command if it failed. This means that it could
// potentially leak the Github token used for accessing the remote. To avoid
// printing a token, we sanitize the string before printing the stderr output.
process.stderr.write(this.omitGithubTokenFromMessage(result.stderr));
}
return result;
}
/** Whether the given branch contains the specified SHA. */
hasCommit(branchName: string, sha: string): boolean {
return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
}
/** Gets the currently checked out branch. */
getCurrentBranch(): string {
return this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
}
/** 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 {
return value.replace(this._tokenRegex, '<TOKEN>');
}
/**
* Assert the GitClient instance is using a token with permissions for the all of the
* provided OAuth scopes.
*/
async hasOauthScopes(...requestedScopes: string[]): Promise<true|{error: string}> {
const missingScopes: string[] = [];
const scopes = await this.getAuthScopesForToken();
requestedScopes.forEach(scope => {
if (!scopes.includes(scope)) {
missingScopes.push(scope);
}
});
// If no missing scopes are found, return true to indicate all OAuth Scopes are available.
if (missingScopes.length === 0) {
return true;
}
/**
* Preconstructed error message to log to the user, providing missing scopes and
* remediation instructions.
**/
const error =
`The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
`${yellow(missingScopes.join(', '))}\n\n` +
`Update the token in use at:\n` +
` https://github.com/settings/tokens\n\n` +
`Alternatively, a new token can be created at: https://github.com/settings/tokens/new\n`;
return {error};
}
/**
* Retrieves the OAuth scopes for the loaded Github token, returning the already
* retrieved list of OAuth scopes if available.
**/
private async getAuthScopesForToken() {
// If the OAuth scopes have already been loaded, return the Promise containing them.
if (this._oauthScopes !== null) {
return this._oauthScopes;
}
// OAuth scopes are loaded via the /rate_limit endpoint to prevent
// usage of a request against that rate_limit for this lookup.
return this._oauthScopes = this.api.rateLimit.get().then(_response => {
const response = _response as RateLimitResponseWithOAuthScopeHeader;
const scopes: string = response.headers['x-oauth-scopes'] || '';
return scopes.split(',').map(scope => scope.trim());
});
}
}

View File

@ -9,9 +9,9 @@
import {getRepoBaseDir} from '../../utils/config'; import {getRepoBaseDir} from '../../utils/config';
import {error, green, info, promptConfirm, red, yellow} from '../../utils/console'; import {error, green, info, promptConfirm, red, yellow} from '../../utils/console';
import {GithubApiRequestError} from '../../utils/git';
import {loadAndValidateConfig, MergeConfigWithRemote} from './config'; import {loadAndValidateConfig, MergeConfigWithRemote} from './config';
import {GithubApiRequestError} from './git';
import {MergeResult, MergeStatus, PullRequestMergeTask} from './task'; import {MergeResult, MergeStatus, PullRequestMergeTask} from './task';
/** URL to the Github page where personal access tokens can be generated. */ /** URL to the Github page where personal access tokens can be generated. */

View File

@ -8,8 +8,9 @@
import * as Octokit from '@octokit/rest'; import * as Octokit from '@octokit/rest';
import {GitClient} from '../../utils/git';
import {PullRequestFailure} from './failures'; import {PullRequestFailure} from './failures';
import {GitClient} from './git';
import {matchesPattern} from './string-pattern'; import {matchesPattern} from './string-pattern';
import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from './target-label'; import {getBranchesFromTargetLabel, getTargetLabelFromPullRequest} from './target-label';
import {PullRequestMergeTask} from './task'; import {PullRequestMergeTask} from './task';

View File

@ -10,9 +10,9 @@ import {PullsListCommitsResponse, PullsMergeParams} from '@octokit/rest';
import {prompt} from 'inquirer'; import {prompt} from 'inquirer';
import {parseCommitMessage} from '../../../commit-message/validate'; import {parseCommitMessage} from '../../../commit-message/validate';
import {GitClient} from '../../../utils/git';
import {GithubApiMergeMethod} from '../config'; import {GithubApiMergeMethod} from '../config';
import {PullRequestFailure} from '../failures'; import {PullRequestFailure} from '../failures';
import {GitClient} from '../git';
import {PullRequest} from '../pull-request'; import {PullRequest} from '../pull-request';
import {matchesPattern} from '../string-pattern'; import {matchesPattern} from '../string-pattern';

View File

@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {GitClient} from '../../../utils/git';
import {PullRequestFailure} from '../failures'; import {PullRequestFailure} from '../failures';
import {GitClient} from '../git';
import {PullRequest} from '../pull-request'; import {PullRequest} from '../pull-request';
/** /**

View File

@ -6,9 +6,10 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {GitClient, GitCommandError} from '../../utils/git';
import {MergeConfigWithRemote} from './config'; import {MergeConfigWithRemote} from './config';
import {PullRequestFailure} from './failures'; import {PullRequestFailure} from './failures';
import {GitClient, GitCommandError} from './git';
import {isPullRequest, loadAndValidatePullRequest,} from './pull-request'; import {isPullRequest, loadAndValidatePullRequest,} from './pull-request';
import {GithubApiMergeStrategy} from './strategies/api-merge'; import {GithubApiMergeStrategy} from './strategies/api-merge';
import {AutosquashMergeStrategy} from './strategies/autosquash-merge'; import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
@ -40,7 +41,7 @@ export interface MergeResult {
*/ */
export class PullRequestMergeTask { export class PullRequestMergeTask {
/** Git client that can be used to execute Git commands. */ /** Git client that can be used to execute Git commands. */
git = new GitClient(this.projectRoot, this._githubToken, this.config); git = new GitClient(this._githubToken, {github: this.config.remote});
constructor( constructor(
public projectRoot: string, public config: MergeConfigWithRemote, public projectRoot: string, public config: MergeConfigWithRemote,

View File

@ -11,7 +11,7 @@ import {URL} from 'url';
import {getConfig, NgDevConfig} from '../../utils/config'; import {getConfig, NgDevConfig} from '../../utils/config';
import {error, info, promptConfirm} from '../../utils/console'; import {error, info, promptConfirm} from '../../utils/console';
import {getCurrentBranch, hasLocalChanges} from '../../utils/git'; import {GitClient} from '../../utils/git';
import {getPr} from '../../utils/github'; import {getPr} from '../../utils/github';
import {exec} from '../../utils/shelljs'; import {exec} from '../../utils/shelljs';
@ -42,8 +42,9 @@ const PR_SCHEMA = {
*/ */
export async function rebasePr( export async function rebasePr(
prNumber: number, githubToken: string, config: Pick<NgDevConfig, 'github'> = getConfig()) { prNumber: number, githubToken: string, config: Pick<NgDevConfig, 'github'> = getConfig()) {
const git = new GitClient();
// TODO: Rely on a common assertNoLocalChanges function. // TODO: Rely on a common assertNoLocalChanges function.
if (hasLocalChanges()) { if (git.hasLocalChanges()) {
error('Cannot perform rebase of PR with local changes.'); error('Cannot perform rebase of PR with local changes.');
process.exit(1); process.exit(1);
} }
@ -52,7 +53,7 @@ export async function rebasePr(
* The branch originally checked out before this method performs any Git * The branch originally checked out before this method performs any Git
* operations that may change the working branch. * operations that may change the working branch.
*/ */
const originalBranch = getCurrentBranch(); const originalBranch = git.getCurrentBranch();
/* Get the PR information from Github. */ /* Get the PR information from Github. */
const pr = await getPr(PR_SCHEMA, prNumber, config.github); const pr = await getPr(PR_SCHEMA, prNumber, config.github);

View File

@ -7,6 +7,7 @@ ts_library(
visibility = ["//dev-infra:__subpackages__"], visibility = ["//dev-infra:__subpackages__"],
deps = [ deps = [
"@npm//@octokit/graphql", "@npm//@octokit/graphql",
"@npm//@octokit/rest",
"@npm//@types/inquirer", "@npm//@types/inquirer",
"@npm//@types/node", "@npm//@types/node",
"@npm//@types/shelljs", "@npm//@types/shelljs",

View File

@ -13,17 +13,22 @@ import {error} from './console';
import {exec} from './shelljs'; import {exec} from './shelljs';
import {isTsNodeAvailable} from './ts-node'; import {isTsNodeAvailable} from './ts-node';
/** /** Configuration for Git client interactions. */
* Describes the Github configuration for dev-infra. This configuration is export interface GitClientConfig {
* used for API requests, determining the upstream remote, etc.
*/
export interface GithubConfig {
/** Owner name of the repository. */ /** Owner name of the repository. */
owner: string; owner: string;
/** Name of the repository. */ /** Name of the repository. */
name: string; name: string;
/** If SSH protocol should be used for git interactions. */
useSsh?: boolean;
} }
/**
* Describes the Github configuration for dev-infra. This configuration is
* used for API requests, determining the upstream remote, etc.
*/
export interface GithubConfig extends GitClientConfig {}
/** The common configuration for ng-dev. */ /** The common configuration for ng-dev. */
type CommonConfig = { type CommonConfig = {
github: GithubConfig github: GithubConfig

View File

@ -6,15 +6,187 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {exec} from './shelljs'; import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {getConfig, getRepoBaseDir, NgDevConfig} from './config';
import {info, yellow} from './console';
/** Whether the repo has any local changes. */ /** Github response type extended to include the `x-oauth-scopes` headers presence. */
export function hasLocalChanges() { type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
return !!exec(`git status --porcelain`).trim(); headers: {'x-oauth-scopes': string};
};
/** Error for failed Github API requests. */
export class GithubApiRequestError extends Error {
constructor(public status: number, message: string) {
super(message);
}
} }
/** Get the currently checked out branch. */ /** Error for failed Git commands. */
export function getCurrentBranch() { export class GitCommandError extends Error {
return exec(`git symbolic-ref --short HEAD`).trim(); constructor(client: GitClient, public args: string[]) {
// Errors are not guaranteed to be caught. To ensure that we don't
// accidentally leak the Github token that might be used in a command,
// we sanitize the command that will be part of the error message.
super(`Command failed: git ${client.omitGithubTokenFromMessage(args.join(' '))}`);
}
}
/**
* Common client for performing Git interactions.
*
* Takes in two optional arguements:
* _githubToken: the token used for authentifation in github interactions, by default empty
* allowing readonly actions.
* _config: The dev-infra configuration containing GitClientConfig information, by default
* loads the config from the default location.
**/
export class GitClient {
/** Short-hand for accessing the remote configuration. */
remoteConfig = this._config.github;
/** Octokit request parameters object for targeting the configured remote. */
remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
/** URL that resolves to the configured repository. */
repoGitUrl = this.remoteConfig.useSsh ?
`git@github.com:${this.remoteConfig.owner}/${this.remoteConfig.name}.git` :
`https://${this._githubToken}@github.com/${this.remoteConfig.owner}/${
this.remoteConfig.name}.git`;
/** Instance of the authenticated Github octokit API. */
api: Octokit;
/** The file path of project's root directory. */
private _projectRoot = getRepoBaseDir();
/** The OAuth scopes available for the provided Github token. */
private _oauthScopes = Promise.resolve<string[]>([]);
/** Regular expression that matches the provided Github token. */
private _tokenRegex = new RegExp(this._githubToken, 'g');
constructor(
private _githubToken = '', private _config: Pick<NgDevConfig, 'github'> = getConfig()) {
this.api = new Octokit({auth: _githubToken});
this.api.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);
});
}
/** Executes the given git command. Throws if the command fails. */
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
const result = this.runGraceful(args, options);
if (result.status !== 0) {
throw new GitCommandError(this, args);
}
// Omit `status` from the type so that it's obvious that the status is never
// non-zero as explained in the method description.
return result as Omit<SpawnSyncReturns<string>, 'status'>;
}
/**
* Spawns a given Git command process. Does not throw if the command fails. Additionally,
* if there is any stderr output, the output will be printed. This makes it easier to
* debug failed commands.
*/
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
// To improve the debugging experience in case something fails, we print all executed
// Git commands. Note that we do not want to print the token if is contained in the
// command. It's common to share errors with others if the tool failed.
info('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
const result = spawnSync('git', args, {
cwd: this._projectRoot,
stdio: 'pipe',
...options,
// Encoding is always `utf8` and not overridable. This ensures that this method
// always returns `string` as output instead of buffers.
encoding: 'utf8',
});
if (result.stderr !== null) {
// Git sometimes prints the command if it failed. This means that it could
// potentially leak the Github token used for accessing the remote. To avoid
// printing a token, we sanitize the string before printing the stderr output.
process.stderr.write(this.omitGithubTokenFromMessage(result.stderr));
}
return result;
}
/** Whether the given branch contains the specified SHA. */
hasCommit(branchName: string, sha: string): boolean {
return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
}
/** Gets the currently checked out branch. */
getCurrentBranch(): string {
return this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
}
/** Gets whether the current Git repository has uncommitted changes. */
hasUncommittedChanges(): boolean {
return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
}
/** Whether the repo has any local changes. */
hasLocalChanges(): boolean {
return !!this.runGraceful(['git', 'status', '--porcelain']).stdout.trim();
}
/** Sanitizes a given message by omitting the provided Github token if present. */
omitGithubTokenFromMessage(value: string): string {
return value.replace(this._tokenRegex, '<TOKEN>');
}
/**
* Assert the GitClient instance is using a token with permissions for the all of the
* provided OAuth scopes.
*/
async hasOauthScopes(...requestedScopes: string[]): Promise<true|{error: string}> {
const missingScopes: string[] = [];
const scopes = await this.getAuthScopesForToken();
requestedScopes.forEach(scope => {
if (!scopes.includes(scope)) {
missingScopes.push(scope);
}
});
// If no missing scopes are found, return true to indicate all OAuth Scopes are available.
if (missingScopes.length === 0) {
return true;
}
/**
* Preconstructed error message to log to the user, providing missing scopes and
* remediation instructions.
**/
const error =
`The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
`${yellow(missingScopes.join(', '))}\n\n` +
`Update the token in use at:\n` +
` https://github.com/settings/tokens\n\n` +
`Alternatively, a new token can be created at: https://github.com/settings/tokens/new\n`;
return {error};
}
/**
* Retrieves the OAuth scopes for the loaded Github token, returning the already
* retrieved list of OAuth scopes if available.
**/
private async getAuthScopesForToken() {
// If the OAuth scopes have already been loaded, return the Promise containing them.
if (this._oauthScopes !== null) {
return this._oauthScopes;
}
// OAuth scopes are loaded via the /rate_limit endpoint to prevent
// usage of a request against that rate_limit for this lookup.
return this._oauthScopes = this.api.rateLimit.get().then(_response => {
const response = _response as RateLimitResponseWithOAuthScopeHeader;
const scopes: string = response.headers['x-oauth-scopes'] || '';
return scopes.split(',').map(scope => scope.trim());
});
}
} }