fix(bitbucket): canonicalize ssh.bitbucket.org remotes in review links and Gitea detection (#30808)

Fold every forge SSH host alias (ssh.github.com, altssh.gitlab.com,
ssh.bitbucket.org) through one shared normalizeForgeWebHost so main-process
provider detection and renderer manual review links agree. Remotes on
ssh.bitbucket.org now open bitbucket.org pull request forms instead of
ssh.bitbucket.org URLs, and Gitea no longer claims the host.

Co-authored-by: Eric Junker <4758+ejunker@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Eric Junker
2026-10-28 01:57:10 -07:00
committed by GitHub
co-authored by Eric Junker Neil
parent 19ecde24a0
commit 780ab3d71d
7 changed files with 66 additions and 25 deletions
+2 -2
View File
@@ -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)
+4 -5
View File
@@ -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<string, { host: string; provider: HostedRemoteProvi
}
function providerForHost(host: string): HostedRemoteHost | null {
const normalized = normalizeGitLabRemoteHost(host)
if (normalized === 'github.com' || normalized === 'ssh.github.com') {
// SSH-over-HTTPS uses ssh.github.com:443, but browser/account identity remains github.com.
const normalized = normalizeForgeWebHost(host)
if (normalized === 'github.com') {
return { host: 'github.com', provider: 'github' }
}
if (normalized === 'gitlab.com') {
return { host: 'gitlab.com', provider: 'gitlab' }
}
if (normalized === 'bitbucket.org' || normalized === 'ssh.bitbucket.org') {
if (normalized === 'bitbucket.org') {
return { host: 'bitbucket.org', provider: 'bitbucket' }
}
return null
+1
View File
@@ -93,6 +93,7 @@ describe('Gitea repository ref parsing', () => {
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()
})
+1
View File
@@ -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'
])
@@ -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({
@@ -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<HostedReviewProvider, 'unsupported'>
@@ -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,
+10
View File
@@ -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))