-
Notifications
You must be signed in to change notification settings - Fork 24.8k
/
index.ts
350 lines (311 loc) Β· 14.4 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {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;
}
/** Whether the repo has any local changes. */
hasLocalChanges(): 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);
}