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:
parent
818d93d7e9
commit
85b6c94cc6
|
@ -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. */
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue