Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitHub - refactor branch protection #181880

Merged
merged 2 commits into from May 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions extensions/github/package.json
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
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
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;
}
}
}