mirror of
https://github.com/Microsoft/vscode
synced 2024-08-27 04:49:35 +00:00
GitHub - refactor branch protection (#181880)
* GitHub - rewrite to use GraphQL instead of REST * Add paging
This commit is contained in:
parent
0c85b95c48
commit
a54b497150
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue