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.`);
|
return new this(`Pull request could not be found upstream.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static insufficientPermissionsToMerge() {
|
static insufficientPermissionsToMerge(
|
||||||
return new this(
|
message = `Insufficient Github API permissions to merge pull request. Please ensure that ` +
|
||||||
`Insufficient Github API permissions to merge pull request. Please ` +
|
`your auth token has write access.`) {
|
||||||
`ensure that your auth token has write access.`);
|
return new this(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,15 @@
|
||||||
import * as Octokit from '@octokit/rest';
|
import * as Octokit from '@octokit/rest';
|
||||||
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
|
||||||
|
|
||||||
import {info} from '../../utils/console';
|
import {info, yellow} from '../../utils/console';
|
||||||
|
|
||||||
import {MergeConfigWithRemote} from './config';
|
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. */
|
/** Error for failed Github API requests. */
|
||||||
export class GithubApiRequestError extends Error {
|
export class GithubApiRequestError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
|
@ -43,6 +48,8 @@ export class GitClient {
|
||||||
/** Instance of the authenticated Github octokit API. */
|
/** Instance of the authenticated Github octokit API. */
|
||||||
api: Octokit;
|
api: Octokit;
|
||||||
|
|
||||||
|
/** The OAuth scopes available for the provided Github token. */
|
||||||
|
private _oauthScopes = Promise.resolve<string[]>([]);
|
||||||
/** Regular expression that matches the provided Github token. */
|
/** Regular expression that matches the provided Github token. */
|
||||||
private _tokenRegex = new RegExp(this._githubToken, 'g');
|
private _tokenRegex = new RegExp(this._githubToken, 'g');
|
||||||
|
|
||||||
|
@ -117,4 +124,55 @@ export class GitClient {
|
||||||
omitGithubTokenFromMessage(value: string): string {
|
omitGithubTokenFromMessage(value: string): string {
|
||||||
return value.replace(this._tokenRegex, '<TOKEN>');
|
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 ' +
|
red('An unknown Git error has been thrown. Please check the output ' +
|
||||||
'above for details.'));
|
'above for details.'));
|
||||||
return false;
|
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:
|
case MergeStatus.FAILED:
|
||||||
error(yellow(`Could not merge the specified pull request.`));
|
error(yellow(`Could not merge the specified pull request.`));
|
||||||
error(red(failure!.message));
|
error(red(failure!.message));
|
||||||
|
|
|
@ -13,12 +13,16 @@ import {isPullRequest, loadAndValidatePullRequest,} from './pull-request';
|
||||||
import {GithubApiMergeStrategy} from './strategies/api-merge';
|
import {GithubApiMergeStrategy} from './strategies/api-merge';
|
||||||
import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
|
import {AutosquashMergeStrategy} from './strategies/autosquash-merge';
|
||||||
|
|
||||||
|
/** Github OAuth scopes required for the merge task. */
|
||||||
|
const REQUIRED_SCOPES = ['repo'];
|
||||||
|
|
||||||
/** Describes the status of a pull request merge. */
|
/** Describes the status of a pull request merge. */
|
||||||
export const enum MergeStatus {
|
export const enum MergeStatus {
|
||||||
UNKNOWN_GIT_ERROR,
|
UNKNOWN_GIT_ERROR,
|
||||||
DIRTY_WORKING_DIR,
|
DIRTY_WORKING_DIR,
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
FAILED,
|
FAILED,
|
||||||
|
GITHUB_ERROR,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of a pull request merge. */
|
/** Result of a pull request merge. */
|
||||||
|
@ -48,6 +52,15 @@ export class PullRequestMergeTask {
|
||||||
* @param force Whether non-critical pull request failures should be ignored.
|
* @param force Whether non-critical pull request failures should be ignored.
|
||||||
*/
|
*/
|
||||||
async merge(prNumber: number, force = false): Promise<MergeResult> {
|
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()) {
|
if (this.git.hasUncommittedChanges()) {
|
||||||
return {status: MergeStatus.DIRTY_WORKING_DIR};
|
return {status: MergeStatus.DIRTY_WORKING_DIR};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue