GitHub - refactor branch protection (#181880)

* GitHub - rewrite to use GraphQL instead of REST

* Add paging
This commit is contained in:
Ladislau Szomoru 2023-05-09 13:14:54 +02:00 committed by GitHub
parent 0c85b95c48
commit a54b497150
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 164 additions and 157 deletions

View file

@ -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"
},

View file

@ -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<Octokit> {
return _octokit;
}
let _octokitGraphql: Promise<graphql> | undefined;
export function getOctokitGraphql(): Promise<graphql> {
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;
}

View file

@ -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';
}
const REPOSITORY_QUERY = `
query repositoryPermissions($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
defaultBranchRef {
name
},
viewerPermission
}
}
`;
interface RepositoryRule {
readonly type: string;
}
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<BranchProtection[]>(this.globalStateKey, []);
repository.status()
.then(() => this.initializeBranchProtection());
repository.status().then(() => this.updateRepositoryBranchProtection());
}
provideBranchProtection(): BranchProtection[] {
return this.branchProtection;
}
private async initializeBranchProtection(): Promise<void> {
try {
// Branch protection (HEAD)
await this.updateHEADBranchProtection();
private async getRepositoryDetails(owner: string, repo: string): Promise<GitHubRepository> {
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<boolean> {
try {
const octokit = await getOctokit();
const response = await octokit.repos.get({ ...repository });
private async getRepositoryRulesets(owner: string, repo: string): Promise<RepositoryRuleset[]> {
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;
let cursor: string | undefined = undefined;
const graphql = await getOctokitGraphql();
while (true) {
const { repository } = await graphql<{ repository: GitHubRepository }>(REPOSITORY_RULESETS_QUERY, { owner, repo, cursor });
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[]);
if (repository.rulesets?.pageInfo.hasNextPage) {
cursor = repository.rulesets.pageInfo.endCursor as string | undefined;
} else {
break;
}
}
}
private async getBranchRules(repository: { owner: string; repo: string }, branch: string): Promise<RepositoryRule[]> {
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<RepositoryRuleset[]> {
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;
}
for (const ruleset of response.data as RepositoryRuleset[]) {
if (ruleset.target !== 'branch' || ruleset.enforcement !== 'active') {
continue;
}
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);
}
}
}
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<void> {
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<void> {
@ -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 });
// Get repository rulesets
const branchProtectionRules: BranchProtectionRule[] = [];
const repositoryRulesets = await this.getRepositoryRulesets(repository.owner, repository.repo);
// 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;
};
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;
}
}
}

View file

@ -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"