From a6c7542c901d10ea649b5f0b9d8c630b8da713c9 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 3 Jan 2025 18:23:15 +0100 Subject: [PATCH] Adds support for GKDev Cloud GitHub Enterprise integration (#3901, #3922) --- src/constants.integrations.ts | 2 ++ .../integrations/authentication/github.ts | 10 ++++++ .../integrationAuthentication.ts | 11 ++++++- .../integrations/authentication/models.ts | 4 ++- src/plus/integrations/integrationService.ts | 32 +++++++++++++++++-- src/plus/integrations/providers/github.ts | 18 ++++++++--- src/plus/integrations/providers/models.ts | 17 ++++++++++ .../integrations/providers/providersApi.ts | 14 ++++++++ src/plus/integrations/providers/utils.ts | 2 +- 9 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 6b64aea480bce..0519e6643d812 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', } @@ -19,6 +20,7 @@ export type IntegrationId = HostingIntegrationId | IssueIntegrationId | SelfHost export const supportedOrderedCloudIssueIntegrationIds = [IssueIntegrationId.Jira]; export const supportedOrderedCloudIntegrationIds = [ + SelfHostedIntegrationId.CloudGitHubEnterprise, HostingIntegrationId.GitHub, HostingIntegrationId.GitLab, IssueIntegrationId.Jira, diff --git a/src/plus/integrations/authentication/github.ts b/src/plus/integrations/authentication/github.ts index 0368d6a795942..3b26217bcfbcf 100644 --- a/src/plus/integrations/authentication/github.ts +++ b/src/plus/integrations/authentication/github.ts @@ -69,6 +69,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/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts index a949581cc6ba2..66cc78cde62f9 100644 --- a/src/plus/integrations/authentication/integrationAuthentication.ts +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -338,7 +338,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 } @@ -522,6 +526,11 @@ export class IntegrationAuthenticationService implements Disposable { ).GitHubAuthenticationProvider(this.container) : new BuiltInAuthenticationProvider(this.container, providerId); + break; + case SelfHostedIntegrationId.CloudGitHubEnterprise: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './github') + ).GitHubEnterpriseCloudAuthenticationProvider(this.container); break; case SelfHostedIntegrationId.GitHubEnterprise: provider = new ( diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index 9c328ea0d7c99..9b340525c17a4 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -32,7 +32,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 +53,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 +65,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..452a825b592b6 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -44,6 +44,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,8 +89,10 @@ export class IntegrationService implements Disposable { @gate() @debug() private async syncCloudIntegrations(forceConnect: boolean) { + const scope = getLogScope(); const connectedIntegrations = new Set(); const loggedIn = await this.container.subscription.getAuthenticationSession(); + const domains = new Map(); if (loggedIn) { const cloudIntegrations = await this.container.cloudIntegrations; const connections = await cloudIntegrations?.getConnections(); @@ -100,10 +103,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; + domains.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(domains)) { await integration.syncCloudConnection( connectedIntegrations.has(integration.id) ? 'connected' : 'disconnected', forceConnect, @@ -121,9 +132,19 @@ export class IntegrationService implements Disposable { return connectedIntegrations; } - private async *getSupportedCloudIntegrations() { + private async *getSupportedCloudIntegrations(domains: Map): AsyncIterable { for (const id of getSupportedCloudIntegrationIds()) { - yield this.get(id); + if (id === SelfHostedIntegrationId.CloudGitHubEnterprise && !domains.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, domains?.get(id)); + } } } @@ -437,6 +458,7 @@ export class IntegrationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './providers/github') ).GitHubIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); break; + case SelfHostedIntegrationId.CloudGitHubEnterprise: case SelfHostedIntegrationId.GitHubEnterprise: if (domain == null) throw new Error(`Domain is required for '${id}' integration`); integration = new ( @@ -446,6 +468,7 @@ export class IntegrationService implements Disposable { this.authenticationService, this.getProvidersApi.bind(this), domain, + id, ); break; case HostingIntegrationId.GitLab: @@ -549,6 +572,9 @@ export class IntegrationService implements Disposable { 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(SelfHostedIntegrationId.CloudGitHubEnterprise, remote.provider.domain) as RT; + } return get(HostingIntegrationId.GitHub) as RT; case 'gitlab': if (remote.provider.custom && remote.provider.domain != null) { 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/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'); }