diff --git a/CHANGELOG.md b/CHANGELOG.md index 75373ef7eabc8..b5fb3776d63e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Fixes incorrect settings.json entry for Google Gemini 2.0 Flash Thinking causes linter warning ([#4168](https://github.com/gitkraken/vscode-gitlens/issues/4168)) - Fixes multiple autolinks in commit message are broken when enriched ([#4069](https://github.com/gitkraken/vscode-gitlens/issues/4069)) - Fixes `gitlens.hovers.autolinks.enhanced` setting is not respected ([#4174](https://github.com/gitkraken/vscode-gitlens/issues/4174)) +- Fixes _Create Pull Request_ feature ([#4142](https://github.com/gitkraken/vscode-gitlens/issues/4142)) ## [16.3.3] - 2025-03-13 diff --git a/package.json b/package.json index e62d903c9173d..860bf49555e25 100644 --- a/package.json +++ b/package.json @@ -3913,6 +3913,14 @@ "type": "string", "markdownDescription": "Specifies the format of a commit URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${id}` — commit SHA" }, + "comparison": { + "type": "string", + "markdownDescription": "Specifies the format of a comparison URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${ref1}` — ref 1\\\n`${ref2}` — ref 2\\\n`${notation}` — notation" + }, + "createPullRequest": { + "type": "string", + "markdownDescription": "Specifies the format of a create pull request URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${base}` — base branch\\\n`${compare}` — compare branch" + }, "file": { "type": "string", "markdownDescription": "Specifies the format of a file URL for the custom remote service\n\nAvailable tokens\\\n`${repo}` — repository path\\\n`${file}` — file name\\\n`${line}` — formatted line information" diff --git a/src/commands/createPullRequestOnRemote.ts b/src/commands/createPullRequestOnRemote.ts index 851a697b0770d..899f26244d74a 100644 --- a/src/commands/createPullRequestOnRemote.ts +++ b/src/commands/createPullRequestOnRemote.ts @@ -11,7 +11,7 @@ import { GlCommandBase } from './commandBase'; import type { OpenOnRemoteCommandArgs } from './openOnRemote'; export interface CreatePullRequestOnRemoteCommandArgs { - base?: string; + base: string | undefined; compare: string; remote: string; repoPath: string; diff --git a/src/config.ts b/src/config.ts index ce6fca7f8ae17..4d0d69aa3dbd0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -641,6 +641,7 @@ export interface RemotesUrlsConfig { readonly branch: string; readonly commit: string; readonly comparison?: string; + readonly createPullRequest?: string; readonly file: string; readonly fileInBranch: string; readonly fileInCommit: string; diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 1b0fb8275bdac..cbf2dde3ef14a 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -516,7 +516,7 @@ export class LocalGitProvider implements GitProvider, Disposable { case 'gitea': case 'gerrit': case 'google-source': - url = remote.provider.url({ type: RemoteResourceType.Repo }); + url = await remote.provider.url({ type: RemoteResourceType.Repo }); if (url == null) return ['private', remote]; break; diff --git a/src/errors.ts b/src/errors.ts index e0b148b9712bd..f9a71fbc5c651 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -269,3 +269,10 @@ export class RequestsAreBlockedTemporarilyError extends Error { Error.captureStackTrace?.(this, RequestsAreBlockedTemporarilyError); } } + +export class RequiresIntegrationError extends Error { + constructor(message: string) { + super(message); + Error.captureStackTrace?.(this, RequiresIntegrationError); + } +} diff --git a/src/git/models/remoteResource.ts b/src/git/models/remoteResource.ts index 560926dffdc74..70e7daf346fe6 100644 --- a/src/git/models/remoteResource.ts +++ b/src/git/models/remoteResource.ts @@ -34,7 +34,7 @@ export type RemoteResource = | { type: RemoteResourceType.CreatePullRequest; base: { - branch?: string; + branch: string | undefined; remote: { path: string; url: string }; }; compare: { diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index 97669a4430aac..3947aa9e55709 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -1,5 +1,9 @@ import type { Range, Uri } from 'vscode'; import type { AutolinkReference, DynamicAutolinkReference } from '../../autolinks/models/autolinks'; +import type { Container } from '../../container'; +import { HostingIntegration } from '../../plus/integrations/integration'; +import { remoteProviderIdToIntegrationId } from '../../plus/integrations/integrationService'; +import { parseAzureHttpsUrl } from '../../plus/integrations/providers/azure/models'; import type { Brand, Unbrand } from '../../system/brand'; import type { Repository } from '../models/repository'; import type { GkProviderId } from '../models/repositoryIdentities'; @@ -17,7 +21,14 @@ const rangeRegex = /line=(\d+)(?:&lineEnd=(\d+))?/; export class AzureDevOpsRemote extends RemoteProvider { private readonly project: string | undefined; - constructor(domain: string, path: string, protocol?: string, name?: string, legacy: boolean = false) { + constructor( + private readonly container: Container, + domain: string, + path: string, + protocol?: string, + name?: string, + legacy: boolean = false, + ) { let repoProject; if (sshDomainRegex.test(domain)) { path = path.replace(sshPathRegex, ''); @@ -182,8 +193,44 @@ export class AzureDevOpsRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/commit/${sha}`); } - protected override getUrlForComparison(base: string, compare: string, _notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/branchCompare?baseVersion=GB${base}&targetVersion=GB${compare}`); + protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string { + return this.encodeUrl(`${this.baseUrl}/branchCompare?baseVersion=GB${base}&targetVersion=GB${head}`); + } + + override async isReadyForForCrossForkPullRequestUrls(): Promise { + const integrationId = remoteProviderIdToIntegrationId(this.id); + const integration = integrationId && (await this.container.integrations.get(integrationId)); + return integration?.maybeConnected ?? integration?.isConnected() ?? false; + } + + protected override async getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + ): Promise { + const query = new URLSearchParams({ sourceRef: head.branch, targetRef: base.branch ?? '' }); + if (base.remote.url !== head.remote.url) { + const parsedBaseUrl = parseAzureUrl(base.remote.url); + if (parsedBaseUrl == null) { + return undefined; + } + const { org: baseOrg, project: baseProject, repo: baseName } = parsedBaseUrl; + const targetDesc = { project: baseProject, name: baseName, owner: baseOrg }; + + const integrationId = remoteProviderIdToIntegrationId(this.id); + const integration = integrationId && (await this.container.integrations.get(integrationId)); + let targetRepoId = undefined; + if (integration?.isConnected && integration instanceof HostingIntegration) { + targetRepoId = (await integration.getRepoInfo?.(targetDesc))?.id; + } + + if (!targetRepoId) { + return undefined; + } + query.set('targetRepositoryId', targetRepoId); + // query.set('sourceRepositoryId', compare.repoId); // ?? looks like not needed + } + + return `${this.encodeUrl(`${this.getRepoBaseUrl(head.remote.path)}/pullrequestcreate`)}?${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { @@ -207,3 +254,22 @@ export class AzureDevOpsRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}?path=/${fileName}${line}`); } } + +const azureSshUrlRegex = /^(?:[^@]+@)?([^:]+):v\d\//; +function parseAzureUrl(url: string): { org: string; project: string; repo: string } | undefined { + if (azureSshUrlRegex.test(url)) { + // Examples of SSH urls: + // - old one: bbbchiv@vs-ssh.visualstudio.com:v3/bbbchiv/MyFirstProject/test + // - modern one: git@ssh.dev.azure.com:v3/bbbchiv2/MyFirstProject/test + url = url.replace(azureSshUrlRegex, ''); + const match = orgAndProjectRegex.exec(url); + if (match != null) { + const [, org, project, rest] = match; + return { org: org, project: project, repo: rest }; + } + } else { + const [org, project, rest] = parseAzureHttpsUrl(url); + return { org: org, project: project, repo: rest }; + } + return undefined; +} diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index 5e2f1ec4b5b7e..15eb43fe84d76 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -157,8 +157,26 @@ export class BitbucketServerRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/commits/${sha}`); } - protected override getUrlForComparison(base: string, compare: string, _notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/branches/compare/${base}%0D${compare}`).replace('%250D', '%0D'); + protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string { + return this.encodeUrl(`${this.baseUrl}/branches/compare/${base}%0D${head}`).replaceAll('%250D', '%0D'); + } + + protected override getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + options?: { title?: string; description?: string }, + ): string | undefined { + const query = new URLSearchParams({ sourceBranch: head.branch, targetBranch: base.branch ?? '' }); + // TODO: figure this out + // query.set('targetRepoId', base.repoId); + if (options?.title) { + query.set('title', options.title); + } + if (options?.description) { + query.set('description', options.description); + } + + return `${this.encodeUrl(`${this.baseUrl}/pull-requests?create`)}&${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/bitbucket.ts b/src/git/remotes/bitbucket.ts index e1269cb763c7a..df1ac01ee9c79 100644 --- a/src/git/remotes/bitbucket.ts +++ b/src/git/remotes/bitbucket.ts @@ -1,5 +1,6 @@ import type { Range, Uri } from 'vscode'; import type { AutolinkReference, DynamicAutolinkReference } from '../../autolinks/models/autolinks'; +import type { RepositoryDescriptor } from '../../plus/integrations/integration'; import type { Brand, Unbrand } from '../../system/brand'; import type { Repository } from '../models/repository'; import type { GkProviderId } from '../models/repositoryIdentities'; @@ -10,7 +11,7 @@ import { RemoteProvider } from './remoteProvider'; const fileRegex = /^\/([^/]+)\/([^/]+?)\/src(.+)$/i; const rangeRegex = /^lines-(\d+)(?::(\d+))?$/; -export class BitbucketRemote extends RemoteProvider { +export class BitbucketRemote extends RemoteProvider { constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { super(domain, path, protocol, name, custom); } @@ -142,8 +143,18 @@ export class BitbucketRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/commits/${sha}`); } - protected override getUrlForComparison(base: string, compare: string, _notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/branches/compare/${base}%0D${compare}`).replace('%250D', '%0D'); + protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string { + return `${this.encodeUrl(`${this.baseUrl}/branches/compare/${head}\r${base}`)}#diff`; + } + + protected override getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + _options?: { title?: string; description?: string }, + ): string | undefined { + const { owner, name } = this.repoDesc; + const query = new URLSearchParams({ source: head.branch, dest: `${owner}/${name}::${base.branch ?? ''}` }); + return `${this.encodeUrl(`${this.getRepoBaseUrl(head.remote.path)}/pull-requests/new`)}?${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/custom.ts b/src/git/remotes/custom.ts index e20cff02e7cdf..7f968b07b783e 100644 --- a/src/git/remotes/custom.ts +++ b/src/git/remotes/custom.ts @@ -58,10 +58,23 @@ export class CustomRemote extends RemoteProvider { return this.getUrl(this.urls.commit, this.getContext({ id: sha })); } - protected override getUrlForComparison(base: string, compare: string, notation: '..' | '...'): string | undefined { + protected override getUrlForComparison(base: string, head: string, notation: '..' | '...'): string | undefined { if (this.urls.comparison == null) return undefined; - return this.getUrl(this.urls.comparison, this.getContext({ ref1: base, ref2: compare, notation: notation })); + return this.getUrl(this.urls.comparison, this.getContext({ ref1: base, ref2: head, notation: notation })); + } + + protected override getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + compare: { branch: string; remote: { path: string; url: string } }, + _options?: { title?: string; description?: string }, + ): string | undefined { + if (this.urls.createPullRequest == null) return undefined; + + return this.getUrl( + this.urls.createPullRequest, + this.getContext({ base: base.branch ?? '', head: compare.branch }), + ); } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/gerrit.ts b/src/git/remotes/gerrit.ts index 0a059e10c7874..b71a166ffcacf 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -189,6 +189,20 @@ export class GerritRemote extends RemoteProvider { return this.encodeUrl(`${this.baseReviewUrl}/q/${sha}`); } + protected override getUrlForComparison(base: string, head: string, notation: '..' | '...'): string | undefined { + return this.encodeUrl(`${this.baseReviewUrl}/q/${base}${notation}${head}`); + } + + protected override getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + _options?: { title?: string; description?: string }, + ): string | undefined { + const query = new URLSearchParams({ sourceBranch: head.branch, targetBranch: base.branch ?? '' }); + + return this.encodeUrl(`${this.baseReviewUrl}/createPullRequest?${query.toString()}`); + } + protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { const line = range != null ? `#${range.start.line}` : ''; diff --git a/src/git/remotes/gitea.ts b/src/git/remotes/gitea.ts index a0718765b240c..b10ce16de9966 100644 --- a/src/git/remotes/gitea.ts +++ b/src/git/remotes/gitea.ts @@ -139,8 +139,23 @@ export class GiteaRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/commit/${sha}`); } - protected override getUrlForComparison(ref1: string, ref2: string, _notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/compare/${ref1}...${ref2}`); + protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string { + return this.encodeUrl(`${this.baseUrl}/compare/${base}...${head}`); + } + + protected override getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + options?: { title?: string; description?: string }, + ): string | undefined { + const query = new URLSearchParams({ head: head.branch, base: base.branch ?? '' }); + if (options?.title) { + query.set('title', options.title); + } + if (options?.description) { + query.set('body', options.description); + } + return `${this.encodeUrl(`${this.baseUrl}/pulls/new`)}?${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/github.ts b/src/git/remotes/github.ts index 644f5afac77c3..02def406fdb0f 100644 --- a/src/git/remotes/github.ts +++ b/src/git/remotes/github.ts @@ -276,20 +276,33 @@ export class GitHubRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/commit/${sha}`); } - protected override getUrlForComparison(base: string, compare: string, notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/compare/${base}${notation}${compare}`); + protected override getUrlForComparison(base: string, head: string, notation: '..' | '...'): string { + return this.encodeUrl(`${this.baseUrl}/compare/${base}${notation}${head}`); } protected override getUrlForCreatePullRequest( base: { branch?: string; remote: { path: string; url: string } }, - compare: { branch: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + options?: { title?: string; description?: string }, ): string | undefined { - if (base.remote.url === compare.remote.url) { - return this.encodeUrl(`${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${compare.branch}`); + const query = new URLSearchParams(); + if (options?.title) { + query.set('title', options.title); + } + if (options?.description) { + query.set('body', options.description); + } + + if (base.remote.url === head.remote.url) { + return `${this.encodeUrl( + `${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${head.branch}`, + )}?${query.toString()}`; } - const [owner] = compare.remote.path.split('/', 1); - return this.encodeUrl(`${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${owner}:${compare.branch}`); + const [owner] = head.remote.path.split('/', 1); + return `${this.encodeUrl( + `${this.baseUrl}/pull/new/${base.branch ?? 'HEAD'}...${owner}:${head.branch}`, + )}?${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/gitlab.ts b/src/git/remotes/gitlab.ts index a10b0822fe066..e7f4d7eb72b51 100644 --- a/src/git/remotes/gitlab.ts +++ b/src/git/remotes/gitlab.ts @@ -6,6 +6,9 @@ import type { MaybeEnrichedAutolink, } from '../../autolinks/models/autolinks'; import { GlyphChars } from '../../constants'; +import type { Container } from '../../container'; +import { HostingIntegration } from '../../plus/integrations/integration'; +import { remoteProviderIdToIntegrationId } from '../../plus/integrations/integrationService'; import type { GitLabRepositoryDescriptor } from '../../plus/integrations/providers/gitlab'; import type { Brand, Unbrand } from '../../system/brand'; import { fromNow } from '../../system/date'; @@ -30,7 +33,14 @@ function isGitLabDotCom(domain: string): boolean { } export class GitLabRemote extends RemoteProvider { - constructor(domain: string, path: string, protocol?: string, name?: string, custom: boolean = false) { + constructor( + private readonly container: Container, + domain: string, + path: string, + protocol?: string, + name?: string, + custom: boolean = false, + ) { super(domain, path, protocol, name, custom); } @@ -372,8 +382,51 @@ export class GitLabRemote extends RemoteProvider { return this.encodeUrl(`${this.baseUrl}/-/commit/${sha}`); } - protected override getUrlForComparison(base: string, compare: string, notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/-/compare/${base}${notation}${compare}`); + protected override getUrlForComparison(base: string, head: string, notation: '..' | '...'): string { + return this.encodeUrl(`${this.baseUrl}/-/compare/${base}${notation}${head}`); + } + + override async isReadyForForCrossForkPullRequestUrls(): Promise { + const integrationId = remoteProviderIdToIntegrationId(this.id); + const integration = integrationId && (await this.container.integrations.get(integrationId)); + return integration?.maybeConnected ?? integration?.isConnected() ?? false; + } + + protected override async getUrlForCreatePullRequest( + base: { branch?: string; remote: { path: string; url: string } }, + head: { branch: string; remote: { path: string; url: string } }, + options?: { title?: string; description?: string }, + ): Promise { + const query = new URLSearchParams({ + utf8: '✓', + 'merge_request[source_branch]': head.branch, + 'merge_request[target_branch]': base.branch ?? '', + }); + if (base.remote.url !== head.remote.url) { + const targetDesc = { + owner: base.remote.path.split('/')[0], + name: base.remote.path.split('/')[1], + }; + const integrationId = remoteProviderIdToIntegrationId(this.id); + const integration = integrationId && (await this.container.integrations.get(integrationId)); + let targetRepoId = undefined; + if (integration?.isConnected && integration instanceof HostingIntegration) { + targetRepoId = (await integration.getRepoInfo?.(targetDesc))?.id; + } + if (!targetRepoId) { + return undefined; + } + query.set('merge_request[target_project_id]', targetRepoId); + // 'merge_request["source_project_id"]': this.path, // ?? seems we don't need it + } + if (options?.title) { + query.set('merge_request[title]', options.title); + } + if (options?.description) { + query.set('merge_request[description]', options.description); + } + + return `${this.encodeUrl(`${this.getRepoBaseUrl(head.remote.path)}/-/merge_requests/new`)}?${query.toString()}`; } protected getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index bd11fcade0f76..21b6cf31c0a26 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -100,7 +100,7 @@ export abstract class RemoteProvider { - const urls = this.getUrlsFromResources(resource); + const urls = await this.getUrlsFromResources(resource); if (!urls.length) return; await env.clipboard.writeText(urls.join('\n')); @@ -113,14 +113,14 @@ export abstract class RemoteProvider; async open(resource: RemoteResource | RemoteResource[]): Promise { - const urls = this.getUrlsFromResources(resource); + const urls = await this.getUrlsFromResources(resource); if (!urls.length) return false; const results = await Promise.allSettled(urls.map(openUrl)); return results.every(r => getSettledValue(r) === true); } - url(resource: RemoteResource): string | undefined { + url(resource: RemoteResource): Promise | string | undefined { switch (resource.type) { case RemoteResourceType.Branch: return this.getUrlForBranch(resource.branch); @@ -129,10 +129,10 @@ export abstract class RemoteProvider { + return Promise.resolve(true); + } - protected getUrlForCreatePullRequest?( + protected abstract getUrlForCreatePullRequest( base: { branch?: string; remote: { path: string; url: string } }, - compare: { branch: string; remote: { path: string; url: string } }, - ): string | undefined; + head: { branch: string; remote: { path: string; url: string } }, + options?: { title?: string; description?: string }, + ): Promise | string | undefined; protected abstract getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string; @@ -199,22 +208,19 @@ export abstract class RemoteProvider { + const urlPromises: (Promise | string | undefined)[] = []; if (Array.isArray(resource)) { for (const r of resource) { - const url = this.url(r); - if (url == null) continue; - - urls.push(url); + urlPromises.push(this.url(r)); } } else { - const url = this.url(resource); - if (url != null) { - urls.push(url); - } + urlPromises.push(this.url(resource)); } + const urls: string[] = (await Promise.allSettled(urlPromises)) + .map(r => getSettledValue(r)) + .filter(r => r != null); return urls; } } diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index c1418e244f26c..070def00e8acb 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -37,12 +37,12 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: 'gitlab.com', - creator: (_container: Container, domain: string, path: string) => new GitLabRemote(domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), }, { custom: false, matcher: /\bdev\.azure\.com$/i, - creator: (_container: Container, domain: string, path: string) => new AzureDevOpsRemote(domain, path), + creator: (container: Container, domain: string, path: string) => new AzureDevOpsRemote(container, domain, path), }, { custom: true, @@ -52,13 +52,13 @@ const builtInProviders: RemoteProviders = [ { custom: false, matcher: /\bgitlab\b/i, - creator: (_container: Container, domain: string, path: string) => new GitLabRemote(domain, path), + creator: (container: Container, domain: string, path: string) => new GitLabRemote(container, domain, path), }, { custom: false, matcher: /\bvisualstudio\.com$/i, - creator: (_container: Container, domain: string, path: string) => - new AzureDevOpsRemote(domain, path, undefined, undefined, true), + creator: (container: Container, domain: string, path: string) => + new AzureDevOpsRemote(container, domain, path, undefined, undefined, true), }, { custom: false, @@ -77,13 +77,16 @@ const builtInProviders: RemoteProviders = [ }, ]; -const cloudRemotesMap: Record< +const cloudProviderCreatorsMap: Record< CloudSelfHostedIntegrationId, - typeof GitHubRemote | typeof GitLabRemote | typeof BitbucketServerRemote + (container: Container, domain: string, path: string) => RemoteProvider > = { - [SelfHostedIntegrationId.CloudGitHubEnterprise]: GitHubRemote, - [SelfHostedIntegrationId.CloudGitLabSelfHosted]: GitLabRemote, - [SelfHostedIntegrationId.BitbucketServer]: BitbucketServerRemote, + [SelfHostedIntegrationId.CloudGitHubEnterprise]: (_container: Container, domain: string, path: string) => + new GitHubRemote(domain, path), + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: (container: Container, domain: string, path: string) => + new GitLabRemote(container, domain, path), + [SelfHostedIntegrationId.BitbucketServer]: (_container: Container, domain: string, path: string) => + new BitbucketServerRemote(domain, path), }; export function loadRemoteProviders( @@ -118,12 +121,10 @@ export function loadRemoteProviders( const integrationId = ci.integrationId; if (isCloudSelfHostedIntegrationId(integrationId) && ci.domain) { const matcher = ci.domain.toLocaleLowerCase(); - const providerCreator = (_container: Container, domain: string, path: string): RemoteProvider => - new cloudRemotesMap[integrationId](domain, path); const provider = { custom: false, matcher: matcher, - creator: providerCreator, + creator: cloudProviderCreatorsMap[integrationId], }; const indexOfCustomDuplication: number = providers.findIndex(p => p.matcher === matcher); @@ -145,8 +146,8 @@ export function loadRemoteProviders( function getCustomProviderCreator(cfg: RemotesConfig) { switch (cfg.type) { case 'AzureDevOps': - return (_container: Container, domain: string, path: string) => - new AzureDevOpsRemote(domain, path, cfg.protocol, cfg.name, true); + return (container: Container, domain: string, path: string) => + new AzureDevOpsRemote(container, domain, path, cfg.protocol, cfg.name, true); case 'Bitbucket': return (_container: Container, domain: string, path: string) => new BitbucketRemote(domain, path, cfg.protocol, cfg.name, true); @@ -169,8 +170,8 @@ function getCustomProviderCreator(cfg: RemotesConfig) { return (_container: Container, domain: string, path: string) => new GitHubRemote(domain, path, cfg.protocol, cfg.name, true); case 'GitLab': - return (_container: Container, domain: string, path: string) => - new GitLabRemote(domain, path, cfg.protocol, cfg.name, true); + return (container: Container, domain: string, path: string) => + new GitLabRemote(container, domain, path, cfg.protocol, cfg.name, true); default: return undefined; } diff --git a/src/plus/integrations/integration.ts b/src/plus/integrations/integration.ts index 158028732e565..5da2dd48b466a 100644 --- a/src/plus/integrations/integration.ts +++ b/src/plus/integrations/integration.ts @@ -44,6 +44,7 @@ import type { ProviderPullRequest, ProviderRepoInput, ProviderReposInput, + ProviderRepository, } from './providers/models'; import { IssueFilter, PagingMode, PullRequestFilter } from './providers/models'; import type { ProvidersApi } from './providers/providersApi'; @@ -785,6 +786,8 @@ export abstract class HostingIntegration< return defaultBranch; } + getRepoInfo?(repo: { owner: string; name: string; project?: string }): Promise; + protected abstract getProviderDefaultBranch( { accessToken }: ProviderAuthenticationSession, repo: T, diff --git a/src/plus/integrations/providers/azure/models.ts b/src/plus/integrations/providers/azure/models.ts index 8ee7bc20ecd51..dea135e4d91b8 100644 --- a/src/plus/integrations/providers/azure/models.ts +++ b/src/plus/integrations/providers/azure/models.ts @@ -337,6 +337,39 @@ export function getAzureRepo(pr: AzurePullRequest): string { return `${pr.repository.project.name}/_git/${pr.repository.name}`; } +// Example: https://bbbchiv.visualstudio.com/MyFirstProject/_git/test +const azureProjectRepoRegex = /([^/]+)\/_git\/([^/]+)/; +function parseVstsHttpsUrl(url: URL): [owner: string, project: string, repo: string] { + const owner = getVSTSOwner(url); + const match = azureProjectRepoRegex.exec(url.pathname); + if (match == null) { + throw new Error(`Invalid VSTS URL: ${url.toString()}`); + } + const [, project, repo] = match; + return [owner, project, repo]; +} + +// Example https://bbbchiv2@dev.azure.com/bbbchiv2/MyFirstProject/_git/test +const azureHttpsUrlRegex = /([^/]+)\/([^/]+)\/_git\/([^/]+)/; +function parseAzureNewStyleUrl(url: URL): [owner: string, project: string, repo: string] { + const match = azureHttpsUrlRegex.exec(url.pathname); + if (match == null) { + throw new Error(`Invalid Azure URL: ${url.toString()}`); + } + const [, owner, project, repo] = match; + return [owner, project, repo]; +} + +export function parseAzureHttpsUrl(url: string): [owner: string, project: string, repo: string]; +export function parseAzureHttpsUrl(urlObj: URL): [owner: string, project: string, repo: string]; +export function parseAzureHttpsUrl(arg: URL | string): [owner: string, project: string, repo: string] { + const url = typeof arg === 'string' ? new URL(arg) : arg; + if (vstsHostnameRegex.test(url.hostname)) { + return parseVstsHttpsUrl(url); + } + return parseAzureNewStyleUrl(url); +} + export function getAzurePullRequestWebUrl(pr: AzurePullRequest): string { const url = new URL(pr.url); const baseUrl = new URL(url.origin).toString(); diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index 3eb4fcc420c4e..59e1faab57eb7 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -17,7 +17,7 @@ import type { AzureRemoteRepositoryDescriptor, AzureRepositoryDescriptor, } from './azure/models'; -import type { ProviderPullRequest } from './models'; +import type { ProviderPullRequest, ProviderRepository } from './models'; import { fromProviderIssue, fromProviderPullRequest, providersMetadata } from './models'; const metadata = providersMetadata[HostingIntegrationId.AzureDevOps]; @@ -300,6 +300,17 @@ export class AzureDevOpsIntegration extends HostingIntegration< return Promise.resolve(undefined); } + public override async getRepoInfo(repo: { + owner: string; + name: string; + project: string; + }): Promise { + const api = await this.getProvidersApi(); + return api.getRepo(this.id, repo.owner, repo.name, repo.project, { + accessToken: this._session?.accessToken, + }); + } + protected override async getProviderRepositoryMetadata( _session: AuthenticationSession, _repo: AzureRepositoryDescriptor, diff --git a/src/plus/integrations/providers/gitlab.ts b/src/plus/integrations/providers/gitlab.ts index a682ed6df0366..304bd57267f94 100644 --- a/src/plus/integrations/providers/gitlab.ts +++ b/src/plus/integrations/providers/gitlab.ts @@ -19,6 +19,7 @@ import type { RepositoryDescriptor } from '../integration'; import { HostingIntegration } from '../integration'; import { getGitLabPullRequestIdentityFromMaybeUrl } from './gitlab/gitlab.utils'; import { fromGitLabMergeRequestProvidersApi } from './gitlab/models'; +import type { ProviderRepository } from './models'; import { ProviderPullRequestReviewState, providersMetadata, toIssueShape } from './models'; import type { ProvidersApi } from './providersApi'; @@ -190,6 +191,13 @@ abstract class GitLabIntegrationBase< ); } + public override async getRepoInfo(repo: { owner: string; name: string }): Promise { + const api = await this.getProvidersApi(); + return api.getRepo(this.id, repo.owner, repo.name, undefined, { + accessToken: this._session?.accessToken, + }); + } + protected override async getProviderRepositoryMetadata( { accessToken }: AuthenticationSession, repo: GitLabRepositoryDescriptor, diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index b82c9816d5dc6..66c376ace5e6f 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -234,6 +234,15 @@ export interface PageInfo { nextPage?: number | null; } +export type GetRepoFn = ( + input: ProviderRepoInput, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderRepository }>; +export type GetRepoOfProjectFn = ( + input: ProviderRepoInput & { project: string }, + options?: EnterpriseOptions, +) => Promise<{ data: ProviderRepository }>; + export type GetPullRequestsForReposFn = ( input: (GetPullRequestsForReposInput | GetPullRequestsForRepoIdsInput) & PagingInput, options?: EnterpriseOptions, @@ -382,6 +391,8 @@ export type GetIssuesForResourceForCurrentUserFn = ( export interface ProviderInfo extends ProviderMetadata { provider: GitHub | GitLab | Bitbucket | BitbucketServer | Jira | Trello | AzureDevOps; + getRepoFn?: GetRepoFn; + getRepoOfProjectFn?: GetRepoOfProjectFn; getPullRequestsForReposFn?: GetPullRequestsForReposFn; getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; getPullRequestsForUserFn?: GetPullRequestsForUserFn; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index c3446d09bb749..82e353c073fda 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -133,6 +133,7 @@ export class ProvidersApi { [HostingIntegrationId.GitLab]: { ...providersMetadata[HostingIntegrationId.GitLab], provider: providerApis.gitlab, + getRepoFn: providerApis.gitlab.getRepo.bind(providerApis.gitlab), getCurrentUserFn: providerApis.gitlab.getCurrentUser.bind(providerApis.gitlab) as GetCurrentUserFn, getPullRequestsForReposFn: providerApis.gitlab.getPullRequestsForRepos.bind( providerApis.gitlab, @@ -238,6 +239,7 @@ export class ProvidersApi { [HostingIntegrationId.AzureDevOps]: { ...providersMetadata[HostingIntegrationId.AzureDevOps], provider: providerApis.azureDevOps, + getRepoOfProjectFn: providerApis.azureDevOps.getRepo.bind(providerApis.azureDevOps), getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind( providerApis.azureDevOps, ) as GetCurrentUserFn, @@ -465,6 +467,48 @@ export class ProvidersApi { } } + async getRepo( + providerId: IntegrationId, + owner: string, + name: string, + project?: string, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, + ): Promise { + if (providerId === HostingIntegrationId.AzureDevOps && project != null) { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getRepoOfProjectFn', + options?.accessToken, + ); + + try { + const result = await provider['getRepoOfProjectFn']?.( + { namespace: owner, name: name, project: project }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, + ); + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } else { + const { provider, token } = await this.ensureProviderTokenAndFunction( + providerId, + 'getRepoFn', + options?.accessToken, + ); + + try { + const result = await provider['getRepoFn']?.( + { namespace: owner, name: name, project: project }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, + ); + return result?.data; + } catch (e) { + return this.handleProviderError(providerId, token, e); + } + } + } + async getCurrentUser( providerId: IntegrationId, options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, diff --git a/src/plus/repos/repositoryIdentityService.ts b/src/plus/repos/repositoryIdentityService.ts index 496c84bdd6be6..fb1942569b7e7 100644 --- a/src/plus/repos/repositoryIdentityService.ts +++ b/src/plus/repos/repositoryIdentityService.ts @@ -179,7 +179,7 @@ export class RepositoryIdentityService implements Disposable { const repoPath = repo.uri.fsPath; for (const remote of remotes) { - const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); + const remoteUrl = await remote.provider?.url({ type: RemoteResourceType.Repo }); if (remoteUrl != null) { await this.locator.storeLocation(repoPath, remoteUrl); } diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts index 8ff430f89764b..df024ae5ddc1a 100644 --- a/src/plus/workspaces/workspacesService.ts +++ b/src/plus/workspaces/workspacesService.ts @@ -10,6 +10,7 @@ import type { OpenWorkspaceLocation } from '../../system/-webview/vscode'; import { openWorkspace } from '../../system/-webview/vscode'; import { log } from '../../system/decorators/log'; import { normalizePath } from '../../system/path'; +import { getSettledValue } from '../../system/promise'; import type { SubscriptionChangeEvent } from '../gk/subscriptionService'; import { isSubscriptionStatePaidOrTrial } from '../gk/utils/subscription.utils'; import type { CloudWorkspaceData, CloudWorkspaceRepositoryDescriptor } from './models/cloudWorkspace'; @@ -473,13 +474,12 @@ export class WorkspacesService implements Disposable { const repoPath = repo.uri.fsPath; const remotes = await repo.git.remotes().getRemotes(); - const remoteUrls: string[] = []; - for (const remote of remotes) { - const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); - if (remoteUrl != null) { - remoteUrls.push(remoteUrl); - } - } + const remoteUrlPromises: Promise[] = remotes.map(async remote => { + return remote.provider?.url({ type: RemoteResourceType.Repo }); + }); + const remoteUrls: string[] = (await Promise.allSettled(remoteUrlPromises)) + .map(r => getSettledValue(r)) + .filter(r => r != null); for (const remoteUrl of remoteUrls) { await this._repositoryLocator?.storeLocation(repoPath, remoteUrl); @@ -906,7 +906,7 @@ export class WorkspacesService implements Disposable { if (repo == null) continue; const remote = (await repo.git.remotes().getRemote('origin')) || (await repo.git.remotes().getRemotes())?.[0]; - const remoteDescriptor = getRemoteDescriptor(remote); + const remoteDescriptor = await getRemoteDescriptor(remote); if (remoteDescriptor == null) continue; repoInputs.push({ owner: remoteDescriptor.owner, @@ -1042,7 +1042,7 @@ export class WorkspacesService implements Disposable { if (workspace instanceof CloudWorkspace) { const remotes = await repo.git.remotes().getRemotes(); for (const remote of remotes) { - const remoteDescriptor = getRemoteDescriptor(remote); + const remoteDescriptor = await getRemoteDescriptor(remote); if (remoteDescriptor == null) continue; reposProviderMap.set( `${remoteDescriptor.provider}/${remoteDescriptor.owner}/${remoteDescriptor.repoName}`, @@ -1324,7 +1324,7 @@ export class WorkspacesService implements Disposable { } } -function getRemoteDescriptor(remote: GitRemote): RemoteDescriptor | undefined { +async function getRemoteDescriptor(remote: GitRemote): Promise { if (remote.provider?.owner == null) return undefined; const remoteRepoName = remote.provider.path.split('/').pop(); if (remoteRepoName == null) return undefined; @@ -1332,7 +1332,7 @@ function getRemoteDescriptor(remote: GitRemote): RemoteDescriptor | undefined { provider: remote.provider.id.toLowerCase(), owner: remote.provider.owner.toLowerCase(), repoName: remoteRepoName.toLowerCase(), - url: remote.provider.url({ type: RemoteResourceType.Repo }), + url: await remote.provider.url({ type: RemoteResourceType.Repo }), }; } diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index 2f1a5c922941a..0958081d274f5 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -1,10 +1,13 @@ -import type { Disposable, QuickInputButton } from 'vscode'; +import type { Disposable, QuickInputButton, QuickPickItem } from 'vscode'; import { env, ThemeIcon, Uri, window } from 'vscode'; import type { OpenOnRemoteCommandArgs } from '../commands/openOnRemote'; import { SetRemoteAsDefaultQuickInputButton } from '../commands/quickCommand.buttons'; import type { Keys } from '../constants'; import { GlyphChars } from '../constants'; +import type { IntegrationId } from '../constants.integrations'; +import type { Sources } from '../constants.telemetry'; import { Container } from '../container'; +import { RequiresIntegrationError } from '../errors'; import type { GitRemote } from '../git/models/remote'; import type { RemoteResource } from '../git/models/remoteResource'; import { RemoteResourceType } from '../git/models/remoteResource'; @@ -13,10 +16,12 @@ import { getDefaultBranchName } from '../git/utils/-webview/branch.utils'; import { getBranchNameWithoutRemote, getRemoteNameFromBranchName } from '../git/utils/branch.utils'; import { getHighlanderProviders } from '../git/utils/remote.utils'; import { getNameFromRemoteResource } from '../git/utils/remoteResource.utils'; +import { remoteProviderIdToIntegrationId } from '../plus/integrations/integrationService'; +import { providersMetadata } from '../plus/integrations/providers/models'; import { getQuickPickIgnoreFocusOut } from '../system/-webview/vscode'; -import { filterMap } from '../system/array'; import { getSettledValue } from '../system/promise'; -import { CommandQuickPickItem } from './items/common'; +import { CommandQuickPickItem, createQuickPickItemOfT } from './items/common'; +import { createDirectiveQuickPickItem, Directive } from './items/directive'; export class ConfigureCustomRemoteProviderCommandQuickPickItem extends CommandQuickPickItem { constructor() { @@ -68,6 +73,18 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { ...resource, base: { branch: branch, remote: { path: this.remote.path, url: this.remote.url } }, }; + + if ( + resource.base.remote.url !== resource.compare.remote.url && + !(await this.remote.provider.isReadyForForCrossForkPullRequestUrls()) + ) { + const integrationId = remoteProviderIdToIntegrationId(this.remote.provider.id); + const connected = + integrationId && (await this.showIntegrationConnectionPicker(integrationId, 'view')); + if (!connected) { + return undefined; + } + } } else if ( resource.type === RemoteResourceType.File && resource.branchOrTag != null && @@ -96,11 +113,68 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { }), ); - const resources = filterMap(resourcesResults, r => getSettledValue(r)); + const resources = resourcesResults + .map(r => { + if (r.status === 'fulfilled') { + return r.value; + } + if (r.reason instanceof RequiresIntegrationError) { + throw r.reason; + } + return undefined; + }) + .filter((r): r is RemoteResource => r !== undefined); void (await (this.clipboard ? this.remote.provider.copy(resources) : this.remote.provider.open(resources))); } + async showIntegrationConnectionPicker(integrationId: IntegrationId, source: Sources): Promise { + const disposables: Disposable[] = []; + const quickpick = window.createQuickPick(); + try { + const integrationName = providersMetadata[integrationId].name; + const connectItem = createQuickPickItemOfT( + { + label: `Connect to ${integrationName}...`, + detail: `Connect an integration with ${integrationName} to create cross-repository pull requests`, + picked: true, + }, + true, + ); + const cancelItem = createDirectiveQuickPickItem(Directive.Cancel, false, { label: 'Cancel' }); + const quickpickPromise = new Promise(resolve => { + disposables.push( + quickpick.onDidHide(() => resolve(undefined)), + quickpick.onDidAccept(() => { + if (quickpick.activeItems.length !== 0) { + resolve(quickpick.activeItems[0]); + } + }), + ); + }); + quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); + quickpick.title = `Connect ${integrationName} Integration`; + quickpick.placeholder = `Requires an integration with ${integrationName} to create cross-repository pull requests`; + quickpick.matchOnDetail = true; + quickpick.items = [connectItem, cancelItem]; + quickpick.show(); + const pick = await quickpickPromise; + if (pick === connectItem) { + const connected = await Container.instance.integrations.connectCloudIntegrations( + { integrationIds: [integrationId] }, + { + source: source, + }, + ); + return connected; + } + } finally { + quickpick.dispose(); + disposables.forEach(d => void d.dispose()); + } + return false; + } + setAsDefault(): Promise { return this.remote.setAsDefault(true); } diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index f21c5025752fc..f09807b4dd7c9 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -863,7 +863,7 @@ export class HomeWebviewProvider implements WebviewProvider