diff --git a/package.json b/package.json index b834fa2..6b253b5 100644 --- a/package.json +++ b/package.json @@ -15774,6 +15774,7 @@ }, "dependencies": { "@gitkraken/gitkraken-components": "10.2.5", + "@gitkraken/provider-apis": "0.10.0", "@gitkraken/shared-web-components": "0.1.1-rc.15", "@lit/react": "1.0.2", "@microsoft/fast-element": "1.12.0", diff --git a/src/annotations/autolinks.ts b/src/annotations/autolinks.ts index e4e44ee..b95593b 100644 --- a/src/annotations/autolinks.ts +++ b/src/annotations/autolinks.ts @@ -7,7 +7,7 @@ import type { IssueOrPullRequest } from '../git/models/issue'; import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/models/issue'; import type { GitRemote } from '../git/models/remote'; import type { RemoteProviderReference } from '../git/models/remoteProvider'; -import type { RepositoryDescriptor, RichRemoteProvider } from '../git/remotes/richRemoteProvider'; +import type { RepositoryDescriptor } from '../plus/integrations/providerIntegration'; import type { MaybePausedResult } from '../system/cancellation'; import { configuration } from '../system/configuration'; import { fromNow } from '../system/date'; @@ -223,10 +223,9 @@ export class Autolinks implements Disposable { } if (messageOrAutolinks.size === 0) return undefined; - let provider: RichRemoteProvider | undefined; - if (remote?.hasRichIntegration()) { - ({ provider } = remote); - const connected = remote.provider.maybeConnected ?? (await remote.provider.isConnected()); + let provider = remote?.getIntegration(); + if (provider != null) { + const connected = provider.maybeConnected ?? (await provider.isConnected()); if (!connected) { provider = undefined; } @@ -239,10 +238,11 @@ export class Autolinks implements Disposable { [ id, [ + remote?.provider != null && provider != null && link.provider?.id === provider.id && link.provider?.domain === provider.domain - ? provider.getIssueOrPullRequest(id, link.descriptor) + ? provider.getIssueOrPullRequest(link.descriptor ?? remote.provider.repoDesc, id) : undefined, link, ] satisfies EnrichedAutolink, @@ -284,8 +284,8 @@ export class Autolinks implements Disposable { if (remotes != null && remotes.length !== 0) { remotes = [...remotes].sort((a, b) => { - const aConnected = a.provider?.maybeConnected; - const bConnected = b.provider?.maybeConnected; + const aConnected = a.maybeIntegrationConnected; + const bConnected = b.maybeIntegrationConnected; return aConnected !== bConnected ? (aConnected ? -1 : bConnected ? 1 : 0) : 0; }); for (const r of remotes) { diff --git a/src/annotations/lineAnnotationController.ts b/src/annotations/lineAnnotationController.ts index c1a9980..01d0873 100644 --- a/src/annotations/lineAnnotationController.ts +++ b/src/annotations/lineAnnotationController.ts @@ -39,7 +39,7 @@ export class LineAnnotationController implements Disposable { once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), container.fileAnnotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this), - container.richRemoteProviders.onAfterDidChangeConnectionState( + container.integrations.onDidChangeConnectionState( debounce(() => void this.refresh(window.activeTextEditor), 250), ), ); @@ -158,7 +158,7 @@ export class LineAnnotationController implements Disposable { const prs = new Map>(); if (lines.size === 0) return prs; - const remotePromise = this.container.git.getBestRemoteWithRichProvider(repoPath); + const remotePromise = this.container.git.getBestRemoteWithIntegration(repoPath); for (const [, state] of lines) { if (state.commit.isUncommitted) continue; diff --git a/src/avatars.ts b/src/avatars.ts index fe17de4..62a9ec2 100644 --- a/src/avatars.ts +++ b/src/avatars.ts @@ -220,8 +220,13 @@ async function getAvatarUriFromRemoteProvider( // account = await remote?.provider.getAccountForEmail(email, { avatarSize: size }); // } else { if (typeof repoPathOrCommit !== 'string') { - const remote = await Container.instance.git.getBestRemoteWithRichProvider(repoPathOrCommit.repoPath); - account = await remote?.provider.getAccountForCommit(repoPathOrCommit.ref, { avatarSize: size }); + const remote = await Container.instance.git.getBestRemoteWithIntegration(repoPathOrCommit.repoPath); + if (remote?.hasIntegration()) { + const integration = remote.getIntegration(); + account = await integration?.getAccountForCommit(remote.provider.repoDesc, repoPathOrCommit.ref, { + avatarSize: size, + }); + } } if (account?.avatarUrl == null) { diff --git a/src/cache.ts b/src/cache.ts index b797612..5416ef4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -4,10 +4,8 @@ import type { Container } from './container'; import type { DefaultBranch } from './git/models/defaultBranch'; import type { IssueOrPullRequest } from './git/models/issue'; import type { PullRequest } from './git/models/pullRequest'; -import type { GitRemote } from './git/models/remote'; import type { RepositoryMetadata } from './git/models/repositoryMetadata'; -import type { RemoteProvider } from './git/remotes/remoteProvider'; -import type { RepositoryDescriptor, RichRemoteProvider } from './git/remotes/richRemoteProvider'; +import type { ProviderIntegration, RepositoryDescriptor } from './plus/integrations/providerIntegration'; import { isPromise } from './system/promise'; type Caches = { @@ -74,11 +72,11 @@ export class CacheProvider implements Disposable { getIssueOrPullRequest( id: string, - repo: RepositoryDescriptor | undefined, - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: RepositoryDescriptor, + integration: ProviderIntegration | undefined, cacheable: Cacheable, ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getRemoteKeyAndEtag(repo, integration); if (repo == null) { return this.get('issuesOrPrsById', `id:${id}:${key}`, etag, cacheable); @@ -88,7 +86,7 @@ export class CacheProvider implements Disposable { // getEnrichedAutolinks( // sha: string, - // remoteOrProvider: RichRemoteProvider | GitRemote, + // remoteOrProvider: Integration, // cacheable: Cacheable>, // ): CacheResult> { // const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); @@ -97,39 +95,43 @@ export class CacheProvider implements Disposable { getPullRequestForBranch( branch: string, - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: RepositoryDescriptor, + integration: ProviderIntegration | undefined, cacheable: Cacheable, ): CacheResult { const cache = 'prByBranch'; - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getRemoteKeyAndEtag(repo, integration); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache return this.get(cache, `branch:${branch}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); } getPullRequestForSha( sha: string, - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: RepositoryDescriptor, + integration: ProviderIntegration | undefined, cacheable: Cacheable, ): CacheResult { const cache = 'prsBySha'; - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getRemoteKeyAndEtag(repo, integration); // Wrap the cacheable so we can also add the result to the issuesOrPrsById cache return this.get(cache, `sha:${sha}:${key}`, etag, this.wrapPullRequestCacheable(cacheable, key, etag)); } getRepositoryDefaultBranch( - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: RepositoryDescriptor, + integration: ProviderIntegration | undefined, cacheable: Cacheable, ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getRemoteKeyAndEtag(repo, integration); return this.get('defaultBranch', `repo:${key}`, etag, cacheable); } getRepositoryMetadata( - remoteOrProvider: RichRemoteProvider | GitRemote, + repo: RepositoryDescriptor, + integration: ProviderIntegration | undefined, cacheable: Cacheable, ): CacheResult { - const { key, etag } = getRemoteKeyAndEtag(remoteOrProvider); + const { key, etag } = getRemoteKeyAndEtag(repo, integration); return this.get('repoMetadata', `repo:${key}`, etag, cacheable); } @@ -218,11 +220,6 @@ function getExpiresAt(cache: T, value: CacheValue | undefine } } -function getRemoteKeyAndEtag(remoteOrProvider: RemoteProvider | GitRemote) { - return { - key: remoteOrProvider.remoteKey, - etag: remoteOrProvider.hasRichIntegration() - ? `${remoteOrProvider.remoteKey}:${remoteOrProvider.maybeConnected ?? false}` - : remoteOrProvider.remoteKey, - }; +function getRemoteKeyAndEtag(repo: RepositoryDescriptor, integration?: ProviderIntegration) { + return { key: repo.key, etag: `${repo.key}:${integration?.maybeConnected ?? false}` }; } diff --git a/src/commands/openAssociatedPullRequestOnRemote.ts b/src/commands/openAssociatedPullRequestOnRemote.ts index 07cde8f..a79c805 100644 --- a/src/commands/openAssociatedPullRequestOnRemote.ts +++ b/src/commands/openAssociatedPullRequestOnRemote.ts @@ -36,7 +36,7 @@ export class OpenAssociatedPullRequestOnRemoteCommand extends ActiveEditorComman } else { try { const repo = await getRepositoryOrShowPicker('Open Associated Pull Request', undefined, undefined, { - filter: async r => (await this.container.git.getBestRemoteWithRichProvider(r.uri)) != null, + filter: async r => (await this.container.git.getBestRemoteWithIntegration(r.uri)) != null, }); if (repo == null) return; diff --git a/src/commands/openPullRequestOnRemote.ts b/src/commands/openPullRequestOnRemote.ts index 497c9b8..e8f0d0e 100644 --- a/src/commands/openPullRequestOnRemote.ts +++ b/src/commands/openPullRequestOnRemote.ts @@ -36,10 +36,13 @@ export class OpenPullRequestOnRemoteCommand extends Command { if (args?.pr == null) { if (args?.repoPath == null || args?.ref == null) return; - const remote = await this.container.git.getBestRemoteWithRichProvider(args.repoPath); - if (!remote?.hasRichIntegration()) return; + const remote = await this.container.git.getBestRemoteWithIntegration(args.repoPath); + if (remote == null) return; - const pr = await remote.provider.getPullRequestForCommit(args.ref); + const provider = this.container.integrations.getByRemote(remote); + if (provider == null) return; + + const pr = await provider.getPullRequestForCommit(remote.provider.repoDesc, args.ref); if (pr == null) { void window.showInformationMessage(`No pull request associated with '${shortenRevision(args.ref)}'`); return; diff --git a/src/commands/remoteProviders.ts b/src/commands/remoteProviders.ts index edaa1be..e868cdb 100644 --- a/src/commands/remoteProviders.ts +++ b/src/commands/remoteProviders.ts @@ -3,7 +3,7 @@ import type { Container } from '../container'; import type { GitCommit } from '../git/models/commit'; import { GitRemote } from '../git/models/remote'; import type { Repository } from '../git/models/repository'; -import type { RichRemoteProvider } from '../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../git/remotes/remoteProvider'; import { showRepositoryPicker } from '../quickpicks/repositoryPicker'; import { command } from '../system/command'; import { first } from '../system/iterable'; @@ -46,15 +46,15 @@ export class ConnectRemoteProviderCommand extends Command { } async execute(args?: ConnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let remotes: GitRemote[] | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { const remote = await repo.getRichRemote(); - if (remote?.provider != null && !(await remote.provider.isConnected())) { + if (remote?.provider != null) { repos.set(repo, remote); } } @@ -78,17 +78,20 @@ export class ConnectRemoteProviderCommand extends Command { } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: true }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: true }); if (remote == null) return false; } else { repoPath = args.repoPath; remotes = await this.container.git.getRemotesWithProviders(repoPath); - remote = remotes.find(r => r.name === args.remote) as GitRemote | undefined; - if (!remote?.hasRichIntegration()) return false; + remote = remotes.find(r => r.name === args.remote) as GitRemote | undefined; + if (!remote?.hasIntegration()) return false; } - const connected = await remote.provider.connect(); + const integration = this.container.integrations.getByRemote(remote); + if (integration == null) return false; + + const connected = await integration.connect(); if ( connected && !(remotes ?? (await this.container.git.getRemotesWithProviders(repoPath))).some(r => r.default) @@ -138,10 +141,10 @@ export class DisconnectRemoteProviderCommand extends Command { } async execute(args?: DisconnectRemoteProviderCommandArgs): Promise { - let remote: GitRemote | undefined; + let remote: GitRemote | undefined; let repoPath; if (args?.repoPath == null) { - const repos = new Map>(); + const repos = new Map>(); for (const repo of this.container.git.openRepositories) { const remote = await repo.getRichRemote(true); @@ -169,17 +172,16 @@ export class DisconnectRemoteProviderCommand extends Command { } else if (args?.remote == null) { repoPath = args.repoPath; - remote = await this.container.git.getBestRemoteWithRichProvider(repoPath, { includeDisconnected: false }); + remote = await this.container.git.getBestRemoteWithIntegration(repoPath, { includeDisconnected: false }); if (remote == null) return undefined; } else { repoPath = args.repoPath; - remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.name === args.remote) as - | GitRemote - | undefined; - if (!remote?.hasRichIntegration()) return undefined; + remote = (await this.container.git.getRemotesWithProviders(repoPath)).find(r => r.name === args.remote); + if (!remote?.hasIntegration()) return undefined; } - return remote.provider.disconnect(); + const integration = this.container.integrations.getByRemote(remote); + return integration?.disconnect(); } } diff --git a/src/container.ts b/src/container.ts index 2dd04b0..d8f1577 100644 --- a/src/container.ts +++ b/src/container.ts @@ -16,9 +16,6 @@ import { Commands, extensionPrefix } from './constants'; import { EventBus } from './eventBus'; import { GitFileSystemProvider } from './git/fsProvider'; import { GitProviderService } from './git/gitProviderService'; -import { GitHubAuthenticationProvider } from './git/remotes/github'; -import { GitLabAuthenticationProvider } from './git/remotes/gitlab'; -import { RichRemoteProviderService } from './git/remotes/remoteProviderService'; import { LineHoverController } from './hovers/lineHoverController'; import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; import { DraftService } from './plus/drafts/draftsService'; @@ -26,7 +23,8 @@ import { FocusService } from './plus/focus/focusService'; import { AccountAuthenticationProvider } from './plus/gk/account/authenticationProvider'; import { SubscriptionService } from './plus/gk/account/subscriptionService'; import { ServerConnection } from './plus/gk/serverConnection'; -import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; +import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthentication'; +import { IntegrationService } from './plus/integrations/integrationService'; import { RepositoryIdentityService } from './plus/repos/repositoryIdentityService'; import { registerAccountWebviewView } from './plus/webviews/account/registration'; import { registerFocusWebviewCommands, registerFocusWebviewPanel } from './plus/webviews/focus/registration'; @@ -495,7 +493,7 @@ export class Container { return this._git; } - private _github: Promise | undefined; + private _github: Promise | undefined; get github() { if (this._github == null) { this._github = this._loadGitHubApi(); @@ -506,7 +504,9 @@ export class Container { private async _loadGitHubApi() { try { - const github = new (await import(/* webpackChunkName: "github" */ './plus/github/github')).GitHubApi(this); + const github = new ( + await import(/* webpackChunkName: "github" */ './plus/integrations/providers/github/github') + ).GitHubApi(this); this._disposables.push(github); return github; } catch (ex) { @@ -515,7 +515,7 @@ export class Container { } } - private _gitlab: Promise | undefined; + private _gitlab: Promise | undefined; get gitlab() { if (this._gitlab == null) { this._gitlab = this._loadGitLabApi(); @@ -526,7 +526,9 @@ export class Container { private async _loadGitLabApi() { try { - const gitlab = new (await import(/* webpackChunkName: "gitlab" */ './plus/gitlab/gitlab')).GitLabApi(this); + const gitlab = new ( + await import(/* webpackChunkName: "gitlab" */ './plus/integrations/providers/gitlab/gitlab') + ).GitLabApi(this); this._disposables.push(gitlab); return gitlab; } catch (ex) { @@ -558,17 +560,20 @@ export class Container { private _integrationAuthentication: IntegrationAuthenticationService | undefined; get integrationAuthentication() { if (this._integrationAuthentication == null) { - this._disposables.push( - (this._integrationAuthentication = new IntegrationAuthenticationService(this)), - // Register any integration authentication providers - new GitHubAuthenticationProvider(this), - new GitLabAuthenticationProvider(this), - ); + this._disposables.push((this._integrationAuthentication = new IntegrationAuthenticationService(this))); } return this._integrationAuthentication; } + private _integrations: IntegrationService | undefined; + get integrations(): IntegrationService { + if (this._integrations == null) { + this._disposables.push((this._integrations = new IntegrationService(this))); + } + return this._integrations; + } + private readonly _keyboard: Keyboard; get keyboard() { return this._keyboard; @@ -640,14 +645,6 @@ export class Container { return this._repositoryPathMapping; } - private _richRemoteProviders: RichRemoteProviderService | undefined; - get richRemoteProviders(): RichRemoteProviderService { - if (this._richRemoteProviders == null) { - this._richRemoteProviders = new RichRemoteProviderService(this); - } - return this._richRemoteProviders; - } - private readonly _searchAndCompareView: SearchAndCompareView; get searchAndCompareView() { return this._searchAndCompareView; diff --git a/src/env/browser/providers.ts b/src/env/browser/providers.ts index 35eb926..7e35e66 100644 --- a/src/env/browser/providers.ts +++ b/src/env/browser/providers.ts @@ -1,7 +1,7 @@ import { Container } from '../../container'; import { GitCommandOptions } from '../../git/commandOptions'; // Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost -import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; +import { GitHubGitProvider } from '../../plus/integrations/providers/github/githubGitProvider'; import { GitProvider } from '../../git/gitProvider'; import { RepositoryWebPathMappingProvider } from './pathMapping/repositoryWebPathMappingProvider'; import { WorkspacesWebPathMappingProvider } from './pathMapping/workspacesWebPathMappingProvider'; diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index bafd22f..48cba85 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -314,8 +314,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (e.changed(RepositoryChange.Remotes, RepositoryChange.RemoteProviders, RepositoryChangeComparisonMode.Any)) { - const remotes = this._remotesCache.get(repo.path); - void disposeRemotes([remotes]); this._remotesCache.delete(repo.path); } @@ -1329,8 +1327,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (caches.length === 0 || caches.includes('remotes')) { - const remotes = this._remotesCache.get(repoPath); - void disposeRemotes([remotes]); this._remotesCache.delete(repoPath); } @@ -1364,7 +1360,6 @@ export class LocalGitProvider implements GitProvider, Disposable { } if (caches.length === 0 || caches.includes('remotes')) { - void disposeRemotes([...this._remotesCache.values()]); this._remotesCache.clear(); } @@ -4536,6 +4531,7 @@ export class LocalGitProvider implements GitProvider, Disposable { try { const data = await this.git.remote(repoPath!); const remotes = parseGitRemotes( + this.container, data, repoPath!, getRemoteProviderMatcher(this.container, providers), @@ -5696,14 +5692,3 @@ async function getEncoding(uri: Uri): Promise { const encodingExists = (await import(/* webpackChunkName: "encoding" */ 'iconv-lite')).encodingExists; return encodingExists(encoding) ? encoding : 'utf8'; } - -async function disposeRemotes(remotes: (Promise | undefined)[]) { - const remotesResults = await Promise.allSettled(remotes); - for (const remotes of remotesResults) { - for (const remote of getSettledValue(remotes) ?? []) { - if (remote.hasRichIntegration()) { - remote.provider?.dispose(); - } - } - } -} diff --git a/src/env/node/providers.ts b/src/env/node/providers.ts index e085df0..164a885 100644 --- a/src/env/node/providers.ts +++ b/src/env/node/providers.ts @@ -40,8 +40,9 @@ export async function getSupportedGitProviders(container: Container): Promise { } else if (remotes != null) { const [remote] = remotes; if ( - remote?.hasRichIntegration() && - !remote.provider.maybeConnected && + remote?.hasIntegration() && + !remote.maybeIntegrationConnected && configuration.get('integrations.enabled') ) { commands += `${separator}[$(plug) Connect to ${remote?.provider.name}${ diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index f065cba..a81038b 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -35,15 +35,7 @@ import { Logger } from '../system/logger'; import { getLogScope, setLogScopeExit } from '../system/logger.scope'; import { getBestPath, getScheme, isAbsolute, maybeUri, normalizePath } from '../system/path'; import type { Deferred } from '../system/promise'; -import { - asSettled, - cancellable, - defer, - getDeferredPromiseIfPending, - getSettledValue, - isPromise, - PromiseCancelledError, -} from '../system/promise'; +import { asSettled, defer, getDeferredPromiseIfPending, getSettledValue } from '../system/promise'; import { sortCompare } from '../system/string'; import { VisitedPathsTrie } from '../system/trie'; import type { @@ -69,15 +61,14 @@ import type { GitContributor } from './models/contributor'; import type { GitDiff, GitDiffFile, GitDiffFiles, GitDiffFilter, GitDiffLine, GitDiffShortStat } from './models/diff'; import type { GitFile } from './models/file'; import type { GitGraph } from './models/graph'; -import type { SearchedIssue } from './models/issue'; import type { GitLog } from './models/log'; import type { GitMergeStatus } from './models/merge'; -import type { SearchedPullRequest } from './models/pullRequest'; import type { GitRebaseStatus } from './models/rebase'; import type { GitBranchReference, GitReference } from './models/reference'; import { createRevisionRange, isSha, isUncommitted, isUncommittedParent } from './models/reference'; import type { GitReflog } from './models/reflog'; -import { getVisibilityCacheKey, GitRemote } from './models/remote'; +import type { GitRemote } from './models/remote'; +import { getVisibilityCacheKey } from './models/remote'; import type { RepositoryChangeEvent } from './models/repository'; import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from './models/repository'; import type { GitStash } from './models/stash'; @@ -88,7 +79,6 @@ import type { GitUser } from './models/user'; import type { GitWorktree } from './models/worktree'; import { parseGitRemoteUrl } from './parsers/remoteParser'; import type { RemoteProvider } from './remotes/remoteProvider'; -import type { RichRemoteProvider } from './remotes/richRemoteProvider'; import type { GitSearch, SearchQuery } from './search'; const emptyArray = Object.freeze([]) as unknown as any[]; @@ -217,10 +207,7 @@ export class GitProviderService implements Disposable { readonly supportedSchemes = new Set(); - private readonly _bestRemotesCache = new Map< - RepoComparisonKey, - Promise[]> - >(); + private readonly _bestRemotesCache = new Map[]>>(); private readonly _disposable: Disposable; private _initializing: Deferred | undefined; private readonly _pendingRepositories = new Map>(); @@ -235,7 +222,7 @@ export class GitProviderService implements Disposable { window.onDidChangeWindowState(this.onWindowStateChanged, this), workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this), configuration.onDidChange(this.onConfigurationChanged, this), - container.richRemoteProviders.onAfterDidChangeConnectionState(e => { + container.integrations.onDidChangeConnectionState(e => { if (e.reason === 'connected') { resetAvatarCache('failed'); } @@ -2048,78 +2035,6 @@ export class GitProviderService implements Disposable { return provider.getPreviousComparisonUrisForLine(path, uri, editorLine, ref, skip); } - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyPullRequests( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyPullRequests(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - - @debug({ args: { 0: remoteOrProvider => remoteOrProvider.name } }) - async getMyIssues( - remoteOrProvider: GitRemote | RichRemoteProvider, - options?: { timeout?: number }, - ): Promise { - let provider; - if (GitRemote.is(remoteOrProvider)) { - ({ provider } = remoteOrProvider); - if (!provider?.hasRichIntegration()) return undefined; - } else { - provider = remoteOrProvider; - } - - let timeout; - if (options != null) { - ({ timeout, ...options } = options); - } - - let promiseOrPRs = provider.searchMyIssues(); - if (promiseOrPRs == null || !isPromise(promiseOrPRs)) { - return promiseOrPRs; - } - - if (timeout != null && timeout > 0) { - promiseOrPRs = cancellable(promiseOrPRs, timeout); - } - - try { - return await promiseOrPRs; - } catch (ex) { - if (ex instanceof PromiseCancelledError) throw ex; - - return undefined; - } - } - @log() async getIncomingActivity( repoPath: string | Uri, @@ -2192,15 +2107,18 @@ export class GitProviderService implements Disposable { // Only check remotes that have extra weighting and less than the default if (weight > 0 && weight < 1000 && !originalFound) { - const p = remote.provider; + const integration = remote.getIntegration(); if ( - p.hasRichIntegration() && - (p.maybeConnected || - (p.maybeConnected === undefined && p.shouldConnect && (await p.isConnected()))) + integration != null && + (integration.maybeConnected || + (integration.maybeConnected === undefined && (await integration.isConnected()))) ) { if (cancellation?.isCancellationRequested) throw new CancellationError(); - const repo = await p.getRepositoryMetadata(cancellation); + const repo = await integration.getRepositoryMetadata( + remote.provider.repoDesc, + cancellation, + ); if (cancellation?.isCancellationRequested) throw new CancellationError(); @@ -2228,19 +2146,22 @@ export class GitProviderService implements Disposable { } @log() - async getBestRemoteWithRichProvider( + async getBestRemoteWithIntegration( repoPath: string | Uri, options?: { includeDisconnected?: boolean }, cancellation?: CancellationToken, - ): Promise | undefined> { + ): Promise | undefined> { const remotes = await this.getBestRemotesWithProviders(repoPath, cancellation); const includeDisconnected = options?.includeDisconnected ?? false; for (const r of remotes) { - if (r.hasRichIntegration()) { - if (includeDisconnected || r.provider.maybeConnected === true) return r; - if (r.provider.maybeConnected === undefined && r.default) { - if (await r.provider.isConnected()) return r; + if (r.hasIntegration()) { + const provider = this.container.integrations.getByRemote(r); + if (provider != null) { + if (includeDisconnected || provider.maybeConnected === true) return r; + if (provider.maybeConnected === undefined && r.default) { + if (await provider.isConnected()) return r; + } } } } @@ -2271,13 +2192,13 @@ export class GitProviderService implements Disposable { } @log() - async getRemotesWithRichProviders( + async getRemotesWithIntegrations( repoPath: string | Uri, options?: { sort?: boolean }, cancellation?: CancellationToken, - ): Promise[]> { + ): Promise[]> { const remotes = await this.getRemotes(repoPath, options, cancellation); - return remotes.filter((r: GitRemote): r is GitRemote => r.hasRichIntegration()); + return remotes.filter((r: GitRemote): r is GitRemote => r.hasIntegration()); } getBestRepository(): Repository | undefined; diff --git a/src/git/models/branch.ts b/src/git/models/branch.ts index a1a0f40..6adf76c 100644 --- a/src/git/models/branch.ts +++ b/src/git/models/branch.ts @@ -106,12 +106,15 @@ export class GitBranch implements GitBranchReference { include?: PullRequestState[]; }): Promise { const remote = await this.getRemote(); - return remote?.hasRichIntegration() - ? remote.provider.getPullRequestForBranch( - this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(), - options, - ) - : undefined; + if (remote?.provider == null) return undefined; + + return this.container.integrations + .getByRemote(remote) + ?.getPullRequestForBranch( + remote.provider.repoDesc, + this.getTrackingWithoutRemote() ?? this.getNameWithoutRemote(), + options, + ); } @memoize() diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index e23eebd..a2d2eb0 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -416,15 +416,18 @@ export class GitCommit implements GitRevisionReference { } async getAssociatedPullRequest(remote?: GitRemote): Promise { - remote ??= await this.container.git.getBestRemoteWithRichProvider(this.repoPath); - return remote?.hasRichIntegration() ? remote.provider.getPullRequestForCommit(this.ref) : undefined; + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (!remote?.hasIntegration()) return undefined; + + const provider = this.container.integrations.getByRemote(remote); + return provider?.getPullRequestForCommit(remote.provider.repoDesc, this.ref); } async getEnrichedAutolinks(remote?: GitRemote): Promise | undefined> { if (this.isUncommitted) return undefined; - remote ??= await this.container.git.getBestRemoteWithRichProvider(this.repoPath); - if (!remote?.hasRichIntegration()) return undefined; + remote ??= await this.container.git.getBestRemoteWithIntegration(this.repoPath); + if (remote?.provider == null) return undefined; // TODO@eamodio should we cache these? Seems like we would use more memory than it's worth // async function getCore(this: GitCommit): Promise | undefined> { diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index 4f4bd7a..bc5eff3 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -2,17 +2,17 @@ import type { ColorTheme } from 'vscode'; import { Uri, window } from 'vscode'; import { GlyphChars } from '../../constants'; import { Container } from '../../container'; +import type { ProviderIntegration } from '../../plus/integrations/providerIntegration'; import { memoize } from '../../system/decorators/memoize'; import { equalsIgnoreCase, sortCompare } from '../../system/string'; import { isLightTheme } from '../../system/utils'; import { parseGitRemoteUrl } from '../parsers/remoteParser'; import type { RemoteProvider } from '../remotes/remoteProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; export type GitRemoteType = 'fetch' | 'push'; -export class GitRemote { - static getHighlanderProviders(remotes: GitRemote[]) { +export class GitRemote { + static getHighlanderProviders(remotes: GitRemote[]) { if (remotes.length === 0) return undefined; const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); @@ -24,7 +24,7 @@ export class GitRemote[]) { + static getHighlanderProviderName(remotes: GitRemote[]) { if (remotes.length === 0) return undefined; const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default); @@ -52,6 +52,7 @@ export class GitRemote { - return this.provider?.hasRichIntegration() ?? false; + getIntegration(): ProviderIntegration | undefined { + return this.provider != null ? this.container.integrations.getByRemote(this) : undefined; } - get maybeConnected(): boolean | undefined { - return this.provider == null ? false : this.provider.maybeConnected; + hasIntegration(): this is GitRemote { + return this.provider != null && this.container.integrations.supports(this.provider.id); } matches(url: string): boolean; diff --git a/src/git/models/remoteProvider.ts b/src/git/models/remoteProvider.ts index 4e76512..4df7057 100644 --- a/src/git/models/remoteProvider.ts +++ b/src/git/models/remoteProvider.ts @@ -4,3 +4,9 @@ export interface RemoteProviderReference { readonly domain: string; readonly icon: string; } + +export interface Provider extends RemoteProviderReference { + getIgnoreSSLErrors(): boolean | 'force'; + reauthenticate(): Promise; + trackRequestException(): void; +} diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts index fa012ab..51a62a0 100644 --- a/src/git/models/repository.ts +++ b/src/git/models/repository.ts @@ -25,7 +25,7 @@ import { updateRecordValue } from '../../system/object'; import { basename, normalizePath } from '../../system/path'; import { sortCompare } from '../../system/string'; import type { GitDir, GitProviderDescriptor, GitRepositoryCaches } from '../gitProvider'; -import type { RichRemoteProvider } from '../remotes/richRemoteProvider'; +import type { RemoteProvider } from '../remotes/remoteProvider'; import type { GitSearch, SearchQuery } from '../search'; import type { BranchSortOptions, GitBranch } from './branch'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from './branch'; @@ -739,8 +739,8 @@ export class Repository implements Disposable { return options?.filter != null ? remotes.filter(options.filter) : remotes; } - async getRichRemote(connectedOnly: boolean = false): Promise | undefined> { - return this.container.git.getBestRemoteWithRichProvider(this.uri, { includeDisconnected: !connectedOnly }); + async getRichRemote(connectedOnly: boolean = false): Promise | undefined> { + return this.container.git.getBestRemoteWithIntegration(this.uri, { includeDisconnected: !connectedOnly }); } getStash(): Promise { diff --git a/src/git/parsers/remoteParser.ts b/src/git/parsers/remoteParser.ts index 4cfef2b..c7c93d9 100644 --- a/src/git/parsers/remoteParser.ts +++ b/src/git/parsers/remoteParser.ts @@ -1,3 +1,4 @@ +import type { Container } from '../../container'; import { maybeStopWatch } from '../../system/stopwatch'; import type { GitRemoteType } from '../models/remote'; import { GitRemote } from '../models/remote'; @@ -8,6 +9,7 @@ const emptyStr = ''; const remoteRegex = /^(.*)\t(.*)\s\((.*)\)$/gm; export function parseGitRemotes( + container: Container, data: string, repoPath: string, remoteProviderMatcher: ReturnType, @@ -45,22 +47,25 @@ export function parseGitRemotes( remote = remotes.get(name); if (remote == null) { - remote = new GitRemote(repoPath, name, scheme, domain, path, remoteProviderMatcher(url, domain, path), [ - { url: url, type: type as GitRemoteType }, - ]); + remote = new GitRemote( + container, + repoPath, + name, + scheme, + domain, + path, + remoteProviderMatcher(url, domain, path), + [{ url: url, type: type as GitRemoteType }], + ); remotes.set(name, remote); } else { remote.urls.push({ url: url, type: type as GitRemoteType }); if (remote.provider != null && type !== 'push') continue; - if (remote.provider?.hasRichIntegration()) { - remote.provider.dispose(); - } - const provider = remoteProviderMatcher(url, domain, path); if (provider == null) continue; - remote = new GitRemote(repoPath, name, scheme, domain, path, provider, remote.urls); + remote = new GitRemote(container, repoPath, name, scheme, domain, path, provider, remote.urls); remotes.set(name, remote); } } while (true); diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 1edc7b7..6d1a658 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -1,65 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { Range } from 'vscode'; +import { Uri } from 'vscode'; import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; import { GlyphChars } from '../../constants'; -import type { Container } from '../../container'; import type { GkProviderId } from '../../gk/models/repositoryIdentities'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; +import type { GitHubRepositoryDescriptor } from '../../plus/integrations/providers/github'; import type { Brand, Unbrand } from '../../system/brand'; import { fromNow } from '../../system/date'; -import { log } from '../../system/decorators/log'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; -import { supportedInVSCodeVersion } from '../../system/utils'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; import type { RemoteProviderId } from './remoteProvider'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import { RemoteProvider } from './remoteProvider'; const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-L(\d+))?$/; -const authProvider = Object.freeze({ id: 'github', scopes: ['repo', 'read:user', 'user:email'] }); -const enterpriseAuthProvider = Object.freeze({ id: 'github-enterprise', scopes: ['repo', 'read:user', 'user:email'] }); - function isGitHubDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'github.com'); } -type GitHubRepositoryDescriptor = - | { - owner: string; - name: string; - } - | Record; - -export class GitHubRemote extends RichRemoteProvider { - @memoize() - protected get authProvider() { - return isGitHubDotCom(this.domain) ? authProvider : enterpriseAuthProvider; - } - - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitHubRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -174,7 +141,11 @@ export class GitHubRemote extends RichRemoteProvider description: `${this.name} Issue or Pull Request ${ownerAndRepo}#${num}`, - descriptor: { owner: owner, name: repo } satisfies GitHubRepositoryDescriptor, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitHubRepositoryDescriptor, }); } while (true); }, @@ -207,15 +178,10 @@ export class GitHubRemote extends RichRemoteProvider return this.formatName('GitHub'); } - @log() - override async connect(): Promise { - if (!isGitHubDotCom(this.domain)) { - if (!(await ensurePaidPlan('GitHub Enterprise instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitHubRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -327,117 +293,6 @@ export class GitHubRemote extends RichRemoteProvider if (branch) return `${this.encodeUrl(`${this.baseUrl}/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected override async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - descriptor: GitHubRepositoryDescriptor | undefined, - ): Promise { - let owner; - let repo; - if (descriptor != null) { - ({ owner, name: repo } = descriptor); - } else { - [owner, repo] = this.splitPath(); - } - return (await this.container.github)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const toGitHubPullRequestState = (await import(/* webpackChunkName: "github" */ '../../plus/github/models')) - .toGitHubPullRequestState; - return (await this.container.github)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => toGitHubPullRequestState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.github)?.getRepositoryMetadata(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyPullRequests({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyPullRequests(this, accessToken, { - repos: [this.path], - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyIssues({ - accessToken, - }: AuthenticationSession): Promise { - return (await this.container.github)?.searchMyIssues(this, accessToken, { - repos: [this.path], - baseUrl: this.apiBaseUrl, - }); - } } const gitHubNoReplyAddressRegex = /^(?:(\d+)\+)?([a-zA-Z\d-]{1,39})@users\.noreply\.(.*)$/i; @@ -451,87 +306,3 @@ export function getGitHubNoReplyAddressParts( const [, userId, login, authority] = match; return { userId: userId, login: login, authority: authority }; } - -export class GitHubAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('github-enterprise', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the GitHub Access Tokens Page', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), - ); - } - }), - ); - - input.password = true; - input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? `Paste your [GitHub Personal Access Token](https://${ - descriptor?.domain ?? 'github.com' - }/settings/tokens "Get your GitHub Access Token")` - : 'Paste your GitHub Personal Access Token'; - - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } -} diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index 2550889..1c98862 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -1,63 +1,32 @@ -import type { AuthenticationSession, Disposable, QuickInputButton, Range } from 'vscode'; -import { env, ThemeIcon, Uri, window } from 'vscode'; +import type { Range, Uri } from 'vscode'; import type { Autolink, DynamicAutolinkReference, MaybeEnrichedAutolink } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; import { GlyphChars } from '../../constants'; -import type { Container } from '../../container'; import type { GkProviderId } from '../../gk/models/repositoryIdentities'; -import type { - IntegrationAuthenticationProvider, - IntegrationAuthenticationSessionDescriptor, -} from '../../plus/integrationAuthentication'; +import type { GitLabRepositoryDescriptor } from '../../plus/integrations/providers/gitlab'; import type { Brand, Unbrand } from '../../system/brand'; import { fromNow } from '../../system/date'; -import { log } from '../../system/decorators/log'; +import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; import { equalsIgnoreCase, escapeMarkdown, unescapeMarkdown } from '../../system/string'; -import { supportedInVSCodeVersion } from '../../system/utils'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; import { getIssueOrPullRequestMarkdownIcon } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; import { isSha } from '../models/reference'; import type { Repository } from '../models/repository'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; import type { RemoteProviderId } from './remoteProvider'; -import { ensurePaidPlan, RichRemoteProvider } from './richRemoteProvider'; +import { RemoteProvider } from './remoteProvider'; const autolinkFullIssuesRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?#([0-9]+)\b(?!]\()/g; const autolinkFullMergeRequestsRegex = /\b([^/\s]+\/[^/\s]+?)(?:\\)?!([0-9]+)\b(?!]\()/g; const fileRegex = /^\/([^/]+)\/([^/]+?)\/-\/blob(.+)$/i; const rangeRegex = /^L(\d+)(?:-(\d+))?$/; -const authProvider = Object.freeze({ id: 'gitlab', scopes: ['read_api', 'read_user', 'read_repository'] }); - function isGitLabDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'gitlab.com'); } -type GitLabRepositoryDescriptor = - | { - owner: string; - name: string; - } - | Record; - -export class GitLabRemote extends RichRemoteProvider { - protected get authProvider() { - return authProvider; - } - - constructor( - container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom: boolean = false, - ) { - super(container, domain, path, protocol, name, custom); +export class GitLabRemote extends RemoteProvider { + constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + super(domain, path, protocol, name, custom); } get apiBaseUrl() { @@ -169,7 +138,11 @@ export class GitLabRemote extends RichRemoteProvider type: 'issue', description: `${this.name} Issue ${ownerAndRepo}#${num}`, - descriptor: { owner: owner, name: repo } satisfies GitLabRepositoryDescriptor, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -266,7 +239,11 @@ export class GitLabRemote extends RichRemoteProvider type: 'pullrequest', description: `${this.name} Merge Request !${num} from ${ownerAndRepo}`, - descriptor: { owner: owner, name: repo } satisfies GitLabRepositoryDescriptor, + descriptor: { + key: this.remoteKey, + owner: owner, + name: repo, + } satisfies GitLabRepositoryDescriptor, }); } while (true); }, @@ -294,15 +271,10 @@ export class GitLabRemote extends RichRemoteProvider return this.formatName('GitLab'); } - @log() - override async connect(): Promise { - if (!equalsIgnoreCase(this.domain, 'gitlab.com')) { - if (!(await ensurePaidPlan('GitLab self-managed instance', this.container))) { - return false; - } - } - - return super.connect(); + @memoize() + override get repoDesc(): GitLabRepositoryDescriptor { + const [owner, repo] = this.splitPath(); + return { key: this.remoteKey, owner: owner, name: repo }; } async getLocalInfoFromRemoteUri( @@ -402,194 +374,4 @@ export class GitLabRemote extends RichRemoteProvider if (branch) return `${this.encodeUrl(`${this.baseUrl}/-/blob/${branch}/${fileName}`)}${line}`; return `${this.encodeUrl(`${this.baseUrl}?path=${fileName}`)}${line}`; } - - protected override async getProviderAccountForCommit( - { accessToken }: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, owner, repo, ref, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderAccountForEmail( - { accessToken }: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, owner, repo, email, { - ...options, - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderIssueOrPullRequest( - { accessToken }: AuthenticationSession, - id: string, - descriptor: GitLabRepositoryDescriptor | undefined, - ): Promise { - let owner; - let repo; - if (descriptor != null) { - ({ owner, name: repo } = descriptor); - } else { - [owner, repo] = this.splitPath(); - } - return (await this.container.gitlab)?.getIssueOrPullRequest(this, accessToken, owner, repo, Number(id), { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForBranch( - { accessToken }: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const [owner, repo] = this.splitPath(); - const { include, ...opts } = options ?? {}; - - const toGitLabMergeRequestState = (await import(/* webpackChunkName: "gitlab" */ '../../plus/gitlab/models')) - .toGitLabMergeRequestState; - return (await this.container.gitlab)?.getPullRequestForBranch(this, accessToken, owner, repo, branch, { - ...opts, - include: include?.map(s => toGitLabMergeRequestState(s)), - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderPullRequestForCommit( - { accessToken }: AuthenticationSession, - ref: string, - ): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, owner, repo, ref, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise { - const [owner, repo] = this.splitPath(); - return (await this.container.gitlab)?.getRepositoryMetadata(this, accessToken, owner, repo, { - baseUrl: this.apiBaseUrl, - }); - } - - protected override async searchProviderMyPullRequests( - _session: AuthenticationSession, - ): Promise { - return Promise.resolve(undefined); - } - - protected override async searchProviderMyIssues( - _session: AuthenticationSession, - ): Promise { - return Promise.resolve(undefined); - } -} - -export class GitLabAuthenticationProvider implements Disposable, IntegrationAuthenticationProvider { - private readonly _disposable: Disposable; - - constructor(container: Container) { - this._disposable = container.integrationAuthentication.registerProvider('gitlab', this); - } - - dispose() { - this._disposable.dispose(); - } - - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { - return descriptor?.domain ?? ''; - } - - async createSession( - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const input = window.createInputBox(); - input.ignoreFocusOut = true; - - const disposables: Disposable[] = []; - - let token; - try { - const infoButton: QuickInputButton = { - iconPath: new ThemeIcon(`link-external`), - tooltip: 'Open the GitLab Access Tokens Page', - }; - - token = await new Promise(resolve => { - disposables.push( - input.onDidHide(() => resolve(undefined)), - input.onDidChangeValue(() => (input.validationMessage = undefined)), - input.onDidAccept(() => { - const value = input.value.trim(); - if (!value) { - input.validationMessage = 'A personal access token is required'; - return; - } - - resolve(value); - }), - input.onDidTriggerButton(e => { - if (e === infoButton) { - void env.openExternal( - Uri.parse( - `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, - ), - ); - } - }), - ); - - input.password = true; - input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; - input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; - input.prompt = input.prompt = supportedInVSCodeVersion('input-prompt-links') - ? `Paste your [GitLab Personal Access Token](https://${ - descriptor?.domain ?? 'gitlab.com' - }/-/profile/personal_access_tokens "Get your GitLab Access Token")` - : 'Paste your GitLab Personal Access Token'; - input.buttons = [infoButton]; - - input.show(); - }); - } finally { - input.dispose(); - disposables.forEach(d => void d.dispose()); - } - - if (!token) return undefined; - - return { - id: this.getSessionId(descriptor), - accessToken: token, - scopes: [], - account: { - id: '', - label: '', - }, - }; - } } diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 24307f5..d4fcf02 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -3,13 +3,13 @@ import { env } from 'vscode'; import type { DynamicAutolinkReference } from '../../annotations/autolinks'; import type { AutolinkReference } from '../../config'; import type { GkProviderId } from '../../gk/models/repositoryIdentities'; +import type { RepositoryDescriptor } from '../../plus/integrations/providerIntegration'; import { memoize } from '../../system/decorators/memoize'; import { encodeUrl } from '../../system/encoding'; import type { RemoteProviderReference } from '../models/remoteProvider'; import type { RemoteResource } from '../models/remoteResource'; import { RemoteResourceType } from '../models/remoteResource'; import type { Repository } from '../models/repository'; -import type { RichRemoteProvider } from './richRemoteProvider'; export type RemoteProviderId = | 'azure-devops' @@ -22,8 +22,9 @@ export type RemoteProviderId = | 'gitlab' | 'google-source'; -export abstract class RemoteProvider implements RemoteProviderReference { - readonly type: 'simple' | 'rich' = 'simple'; +export abstract class RemoteProvider + implements RemoteProviderReference +{ protected readonly _name: string | undefined; constructor( @@ -36,11 +37,6 @@ export abstract class RemoteProvider implements RemoteProviderReference { this._name = name; } - @memoize() - get remoteKey() { - return this.domain ? `${this.domain}/${this.path}` : this.path; - } - get autolinks(): (AutolinkReference | DynamicAutolinkReference)[] { return []; } @@ -61,6 +57,15 @@ export abstract class RemoteProvider implements RemoteProviderReference { return this.splitPath()[0]; } + @memoize() + get remoteKey() { + return this.domain ? `${this.domain}/${this.path}` : this.path; + } + + get repoDesc(): T { + return { owner: this.owner, name: this.repoName } as unknown as T; + } + get repoName(): string | undefined { return this.splitPath()[1]; } @@ -78,14 +83,6 @@ export abstract class RemoteProvider implements RemoteProviderReference { await env.clipboard.writeText(url); } - hasRichIntegration(): this is RichRemoteProvider { - return this.type === 'rich'; - } - - get maybeConnected(): boolean | undefined { - return false; - } - abstract getLocalInfoFromRemoteUri( repository: Repository, uri: Uri, diff --git a/src/git/remotes/remoteProviderService.ts b/src/git/remotes/remoteProviderService.ts deleted file mode 100644 index a92d036..0000000 --- a/src/git/remotes/remoteProviderService.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Event } from 'vscode'; -import { EventEmitter } from 'vscode'; -import type { Container } from '../../container'; - -export interface ConnectionStateChangeEvent { - key: string; - reason: 'connected' | 'disconnected'; -} - -export class RichRemoteProviderService { - private readonly _onDidChangeConnectionState = new EventEmitter(); - get onDidChangeConnectionState(): Event { - return this._onDidChangeConnectionState.event; - } - - private readonly _onAfterDidChangeConnectionState = new EventEmitter(); - get onAfterDidChangeConnectionState(): Event { - return this._onAfterDidChangeConnectionState.event; - } - - private readonly _connectedCache = new Set(); - - constructor(private readonly container: Container) {} - - connected(key: string): void { - // Only fire events if the key is being connected for the first time - if (this._connectedCache.has(key)) return; - - this._connectedCache.add(key); - this.container.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); - - this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }); - setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); - } - - disconnected(key: string): void { - // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe - // if (!_connectedCache.has(key)) return; - this._connectedCache.delete(key); - this.container.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); - - this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }); - setTimeout(() => this._onAfterDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); - } - - isConnected(key?: string): boolean { - return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); - } -} diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index b7b5896..75d19c9 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -28,12 +28,12 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: 'github.com', - creator: (container: Container, domain: string, path: string) => new GitHubRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitHubRemote(domain, path), }, { custom: false, matcher: 'gitlab.com', - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -48,7 +48,7 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: /\bgitlab\b/i, - creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(domain, path), }, { custom: false, @@ -127,10 +127,10 @@ function getCustomProviderCreator(cfg: RemotesConfig) { new GiteaRemote(domain, path, cfg.protocol, cfg.name, true); case 'GitHub': return (container: Container, domain: string, path: string) => - new GitHubRemote(container, domain, path, cfg.protocol, cfg.name, true); + new GitHubRemote(domain, path, cfg.protocol, cfg.name, true); case 'GitLab': return (container: Container, domain: string, path: string) => - new GitLabRemote(container, domain, path, cfg.protocol, cfg.name, true); + new GitLabRemote(domain, path, cfg.protocol, cfg.name, true); default: return undefined; } diff --git a/src/git/remotes/richRemoteProvider.ts b/src/git/remotes/richRemoteProvider.ts deleted file mode 100644 index 7dd68ba..0000000 --- a/src/git/remotes/richRemoteProvider.ts +++ /dev/null @@ -1,621 +0,0 @@ -/* eslint-disable @typescript-eslint/no-confusing-void-expression */ -import type { - AuthenticationSession, - AuthenticationSessionsChangeEvent, - CancellationToken, - Event, - MessageItem, -} from 'vscode'; -import { authentication, CancellationError, Disposable, EventEmitter, window } from 'vscode'; -import { wrapForForcedInsecureSSL } from '@env/fetch'; -import { isWeb } from '@env/platform'; -import type { Container } from '../../container'; -import { AuthenticationError, ProviderRequestClientError } from '../../errors'; -import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; -import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../../plus/gk/account/subscription'; -import type { IntegrationAuthenticationSessionDescriptor } from '../../plus/integrationAuthentication'; -import { configuration } from '../../system/configuration'; -import { gate } from '../../system/decorators/gate'; -import { debug, log, logName } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import type { Account } from '../models/author'; -import type { DefaultBranch } from '../models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../models/issue'; -import type { PullRequest, PullRequestState, SearchedPullRequest } from '../models/pullRequest'; -import type { RepositoryMetadata } from '../models/repositoryMetadata'; -import { RemoteProvider } from './remoteProvider'; - -// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart - -export type RepositoryDescriptor = Record; - -@logName((c, name) => `${name}(${c.remoteKey})`) -export abstract class RichRemoteProvider - extends RemoteProvider - implements Disposable -{ - override readonly type: 'simple' | 'rich' = 'rich'; - - private readonly _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - - private readonly _disposable: Disposable; - - constructor( - protected readonly container: Container, - domain: string, - path: string, - protocol?: string, - name?: string, - custom?: boolean, - ) { - super(domain, path, protocol, name, custom); - - this._disposable = Disposable.from( - configuration.onDidChange(e => { - if (configuration.changed(e, 'remotes')) { - this._ignoreSSLErrors.clear(); - } - }), - // TODO@eamodio revisit how connections are linked or not - container.richRemoteProviders.onDidChangeConnectionState(e => { - if (e.key !== this.key) return; - - if (e.reason === 'disconnected') { - void this.disconnect({ silent: true }); - } else if (e.reason === 'connected') { - void this.ensureSession(false); - } - }), - authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), - ); - - container.context.subscriptions.push(this._disposable); - - // If we think we should be connected, try to - if (this.shouldConnect) { - void this.isConnected(); - } - } - - disposed = false; - - dispose() { - this._disposable.dispose(); - this.disposed = true; - } - - abstract get apiBaseUrl(): string; - protected abstract get authProvider(): { id: string; scopes: string[] }; - protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { - return { domain: this.domain, scopes: this.authProvider.scopes }; - } - - private get key() { - return this.custom ? `${this.name}:${this.domain}` : this.name; - } - - private get connectedKey(): `connected:${string}` { - return `connected:${this.key}`; - } - - override get maybeConnected(): boolean | undefined { - return this._session === undefined ? undefined : this._session !== null; - } - - // This is a hack for now, since providers come and go with remotes - get shouldConnect(): boolean { - return this.container.richRemoteProviders.isConnected(this.key); - } - - protected _session: AuthenticationSession | null | undefined; - protected session() { - if (this._session === undefined) { - return this.ensureSession(false); - } - return this._session ?? undefined; - } - - private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { - if (e.provider.id === this.authProvider.id) { - void this.ensureSession(false); - } - } - - @log() - async connect(): Promise { - try { - const session = await this.ensureSession(true); - return Boolean(session); - } catch (ex) { - return false; - } - } - - @gate() - @log() - async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise { - if (options?.currentSessionOnly && this._session === null) return; - - const connected = this._session != null; - - if (connected && !options?.silent) { - if (options?.currentSessionOnly) { - void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); - } else { - const disable = { title: 'Disable' }; - const signout = { title: 'Disable & Sign Out' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - - let result: MessageItem | undefined; - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, - { modal: true }, - disable, - signout, - cancel, - ); - } else { - result = await window.showWarningMessage( - `Are you sure you want to disable the rich integration with ${this.name}?`, - { modal: true }, - disable, - cancel, - ); - } - - if (result == null || result === cancel) return; - if (result === signout) { - void this.container.integrationAuthentication.deleteSession( - this.authProvider.id, - this.authProviderDescriptor, - ); - } - } - } - - this.resetRequestExceptionCount(); - this._session = null; - - if (connected) { - // Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) - if (!options?.currentSessionOnly) { - void this.container.storage.storeWorkspace(this.connectedKey, false); - } - - this._onDidChange.fire(); - if (!options?.silent && !options?.currentSessionOnly) { - this.container.richRemoteProviders.disconnected(this.key); - } - } - } - - @log() - async reauthenticate(): Promise { - if (this._session === undefined) return; - - this._session = undefined; - void (await this.ensureSession(true, true)); - } - - private requestExceptionCount = 0; - - resetRequestExceptionCount() { - this.requestExceptionCount = 0; - } - - private handleProviderException(ex: Error, scope: LogScope | undefined, defaultValue: T): T { - if (ex instanceof CancellationError) return defaultValue; - - Logger.error(ex, scope); - - if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { - this.trackRequestException(); - } - return defaultValue; - } - - @debug() - trackRequestException() { - this.requestExceptionCount++; - - if (this.requestExceptionCount >= 5 && this._session !== null) { - void this.disconnect({ currentSessionOnly: true }); - } - } - - @gate() - @debug({ exit: true }) - async isConnected(): Promise { - return (await this.session()) != null; - } - - @gate() - @debug() - async getAccountForCommit( - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForCommit(this._session!, ref, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - - protected abstract getProviderAccountForCommit( - session: AuthenticationSession, - ref: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @gate() - @debug() - async getAccountForEmail( - email: string, - options?: { - avatarSize?: number; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - const author = await this.getProviderAccountForEmail(this._session!, email, options); - this.resetRequestExceptionCount(); - return author; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - - protected abstract getProviderAccountForEmail( - session: AuthenticationSession, - email: string, - options?: { - avatarSize?: number; - }, - ): Promise; - - @debug() - async getDefaultBranch(): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const defaultBranch = this.container.cache.getRepositoryDefaultBranch(this, () => ({ - value: (async () => { - try { - const result = await this.getProviderDefaultBranch(this._session!); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return defaultBranch; - } - - protected abstract getProviderDefaultBranch({ - accessToken, - }: AuthenticationSession): Promise; - - private _ignoreSSLErrors = new Map(); - getIgnoreSSLErrors(): boolean | 'force' { - if (isWeb) return false; - - let ignoreSSLErrors = this._ignoreSSLErrors.get(this.id); - if (ignoreSSLErrors === undefined) { - const cfg = configuration - .get('remotes') - ?.find(remote => remote.type.toLowerCase() === this.id && remote.domain === this.domain); - ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; - this._ignoreSSLErrors.set(this.id, ignoreSSLErrors); - } - - return ignoreSSLErrors; - } - - @debug() - async getRepositoryMetadata(_cancellation?: CancellationToken): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const metadata = this.container.cache.getRepositoryMetadata(this, () => ({ - value: (async () => { - try { - const result = await this.getProviderRepositoryMetadata(this._session!); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return metadata; - } - - protected abstract getProviderRepositoryMetadata({ - accessToken, - }: AuthenticationSession): Promise; - - @debug() - async getIssueOrPullRequest(id: string, repo: T | undefined): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const issueOrPR = this.container.cache.getIssueOrPullRequest(id, repo, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderIssueOrPullRequest(this._session!, id, repo); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return issueOrPR; - } - - protected abstract getProviderIssueOrPullRequest( - session: AuthenticationSession, - id: string, - repo: T | undefined, - ): Promise; - - @debug() - async getPullRequestForBranch( - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const pr = this.container.cache.getPullRequestForBranch(branch, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderPullRequestForBranch(this._session!, branch, options); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return pr; - } - - protected abstract getProviderPullRequestForBranch( - session: AuthenticationSession, - branch: string, - options?: { - avatarSize?: number; - include?: PullRequestState[]; - }, - ): Promise; - - @debug() - async getPullRequestForCommit(ref: string): Promise { - const scope = getLogScope(); - - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - const pr = this.container.cache.getPullRequestForSha(ref, this, () => ({ - value: (async () => { - try { - const result = await this.getProviderPullRequestForCommit(this._session!, ref); - this.resetRequestExceptionCount(); - return result; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - })(), - })); - return pr; - } - - protected abstract getProviderPullRequestForCommit( - session: AuthenticationSession, - ref: string, - ): Promise; - - @gate() - @debug() - async searchMyIssues(): Promise { - const scope = getLogScope(); - - try { - const issues = await this.searchProviderMyIssues(this._session!); - this.resetRequestExceptionCount(); - return issues; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise; - - @gate() - @debug() - async searchMyPullRequests(): Promise { - const scope = getLogScope(); - - try { - const pullRequests = await this.searchProviderMyPullRequests(this._session!); - this.resetRequestExceptionCount(); - return pullRequests; - } catch (ex) { - return this.handleProviderException(ex, scope, undefined); - } - } - protected abstract searchProviderMyPullRequests( - session: AuthenticationSession, - ): Promise; - - @gate() - private async ensureSession( - createIfNeeded: boolean, - forceNewSession: boolean = false, - ): Promise { - if (this._session != null) return this._session; - if (!configuration.get('integrations.enabled')) return undefined; - - if (createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { - return undefined; - } - - let session: AuthenticationSession | undefined | null; - try { - if (this.container.integrationAuthentication.hasProvider(this.authProvider.id)) { - session = await this.container.integrationAuthentication.getSession( - this.authProvider.id, - this.authProviderDescriptor, - { createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, - ); - } else { - session = await wrapForForcedInsecureSSL(this.getIgnoreSSLErrors(), () => - authentication.getSession(this.authProvider.id, this.authProvider.scopes, { - createIfNone: forceNewSession ? undefined : createIfNeeded, - silent: !createIfNeeded && !forceNewSession ? true : undefined, - forceNewSession: forceNewSession ? true : undefined, - }), - ); - } - } catch (ex) { - await this.container.storage.deleteWorkspace(this.connectedKey); - - if (ex instanceof Error && ex.message.includes('User did not consent')) { - return undefined; - } - - session = null; - } - - if (session === undefined && !createIfNeeded) { - await this.container.storage.deleteWorkspace(this.connectedKey); - } - - this._session = session ?? null; - this.resetRequestExceptionCount(); - - if (session != null) { - await this.container.storage.storeWorkspace(this.connectedKey, true); - - queueMicrotask(() => { - this._onDidChange.fire(); - this.container.richRemoteProviders.connected(this.key); - }); - } - - return session ?? undefined; - } -} - -export async function ensurePaidPlan(providerName: string, container: Container): Promise { - const title = `Connecting to a ${providerName} instance for rich integration features requires a trial or paid plan.`; - - while (true) { - const subscription = await container.subscription.getSubscription(); - if (subscription.account?.verified === false) { - const resend = { title: 'Resend Verification' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nYou must verify your email before you can continue.`, - { modal: true }, - resend, - cancel, - ); - - if (result === resend) { - if (await container.subscription.resendVerification()) { - continue; - } - } - - return false; - } - - const plan = subscription.plan.effective.id; - if (isSubscriptionPaidPlan(plan)) break; - - if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { - const startTrial = { title: 'Preview Pro' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to preview ✨ features for 3 days?`, - { modal: true }, - startTrial, - cancel, - ); - - if (result !== startTrial) return false; - - void container.subscription.startPreviewTrial(); - break; - } else if (subscription.account == null) { - const signIn = { title: 'Start Free GitKraken Trial' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos, free for an additional 7 days?`, - { modal: true }, - signIn, - cancel, - ); - - if (result === signIn) { - if (await container.subscription.loginOrSignUp()) { - continue; - } - } - } else { - const upgrade = { title: 'Upgrade to Pro' }; - const cancel = { title: 'Cancel', isCloseAffordance: true }; - const result = await window.showWarningMessage( - `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos?`, - { modal: true }, - upgrade, - cancel, - ); - - if (result === upgrade) { - void container.subscription.purchase(); - } - } - - return false; - } - - return true; -} diff --git a/src/hovers/hovers.ts b/src/hovers/hovers.ts index a05f47a..f950ccf 100644 --- a/src/hovers/hovers.ts +++ b/src/hovers/hovers.ts @@ -229,8 +229,8 @@ export async function detailsMessage( (options?.autolinks || (options?.autolinks !== false && cfg.autolinks.enabled && cfg.autolinks.enhanced)) && CommitFormatter.has(cfg.detailsMarkdownFormat, 'message'); const prs = - remote?.hasRichIntegration() && - remote.provider.maybeConnected !== false && + remote?.hasIntegration() && + remote.maybeIntegrationConnected !== false && (options?.pullRequests || (options?.pullRequests !== false && cfg.pullRequests.enabled)) && CommitFormatter.has( options.format, diff --git a/src/plus/focus/focusService.ts b/src/plus/focus/focusService.ts index 6b1ef2b..c8fc95d 100644 --- a/src/plus/focus/focusService.ts +++ b/src/plus/focus/focusService.ts @@ -1,7 +1,7 @@ import type { Disposable } from 'vscode'; import type { Container } from '../../container'; import type { GitRemote } from '../../git/models/remote'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; import { log } from '../../system/decorators/log'; import { Logger } from '../../system/logger'; import { getLogScope } from '../../system/logger.scope'; @@ -11,7 +11,7 @@ import { ensureAccount, ensurePaidPlan } from '../utils'; export interface FocusItem { type: EnrichedItemResponse['entityType']; id: string; - remote: GitRemote; + remote: GitRemote; url: string; } diff --git a/src/plus/integrationAuthentication.ts b/src/plus/integrationAuthentication.ts deleted file mode 100644 index 880031a..0000000 --- a/src/plus/integrationAuthentication.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AuthenticationSession, Disposable } from 'vscode'; -import type { Container } from '../container'; -import { debug } from '../system/decorators/log'; - -interface StoredSession { - id: string; - accessToken: string; - account?: { - label?: string; - displayName?: string; - id: string; - }; - scopes: string[]; -} - -export interface IntegrationAuthenticationSessionDescriptor { - domain: string; - scopes: string[]; - [key: string]: unknown; -} - -export interface IntegrationAuthenticationProvider { - getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; - createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; -} - -export class IntegrationAuthenticationService implements Disposable { - private readonly providers = new Map(); - - constructor(private readonly container: Container) {} - - dispose() { - this.providers.clear(); - } - - registerProvider(providerId: string, provider: IntegrationAuthenticationProvider): Disposable { - if (this.providers.has(providerId)) throw new Error(`Provider with id ${providerId} already registered`); - - this.providers.set(providerId, provider); - return { - dispose: () => this.providers.delete(providerId), - }; - } - - hasProvider(providerId: string): boolean { - return this.providers.has(providerId); - } - - @debug() - async createSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const session = await provider?.createSession(descriptor); - if (session == null) return undefined; - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.storeSecret(key, JSON.stringify(session)); - - return session; - } - - @debug() - async getSession( - providerId: string, - descriptor?: IntegrationAuthenticationSessionDescriptor, - options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, - ): Promise { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - - if (options?.forceNewSession) { - await this.container.storage.deleteSecret(key); - } - - let storedSession: StoredSession | undefined; - try { - const sessionJSON = await this.container.storage.getSecret(key); - if (sessionJSON) { - storedSession = JSON.parse(sessionJSON); - } - } catch (ex) { - try { - await this.container.storage.deleteSecret(key); - } catch {} - - if (!options?.createIfNeeded) { - throw ex; - } - } - - if (options?.createIfNeeded && storedSession == null) { - return this.createSession(providerId, descriptor); - } - - return storedSession as AuthenticationSession | undefined; - } - - @debug() - async deleteSession(providerId: string, descriptor?: IntegrationAuthenticationSessionDescriptor) { - const provider = this.providers.get(providerId); - if (provider == null) throw new Error(`Provider with id ${providerId} not registered`); - - const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); - await this.container.storage.deleteSecret(key); - } - - private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { - return `gitlens.integration.auth:${providerId}|${id}`; - } -} diff --git a/src/plus/integrations/authentication/azureDevOps.ts b/src/plus/integrations/authentication/azureDevOps.ts new file mode 100644 index 0000000..bda62cd --- /dev/null +++ b/src/plus/integrations/authentication/azureDevOps.ts @@ -0,0 +1,123 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { supportedInVSCodeVersion } from '../../../system/utils'; +import type { + IntegrationAuthenticationProvider, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; + +export class AzureDevOpsAuthenticationProvider implements IntegrationAuthenticationProvider { + getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let azureOrganization: string | undefined = descriptor?.organization as string | undefined; + if (!azureOrganization) { + const orgInput = window.createInputBox(); + orgInput.ignoreFocusOut = true; + const orgInputDisposables: Disposable[] = []; + try { + azureOrganization = await new Promise(resolve => { + orgInputDisposables.push( + orgInput.onDidHide(() => resolve(undefined)), + orgInput.onDidChangeValue(() => (orgInput.validationMessage = undefined)), + orgInput.onDidAccept(() => { + const value = orgInput.value.trim(); + if (!value) { + orgInput.validationMessage = 'An organization is required'; + return; + } + + resolve(value); + }), + ); + + orgInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + orgInput.placeholder = 'Organization'; + orgInput.prompt = 'Enter your Azure DevOps organization'; + orgInput.show(); + }); + } finally { + orgInput.dispose(); + orgInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!azureOrganization) return undefined; + + const tokenInput = window.createInputBox(); + tokenInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Azure DevOps Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + tokenInput.onDidHide(() => resolve(undefined)), + tokenInput.onDidChangeValue(() => (tokenInput.validationMessage = undefined)), + tokenInput.onDidAccept(() => { + const value = tokenInput.value.trim(); + if (!value) { + tokenInput.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + tokenInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens`, + ), + ); + } + }), + ); + + tokenInput.password = true; + tokenInput.title = `Azure DevOps Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + tokenInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + tokenInput.prompt = supportedInVSCodeVersion('input-prompt-links') + ? `Paste your [Azure DevOps Personal Access Token](https://${ + descriptor?.domain ?? 'dev.azure.com' + }/${azureOrganization}/_usersSettings/tokens "Get your Azure DevOps Access Token")` + : 'Paste your Azure DevOps Personal Access Token'; + tokenInput.buttons = [infoButton]; + + tokenInput.show(); + }); + } finally { + tokenInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`:${token}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/bitbucket.ts b/src/plus/integrations/authentication/bitbucket.ts new file mode 100644 index 0000000..3995fab --- /dev/null +++ b/src/plus/integrations/authentication/bitbucket.ts @@ -0,0 +1,137 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { base64 } from '../../../system/string'; +import { supportedInVSCodeVersion } from '../../../system/utils'; +import type { + IntegrationAuthenticationProvider, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; + +export class BitbucketAuthenticationProvider implements IntegrationAuthenticationProvider { + getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + let bitbucketUsername: string | undefined = descriptor?.username as string | undefined; + if (!bitbucketUsername) { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket Settings Page', + }; + + const usernameInput = window.createInputBox(); + usernameInput.ignoreFocusOut = true; + const usernameInputDisposables: Disposable[] = []; + try { + bitbucketUsername = await new Promise(resolve => { + usernameInputDisposables.push( + usernameInput.onDidHide(() => resolve(undefined)), + usernameInput.onDidChangeValue(() => (usernameInput.validationMessage = undefined)), + usernameInput.onDidAccept(() => { + const value = usernameInput.value.trim(); + if (!value) { + usernameInput.validationMessage = 'A Bitbucket username is required'; + return; + } + + resolve(value); + }), + usernameInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/`), + ); + } + }), + ); + + usernameInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + usernameInput.placeholder = 'Username'; + usernameInput.prompt = supportedInVSCodeVersion('input-prompt-links') + ? `Enter your [Bitbucket Username](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/ "Get your Bitbucket App Password")` + : 'Enter your Bitbucket Username'; + usernameInput.show(); + }); + } finally { + usernameInput.dispose(); + usernameInputDisposables.forEach(d => void d.dispose()); + } + } + + if (!bitbucketUsername) return undefined; + + const appPasswordInput = window.createInputBox(); + appPasswordInput.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let appPassword; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the Bitbucket App Passwords Page', + }; + + appPassword = await new Promise(resolve => { + disposables.push( + appPasswordInput.onDidHide(() => resolve(undefined)), + appPasswordInput.onDidChangeValue(() => (appPasswordInput.validationMessage = undefined)), + appPasswordInput.onDidAccept(() => { + const value = appPasswordInput.value.trim(); + if (!value) { + appPasswordInput.validationMessage = 'An app password is required'; + return; + } + + resolve(value); + }), + appPasswordInput.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'bitbucket.org'}/account/settings/app-passwords/`, + ), + ); + } + }), + ); + + appPasswordInput.password = true; + appPasswordInput.title = `Bitbucket Authentication${ + descriptor?.domain ? ` \u2022 ${descriptor.domain}` : '' + }`; + appPasswordInput.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + appPasswordInput.prompt = supportedInVSCodeVersion('input-prompt-links') + ? `Paste your [Bitbucket App Password](https://${ + descriptor?.domain ?? 'bitbucket.org' + }/account/settings/app-passwords/ "Get your Bitbucket App Password")` + : 'Paste your Bitbucket App Password'; + appPasswordInput.buttons = [infoButton]; + + appPasswordInput.show(); + }); + } finally { + appPasswordInput.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!appPassword) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: base64(`${bitbucketUsername}:${appPassword}`), + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts new file mode 100644 index 0000000..ab28755 --- /dev/null +++ b/src/plus/integrations/authentication/github.ts @@ -0,0 +1,81 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { supportedInVSCodeVersion } from '../../../system/utils'; +import type { + IntegrationAuthenticationProvider, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; + +export class GitHubEnterpriseAuthenticationProvider implements IntegrationAuthenticationProvider { + getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitHub Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse(`https://${descriptor?.domain ?? 'github.com'}/settings/tokens`), + ); + } + }), + ); + + input.password = true; + input.title = `GitHub Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = supportedInVSCodeVersion('input-prompt-links') + ? `Paste your [GitHub Personal Access Token](https://${ + descriptor?.domain ?? 'github.com' + }/settings/tokens "Get your GitHub Access Token")` + : 'Paste your GitHub Personal Access Token'; + + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts new file mode 100644 index 0000000..f34fafb --- /dev/null +++ b/src/plus/integrations/authentication/gitlab.ts @@ -0,0 +1,82 @@ +import type { AuthenticationSession, Disposable, QuickInputButton } from 'vscode'; +import { env, ThemeIcon, Uri, window } from 'vscode'; +import { supportedInVSCodeVersion } from '../../../system/utils'; +import type { + IntegrationAuthenticationProvider, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; + +export class GitLabAuthenticationProvider implements IntegrationAuthenticationProvider { + getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string { + return descriptor?.domain ?? ''; + } + + async createSession( + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + let token; + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open the GitLab Access Tokens Page', + }; + + token = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(() => (input.validationMessage = undefined)), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value) { + input.validationMessage = 'A personal access token is required'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal( + Uri.parse( + `https://${descriptor?.domain ?? 'gitlab.com'}/-/profile/personal_access_tokens`, + ), + ); + } + }), + ); + + input.password = true; + input.title = `GitLab Authentication${descriptor?.domain ? ` \u2022 ${descriptor.domain}` : ''}`; + input.placeholder = `Requires ${descriptor?.scopes.join(', ') ?? 'all'} scopes`; + input.prompt = supportedInVSCodeVersion('input-prompt-links') + ? `Paste your [GitLab Personal Access Token](https://${ + descriptor?.domain ?? 'gitlab.com' + }/-/profile/personal_access_tokens "Get your GitLab Access Token")` + : 'Paste your GitLab Personal Access Token'; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!token) return undefined; + + return { + id: this.getSessionId(descriptor), + accessToken: token, + scopes: descriptor?.scopes ?? [], + account: { + id: '', + label: '', + }, + }; + } +} diff --git a/src/plus/integrations/authentication/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts new file mode 100644 index 0000000..0ddb6a1 --- /dev/null +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -0,0 +1,166 @@ +import type { AuthenticationSession, Disposable } from 'vscode'; +import { authentication } from 'vscode'; +import { wrapForForcedInsecureSSL } from '@env/fetch'; +import type { Container } from '../../../container'; +import { debug } from '../../../system/decorators/log'; +import { ProviderId } from '../providers/models'; +import { AzureDevOpsAuthenticationProvider } from './azureDevOps'; +import { BitbucketAuthenticationProvider } from './bitbucket'; +import { GitHubEnterpriseAuthenticationProvider } from './github'; +import { GitLabAuthenticationProvider } from './gitlab'; + +interface StoredSession { + id: string; + accessToken: string; + account?: { + label?: string; + displayName?: string; + id: string; + }; + scopes: string[]; +} + +export interface IntegrationAuthenticationProviderDescriptor { + id: ProviderId; + scopes: string[]; +} + +export interface IntegrationAuthenticationSessionDescriptor { + domain: string; + scopes: string[]; + [key: string]: unknown; +} + +export interface IntegrationAuthenticationProvider { + getSessionId(descriptor?: IntegrationAuthenticationSessionDescriptor): string; + createSession(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise; +} + +export class IntegrationAuthenticationService implements Disposable { + private readonly providers = new Map(); + + constructor(private readonly container: Container) {} + + dispose() { + this.providers.clear(); + } + + @debug() + async createSession( + providerId: ProviderId, + descriptor?: IntegrationAuthenticationSessionDescriptor, + ): Promise { + const provider = this.ensureProvider(providerId); + + const session = await provider.createSession(descriptor); + if (session == null) return undefined; + + const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); + await this.container.storage.storeSecret(key, JSON.stringify(session)); + + return session; + } + + @debug() + async getSession( + providerId: ProviderId, + descriptor?: IntegrationAuthenticationSessionDescriptor, + options?: { createIfNeeded?: boolean; forceNewSession?: boolean }, + ): Promise { + if (this.supports(providerId)) { + const provider = this.ensureProvider(providerId); + + const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); + + if (options?.forceNewSession) { + await this.container.storage.deleteSecret(key); + } + + let storedSession: StoredSession | undefined; + try { + const sessionJSON = await this.container.storage.getSecret(key); + if (sessionJSON) { + storedSession = JSON.parse(sessionJSON); + } + } catch (ex) { + try { + await this.container.storage.deleteSecret(key); + } catch {} + + if (!options?.createIfNeeded) { + throw ex; + } + } + + if (options?.createIfNeeded && storedSession == null) { + return this.createSession(providerId, descriptor); + } + + return storedSession as AuthenticationSession | undefined; + } + + if (descriptor == null) return undefined; + + const { createIfNeeded, forceNewSession } = options ?? {}; + return wrapForForcedInsecureSSL( + this.container.integrations.ignoreSSLErrors({ id: providerId, domain: descriptor?.domain }), + () => + authentication.getSession(providerId, descriptor.scopes, { + createIfNone: forceNewSession ? undefined : createIfNeeded, + silent: !createIfNeeded && !forceNewSession ? true : undefined, + forceNewSession: forceNewSession ? true : undefined, + }), + ); + } + + @debug() + async deleteSession(providerId: ProviderId, descriptor?: IntegrationAuthenticationSessionDescriptor) { + const provider = this.ensureProvider(providerId); + + const key = this.getSecretKey(providerId, provider.getSessionId(descriptor)); + await this.container.storage.deleteSecret(key); + } + + supports(providerId: string): boolean { + switch (providerId) { + case ProviderId.AzureDevOps: + case ProviderId.Bitbucket: + case ProviderId.GitHubEnterprise: + case ProviderId.GitLab: + case ProviderId.GitLabSelfHosted: + return true; + default: + return false; + } + } + + private getSecretKey(providerId: string, id: string): `gitlens.integration.auth:${string}` { + return `gitlens.integration.auth:${providerId}|${id}`; + } + + private ensureProvider(providerId: ProviderId): IntegrationAuthenticationProvider { + let provider = this.providers.get(providerId); + if (provider == null) { + switch (providerId) { + case ProviderId.AzureDevOps: + provider = new AzureDevOpsAuthenticationProvider(); + break; + case ProviderId.Bitbucket: + provider = new BitbucketAuthenticationProvider(); + break; + case ProviderId.GitHubEnterprise: + provider = new GitHubEnterpriseAuthenticationProvider(); + break; + case ProviderId.GitLab: + case ProviderId.GitLabSelfHosted: + provider = new GitLabAuthenticationProvider(); + break; + default: + throw new Error(`Provider '${providerId}' is not supported`); + } + this.providers.set(providerId, provider); + } + + return provider; + } +} diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts new file mode 100644 index 0000000..6e28ec7 --- /dev/null +++ b/src/plus/integrations/integrationService.ts @@ -0,0 +1,177 @@ +import type { AuthenticationSessionsChangeEvent, Event } from 'vscode'; +import { authentication, Disposable, EventEmitter } from 'vscode'; +import { isWeb } from '@env/platform'; +import type { Container } from '../../container'; +import type { SearchedIssue } from '../../git/models/issue'; +import type { SearchedPullRequest } from '../../git/models/pullRequest'; +import type { GitRemote } from '../../git/models/remote'; +import type { RemoteProviderId } from '../../git/remotes/remoteProvider'; +import { configuration } from '../../system/configuration'; +import { debug } from '../../system/decorators/log'; +import type { ProviderIntegration, ProviderKey, SupportedProviderIds } from './providerIntegration'; +import { AzureDevOpsIntegration } from './providers/azureDevOps'; +import { BitbucketIntegration } from './providers/bitbucket'; +import { GitHubEnterpriseIntegration, GitHubIntegration } from './providers/github'; +import { GitLabIntegration, GitLabSelfHostedIntegration } from './providers/gitlab'; +import { ProviderId } from './providers/models'; +import { ProvidersApi } from './providers/providersApi'; + +export interface ConnectionStateChangeEvent { + key: string; + reason: 'connected' | 'disconnected'; +} + +export class IntegrationService implements Disposable { + private readonly _onDidChangeConnectionState = new EventEmitter(); + get onDidChangeConnectionState(): Event { + return this._onDidChangeConnectionState.event; + } + + private readonly _connectedCache = new Set(); + private readonly _disposable: Disposable; + private _integrations = new Map(); + private _providersApi: ProvidersApi; + + constructor(private readonly container: Container) { + this._providersApi = new ProvidersApi(container); + + this._disposable = Disposable.from( + configuration.onDidChange(e => { + if (configuration.changed(e, 'remotes')) { + this._ignoreSSLErrors.clear(); + } + }), + authentication.onDidChangeSessions(this.onAuthenticationSessionsChanged, this), + ); + } + + dispose() { + this._disposable?.dispose(); + } + + private onAuthenticationSessionsChanged(e: AuthenticationSessionsChangeEvent) { + for (const provider of this._integrations.values()) { + if (e.provider.id === provider.authProvider.id) { + provider.refresh(); + } + } + } + + connected(key: string): void { + // Only fire events if the key is being connected for the first time + if (this._connectedCache.has(key)) return; + + this._connectedCache.add(key); + this.container.telemetry.sendEvent('remoteProviders/connected', { 'remoteProviders.key': key }); + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'connected' }), 250); + } + + disconnected(key: string): void { + // Probably shouldn't bother to fire the event if we don't already think we are connected, but better to be safe + // if (!_connectedCache.has(key)) return; + this._connectedCache.delete(key); + this.container.telemetry.sendEvent('remoteProviders/disconnected', { 'remoteProviders.key': key }); + + setTimeout(() => this._onDidChangeConnectionState.fire({ key: key, reason: 'disconnected' }), 250); + } + + isConnected(key?: string): boolean { + return key == null ? this._connectedCache.size !== 0 : this._connectedCache.has(key); + } + + get(id: SupportedProviderIds, domain?: string): ProviderIntegration { + const key: ProviderKey = `${id}|${domain}`; + let provider = this._integrations.get(key); + if (provider == null) { + switch (id) { + case ProviderId.GitHub: + provider = new GitHubIntegration(this.container, this._providersApi); + break; + case ProviderId.GitHubEnterprise: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + provider = new GitHubEnterpriseIntegration(this.container, this._providersApi, domain); + break; + case ProviderId.GitLab: + provider = new GitLabIntegration(this.container, this._providersApi); + break; + case ProviderId.GitLabSelfHosted: + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + provider = new GitLabSelfHostedIntegration(this.container, this._providersApi, domain); + break; + case ProviderId.Bitbucket: + provider = new BitbucketIntegration(this.container, this._providersApi); + break; + case ProviderId.AzureDevOps: + provider = new AzureDevOpsIntegration(this.container, this._providersApi); + break; + default: + throw new Error(`Provider '${id}' is not supported`); + } + this._integrations.set(key, provider); + } + + return provider; + } + + getByRemote(remote: GitRemote): ProviderIntegration | undefined { + if (remote?.provider == null) return undefined; + + const id = convertRemoteIdToProviderId(remote.provider.id); + return id != null ? this.get(id, remote.domain) : undefined; + } + + @debug({ args: { 0: r => r.name } }) + async getMyIssues(remote: GitRemote): Promise { + if (remote?.provider == null) return undefined; + + const provider = this.getByRemote(remote); + return provider?.searchMyIssues(); + } + + @debug({ args: { 0: r => r.name } }) + async getMyPullRequests(remote: GitRemote): Promise { + if (remote?.provider == null) return undefined; + + const provider = this.getByRemote(remote); + return provider?.searchMyPullRequests(); + } + + supports(remoteId: RemoteProviderId): boolean { + return convertRemoteIdToProviderId(remoteId) != null; + } + + private _ignoreSSLErrors = new Map(); + ignoreSSLErrors( + integration: ProviderIntegration | { id: SupportedProviderIds; domain: string }, + ): boolean | 'force' { + if (isWeb) return false; + + let ignoreSSLErrors = this._ignoreSSLErrors.get(integration.id); + if (ignoreSSLErrors === undefined) { + const cfg = configuration + .get('remotes') + ?.find(remote => remote.type.toLowerCase() === integration.id && remote.domain === integration.domain); + ignoreSSLErrors = cfg?.ignoreSSLErrors ?? false; + this._ignoreSSLErrors.set(integration.id, ignoreSSLErrors); + } + + return ignoreSSLErrors; + } +} + +function convertRemoteIdToProviderId(remoteId: RemoteProviderId): SupportedProviderIds | undefined { + switch (remoteId) { + case 'azure-devops': + return ProviderId.AzureDevOps; + case 'bitbucket': + case 'bitbucket-server': + return ProviderId.Bitbucket; + case 'github': + return ProviderId.GitHub; + case 'gitlab': + return ProviderId.GitLab; + default: + return undefined; + } +} diff --git a/src/plus/integrations/providerIntegration.ts b/src/plus/integrations/providerIntegration.ts new file mode 100644 index 0000000..12af3dc --- /dev/null +++ b/src/plus/integrations/providerIntegration.ts @@ -0,0 +1,925 @@ +import type { AuthenticationSession, CancellationToken, Event, MessageItem } from 'vscode'; +import { CancellationError, EventEmitter, window } from 'vscode'; +import type { Container } from '../../container'; +import { AuthenticationError, ProviderRequestClientError } from '../../errors'; +import type { PagedResult } from '../../git/gitProvider'; +import type { Account } from '../../git/models/author'; +import type { DefaultBranch } from '../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; +import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; +import { showIntegrationDisconnectedTooManyFailedRequestsWarningMessage } from '../../messages'; +import { configuration } from '../../system/configuration'; +import { gate } from '../../system/decorators/gate'; +import { debug, log } from '../../system/decorators/log'; +import { Logger } from '../../system/logger'; +import type { LogScope } from '../../system/logger.scope'; +import { getLogScope } from '../../system/logger.scope'; +import { isSubscriptionPaidPlan, isSubscriptionPreviewTrialExpired } from '../gk/account/subscription'; +import type { + IntegrationAuthenticationProviderDescriptor, + IntegrationAuthenticationSessionDescriptor, +} from './authentication/integrationAuthentication'; +import type { + GetIssuesOptions, + GetPullRequestsOptions, + PagedProjectInput, + PagedRepoInput, + ProviderAccount, + ProviderIssue, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, +} from './providers/models'; +import { IssueFilter, PagingMode, ProviderId, PullRequestFilter } from './providers/models'; +import type { ProvidersApi } from './providers/providersApi'; + +// TODO@eamodio revisit how once authenticated, all remotes are always connected, even after a restart + +export type SupportedProviderIds = ProviderId; +export type ProviderKey = `${SupportedProviderIds}|${string}`; + +export type RepositoryDescriptor = { key: string } & Record; + +export abstract class ProviderIntegration { + private readonly _onDidChange = new EventEmitter(); + get onDidChange(): Event { + return this._onDidChange.event; + } + + constructor( + protected readonly container: Container, + protected readonly api: ProvidersApi, + ) {} + + abstract get authProvider(): IntegrationAuthenticationProviderDescriptor; + abstract get id(): SupportedProviderIds; + abstract get name(): string; + abstract get domain(): string; + + protected get authProviderDescriptor(): IntegrationAuthenticationSessionDescriptor { + return { domain: this.domain, scopes: this.authProvider.scopes }; + } + + get icon(): string { + return this.id; + } + + protected get key(): `${SupportedProviderIds}` | `${SupportedProviderIds}:${string}` { + return this.id; + } + + private get connectedKey(): `connected:${ProviderIntegration['key']}` { + return `connected:${this.key}`; + } + + get maybeConnected(): boolean | undefined { + return this._session === undefined ? undefined : this._session !== null; + } + + protected _session: AuthenticationSession | null | undefined; + protected session() { + if (this._session === undefined) { + return this.ensureSession(false); + } + return this._session ?? undefined; + } + + @log() + async connect(): Promise { + try { + const session = await this.ensureSession(true); + return Boolean(session); + } catch (ex) { + return false; + } + } + + @gate() + @log() + async disconnect(options?: { silent?: boolean; currentSessionOnly?: boolean }): Promise { + if (options?.currentSessionOnly && this._session === null) return; + + const connected = this._session != null; + + if (connected && !options?.silent) { + if (options?.currentSessionOnly) { + void showIntegrationDisconnectedTooManyFailedRequestsWarningMessage(this.name); + } else { + const disable = { title: 'Disable' }; + const signout = { title: 'Disable & Sign Out' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + + let result: MessageItem | undefined; + if (this.container.integrationAuthentication.supports(this.authProvider.id)) { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?\n\nNote: signing out clears the saved authentication.`, + { modal: true }, + disable, + signout, + cancel, + ); + } else { + result = await window.showWarningMessage( + `Are you sure you want to disable the rich integration with ${this.name}?`, + { modal: true }, + disable, + cancel, + ); + } + + if (result == null || result === cancel) return; + if (result === signout) { + void this.container.integrationAuthentication.deleteSession( + this.authProvider.id, + this.authProviderDescriptor, + ); + } + } + } + + this.resetRequestExceptionCount(); + this._session = null; + + if (connected) { + // Don't store the disconnected flag if this only for this current VS Code session (will be re-connected on next restart) + if (!options?.currentSessionOnly) { + void this.container.storage.storeWorkspace(this.connectedKey, false); + } + + this._onDidChange.fire(); + if (!options?.silent && !options?.currentSessionOnly) { + this.container.integrations.disconnected(this.key); + } + } + } + + @log() + async reauthenticate(): Promise { + if (this._session === undefined) return; + + this._session = undefined; + void (await this.ensureSession(true, true)); + } + + refresh() { + void this.ensureSession(false); + } + + private requestExceptionCount = 0; + + resetRequestExceptionCount(): void { + this.requestExceptionCount = 0; + } + + private handleProviderException(ex: Error, scope: LogScope | undefined, defaultValue: T): T { + if (ex instanceof CancellationError) return defaultValue; + + Logger.error(ex, scope); + + if (ex instanceof AuthenticationError || ex instanceof ProviderRequestClientError) { + this.trackRequestException(); + } + return defaultValue; + } + + @debug() + trackRequestException(): void { + this.requestExceptionCount++; + + if (this.requestExceptionCount >= 5 && this._session !== null) { + void this.disconnect({ currentSessionOnly: true }); + } + } + + @gate() + @debug({ exit: true }) + async isConnected(): Promise { + return (await this.session()) != null; + } + + @gate() + @debug() + async getAccountForCommit( + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForCommit(this._session!, repo, ref, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForCommit( + session: AuthenticationSession, + repo: T, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @gate() + @debug() + async getAccountForEmail( + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + const author = await this.getProviderAccountForEmail(this._session!, repo, email, options); + this.resetRequestExceptionCount(); + return author; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract getProviderAccountForEmail( + session: AuthenticationSession, + repo: T, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise; + + @debug() + async getDefaultBranch(repo: T): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const defaultBranch = this.container.cache.getRepositoryDefaultBranch(repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderDefaultBranch(this._session!, repo); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return defaultBranch; + } + + protected abstract getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: T, + ): Promise; + + @debug() + async getRepositoryMetadata(repo: T, _cancellation?: CancellationToken): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const metadata = this.container.cache.getRepositoryMetadata(repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderRepositoryMetadata(this._session!, repo); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return metadata; + } + + protected abstract getProviderRepositoryMetadata( + session: AuthenticationSession, + repo: T, + ): Promise; + + @debug() + async getIssueOrPullRequest(repo: T, id: string): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const issueOrPR = this.container.cache.getIssueOrPullRequest(id, repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderIssueOrPullRequest(this._session!, repo, id); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return issueOrPR; + } + + protected abstract getProviderIssueOrPullRequest( + session: AuthenticationSession, + repo: T, + id: string, + ): Promise; + + @debug() + async getPullRequestForBranch( + repo: T, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequestForBranch(branch, repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForBranch(this._session!, repo, branch, options); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return pr; + } + + protected abstract getProviderPullRequestForBranch( + session: AuthenticationSession, + repo: T, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise; + + @debug() + async getPullRequestForCommit(repo: T, ref: string): Promise { + const scope = getLogScope(); + + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + const pr = this.container.cache.getPullRequestForSha(ref, repo, this, () => ({ + value: (async () => { + try { + const result = await this.getProviderPullRequestForCommit(this._session!, repo, ref); + this.resetRequestExceptionCount(); + return result; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + })(), + })); + return pr; + } + + protected abstract getProviderPullRequestForCommit( + session: AuthenticationSession, + repo: T, + ref: string, + ): Promise; + + async getMyIssuesForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: IssueFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + if ( + providerId !== ProviderId.GitLab && + (this.api.isRepoIdsInput(reposOrRepoIds) || + (providerId === ProviderId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let getIssuesOptions: GetIssuesOptions | undefined; + if (providerId === ProviderId.AzureDevOps) { + const organizations = new Set(); + const projects = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + projects.add(repo.project!); + } + + if (organizations.size > 1) { + Logger.warn(`Multiple organizations not supported for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const organization: string = organizations.values().next().value; + + if (options?.filters != null) { + if (!this.api.providerSupportsIssueFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + try { + userAccount = await this.api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.name; + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedProjectInput[] = cursorInfo.cursors ?? []; + let projectInputs: PagedProjectInput[] = Array.from(projects.values()).map(project => ({ + namespace: organization, + project: project, + cursor: undefined, + })); + if (cursors.length > 0) { + projectInputs = cursors; + } + + try { + const cursor: { cursors: PagedProjectInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + projectInputs.map(async projectInput => { + const results = await this.api.getIssuesForAzureProject( + projectInput.namespace, + projectInput.project, + { + ...getIssuesOptions, + cursor: projectInput.cursor, + }, + ); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ + namespace: projectInput.namespace, + project: projectInput.project, + cursor: results.paging.cursor, + }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + if (options?.filters != null) { + let userAccount: ProviderAccount | undefined; + try { + userAccount = await this.api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + const userFilterProperty = userAccount.username; + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getIssuesForRepos'); + return undefined; + } + + getIssuesOptions = { + authorLogin: options.filters.includes(IssueFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(IssueFilter.Assignee) ? [userFilterProperty] : undefined, + mentionLogin: options.filters.includes(IssueFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if ( + this.api.getProviderIssuesPagingMode(providerId) === PagingMode.Repo && + !this.api.isRepoIdsInput(reposOrRepoIds) + ) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderIssue[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await this.api.getIssuesForRepo(providerId, repoInput.repo, { + ...getIssuesOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + try { + return await this.api.getIssuesForRepos(providerId, reposOrRepoIds, { + ...getIssuesOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getIssuesForRepos'); + return undefined; + } + } + + async getMyPullRequestsForRepos( + reposOrRepoIds: ProviderReposInput, + options?: { + filters?: PullRequestFilter[]; + cursor?: string; + customUrl?: string; + }, + ): Promise | undefined> { + const providerId = this.authProvider.id; + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + if ( + providerId !== ProviderId.GitLab && + (this.api.isRepoIdsInput(reposOrRepoIds) || + (providerId === ProviderId.AzureDevOps && + !reposOrRepoIds.every(repo => repo.project != null && repo.namespace != null))) + ) { + Logger.warn(`Unsupported input for provider ${providerId}`); + return undefined; + } + + let getPullRequestsOptions: GetPullRequestsOptions | undefined; + if (options?.filters != null) { + if (!this.api.providerSupportsPullRequestFilters(providerId, options.filters)) { + Logger.warn(`Unsupported filters for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userAccount: ProviderAccount | undefined; + if (providerId === ProviderId.AzureDevOps) { + const organizations = new Set(); + for (const repo of reposOrRepoIds as ProviderRepoInput[]) { + organizations.add(repo.namespace); + } + + if (organizations.size > 1) { + Logger.warn( + `Multiple organizations not supported for provider ${providerId}`, + 'getPullRequestsForRepos', + ); + return undefined; + } else if (organizations.size === 0) { + Logger.warn(`No organizations found for provider ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + const organization: string = organizations.values().next().value; + try { + userAccount = await this.api.getCurrentUserForInstance(providerId, organization); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } else { + try { + userAccount = await this.api.getCurrentUser(providerId); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + if (userAccount == null) { + Logger.warn(`Unable to get current user for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + let userFilterProperty: string | null; + switch (providerId) { + case ProviderId.Bitbucket: + case ProviderId.AzureDevOps: + userFilterProperty = userAccount.id; + break; + default: + userFilterProperty = userAccount.username; + break; + } + + if (userFilterProperty == null) { + Logger.warn(`Unable to get user property for filter for ${providerId}`, 'getPullRequestsForRepos'); + return undefined; + } + + getPullRequestsOptions = { + authorLogin: options.filters.includes(PullRequestFilter.Author) ? userFilterProperty : undefined, + assigneeLogins: options.filters.includes(PullRequestFilter.Assignee) ? [userFilterProperty] : undefined, + reviewRequestedLogin: options.filters.includes(PullRequestFilter.ReviewRequested) + ? userFilterProperty + : undefined, + mentionLogin: options.filters.includes(PullRequestFilter.Mention) ? userFilterProperty : undefined, + }; + } + + if ( + this.api.getProviderPullRequestsPagingMode(providerId) === PagingMode.Repo && + !this.api.isRepoIdsInput(reposOrRepoIds) + ) { + const cursorInfo = JSON.parse(options?.cursor ?? '{}'); + const cursors: PagedRepoInput[] = cursorInfo.cursors ?? []; + let repoInputs: PagedRepoInput[] = reposOrRepoIds.map(repo => ({ repo: repo, cursor: undefined })); + if (cursors.length > 0) { + repoInputs = cursors; + } + + try { + const cursor: { cursors: PagedRepoInput[] } = { cursors: [] }; + let hasMore = false; + const data: ProviderPullRequest[] = []; + await Promise.all( + repoInputs.map(async repoInput => { + const results = await this.api.getPullRequestsForRepo(providerId, repoInput.repo, { + ...getPullRequestsOptions, + cursor: repoInput.cursor, + baseUrl: options?.customUrl, + }); + data.push(...results.values); + if (results.paging?.more) { + hasMore = true; + cursor.cursors.push({ repo: repoInput.repo, cursor: results.paging.cursor }); + } + }), + ); + + return { + values: data, + paging: { + more: hasMore, + cursor: JSON.stringify(cursor), + }, + }; + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + try { + return this.api.getPullRequestsForRepos(providerId, reposOrRepoIds, { + ...getPullRequestsOptions, + cursor: options?.cursor, + baseUrl: options?.customUrl, + }); + } catch (ex) { + Logger.error(ex, 'getPullRequestsForRepos'); + return undefined; + } + } + + @debug() + async searchMyIssues(): Promise { + const scope = getLogScope(); + + try { + const issues = await this.searchProviderMyIssues(this._session!); + this.resetRequestExceptionCount(); + return issues; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract searchProviderMyIssues(session: AuthenticationSession): Promise; + + @debug() + async searchMyPullRequests(): Promise { + const scope = getLogScope(); + + try { + const pullRequests = await this.searchProviderMyPullRequests(this._session!); + this.resetRequestExceptionCount(); + return pullRequests; + } catch (ex) { + return this.handleProviderException(ex, scope, undefined); + } + } + + protected abstract searchProviderMyPullRequests( + session: AuthenticationSession, + ): Promise; + + @gate() + private async ensureSession( + createIfNeeded: boolean, + forceNewSession: boolean = false, + ): Promise { + if (this._session != null) return this._session; + if (!configuration.get('integrations.enabled')) return undefined; + + if (createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } else if (this.container.storage.getWorkspace(this.connectedKey) === false) { + return undefined; + } + + let session: AuthenticationSession | undefined | null; + try { + session = await this.container.integrationAuthentication.getSession( + this.authProvider.id, + this.authProviderDescriptor, + { createIfNeeded: createIfNeeded, forceNewSession: forceNewSession }, + ); + } catch (ex) { + await this.container.storage.deleteWorkspace(this.connectedKey); + + if (ex instanceof Error && ex.message.includes('User did not consent')) { + return undefined; + } + + session = null; + } + + if (session === undefined && !createIfNeeded) { + await this.container.storage.deleteWorkspace(this.connectedKey); + } + + this._session = session ?? null; + this.resetRequestExceptionCount(); + + if (session != null) { + await this.container.storage.storeWorkspace(this.connectedKey, true); + + queueMicrotask(() => { + this._onDidChange.fire(); + this.container.integrations.connected(this.key); + }); + } + + return session ?? undefined; + } + + getIgnoreSSLErrors(): boolean | 'force' { + return this.container.integrations.ignoreSSLErrors(this); + } +} + +export async function ensurePaidPlan(providerName: string, container: Container): Promise { + const title = `Connecting to a ${providerName} instance for rich integration features requires a trial or paid plan.`; + + while (true) { + const subscription = await container.subscription.getSubscription(); + if (subscription.account?.verified === false) { + const resend = { title: 'Resend Verification' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nYou must verify your email before you can continue.`, + { modal: true }, + resend, + cancel, + ); + + if (result === resend) { + if (await container.subscription.resendVerification()) { + continue; + } + } + + return false; + } + + const plan = subscription.plan.effective.id; + if (isSubscriptionPaidPlan(plan)) break; + + if (subscription.account == null && !isSubscriptionPreviewTrialExpired(subscription)) { + const startTrial = { title: 'Preview Pro' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to preview ✨ features for 3 days?`, + { modal: true }, + startTrial, + cancel, + ); + + if (result !== startTrial) return false; + + void container.subscription.startPreviewTrial(); + break; + } else if (subscription.account == null) { + const signIn = { title: 'Start Free GitKraken Trial' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos, free for an additional 7 days?`, + { modal: true }, + signIn, + cancel, + ); + + if (result === signIn) { + if (await container.subscription.loginOrSignUp()) { + continue; + } + } + } else { + const upgrade = { title: 'Upgrade to Pro' }; + const cancel = { title: 'Cancel', isCloseAffordance: true }; + const result = await window.showWarningMessage( + `${title}\n\nDo you want to continue to use ✨ features on privately hosted repos?`, + { modal: true }, + upgrade, + cancel, + ); + + if (result === upgrade) { + void container.subscription.purchase(); + } + } + + return false; + } + + return true; +} diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts new file mode 100644 index 0000000..6539289 --- /dev/null +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -0,0 +1,129 @@ +import type { AuthenticationSession } from 'vscode'; +import type { PagedResult } from '../../../git/gitProvider'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { Logger } from '../../../system/logger'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { RepositoryDescriptor, SupportedProviderIds } from '../providerIntegration'; +import { ProviderIntegration } from '../providerIntegration'; +import type { ProviderRepository } from './models'; +import { ProviderId, providersMetadata } from './models'; + +const metadata = providersMetadata[ProviderId.AzureDevOps]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface AzureRepositoryDescriptor extends RepositoryDescriptor { + owner: string; + name: string; +} + +export class AzureDevOpsIntegration extends ProviderIntegration { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id: SupportedProviderIds = ProviderId.AzureDevOps; + readonly name: string = 'Azure DevOps'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://dev.azure.com'; + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: { cursor?: string }, + ): Promise | undefined> { + const connected = this.maybeConnected ?? (await this.isConnected()); + if (!connected) return undefined; + + try { + return await this.api.getReposForAzureProject(namespace, project, { cursor: options?.cursor }); + } catch (ex) { + Logger.error(ex, 'getReposForAzureProject'); + return undefined; + } + } + + // TODO: implement + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repo?: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repo?: AzureRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts new file mode 100644 index 0000000..f30f9a9 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket.ts @@ -0,0 +1,110 @@ +import type { AuthenticationSession } from 'vscode'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { RepositoryDescriptor, SupportedProviderIds } from '../providerIntegration'; +import { ProviderIntegration } from '../providerIntegration'; +import { ProviderId, providersMetadata } from './models'; + +const metadata = providersMetadata[ProviderId.Bitbucket]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +interface BitbucketRepositoryDescriptor extends RepositoryDescriptor { + owner: string; + name: string; +} + +export class BitbucketIntegration extends ProviderIntegration { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id: SupportedProviderIds = ProviderId.Bitbucket; + readonly name: string = 'Bitbucket'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.bitbucket.org/2.0'; + } + + // TODO: implement + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyPullRequests( + _session: AuthenticationSession, + _repo?: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repo?: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } +} diff --git a/src/plus/integrations/providers/github.ts b/src/plus/integrations/providers/github.ts new file mode 100644 index 0000000..ef69f01 --- /dev/null +++ b/src/plus/integrations/providers/github.ts @@ -0,0 +1,196 @@ +import type { AuthenticationSession } from 'vscode'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { SupportedProviderIds } from '../providerIntegration'; +import { ensurePaidPlan, ProviderIntegration } from '../providerIntegration'; +import { ProviderId, providersMetadata } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[ProviderId.GitHub]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[ProviderId.GitHubEnterprise]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitHubRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +export class GitHubIntegration extends ProviderIntegration { + readonly authProvider = authProvider; + readonly id: SupportedProviderIds = ProviderId.GitHub; + readonly name: string = 'GitHub'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://api.github.com'; + } + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.github)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.github)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitHubPullRequestState = (await import(/* webpackChunkName: "github" */ './github/models')) + .toGitHubPullRequestState; + return (await this.container.github)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitHubPullRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.github)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.getRepositoryMetadata(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async searchProviderMyPullRequests( + { accessToken }: AuthenticationSession, + repo?: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.searchMyPullRequests(this, accessToken, { + repos: repo != null ? [`${repo.owner}/${repo.name}`] : undefined, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async searchProviderMyIssues( + { accessToken }: AuthenticationSession, + repo?: GitHubRepositoryDescriptor, + ): Promise { + return (await this.container.github)?.searchMyIssues(this, accessToken, { + repos: repo != null ? [`${repo.owner}/${repo.name}`] : undefined, + baseUrl: this.apiBaseUrl, + }); + } +} + +export class GitHubEnterpriseIntegration extends GitHubIntegration { + override readonly authProvider = enterpriseAuthProvider; + override readonly id = ProviderId.GitHubEnterprise; + override readonly name = 'GitHub Enterprise'; + override get domain(): string { + return this._domain; + } + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api/v3`; + } + protected override get key(): `${SupportedProviderIds}:${string}` { + return `${this.id}:${this.domain}`; + } + + constructor( + container: Container, + override readonly api: ProvidersApi, + private readonly _domain: string, + ) { + super(container, api); + } + + @log() + override async connect(): Promise { + if (!(await ensurePaidPlan(`${this.name} instance`, this.container))) { + return false; + } + + return super.connect(); + } +} diff --git a/src/plus/github/github.ts b/src/plus/integrations/providers/github/github.ts similarity index 96% rename from src/plus/github/github.ts rename to src/plus/integrations/providers/github/github.ts index 38f70a2..2f93025 100644 --- a/src/plus/github/github.ts +++ b/src/plus/integrations/providers/github/github.ts @@ -7,8 +7,8 @@ import type { CancellationToken, Disposable, Event } from 'vscode'; import { EventEmitter, Uri, window } from 'vscode'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; -import type { CoreConfiguration } from '../../constants'; -import type { Container } from '../../container'; +import type { CoreConfiguration } from '../../../../constants'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, @@ -16,31 +16,31 @@ import { ProviderRequestClientError, ProviderRequestNotFoundError, ProviderRequestRateLimitError, -} from '../../errors'; -import type { PagedResult, RepositoryVisibility } from '../../git/gitProvider'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest, SearchedIssue } from '../../git/models/issue'; -import type { PullRequest, SearchedPullRequest } from '../../git/models/pullRequest'; -import { isSha } from '../../git/models/reference'; -import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; -import type { GitUser } from '../../git/models/user'; -import { getGitHubNoReplyAddressParts } from '../../git/remotes/github'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +} from '../../../../errors'; +import type { PagedResult, RepositoryVisibility } from '../../../../git/gitProvider'; +import type { Account } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../../git/models/issue'; +import type { PullRequest, SearchedPullRequest } from '../../../../git/models/pullRequest'; +import { isSha } from '../../../../git/models/reference'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; +import type { GitUser } from '../../../../git/models/user'; +import { getGitHubNoReplyAddressParts } from '../../../../git/remotes/github'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { uniqueBy } from '../../system/array'; -import { configuration } from '../../system/configuration'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import { maybeStopWatch } from '../../system/stopwatch'; -import { base64 } from '../../system/string'; -import type { Version } from '../../system/version'; -import { fromString, satisfies } from '../../system/version'; +} from '../../../../messages'; +import { uniqueBy } from '../../../../system/array'; +import { configuration } from '../../../../system/configuration'; +import { debug } from '../../../../system/decorators/log'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { base64 } from '../../../../system/string'; +import type { Version } from '../../../../system/version'; +import { fromString, satisfies } from '../../../../system/version'; import type { GitHubBlame, GitHubBlameRange, @@ -223,7 +223,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -316,7 +316,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -403,7 +403,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -462,7 +462,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -545,7 +545,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -651,7 +651,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -753,7 +753,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getRepositoryMetadata( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -2272,7 +2272,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p?.name, 1: '' } }) private async getEnterpriseVersion( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, options?: { baseUrl?: string }, ): Promise { @@ -2296,7 +2296,7 @@ export class GitHubApi implements Disposable { } private async graphql( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, query: string, variables: RequestParameters, @@ -2356,7 +2356,7 @@ export class GitHubApi implements Disposable { } private async request( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, route: keyof Endpoints | R, options: @@ -2458,7 +2458,7 @@ export class GitHubApi implements Disposable { } private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, ex: RequestError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, @@ -2494,7 +2494,7 @@ export class GitHubApi implements Disposable { provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitHub'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'github' ? ' Please visit the [GitHub status page](https://githubstatus.com) for more information.' : '' }`, @@ -2523,7 +2523,7 @@ export class GitHubApi implements Disposable { } } - private handleException(ex: Error, provider: RichRemoteProvider | undefined, scope: LogScope | undefined): Error { + private handleException(ex: Error, provider: Provider | undefined, scope: LogScope | undefined): Error { Logger.error(ex, scope); // debugger; @@ -2533,7 +2533,7 @@ export class GitHubApi implements Disposable { return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider | undefined) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider | undefined) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( @@ -2554,7 +2554,7 @@ export class GitHubApi implements Disposable { } private async createEnterpriseAvatarUrl( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, baseUrl: string, email: string, @@ -2598,7 +2598,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyPullRequests( - provider: RichRemoteProvider, + provider: Provider, token: string, options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string }, ): Promise { @@ -2709,7 +2709,7 @@ export class GitHubApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async searchMyIssues( - provider: RichRemoteProvider, + provider: Provider, token: string, options?: { search?: string; user?: string; repos?: string[]; baseUrl?: string }, ): Promise { diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/integrations/providers/github/githubGitProvider.ts similarity index 96% rename from src/plus/github/githubGitProvider.ts rename to src/plus/integrations/providers/github/githubGitProvider.ts index 3cc75f3..7bb9705 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/integrations/providers/github/githubGitProvider.ts @@ -11,18 +11,18 @@ import type { } from 'vscode'; import { authentication, EventEmitter, FileType, Uri, window, workspace } from 'vscode'; import { encodeUtf8Hex } from '@env/hex'; -import { CharCode, Schemes } from '../../constants'; -import type { Container } from '../../container'; -import { emojify } from '../../emojis'; +import { CharCode, Schemes } from '../../../../constants'; +import type { Container } from '../../../../container'; +import { emojify } from '../../../../emojis'; import { AuthenticationError, AuthenticationErrorReason, ExtensionNotFoundError, OpenVirtualRepositoryError, OpenVirtualRepositoryErrorReason, -} from '../../errors'; -import { Features } from '../../features'; -import { GitSearchError } from '../../git/errors'; +} from '../../../../errors'; +import { Features } from '../../../../features'; +import { GitSearchError } from '../../../../git/errors'; import type { GitCaches, GitProvider, @@ -34,18 +34,18 @@ import type { RepositoryOpenEvent, RepositoryVisibility, ScmRepository, -} from '../../git/gitProvider'; -import { GitUri } from '../../git/gitUri'; -import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../git/models/blame'; -import type { BranchSortOptions } from '../../git/models/branch'; -import { getBranchId, getBranchNameWithoutRemote, GitBranch, sortBranches } from '../../git/models/branch'; -import type { GitCommitLine } from '../../git/models/commit'; -import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../git/models/commit'; -import { deletedOrMissing, uncommitted } from '../../git/models/constants'; -import { GitContributor } from '../../git/models/contributor'; -import type { GitDiffFile, GitDiffFilter, GitDiffLine, GitDiffShortStat } from '../../git/models/diff'; -import type { GitFile } from '../../git/models/file'; -import { GitFileChange, GitFileIndexStatus } from '../../git/models/file'; +} from '../../../../git/gitProvider'; +import { GitUri } from '../../../../git/gitUri'; +import type { GitBlame, GitBlameAuthor, GitBlameLine, GitBlameLines } from '../../../../git/models/blame'; +import type { BranchSortOptions } from '../../../../git/models/branch'; +import { getBranchId, getBranchNameWithoutRemote, GitBranch, sortBranches } from '../../../../git/models/branch'; +import type { GitCommitLine } from '../../../../git/models/commit'; +import { getChangedFilesCount, GitCommit, GitCommitIdentity } from '../../../../git/models/commit'; +import { deletedOrMissing, uncommitted } from '../../../../git/models/constants'; +import { GitContributor } from '../../../../git/models/contributor'; +import type { GitDiffFile, GitDiffFilter, GitDiffLine, GitDiffShortStat } from '../../../../git/models/diff'; +import type { GitFile } from '../../../../git/models/file'; +import { GitFileChange, GitFileIndexStatus } from '../../../../git/models/file'; import type { GitGraph, GitGraphHostingServiceType, @@ -56,49 +56,55 @@ import type { GitGraphRowsStats, GitGraphRowStats, GitGraphRowTag, -} from '../../git/models/graph'; -import type { GitLog } from '../../git/models/log'; -import type { GitMergeStatus } from '../../git/models/merge'; -import type { GitRebaseStatus } from '../../git/models/rebase'; -import type { GitBranchReference, GitReference } from '../../git/models/reference'; -import { createReference, isRevisionRange, isSha, isShaLike, isUncommitted } from '../../git/models/reference'; -import type { GitReflog } from '../../git/models/reflog'; -import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../git/models/remote'; -import type { RepositoryChangeEvent } from '../../git/models/repository'; -import { Repository } from '../../git/models/repository'; -import type { GitStash } from '../../git/models/stash'; -import type { GitStatusFile } from '../../git/models/status'; -import { GitStatus } from '../../git/models/status'; -import type { TagSortOptions } from '../../git/models/tag'; -import { getTagId, GitTag, sortTags } from '../../git/models/tag'; -import type { GitTreeEntry } from '../../git/models/tree'; -import type { GitUser } from '../../git/models/user'; -import { isUserMatch } from '../../git/models/user'; -import { getRemoteProviderMatcher, loadRemoteProviders } from '../../git/remotes/remoteProviders'; -import type { GitSearch, GitSearchResultData, GitSearchResults, SearchOperators, SearchQuery } from '../../git/search'; -import { getSearchQueryComparisonKey, parseSearchQuery } from '../../git/search'; -import { configuration } from '../../system/configuration'; -import { setContext } from '../../system/context'; -import { gate } from '../../system/decorators/gate'; -import { debug, log } from '../../system/decorators/log'; -import { filterMap, first, last, map, some } from '../../system/iterable'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope } from '../../system/logger.scope'; -import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path'; -import { asSettled, getSettledValue } from '../../system/promise'; -import { serializeWebviewItemContext } from '../../system/webview'; -import type { CachedBlame, CachedLog } from '../../trackers/gitDocumentTracker'; -import { GitDocumentState } from '../../trackers/gitDocumentTracker'; -import type { TrackedDocument } from '../../trackers/trackedDocument'; -import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../remotehub'; -import { getRemoteHubApi, HeadType } from '../remotehub'; +} from '../../../../git/models/graph'; +import type { GitLog } from '../../../../git/models/log'; +import type { GitMergeStatus } from '../../../../git/models/merge'; +import type { GitRebaseStatus } from '../../../../git/models/rebase'; +import type { GitBranchReference, GitReference } from '../../../../git/models/reference'; +import { createReference, isRevisionRange, isSha, isShaLike, isUncommitted } from '../../../../git/models/reference'; +import type { GitReflog } from '../../../../git/models/reflog'; +import { getRemoteIconUri, getVisibilityCacheKey, GitRemote } from '../../../../git/models/remote'; +import type { RepositoryChangeEvent } from '../../../../git/models/repository'; +import { Repository } from '../../../../git/models/repository'; +import type { GitStash } from '../../../../git/models/stash'; +import type { GitStatusFile } from '../../../../git/models/status'; +import { GitStatus } from '../../../../git/models/status'; +import type { TagSortOptions } from '../../../../git/models/tag'; +import { getTagId, GitTag, sortTags } from '../../../../git/models/tag'; +import type { GitTreeEntry } from '../../../../git/models/tree'; +import type { GitUser } from '../../../../git/models/user'; +import { isUserMatch } from '../../../../git/models/user'; +import { getRemoteProviderMatcher, loadRemoteProviders } from '../../../../git/remotes/remoteProviders'; +import type { + GitSearch, + GitSearchResultData, + GitSearchResults, + SearchOperators, + SearchQuery, +} from '../../../../git/search'; +import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../../git/search'; +import { configuration } from '../../../../system/configuration'; +import { setContext } from '../../../../system/context'; +import { gate } from '../../../../system/decorators/gate'; +import { debug, log } from '../../../../system/decorators/log'; +import { filterMap, first, last, map, some } from '../../../../system/iterable'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../../../system/path'; +import { asSettled, getSettledValue } from '../../../../system/promise'; +import { serializeWebviewItemContext } from '../../../../system/webview'; +import type { CachedBlame, CachedLog } from '../../../../trackers/gitDocumentTracker'; +import { GitDocumentState } from '../../../../trackers/gitDocumentTracker'; +import type { TrackedDocument } from '../../../../trackers/trackedDocument'; +import type { GitHubAuthorityMetadata, Metadata, RemoteHubApi } from '../../../remotehub'; +import { getRemoteHubApi, HeadType } from '../../../remotehub'; import type { GraphBranchContextValue, GraphItemContext, GraphItemRefContext, GraphTagContextValue, -} from '../webviews/graph/protocol'; +} from '../../../webviews/graph/protocol'; import type { GitHubApi } from './github'; import { fromCommitFileStatus } from './models'; @@ -2574,6 +2580,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { return [ new GitRemote( + this.container, repoPath, 'origin', 'https', diff --git a/src/plus/github/models.ts b/src/plus/integrations/providers/github/models.ts similarity index 93% rename from src/plus/github/models.ts rename to src/plus/integrations/providers/github/models.ts index 5627ae4..e43459e 100644 --- a/src/plus/github/models.ts +++ b/src/plus/integrations/providers/github/models.ts @@ -1,10 +1,10 @@ import type { Endpoints } from '@octokit/types'; -import { GitFileIndexStatus } from '../../git/models/file'; -import type { IssueLabel, IssueOrPullRequestType } from '../../git/models/issue'; -import { Issue } from '../../git/models/issue'; -import type { PullRequestState } from '../../git/models/pullRequest'; -import { PullRequest, PullRequestMergeableState, PullRequestReviewDecision } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import { GitFileIndexStatus } from '../../../../git/models/file'; +import type { IssueLabel, IssueOrPullRequestType } from '../../../../git/models/issue'; +import { Issue } from '../../../../git/models/issue'; +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { PullRequest, PullRequestMergeableState, PullRequestReviewDecision } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; export interface GitHubBlame { ranges: GitHubBlameRange[]; @@ -160,7 +160,7 @@ export interface GitHubDetailedPullRequest extends GitHubPullRequest { }; } -export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: RichRemoteProvider): PullRequest { +export function fromGitHubPullRequest(pr: GitHubPullRequest, provider: Provider): PullRequest { return new PullRequest( provider, { @@ -239,10 +239,7 @@ export function toGitHubPullRequestMergeableState( } } -export function fromGitHubPullRequestDetailed( - pr: GitHubDetailedPullRequest, - provider: RichRemoteProvider, -): PullRequest { +export function fromGitHubPullRequestDetailed(pr: GitHubDetailedPullRequest, provider: Provider): PullRequest { return new PullRequest( provider, { @@ -299,7 +296,7 @@ export function fromGitHubPullRequestDetailed( ); } -export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: RichRemoteProvider): Issue { +export function fromGitHubIssueDetailed(value: GitHubIssueDetailed, provider: Provider): Issue { return new Issue( { id: provider.id, diff --git a/src/plus/integrations/providers/gitlab.ts b/src/plus/integrations/providers/gitlab.ts new file mode 100644 index 0000000..ed898c7 --- /dev/null +++ b/src/plus/integrations/providers/gitlab.ts @@ -0,0 +1,190 @@ +import type { AuthenticationSession } from 'vscode'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +import type { IssueOrPullRequest, SearchedIssue } from '../../../git/models/issue'; +import type { PullRequest, PullRequestState, SearchedPullRequest } from '../../../git/models/pullRequest'; +import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; +import { log } from '../../../system/decorators/log'; +import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthentication'; +import type { SupportedProviderIds } from '../providerIntegration'; +import { ensurePaidPlan, ProviderIntegration } from '../providerIntegration'; +import { ProviderId, providersMetadata } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[ProviderId.GitLab]; +const authProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: metadata.id, + scopes: metadata.scopes, +}); + +const enterpriseMetadata = providersMetadata[ProviderId.GitLabSelfHosted]; +const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: enterpriseMetadata.id, + scopes: enterpriseMetadata.scopes, +}); + +export type GitLabRepositoryDescriptor = { + key: string; + owner: string; + name: string; +}; + +export class GitLabIntegration extends ProviderIntegration { + readonly authProvider = authProvider; + readonly id: SupportedProviderIds = ProviderId.GitLab; + readonly name: string = 'GitLab'; + get domain(): string { + return metadata.domain; + } + + protected get apiBaseUrl(): string { + return 'https://gitlab.com/api'; + } + + protected override async getProviderAccountForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForCommit(this, accessToken, repo.owner, repo.name, ref, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForEmail( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + email: string, + options?: { + avatarSize?: number; + }, + ): Promise { + return (await this.container.gitlab)?.getAccountForEmail(this, accessToken, repo.owner, repo.name, email, { + ...options, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderDefaultBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ): Promise { + return (await this.container.gitlab)?.getDefaultBranch(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + id: string, + ): Promise { + return (await this.container.gitlab)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + Number(id), + { + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + branch: string, + options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const { include, ...opts } = options ?? {}; + + const toGitLabMergeRequestState = (await import(/* webpackChunkName: "gitlab" */ './gitlab/models')) + .toGitLabMergeRequestState; + return (await this.container.gitlab)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + ...opts, + include: include?.map(s => toGitLabMergeRequestState(s)), + baseUrl: this.apiBaseUrl, + }, + ); + } + + protected override async getProviderPullRequestForCommit( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ref: string, + ): Promise { + return (await this.container.gitlab)?.getPullRequestForCommit(this, accessToken, repo.owner, repo.name, ref, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderRepositoryMetadata( + { accessToken }: AuthenticationSession, + repo: GitLabRepositoryDescriptor, + ): Promise { + return (await this.container.gitlab)?.getRepositoryMetadata(this, accessToken, repo.owner, repo.name, { + baseUrl: this.apiBaseUrl, + }); + } + + protected override searchProviderMyPullRequests( + _session: AuthenticationSession, + _repo?: GitLabRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override searchProviderMyIssues( + _session: AuthenticationSession, + _repo?: GitLabRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } +} + +export class GitLabSelfHostedIntegration extends GitLabIntegration { + override readonly authProvider = enterpriseAuthProvider; + override readonly id = ProviderId.GitHubEnterprise; + override readonly name = 'GitLab Self-Hosted'; + override get domain(): string { + return this._domain; + } + protected override get apiBaseUrl(): string { + return `https://${this._domain}/api`; + } + protected override get key(): `${SupportedProviderIds}:${string}` { + return `${this.id}:${this.domain}`; + } + + constructor( + container: Container, + override readonly api: ProvidersApi, + private readonly _domain: string, + ) { + super(container, api); + } + + @log() + override async connect(): Promise { + if (!(await ensurePaidPlan(`${this.name} instance`, this.container))) { + return false; + } + + return super.connect(); + } +} diff --git a/src/plus/gitlab/gitlab.ts b/src/plus/integrations/providers/gitlab/gitlab.ts similarity index 93% rename from src/plus/gitlab/gitlab.ts rename to src/plus/integrations/providers/gitlab/gitlab.ts index 0b35ea5..700d6e6 100644 --- a/src/plus/gitlab/gitlab.ts +++ b/src/plus/integrations/providers/gitlab/gitlab.ts @@ -4,8 +4,8 @@ import { Uri, window } from 'vscode'; import type { RequestInit, Response } from '@env/fetch'; import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; import { isWeb } from '@env/platform'; -import type { CoreConfiguration } from '../../constants'; -import type { Container } from '../../container'; +import type { CoreConfiguration } from '../../../../constants'; +import type { Container } from '../../../../container'; import { AuthenticationError, AuthenticationErrorReason, @@ -14,24 +14,24 @@ import { ProviderRequestClientError, ProviderRequestNotFoundError, ProviderRequestRateLimitError, -} from '../../errors'; -import type { Account } from '../../git/models/author'; -import type { DefaultBranch } from '../../git/models/defaultBranch'; -import type { IssueOrPullRequest } from '../../git/models/issue'; -import { PullRequest } from '../../git/models/pullRequest'; -import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +} from '../../../../errors'; +import type { Account } from '../../../../git/models/author'; +import type { DefaultBranch } from '../../../../git/models/defaultBranch'; +import type { IssueOrPullRequest } from '../../../../git/models/issue'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; import { showIntegrationRequestFailed500WarningMessage, showIntegrationRequestTimedOutWarningMessage, -} from '../../messages'; -import { configuration } from '../../system/configuration'; -import { debug } from '../../system/decorators/log'; -import { Logger } from '../../system/logger'; -import type { LogScope } from '../../system/logger.scope'; -import { getLogScope, setLogScopeExit } from '../../system/logger.scope'; -import { maybeStopWatch } from '../../system/stopwatch'; -import { equalsIgnoreCase } from '../../system/string'; +} from '../../../../messages'; +import { configuration } from '../../../../system/configuration'; +import { debug } from '../../../../system/decorators/log'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope, setLogScopeExit } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import { equalsIgnoreCase } from '../../../../system/string'; import type { GitLabCommit, GitLabIssue, @@ -68,7 +68,7 @@ export class GitLabApi implements Disposable { } private _proxyAgents = new Map(); - private getProxyAgent(provider: RichRemoteProvider): HttpsProxyAgent | undefined { + private getProxyAgent(provider: Provider): HttpsProxyAgent | undefined { if (isWeb) return undefined; let proxyAgent = this._proxyAgents.get(provider.id); @@ -83,7 +83,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -150,7 +150,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getAccountForEmail( - provider: RichRemoteProvider, + provider: Provider, token: string, _owner: string, _repo: string, @@ -181,7 +181,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getDefaultBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -241,7 +241,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getIssueOrPullRequest( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -357,7 +357,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForBranch( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -507,7 +507,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getPullRequestForCommit( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -556,7 +556,7 @@ export class GitLabApi implements Disposable { @debug({ args: { 0: p => p.name, 1: '' } }) async getRepositoryMetadata( - provider: RichRemoteProvider, + provider: Provider, token: string, owner: string, repo: string, @@ -606,7 +606,7 @@ export class GitLabApi implements Disposable { } private async findUser( - provider: RichRemoteProvider, + provider: Provider, token: string, search: string, options?: { @@ -691,7 +691,7 @@ $search: String! } private getProjectId( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, @@ -710,7 +710,7 @@ $search: String! } private async getProjectIdCore( - provider: RichRemoteProvider, + provider: Provider, token: string, group: string, repo: string, @@ -762,7 +762,7 @@ $search: String! } private async graphql( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, query: string, @@ -820,7 +820,7 @@ $search: String! } private async request( - provider: RichRemoteProvider, + provider: Provider, token: string, baseUrl: string | undefined, route: string, @@ -874,7 +874,7 @@ $search: String! } private handleRequestError( - provider: RichRemoteProvider | undefined, + provider: Provider | undefined, token: string, ex: ProviderFetchError | (Error & { name: 'AbortError' }), scope: LogScope | undefined, @@ -910,7 +910,7 @@ $search: String! provider?.trackRequestException(); void showIntegrationRequestFailed500WarningMessage( `${provider?.name ?? 'GitLab'} failed to respond and might be experiencing issues.${ - !provider?.custom + provider == null || provider.id === 'gitlab' ? ' Please visit the [GitLab status page](https://status.gitlab.com) for more information.' : '' }`, @@ -939,7 +939,7 @@ $search: String! } } - private handleException(ex: Error, provider: RichRemoteProvider, scope: LogScope | undefined): Error { + private handleException(ex: Error, provider: Provider, scope: LogScope | undefined): Error { Logger.error(ex, scope); // debugger; @@ -949,7 +949,7 @@ $search: String! return ex; } - private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: RichRemoteProvider) { + private async showAuthenticationErrorMessage(ex: AuthenticationError, provider: Provider) { if (ex.reason === AuthenticationErrorReason.Unauthorized || ex.reason === AuthenticationErrorReason.Forbidden) { const confirm = 'Reauthenticate'; const result = await window.showErrorMessage( diff --git a/src/plus/gitlab/models.ts b/src/plus/integrations/providers/gitlab/models.ts similarity index 90% rename from src/plus/gitlab/models.ts rename to src/plus/integrations/providers/gitlab/models.ts index 9742f91..8df2641 100644 --- a/src/plus/gitlab/models.ts +++ b/src/plus/integrations/providers/gitlab/models.ts @@ -1,6 +1,6 @@ -import type { PullRequestState } from '../../git/models/pullRequest'; -import { PullRequest } from '../../git/models/pullRequest'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { PullRequestState } from '../../../../git/models/pullRequest'; +import { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; export interface GitLabUser { id: number; @@ -89,7 +89,7 @@ export interface GitLabMergeRequestREST { web_url: string; } -export function fromGitLabMergeRequestREST(pr: GitLabMergeRequestREST, provider: RichRemoteProvider): PullRequest { +export function fromGitLabMergeRequestREST(pr: GitLabMergeRequestREST, provider: Provider): PullRequest { return new PullRequest( provider, { diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts new file mode 100644 index 0000000..8e3c14f --- /dev/null +++ b/src/plus/integrations/providers/models.ts @@ -0,0 +1,283 @@ +import type { + Account, + AzureDevOps, + Bitbucket, + EnterpriseOptions, + GetRepoInput, + GitHub, + GitLab, + GitPullRequest, + GitRepository, + Issue, + Jira, + Trello, +} from '@gitkraken/provider-apis'; + +export type ProviderAccount = Account; +export type ProviderReposInput = (string | number)[] | GetRepoInput[]; +export type ProviderRepoInput = GetRepoInput; +export type ProviderPullRequest = GitPullRequest; +export type ProviderRepository = GitRepository; +export type ProviderIssue = Issue; + +export enum ProviderId { + GitHub = 'github', + GitHubEnterprise = 'github-enterprise', + GitLab = 'gitlab', + GitLabSelfHosted = 'gitlab-self-hosted', + Bitbucket = 'bitbucket', + Jira = 'jira', + Trello = 'trello', + AzureDevOps = 'azureDevOps', +} + +export enum PullRequestFilter { + Author = 'author', + Assignee = 'assignee', + ReviewRequested = 'review-requested', + Mention = 'mention', +} + +export enum IssueFilter { + Author = 'author', + Assignee = 'assignee', + Mention = 'mention', +} + +export enum PagingMode { + Project = 'project', + Repo = 'repo', + Repos = 'repos', +} + +export interface PagingInput { + cursor?: string | null; + page?: number; +} + +export interface PagedRepoInput { + repo: GetRepoInput; + cursor?: string; +} + +export interface PagedProjectInput { + namespace: string; + project: string; + cursor?: string; +} + +export interface GetPullRequestsOptions { + authorLogin?: string; + assigneeLogins?: string[]; + reviewRequestedLogin?: string; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetPullRequestsForRepoInput extends GetPullRequestsOptions { + repo: GetRepoInput; +} + +export interface GetPullRequestsForReposInput extends GetPullRequestsOptions { + repos: GetRepoInput[]; +} + +export interface GetPullRequestsForRepoIdsInput extends GetPullRequestsOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesOptions { + authorLogin?: string; + assigneeLogins?: string[]; + mentionLogin?: string; + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} + baseUrl?: string; +} + +export interface GetIssuesForRepoInput extends GetIssuesOptions { + repo: GetRepoInput; +} + +export interface GetIssuesForReposInput extends GetIssuesOptions { + repos: GetRepoInput[]; +} + +export interface GetIssuesForRepoIdsInput extends GetIssuesOptions { + repoIds: (string | number)[]; +} + +export interface GetIssuesForAzureProjectInput extends GetIssuesOptions { + namespace: string; + project: string; +} + +export interface GetReposOptions { + cursor?: string; // stringified JSON object of type { type: 'cursor' | 'page'; value: string | number } | {} +} + +export interface GetReposForAzureProjectInput { + namespace: string; + project: string; +} + +export interface PageInfo { + hasNextPage: boolean; + endCursor?: string | null; + nextPage?: number | null; +} + +export type GetPullRequestsForReposFn = ( + input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: GitPullRequest[]; pageInfo?: PageInfo }>; + +export type GetPullRequestsForRepoFn = ( + input: GetPullRequestsForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: GitPullRequest[]; pageInfo?: PageInfo }>; + +export type GetIssuesForReposFn = ( + input: (GetIssuesForReposInput | GetIssuesForRepoIdsInput) & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForRepoFn = ( + input: GetIssuesForRepoInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; + +export type GetIssuesForAzureProjectFn = ( + input: GetIssuesForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: Issue[]; pageInfo?: PageInfo }>; + +export type GetReposForAzureProjectFn = ( + input: GetReposForAzureProjectInput & PagingInput, + options?: EnterpriseOptions, +) => Promise<{ data: GitRepository[]; pageInfo?: PageInfo }>; + +export type getCurrentUserFn = (options?: EnterpriseOptions) => Promise<{ data: Account }>; +export type getCurrentUserForInstanceFn = ( + input: { namespace: string }, + options?: EnterpriseOptions, +) => Promise<{ data: Account }>; + +export interface ProviderInfo extends ProviderMetadata { + provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps; + getPullRequestsForReposFn?: GetPullRequestsForReposFn; + getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; + getIssuesForReposFn?: GetIssuesForReposFn; + getIssuesForRepoFn?: GetIssuesForRepoFn; + getIssuesForAzureProjectFn?: GetIssuesForAzureProjectFn; + getCurrentUserFn?: getCurrentUserFn; + getCurrentUserForInstanceFn?: getCurrentUserForInstanceFn; + getReposForAzureProjectFn?: GetReposForAzureProjectFn; +} + +export interface ProviderMetadata { + domain: string; + id: ProviderId; + issuesPagingMode?: PagingMode; + pullRequestsPagingMode?: PagingMode; + scopes: string[]; + supportedPullRequestFilters?: PullRequestFilter[]; + supportedIssueFilters?: IssueFilter[]; +} + +export type Providers = Record; +export type ProvidersMetadata = Record; + +export const providersMetadata: ProvidersMetadata = { + [ProviderId.GitHub]: { + domain: 'github.com', + id: ProviderId.GitHub, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [ProviderId.GitHubEnterprise]: { + domain: '', + id: ProviderId.GitHubEnterprise, + issuesPagingMode: PagingMode.Repos, + pullRequestsPagingMode: PagingMode.Repos, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + PullRequestFilter.Mention, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['repo', 'read:user', 'user:email'], + }, + [ProviderId.GitLab]: { + domain: 'gitlab.com', + id: ProviderId.GitLab, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['read_api', 'read_user', 'read_repository'], + }, + [ProviderId.GitLabSelfHosted]: { + domain: '', + id: ProviderId.GitLabSelfHosted, + issuesPagingMode: PagingMode.Repo, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'username' property on account for PR filters + supportedPullRequestFilters: [ + PullRequestFilter.Author, + PullRequestFilter.Assignee, + PullRequestFilter.ReviewRequested, + ], + // Use 'username' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], + scopes: ['read_api', 'read_user', 'read_repository'], + }, + [ProviderId.Bitbucket]: { + domain: 'bitbucket.org', + id: ProviderId.Bitbucket, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author], + scopes: ['account:read', 'repository:read', 'pullrequest:read', 'issue:read'], + }, + [ProviderId.AzureDevOps]: { + domain: 'dev.azure.com', + id: ProviderId.AzureDevOps, + issuesPagingMode: PagingMode.Project, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.Assignee], + // Use 'name' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], + }, + [ProviderId.Jira]: { + domain: 'atlassian.net', + id: ProviderId.Jira, + scopes: [], + }, + [ProviderId.Trello]: { + domain: 'trello.com', + id: ProviderId.Trello, + scopes: [], + }, +}; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts new file mode 100644 index 0000000..b67d5df --- /dev/null +++ b/src/plus/integrations/providers/providersApi.ts @@ -0,0 +1,609 @@ +import ProviderApis from '@gitkraken/provider-apis'; +import type { Container } from '../../../container'; +import type { PagedResult } from '../../../git/gitProvider'; +import type { + getCurrentUserFn, + getCurrentUserForInstanceFn, + GetIssuesForAzureProjectFn, + GetIssuesForRepoFn, + GetIssuesForReposFn, + GetIssuesOptions, + GetPullRequestsForRepoFn, + GetPullRequestsForReposFn, + GetPullRequestsOptions, + GetReposForAzureProjectFn, + GetReposOptions, + IssueFilter, + PagingMode, + ProviderAccount, + ProviderInfo, + ProviderIssue, + ProviderPullRequest, + ProviderRepoInput, + ProviderReposInput, + ProviderRepository, + Providers, + PullRequestFilter, +} from './models'; +import { ProviderId, providersMetadata } from './models'; + +export class ProvidersApi { + private readonly providers: Providers; + constructor(private readonly container: Container) { + const providerApis = ProviderApis(); + this.providers = { + [ProviderId.GitHub]: { + ...providersMetadata[ProviderId.GitHub], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as getCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [ProviderId.GitHubEnterprise]: { + ...providersMetadata[ProviderId.GitHubEnterprise], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as getCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, + [ProviderId.GitLab]: { + ...providersMetadata[ProviderId.GitLab], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as getCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + }, + [ProviderId.GitLabSelfHosted]: { + ...providersMetadata[ProviderId.GitLabSelfHosted], + provider: providerApis.gitlab, + getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as getCurrentUserFn, + getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( + providerApis.gitlab, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.gitlab.getPullRequestsForRepo.bind( + providerApis.gitlab, + ) as GetPullRequestsForRepoFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + }, + [ProviderId.Bitbucket]: { + ...providersMetadata[ProviderId.Bitbucket], + provider: providerApis.bitbucket, + getCurrentUserFn: providerApis.bitbucket.getCurrentUser.bind( + providerApis.bitbucket, + ) as getCurrentUserFn, + getPullRequestsForReposFn: providerApis.bitbucket.getPullRequestsForRepos.bind( + providerApis.bitbucket, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.bitbucket.getPullRequestsForRepo.bind( + providerApis.bitbucket, + ) as GetPullRequestsForRepoFn, + }, + [ProviderId.AzureDevOps]: { + ...providersMetadata[ProviderId.AzureDevOps], + provider: providerApis.azureDevOps, + getCurrentUserForInstanceFn: providerApis.azureDevOps.getCurrentUserForInstance.bind( + providerApis.azureDevOps, + ) as getCurrentUserForInstanceFn, + getPullRequestsForReposFn: providerApis.azureDevOps.getPullRequestsForRepos.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.azureDevOps.getPullRequestsForRepo.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForRepoFn, + getIssuesForAzureProjectFn: providerApis.azureDevOps.getIssuesForAzureProject.bind( + providerApis.azureDevOps, + ) as GetIssuesForAzureProjectFn, + getReposForAzureProjectFn: providerApis.azureDevOps.getReposForAzureProject.bind( + providerApis.azureDevOps, + ) as GetReposForAzureProjectFn, + }, + [ProviderId.Jira]: { + ...providersMetadata[ProviderId.Jira], + provider: providerApis.jira, + }, + [ProviderId.Trello]: { + ...providersMetadata[ProviderId.Trello], + provider: providerApis.trello, + }, + }; + } + + getScopesForProvider(providerId: ProviderId): string[] | undefined { + return this.providers[providerId]?.scopes; + } + + getProviderDomain(providerId: ProviderId): string | undefined { + return this.providers[providerId]?.domain; + } + + getProviderPullRequestsPagingMode(providerId: ProviderId): PagingMode | undefined { + return this.providers[providerId]?.pullRequestsPagingMode; + } + + getProviderIssuesPagingMode(providerId: ProviderId): PagingMode | undefined { + return this.providers[providerId]?.issuesPagingMode; + } + + providerSupportsPullRequestFilters(providerId: ProviderId, filters: PullRequestFilter[]): boolean { + return ( + this.providers[providerId]?.supportedPullRequestFilters != null && + filters.every(filter => this.providers[providerId]?.supportedPullRequestFilters?.includes(filter)) + ); + } + + providerSupportsIssueFilters(providerId: ProviderId, filters: IssueFilter[]): boolean { + return ( + this.providers[providerId]?.supportedIssueFilters != null && + filters.every(filter => this.providers[providerId]?.supportedIssueFilters?.includes(filter)) + ); + } + + isRepoIdsInput(input: any): input is (string | number)[] { + return ( + input != null && + Array.isArray(input) && + input.every((id: any) => typeof id === 'string' || typeof id === 'number') + ); + } + + async getProviderToken( + provider: ProviderInfo, + options?: { createSessionIfNeeded?: boolean }, + ): Promise { + const providerDescriptor = + provider.domain == null || provider.scopes == null + ? undefined + : { domain: provider.domain, scopes: provider.scopes }; + try { + return ( + await this.container.integrationAuthentication.getSession(provider.id, providerDescriptor, { + createIfNeeded: options?.createSessionIfNeeded, + }) + )?.accessToken; + } catch { + return undefined; + } + } + + async getPullRequestsForRepos( + providerId: ProviderId, + reposOrIds: ProviderReposInput, + options?: GetPullRequestsOptions, + ): Promise> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getPullRequestsForReposFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting pull requests for repositories`); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const input = { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + ...cursorOrPage, + }; + + const result = await provider.getPullRequestsForReposFn(input, { token: token, isPAT: true }); + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getPullRequestsForRepo( + providerId: ProviderId, + repo: ProviderRepoInput, + options?: GetPullRequestsOptions, + ): Promise> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getPullRequestsForRepoFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting pull requests for a repository`); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const result = await provider.getPullRequestsForRepoFn( + { + repo: repo, + ...options, + ...cursorOrPage, + }, + { token: token, isPAT: true }, + ); + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getIssuesForRepos( + providerId: ProviderId, + reposOrIds: ProviderReposInput, + options?: GetIssuesOptions, + ): Promise> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getIssuesForReposFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting issues for repositories`); + } + + if (provider.id === ProviderId.AzureDevOps) { + throw new Error( + `Provider with id ${providerId} does not support getting issues for repositories; use getIssuesForAzureProject instead`, + ); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const input = { + ...(this.isRepoIdsInput(reposOrIds) ? { repoIds: reposOrIds } : { repos: reposOrIds }), + ...options, + ...cursorOrPage, + }; + + const result = await provider.getIssuesForReposFn(input, { token: token, isPAT: true }); + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getIssuesForRepo( + providerId: ProviderId, + repo: ProviderRepoInput, + options?: GetIssuesOptions, + ): Promise> { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getIssuesForRepoFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting issues for a repository`); + } + + if (provider.id === ProviderId.AzureDevOps) { + throw new Error( + `Provider with id ${providerId} does not support getting issues for a repository; use getIssuesForAzureProject instead`, + ); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const result = await provider.getIssuesForRepoFn( + { + repo: repo, + ...options, + ...cursorOrPage, + }, + { token: token, isPAT: true }, + ); + + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getIssuesForAzureProject( + namespace: string, + project: string, + options?: GetIssuesOptions, + ): Promise> { + const provider = this.providers[ProviderId.AzureDevOps]; + if (provider == null) { + throw new Error(`Provider with id ${ProviderId.AzureDevOps} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${ProviderId.AzureDevOps}`); + } + + if (provider.getIssuesForAzureProjectFn == null) { + throw new Error( + `Provider with id ${ProviderId.AzureDevOps} does not support getting issues for an Azure project`, + ); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const result = await provider.getIssuesForAzureProjectFn( + { + namespace: namespace, + project: project, + ...options, + ...cursorOrPage, + }, + { token: token, isPAT: true }, + ); + + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getReposForAzureProject( + namespace: string, + project: string, + options?: GetReposOptions, + ): Promise> { + const provider = this.providers[ProviderId.AzureDevOps]; + if (provider == null) { + throw new Error(`Provider with id ${ProviderId.AzureDevOps} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${ProviderId.AzureDevOps}`); + } + + if (provider.getReposForAzureProjectFn == null) { + throw new Error( + `Provider with id ${ProviderId.AzureDevOps} does not support getting repositories for Azure projects`, + ); + } + + let cursorInfo; + try { + cursorInfo = JSON.parse(options?.cursor ?? '{}'); + } catch { + cursorInfo = {}; + } + const cursorValue = cursorInfo.value; + const cursorType = cursorInfo.type; + let cursorOrPage = {}; + if (cursorType === 'page') { + cursorOrPage = { page: cursorValue }; + } else if (cursorType === 'cursor') { + cursorOrPage = { cursor: cursorValue }; + } + + const result = await provider.getReposForAzureProjectFn( + { + namespace: namespace, + project: project, + ...cursorOrPage, + }, + { token: token, isPAT: true }, + ); + + const hasMore = result.pageInfo?.hasNextPage ?? false; + + let nextCursor = '{}'; + if (result.pageInfo?.endCursor != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.endCursor, type: 'cursor' }); + } else if (result.pageInfo?.nextPage != null) { + nextCursor = JSON.stringify({ value: result.pageInfo?.nextPage, type: 'page' }); + } + + return { + values: result.data, + paging: { + cursor: nextCursor, + more: hasMore, + }, + }; + } + + async getCurrentUser(providerId: ProviderId): Promise { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getCurrentUserFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting current user`); + } + + const { data: account } = await provider.getCurrentUserFn({ token: token, isPAT: true }); + return account; + } + + async getCurrentUserForInstance(providerId: ProviderId, namespace: string): Promise { + const provider = this.providers[providerId]; + if (provider == null) { + throw new Error(`Provider with id ${providerId} not registered`); + } + + const token = await this.getProviderToken(provider); + if (token == null) { + throw new Error(`Not connected to provider ${providerId}`); + } + + if (provider.getCurrentUserForInstanceFn == null) { + throw new Error(`Provider with id ${providerId} does not support getting current user for an instance`); + } + + const { data: account } = await provider.getCurrentUserForInstanceFn( + { namespace: namespace }, + { token: token, isPAT: true }, + ); + return account; + } +} diff --git a/src/plus/webviews/focus/focusWebview.ts b/src/plus/webviews/focus/focusWebview.ts index a7066c0..57d89b0 100644 --- a/src/plus/webviews/focus/focusWebview.ts +++ b/src/plus/webviews/focus/focusWebview.ts @@ -23,7 +23,7 @@ import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/m import type { GitWorktree } from '../../../git/models/worktree'; import { getWorktreeForBranch } from '../../../git/models/worktree'; import { parseGitRemoteUrl } from '../../../git/parsers/remoteParser'; -import type { RichRemoteProvider } from '../../../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../../../git/remotes/remoteProvider'; import { executeCommand } from '../../../system/command'; import { debug } from '../../../system/decorators/log'; import { Logger } from '../../../system/logger'; @@ -59,7 +59,7 @@ import { interface RepoWithRichRemote { repo: Repository; - remote: GitRemote; + remote: GitRemote; isConnected: boolean; isGitHub: boolean; } @@ -528,10 +528,12 @@ export class FocusWebviewProvider implements WebviewProvider { disposables.push(repo.onDidChange(this.onRepositoryChanged, this)); + const provider = this.container.integrations.getByRemote(richRemote); + repos.push({ repo: repo, remote: richRemote, - isConnected: await richRemote.provider.isConnected(), + isConnected: provider?.maybeConnected ?? (await provider?.isConnected()) ?? false, isGitHub: richRemote.provider.id === 'github', }); } @@ -565,7 +567,7 @@ export class FocusWebviewProvider implements WebviewProvider { const branchesByRepo = new Map>(); const worktreesByRepo = new Map(); - const queries = richRepos.map(r => [r, this.container.git.getMyPullRequests(r.remote)] as const); + const queries = richRepos.map(r => [r, this.container.integrations.getMyPullRequests(r.remote)] as const); for (const [r, query] of queries) { let prs; try { @@ -647,7 +649,7 @@ export class FocusWebviewProvider implements WebviewProvider { if (force || this._pullRequests == null) { const allIssues = []; - const queries = richRepos.map(r => [r, this.container.git.getMyIssues(r.remote)] as const); + const queries = richRepos.map(r => [r, this.container.integrations.getMyIssues(r.remote)] as const); for (const [r, query] of queries) { let issues; try { diff --git a/src/plus/webviews/focus/protocol.ts b/src/plus/webviews/focus/protocol.ts index 33d7697..526353e 100644 --- a/src/plus/webviews/focus/protocol.ts +++ b/src/plus/webviews/focus/protocol.ts @@ -9,7 +9,7 @@ export interface State extends WebviewState { access: FeatureAccess; pullRequests?: PullRequestResult[]; issues?: IssueResult[]; - repos?: RepoWithRichProvider[]; + repos?: RepoWithIntegration[]; } export interface SearchResultBase { @@ -35,7 +35,7 @@ export interface PullRequestResult extends SearchResultBase { hasLocalBranch: boolean; } -export interface RepoWithRichProvider { +export interface RepoWithIntegration { repo: string; isGitHub: boolean; isConnected: boolean; diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index f44f51e..8d58d47 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -54,8 +54,9 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { let branch = resource.base.branch; if (branch == null) { branch = await Container.instance.git.getDefaultBranchName(this.remote.repoPath, this.remote.name); - if (branch == null && this.remote.hasRichIntegration()) { - const defaultBranch = await this.remote.provider.getDefaultBranch?.(); + if (branch == null && this.remote.hasIntegration()) { + const provider = Container.instance.integrations.getByRemote(this.remote); + const defaultBranch = await provider?.getDefaultBranch?.(this.remote.provider.repoDesc); branch = defaultBranch?.name; } } diff --git a/src/statusbar/statusBarController.ts b/src/statusbar/statusBarController.ts index 0108582..29949a8 100644 --- a/src/statusbar/statusBarController.ts +++ b/src/statusbar/statusBarController.ts @@ -306,7 +306,7 @@ export class StatusBarController implements Disposable { const showPullRequests = !commit.isUncommitted && - remote?.hasRichIntegration() && + remote?.hasIntegration() && cfg.pullRequests.enabled && (CommitFormatter.has( cfg.format, diff --git a/src/views/nodes/commitNode.ts b/src/views/nodes/commitNode.ts index 70b35b5..07f056a 100644 --- a/src/views/nodes/commitNode.ts +++ b/src/views/nodes/commitNode.ts @@ -9,7 +9,7 @@ import type { GitCommit } from '../../git/models/commit'; import type { PullRequest } from '../../git/models/pullRequest'; import type { GitRevisionReference } from '../../git/models/reference'; import type { GitRemote } from '../../git/models/remote'; -import type { RichRemoteProvider } from '../../git/remotes/richRemoteProvider'; +import type { RemoteProvider } from '../../git/remotes/remoteProvider'; import { makeHierarchical } from '../../system/array'; import { pauseOnCancelOrTimeoutMapTuplePromise } from '../../system/cancellation'; import { configuration } from '../../system/configuration'; @@ -232,7 +232,7 @@ export class CommitNode extends ViewRefNode<'commit', ViewsWithCommits | FileHis private async getAssociatedPullRequest( commit: GitCommit, - remote?: GitRemote, + remote?: GitRemote, ): Promise { let pullRequest = this.getState('pullRequest'); if (pullRequest !== undefined) return Promise.resolve(pullRequest ?? undefined); @@ -264,7 +264,7 @@ export class CommitNode extends ViewRefNode<'commit', ViewsWithCommits | FileHis let enrichedAutolinks; let pr; - if (remote?.hasRichIntegration()) { + if (remote?.hasIntegration()) { const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ pauseOnCancelOrTimeoutMapTuplePromise(this.commit.getEnrichedAutolinks(remote)), this.getAssociatedPullRequest(this.commit, remote), diff --git a/src/views/nodes/fileRevisionAsCommitNode.ts b/src/views/nodes/fileRevisionAsCommitNode.ts index c5bcecb..c65def7 100644 --- a/src/views/nodes/fileRevisionAsCommitNode.ts +++ b/src/views/nodes/fileRevisionAsCommitNode.ts @@ -246,7 +246,7 @@ export async function getFileRevisionAsCommitTooltip( let enrichedAutolinks; let pr; - if (remote?.hasRichIntegration()) { + if (remote?.hasIntegration()) { const [enrichedAutolinksResult, prResult] = await Promise.allSettled([ pauseOnCancelOrTimeoutMapTuplePromise(commit.getEnrichedAutolinks(remote)), commit.getAssociatedPullRequest(remote), diff --git a/src/views/nodes/remoteNode.ts b/src/views/nodes/remoteNode.ts index 4eeec86..72b66b9 100644 --- a/src/views/nodes/remoteNode.ts +++ b/src/views/nodes/remoteNode.ts @@ -102,8 +102,9 @@ export class RemoteNode extends ViewNode<'remote', ViewsWithRemotes> { ), }; - if (provider.hasRichIntegration()) { - const connected = provider.maybeConnected ?? (await provider.isConnected()); + if (this.remote.hasIntegration()) { + const integration = this.view.container.integrations.getByRemote(this.remote); + const connected = integration?.maybeConnected ?? (await integration?.isConnected()); item.contextValue = `${ContextValues.Remote}${connected ? '+connected' : '+disconnected'}`; item.tooltip = `${this.remote.name} (${provider.name} ${GlyphChars.Dash} ${ diff --git a/src/webviews/commitDetails/commitDetailsWebview.ts b/src/webviews/commitDetails/commitDetailsWebview.ts index 0f3af67..cffd56b 100644 --- a/src/webviews/commitDetails/commitDetailsWebview.ts +++ b/src/webviews/commitDetails/commitDetailsWebview.ts @@ -647,7 +647,7 @@ export class CommitDetailsWebviewProvider const { commit } = current; if (commit == null) return; - const remote = await this.container.git.getBestRemoteWithRichProvider(commit.repoPath); + const remote = await this.container.git.getBestRemoteWithIntegration(commit.repoPath); if (cancellation.isCancellationRequested) return; @@ -966,7 +966,7 @@ export class CommitDetailsWebviewProvider const [commitResult, avatarUriResult, remoteResult] = await Promise.allSettled([ !commit.hasFullDetails() ? commit.ensureFullDetails().then(() => commit) : commit, commit.author.getAvatarUri(commit, { size: 32 }), - this.container.git.getBestRemoteWithRichProvider(commit.repoPath, { includeDisconnected: true }), + this.container.git.getBestRemoteWithIntegration(commit.repoPath, { includeDisconnected: true }), ]); commit = getSettledValue(commitResult, commit); diff --git a/yarn.lock b/yarn.lock index 1f3e5fc..37f3435 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,6 +242,13 @@ react-dragula "1.1.17" react-onclickoutside "^6.13.0" +"@gitkraken/provider-apis@0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@gitkraken/provider-apis/-/provider-apis-0.10.0.tgz#75cc20d710f07f3eb28df89d83213833b6d40073" + integrity sha512-aXMNYROQ/7fCTk08/dsDVqdyvYkohgU39dIsbqdhdj5uRjTVmybqaS3F9mQXPSdnTqztAkz46w43t8JfIi+gTQ== + dependencies: + node-fetch "2.6.9" + "@gitkraken/shared-web-components@0.1.1-rc.15": version "0.1.1-rc.15" resolved "https://registry.yarnpkg.com/@gitkraken/shared-web-components/-/shared-web-components-0.1.1-rc.15.tgz#efd520083a7f5a32fe342108f447e8c77723cfd2" @@ -4920,7 +4927,7 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== -node-fetch@2.7.0: +node-fetch@2.6.9, node-fetch@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==