diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 435948ee898d9..e187d09f3ad5a 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -319,7 +319,7 @@ or ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' } ``` @@ -330,7 +330,7 @@ or ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' } ``` @@ -341,7 +341,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' } ``` @@ -352,7 +352,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' } ``` @@ -373,7 +373,7 @@ or ```typescript { - 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' + 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' } ``` @@ -1465,7 +1465,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted', // @deprecated: true 'remoteProviders.key': string } @@ -1478,7 +1478,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted', // @deprecated: true 'remoteProviders.key': string } diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 6b64aea480bce..8d3a6b9063a76 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -7,6 +7,7 @@ export enum HostingIntegrationId { export enum SelfHostedIntegrationId { GitHubEnterprise = 'github-enterprise', + CloudGitHubEnterprise = 'cloud-github-enterprise', GitLabSelfHosted = 'gitlab-self-hosted', } @@ -20,6 +21,7 @@ export type IntegrationId = HostingIntegrationId | IssueIntegrationId | SelfHost export const supportedOrderedCloudIssueIntegrationIds = [IssueIntegrationId.Jira]; export const supportedOrderedCloudIntegrationIds = [ HostingIntegrationId.GitHub, + SelfHostedIntegrationId.CloudGitHubEnterprise, HostingIntegrationId.GitLab, IssueIntegrationId.Jira, ]; @@ -45,6 +47,12 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ icon: 'gl-provider-github', supports: ['prs', 'issues'], }, + { + id: SelfHostedIntegrationId.CloudGitHubEnterprise, + name: 'GitHub Enterprise', + icon: 'gl-provider-github', + supports: ['prs', 'issues'], + }, { id: HostingIntegrationId.GitLab, name: 'GitLab', diff --git a/src/constants.storage.ts b/src/constants.storage.ts index f6dece94467b0..8ba966544b93f 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -78,6 +78,7 @@ export type GlobalStorage = { 'launchpadView:groups:expanded': StoredLaunchpadGroup[]; 'graph:searchMode': StoredGraphSearchMode; 'views:scm:grouped:welcome:dismissed': boolean; + 'integrations:configured': StoredIntegrationConfigurations; } & { [key in `plus:preview:${FeaturePreviews}:usages`]: StoredFeaturePreviewUsagePeriod[] } & { [key in `confirm:ai:tos:${AIProviders}`]: boolean; } & { @@ -88,6 +89,16 @@ export type GlobalStorage = { [key in `jira:${string}:projects`]: Stored; }; +export type StoredIntegrationConfigurations = Record; + +export interface StoredConfiguredIntegrationDescriptor { + cloud: boolean; + integrationId: IntegrationId; + domain?: string; + expiresAt?: string; + scopes: string; +} + export type DeprecatedWorkspaceStorage = { /** @deprecated use `confirm:ai:tos:${AIProviders}` */ 'confirm:sendToOpenAI': boolean; diff --git a/src/env/node/git/sub-providers/remotes.ts b/src/env/node/git/sub-providers/remotes.ts index d6b9b10ce89b6..f5bc80f92ad94 100644 --- a/src/env/node/git/sub-providers/remotes.ts +++ b/src/env/node/git/sub-providers/remotes.ts @@ -40,6 +40,7 @@ export class RemotesGitSubProvider extends RemotesGitProviderBase implements Git async function load(this: RemotesGitSubProvider): Promise { const providers = loadRemoteProviders( configuration.get('remotes', this.container.git.getRepository(repoPath!)?.folder?.uri ?? null), + this.container.integrations.getConfiguredIntegrationDescriptors(), ); try { diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index 8604802ff9f73..f8242320b06ee 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -1,5 +1,7 @@ import type { RemotesConfig } from '../../config'; +import { SelfHostedIntegrationId } from '../../constants.integrations'; import type { Container } from '../../container'; +import type { ConfiguredIntegrationDescriptor } from '../../plus/integrations/authentication/models'; import { Logger } from '../../system/logger'; import { configuration } from '../../system/vscode/configuration'; import { AzureDevOpsRemote } from './azure-devops'; @@ -73,7 +75,10 @@ const builtInProviders: RemoteProviders = [ }, ]; -export function loadRemoteProviders(cfg: RemotesConfig[] | null | undefined): RemoteProviders { +export function loadRemoteProviders( + cfg: RemotesConfig[] | null | undefined, + configuredIntegrations?: ConfiguredIntegrationDescriptor[], +): RemoteProviders { const providers: RemoteProviders = []; if (cfg?.length) { @@ -97,6 +102,29 @@ export function loadRemoteProviders(cfg: RemotesConfig[] | null | undefined): Re } } + if (configuredIntegrations?.length) { + for (const ci of configuredIntegrations) { + if (ci.integrationId === SelfHostedIntegrationId.CloudGitHubEnterprise && ci.domain) { + const matcher = ci.domain.toLocaleLowerCase(); + const providerCreator = (_container: Container, domain: string, path: string) => + new GitHubRemote(domain, path); + const provider = { + custom: false, + matcher: matcher, + creator: providerCreator, + }; + + const indexOfCustomDuplication: number = providers.findIndex(p => p.matcher === matcher); + + if (indexOfCustomDuplication !== -1) { + providers[indexOfCustomDuplication] = provider; + } else { + providers.push(provider); + } + } + } + } + providers.push(...builtInProviders); return providers; @@ -141,7 +169,10 @@ export function getRemoteProviderMatcher( providers?: RemoteProviders, ): (url: string, domain: string, path: string) => RemoteProvider | undefined { if (providers == null) { - providers = loadRemoteProviders(configuration.get('remotes', null)); + providers = loadRemoteProviders( + configuration.get('remotes', null), + container.integrations.getConfiguredIntegrationDescriptors(), + ); } return (url: string, domain: string, path: string) => diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts index 0368d6a795942..65afd26049c9e 100644 --- a/src/plus/integrations/authentication/github.ts +++ b/src/plus/integrations/authentication/github.ts @@ -4,7 +4,10 @@ import { wrapForForcedInsecureSSL } from '@env/fetch'; import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Sources } from '../../../constants.telemetry'; import type { Container } from '../../../container'; -import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import type { + IntegrationAuthenticationService, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; import { CloudIntegrationAuthenticationProvider, LocalIntegrationAuthenticationProvider, @@ -12,8 +15,8 @@ import { import type { ProviderAuthenticationSession } from './models'; export class GitHubAuthenticationProvider extends CloudIntegrationAuthenticationProvider { - constructor(container: Container) { - super(container); + constructor(container: Container, authenticationService: IntegrationAuthenticationService) { + super(container, authenticationService); this.disposables.push( authentication.onDidChangeSessions(e => { if (e.provider.id === this.authProviderId) { @@ -69,6 +72,16 @@ export class GitHubAuthenticationProvider extends CloudIntegrationAuthentication } } +export class GitHubEnterpriseCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override getCompletionInputTitle(): string { + throw new Error('Connect to GitHub Enterprise'); + } + + protected override get authProviderId(): SelfHostedIntegrationId.CloudGitHubEnterprise { + return SelfHostedIntegrationId.CloudGitHubEnterprise; + } +} + export class GitHubEnterpriseAuthenticationProvider extends LocalIntegrationAuthenticationProvider { protected override get authProviderId(): SelfHostedIntegrationId.GitHubEnterprise { return SelfHostedIntegrationId.GitHubEnterprise; diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts index 980946af2f519..25d9750eda9ff 100644 --- a/src/plus/integrations/authentication/gitlab.ts +++ b/src/plus/integrations/authentication/gitlab.ts @@ -3,7 +3,10 @@ import { env, ThemeIcon, Uri, window } from 'vscode'; import type { SelfHostedIntegrationId } from '../../../constants.integrations'; import { HostingIntegrationId } from '../../../constants.integrations'; import type { Container } from '../../../container'; -import type { IntegrationAuthenticationSessionDescriptor } from './integrationAuthentication'; +import type { + IntegrationAuthenticationService, + IntegrationAuthenticationSessionDescriptor, +} from './integrationAuthentication'; import { CloudIntegrationAuthenticationProvider, LocalIntegrationAuthenticationProvider, @@ -15,9 +18,10 @@ type GitLabId = HostingIntegrationId.GitLab | SelfHostedIntegrationId.GitLabSelf export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthenticationProvider { constructor( container: Container, + authenticationService: IntegrationAuthenticationService, protected readonly authProviderId: GitLabId, ) { - super(container); + super(container, authenticationService); } override async createSession( diff --git a/src/plus/integrations/authentication/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts index a949581cc6ba2..5d5cd28979aab 100644 --- a/src/plus/integrations/authentication/integrationAuthentication.ts +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -3,14 +3,14 @@ import { authentication, EventEmitter, window } from 'vscode'; import { wrapForForcedInsecureSSL } from '@env/fetch'; import type { IntegrationId } from '../../../constants.integrations'; import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; -import type { IntegrationAuthenticationKeys } from '../../../constants.storage'; +import type { IntegrationAuthenticationKeys, StoredConfiguredIntegrationDescriptor } from '../../../constants.storage'; import type { Sources } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { gate } from '../../../system/decorators/gate'; import { debug, log } from '../../../system/decorators/log'; import type { DeferredEventExecutor } from '../../../system/event'; -import { supportedIntegrationIds } from '../providers/models'; -import type { ProviderAuthenticationSession } from './models'; +import { isSelfHostedIntegrationId, supportedIntegrationIds } from '../providers/models'; +import type { ConfiguredIntegrationDescriptor, ProviderAuthenticationSession } from './models'; import { isSupportedCloudIntegrationId } from './models'; const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers) @@ -55,7 +55,10 @@ abstract class IntegrationAuthenticationProviderBase void d.dispose()); @@ -94,8 +97,12 @@ abstract class IntegrationAuthenticationProviderBase; - protected async deleteSecret(key: IntegrationAuthenticationKeys) { + protected async deleteSecret(key: IntegrationAuthenticationKeys, sessionId: string) { await this.container.storage.deleteSecret(key); + await this.authenticationService.removeConfigured({ + integrationId: this.authProviderId, + domain: isSelfHostedIntegrationId(this.authProviderId) ? sessionId : undefined, + }); } protected async writeSecret( @@ -103,18 +110,46 @@ abstract class IntegrationAuthenticationProviderBase { + protected async readSecret( + key: IntegrationAuthenticationKeys, + sessionId: string, + ): Promise { let storedSession: StoredSession | undefined; try { const sessionJSON = await this.container.storage.getSecret(key); if (sessionJSON) { storedSession = JSON.parse(sessionJSON); + if (storedSession != null) { + const configured = this.authenticationService.configured.get(this.authProviderId); + const domain = isSelfHostedIntegrationId(this.authProviderId) ? storedSession.id : undefined; + if ( + configured == null || + configured.length === 0 || + !configured.some(c => c.domain === domain && c.integrationId === this.authProviderId) + ) { + await this.authenticationService.addConfigured({ + integrationId: this.authProviderId, + domain: domain, + expiresAt: storedSession.expiresAt, + scopes: storedSession.scopes.join(','), + cloud: storedSession.cloud ?? false, + }); + } + } } } catch (_ex) { try { - await this.deleteSecret(key); + await this.deleteSecret(key, sessionId); } catch {} } return storedSession; @@ -189,7 +224,7 @@ export abstract class LocalIntegrationAuthenticationProvider< ID extends IntegrationId = IntegrationId, > extends IntegrationAuthenticationProviderBase { protected override async deleteAllSecrets(sessionId: string) { - await this.deleteSecret(this.getLocalSecretKey(sessionId)); + await this.deleteSecret(this.getLocalSecretKey(sessionId), sessionId); } protected override async storeSession(sessionId: string, session: ProviderAuthenticationSession) { @@ -198,7 +233,7 @@ export abstract class LocalIntegrationAuthenticationProvider< protected override async restoreSession(sessionId: string): Promise { const key = this.getLocalSecretKey(sessionId); - return convertStoredSessionToSession(await this.readSecret(key), false); + return convertStoredSessionToSession(await this.readSecret(key, sessionId), false); } protected abstract createSession( @@ -225,8 +260,8 @@ export abstract class CloudIntegrationAuthenticationProvider< protected override async deleteAllSecrets(sessionId: string) { await Promise.allSettled([ - this.deleteSecret(this.getLocalSecretKey(sessionId)), - this.deleteSecret(this.getCloudSecretKey(sessionId)), + this.deleteSecret(this.getLocalSecretKey(sessionId), sessionId), + this.deleteSecret(this.getCloudSecretKey(sessionId), sessionId), ]); } @@ -241,7 +276,7 @@ export abstract class CloudIntegrationAuthenticationProvider< protected override async restoreSession(sessionId: string): Promise { let cloudIfMissing = false; // At first we try to restore a token with the local key - let session = await this.readSecret(this.getLocalSecretKey(sessionId)); + let session = await this.readSecret(this.getLocalSecretKey(sessionId), sessionId); if (session != null) { // Check the `expiresAt` field // If it has an expiresAt property and the key is the old type, then it's a cloud session, @@ -251,7 +286,7 @@ export abstract class CloudIntegrationAuthenticationProvider< if (session.expiresAt != null) { cloudIfMissing = true; await Promise.allSettled([ - this.deleteSecret(this.getLocalSecretKey(sessionId)), + this.deleteSecret(this.getLocalSecretKey(sessionId), session.id), this.writeSecret(this.getCloudSecretKey(sessionId), session), ]); } @@ -260,7 +295,7 @@ export abstract class CloudIntegrationAuthenticationProvider< // If no local session we try to restore a session with the cloud key if (session == null) { cloudIfMissing = true; - session = await this.readSecret(this.getCloudSecretKey(sessionId)); + session = await this.readSecret(this.getCloudSecretKey(sessionId), sessionId); } return convertStoredSessionToSession(session, cloudIfMissing); @@ -338,7 +373,11 @@ export abstract class CloudIntegrationAuthenticationProvider< let session = await cloudIntegrations.getConnectionSession(this.authProviderId); // Make an exception for GitHub because they always return 0 - if (session?.expiresIn === 0 && this.authProviderId === HostingIntegrationId.GitHub) { + if ( + session?.expiresIn === 0 && + (this.authProviderId === HostingIntegrationId.GitHub || + this.authProviderId === SelfHostedIntegrationId.CloudGitHubEnterprise) + ) { // It never expires so don't refresh it frequently: session.expiresIn = maxSmallIntegerV8; // maximum expiration length } @@ -419,9 +458,10 @@ export abstract class CloudIntegrationAuthenticationProvider< class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticationProvider { constructor( container: Container, + authenticationService: IntegrationAuthenticationService, protected readonly authProviderId: IntegrationId, ) { - super(container); + super(container, authenticationService); this.disposables.push( authentication.onDidChangeSessions(e => { if (e.provider.id === this.authProviderId) { @@ -464,6 +504,7 @@ class BuiltInAuthenticationProvider extends LocalIntegrationAuthenticationProvid export class IntegrationAuthenticationService implements Disposable { private readonly providers = new Map(); + private _configured?: Map; constructor(private readonly container: Container) {} @@ -472,6 +513,65 @@ export class IntegrationAuthenticationService implements Disposable { this.providers.clear(); } + get configured(): Map { + if (this._configured == null) { + this._configured = new Map(); + const storedConfigured = this.container.storage.get('integrations:configured'); + for (const [id, configured] of Object.entries(storedConfigured ?? {})) { + if (configured == null) continue; + const descriptors = configured.map(d => ({ + ...d, + expiresAt: d.expiresAt ? new Date(d.expiresAt) : undefined, + })); + this._configured.set(id as IntegrationId, descriptors); + } + } + + return this._configured; + } + + private async storeConfigured() { + // We need to convert the map to a record to store + const configured: Record = {}; + for (const [id, descriptors] of this.configured) { + configured[id] = descriptors.map(d => ({ + ...d, + expiresAt: d.expiresAt + ? d.expiresAt instanceof Date + ? d.expiresAt.toISOString() + : d.expiresAt + : undefined, + })); + } + + await this.container.storage.store('integrations:configured', configured); + } + + async addConfigured(descriptor: ConfiguredIntegrationDescriptor) { + const descriptors = this.configured.get(descriptor.integrationId) ?? []; + // Only add if one does not exist + if (descriptors.some(d => d.domain === descriptor.domain && d.integrationId === descriptor.integrationId)) { + return; + } + descriptors.push(descriptor); + this.configured.set(descriptor.integrationId, descriptors); + await this.storeConfigured(); + } + + async removeConfigured(descriptor: Pick) { + const descriptors = this.configured.get(descriptor.integrationId); + if (descriptors == null) return; + const index = descriptors.findIndex( + d => d.domain === descriptor.domain && d.integrationId === descriptor.integrationId, + ); + if (index === -1) return; + + descriptors.splice(index, 1); + this.configured.set(descriptor.integrationId, descriptors); + + await this.storeConfigured(); + } + async get(providerId: IntegrationId): Promise { return this.ensureProvider(providerId); } @@ -508,47 +608,52 @@ export class IntegrationAuthenticationService implements Disposable { case HostingIntegrationId.AzureDevOps: provider = new ( await import(/* webpackChunkName: "integrations" */ './azureDevOps') - ).AzureDevOpsAuthenticationProvider(this.container); + ).AzureDevOpsAuthenticationProvider(this.container, this); break; case HostingIntegrationId.Bitbucket: provider = new ( await import(/* webpackChunkName: "integrations" */ './bitbucket') - ).BitbucketAuthenticationProvider(this.container); + ).BitbucketAuthenticationProvider(this.container, this); break; case HostingIntegrationId.GitHub: provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitHub) ? new ( await import(/* webpackChunkName: "integrations" */ './github') - ).GitHubAuthenticationProvider(this.container) - : new BuiltInAuthenticationProvider(this.container, providerId); + ).GitHubAuthenticationProvider(this.container, this) + : new BuiltInAuthenticationProvider(this.container, this, providerId); + break; + case SelfHostedIntegrationId.CloudGitHubEnterprise: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './github') + ).GitHubEnterpriseCloudAuthenticationProvider(this.container, this); break; case SelfHostedIntegrationId.GitHubEnterprise: provider = new ( await import(/* webpackChunkName: "integrations" */ './github') - ).GitHubEnterpriseAuthenticationProvider(this.container); + ).GitHubEnterpriseAuthenticationProvider(this.container, this); break; case HostingIntegrationId.GitLab: provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitLab) ? new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabCloudAuthenticationProvider(this.container) + ).GitLabCloudAuthenticationProvider(this.container, this) : new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabLocalAuthenticationProvider(this.container, HostingIntegrationId.GitLab); + ).GitLabLocalAuthenticationProvider(this.container, this, HostingIntegrationId.GitLab); break; case SelfHostedIntegrationId.GitLabSelfHosted: provider = new ( await import(/* webpackChunkName: "integrations" */ './gitlab') - ).GitLabLocalAuthenticationProvider(this.container, SelfHostedIntegrationId.GitLabSelfHosted); + ).GitLabLocalAuthenticationProvider(this.container, this, SelfHostedIntegrationId.GitLabSelfHosted); break; case IssueIntegrationId.Jira: provider = new ( await import(/* webpackChunkName: "integrations" */ './jira') - ).JiraAuthenticationProvider(this.container); + ).JiraAuthenticationProvider(this.container, this); break; default: - provider = new BuiltInAuthenticationProvider(this.container, providerId); + provider = new BuiltInAuthenticationProvider(this.container, this, providerId); } this.providers.set(providerId, provider); } diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index 9c328ea0d7c99..81483eb68a4ab 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -14,6 +14,14 @@ export interface ProviderAuthenticationSession extends AuthenticationSession { readonly expiresAt?: Date; } +export interface ConfiguredIntegrationDescriptor { + readonly cloud: boolean; + readonly integrationId: IntegrationId; + readonly domain?: string; + readonly expiresAt?: string | Date; + readonly scopes: string; +} + export interface CloudIntegrationAuthenticationSession { type: CloudIntegrationAuthType; accessToken: string; @@ -32,7 +40,7 @@ export interface CloudIntegrationConnection { domain: string; } -export type CloudIntegrationType = 'jira' | 'trello' | 'gitlab' | 'github' | 'bitbucket' | 'azure'; +export type CloudIntegrationType = 'jira' | 'trello' | 'gitlab' | 'github' | 'bitbucket' | 'azure' | 'githubEnterprise'; export type CloudIntegrationAuthType = 'oauth' | 'pat'; @@ -53,6 +61,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationId } = trello: IssueIntegrationId.Trello, gitlab: HostingIntegrationId.GitLab, github: HostingIntegrationId.GitHub, + githubEnterprise: SelfHostedIntegrationId.CloudGitHubEnterprise, bitbucket: HostingIntegrationId.Bitbucket, azure: HostingIntegrationId.AzureDevOps, }; @@ -64,6 +73,7 @@ export const toCloudIntegrationType: { [key in IntegrationId]: CloudIntegrationT [HostingIntegrationId.GitHub]: 'github', [HostingIntegrationId.Bitbucket]: 'bitbucket', [HostingIntegrationId.AzureDevOps]: 'azure', + [SelfHostedIntegrationId.CloudGitHubEnterprise]: 'githubEnterprise', [SelfHostedIntegrationId.GitHubEnterprise]: undefined, [SelfHostedIntegrationId.GitLabSelfHosted]: undefined, }; diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index 5cc0edcfde272..4ead07c9bfc43 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -21,6 +21,7 @@ import { configuration } from '../../system/vscode/configuration'; import { openUrl } from '../../system/vscode/utils'; import type { SubscriptionChangeEvent } from '../gk/account/subscriptionService'; import type { IntegrationAuthenticationService } from './authentication/integrationAuthentication'; +import type { ConfiguredIntegrationDescriptor } from './authentication/models'; import { CloudIntegrationAuthenticationUriPathPrefix, getSupportedCloudIntegrationIds, @@ -44,6 +45,7 @@ import type { } from './integration'; import { isHostingIntegrationId, isSelfHostedIntegrationId } from './providers/models'; import type { ProvidersApi } from './providers/providersApi'; +import { isGitHubDotCom } from './providers/utils'; export interface ConnectionStateChangeEvent { key: string; @@ -88,7 +90,10 @@ export class IntegrationService implements Disposable { @gate() @debug() private async syncCloudIntegrations(forceConnect: boolean) { + const scope = getLogScope(); const connectedIntegrations = new Set(); + const domainsById = new Map(); + const loggedIn = await this.container.subscription.getAuthenticationSession(); if (loggedIn) { const cloudIntegrations = await this.container.cloudIntegrations; @@ -100,10 +105,18 @@ export class IntegrationService implements Disposable { // GKDev includes some integrations like "google" that we don't support if (integrationId == null) return; connectedIntegrations.add(toIntegrationId[p.provider]); + if (p.domain?.length > 0) { + try { + const host = new URL(p.domain).host; + domainsById.set(integrationId, host); + } catch { + Logger.warn(`Invalid domain for ${integrationId} integration: ${p.domain}. Ignoring.`, scope); + } + } }); } - for await (const integration of this.getSupportedCloudIntegrations()) { + for await (const integration of this.getSupportedCloudIntegrations(domainsById)) { await integration.syncCloudConnection( connectedIntegrations.has(integration.id) ? 'connected' : 'disconnected', forceConnect, @@ -121,9 +134,19 @@ export class IntegrationService implements Disposable { return connectedIntegrations; } - private async *getSupportedCloudIntegrations() { + private async *getSupportedCloudIntegrations(domainsById: Map): AsyncIterable { for (const id of getSupportedCloudIntegrationIds()) { - yield this.get(id); + if (id === SelfHostedIntegrationId.CloudGitHubEnterprise && !domainsById.has(id)) { + try { + // Try getting whatever we have now because we will need to disconnect + yield this.get(id); + } catch { + // Ignore this exception and continue, + // because we probably haven't ever had an instance of this integration + } + } else { + yield this.get(id, domainsById.get(id)); + } } } @@ -216,9 +239,16 @@ export class IntegrationService implements Disposable { } for (const integrationId of integrationIds) { - const integration = await this.get(integrationId); - if (integration.maybeConnected ?? (await integration.isConnected())) { - connectedIntegrations.add(integrationId); + try { + const integration = await this.get(integrationId); + if (integration.maybeConnected ?? (await integration.isConnected())) { + connectedIntegrations.add(integrationId); + } + } catch (ex) { + Logger.log( + `Failed to get integration ${integrationId} by its ID. Consider it as not-connected and ignore. Error message: ${ex.message}`, + scope, + ); } } @@ -437,6 +467,45 @@ export class IntegrationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './providers/github') ).GitHubIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); break; + case SelfHostedIntegrationId.CloudGitHubEnterprise: + if (domain == null) { + integration = this.findCachedById(id); + if (integration != null) { + // return immediately in order to not to cache it after the "switch" block: + return integration; + } + + const existingConfigured = this.authenticationService.configured?.get( + SelfHostedIntegrationId.CloudGitHubEnterprise, + ); + if (existingConfigured?.length) { + const { domain } = existingConfigured[0]; + if (domain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubEnterpriseIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + id, + ); + break; + } + + throw new Error(`Domain is required for '${id}' integration`); + } + + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/github') + ).GitHubEnterpriseIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + id, + ); + break; case SelfHostedIntegrationId.GitHubEnterprise: if (domain == null) throw new Error(`Domain is required for '${id}' integration`); integration = new ( @@ -446,6 +515,7 @@ export class IntegrationService implements Disposable { this.authenticationService, this.getProvidersApi.bind(this), domain, + id, ); break; case HostingIntegrationId.GitLab: @@ -546,8 +616,13 @@ export class IntegrationService implements Disposable { // case 'bitbucket': // return get(HostingIntegrationId.Bitbucket) as RT; case 'github': - if (remote.provider.custom && remote.provider.domain != null) { - return get(SelfHostedIntegrationId.GitHubEnterprise, remote.provider.domain) as RT; + if (remote.provider.domain != null && !isGitHubDotCom(remote.provider.domain)) { + return get( + remote.provider.custom + ? SelfHostedIntegrationId.GitHubEnterprise + : SelfHostedIntegrationId.CloudGitHubEnterprise, + remote.provider.domain, + ) as RT; } return get(HostingIntegrationId.GitHub) as RT; case 'gitlab': @@ -857,12 +932,33 @@ export class IntegrationService implements Disposable { return this._integrations.get(this.getCacheKey(id, domain)); } + private findCachedById(id: SupportedSelfHostedIntegrationIds): Integration | undefined { + const key = this.getCacheKey(id, ''); + for (const [k, integration] of this._integrations) { + if (k.startsWith(key)) { + return integration; + } + } + return undefined; + } + private getCacheKey( id: SupportedHostingIntegrationIds | SupportedIssueIntegrationIds | SupportedSelfHostedIntegrationIds, domain?: string, ): IntegrationKey { return isSelfHostedIntegrationId(id) ? (`${id}:${domain}` as const) : id; } + + getConfiguredIntegrationDescriptors(id?: IntegrationId): ConfiguredIntegrationDescriptor[] { + const configured = this.authenticationService.configured; + if (id != null) return configured.get(id) ?? []; + const results = []; + for (const [, descriptors] of configured) { + results.push(...descriptors); + } + + return results; + } } export function remoteProviderIdToIntegrationId( diff --git a/src/plus/integrations/providers/github.ts b/src/plus/integrations/providers/github.ts index 4c8570a80dbb9..d99f589b87e8c 100644 --- a/src/plus/integrations/providers/github.ts +++ b/src/plus/integrations/providers/github.ts @@ -36,6 +36,11 @@ const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Obje id: enterpriseMetadata.id, scopes: enterpriseMetadata.scopes, }); +const cloudEnterpriseMetadata = providersMetadata[SelfHostedIntegrationId.CloudGitHubEnterprise]; +const cloudEnterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: cloudEnterpriseMetadata.id, + scopes: cloudEnterpriseMetadata.scopes, +}); export type GitHubRepositoryDescriptor = RepositoryDescriptor; @@ -300,10 +305,11 @@ export class GitHubIntegration extends GitHubIntegrationBase { - readonly authProvider = enterpriseAuthProvider; - readonly id = SelfHostedIntegrationId.GitHubEnterprise; - protected readonly key = `${this.id}:${this.domain}` as const; +export class GitHubEnterpriseIntegration extends GitHubIntegrationBase< + SelfHostedIntegrationId.GitHubEnterprise | SelfHostedIntegrationId.CloudGitHubEnterprise +> { + readonly authProvider; + protected readonly key; readonly name = 'GitHub Enterprise'; get domain(): string { return this._domain; @@ -318,8 +324,12 @@ export class GitHubEnterpriseIntegration extends GitHubIntegrationBase Promise, private readonly _domain: string, + readonly id: SelfHostedIntegrationId.GitHubEnterprise | SelfHostedIntegrationId.CloudGitHubEnterprise, ) { super(container, authenticationService, getProvidersApi); + this.key = `${this.id}:${this.domain}` as const; + this.authProvider = + this.id === SelfHostedIntegrationId.GitHubEnterprise ? enterpriseAuthProvider : cloudEnterpriseAuthProvider; } @log() diff --git a/src/plus/integrations/providers/github/sub-providers/remotes.ts b/src/plus/integrations/providers/github/sub-providers/remotes.ts index cb92bf9152e5b..80adca4a70b6e 100644 --- a/src/plus/integrations/providers/github/sub-providers/remotes.ts +++ b/src/plus/integrations/providers/github/sub-providers/remotes.ts @@ -14,7 +14,7 @@ export class RemotesGitSubProvider extends RemotesGitProviderBase { ): Promise { if (repoPath == null) return []; - const providers = loadRemoteProviders(configuration.get('remotes', null)); + const providers = loadRemoteProviders(configuration.get('remotes', null), undefined); const uri = Uri.parse(repoPath, true); const [, owner, repo] = uri.path.split('/', 3); diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 180d0a5288504..0003490d4a048 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -73,6 +73,7 @@ export type ProviderRequestResponse = Response; export type ProviderRequestOptions = RequestOptions; const selfHostedIntegrationIds: SelfHostedIntegrationId[] = [ + SelfHostedIntegrationId.CloudGitHubEnterprise, SelfHostedIntegrationId.GitHubEnterprise, SelfHostedIntegrationId.GitLabSelfHosted, ] as const; @@ -349,6 +350,22 @@ export const providersMetadata: ProvidersMetadata = { supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], scopes: ['repo', 'read:user', 'user:email'], }, + [SelfHostedIntegrationId.CloudGitHubEnterprise]: { + domain: '', + id: SelfHostedIntegrationId.CloudGitHubEnterprise, + 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'], + }, [SelfHostedIntegrationId.GitHubEnterprise]: { domain: '', id: SelfHostedIntegrationId.GitHubEnterprise, diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 75ae222a0fe0a..b9cc9b1e92638 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -98,6 +98,20 @@ export class ProvidersApi { providerApis.github, ) as GetIssuesForReposFn, }, + [SelfHostedIntegrationId.CloudGitHubEnterprise]: { + ...providersMetadata[SelfHostedIntegrationId.GitHubEnterprise], + provider: providerApis.github, + getCurrentUserFn: providerApis.github.getCurrentUser.bind(providerApis.github) as GetCurrentUserFn, + getPullRequestsForReposFn: providerApis.github.getPullRequestsForRepos.bind( + providerApis.github, + ) as GetPullRequestsForReposFn, + getPullRequestsForUserFn: providerApis.github.getPullRequestsAssociatedWithUser.bind( + providerApis.github, + ) as GetPullRequestsForUserFn, + getIssuesForReposFn: providerApis.github.getIssuesForRepos.bind( + providerApis.github, + ) as GetIssuesForReposFn, + }, [SelfHostedIntegrationId.GitHubEnterprise]: { ...providersMetadata[SelfHostedIntegrationId.GitHubEnterprise], provider: providerApis.github, diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 164bf4ba23c3b..63a3d2082fb19 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -12,7 +12,7 @@ import type { IssueResourceDescriptor, RepositoryDescriptor } from '../integrati import { isIssueResourceDescriptor, isRepositoryDescriptor } from '../integration'; import type { GitConfigEntityIdentifier } from './models'; -function isGitHubDotCom(domain: string): boolean { +export function isGitHubDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'github.com'); } diff --git a/src/webviews/apps/plus/shared/components/integrations-chip.ts b/src/webviews/apps/plus/shared/components/integrations-chip.ts index 021440d90d359..42d3e334cf47c 100644 --- a/src/webviews/apps/plus/shared/components/integrations-chip.ts +++ b/src/webviews/apps/plus/shared/components/integrations-chip.ts @@ -155,11 +155,12 @@ export class GLIntegrationsChip extends LitElement { override render() { const anyConnected = this.hasConnectedIntegrations; + const statusFilter = createIconBasedStatusFilter(this.integrations); return html` ${!anyConnected ? html`Connect` : ''}${this.integrations.map(i => - this.renderIntegrationStatus(i, anyConnected), - )}${!anyConnected ? html`Connect` : ''}${this.integrations + .filter(statusFilter) + .map(i => this.renderIntegrationStatus(i, anyConnected))}
@@ -267,3 +268,30 @@ function getIntegrationDetails(integration: IntegrationState): string { const last = features.pop(); return `Supports ${features.join(', ')} and ${last}`; } + +function createIconBasedStatusFilter(integrations: IntegrationState[]) { + const nothing = -1; + const icons = integrations.reduce<{ + [key: string]: undefined | { connectedIndex: number; firstIndex: number }; + }>((icons, i, index) => { + const state = icons[i.icon]; + if (!state) { + icons[i.icon] = { connectedIndex: i.connected ? index : nothing, firstIndex: index }; + } else if (i.connected && state.connectedIndex === nothing) { + state.connectedIndex = index; + } + return icons; + }, {}); + + // This filter returns true or false to allow or decline the integration. + // If nothing is connected with the same icon then allows the first one. + // If any connected then allows the first connected. + return function filter(i: IntegrationState, index: number) { + const state = icons[i.icon]; + if (state === undefined) return true; + if (state.connectedIndex !== nothing) { + return state.connectedIndex === index; + } + return state.firstIndex === index; + }; +} diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index cc62b31b70d1a..293f168d59013 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -879,9 +879,15 @@ export class HomeWebviewProvider implements WebviewProvider !integrations.some(i => i.id === d.id)), - ); + this._defaultSupportedCloudIntegrations.forEach(d => { + const i = integrations.find(i => i.id === d.id); + if (i == null) { + integrations.push(d); + } else if (i.icon !== d.icon) { + i.icon = d.icon; + } + }); + integrations.sort( (a, b) => supportedOrderedCloudIntegrationIds.indexOf(a.id) -