diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts index 8a0c35ff986..0dc70b2d73a 100644 --- a/src/main/bitbucket/repository-ref.ts +++ b/src/main/bitbucket/repository-ref.ts @@ -1,3 +1,4 @@ +import { normalizeBitbucketRemoteHost } from '../../shared/git-remote-host-alias' import { createRemoteRefProbeCache } from '../git/remote-ref-probe-cache' export type BitbucketRepoRef = { @@ -58,8 +59,7 @@ export function parseBitbucketRepoRef(remoteUrl: string): BitbucketRepoRef | nul try { const url = new URL(trimmed) - const host = url.hostname.toLowerCase() - if (host !== 'bitbucket.org' && host !== 'ssh.bitbucket.org') { + if (normalizeBitbucketRemoteHost(url.hostname) !== 'bitbucket.org') { return null } return parseBitbucketPath(url.pathname) diff --git a/src/main/git/hosted-remote-url.ts b/src/main/git/hosted-remote-url.ts index a1b080f6abf..500fbd2c1d8 100644 --- a/src/main/git/hosted-remote-url.ts +++ b/src/main/git/hosted-remote-url.ts @@ -1,4 +1,4 @@ -import { normalizeGitLabRemoteHost } from '../../shared/git-remote-host-alias' +import { normalizeForgeWebHost } from '../../shared/git-remote-host-alias' // Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used. // Keep only the provider parsing and file/commit URL construction needed by source links. @@ -22,15 +22,14 @@ const shorthandHosts: Record { expect(parseGiteaRepoRef('git@github.com:team/project.git')).toBeNull() expect(parseGiteaRepoRef('https://gitlab.com/team/project.git')).toBeNull() expect(parseGiteaRepoRef('https://bitbucket.org/team/project.git')).toBeNull() + expect(parseGiteaRepoRef('git@ssh.bitbucket.org:team/project.git')).toBeNull() expect(parseGiteaRepoRef('https://dev.azure.com/team/project/_git/repo')).toBeNull() expect(parseGiteaRepoRef('https://team.visualstudio.com/project/_git/repo')).toBeNull() }) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index cebdea882e3..8ab57367afc 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -16,6 +16,7 @@ const KNOWN_NON_GITEA_HOSTS = new Set([ 'github.com', 'gitlab.com', 'bitbucket.org', + 'ssh.bitbucket.org', 'dev.azure.com', 'ssh.dev.azure.com' ]) diff --git a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts index 9b502c865a7..affb0d9d941 100644 --- a/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-manual-review-url.test.ts @@ -106,6 +106,41 @@ describe('buildSourceControlManualReviewUrl', () => { } }) + it.each([ + 'ssh://git@ssh.bitbucket.org/team/project.git', + 'git@ssh.bitbucket.org:team/project.git', + 'git@SSH.BITBUCKET.ORG:team/project.git' + ])('canonicalizes the Bitbucket SSH host web identity: %s', (remote) => { + expect(parseRemoteRepo(remote)).toEqual({ + provider: 'bitbucket', + path: 'team/project', + webBaseUrl: 'https://bitbucket.org/team/project' + }) + for (const provider of [undefined, 'bitbucket'] as const) { + expect( + buildSourceControlManualReviewUrl({ + baseRef: 'main', + branchName: 'feature/x', + repoRemoteName: 'origin', + repoRemoteUrl: remote, + provider, + upstreamName: 'origin/feature/x' + }) + ).toBe('https://bitbucket.org/team/project/pull-requests/new?source=feature%2Fx&dest=main') + } + }) + + it.each(['ssh://git@ssh.github.com:443/team/project.git', 'git@ssh.github.com:team/project.git'])( + 'canonicalizes the GitHub SSH-over-443 web identity: %s', + (remote) => { + expect(parseRemoteRepo(remote)).toEqual({ + provider: 'github', + path: 'team/project', + webBaseUrl: 'https://github.com/team/project' + }) + } + ) + it('does not infer GitLab or rewrite the web origin of an alias lookalike', () => { const remote = 'ssh://git@altssh.gitlab.com.example.com:443/team/project.git' expect(parseRemoteRepo(remote)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/source-control/review/remote-repo.ts b/src/renderer/src/components/right-sidebar/source-control/review/remote-repo.ts index 65e978294db..888ebad0df5 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/remote-repo.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/remote-repo.ts @@ -1,5 +1,5 @@ import type { HostedReviewProvider } from '../../../../../../shared/hosted-review' -import { normalizeGitLabRemoteHost } from '../../../../../../shared/git-remote-host-alias' +import { normalizeForgeWebHost } from '../../../../../../shared/git-remote-host-alias' export type ManualReviewProvider = Exclude @@ -48,20 +48,19 @@ function cleanPath(path: string): string | null { } function providerForHost(host: string): ManualReviewProvider | null { - const normalized = normalizeGitLabRemoteHost(host) - if (normalized === 'github.com' || normalized === 'ssh.github.com') { + if (host === 'github.com') { return 'github' } - if (normalized === 'gitlab.com') { + if (host === 'gitlab.com') { return 'gitlab' } - if (normalized === 'bitbucket.org') { + if (host === 'bitbucket.org') { return 'bitbucket' } if ( - normalized === 'dev.azure.com' || - normalized === 'ssh.dev.azure.com' || - normalized.endsWith('.visualstudio.com') + host === 'dev.azure.com' || + host === 'ssh.dev.azure.com' || + host.endsWith('.visualstudio.com') ) { return 'azure-devops' } @@ -154,7 +153,7 @@ export function parseRemoteRepo( ? trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/) : null if (scpLike) { - const host = normalizeGitLabRemoteHost(scpLike[1].toLowerCase()) + const host = normalizeForgeWebHost(scpLike[1]) const path = cleanPath(scpLike[2]) if (!path) { return null @@ -178,18 +177,14 @@ export function parseRemoteRepo( return null } const remoteHost = url.hostname.toLowerCase() - const host = normalizeGitLabRemoteHost(remoteHost) + const host = normalizeForgeWebHost(remoteHost) const hintedProvider = normalizeProvider(providerHint) const inferredProvider = providerForHost(host) const provider = hintedProvider ?? inferredProvider - let webOrigin: string - if (host === 'ssh.github.com') { - webOrigin = 'https://github.com' - } else if (remoteHost === 'altssh.gitlab.com') { - webOrigin = 'https://gitlab.com' - } else { - webOrigin = buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '') - } + const webOrigin = + host === remoteHost + ? buildWebOrigin(protocol, url.host, url.hostname).replace(/\/+$/, '') + : `https://${host}` return { provider, path, diff --git a/src/shared/git-remote-host-alias.ts b/src/shared/git-remote-host-alias.ts index dab031734ea..ae70cc03cfc 100644 --- a/src/shared/git-remote-host-alias.ts +++ b/src/shared/git-remote-host-alias.ts @@ -21,6 +21,16 @@ export function normalizeGitLabRemoteHost(host: string): string { return normalizedHost === 'altssh.gitlab.com' ? 'gitlab.com' : normalizedHost } +export function normalizeBitbucketRemoteHost(host: string): string { + const normalizedHost = normalizeRemoteHost(host) + // Why: Bitbucket Cloud serves SSH for bitbucket.org repos from ssh.bitbucket.org. + return normalizedHost === 'ssh.bitbucket.org' ? 'bitbucket.org' : normalizedHost +} + +export function normalizeForgeWebHost(host: string): string { + return normalizeBitbucketRemoteHost(normalizeGitLabRemoteHost(normalizeGitHubRemoteHost(host))) +} + /** Comparison-only host fold: the forge alias plus `www.`, for matching a remote to a pasted URL. */ export function foldComparableGitHubHost(host: string): string { return normalizeGitHubRemoteHost(foldWwwHostAlias(host))