diff --git a/extensions/github/package.json b/extensions/github/package.json index 90f6d96c111c9..c86b0495b4308 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -176,6 +176,8 @@ "watch": "gulp watch-extension:github" }, "dependencies": { + "@octokit/graphql": "5.0.5", + "@octokit/graphql-schema": "14.4.0", "@octokit/rest": "19.0.4", "tunnel": "^0.0.6" }, diff --git a/extensions/github/src/auth.ts b/extensions/github/src/auth.ts index 859fe9fa821fa..c96d21187d02e 100644 --- a/extensions/github/src/auth.ts +++ b/extensions/github/src/auth.ts @@ -5,6 +5,7 @@ import { AuthenticationSession, authentication, window } from 'vscode'; import { Agent, globalAgent } from 'https'; +import { graphql } from '@octokit/graphql/dist-types/types'; import { Octokit } from '@octokit/rest'; import { httpsOverHttp } from 'tunnel'; import { URL } from 'url'; @@ -53,3 +54,29 @@ export function getOctokit(): Promise { return _octokit; } + +let _octokitGraphql: Promise | undefined; + +export function getOctokitGraphql(): Promise { + if (!_octokitGraphql) { + _octokitGraphql = getSession() + .then(async session => { + const token = session.accessToken; + const { graphql } = await import('@octokit/graphql'); + + return graphql.defaults({ + headers: { + authorization: `token ${token}` + }, + request: { + agent: getAgent() + } + }); + }).then(null, async err => { + _octokitGraphql = undefined; + throw err; + }); + } + + return _octokitGraphql; +} diff --git a/extensions/github/src/branchProtection.ts b/extensions/github/src/branchProtection.ts index 3ed94616a8644..0ca239d2054ef 100644 --- a/extensions/github/src/branchProtection.ts +++ b/extensions/github/src/branchProtection.ts @@ -4,26 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import { EventEmitter, LogOutputChannel, Memento, Uri, workspace } from 'vscode'; -import { getOctokit } from './auth'; +import { Repository as GitHubRepository, RepositoryRuleset } from '@octokit/graphql-schema'; +import { getOctokitGraphql } from './auth'; import { API, BranchProtection, BranchProtectionProvider, BranchProtectionRule, Repository } from './typings/git'; import { DisposableStore, getRepositoryFromUrl } from './util'; -interface RepositoryRuleset { - readonly id: number; - readonly conditions: { - ref_name: { - exclude: string[]; - include: string[]; - }; - }; - readonly enforcement: 'active' | 'disabled' | 'evaluate'; - readonly rules: RepositoryRule[]; - readonly target: 'branch' | 'tag'; -} - -interface RepositoryRule { - readonly type: string; -} +const REPOSITORY_QUERY = ` + query repositoryPermissions($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + }, + viewerPermission + } + } +`; + +const REPOSITORY_RULESETS_QUERY = ` + query repositoryRulesets($owner: String!, $repo: String!, $cursor: String, $limit: Int = 100) { + repository(owner: $owner, name: $repo) { + rulesets(includeParents: true, first: $limit, after: $cursor) { + nodes { + name + enforcement + rules(type: PULL_REQUEST) { + totalCount + } + conditions { + refName { + include + exclude + } + } + target + }, + pageInfo { + endCursor, + hasNextPage + } + } + } + } +`; export class GithubBranchProtectionProviderManager { @@ -92,130 +114,41 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider // Restore branch protection from global state this.branchProtection = this.globalState.get(this.globalStateKey, []); - repository.status() - .then(() => this.initializeBranchProtection()); + repository.status().then(() => this.updateRepositoryBranchProtection()); } provideBranchProtection(): BranchProtection[] { return this.branchProtection; } - private async initializeBranchProtection(): Promise { - try { - // Branch protection (HEAD) - await this.updateHEADBranchProtection(); + private async getRepositoryDetails(owner: string, repo: string): Promise { + const graphql = await getOctokitGraphql(); + const { repository } = await graphql<{ repository: GitHubRepository }>(REPOSITORY_QUERY, { owner, repo }); - // Branch protection (remotes) - await this.updateRepositoryBranchProtection(); - } catch (err) { - // noop - this.logger.warn(`Failed to initialize branch protection: ${this.formatErrorMessage(err)}`); - } + return repository; } - private async hasPushPermission(repository: { owner: string; repo: string }): Promise { - try { - const octokit = await getOctokit(); - const response = await octokit.repos.get({ ...repository }); + private async getRepositoryRulesets(owner: string, repo: string): Promise { + const rulesets: RepositoryRuleset[] = []; - return response.data.permissions?.push === true; - } catch (err) { - this.logger.warn(`Failed to get repository permissions for repository (${repository.owner}/${repository.repo}): ${this.formatErrorMessage(err)}`); - throw err; - } - } - - private async getBranchRules(repository: { owner: string; repo: string }, branch: string): Promise { - try { - const octokit = await getOctokit(); - const response = await octokit.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { - ...repository, - branch, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - return response.data as RepositoryRule[]; - } catch (err) { - this.logger.warn(`Failed to get branch rules for repository (${repository.owner}/${repository.repo}), branch (${branch}): ${this.formatErrorMessage(err)}`); - throw err; - } - } - - private async getRepositoryRulesets(repository: { owner: string; repo: string }): Promise { + let cursor: string | undefined = undefined; + const graphql = await getOctokitGraphql(); - try { - const rulesets: RepositoryRuleset[] = []; - const octokit = await getOctokit(); - for await (const response of octokit.paginate.iterator('GET /repos/{owner}/{repo}/rulesets', { ...repository, includes_parents: true })) { - if (response.status !== 200) { - continue; - } + while (true) { + const { repository } = await graphql<{ repository: GitHubRepository }>(REPOSITORY_RULESETS_QUERY, { owner, repo, cursor }); - for (const ruleset of response.data as RepositoryRuleset[]) { - if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') { - continue; - } + rulesets.push(...(repository.rulesets?.nodes ?? []) + // Active branch ruleset that contains the pull request required rule + .filter(node => node && node.target === 'BRANCH' && node.enforcement === 'ACTIVE' && (node.rules?.totalCount ?? 0) > 0) as RepositoryRuleset[]); - const response = await octokit.request('GET /repos/{owner}/{repo}/rulesets/{id}', { - ...repository, - id: ruleset.id, - headers: { - 'X-GitHub-Api-Version': '2022-11-28' - } - }); - - const rulesetWithDetails = response.data as RepositoryRuleset; - if (rulesetWithDetails?.rules.find(r => r.type === 'pull_request')) { - rulesets.push(rulesetWithDetails); - } - } + if (repository.rulesets?.pageInfo.hasNextPage) { + cursor = repository.rulesets.pageInfo.endCursor as string | undefined; + } else { + break; } - - return rulesets; } - catch (err) { - this.logger.warn(`Failed to get repository rulesets for repository (${repository.owner}/${repository.repo}): ${this.formatErrorMessage(err)}`); - throw err; - } - } - - private async updateHEADBranchProtection(): Promise { - try { - const HEAD = this.repository.state.HEAD; - - if (!HEAD?.name || !HEAD?.upstream?.remote) { - return; - } - - const remoteName = HEAD.upstream.remote; - const remote = this.repository.state.remotes.find(r => r.name === remoteName); - - if (!remote) { - return; - } - - const repository = getRepositoryFromUrl(remote.pushUrl ?? remote.fetchUrl ?? ''); - - if (!repository) { - return; - } - if (!(await this.hasPushPermission(repository))) { - return; - } - - const rules = await this.getBranchRules(repository, HEAD.name); - if (!rules.find(r => r.type === 'pull_request')) { - return; - } - - this.branchProtection = [{ remote: remote.name, rules: [{ include: [HEAD.name] }] }]; - this._onDidChangeBranchProtection.fire(this.repository.rootUri); - } catch (err) { - this.logger.warn(`Failed to update HEAD branch protection: ${this.formatErrorMessage(err)}`); - throw err; - } + return rulesets; } private async updateRepositoryBranchProtection(): Promise { @@ -229,38 +162,26 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider continue; } - if (!(await this.hasPushPermission(repository))) { + // Repository details + const repositoryDetails = await this.getRepositoryDetails(repository.owner, repository.repo); + + // Check repository write permission + if (repositoryDetails.viewerPermission !== 'ADMIN' && repositoryDetails.viewerPermission !== 'MAINTAIN' && repositoryDetails.viewerPermission !== 'WRITE') { continue; } - // Repository details - const octokit = await getOctokit(); - const response = await octokit.repos.get({ ...repository }); - - // Repository rulesets - const rulesets = await this.getRepositoryRulesets(repository); - - const parseRef = (ref: string): string => { - if (ref.startsWith('refs/heads/')) { - return ref.substring(11); - } else if (ref === '~DEFAULT_BRANCH') { - return response.data.default_branch; - } else if (ref === '~ALL') { - return '**/*'; - } - - return ref; - }; + // Get repository rulesets + const branchProtectionRules: BranchProtectionRule[] = []; + const repositoryRulesets = await this.getRepositoryRulesets(repository.owner, repository.repo); - const rules: BranchProtectionRule[] = []; - for (const ruleset of rulesets) { - rules.push({ - include: ruleset.conditions.ref_name.include.map(r => parseRef(r)), - exclude: ruleset.conditions.ref_name.exclude.map(r => parseRef(r)) + for (const ruleset of repositoryRulesets) { + branchProtectionRules.push({ + include: (ruleset.conditions.refName?.include ?? []).map(r => this.parseRulesetRefName(repositoryDetails, r)), + exclude: (ruleset.conditions.refName?.exclude ?? []).map(r => this.parseRulesetRefName(repositoryDetails, r)) }); } - branchProtection.push({ remote: remote.name, rules }); + branchProtection.push({ remote: remote.name, rules: branchProtectionRules }); } this.branchProtection = branchProtection; @@ -269,12 +190,23 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider // Save branch protection to global state await this.globalState.update(this.globalStateKey, branchProtection); } catch (err) { - this.logger.warn(`Failed to update repository branch protection: ${this.formatErrorMessage(err)}`); - throw err; + // noop + this.logger.warn(`Failed to update repository branch protection: ${err.message}`); } } - private formatErrorMessage(err: any): string { - return `${err.message ?? ''}${err.status ? ` (${err.status})` : ''}`; + private parseRulesetRefName(repository: GitHubRepository, refName: string): string { + if (refName.startsWith('refs/heads/')) { + return refName.substring(11); + } + + switch (refName) { + case '~ALL': + return '**/*'; + case '~DEFAULT_BRANCH': + return repository.defaultBranchRef!.name; + default: + return refName; + } } } diff --git a/extensions/github/yarn.lock b/extensions/github/yarn.lock index 775f3c139d34f..6d2eafc2bf5cd 100644 --- a/extensions/github/yarn.lock +++ b/extensions/github/yarn.lock @@ -31,6 +31,23 @@ is-plain-object "^5.0.0" universal-user-agent "^6.0.0" +"@octokit/graphql-schema@14.4.0": + version "14.4.0" + resolved "https://registry.yarnpkg.com/@octokit/graphql-schema/-/graphql-schema-14.4.0.tgz#9336f64c3103a2e82ee3ce060c3ccf99d177d7f0" + integrity sha512-+O6/dsLlR6V9gv+t1lqsN+x73TLwyQWZpd3M8/eYnuny7VaznV9TAyUxf18tX8WBBS5IqtlLDk1nG+aSTPRZzQ== + dependencies: + graphql "^16.0.0" + graphql-tag "^2.10.3" + +"@octokit/graphql@5.0.5": + version "5.0.5" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.5.tgz#a4cb3ea73f83b861893a6370ee82abb36e81afd2" + integrity sha512-Qwfvh3xdqKtIznjX9lz2D458r7dJPP8l6r4GQkIdWQouZwHQK0mVT88uwiU2bdTU2OtT1uOlKpRciUWldpG0yQ== + dependencies: + "@octokit/request" "^6.0.0" + "@octokit/types" "^9.0.0" + universal-user-agent "^6.0.0" + "@octokit/graphql@^5.0.0": version "5.0.1" resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-5.0.1.tgz#a06982514ad131fb6fbb9da968653b2233fade9b" @@ -45,6 +62,11 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-13.6.0.tgz#381884008e23fd82fd444553f6b4dcd24a5c4a4d" integrity sha512-bxftLwoZ2J6zsU1rzRvk0O32j7lVB0NWWn+P5CDHn9zPzytasR3hdAeXlTngRDkqv1LyEeuy5psVnDkmOSwrcQ== +"@octokit/openapi-types@^17.1.0": + version "17.1.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-17.1.0.tgz#9a712b5bb9d644940d8a1f24115c798c317a64a5" + integrity sha512-rnI26BAITDZTo5vqFOmA7oX4xRd18rO+gcK4MiTpJmsRMxAw0JmevNjPsjpry1bb9SVNo56P/0kbiyXXa4QluA== + "@octokit/plugin-paginate-rest@^4.0.0": version "4.2.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-4.2.0.tgz#41fc6ca312446a85a4275aca698b4d9c4c5e06ab" @@ -103,6 +125,13 @@ dependencies: "@octokit/openapi-types" "^13.6.0" +"@octokit/types@^9.0.0": + version "9.2.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-9.2.0.tgz#0358e3de070b1d43c5a8af63b9951c88a09fc9ed" + integrity sha512-xySzJG4noWrIBFyMu4lg4tu9vAgNg9S0aoLRONhAEz6ueyi1evBzb40HitIosaYS4XOexphG305IVcLrIX/30g== + dependencies: + "@octokit/openapi-types" "^17.1.0" + "@types/node@16.x": version "16.11.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.6.tgz#6bef7a2a0ad684cf6e90fcfe31cecabd9ce0a3ae" @@ -118,6 +147,18 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== +graphql-tag@^2.10.3: + version "2.12.6" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" + integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== + dependencies: + tslib "^2.1.0" + +graphql@^16.0.0: + version "16.6.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.6.0.tgz#c2dcffa4649db149f6282af726c8c83f1c7c5fdb" + integrity sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw== + is-plain-object@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" @@ -142,6 +183,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= +tslib@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== + tunnel@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"