GitHub - leveral repository rules for branch protection (#180416)

* WIP - switching to use repository ruleset information

* Handle includes

* Refactor to add support for exclusion

* Fix exclude condition

* Fix request string

* Add logging

* Update setting description
This commit is contained in:
Ladislau Szomoru
2023-04-20 16:43:42 +02:00
committed by GitHub
parent 3f6a54de51
commit 5ea57c3b48
7 changed files with 182 additions and 46 deletions

View File

@@ -3,11 +3,28 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EventEmitter, Memento, Uri, workspace } from 'vscode';
import { EventEmitter, LogOutputChannel, Memento, Uri, workspace } from 'vscode';
import { getOctokit } from './auth';
import { API, BranchProtection, BranchProtectionProvider, Repository } from './typings/git';
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;
}
export class GithubBranchProtectionProviderManager {
private readonly disposables = new DisposableStore();
@@ -21,7 +38,7 @@ export class GithubBranchProtectionProviderManager {
if (enabled) {
for (const repository of this.gitAPI.repositories) {
this.providerDisposables.add(this.gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository, this.globalState)));
this.providerDisposables.add(this.gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository, this.globalState, this.logger)));
}
} else {
this.providerDisposables.dispose();
@@ -30,10 +47,13 @@ export class GithubBranchProtectionProviderManager {
this._enabled = enabled;
}
constructor(private readonly gitAPI: API, private readonly globalState: Memento) {
constructor(
private readonly gitAPI: API,
private readonly globalState: Memento,
private readonly logger: LogOutputChannel) {
this.disposables.add(this.gitAPI.onDidOpenRepository(repository => {
if (this._enabled) {
this.providerDisposables.add(gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository, this.globalState)));
this.providerDisposables.add(gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository, this.globalState, this.logger)));
}
}));
@@ -65,7 +85,10 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
private branchProtection: BranchProtection[];
private readonly globalStateKey = `branchProtection:${this.repository.rootUri.toString()}`;
constructor(private readonly repository: Repository, private readonly globalState: Memento) {
constructor(
private readonly repository: Repository,
private readonly globalState: Memento,
private readonly logger: LogOutputChannel) {
// Restore branch protection from global state
this.branchProtection = this.globalState.get<BranchProtection[]>(this.globalStateKey, []);
@@ -82,21 +105,76 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
await this.updateHEADBranchProtection();
// Branch protection (remotes)
await this.updateBranchProtection();
await this.updateRepositoryBranchProtection();
}
private async checkPushPermission(repository: { owner: string; repo: string }): Promise<boolean> {
private async hasPushPermission(repository: { owner: string; repo: string }): Promise<boolean> {
try {
const octokit = await getOctokit();
const response = await octokit.repos.get({ ...repository });
return response.data.permissions?.push === true;
} catch {
// todo@lszomoru - add logging
} catch (err) {
this.logger.warn(`Failed to get repository permissions for repository (${repository.owner}/${repository.repo}): ${err.message} (${err.status})`);
return false;
}
}
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}): ${err.message} (${err.status})`);
return [];
}
}
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}): ${err.message} (${err.status})`);
return [];
}
}
private async updateHEADBranchProtection(): Promise<void> {
try {
const HEAD = this.repository.state.HEAD;
@@ -118,25 +196,24 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
return;
}
if (!(await this.checkPushPermission(repository))) {
if (!(await this.hasPushPermission(repository))) {
return;
}
const octokit = await getOctokit();
const response = await octokit.repos.getBranch({ ...repository, branch: HEAD.name });
if (!response.data.protected) {
const rules = await this.getBranchRules(repository, HEAD.name);
if (!rules.find(r => r.type === 'pull_request')) {
return;
}
this.branchProtection = [{ remote: remote.name, branches: [HEAD.name] }];
this.branchProtection = [{ remote: remote.name, rules: [{ include: [HEAD.name] }] }];
this._onDidChangeBranchProtection.fire(this.repository.rootUri);
} catch {
// todo@lszomoru - add logging
} catch (err) {
// noop
this.logger.warn(`Failed to update HEAD branch protection: ${err.message} (${err.status})`);
}
}
private async updateBranchProtection(): Promise<void> {
private async updateRepositoryBranchProtection(): Promise<void> {
try {
const branchProtection: BranchProtection[] = [];
@@ -147,27 +224,38 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
continue;
}
if (!(await this.checkPushPermission(repository))) {
if (!(await this.hasPushPermission(repository))) {
continue;
}
// Repository details
const octokit = await getOctokit();
const response = await octokit.repos.get({ ...repository });
let page = 1;
const protectedBranches: string[] = [];
// Repository rulesets
const rulesets = await this.getRepositoryRulesets(repository);
while (true) {
const response = await octokit.repos.listBranches({ ...repository, protected: true, per_page: 100, page });
if (response.data.length === 0) {
break;
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 '**/*';
}
protectedBranches.push(...response.data.map(b => b.name));
page++;
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))
});
}
branchProtection.push({ remote: remote.name, branches: protectedBranches });
branchProtection.push({ remote: remote.name, rules });
}
this.branchProtection = branchProtection;
@@ -175,8 +263,9 @@ export class GithubBranchProtectionProvider implements BranchProtectionProvider
// Save branch protection to global state
await this.globalState.update(this.globalStateKey, branchProtection);
} catch {
// todo@lszomoru - add logging
} catch (err) {
// noop
this.logger.warn(`Failed to update repository branch protection: ${err.message} (${err.status})`);
}
}