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
3b434ed94d
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
This commit is contained in:
parent
aea56048f6
commit
67f65a9d25
|
@ -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, '<TOKEN>');
|
||||
};
|
||||
/**
|
||||
* 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 <TOKEN> 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.
|
||||
|
|
|
@ -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<Data> {
|
||||
/** 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();
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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, '<TOKEN>');
|
||||
};
|
||||
/**
|
||||
* 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 <TOKEN> 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, '<TOKEN>');
|
||||
};
|
||||
/** 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 <TOKEN> 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 <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;
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<true>, prNumber: number): Promise<RawPullRequest|null> {
|
||||
git: AuthenticatedGitClient, prNumber: number): Promise<RawPullRequest|null> {
|
||||
try {
|
||||
return await getPr(PR_SCHEMA, prNumber, git);
|
||||
} catch (e) {
|
||||
|
|
|
@ -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<true>, private _config: GithubApiMergeStrategyConfig) {
|
||||
constructor(git: AuthenticatedGitClient, private _config: GithubApiMergeStrategyConfig) {
|
||||
super(git);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<true>) {}
|
||||
constructor(protected git: AuthenticatedGitClient) {}
|
||||
|
||||
/**
|
||||
* Prepares a merge of the given pull request. The strategy by default will
|
||||
|
|
|
@ -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<true>,
|
||||
public config: MergeConfigWithRemote, public git: AuthenticatedGitClient,
|
||||
flags: Partial<PullRequestMergeTaskFlags>) {
|
||||
// Update flags property with the provided flags values as patches to the default flag values.
|
||||
this.flags = {...defaultPullRequestMergeTaskFlags, ...flags};
|
||||
|
|
|
@ -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<NgDevConfig, 'github'> = 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);
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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<ReleaseNotesOptions> {
|
|||
async function handler({releaseVersion, from, to, outFile, type}: Arguments<ReleaseNotesOptions>) {
|
||||
// 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);
|
||||
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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<T extends ReleaseAction = ReleaseActio
|
|||
/** Whether the release action is currently active. */
|
||||
isActive(active: ActiveReleaseTrains, config: ReleaseConfig): Promise<boolean>;
|
||||
/** Constructs a release action. */
|
||||
new(...args: [ActiveReleaseTrains, GitClient<true>, 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<true>,
|
||||
protected active: ActiveReleaseTrains, protected git: AuthenticatedGitClient,
|
||||
protected config: ReleaseConfig, protected projectDir: string) {}
|
||||
|
||||
/** Updates the version in the project top-level `package.json` file. */
|
||||
|
|
|
@ -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<ReleasePublishOptions> {
|
|||
|
||||
/** 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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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<boolean>, id: number): Promise<PullRequestState> {
|
||||
export async function getPullRequestState(api: GitClient, id: number): Promise<PullRequestState> {
|
||||
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<boolean>, 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<boolean>,
|
|||
}
|
||||
|
||||
/** Checks whether the specified commit is closing the given pull request. */
|
||||
async function isCommitClosingPullRequest(api: GitClient<boolean>, 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.
|
||||
|
|
|
@ -34,7 +34,7 @@ export const testTmpDir: string = process.env['TEST_TMPDIR']!;
|
|||
/** Interface describing a test release action. */
|
||||
export interface TestReleaseAction<T extends ReleaseAction = ReleaseAction> {
|
||||
instance: T;
|
||||
gitClient: VirtualGitClient<boolean>;
|
||||
gitClient: VirtualGitClient;
|
||||
repo: GithubTestingRepo;
|
||||
fork: GithubTestingRepo;
|
||||
testTmpDir: string;
|
||||
|
@ -45,7 +45,7 @@ export interface TestReleaseAction<T extends ReleaseAction = ReleaseAction> {
|
|||
/** 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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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. */
|
||||
|
|
|
@ -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<Octokit.RateLimitGetResponse>&{
|
||||
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<string[]>|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, '<TOKEN>');
|
||||
}
|
||||
|
||||
/** 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<true|{error: string}> {
|
||||
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 <TOKEN> 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);
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
* info failed commands.
|
||||
*/
|
||||
runGraceful(args: string[], options: GitCommandRunOptions = {}): SpawnSyncReturns<string> {
|
||||
/** 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>): 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();
|
||||
}
|
|
@ -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<boolean>, branchName: string) {
|
||||
export function getListCommitsInBranchUrl({remoteParams}: GitClient, branchName: string) {
|
||||
return `https://github.com/${remoteParams.owner}/${remoteParams.repo}/commits/${branchName}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
* 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. */
|
||||
export class 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});
|
||||
/** 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) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param token The github authentication token for Github Rest and Graphql API requests.
|
||||
* Extension of the `GithubClient` that provides utilities which are specific
|
||||
* to authenticated instances.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
export class AuthenticatedGithubClient extends GithubClient {
|
||||
/** The graphql instance with authentication set during construction. */
|
||||
private _graphql = graphql.defaults({headers: {authorization: `token ${this._token}`}});
|
||||
|
||||
constructor(private _token: string) {
|
||||
// Set the token for the octokit instance.
|
||||
super({auth: _token});
|
||||
}
|
||||
|
||||
/** 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).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;
|
||||
}
|
||||
|
|
|
@ -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<Octokit.RateLimitGetResponse>&{
|
||||
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<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.
|
||||
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<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);
|
||||
}
|
||||
|
||||
/** 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<string[]>|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<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
|
||||
* info failed commands.
|
||||
*/
|
||||
runGraceful(args: string[], options: GitClientRunOptions = {}): SpawnSyncReturns<string> {
|
||||
/** 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, '<TOKEN>');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<true|{error: string}> {
|
||||
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 <TOKEN> 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>): string[] {
|
||||
return gitCommandResult.stdout.split('\n').map(x => x.trim()).filter(x => !!x);
|
||||
}
|
|
@ -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: PrSchema, prNumber: number, git: GitClient<true>) {
|
||||
export async function getPr<PrSchema>(
|
||||
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: PrSchema, prNumber: number, git:
|
|||
}
|
||||
|
||||
/** Get all pending PRs from github */
|
||||
export async function getPendingPrs<PrSchema>(prSchema: PrSchema, git: GitClient<true>) {
|
||||
export async function getPendingPrs<PrSchema>(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 */
|
||||
|
|
|
@ -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<Authenticated extends boolean> extends GitClient<Authenticated> {
|
||||
static getInstance(config = mockNgDevConfig, tmpDir = testTmpDir): VirtualGitClient<false> {
|
||||
return new VirtualGitClient(undefined, config, tmpDir);
|
||||
}
|
||||
|
||||
static getAuthenticatedInstance(config = mockNgDevConfig, tmpDir = testTmpDir):
|
||||
VirtualGitClient<true> {
|
||||
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<Authenticated extends boolean> extends GitClient<A
|
|||
/** List of pushed heads to a given remote ref. */
|
||||
pushed: {remote: RemoteRef, head: GitHead}[] = [];
|
||||
|
||||
|
||||
/**
|
||||
* Override the actual GitClient getLatestSemverTag, as an actual tag cannot be retrieved in
|
||||
* testing.
|
||||
|
@ -213,11 +203,8 @@ export class VirtualGitClient<Authenticated extends boolean> extends GitClient<A
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
export function installVirtualGitClientSpies() {
|
||||
const authenticatedVirtualGitClient = VirtualGitClient.getAuthenticatedInstance();
|
||||
spyOn(GitClient, 'getAuthenticatedInstance').and.returnValue(authenticatedVirtualGitClient);
|
||||
|
||||
const unauthenticatedVirtualGitClient = VirtualGitClient.getInstance();
|
||||
spyOn(GitClient, 'getInstance').and.returnValue(unauthenticatedVirtualGitClient);
|
||||
const mockInstance = VirtualGitClient.createInstance();
|
||||
spyOn(GitClient, 'get').and.returnValue(mockInstance);
|
||||
spyOn(AuthenticatedGitClient, 'get').and.returnValue(mockInstance);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue