From 59b26701baf48f1971f5297cafedc87a6c7fd2d4 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 18 Mar 2025 21:26:52 +0100 Subject: [PATCH 01/12] Adds support for comparison and create pull request URLs in remote services Adds configuration options for comparison and create pull request URLs to support custom remote services. Updates the relevant interfaces and abstract methods to include these new URL types. (#4142, #4143) --- package.json | 8 ++++++++ src/commands/createPullRequestOnRemote.ts | 2 +- src/config.ts | 1 + src/git/models/remoteResource.ts | 2 +- src/git/remotes/gerrit.ts | 4 ++++ src/git/remotes/remoteProvider.ts | 7 ++++--- 6 files changed, 19 insertions(+), 5 deletions(-) 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/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/gerrit.ts b/src/git/remotes/gerrit.ts index 0a059e10c7874..9d6c3bead6692 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -189,6 +189,10 @@ 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 getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string { const line = range != null ? `#${range.start.line}` : ''; diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index bd11fcade0f76..41e0a9370a542 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -129,7 +129,7 @@ export abstract class RemoteProvider Date: Sun, 16 Mar 2025 19:07:42 +0100 Subject: [PATCH 02/12] Gets provider resource URL asyncronously (#4142, #4143) --- src/env/node/git/localGitProvider.ts | 2 +- src/git/remotes/remoteProvider.ts | 25 +++++++++----------- src/plus/repos/repositoryIdentityService.ts | 2 +- src/plus/workspaces/workspacesService.ts | 22 ++++++++--------- src/webviews/home/homeWebview.ts | 2 +- src/webviews/plus/graph/graphWebview.ts | 2 +- src/webviews/plus/graph/graphWebviewUtils.ts | 2 +- 7 files changed, 27 insertions(+), 30 deletions(-) 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/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 41e0a9370a542..d3c7adefb11f2 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); @@ -186,7 +186,7 @@ export abstract class RemoteProvider | string | undefined; protected abstract getUrlForFile(fileName: string, branch?: string, sha?: string, range?: Range): string; @@ -200,22 +200,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/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/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 Date: Mon, 17 Mar 2025 15:52:33 +0100 Subject: [PATCH 03/12] Generates create AzureDevOps PR URLs and retrieves repoId for cross-forks (#4142, #4143) --- src/git/remotes/azure-devops.ts | 66 ++++++++++++++++++- src/git/remotes/remoteProvider.ts | 6 +- src/git/remotes/remoteProviders.ts | 10 +-- src/plus/integrations/integration.ts | 3 + .../integrations/providers/azure/models.ts | 33 ++++++++++ .../integrations/providers/azureDevOps.ts | 13 +++- src/plus/integrations/providers/models.ts | 11 ++++ .../integrations/providers/providersApi.ts | 43 ++++++++++++ 8 files changed, 175 insertions(+), 10 deletions(-) diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index 97669a4430aac..edeb6854cf0b9 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,38 @@ 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}`); + } + + 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 +248,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/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index d3c7adefb11f2..8588b8ef85a5a 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -159,7 +159,11 @@ export abstract class RemoteProvider new AzureDevOpsRemote(domain, path), + creator: (container: Container, domain: string, path: string) => new AzureDevOpsRemote(container, domain, path), }, { custom: true, @@ -57,8 +57,8 @@ const builtInProviders: RemoteProviders = [ { 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, @@ -145,8 +145,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); 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/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..8083ecd64db0f 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -238,6 +238,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 +466,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 }, From 335e51e63075722758e3a7081ae4654b73cdbd47 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 18 Mar 2025 18:37:04 +0100 Subject: [PATCH 04/12] Lets user connect and integration when it is required for a cross-fork PR (#4142, #4143) --- src/errors.ts | 7 +++ src/git/remotes/azure-devops.ts | 6 ++ src/git/remotes/remoteProvider.ts | 4 ++ src/quickpicks/remoteProviderPicker.ts | 84 ++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 4 deletions(-) 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/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index edeb6854cf0b9..3947aa9e55709 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -197,6 +197,12 @@ export class AzureDevOpsRemote extends RemoteProvider { 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 } }, diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 8588b8ef85a5a..31d087661d4a6 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -186,6 +186,10 @@ export abstract class RemoteProvider { + return Promise.resolve(true); + } + protected getUrlForCreatePullRequest?( base: { branch?: string; remote: { path: string; url: string } }, head: { branch: string; remote: { path: string; url: string } }, diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index 2f1a5c922941a..8c6fdc91eafea 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,20 @@ 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) { + throw new RequiresIntegrationError( + 'Cross-fork pull request URLs are not supported by this provider', + ); + } + } } else if ( resource.type === RemoteResourceType.File && resource.branchOrTag != null && @@ -96,11 +115,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 a ${integrationName} Integration...`, + detail: `Connect a ${integrationName} integration to be able to create cross-fork 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 a ${integrationName} Integration`; + quickpick.placeholder = `Connect a ${integrationName} integration to be able to create cross-fork 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); } From 9ee38b099e8fb2ce0a7b02be57adc9b3fc16185a Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 19 Mar 2025 13:55:04 +0100 Subject: [PATCH 05/12] Generates create GitLab PR URLs and retrieves repoId for cross-forks (#4142, #4143) --- src/git/remotes/gitlab.ts | 59 ++++++++++++++++++- src/git/remotes/remoteProviders.ts | 25 ++++---- src/plus/integrations/providers/gitlab.ts | 8 +++ .../integrations/providers/providersApi.ts | 1 + 4 files changed, 78 insertions(+), 15 deletions(-) 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/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index 7279b803fe00d..070def00e8acb 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -37,7 +37,7 @@ 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, @@ -52,7 +52,7 @@ 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, @@ -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); @@ -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/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/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 8083ecd64db0f..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, From 28a5220f947a61c1c7ab19ca0d7989bc6296ef87 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 19 Mar 2025 14:11:14 +0100 Subject: [PATCH 06/12] Generates create Bitbucket PR URLs (#4142, #4143) --- src/git/remotes/bitbucket.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 { From e53c6fba2fb1d8cda9d5cdf032d1b2f2aa02bde2 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 19 Mar 2025 14:24:42 +0100 Subject: [PATCH 07/12] Generates create Bitbucket DataCenter PR URLs (without cross-forks) (#4142, #4143) --- src/git/remotes/bitbucket-server.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index 5e2f1ec4b5b7e..e22221fc64dfa 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}`).replace('%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 { From bf2d1d54c33cabfdf41dc36cfbfbd6ce60073f79 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 19 Mar 2025 15:11:28 +0100 Subject: [PATCH 08/12] Generates create GitHub PR URLs (#4142, #4143) --- src/git/remotes/github.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) 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 { From 2ccaefa056675c44eda22448273a35d949bba762 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Wed, 12 Mar 2025 01:41:00 -0400 Subject: [PATCH 09/12] Adds create pr urls for other providers (#4142, #4143) --- CHANGELOG.md | 1 + src/git/remotes/custom.ts | 17 +++++++++++++++-- src/git/remotes/gerrit.ts | 10 ++++++++++ src/git/remotes/gitea.ts | 19 +++++++++++++++++-- src/git/remotes/remoteProvider.ts | 4 ++-- 5 files changed, 45 insertions(+), 6 deletions(-) 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/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 9d6c3bead6692..b71a166ffcacf 100644 --- a/src/git/remotes/gerrit.ts +++ b/src/git/remotes/gerrit.ts @@ -193,6 +193,16 @@ export class GerritRemote extends RemoteProvider { 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/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 31d087661d4a6..21b6cf31c0a26 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -132,7 +132,7 @@ export abstract class RemoteProvider Date: Thu, 27 Mar 2025 11:03:39 -0700 Subject: [PATCH 10/12] Updates wording on the cross-repository prompt --- src/quickpicks/remoteProviderPicker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index 8c6fdc91eafea..a6bf7660b7e3d 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -137,8 +137,8 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { const integrationName = providersMetadata[integrationId].name; const connectItem = createQuickPickItemOfT( { - label: `Connect a ${integrationName} Integration...`, - detail: `Connect a ${integrationName} integration to be able to create cross-fork pull requests`, + label: `Connect to ${integrationName}...`, + detail: `Connect an integration with ${integrationName} to create cross-repository pull requests`, picked: true, }, true, @@ -155,8 +155,8 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { ); }); quickpick.ignoreFocusOut = getQuickPickIgnoreFocusOut(); - quickpick.title = `Connect a ${integrationName} Integration`; - quickpick.placeholder = `Connect a ${integrationName} integration to be able to create cross-fork pull requests`; + 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(); From b2f2ab3e2771e96bbaf24414171cfc349fdb060e Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 27 Mar 2025 19:59:57 +0100 Subject: [PATCH 11/12] Stops showing an error when user refuses to connect an integration (#4142, #4143) --- src/quickpicks/remoteProviderPicker.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/quickpicks/remoteProviderPicker.ts b/src/quickpicks/remoteProviderPicker.ts index a6bf7660b7e3d..0958081d274f5 100644 --- a/src/quickpicks/remoteProviderPicker.ts +++ b/src/quickpicks/remoteProviderPicker.ts @@ -82,9 +82,7 @@ export class CopyOrOpenRemoteCommandQuickPickItem extends CommandQuickPickItem { const connected = integrationId && (await this.showIntegrationConnectionPicker(integrationId, 'view')); if (!connected) { - throw new RequiresIntegrationError( - 'Cross-fork pull request URLs are not supported by this provider', - ); + return undefined; } } } else if ( From 27f916aeef387cd4d35a5f77fb8bf3e3fb3dd00d Mon Sep 17 00:00:00 2001 From: Ramin Tadayon Date: Thu, 27 Mar 2025 13:48:08 -0700 Subject: [PATCH 12/12] Updates use of `replace` --- src/git/remotes/bitbucket-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git/remotes/bitbucket-server.ts b/src/git/remotes/bitbucket-server.ts index e22221fc64dfa..15eb43fe84d76 100644 --- a/src/git/remotes/bitbucket-server.ts +++ b/src/git/remotes/bitbucket-server.ts @@ -158,7 +158,7 @@ export class BitbucketServerRemote extends RemoteProvider { } protected override getUrlForComparison(base: string, head: string, _notation: '..' | '...'): string { - return this.encodeUrl(`${this.baseUrl}/branches/compare/${base}%0D${head}`).replace('%250D', '%0D'); + return this.encodeUrl(`${this.baseUrl}/branches/compare/${base}%0D${head}`).replaceAll('%250D', '%0D'); } protected override getUrlForCreatePullRequest(