diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 997e170bbf0..e3ec7c1000d 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -276,7 +276,12 @@ export interface PushErrorHandler { export interface BranchProtection { readonly remote: string; - readonly branches: string[]; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; } export interface BranchProtectionProvider { diff --git a/extensions/git/src/branchProtection.ts b/extensions/git/src/branchProtection.ts index 7ca1705c125..0fbb3b7d4b1 100644 --- a/extensions/git/src/branchProtection.ts +++ b/extensions/git/src/branchProtection.ts @@ -42,7 +42,7 @@ export class GitBranchProtectionProvider implements BranchProtectionProvider { .map(bp => typeof bp === 'string' ? bp.trim() : '') .filter(bp => bp !== ''); - this.branchProtection = { remote: '', branches }; + this.branchProtection = { remote: '', rules: [{ include: branches }] }; this._onDidChangeBranchProtection.fire(this.repositoryRoot); } diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 65919283a83..cd6830b0099 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -605,6 +605,11 @@ class ResourceCommandResolver { } } +interface BranchProtectionMatcher { + include?: picomatch.Matcher; + exclude?: picomatch.Matcher; +} + export class Repository implements Disposable { private _onDidChangeRepository = new EventEmitter(); @@ -744,7 +749,7 @@ export class Repository implements Disposable { private isRepositoryHuge: false | { limit: number } = false; private didWarnAboutLimit = false; - private branchProtection = new Map(); + private branchProtection = new Map(); private commitCommandCenter: CommitCommandsCenter; private resourceCommandResolver = new ResourceCommandResolver(this); private updateModelStateCancellationTokenSource: CancellationTokenSource | undefined; @@ -2367,8 +2372,19 @@ export class Repository implements Disposable { this.branchProtection.clear(); for (const provider of this.branchProtectionProviderRegistry.getBranchProtectionProviders(root)) { - for (const { remote, branches } of provider.provideBranchProtection()) { - this.branchProtection.set(remote, branches.length !== 0 ? picomatch(branches) : undefined); + for (const { remote, rules } of provider.provideBranchProtection()) { + const matchers: BranchProtectionMatcher[] = []; + + for (const rule of rules) { + const include = rule.include && rule.include.length !== 0 ? picomatch(rule.include) : undefined; + const exclude = rule.exclude && rule.exclude.length !== 0 ? picomatch(rule.exclude) : undefined; + + if (include || exclude) { + matchers.push({ include, exclude }); + } + } + + this.branchProtection.set(remote, matchers); } } @@ -2416,14 +2432,23 @@ export class Repository implements Disposable { if (branch?.name) { // Default branch protection (settings) const defaultBranchProtectionMatcher = this.branchProtection.get(''); - if (defaultBranchProtectionMatcher && defaultBranchProtectionMatcher(branch.name)) { + if (defaultBranchProtectionMatcher?.length === 1 && + defaultBranchProtectionMatcher[0].include && + defaultBranchProtectionMatcher[0].include(branch.name)) { return true; } if (branch.upstream?.remote) { // Branch protection (contributed) const remoteBranchProtectionMatcher = this.branchProtection.get(branch.upstream.remote); - return remoteBranchProtectionMatcher ? remoteBranchProtectionMatcher(branch.name) : false; + if (remoteBranchProtectionMatcher && remoteBranchProtectionMatcher?.length !== 0) { + return remoteBranchProtectionMatcher.some(matcher => { + const include = matcher.include ? matcher.include(branch.name!) : true; + const exclude = matcher.exclude ? matcher.exclude(branch.name!) : false; + + return include && !exclude; + }); + } } } diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index dc9c7bf887c..ad3cf82e010 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -1,7 +1,7 @@ { "displayName": "GitHub", "description": "GitHub features for VS Code", - "config.branchProtection": "Controls whether to query branch protection information for GitHub repositories", + "config.branchProtection": "Controls whether to query repository rules for GitHub repositories", "config.gitAuthentication": "Controls whether to enable automatic GitHub authentication for git commands within VS Code.", "config.gitProtocol": "Controls which protocol is used to clone a GitHub repository", "welcome.publishFolder": { diff --git a/extensions/github/src/branchProtection.ts b/extensions/github/src/branchProtection.ts index fe89ac3ed96..023bc20f257 100644 --- a/extensions/github/src/branchProtection.ts +++ b/extensions/github/src/branchProtection.ts @@ -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(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 { + private async hasPushPermission(repository: { owner: string; repo: string }): Promise { 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 { + 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 { + + 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 { 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 { + private async updateRepositoryBranchProtection(): Promise { 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})`); } } diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index cf72df61aa1..4d183b8939e 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { commands, Disposable, ExtensionContext, extensions } from 'vscode'; +import { commands, Disposable, ExtensionContext, extensions, l10n, LogLevel, LogOutputChannel, window } from 'vscode'; import { GithubRemoteSourceProvider } from './remoteSourceProvider'; import { API, GitExtension } from './typings/git'; import { registerCommands } from './commands'; @@ -15,8 +15,20 @@ import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; import { GithubBranchProtectionProviderManager } from './branchProtection'; export function activate(context: ExtensionContext): void { - context.subscriptions.push(initializeGitBaseExtension()); - context.subscriptions.push(initializeGitExtension(context)); + const disposables: Disposable[] = []; + context.subscriptions.push(new Disposable(() => Disposable.from(...disposables).dispose())); + + const logger = window.createOutputChannel('GitHub', { log: true }); + disposables.push(logger); + + const onDidChangeLogLevel = (logLevel: LogLevel) => { + logger.appendLine(l10n.t('Log level: {0}', LogLevel[logLevel])); + }; + disposables.push(logger.onDidChangeLogLevel(onDidChangeLogLevel)); + onDidChangeLogLevel(logger.logLevel); + + disposables.push(initializeGitBaseExtension()); + disposables.push(initializeGitExtension(context, logger)); } function initializeGitBaseExtension(): Disposable { @@ -64,7 +76,7 @@ function setGitHubContext(gitAPI: API, disposables: DisposableStore) { } } -function initializeGitExtension(context: ExtensionContext): Disposable { +function initializeGitExtension(context: ExtensionContext, logger: LogOutputChannel): Disposable { const disposables = new DisposableStore(); let gitExtension = extensions.getExtension('vscode.git'); @@ -78,7 +90,7 @@ function initializeGitExtension(context: ExtensionContext): Disposable { disposables.add(registerCommands(gitAPI)); disposables.add(new GithubCredentialProviderManager(gitAPI)); - disposables.add(new GithubBranchProtectionProviderManager(gitAPI, context.globalState)); + disposables.add(new GithubBranchProtectionProviderManager(gitAPI, context.globalState, logger)); disposables.add(gitAPI.registerPushErrorHandler(new GithubPushErrorHandler())); disposables.add(gitAPI.registerRemoteSourcePublisher(new GithubRemoteSourcePublisher(gitAPI))); setGitHubContext(gitAPI, disposables); diff --git a/extensions/github/src/typings/git.d.ts b/extensions/github/src/typings/git.d.ts index fa270a382ee..83b00f324c0 100644 --- a/extensions/github/src/typings/git.d.ts +++ b/extensions/github/src/typings/git.d.ts @@ -270,7 +270,12 @@ export interface PushErrorHandler { export interface BranchProtection { readonly remote: string; - readonly branches: string[]; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; } export interface BranchProtectionProvider {