diff --git a/dev-infra/pr/merge/failures.ts b/dev-infra/pr/merge/failures.ts index 3966692e50..b1ae0b70a5 100644 --- a/dev-infra/pr/merge/failures.ts +++ b/dev-infra/pr/merge/failures.ts @@ -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); } } diff --git a/dev-infra/pr/merge/git.ts b/dev-infra/pr/merge/git.ts index 32cc1d5704..e91d443f27 100644 --- a/dev-infra/pr/merge/git.ts +++ b/dev-infra/pr/merge/git.ts @@ -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&{ + 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([]); /** 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, ''); } + + /** + * Assert the GitClient instance is using a token with permissions for the all of the + * provided OAuth scopes. + */ + async hasOauthScopes(...requestedScopes: string[]): Promise { + 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 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; + } } diff --git a/dev-infra/pr/merge/index.ts b/dev-infra/pr/merge/index.ts index 61238071bd..c01abd0865 100644 --- a/dev-infra/pr/merge/index.ts +++ b/dev-infra/pr/merge/index.ts @@ -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)); diff --git a/dev-infra/pr/merge/task.ts b/dev-infra/pr/merge/task.ts index e85065322d..cadfe60b49 100644 --- a/dev-infra/pr/merge/task.ts +++ b/dev-infra/pr/merge/task.ts @@ -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 { + // 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}; }