* fix(github): resolve owner/repo through SSH Host aliases (#10284) Expand OpenSSH Host → HostName via ssh -G before classifying github.com identity so PR merge works when origin is git@alias:owner/repo.git. Transport URLs stay unchanged so IdentityFile selection is preserved. Do not long-negative-cache indeterminate ssh -G failures. * fix(github): harden SSH alias resolution
This commit is contained in:
parent
c67aadbc18
commit
19d082a164
|
|
@ -501,6 +501,32 @@ describe('runner execFile timeout handling', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('routes fixed commands through an explicitly selected WSL distro', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const child = createMockChildProcess(1234)
|
||||
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
|
||||
cb(null, 'hostname github.com\n', '')
|
||||
return child
|
||||
})
|
||||
|
||||
await commandExecFileAsync('ssh', ['-G', '--', 'github-work'], {
|
||||
cwd: String.raw`C:\repo`,
|
||||
timeout: 5_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', expect.any(String)],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string
|
||||
expect(shellCommand).toContain('/mnt/c/repo')
|
||||
expect(shellCommand).toContain("'ssh' '-G' '--' 'github-work'")
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards synthesized network SSH policy into the selected WSL distro', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const child = createMockChildProcess(1234)
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ type CommandExecOptions = {
|
|||
timeout?: number
|
||||
env?: NodeJS.ProcessEnv
|
||||
signal?: AbortSignal
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
function isMissingCommandError(error: unknown): boolean {
|
||||
|
|
@ -879,23 +880,24 @@ export async function commandExecFileAsync(
|
|||
args: string[],
|
||||
options: CommandExecOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const resolved = resolveCommand(command, args, options.cwd)
|
||||
const { wslDistro, ...execOptions } = options
|
||||
const resolved = resolveCommand(command, args, options.cwd, wslDistro)
|
||||
const binary =
|
||||
resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary
|
||||
if (isWindowsBatchScript(binary)) {
|
||||
return spawnCommandCapture(binary, resolved.args, {
|
||||
...options,
|
||||
...execOptions,
|
||||
cwd: resolved.cwd
|
||||
})
|
||||
}
|
||||
try {
|
||||
const { stdout, stderr } = await execFileCapture(binary, resolved.args, {
|
||||
cwd: resolved.cwd,
|
||||
encoding: options.encoding ?? 'utf-8',
|
||||
maxBuffer: options.maxBuffer,
|
||||
timeout: options.timeout,
|
||||
env: options.env,
|
||||
signal: options.signal
|
||||
encoding: execOptions.encoding ?? 'utf-8',
|
||||
maxBuffer: execOptions.maxBuffer,
|
||||
timeout: execOptions.timeout,
|
||||
env: execOptions.env,
|
||||
signal: execOptions.signal
|
||||
})
|
||||
return { stdout: stdout as string, stderr: stderr as string }
|
||||
} catch (error) {
|
||||
|
|
@ -904,7 +906,7 @@ export async function commandExecFileAsync(
|
|||
resolveWindowsCommand(`${resolved.binary}.cmd`, options.env),
|
||||
resolved.args,
|
||||
{
|
||||
...options,
|
||||
...execOptions,
|
||||
cwd: resolved.cwd
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ import { tmpdir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted(
|
||||
() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
|
|
@ -14,6 +17,7 @@ vi.mock('../git/runner', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
|
|
@ -38,6 +42,8 @@ import {
|
|||
describe('github owner/repo resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
getSshGitProviderMock.mockReset()
|
||||
_resetOwnerRepoCache()
|
||||
__resetLocalGitConfigSignatureCacheForTests()
|
||||
|
|
|
|||
|
|
@ -13,25 +13,26 @@ export { classifyGhError, classifyListIssuesError } from './gh-error-classificat
|
|||
export {
|
||||
_getOwnerRepoCacheSize,
|
||||
_resetOwnerRepoCache,
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepo,
|
||||
getOwnerRepoForRemote,
|
||||
getRemoteUrlForRepo,
|
||||
ghRepoExecOptions,
|
||||
githubRepoContext,
|
||||
parseGitHubOwnerRepo,
|
||||
parseGitHubRemoteIdentity,
|
||||
resolveIssueSource,
|
||||
resolvePRRepositoryCandidates
|
||||
parseGitHubRemoteIdentity
|
||||
} from './github-repository-identity'
|
||||
export type {
|
||||
GitHubRemoteIdentity,
|
||||
GitHubRepoContext,
|
||||
LocalGitExecOptions,
|
||||
OwnerRepo,
|
||||
PRRepositoryCandidates,
|
||||
ResolvedIssueSource
|
||||
OwnerRepo
|
||||
} from './github-repository-identity'
|
||||
export {
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepo,
|
||||
resolveIssueSource,
|
||||
resolvePRRepositoryCandidates
|
||||
} from './github-owner-repo-selection'
|
||||
export type { PRRepositoryCandidates, ResolvedIssueSource } from './github-owner-repo-selection'
|
||||
|
||||
const MAX_CONCURRENT = 4
|
||||
let running = 0
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
const { commandExecFileAsyncMock, ghExecFileAsyncMock, gitExecFileAsyncMock, resolveWithSshGMock } =
|
||||
vi.hoisted(() => ({
|
||||
commandExecFileAsyncMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
resolveWithSshGMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock only the exec boundary so the real remote-identity parsing, runtime
|
||||
// option resolution, and `gh auth status` parsing run against controlled output.
|
||||
vi.mock('../git/runner', () => ({
|
||||
commandExecFileAsync: commandExecFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../ssh/ssh-g-config-resolution', () => ({
|
||||
resolveWithSshG: resolveWithSshGMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetGitHubHostAuthCache,
|
||||
getEnterpriseGitHubRepoSlug,
|
||||
isGitHubHostAuthenticated,
|
||||
isGitHubHostAuthenticatedForGlobalCli
|
||||
} from './github-enterprise-repository'
|
||||
import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution'
|
||||
|
||||
function mockOriginRemote(url: string): void {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
|
|
@ -28,6 +37,19 @@ function mockOriginRemote(url: string): void {
|
|||
})
|
||||
}
|
||||
|
||||
function sshConfig(hostname: string, port = 22) {
|
||||
return {
|
||||
hostname,
|
||||
port,
|
||||
identityFile: [],
|
||||
identitiesOnly: false,
|
||||
forwardAgent: false,
|
||||
proxyUseFdpass: false,
|
||||
controlMaster: 'no',
|
||||
controlPersist: 'no'
|
||||
}
|
||||
}
|
||||
|
||||
// gh auth status inventory entries represent hosts with configured credentials.
|
||||
function mockHostAuthenticated(host = 'github.acme-corp.com'): void {
|
||||
mockAuthenticatedHosts([host])
|
||||
|
|
@ -54,9 +76,13 @@ function mockHostNotAuthenticated(): void {
|
|||
|
||||
describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
beforeEach(() => {
|
||||
commandExecFileAsyncMock.mockReset()
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
resolveWithSshGMock.mockReset()
|
||||
resolveWithSshGMock.mockResolvedValue(null)
|
||||
_resetGitHubHostAuthCache()
|
||||
_resetSshHostnameResolutionCache()
|
||||
})
|
||||
|
||||
it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => {
|
||||
|
|
@ -84,6 +110,67 @@ describe('getEnterpriseGitHubRepoSlug', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('expands an SSH Host alias to the authenticated GHES HostName (#10284)', async () => {
|
||||
mockOriginRemote('git@ghe-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.acme-corp.com'))
|
||||
mockHostAuthenticated('github.acme-corp.com')
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledWith('ghe-work')
|
||||
})
|
||||
|
||||
it('keeps a failed GHES alias probe indeterminate and recovers on retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockOriginRemote('git@ghe-work:team/orca.git')
|
||||
resolveWithSshGMock
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(sshConfig('github.acme-corp.com'))
|
||||
mockHostNotAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
mockHostAuthenticated('github.acme-corp.com')
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a Host alias that resolves to github.com (dotcom path owns it)', async () => {
|
||||
mockOriginRemote('git@github-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('expands aliases in the repository WSL runtime', async () => {
|
||||
mockOriginRemote('git@github-work:team/orca.git')
|
||||
commandExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'hostname github.com\nport 22\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
getEnterpriseGitHubRepoSlug('/repo', null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], {
|
||||
cwd: '/repo',
|
||||
timeout: 5_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the unique ported auth host for a hostname-only SSH remote', async () => {
|
||||
mockOriginRemote('git@ghe.acme.com:team/orca.git')
|
||||
mockHostAuthenticated('ghe.acme.com:8443')
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
parseGitHubRemoteIdentity,
|
||||
type LocalGitExecOptions
|
||||
} from './github-repository-identity'
|
||||
import {
|
||||
effectiveGitHubRemoteHost,
|
||||
gitHubSshConfigHostAlias
|
||||
} from './github-remote-identity-parsing'
|
||||
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
|
||||
import { parseWslPath } from '../wsl'
|
||||
|
||||
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
|
||||
|
|
@ -225,11 +230,32 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
|
|||
return null
|
||||
}
|
||||
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
if (!identity) {
|
||||
return null
|
||||
}
|
||||
// Why: GHES routing needs the effective host behind an SSH alias.
|
||||
let effectiveHost = identity.host
|
||||
const aliasHost = remoteUrl ? gitHubSshConfigHostAlias(remoteUrl) : null
|
||||
if (aliasHost) {
|
||||
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
|
||||
if (!resolved || !hostname) {
|
||||
const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost(
|
||||
identity.host,
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
return authenticatedLiteralHost
|
||||
? { owner: identity.owner, repo: identity.repo, host: authenticatedLiteralHost }
|
||||
: undefined
|
||||
}
|
||||
effectiveHost = effectiveGitHubRemoteHost(identity.host, hostname)
|
||||
}
|
||||
if (effectiveHost === 'github.com') {
|
||||
return null
|
||||
}
|
||||
const authenticatedHost = await resolveAuthenticatedGitHubHost(
|
||||
identity.host,
|
||||
effectiveHost,
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import type { IssueSourcePreference } from '../../shared/types'
|
||||
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
|
||||
import {
|
||||
getOwnerRepoForRemote,
|
||||
type LocalGitExecOptions,
|
||||
type OwnerRepo
|
||||
} from './github-repository-identity'
|
||||
|
||||
export async function getOwnerRepo(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
// Why: on a fork checkout PRs live on the upstream parent, not origin (#7331).
|
||||
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
}
|
||||
|
||||
export const getIssueOwnerRepo = getOwnerRepo
|
||||
|
||||
export type PRRepositoryCandidates = {
|
||||
candidates: OwnerRepo[]
|
||||
headRepo: OwnerRepo | null
|
||||
}
|
||||
|
||||
export async function resolvePRRepositoryCandidates(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRRepositoryCandidates> {
|
||||
const [upstream, origin] = await Promise.all([
|
||||
getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions),
|
||||
getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
const candidates: OwnerRepo[] = []
|
||||
|
||||
for (const candidate of [upstream, origin]) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const key = githubRepoIdentityKey(candidate)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
return { candidates, headRepo: origin }
|
||||
}
|
||||
|
||||
export type ResolvedIssueSource = {
|
||||
source: OwnerRepo | null
|
||||
/** True when explicit upstream is gone and resolver fell back to origin. */
|
||||
fellBack: boolean
|
||||
}
|
||||
|
||||
export async function resolveIssueSource(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ResolvedIssueSource> {
|
||||
if (preference === 'upstream') {
|
||||
const upstream = await getOwnerRepoForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstream) {
|
||||
return { source: upstream, fellBack: false }
|
||||
}
|
||||
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
return { source: origin, fellBack: origin !== null }
|
||||
}
|
||||
if (preference === 'origin') {
|
||||
return {
|
||||
source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing'
|
||||
import {
|
||||
effectiveGitHubRemoteHost,
|
||||
gitHubSshConfigHostAlias,
|
||||
parseGitHubOwnerRepo,
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname,
|
||||
parseGitHubRemoteIdentity,
|
||||
remoteUrlUsesSshTransport
|
||||
} from './github-remote-identity-parsing'
|
||||
|
||||
describe('parseGitHubRemoteIdentity', () => {
|
||||
it('parses a plain github.com https remote', () => {
|
||||
|
|
@ -106,4 +113,78 @@ describe('parseGitHubOwnerRepo', () => {
|
|||
repo: 'orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for an SSH Host alias remote without HostName resolution', () => {
|
||||
expect(parseGitHubOwnerRepo('git@github-work:team/orca.git')).toBeNull()
|
||||
expect(parseGitHubOwnerRepo('git@github.com-work:team/orca.git')).toBeNull()
|
||||
expect(parseGitHubOwnerRepo('ssh://git@github-work/team/orca.git')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSH Host alias identity (#10284)', () => {
|
||||
it('detects SCP and ssh:// remotes as SSH transport', () => {
|
||||
expect(remoteUrlUsesSshTransport('git@github-work:team/orca.git')).toBe(true)
|
||||
expect(remoteUrlUsesSshTransport('ssh://git@github-work/team/orca.git')).toBe(true)
|
||||
expect(remoteUrlUsesSshTransport('git+ssh://git@github-work/team/orca.git')).toBe(true)
|
||||
expect(remoteUrlUsesSshTransport('https://github.com/team/orca.git')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes Host aliases that need ssh -G expansion', () => {
|
||||
expect(gitHubSshConfigHostAlias('git@github-work:team/orca.git')).toBe('github-work')
|
||||
expect(gitHubSshConfigHostAlias('git@github.com-work:team/orca.git')).toBe('github.com-work')
|
||||
expect(gitHubSshConfigHostAlias('ssh://git@github-work/team/orca.git')).toBe('github-work')
|
||||
expect(gitHubSshConfigHostAlias('git@github.com:team/orca.git')).toBeNull()
|
||||
expect(gitHubSshConfigHostAlias('https://github.com/team/orca.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves SSH Host alias case for OpenSSH Host matching', () => {
|
||||
expect(gitHubSshConfigHostAlias('git@GitHub-Work:team/orca.git')).toBe('GitHub-Work')
|
||||
expect(gitHubSshConfigHostAlias('ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work')
|
||||
expect(gitHubSshConfigHostAlias('git+ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work')
|
||||
})
|
||||
|
||||
it('returns owner/repo when resolved HostName is github.com', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'github.com')
|
||||
).toEqual({ owner: 'team', repo: 'orca' })
|
||||
})
|
||||
|
||||
it('returns owner/repo when resolved HostName is ssh.github.com (SSH-over-HTTPS)', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'ssh.github.com')
|
||||
).toEqual({ owner: 'team', repo: 'orca' })
|
||||
})
|
||||
|
||||
it('keeps owner/repo for literal github.com even if resolved host is unused', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@github.com:team/orca.git', null)
|
||||
).toEqual({ owner: 'team', repo: 'orca' })
|
||||
})
|
||||
|
||||
it('returns null when resolved HostName is a non-GitHub forge', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@gitlab-work:team/orca.git', 'gitlab.com')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not apply SSH HostName resolution to HTTPS remotes', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('https://github-work/team/orca.git', 'github.com')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when SSH resolution is missing', () => {
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', null)
|
||||
).toBeNull()
|
||||
expect(
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', ' ')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes effective host for enterprise routing after HostName expansion', () => {
|
||||
expect(effectiveGitHubRemoteHost('github-work', 'ssh.github.com')).toBe('github.com')
|
||||
expect(effectiveGitHubRemoteHost('ghe-work', 'ghe.acme.com')).toBe('ghe.acme.com')
|
||||
expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { GitHubOwnerRepo } from '../../shared/types'
|
|||
|
||||
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
|
||||
|
||||
function normalizeGitHubRemoteHost(host: string): string {
|
||||
export function normalizeGitHubRemoteHost(host: string): string {
|
||||
const normalizedHost = host.toLowerCase()
|
||||
// Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos.
|
||||
return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost
|
||||
|
|
@ -28,6 +28,44 @@ function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner'
|
|||
return { owner, repo }
|
||||
}
|
||||
|
||||
/** SCP-style / ssh:// / git+ssh:// remotes may use an OpenSSH Host alias. */
|
||||
export function remoteUrlUsesSshTransport(remoteUrl: string): boolean {
|
||||
const trimmed = remoteUrl.trim().toLowerCase()
|
||||
return (
|
||||
trimmed.startsWith('git@') || trimmed.startsWith('ssh://') || trimmed.startsWith('git+ssh://')
|
||||
)
|
||||
}
|
||||
|
||||
/** SSH transport host as written, preserving SCP alias case. */
|
||||
export function rawSshTransportHost(remoteUrl: string): string | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const scpMatch = trimmed.match(/^git@([^:]+):/i)
|
||||
if (scpMatch) {
|
||||
return scpMatch[1]
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['ssh:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return url.hostname || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-GitHub SSH host that may need OpenSSH alias expansion. */
|
||||
export function gitHubSshConfigHostAlias(remoteUrl: string): string | null {
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
return null
|
||||
}
|
||||
return rawSshTransportHost(remoteUrl) ?? identity.host
|
||||
}
|
||||
|
||||
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
|
||||
|
|
@ -54,3 +92,37 @@ export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null
|
|||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Parse github.com identity using an expanded SSH HostName. */
|
||||
export function parseGitHubOwnerRepoWithResolvedSshHostname(
|
||||
remoteUrl: string,
|
||||
resolvedSshHostname: string | null | undefined
|
||||
): GitHubOwnerRepo | null {
|
||||
const direct = parseGitHubOwnerRepo(remoteUrl)
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
if (!remoteUrlUsesSshTransport(remoteUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!resolvedSshHostname?.trim()) {
|
||||
return null
|
||||
}
|
||||
const identity = parseGitHubRemoteIdentity(remoteUrl)
|
||||
if (!identity) {
|
||||
return null
|
||||
}
|
||||
if (normalizeGitHubRemoteHost(resolvedSshHostname.trim()) !== 'github.com') {
|
||||
return null
|
||||
}
|
||||
return { owner: identity.owner, repo: identity.repo }
|
||||
}
|
||||
|
||||
/** Effective forge host after optional SSH config HostName expansion. */
|
||||
export function effectiveGitHubRemoteHost(
|
||||
parsedHost: string,
|
||||
resolvedSshHostname?: string | null
|
||||
): string {
|
||||
const candidate = resolvedSshHostname?.trim() || parsedHost
|
||||
return normalizeGitHubRemoteHost(candidate)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,8 @@ vi.mock('./local-git-config-signature', () => ({
|
|||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
|
||||
import {
|
||||
getOwnerRepo,
|
||||
getIssueOwnerRepo,
|
||||
getOwnerRepoForRemote,
|
||||
_resetOwnerRepoCache
|
||||
} from './github-repository-identity'
|
||||
import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity'
|
||||
import { getOwnerRepo, getIssueOwnerRepo } from './github-owner-repo-selection'
|
||||
import { getRepoUpstream } from './client'
|
||||
|
||||
const FORK_PATH = '/tmp/fork-checkout'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitRunner from '../git/runner'
|
||||
|
||||
const {
|
||||
commandExecFileAsyncMock,
|
||||
getSshGitProviderGenerationMock,
|
||||
getSshGitProviderMock,
|
||||
gitExecFileAsyncMock,
|
||||
resolveWithSshGMock,
|
||||
readLocalGitConfigSignatureMock
|
||||
} = vi.hoisted(() => ({
|
||||
commandExecFileAsyncMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
resolveWithSshGMock: vi.fn(),
|
||||
readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284')
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitRunner>()),
|
||||
commandExecFileAsync: commandExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
|
||||
}))
|
||||
|
||||
vi.mock('../ssh/ssh-g-config-resolution', () => ({
|
||||
resolveWithSshG: resolveWithSshGMock
|
||||
}))
|
||||
|
||||
import {
|
||||
getOwnerRepoForRemote,
|
||||
_resetOwnerRepoCache,
|
||||
_getOwnerRepoCacheSize
|
||||
} from './github-repository-identity'
|
||||
import {
|
||||
classifyGitHubOwnerRepoFromRemoteUrl,
|
||||
resolveGitHubOwnerRepoFromRemoteUrl,
|
||||
_resetSshHostnameResolutionCache
|
||||
} from './github-ssh-host-alias-resolution'
|
||||
|
||||
const REPO = '/tmp/ssh-alias-checkout'
|
||||
|
||||
function sshConfig(hostname: string, port = 22) {
|
||||
return {
|
||||
hostname,
|
||||
port,
|
||||
identityFile: [],
|
||||
identitiesOnly: false,
|
||||
forwardAgent: false,
|
||||
proxyUseFdpass: false,
|
||||
controlMaster: 'no',
|
||||
controlPersist: 'no'
|
||||
}
|
||||
}
|
||||
|
||||
function mockRemoteUrl(url: string): void {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: `${url}\n` }
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') {
|
||||
return {
|
||||
exec: vi.fn().mockResolvedValue({
|
||||
stdout: `${remoteUrl}\n`,
|
||||
stderr: ''
|
||||
}),
|
||||
execNonInteractive: vi.fn().mockResolvedValue({
|
||||
stdout: `hostname ${hostname}\nport 22\n`,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
canceled: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetOwnerRepoCache()
|
||||
_resetSshHostnameResolutionCache()
|
||||
commandExecFileAsyncMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReset()
|
||||
getSshGitProviderGenerationMock.mockReturnValue(0)
|
||||
getSshGitProviderMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
resolveWithSshGMock.mockReset()
|
||||
readLocalGitConfigSignatureMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('#10284 SSH Host alias → github.com owner/repo', () => {
|
||||
it('resolveGitHubOwnerRepoFromRemoteUrl expands HostName ssh.github.com', async () => {
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
|
||||
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
|
||||
).resolves.toEqual({ owner: 'team', repo: 'orca' })
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work')
|
||||
})
|
||||
|
||||
it('getOwnerRepoForRemote resolves SCP alias remote used for multi-account GitHub', async () => {
|
||||
mockRemoteUrl('git@github-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work')
|
||||
})
|
||||
|
||||
it('getOwnerRepoForRemote resolves ssh:// Host alias remotes', async () => {
|
||||
mockRemoteUrl('ssh://git@github.com-work/acme/widgets.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'widgets'
|
||||
})
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledWith('github.com-work')
|
||||
})
|
||||
|
||||
it('resolves aliases inside the repository WSL runtime', async () => {
|
||||
mockRemoteUrl('git@github-work:team/orca.git')
|
||||
commandExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'hostname github.com\nport 22\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
getOwnerRepoForRemote(REPO, 'origin', null, { wslDistro: 'Ubuntu' })
|
||||
).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], {
|
||||
cwd: REPO,
|
||||
timeout: 5_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves aliases inside the repository SSH runtime', async () => {
|
||||
const provider = sshProvider('github.com')
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getSshGitProviderGenerationMock.mockReturnValue(4)
|
||||
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(provider.execNonInteractive).toHaveBeenCalledWith(
|
||||
'ssh',
|
||||
['-G', '--', 'github-work'],
|
||||
'/remote/repo',
|
||||
5_000
|
||||
)
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates the same alias across native and WSL runtimes', async () => {
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
|
||||
commandExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'hostname gitlab.com\nport 22\n',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git')
|
||||
).resolves.toEqual({ owner: 'team', repo: 'orca' })
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', {
|
||||
repoPath: REPO,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledTimes(1)
|
||||
expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('invalidates alias resolution when an SSH provider reconnects', async () => {
|
||||
const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' }
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider('github.com'))
|
||||
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
|
||||
).resolves.toEqual({ owner: 'team', repo: 'orca' })
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com'))
|
||||
await expect(
|
||||
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates owner/repo identity when an SSH provider reconnects', async () => {
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
getSshGitProviderMock.mockReturnValue(
|
||||
sshProvider('github.com', 'git@github-work:team/orca.git')
|
||||
)
|
||||
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
|
||||
getSshGitProviderGenerationMock.mockReturnValue(2)
|
||||
getSshGitProviderMock.mockReturnValue(
|
||||
sshProvider('github.com', 'git@github-work:acme/widgets.git')
|
||||
)
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'acme',
|
||||
repo: 'widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call ssh -G for literal github.com remotes', async () => {
|
||||
mockRemoteUrl('git@github.com:team/orca.git')
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call ssh -G for https remotes', async () => {
|
||||
mockRemoteUrl('https://github.com/team/orca.git')
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when alias resolves to a non-GitHub host', async () => {
|
||||
mockRemoteUrl('git@gitlab-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com'))
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when ssh -G fails for an alias', async () => {
|
||||
mockRemoteUrl('git@github-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not rewrite transport: identity resolution only consumes HostName', async () => {
|
||||
const remote = 'git@github-work:team/orca.git'
|
||||
mockRemoteUrl(remote)
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
|
||||
|
||||
await getOwnerRepoForRemote(REPO, 'origin')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['remote', 'get-url', 'origin'],
|
||||
expect.objectContaining({ cwd: REPO })
|
||||
)
|
||||
const gitArgLists = gitExecFileAsyncMock.mock.calls.map(([args]) => args.join(' '))
|
||||
expect(gitArgLists.every((cmd) => cmd.startsWith('remote get-url'))).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies ssh -G failure as indeterminate (not stable not-github)', async () => {
|
||||
resolveWithSshGMock.mockResolvedValueOnce(null)
|
||||
await expect(
|
||||
classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
|
||||
).resolves.toEqual({ kind: 'indeterminate' })
|
||||
})
|
||||
|
||||
it('does not long-negative-cache owner/repo when ssh -G is indeterminate', async () => {
|
||||
mockRemoteUrl('git@github-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValue(null)
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
|
||||
expect(_getOwnerRepoCacheSize()).toBe(0)
|
||||
|
||||
_resetSshHostnameResolutionCache()
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledTimes(2)
|
||||
expect(_getOwnerRepoCacheSize()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not pin an SSH-config-dependent miss to the Git config signature', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockRemoteUrl('git@forge-work:team/orca.git')
|
||||
resolveWithSshGMock
|
||||
.mockResolvedValueOnce(sshConfig('gitlab.com'))
|
||||
.mockResolvedValueOnce(sshConfig('github.com'))
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(60_001)
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('caches a successful HostName expansion so repeat probes skip ssh -G', async () => {
|
||||
mockRemoteUrl('git@github-work:team/orca.git')
|
||||
resolveWithSshGMock.mockResolvedValue(sshConfig('github.com'))
|
||||
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('isolates case-sensitive OpenSSH Host aliases in the cache', async () => {
|
||||
resolveWithSshGMock.mockImplementation(async (host: string) =>
|
||||
sshConfig(host === 'GitHub-Work' ? 'github.com' : 'gitlab.com')
|
||||
)
|
||||
|
||||
await expect(
|
||||
classifyGitHubOwnerRepoFromRemoteUrl('git@GitHub-Work:team/orca.git')
|
||||
).resolves.toEqual({
|
||||
kind: 'github',
|
||||
ownerRepo: { owner: 'team', repo: 'orca' }
|
||||
})
|
||||
await expect(
|
||||
classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
|
||||
).resolves.toEqual({
|
||||
kind: 'not-github',
|
||||
cacheWithGitConfigSignature: false
|
||||
})
|
||||
expect(resolveWithSshGMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('classifies a resolved non-GitHub HostName as not-github', async () => {
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com'))
|
||||
await expect(
|
||||
classifyGitHubOwnerRepoFromRemoteUrl('git@gitlab-work:team/orca.git')
|
||||
).resolves.toEqual({ kind: 'not-github', cacheWithGitConfigSignature: false })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { gitExecFileAsync } from '../git/runner'
|
||||
import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { GitHubOwnerRepo } from '../../shared/types'
|
||||
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { readLocalGitConfigSignature } from './local-git-config-signature'
|
||||
import {
|
||||
parseGitHubOwnerRepo,
|
||||
parseGitHubRemoteIdentity,
|
||||
type GitHubRemoteIdentity
|
||||
} from './github-remote-identity-parsing'
|
||||
import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
|
||||
|
||||
export type OwnerRepo = GitHubOwnerRepo
|
||||
|
||||
|
|
@ -122,7 +122,9 @@ export async function getOwnerRepoForRemote(
|
|||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}`
|
||||
const runtimeKey = context.connectionId
|
||||
? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}`
|
||||
: `local:${context.wslDistro ?? 'host'}`
|
||||
const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}`
|
||||
const now = Date.now()
|
||||
pruneOwnerRepoCache(now)
|
||||
|
|
@ -177,15 +179,40 @@ async function resolveOwnerRepoForRemote(
|
|||
const now = Date.now()
|
||||
try {
|
||||
const remoteUrl = await getRemoteUrlForRepo(context, remoteName)
|
||||
const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null
|
||||
if (result) {
|
||||
if (!remoteUrl) {
|
||||
// Empty remote URL is stable until git config changes.
|
||||
ownerRepoCache.set(cacheKey, {
|
||||
value: result,
|
||||
expiresAt: now + getOwnerRepoCacheTtl(result, configSignature)
|
||||
value: null,
|
||||
expiresAt: now + getOwnerRepoCacheTtl(null, configSignature),
|
||||
...(configSignature ? { configSignature } : {})
|
||||
})
|
||||
pruneOwnerRepoCache(now)
|
||||
return result
|
||||
return null
|
||||
}
|
||||
// Why: PR mutations need the effective host behind an SSH alias.
|
||||
const classification = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context)
|
||||
if (classification.kind === 'github') {
|
||||
ownerRepoCache.set(cacheKey, {
|
||||
value: classification.ownerRepo,
|
||||
expiresAt: now + getOwnerRepoCacheTtl(classification.ownerRepo, configSignature)
|
||||
})
|
||||
pruneOwnerRepoCache(now)
|
||||
return classification.ownerRepo
|
||||
}
|
||||
if (classification.kind === 'indeterminate') {
|
||||
// Why: a failed ssh -G probe is not a stable "not GitHub" result.
|
||||
return null
|
||||
}
|
||||
const stableConfigSignature = classification.cacheWithGitConfigSignature
|
||||
? configSignature
|
||||
: undefined
|
||||
ownerRepoCache.set(cacheKey, {
|
||||
value: null,
|
||||
expiresAt: now + getOwnerRepoCacheTtl(null, stableConfigSignature),
|
||||
...(stableConfigSignature ? { configSignature: stableConfigSignature } : {})
|
||||
})
|
||||
pruneOwnerRepoCache(now)
|
||||
return null
|
||||
} catch (error) {
|
||||
// Why: only stable "no such remote" misses are safe to hold for minutes.
|
||||
// Transient git lock/IO failures must retry on the next lookup.
|
||||
|
|
@ -193,7 +220,7 @@ async function resolveOwnerRepoForRemote(
|
|||
return null
|
||||
}
|
||||
}
|
||||
// Why: a missing/non-GitHub remote is stable until `.git/config` changes.
|
||||
// Why: a missing remote is stable until `.git/config` changes.
|
||||
// Holding that negative longer avoids Git process churn across PR polling.
|
||||
ownerRepoCache.set(cacheKey, {
|
||||
value: null,
|
||||
|
|
@ -203,99 +230,3 @@ async function resolveOwnerRepoForRemote(
|
|||
pruneOwnerRepoCache(now)
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getOwnerRepo(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
// Why: on a fork checkout PRs live on the upstream parent, not origin (#7331).
|
||||
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
}
|
||||
|
||||
export async function getIssueOwnerRepo(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
}
|
||||
|
||||
export type PRRepositoryCandidates = {
|
||||
candidates: OwnerRepo[]
|
||||
headRepo: OwnerRepo | null
|
||||
}
|
||||
|
||||
function ownerRepoKey(ownerRepo: OwnerRepo): string {
|
||||
return githubRepoIdentityKey(ownerRepo)
|
||||
}
|
||||
|
||||
export async function resolvePRRepositoryCandidates(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRRepositoryCandidates> {
|
||||
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
const seen = new Set<string>()
|
||||
const candidates: OwnerRepo[] = []
|
||||
|
||||
for (const candidate of [upstream, origin]) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const key = ownerRepoKey(candidate)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
return { candidates, headRepo: origin }
|
||||
}
|
||||
|
||||
export type ResolvedIssueSource = {
|
||||
source: OwnerRepo | null
|
||||
/** True when explicit upstream is gone and resolver fell back to origin. */
|
||||
fellBack: boolean
|
||||
}
|
||||
|
||||
export async function resolveIssueSource(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<ResolvedIssueSource> {
|
||||
if (preference === 'upstream') {
|
||||
const upstream = await getOwnerRepoForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (upstream) {
|
||||
return { source: upstream, fellBack: false }
|
||||
}
|
||||
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
return { source: origin, fellBack: origin !== null }
|
||||
}
|
||||
if (preference === 'origin') {
|
||||
return {
|
||||
source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
|
||||
fellBack: false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import type { GitHubOwnerRepo } from '../../shared/types'
|
||||
import { commandExecFileAsync } from '../git/runner'
|
||||
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { resolveWithSshG } from '../ssh/ssh-g-config-resolution'
|
||||
import {
|
||||
gitHubSshConfigHostAlias,
|
||||
parseGitHubOwnerRepo,
|
||||
parseGitHubOwnerRepoWithResolvedSshHostname
|
||||
} from './github-remote-identity-parsing'
|
||||
|
||||
/** `indeterminate` means SSH alias expansion failed and must remain retryable. */
|
||||
export type GitHubOwnerRepoResolution =
|
||||
| { kind: 'github'; ownerRepo: GitHubOwnerRepo }
|
||||
| { kind: 'not-github'; cacheWithGitConfigSignature: boolean }
|
||||
| { kind: 'indeterminate' }
|
||||
|
||||
const SSH_HOSTNAME_CACHE_TTL_MS = 60_000
|
||||
const SSH_HOSTNAME_FAILURE_CACHE_TTL_MS = 5_000
|
||||
const SSH_HOSTNAME_CACHE_MAX = 256
|
||||
const SSH_G_TIMEOUT_MS = 5_000
|
||||
|
||||
export type SshConfigResolutionContext = {
|
||||
repoPath: string
|
||||
connectionId?: string | null
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
type SshHostnameCacheEntry = {
|
||||
hostname: string | null
|
||||
resolved: boolean
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const sshHostnameCache = new Map<string, SshHostnameCacheEntry>()
|
||||
const sshHostnameInFlight = new Map<string, Promise<SshHostnameCacheEntry>>()
|
||||
|
||||
/** @internal - tests only */
|
||||
export function _resetSshHostnameResolutionCache(): void {
|
||||
sshHostnameCache.clear()
|
||||
sshHostnameInFlight.clear()
|
||||
}
|
||||
|
||||
function pruneSshHostnameCache(now: number): void {
|
||||
for (const [key, entry] of sshHostnameCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
sshHostnameCache.delete(key)
|
||||
}
|
||||
}
|
||||
while (sshHostnameCache.size > SSH_HOSTNAME_CACHE_MAX) {
|
||||
const oldest = sshHostnameCache.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
return
|
||||
}
|
||||
sshHostnameCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
function sshRuntimeCacheKey(context: SshConfigResolutionContext): string {
|
||||
if (context.connectionId) {
|
||||
const generation = getSshGitProviderGeneration(context.connectionId)
|
||||
return `ssh:${context.connectionId}:${generation}`
|
||||
}
|
||||
const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
|
||||
return `local:${distro?.toLowerCase() ?? 'host'}`
|
||||
}
|
||||
|
||||
function parseSshGHostname(stdout: string): string | null {
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const match = line.match(/^hostname\s+(.+)$/i)
|
||||
if (match?.[1].trim()) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveSshHostnameInRuntime(
|
||||
host: string,
|
||||
context: SshConfigResolutionContext
|
||||
): Promise<string | null> {
|
||||
if (context.connectionId) {
|
||||
const provider = getSshGitProvider(context.connectionId)
|
||||
if (!provider) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const result = await provider.execNonInteractive(
|
||||
'ssh',
|
||||
['-G', '--', host],
|
||||
context.repoPath,
|
||||
SSH_G_TIMEOUT_MS
|
||||
)
|
||||
return result.exitCode === 0 && !result.timedOut && !result.canceled
|
||||
? parseSshGHostname(result.stdout)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
|
||||
if (!wslDistro) {
|
||||
return (await resolveWithSshG(host))?.hostname?.trim() || null
|
||||
}
|
||||
try {
|
||||
const { stdout } = await commandExecFileAsync('ssh', ['-G', '--', host], {
|
||||
cwd: context.repoPath,
|
||||
timeout: SSH_G_TIMEOUT_MS,
|
||||
wslDistro
|
||||
})
|
||||
return parseSshGHostname(stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve OpenSSH Host → HostName in the repository runtime. */
|
||||
export async function resolveSshConfigHostname(
|
||||
host: string,
|
||||
context: SshConfigResolutionContext = { repoPath: '' }
|
||||
): Promise<{
|
||||
hostname: string | null
|
||||
resolved: boolean
|
||||
}> {
|
||||
const cacheKey = `${sshRuntimeCacheKey(context)}\0${host}`
|
||||
const now = Date.now()
|
||||
pruneSshHostnameCache(now)
|
||||
const cached = sshHostnameCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return { hostname: cached.hostname, resolved: cached.resolved }
|
||||
}
|
||||
const inFlight = sshHostnameInFlight.get(cacheKey)
|
||||
if (inFlight) {
|
||||
const entry = await inFlight
|
||||
return { hostname: entry.hostname, resolved: entry.resolved }
|
||||
}
|
||||
const probe = (async (): Promise<SshHostnameCacheEntry> => {
|
||||
const hostname = await resolveSshHostnameInRuntime(host, context)
|
||||
const resolved = hostname != null && hostname.length > 0
|
||||
const entry: SshHostnameCacheEntry = {
|
||||
hostname: resolved ? hostname : null,
|
||||
resolved,
|
||||
expiresAt:
|
||||
Date.now() + (resolved ? SSH_HOSTNAME_CACHE_TTL_MS : SSH_HOSTNAME_FAILURE_CACHE_TTL_MS)
|
||||
}
|
||||
sshHostnameCache.set(cacheKey, entry)
|
||||
pruneSshHostnameCache(Date.now())
|
||||
return entry
|
||||
})()
|
||||
sshHostnameInFlight.set(cacheKey, probe)
|
||||
try {
|
||||
const entry = await probe
|
||||
return { hostname: entry.hostname, resolved: entry.resolved }
|
||||
} finally {
|
||||
if (sshHostnameInFlight.get(cacheKey) === probe) {
|
||||
sshHostnameInFlight.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve github.com identity without rewriting the Git transport URL. */
|
||||
export async function classifyGitHubOwnerRepoFromRemoteUrl(
|
||||
remoteUrl: string,
|
||||
context: SshConfigResolutionContext = { repoPath: '' }
|
||||
): Promise<GitHubOwnerRepoResolution> {
|
||||
const direct = parseGitHubOwnerRepo(remoteUrl)
|
||||
if (direct) {
|
||||
return { kind: 'github', ownerRepo: direct }
|
||||
}
|
||||
const aliasHost = gitHubSshConfigHostAlias(remoteUrl)
|
||||
if (!aliasHost) {
|
||||
return { kind: 'not-github', cacheWithGitConfigSignature: true }
|
||||
}
|
||||
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
|
||||
if (!resolved || !hostname) {
|
||||
return { kind: 'indeterminate' }
|
||||
}
|
||||
const ownerRepo = parseGitHubOwnerRepoWithResolvedSshHostname(remoteUrl, hostname)
|
||||
return ownerRepo
|
||||
? { kind: 'github', ownerRepo }
|
||||
: { kind: 'not-github', cacheWithGitConfigSignature: false }
|
||||
}
|
||||
|
||||
/** Convenience wrapper for callers that only need owner/repo or null. */
|
||||
export async function resolveGitHubOwnerRepoFromRemoteUrl(
|
||||
remoteUrl: string,
|
||||
context: SshConfigResolutionContext = { repoPath: '' }
|
||||
): Promise<GitHubOwnerRepo | null> {
|
||||
const result = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context)
|
||||
return result.kind === 'github' ? result.ownerRepo : null
|
||||
}
|
||||
|
|
@ -120,6 +120,7 @@ vi.mock('../worktree-root-preparation', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: () => 0,
|
||||
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
return mockGitProvider
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ vi.mock('../source-control/hosted-review', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: () => 0,
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
|
||||
|
|
|
|||
Loading…
Reference in New Issue