From 9a2f123f72989a0e3bdd3c6ce85e8e192b3f5f4b Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Thu, 16 Jan 2025 12:44:16 -0700 Subject: [PATCH] Adds Gitlab Self Managed support to Launchpad and Start Work --- docs/telemetry-events.md | 14 +-- src/constants.integrations.ts | 8 ++ src/git/remotes/remoteProvider.ts | 1 + src/git/remotes/remoteProviders.ts | 10 +- .../integrations/authentication/gitlab.ts | 13 ++- .../integrationAuthentication.ts | 9 +- .../integrations/authentication/models.ts | 12 ++- src/plus/integrations/integrationService.ts | 92 +++++++++++++++++-- src/plus/integrations/providers/gitlab.ts | 58 ++++++++++-- src/plus/integrations/providers/models.ts | 43 ++++++++- .../integrations/providers/providersApi.ts | 91 ++++++++++++++---- src/plus/integrations/providers/utils.ts | 15 ++- src/plus/launchpad/enrichmentService.ts | 1 + src/plus/launchpad/launchpad.ts | 2 + src/plus/launchpad/launchpadProvider.ts | 12 ++- src/plus/startWork/startWork.ts | 2 + 16 files changed, 326 insertions(+), 57 deletions(-) diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index e187d09f3ad5a..75136b413f61e 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' | 'cloud-github-enterprise' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -330,7 +330,7 @@ or ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -341,7 +341,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -352,7 +352,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -373,7 +373,7 @@ or ```typescript { - 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' + 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -1465,7 +1465,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | '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' | 'cloud-github-enterprise' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted', // @deprecated: true 'remoteProviders.key': string } diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 8d3a6b9063a76..209498fa25844 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -8,6 +8,7 @@ export enum HostingIntegrationId { export enum SelfHostedIntegrationId { GitHubEnterprise = 'github-enterprise', CloudGitHubEnterprise = 'cloud-github-enterprise', + CloudGitLabSelfHosted = 'cloud-gitlab-self-hosted', GitLabSelfHosted = 'gitlab-self-hosted', } @@ -23,6 +24,7 @@ export const supportedOrderedCloudIntegrationIds = [ HostingIntegrationId.GitHub, SelfHostedIntegrationId.CloudGitHubEnterprise, HostingIntegrationId.GitLab, + SelfHostedIntegrationId.CloudGitLabSelfHosted, IssueIntegrationId.Jira, ]; @@ -59,6 +61,12 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ icon: 'gl-provider-gitlab', supports: ['prs', 'issues'], }, + { + id: SelfHostedIntegrationId.CloudGitLabSelfHosted, + name: 'GitLab Self-Managed', + icon: 'gl-provider-gitlab', + supports: ['prs', 'issues'], + }, { id: IssueIntegrationId.Jira, name: 'Jira', diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index e556f59d09e2e..a5beda19389c9 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -21,6 +21,7 @@ export type RemoteProviderId = | 'gitea' | 'github' | 'cloud-github-enterprise' + | 'cloud-gitlab-self-hosted' | 'gitlab' | 'google-source'; diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index f8242320b06ee..50ed6ed743fd2 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -104,10 +104,16 @@ export function loadRemoteProviders( if (configuredIntegrations?.length) { for (const ci of configuredIntegrations) { - if (ci.integrationId === SelfHostedIntegrationId.CloudGitHubEnterprise && ci.domain) { + if ( + (ci.integrationId === SelfHostedIntegrationId.CloudGitHubEnterprise || + ci.integrationId === SelfHostedIntegrationId.CloudGitLabSelfHosted) && + ci.domain + ) { const matcher = ci.domain.toLocaleLowerCase(); const providerCreator = (_container: Container, domain: string, path: string) => - new GitHubRemote(domain, path); + ci.integrationId === SelfHostedIntegrationId.CloudGitHubEnterprise + ? new GitHubRemote(domain, path) + : new GitLabRemote(domain, path); const provider = { custom: false, matcher: matcher, diff --git a/src/plus/integrations/authentication/gitlab.ts b/src/plus/integrations/authentication/gitlab.ts index 25d9750eda9ff..b8ed8e3d4d015 100644 --- a/src/plus/integrations/authentication/gitlab.ts +++ b/src/plus/integrations/authentication/gitlab.ts @@ -1,7 +1,6 @@ import type { Disposable, QuickInputButton } from 'vscode'; import { env, ThemeIcon, Uri, window } from 'vscode'; -import type { SelfHostedIntegrationId } from '../../../constants.integrations'; -import { HostingIntegrationId } from '../../../constants.integrations'; +import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Container } from '../../../container'; import type { IntegrationAuthenticationService, @@ -95,6 +94,16 @@ export class GitLabLocalAuthenticationProvider extends LocalIntegrationAuthentic } } +export class GitLabSelfHostedCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override getCompletionInputTitle(): string { + throw new Error('Connect to GitLab Enterprise'); + } + + protected override get authProviderId(): SelfHostedIntegrationId.CloudGitLabSelfHosted { + return SelfHostedIntegrationId.CloudGitLabSelfHosted; + } +} + export class GitLabCloudAuthenticationProvider extends CloudIntegrationAuthenticationProvider { protected override get authProviderId(): GitLabId { return HostingIntegrationId.GitLab; diff --git a/src/plus/integrations/authentication/integrationAuthentication.ts b/src/plus/integrations/authentication/integrationAuthentication.ts index 5d5cd28979aab..880489210fc55 100644 --- a/src/plus/integrations/authentication/integrationAuthentication.ts +++ b/src/plus/integrations/authentication/integrationAuthentication.ts @@ -376,7 +376,9 @@ export abstract class CloudIntegrationAuthenticationProvider< if ( session?.expiresIn === 0 && (this.authProviderId === HostingIntegrationId.GitHub || - this.authProviderId === SelfHostedIntegrationId.CloudGitHubEnterprise) + this.authProviderId === SelfHostedIntegrationId.CloudGitHubEnterprise || + // Note: added GitLab self managed here because the cloud token is always a PAT, and the api does not know when it expires, nor can it refresh it + this.authProviderId === SelfHostedIntegrationId.CloudGitLabSelfHosted) ) { // It never expires so don't refresh it frequently: session.expiresIn = maxSmallIntegerV8; // maximum expiration length @@ -642,6 +644,11 @@ export class IntegrationAuthenticationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './gitlab') ).GitLabLocalAuthenticationProvider(this.container, this, HostingIntegrationId.GitLab); break; + case SelfHostedIntegrationId.CloudGitLabSelfHosted: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './gitlab') + ).GitLabSelfHostedCloudAuthenticationProvider(this.container, this); + break; case SelfHostedIntegrationId.GitLabSelfHosted: provider = new ( await import(/* webpackChunkName: "integrations" */ './gitlab') diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index 81483eb68a4ab..784fe986ab17e 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -40,7 +40,15 @@ export interface CloudIntegrationConnection { domain: string; } -export type CloudIntegrationType = 'jira' | 'trello' | 'gitlab' | 'github' | 'bitbucket' | 'azure' | 'githubEnterprise'; +export type CloudIntegrationType = + | 'jira' + | 'trello' + | 'gitlab' + | 'github' + | 'bitbucket' + | 'azure' + | 'githubEnterprise' + | 'gitlabSelfHosted'; export type CloudIntegrationAuthType = 'oauth' | 'pat'; @@ -62,6 +70,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationId } = gitlab: HostingIntegrationId.GitLab, github: HostingIntegrationId.GitHub, githubEnterprise: SelfHostedIntegrationId.CloudGitHubEnterprise, + gitlabSelfHosted: SelfHostedIntegrationId.CloudGitLabSelfHosted, bitbucket: HostingIntegrationId.Bitbucket, azure: HostingIntegrationId.AzureDevOps, }; @@ -74,6 +83,7 @@ export const toCloudIntegrationType: { [key in IntegrationId]: CloudIntegrationT [HostingIntegrationId.Bitbucket]: 'bitbucket', [HostingIntegrationId.AzureDevOps]: 'azure', [SelfHostedIntegrationId.CloudGitHubEnterprise]: 'githubEnterprise', + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: 'gitlabSelfHosted', [SelfHostedIntegrationId.GitHubEnterprise]: undefined, [SelfHostedIntegrationId.GitLabSelfHosted]: undefined, }; diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index 3573a200da88b..3c01dfba9a904 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -45,7 +45,7 @@ import type { } from './integration'; import { isHostingIntegrationId, isSelfHostedIntegrationId } from './providers/models'; import type { ProvidersApi } from './providers/providersApi'; -import { isGitHubDotCom } from './providers/utils'; +import { isGitHubDotCom, isGitLabDotCom } from './providers/utils'; export interface ConnectionStateChangeEvent { key: string; @@ -136,7 +136,11 @@ export class IntegrationService implements Disposable { private async *getSupportedCloudIntegrations(domainsById: Map): AsyncIterable { for (const id of getSupportedCloudIntegrationIds()) { - if (id === SelfHostedIntegrationId.CloudGitHubEnterprise && !domainsById.has(id)) { + if ( + (id === SelfHostedIntegrationId.CloudGitHubEnterprise || + id === SelfHostedIntegrationId.CloudGitLabSelfHosted) && + !domainsById.has(id) + ) { try { // Try getting whatever we have now because we will need to disconnect yield this.get(id); @@ -452,7 +456,10 @@ export class IntegrationService implements Disposable { } get( - id: SupportedHostingIntegrationIds | SelfHostedIntegrationId.CloudGitHubEnterprise, + id: + | SupportedHostingIntegrationIds + | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.CloudGitLabSelfHosted, ): Promise; get(id: SupportedIssueIntegrationIds): Promise; get(id: SupportedSelfHostedIntegrationIds, domain: string): Promise; @@ -527,6 +534,47 @@ export class IntegrationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './providers/gitlab') ).GitLabIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); break; + case SelfHostedIntegrationId.CloudGitLabSelfHosted: + 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.CloudGitLabSelfHosted, + ); + if (existingConfigured?.length) { + const { domain: configuredDomain } = existingConfigured[0]; + if (configuredDomain == null) throw new Error(`Domain is required for '${id}' integration`); + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabSelfHostedIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + configuredDomain, + id, + ); + // assign domain because it's part of caching key: + domain = configuredDomain; + break; + } + + throw new Error(`Domain is required for '${id}' integration`); + } + + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/gitlab') + ).GitLabSelfHostedIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + id, + ); + break; case SelfHostedIntegrationId.GitLabSelfHosted: if (domain == null) throw new Error(`Domain is required for '${id}' integration`); integration = new ( @@ -536,6 +584,7 @@ export class IntegrationService implements Disposable { this.authenticationService, this.getProvidersApi.bind(this), domain, + id, ); break; case HostingIntegrationId.Bitbucket: @@ -630,8 +679,13 @@ export class IntegrationService implements Disposable { } return get(HostingIntegrationId.GitHub) as RT; case 'gitlab': - if (remote.provider.custom && remote.provider.domain != null) { - return get(SelfHostedIntegrationId.GitLabSelfHosted, remote.provider.domain) as RT; + if (remote.provider.domain != null && !isGitLabDotCom(remote.provider.domain)) { + return get( + remote.provider.custom + ? SelfHostedIntegrationId.GitLabSelfHosted + : SelfHostedIntegrationId.CloudGitLabSelfHosted, + remote.provider.domain, + ) as RT; } return get(HostingIntegrationId.GitLab) as RT; default: @@ -771,9 +825,25 @@ export class IntegrationService implements Disposable { args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : '') }, }) async getMyCurrentAccounts( - integrationIds: (HostingIntegrationId | SelfHostedIntegrationId.CloudGitHubEnterprise)[], - ): Promise> { - const accounts = new Map(); + integrationIds: ( + | HostingIntegrationId + | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.CloudGitLabSelfHosted + )[], + ): Promise< + Map< + | HostingIntegrationId + | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.CloudGitLabSelfHosted, + Account + > + > { + const accounts = new Map< + | HostingIntegrationId + | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.CloudGitLabSelfHosted, + Account + >(); await Promise.allSettled( integrationIds.map(async integrationId => { const integration = await this.get(integrationId); @@ -792,7 +862,11 @@ export class IntegrationService implements Disposable { args: { 0: integrationIds => (integrationIds?.length ? integrationIds.join(',') : ''), 1: false }, }) async getMyPullRequests( - integrationIds?: (HostingIntegrationId | SelfHostedIntegrationId.CloudGitHubEnterprise)[], + integrationIds?: ( + | HostingIntegrationId + | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.CloudGitLabSelfHosted + )[], cancellation?: CancellationToken, silent?: boolean, ): Promise> { diff --git a/src/plus/integrations/providers/gitlab.ts b/src/plus/integrations/providers/gitlab.ts index ab553b269d9a1..8268f0467673e 100644 --- a/src/plus/integrations/providers/gitlab.ts +++ b/src/plus/integrations/providers/gitlab.ts @@ -40,11 +40,19 @@ const enterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Obje id: enterpriseMetadata.id, scopes: enterpriseMetadata.scopes, }); +const cloudEnterpriseMetadata = providersMetadata[SelfHostedIntegrationId.CloudGitLabSelfHosted]; +const cloudEnterpriseAuthProvider: IntegrationAuthenticationProviderDescriptor = Object.freeze({ + id: cloudEnterpriseMetadata.id, + scopes: cloudEnterpriseMetadata.scopes, +}); export type GitLabRepositoryDescriptor = RepositoryDescriptor; abstract class GitLabIntegrationBase< - ID extends HostingIntegrationId.GitLab | SelfHostedIntegrationId.GitLabSelfHosted, + ID extends + | HostingIntegrationId.GitLab + | SelfHostedIntegrationId.GitLabSelfHosted + | SelfHostedIntegrationId.CloudGitLabSelfHosted, > extends HostingIntegration { protected abstract get apiBaseUrl(): string; @@ -109,6 +117,9 @@ abstract class GitLabIntegrationBase< ): Promise { const api = await this.container.gitlab; const providerApi = await this.getProvidersApi(); + const isEnterprise = + this.id === SelfHostedIntegrationId.GitLabSelfHosted || + this.id === SelfHostedIntegrationId.CloudGitLabSelfHosted; if (!api || !repo || !id) { return undefined; @@ -122,7 +133,11 @@ abstract class GitLabIntegrationBase< const apiResult = await providerApi.getIssue( this.id, { namespace: repo.owner, name: repo.name, number: id }, - { accessToken: accessToken }, + { + accessToken: accessToken, + isPAT: isEnterprise, + baseUrl: isEnterprise ? `https://${this.domain}` : undefined, + }, ); const issue = apiResult != null ? toSearchedIssue(apiResult, this)?.issue : undefined; return issue != null ? { ...issue, type: 'issue' } : undefined; @@ -204,12 +219,17 @@ abstract class GitLabIntegrationBase< repos?: GitLabRepositoryDescriptor[], ): Promise { const api = await this.getProvidersApi(); + const isEnterprise = + this.id === SelfHostedIntegrationId.GitLabSelfHosted || + this.id === SelfHostedIntegrationId.CloudGitLabSelfHosted; const username = (await this.getCurrentAccount())?.username; if (!username) { return Promise.resolve([]); } const apiResult = await api.getPullRequestsForUser(this.id, username, { accessToken: accessToken, + isPAT: isEnterprise, + baseUrl: isEnterprise ? `https://${this.domain}` : undefined, }); if (apiResult == null) { @@ -291,6 +311,9 @@ abstract class GitLabIntegrationBase< ): Promise { const api = await this.container.gitlab; const providerApi = await this.getProvidersApi(); + const isEnterprise = + this.id === SelfHostedIntegrationId.GitLabSelfHosted || + this.id === SelfHostedIntegrationId.CloudGitLabSelfHosted; if (!api || !repos) { return undefined; @@ -307,6 +330,8 @@ abstract class GitLabIntegrationBase< .filter((r): r is string => r != null); const apiResult = await providerApi.getIssuesForRepos(this.id, repoInput, { accessToken: accessToken, + isPAT: isEnterprise, + baseUrl: isEnterprise ? `https://${this.domain}` : undefined, }); return apiResult.values @@ -346,8 +371,15 @@ abstract class GitLabIntegrationBase< ): Promise { if (!this.isPullRequest(pr)) return false; const api = await this.getProvidersApi(); + const isEnterprise = + this.id === SelfHostedIntegrationId.GitLabSelfHosted || + this.id === SelfHostedIntegrationId.CloudGitLabSelfHosted; try { - const res = await api.mergePullRequest(this.id, pr, options); + const res = await api.mergePullRequest(this.id, pr, { + ...options, + isPAT: isEnterprise, + baseUrl: isEnterprise ? `https://${this.domain}` : undefined, + }); return res; } catch (ex) { void this.showMergeErrorMessage(ex); @@ -378,7 +410,14 @@ abstract class GitLabIntegrationBase< accessToken, }: AuthenticationSession): Promise { const api = await this.getProvidersApi(); - const currentUser = await api.getCurrentUser(this.id, { accessToken: accessToken }); + const isEnterprise = + this.id === SelfHostedIntegrationId.GitLabSelfHosted || + this.id === SelfHostedIntegrationId.CloudGitLabSelfHosted; + const currentUser = await api.getCurrentUser(this.id, { + accessToken: accessToken, + isPAT: isEnterprise, + baseUrl: isEnterprise ? `https://${this.domain}` : undefined, + }); if (currentUser == null) return undefined; return { @@ -420,10 +459,11 @@ export class GitLabIntegration extends GitLabIntegrationBase { +export class GitLabSelfHostedIntegration extends GitLabIntegrationBase< + SelfHostedIntegrationId.GitLabSelfHosted | SelfHostedIntegrationId.CloudGitLabSelfHosted +> { readonly authProvider = enterpriseAuthProvider; - readonly id = SelfHostedIntegrationId.GitLabSelfHosted; - protected readonly key = `${this.id}:${this.domain}` as const; + protected readonly key; readonly name = 'GitLab Self-Hosted'; get domain(): string { return this._domain; @@ -437,8 +477,12 @@ export class GitLabSelfHostedIntegration extends GitLabIntegrationBase Promise, private readonly _domain: string, + readonly id: SelfHostedIntegrationId.GitLabSelfHosted | SelfHostedIntegrationId.CloudGitLabSelfHosted, ) { super(container, authenticationService, getProvidersApi); + this.key = `${this.id}:${this.domain}` as const; + this.authProvider = + this.id === SelfHostedIntegrationId.GitLabSelfHosted ? enterpriseAuthProvider : cloudEnterpriseAuthProvider; } @log() diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index cf7694dd1ef9b..16e3183079127 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -10,6 +10,7 @@ import type { GetRepoInput, GitHub, GitLab, + GitMergeStrategy, GitPullRequest, GitRepository, Issue, @@ -20,6 +21,7 @@ import type { RequestFunction, RequestOptions, Response, + SetPullRequestInput, Trello, } from '@gitkraken/provider-apis'; import { @@ -30,7 +32,6 @@ import { GitPullRequestReviewState, GitPullRequestState, } from '@gitkraken/provider-apis'; -import type { GitProvider } from '@gitkraken/provider-apis/dist/providers/gitProvider'; import type { IntegrationId } from '../../../constants.integrations'; import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Account as UserAccount } from '../../../git/models/author'; @@ -75,6 +76,7 @@ export type ProviderRequestOptions = RequestOptions; const selfHostedIntegrationIds: SelfHostedIntegrationId[] = [ SelfHostedIntegrationId.CloudGitHubEnterprise, SelfHostedIntegrationId.GitHubEnterprise, + SelfHostedIntegrationId.CloudGitLabSelfHosted, SelfHostedIntegrationId.GitLabSelfHosted, ] as const; @@ -236,7 +238,29 @@ export type GetPullRequestsForAzureProjectsFn = ( options?: EnterpriseOptions, ) => Promise<{ data: ProviderPullRequest[] }>; -export type MergePullRequestFn = GitProvider['mergePullRequest']; +export type MergePullRequestFn = + | (( + input: { + pullRequest: { + headRef: { + oid: string | null; + } | null; + } & SetPullRequestInput; + mergeStrategy?: GitMergeStrategy; + }, + options?: EnterpriseOptions, + ) => Promise) + | (( + input: { + pullRequest: { + headRef: { + oid: string | null; + } | null; + } & SetPullRequestInput; + mergeStrategy?: GitMergeStrategy.Squash; + }, + options?: EnterpriseOptions, + ) => Promise); export type GetIssueFn = ( input: @@ -397,6 +421,21 @@ export const providersMetadata: ProvidersMetadata = { supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee], scopes: ['api', 'read_user', 'read_repository'], }, + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: { + domain: '', + id: SelfHostedIntegrationId.CloudGitLabSelfHosted, + 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: ['api', 'read_user', 'read_repository'], + }, [SelfHostedIntegrationId.GitLabSelfHosted]: { domain: '', id: SelfHostedIntegrationId.GitLabSelfHosted, diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index b9cc9b1e92638..1f759ba5642e8 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -148,6 +148,28 @@ export class ProvidersApi { ) as GetIssuesForRepoFn, mergePullRequestFn: providerApis.gitlab.mergePullRequest.bind(providerApis.gitlab), }, + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: { + ...providersMetadata[HostingIntegrationId.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, + getPullRequestsForUserFn: providerApis.gitlab.getPullRequestsAssociatedWithUser.bind( + providerApis.gitlab, + ) as GetPullRequestsForUserFn, + getIssueFn: providerApis.gitlab.getIssue.bind(providerApis.gitlab) as GetIssueFn, + getIssuesForReposFn: providerApis.gitlab.getIssuesForRepos.bind( + providerApis.gitlab, + ) as GetIssuesForReposFn, + getIssuesForRepoFn: providerApis.gitlab.getIssuesForRepo.bind( + providerApis.gitlab, + ) as GetIssuesForRepoFn, + mergePullRequestFn: providerApis.gitlab.mergePullRequest.bind(providerApis.gitlab), + }, [SelfHostedIntegrationId.GitLabSelfHosted]: { ...providersMetadata[SelfHostedIntegrationId.GitLabSelfHosted], provider: providerApis.gitlab, @@ -361,12 +383,13 @@ export class ProvidersApi { providerFn: | (( input: any, - options?: { token?: string; isPAT?: boolean }, + options?: { token?: string; isPAT?: boolean; baseUrl?: string }, ) => Promise<{ data: NonNullable[]; pageInfo?: PageInfo }>) | undefined, token: string, cursor: string = '{}', - usePAT: boolean = false, + isPAT: boolean = false, + baseUrl?: string, ): Promise> { let cursorInfo; try { @@ -389,7 +412,7 @@ export class ProvidersApi { }; try { - const result = await providerFn?.(input, { token: token, isPAT: usePAT }); + const result = await providerFn?.(input, { token: token, isPAT: isPAT, baseUrl: baseUrl }); if (result == null) { return { values: [] }; } @@ -417,7 +440,7 @@ export class ProvidersApi { async getCurrentUser( providerId: IntegrationId, - options?: { accessToken?: string; isPAT?: boolean }, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -426,7 +449,12 @@ export class ProvidersApi { ); try { - return (await provider.getCurrentUserFn?.({}, { token: token, isPAT: options?.isPAT }))?.data; + return ( + await provider.getCurrentUserFn?.( + {}, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, + ) + )?.data; } catch (e) { return this.handleProviderError(providerId, token, e); } @@ -435,7 +463,7 @@ export class ProvidersApi { async getCurrentUserForInstance( providerId: IntegrationId, namespace: string, - options?: { accessToken?: string; isPAT?: boolean }, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -446,7 +474,7 @@ export class ProvidersApi { return ( await provider.getCurrentUserForInstanceFn?.( { namespace: namespace }, - { token: token, isPAT: options?.isPAT }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, ) )?.data; } @@ -454,7 +482,7 @@ export class ProvidersApi { async getCurrentUserForResource( providerId: IntegrationId, resourceId: string, - options?: { accessToken?: string }, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -463,7 +491,12 @@ export class ProvidersApi { ); try { - return (await provider.getCurrentUserForResourceFn?.({ resourceId: resourceId }, { token: token }))?.data; + return ( + await provider.getCurrentUserForResourceFn?.( + { resourceId: resourceId }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, + ) + )?.data; } catch (e) { return this.handleProviderError(providerId, token, e); } @@ -578,7 +611,7 @@ export class ProvidersApi { async getPullRequestsForRepos( providerId: IntegrationId, reposOrIds: ProviderReposInput, - options?: GetPullRequestsOptions & { accessToken?: string }, + options?: GetPullRequestsOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -595,13 +628,15 @@ export class ProvidersApi { provider.getPullRequestsForReposFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } async getPullRequestsForRepo( providerId: IntegrationId, repo: ProviderRepoInput, - options?: GetPullRequestsOptions & { accessToken?: string }, + options?: GetPullRequestsOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -615,23 +650,25 @@ export class ProvidersApi { provider.getPullRequestsForRepoFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } async getPullRequestsForUser( providerId: HostingIntegrationId.Bitbucket, userId: string, - options?: { accessToken?: string } & GetPullRequestsForUserOptions, + options?: { accessToken?: string; isPAT?: boolean } & GetPullRequestsForUserOptions, ): Promise>; async getPullRequestsForUser( providerId: Exclude, username: string, - options?: { accessToken?: string } & GetPullRequestsForUserOptions, + options?: { accessToken?: string; isPAT?: boolean } & GetPullRequestsForUserOptions, ): Promise>; async getPullRequestsForUser( providerId: IntegrationId, usernameOrId: string, - options?: { accessToken?: string } & GetPullRequestsForUserOptions, + options?: { accessToken?: string; isPAT?: boolean } & GetPullRequestsForUserOptions, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -650,6 +687,8 @@ export class ProvidersApi { provider.getPullRequestsForUserFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } @@ -683,6 +722,8 @@ export class ProvidersApi { pr: PullRequest, options?: { mergeMethod?: PullRequestMergeMethod; + isPAT?: boolean; + baseUrl?: string; }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction(providerId, 'mergePullRequestFn'); @@ -706,7 +747,7 @@ export class ProvidersApi { }, ...options, }, - { token: token }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, ); return true; } catch (e) { @@ -717,7 +758,7 @@ export class ProvidersApi { async getIssuesForRepos( providerId: IntegrationId, reposOrIds: ProviderReposInput, - options?: GetIssuesOptions & { accessToken?: string }, + options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -734,13 +775,15 @@ export class ProvidersApi { provider.getIssuesForReposFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } async getIssuesForRepo( providerId: IntegrationId, repo: ProviderRepoInput, - options?: GetIssuesOptions & { accessToken?: string }, + options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -754,6 +797,8 @@ export class ProvidersApi { provider.getIssuesForRepoFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } @@ -804,7 +849,7 @@ export class ProvidersApi { async getIssuesForResourceForCurrentUser( providerId: IntegrationId, resourceId: string, - options?: { accessToken?: string; cursor?: string }, + options?: { accessToken?: string; cursor?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -818,13 +863,15 @@ export class ProvidersApi { provider.getIssuesForResourceForCurrentUserFn, token, options?.cursor, + options?.isPAT, + options?.baseUrl, ); } async getIssue( providerId: IntegrationId, input: { resourceId: string; number: string } | { namespace: string; name: string; number: string }, - options?: { accessToken?: string }, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -833,7 +880,11 @@ export class ProvidersApi { ); try { - const result = await provider.getIssueFn?.(input, { token: token }); + const result = await provider.getIssueFn?.(input, { + token: token, + isPAT: options?.isPAT, + baseUrl: options?.baseUrl, + }); return result?.data; } catch (e) { diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 29cce192dcbb3..2584aa310d627 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -16,7 +16,7 @@ export function isGitHubDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'github.com'); } -function isGitLabDotCom(domain: string): boolean { +export function isGitLabDotCom(domain: string): boolean { return equalsIgnoreCase(domain, 'gitlab.com'); } @@ -82,7 +82,9 @@ export function getProviderIdFromEntityIdentifier( case EntityIdentifierProviderType.Gitlab: return HostingIntegrationId.GitLab; case EntityIdentifierProviderType.GitlabSelfHosted: - return SelfHostedIntegrationId.GitLabSelfHosted; + return isGitConfigEntityIdentifier(entityIdentifier) && entityIdentifier.metadata.isCloudEnterprise + ? SelfHostedIntegrationId.CloudGitLabSelfHosted + : SelfHostedIntegrationId.GitLabSelfHosted; case EntityIdentifierProviderType.Jira: return IssueIntegrationId.Jira; default: @@ -96,6 +98,8 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier return EntityIdentifierProviderType.Github; case 'cloud-github-enterprise': return EntityIdentifierProviderType.GithubEnterprise; + case 'cloud-gitlab-self-hosted': + return EntityIdentifierProviderType.GitlabSelfHosted; case 'gitlab': return EntityIdentifierProviderType.Gitlab; case 'jira': @@ -129,7 +133,9 @@ export function encodeIssueOrPullRequestForGitConfig( id: entity.id, owner: encodedOwner, createdDate: new Date().toISOString(), - isCloudEnterprise: entity.provider.id === SelfHostedIntegrationId.CloudGitHubEnterprise, + isCloudEnterprise: + entity.provider.id === SelfHostedIntegrationId.CloudGitHubEnterprise || + entity.provider.id === SelfHostedIntegrationId.CloudGitLabSelfHosted, }, }; } @@ -198,7 +204,8 @@ export async function getIssueFromGitConfigEntityIdentifier( identifier.provider !== EntityIdentifierProviderType.Jira && identifier.provider !== EntityIdentifierProviderType.Github && identifier.provider !== EntityIdentifierProviderType.Gitlab && - identifier.provider !== EntityIdentifierProviderType.GithubEnterprise + identifier.provider !== EntityIdentifierProviderType.GithubEnterprise && + identifier.provider !== EntityIdentifierProviderType.GitlabSelfHosted ) { return undefined; } diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts index 82f528df09ee6..62d8d2e8ad1db 100644 --- a/src/plus/launchpad/enrichmentService.ts +++ b/src/plus/launchpad/enrichmentService.ts @@ -218,6 +218,7 @@ const supportedRemoteProvidersToEnrich: Record