diff --git a/src/utils/__tests__/git-review-cache.test.ts b/src/utils/__tests__/git-review-cache.test.ts index e4494ef9..e41eb941 100644 --- a/src/utils/__tests__/git-review-cache.test.ts +++ b/src/utils/__tests__/git-review-cache.test.ts @@ -24,6 +24,7 @@ interface PrCacheHarness { setOriginRemoteUrl: (url: string) => void; setGlabAvailable: (available: boolean) => void; setCliAuthedForHost: (cli: 'gh' | 'glab', host: string, authed: boolean) => void; + setSshHostAlias: (host: string, hostname: string) => void; } function createHarness(): PrCacheHarness { @@ -39,6 +40,7 @@ function createHarness(): PrCacheHarness { gh: new Set(), glab: new Set() }; + const sshHostAliases = new Map(); const deps: GitReviewCacheDeps = { execFileSync: ((cmd, args, options) => { @@ -63,6 +65,12 @@ function createHarness(): PrCacheHarness { return `${currentRef}\n`; if (cmd === 'git' && commandArgs[0] === 'rev-parse') return 'abc123\n'; + if (cmd === 'ssh' && commandArgs[0] === '-G') { + const host = commandArgs[1]; + if (!host) + throw new Error('missing ssh host'); + return `hostname ${sshHostAliases.get(host) ?? host}\n`; + } if (cmd === 'gh' && commandArgs[0] === '--version') return 'gh version 2.0.0\n'; if (cmd === 'gh' && commandArgs[0] === 'auth' && commandArgs[1] === 'status') { @@ -141,6 +149,9 @@ function createHarness(): PrCacheHarness { } else { authedHosts[cli].delete(host); } + }, + setSshHostAlias: (host: string, hostname: string) => { + sshHostAliases.set(host, hostname); } }; } @@ -326,6 +337,46 @@ describe('git-review-cache', () => { expect(ghPrCalls[1]?.args).toContain('feature/cache-a'); }); + it('resolves SSH host aliases before selecting GitHub and pinning --repo', () => { + const harness = createHarness(); + harness.setOriginRemoteUrl('git@mygit:owner/repo.git'); + harness.setSshHostAlias('mygit', 'github.com'); + harness.ghResponses.push(''); + harness.ghResponses.push(JSON.stringify({ + number: 1485, + reviewDecision: '', + state: 'OPEN', + title: 'Alias PR', + url: 'https://github.com/owner/repo/pull/1485' + })); + + expect(fetchGitReviewData('/tmp/repo', harness.deps)).toEqual({ + number: 1485, + provider: 'gh', + reviewDecision: '', + state: 'OPEN', + title: 'Alias PR', + url: 'https://github.com/owner/repo/pull/1485' + }); + + const sshCalls = harness.execCalls.filter(call => call.cmd === 'ssh'); + expect(sshCalls.length).toBeGreaterThan(0); + expect(sshCalls.every(call => call.args.join(' ') === '-G mygit')).toBe(true); + + const ghAuthCalls = harness.execCalls.filter( + call => call.cmd === 'gh' && call.args[0] === 'auth' + ); + expect(ghAuthCalls).toHaveLength(0); + + const ghPrCalls = harness.execCalls.filter( + call => call.cmd === 'gh' && call.args[0] === 'pr' + ); + expect(ghPrCalls).toHaveLength(2); + expect(ghPrCalls[0]?.args).not.toContain('--repo'); + expect(ghPrCalls[1]?.args).toContain('--repo'); + expect(ghPrCalls[1]?.args).toContain('https://github.com/owner/repo'); + }); + it('falls back to --repo for forked GitLab repos when glab\'s default resolves elsewhere', () => { const harness = createHarness(); harness.setOriginRemoteUrl('git@gitlab.com:fork-owner/example-fork.git'); diff --git a/src/utils/git-review-cache.ts b/src/utils/git-review-cache.ts index ed209a22..22c8ac54 100644 --- a/src/utils/git-review-cache.ts +++ b/src/utils/git-review-cache.ts @@ -140,26 +140,58 @@ function getOriginUrl(cwd: string, deps: GitReviewCacheDeps): string | null { return url.length > 0 ? url : null; } +function isSshRemoteUrl(url: string): boolean { + const trimmed = url.trim().toLowerCase(); + return trimmed.startsWith('ssh://') || !trimmed.includes('://'); +} + +function resolveSshHostAlias(host: string, deps: GitReviewCacheDeps): string { + try { + const output = deps.execFileSync('ssh', ['-G', host], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + timeout: CLI_TIMEOUT, + windowsHide: true + }).trim(); + + for (const line of output.split(/\r?\n/)) { + const match = /^hostname\s+(.+)$/i.exec(line.trim()); + if (match?.[1]) { + return match[1].toLowerCase(); + } + } + } catch { + // Leave the parsed remote host unchanged when ssh is unavailable or + // cannot resolve the alias. + } + + return host.toLowerCase(); +} + +function getEffectiveRemoteHost(url: string, host: string, deps: GitReviewCacheDeps): string { + return isSshRemoteUrl(url) ? resolveSshHostAlias(host, deps) : host.toLowerCase(); +} + function getOriginHost(cwd: string, deps: GitReviewCacheDeps): string | null { const url = getOriginUrl(cwd, deps); if (!url) { return null; } const parsed = parseRemoteUrl(url); - return parsed ? parsed.host.toLowerCase() : null; + return parsed ? getEffectiveRemoteHost(url, parsed.host, deps) : null; } -function toHttpsRepoRef(url: string): string | null { +function toHttpsRepoRef(url: string, deps: GitReviewCacheDeps): string | null { const parsed = parseRemoteUrl(url); if (!parsed) { return null; } - return `https://${parsed.host}/${parsed.owner}/${parsed.repo}`; + return `https://${getEffectiveRemoteHost(url, parsed.host, deps)}/${parsed.owner}/${parsed.repo}`; } function getOriginRepoRef(cwd: string, deps: GitReviewCacheDeps): string | null { const url = getOriginUrl(cwd, deps); - return url ? toHttpsRepoRef(url) : null; + return url ? toHttpsRepoRef(url, deps) : null; } // Self-hosted hosts that name neither forge are resolved by probing each CLI's