feat(dev-infra): Add oauth scope check to ensure necessary permissions for merge tooling (#37421)
Adds an assertion that the provided TOKEN has OAuth scope permissions for `repo` as this is required for all merge attempts. On failure, provides detailed error message with remediation steps for the user. PR Close #37421
This commit is contained in:
parent
57411c85b9
commit
c025357fb8
|
@ -71,9 +71,9 @@ export class PullRequestFailure {
|
|||
return new this(`Pull request could not be found upstream.`);
|
||||
}
|
||||
|
||||
static insufficientPermissionsToMerge() {
|
||||
return new this(
|
||||
`Insufficient Github API permissions to merge pull request. Please ` +
|
||||
`ensure that your auth token has write access.`);
|
||||
static insufficientPermissionsToMerge(
|
||||
message = `Insufficient Github API permissions to merge pull request. Please ensure that ` +
|
||||
`your auth token has write access.`) {
|
||||
return new this(message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,15 @@
|
|||
import * as Octokit from '@octokit/rest';
|
||||
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
||||
|
||||
import {info} from '../../utils/console';
|
||||
import {info, yellow} from '../../utils/console';
|
||||
|
||||
import {MergeConfigWithRemote} from './config';
|
||||
|
||||
/** Github response type extended to include the `x-oauth-scopes` headers presence. */
|
||||
type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
|
||||
headers: {'x-oauth-scopes': string};
|
||||
};
|
||||
|
||||
/** Error for failed Github API requests. */
|
||||
export class GithubApiRequestError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
|
@ -43,6 +48,8 @@ export class GitClient {
|
|||
/** Instance of the authenticated Github octokit API. */
|
||||
api: Octokit;
|
||||
|
||||
/** The OAuth scopes available for the provided Github token. */
|
||||
private _oauthScopes = Promise.resolve<string[]>([]);
|
||||
/** Regular expression that matches the provided Github token. */
|
||||
private _tokenRegex = new RegExp(this._githubToken, 'g');
|
||||
|
||||
|
@ -117,4 +124,55 @@ export class GitClient {
|
|||
omitGithubTokenFromMessage(value: string): string {
|
||||
return value.replace(this._tokenRegex, '<TOKEN>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the GitClient instance is using a token with permissions for the all of the
|
||||
* provided OAuth scopes.
|
||||
*/
|
||||
async hasOauthScopes(...requestedScopes: string[]): Promise<true|{error: string}> {
|
||||
const missingScopes: string[] = [];
|
||||
const scopes = await this.getAuthScopes();
|
||||
requestedScopes.forEach(scope => {
|
||||
if (!scopes.includes(scope)) {
|
||||
missingScopes.push(scope);
|
||||
}
|
||||
});
|
||||
// If no missing scopes are found, return true to indicate all OAuth Scopes are available.
|
||||
if (missingScopes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preconstructed error message to log to the user, providing missing scopes and
|
||||
* remediation instructions.
|
||||
**/
|
||||
const error =
|
||||
`The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
|
||||
`${yellow(missingScopes.join(', '))}\n\n` +
|
||||
`Update the token in use at:\n` +
|
||||
` https://github.com/settings/tokens\n\n` +
|
||||
`Alternatively, a new token can be created at: https://github.com/settings/tokens/new\n`;
|
||||
|
||||
return {error};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves the OAuth scopes for the loaded Github token, returning the already retrived
|
||||
* list of OAuth scopes if available.
|
||||
**/
|
||||
private getAuthScopes() {
|
||||
// If the OAuth scopes have already been loaded, return the Promise containing them.
|
||||
if (this._oauthScopes) {
|
||||
return this._oauthScopes;
|
||||
}
|
||||
// OAuth scopes are loaded via the /rate_limit endpoint to prevent
|
||||
// usage of a request against that rate_limit for this lookup.
|
||||
this._oauthScopes = this.api.rateLimit.get().then(_response => {
|
||||
const response = _response as RateLimitResponseWithOAuthScopeHeader;
|
||||
const scopes: string = response.headers['x-oauth-scopes'] || '';
|
||||
return scopes.split(',').map(scope => scope.trim());
|
||||
});
|
||||
return this._oauthScopes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -110,6 +110,10 @@ export async function mergePullRequest(
|
|||
red('An unknown Git error has been thrown. Please check the output ' +
|
||||
'above for details.'));
|
||||
return false;
|
||||
case MergeStatus.GITHUB_ERROR:
|
||||
error(red('An error related to interacting with Github has been discovered.'));
|
||||
error(failure!.message);
|
||||
return false;
|
||||
case MergeStatus.FAILED:
|
||||
error(yellow(`Could not merge the specified pull request.`));
|
||||
error(red(failure!.message));
|
||||
|
|
|
@ -13,12 +13,16 @@ import {isPullRequest, loadAndValidatePullRequest,} from './pull-request';
|
|||
import {GithubApiMergeStrategy} from './strategies/api-merge';
|
||||
import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
|
||||
|
||||
/** Github OAuth scopes required for the merge task. */
|
||||
const REQUIRED_SCOPES = ['repo'];
|
||||
|
||||
/** Describes the status of a pull request merge. */
|
||||
export const enum MergeStatus {
|
||||
UNKNOWN_GIT_ERROR,
|
||||
DIRTY_WORKING_DIR,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
GITHUB_ERROR,
|
||||
}
|
||||
|
||||
/** Result of a pull request merge. */
|
||||
|
@ -48,6 +52,15 @@ export class PullRequestMergeTask {
|
|||
* @param force Whether non-critical pull request failures should be ignored.
|
||||
*/
|
||||
async merge(prNumber: number, force = false): Promise<MergeResult> {
|
||||
// Assert the authenticated GitClient has access on the required scopes.
|
||||
const hasOauthScopes = await this.git.hasOauthScopes(...REQUIRED_SCOPES);
|
||||
if (hasOauthScopes !== true) {
|
||||
return {
|
||||
status: MergeStatus.GITHUB_ERROR,
|
||||
failure: PullRequestFailure.insufficientPermissionsToMerge(hasOauthScopes.error)
|
||||
};
|
||||
}
|
||||
|
||||
if (this.git.hasUncommittedChanges()) {
|
||||
return {status: MergeStatus.DIRTY_WORKING_DIR};
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue