Skip to content

Commit

Permalink
GitHub - refactor branch protection (#181880)
Browse files Browse the repository at this point in the history
* GitHub - rewrite to use GraphQL instead of REST

* Add paging
  • Loading branch information
lszomoru committed May 9, 2023
1 parent 0c85b95 commit a54b497
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 154 deletions.
2 changes: 2 additions & 0 deletions extensions/github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 27 additions & 0 deletions extensions/github/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
240 changes: 86 additions & 154 deletions extensions/github/src/branchProtection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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;
}
}

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[]> {
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<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> {
Expand All @@ -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;
Expand All @@ -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;
}
}
}
Loading

0 comments on commit a54b497

Please sign in to comment.