diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index faa70b84949..e1fcc022aa6 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,7 +5,7 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery } from './git'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, FetchOptions, RemoteSourceProvider, RemoteSourcePublisher, PostCommitCommandsProvider, RefQuery, BranchProtectionProvider } from './git'; import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; import { combinedDisposable, mapEvent } from '../util'; import { toGitUri } from '../uri'; @@ -333,6 +333,10 @@ export class ApiImpl implements API { return this._model.registerPushErrorHandler(handler); } + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable { + return this._model.registerBranchProtectionProvider(root, provider); + } + constructor(private _model: Model) { } } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index c3b6b76111e..6a20b9017f0 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -305,6 +305,7 @@ export interface API { registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; } export interface GitExtension { diff --git a/extensions/git/src/model.ts b/extensions/git/src/model.ts index fc6a1e1dd9e..2624ffb4126 100644 --- a/extensions/git/src/model.ts +++ b/extensions/git/src/model.ts @@ -780,6 +780,7 @@ export class Model implements IBranchProtectionProviderRegistry, IRemoteSourcePu if (providers && providers.has(provider)) { providers.delete(provider); this.branchProtectionProviders.set(root, providers); + this._onDidChangeBranchProtectionProviders.fire(root); } dispose(providerDisposables); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 2c6d4b37755..75e7c909788 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -2364,6 +2364,8 @@ export class Repository implements Disposable { } private updateBranchProtectionMatchers(root: Uri): void { + this.branchProtection.clear(); + for (const provider of this.branchProtectionProviderRegistry.getBranchProtectionProviders(root)) { for (const [remote, branches] of provider.provideBranchProtection().entries()) { this.branchProtection.set(remote, branches.length !== 0 ? picomatch(branches) : undefined); diff --git a/extensions/github/package.json b/extensions/github/package.json index f9e5cf40ef4..90f6d96c111 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -125,12 +125,17 @@ "group": "0_vscode@0" } ] - }, "configuration": [ { "title": "GitHub", "properties": { + "github.branchProtection": { + "type": "boolean", + "scope": "resource", + "default": false, + "description": "%config.branchProtection%" + }, "github.gitAuthentication": { "type": "boolean", "scope": "resource", diff --git a/extensions/github/package.nls.json b/extensions/github/package.nls.json index 1e0ac702bb4..dc9c7bf887c 100644 --- a/extensions/github/package.nls.json +++ b/extensions/github/package.nls.json @@ -1,6 +1,7 @@ { "displayName": "GitHub", "description": "GitHub features for VS Code", + "config.branchProtection": "Controls whether to query branch protection information 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 new file mode 100644 index 00000000000..05dee5465a9 --- /dev/null +++ b/extensions/github/src/branchProtection.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { EventEmitter, Uri, workspace } from 'vscode'; +import { getOctokit } from './auth'; +import { API, BranchProtectionProvider, Repository } from './typings/git'; +import { DisposableStore, getRepositoryFromUrl } from './util'; + +export class GithubBranchProtectionProviderManager { + + private readonly disposables = new DisposableStore(); + private readonly providerDisposables = new DisposableStore(); + + private _enabled = false; + private set enabled(enabled: boolean) { + if (this._enabled === enabled) { + return; + } + + if (enabled) { + for (const repository of this.gitAPI.repositories) { + this.providerDisposables.add(this.gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository))); + } + } else { + this.providerDisposables.dispose(); + } + + this._enabled = enabled; + } + + constructor(private gitAPI: API) { + this.disposables.add(this.gitAPI.onDidOpenRepository(repository => { + if (this._enabled) { + this.providerDisposables.add(gitAPI.registerBranchProtectionProvider(repository.rootUri, new GithubBranchProtectionProvider(repository))); + } + })); + + this.disposables.add(workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('github.branchProtection')) { + this.updateEnablement(); + } + })); + + this.updateEnablement(); + } + + private updateEnablement(): void { + const config = workspace.getConfiguration('github', null); + this.enabled = config.get('branchProtection', true) === true; + } + + dispose(): void { + this.enabled = false; + this.disposables.dispose(); + } + +} + +export class GithubBranchProtectionProvider implements BranchProtectionProvider { + private readonly _onDidChangeBranchProtection = new EventEmitter(); + onDidChangeBranchProtection = this._onDidChangeBranchProtection.event; + + private branchProtection = new Map(); + + constructor(private readonly repository: Repository) { + repository.status() + .then(() => this.initializeBranchProtection()); + } + + provideBranchProtection(): Map { + return this.branchProtection; + } + + private async initializeBranchProtection(): Promise { + // Branch protection (HEAD) + await this.updateHEADBranchProtection(); + + // Branch protection (remotes) + await this.updateBranchProtection(); + } + + private async updateHEADBranchProtection(): Promise { + 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; + } + + const octokit = await getOctokit(); + const response = await octokit.repos.getBranch({ ...repository, branch: HEAD.name }); + + if (!response.data.protected) { + return; + } + + this.branchProtection.set(remote.name, [HEAD.name]); + this._onDidChangeBranchProtection.fire(this.repository.rootUri); + } catch { + // todo@lszomoru - add logging + } + } + + private async updateBranchProtection(): Promise { + try { + let branchProtectionUpdated = false; + + for (const remote of this.repository.state.remotes) { + const repository = getRepositoryFromUrl(remote.pushUrl ?? remote.fetchUrl ?? ''); + + if (!repository) { + continue; + } + + const octokit = await getOctokit(); + + let page = 1; + const protectedBranches: string[] = []; + + while (true) { + const response = await octokit.repos.listBranches({ ...repository, protected: true, per_page: 100, page }); + + if (response.data.length === 0) { + break; + } + + protectedBranches.push(...response.data.map(b => b.name)); + page++; + } + + if (protectedBranches.length > 0) { + this.branchProtection.set(remote.name, protectedBranches); + branchProtectionUpdated = true; + } + } + + if (branchProtectionUpdated) { + this._onDidChangeBranchProtection.fire(this.repository.rootUri); + } + } catch { + // todo@lszomoru - add logging + } + } + +} diff --git a/extensions/github/src/extension.ts b/extensions/github/src/extension.ts index a3a84b033dd..b58e23683f0 100644 --- a/extensions/github/src/extension.ts +++ b/extensions/github/src/extension.ts @@ -12,6 +12,7 @@ import { DisposableStore, repositoryHasGitHubRemote } from './util'; import { GithubPushErrorHandler } from './pushErrorHandler'; import { GitBaseExtension } from './typings/git-base'; import { GithubRemoteSourcePublisher } from './remoteSourcePublisher'; +import { GithubBranchProtectionProviderManager } from './branchProtection'; export function activate(context: ExtensionContext): void { context.subscriptions.push(initializeGitBaseExtension()); @@ -77,6 +78,7 @@ function initializeGitExtension(): Disposable { disposables.add(registerCommands(gitAPI)); disposables.add(new GithubCredentialProviderManager(gitAPI)); + disposables.add(new GithubBranchProtectionProviderManager(gitAPI)); 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 2e10affa154..5a84e952abb 100644 --- a/extensions/github/src/typings/git.d.ts +++ b/extensions/github/src/typings/git.d.ts @@ -268,6 +268,11 @@ export interface PushErrorHandler { handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; } +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): Map; +} + export type APIState = 'uninitialized' | 'initialized'; export interface PublishEvent { @@ -294,6 +299,7 @@ export interface API { registerCredentialsProvider(provider: CredentialsProvider): Disposable; registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; } export interface GitExtension {