mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
fix(github): host-qualify GHES gh commands and probe auth in the repo runtime
Addresses two correctness issues found in review of the #8312 fix. 1. GHES host was discarded before `gh pr create`. `--repo owner/repo` shorthand resolves against gh's default host (usually github.com), so for a user authed to both github.com and GHES it could target a same-named github.com repo or fail — deterministic for SSH repos, which run gh with no cwd. Now `createGitHubPullRequest` and the `findOpenPRByHeadBase` fallback pass a host-qualified `HOST/owner/repo` for GHES (github.com keeps the shorthand). Also generalize `parseCreatePRPayload`'s URL regex off github.com so a GHES PR URL parses directly instead of limping through the list fallback. 2. GHES auth was probed on the wrong gh runtime. `getAuthenticatedGitHubHosts` ran a global `gh auth status` with no cwd/WSL/SSH context and cached every runtime under one "local" key, so a GHES login present only in the repo's WSL distro was missed and the repo fell back to Gitea. Replaced with `isGitHubHostAuthenticated`, which runs `gh auth status --hostname <host>` with the repository's execution options (cwd/WSL distro, or SSH-local like the create path) and caches per runtime+host — mirroring GitLab's isGlabConfiguredForRemoteHost. This also honors GH_ENTERPRISE_TOKEN inferred from repo context. Spawn failures stay indeterminate (uncached). Adds createGitHubPullRequest-level tests asserting the actual gh `--repo` arguments (create + fallback) and the WSL/SSH runtime of the auth probe.
This commit is contained in:
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
extractExecErrorMock,
|
||||
acquireMock,
|
||||
releaseMock,
|
||||
@@ -11,6 +12,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
extractExecErrorMock: vi.fn((error: unknown) => {
|
||||
const value = error as { stderr?: string; stdout?: string; message?: string }
|
||||
return {
|
||||
@@ -52,12 +54,18 @@ vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock
|
||||
}))
|
||||
|
||||
import { createGitHubPullRequest } from './client'
|
||||
|
||||
describe('createGitHubPullRequest', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
extractExecErrorMock.mockClear()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
@@ -115,6 +123,78 @@ describe('createGitHubPullRequest', () => {
|
||||
expect(releaseMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => {
|
||||
// github.com-only slug parsing misses GHES, so creation comes from the
|
||||
// enterprise resolver, which carries the host.
|
||||
getOwnerRepoMock.mockResolvedValueOnce(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
// gh prints the PR URL (not JSON); the GHES host must still parse directly.
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: 'https://github.acme-corp.com/team/orca/pull/7\n'
|
||||
})
|
||||
|
||||
await expect(
|
||||
createGitHubPullRequest('/repo-root', {
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'GHES PR'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 7,
|
||||
url: 'https://github.acme-corp.com/team/orca/pull/7'
|
||||
})
|
||||
|
||||
const [args] = ghExecFileAsyncMock.mock.calls[0]
|
||||
// Bare "team/orca" would resolve against gh's default host (github.com);
|
||||
// the host prefix pins the command to the Enterprise server.
|
||||
expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca')
|
||||
})
|
||||
|
||||
it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
// Create reports "already exists", forcing the pr-list fallback.
|
||||
ghExecFileAsyncMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('exists'), {
|
||||
stderr: 'a pull request for branch "feature/create-pr" already exists',
|
||||
stdout: ''
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{ number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' }
|
||||
])
|
||||
})
|
||||
|
||||
await expect(
|
||||
createGitHubPullRequest('/repo-root', {
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature/create-pr',
|
||||
title: 'GHES PR'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'already_exists',
|
||||
existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' }
|
||||
})
|
||||
|
||||
const [listArgs] = ghExecFileAsyncMock.mock.calls[1]
|
||||
expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list']))
|
||||
expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca')
|
||||
})
|
||||
|
||||
it('runs local WSL project pull request creation through the selected distro', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -1751,16 +1751,29 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } |
|
||||
} catch {
|
||||
// Fall through to URL parsing for older gh versions without --json support.
|
||||
}
|
||||
const urlMatch = trimmed.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/)
|
||||
// Why: gh prints the PR URL (not JSON) here; match any host, not just
|
||||
// github.com, so a GitHub Enterprise Server URL still parses directly (#8312).
|
||||
const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/)
|
||||
if (!urlMatch) {
|
||||
return null
|
||||
}
|
||||
return { number: Number(urlMatch[1]), url: urlMatch[0] }
|
||||
}
|
||||
|
||||
// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host
|
||||
// (usually github.com), not the repo's remote — so a GHES repo would target a
|
||||
// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits
|
||||
// the Enterprise server; this is the only host signal for SSH repos, which run
|
||||
// gh with no cwd context (#8312). github.com keeps the bare shorthand.
|
||||
function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string {
|
||||
return slug.host && slug.host.toLowerCase() !== 'github.com'
|
||||
? `${slug.host}/${slug.owner}/${slug.repo}`
|
||||
: `${slug.owner}/${slug.repo}`
|
||||
}
|
||||
|
||||
async function findOpenPRByHeadBase(args: {
|
||||
repoPath: string
|
||||
ownerRepo: OwnerRepo
|
||||
repoArg: string
|
||||
head: string
|
||||
base: string
|
||||
connectionId?: string | null
|
||||
@@ -1772,7 +1785,7 @@ async function findOpenPRByHeadBase(args: {
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${args.ownerRepo.owner}/${args.ownerRepo.repo}`,
|
||||
args.repoArg,
|
||||
'--head',
|
||||
args.head,
|
||||
'--base',
|
||||
@@ -1857,6 +1870,8 @@ export async function createGitHubPullRequest(
|
||||
error: 'Creating pull requests requires a GitHub remote.'
|
||||
}
|
||||
}
|
||||
// Host-qualified for GHES so gh targets the Enterprise server, not github.com.
|
||||
const repoArg = ghRepoArg(ownerRepo)
|
||||
|
||||
const base = normalizeHostedReviewBaseRef(input.base)
|
||||
const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined
|
||||
@@ -1889,7 +1904,7 @@ export async function createGitHubPullRequest(
|
||||
'pr',
|
||||
'create',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
repoArg,
|
||||
'--base',
|
||||
base,
|
||||
'--title',
|
||||
@@ -1918,7 +1933,7 @@ export async function createGitHubPullRequest(
|
||||
const found = head
|
||||
? await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
repoArg,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
@@ -1942,7 +1957,7 @@ export async function createGitHubPullRequest(
|
||||
) {
|
||||
const existing = await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
repoArg,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
|
||||
@@ -5,17 +5,17 @@ const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock only the exec boundary so the real remote-identity parsing and
|
||||
// `gh auth status` parsing run against controlled command output.
|
||||
// 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', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetAuthenticatedGitHubHostsCache,
|
||||
getAuthenticatedGitHubHosts,
|
||||
getEnterpriseGitHubRepoSlug
|
||||
_resetGitHubHostAuthCache,
|
||||
getEnterpriseGitHubRepoSlug,
|
||||
isGitHubHostAuthenticated
|
||||
} from './github-enterprise-repository'
|
||||
|
||||
function mockOriginRemote(url: string): void {
|
||||
@@ -27,26 +27,50 @@ function mockOriginRemote(url: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function mockGhAuthStatus(hosts: string[]): void {
|
||||
const text = hosts
|
||||
.map(
|
||||
(host) =>
|
||||
`${host}\n ✓ Logged in to ${host} account kelora (keyring)\n - Active account: true\n - Token scopes: 'repo', 'read:org'`
|
||||
)
|
||||
.join('\n')
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: text, stderr: '' })
|
||||
// gh exit 0 for `auth status --hostname <host>` means logged in to that host.
|
||||
function mockHostAuthenticated(host = 'github.acme-corp.com'): void {
|
||||
ghExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: `${host}\n ✓ Logged in to ${host} account kelora (keyring)`,
|
||||
stderr: ''
|
||||
})
|
||||
}
|
||||
|
||||
// gh exits non-zero and reports no matching host when not logged in.
|
||||
function mockHostNotAuthenticated(): void {
|
||||
ghExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
stdout: '',
|
||||
stderr: 'You are not logged into any GitHub hosts. To log in, run: gh auth login'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetAuthenticatedGitHubHostsCache()
|
||||
_resetGitHubHostAuthCache()
|
||||
})
|
||||
|
||||
it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => {
|
||||
mockOriginRemote('https://github.acme-corp.com/team/orca.git')
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
mockHostAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
// The auth probe targets the remote's host, not a hardcoded github.com.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.acme-corp.com'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a GHES SCP-style SSH remote', async () => {
|
||||
mockOriginRemote('git@github.acme-corp.com:team/orca.git')
|
||||
mockHostAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
@@ -55,15 +79,18 @@ describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a GHES SCP-style SSH remote', async () => {
|
||||
mockOriginRemote('git@github.acme-corp.com:team/orca.git')
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
it('probes gh in the repository WSL runtime, not the host/default distro', async () => {
|
||||
mockOriginRemote('https://github.acme-corp.com/team/orca.git')
|
||||
mockHostAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
await getEnterpriseGitHubRepoSlug('/repo', null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.acme-corp.com'],
|
||||
{ cwd: '/repo', wslDistro: 'Ubuntu' }
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves github.com to getOwnerRepo without probing gh auth', async () => {
|
||||
@@ -75,16 +102,17 @@ describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
|
||||
it('declines a custom host the user is not gh-authenticated to (leaves it for Gitea)', async () => {
|
||||
mockOriginRemote('https://gitea.example.com/team/orca.git')
|
||||
mockGhAuthStatus(['github.com'])
|
||||
mockHostNotAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unparseable remote', async () => {
|
||||
mockOriginRemote('not-a-remote-url')
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
mockHostAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the origin remote lookup fails', async () => {
|
||||
@@ -94,49 +122,60 @@ describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthenticatedGitHubHosts', () => {
|
||||
describe('isGitHubHostAuthenticated', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetAuthenticatedGitHubHostsCache()
|
||||
_resetGitHubHostAuthCache()
|
||||
})
|
||||
|
||||
it('parses and lowercases the logged-in hosts', async () => {
|
||||
mockGhAuthStatus(['github.com', 'GitHub.Acme-Corp.com'])
|
||||
it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => {
|
||||
mockHostAuthenticated()
|
||||
|
||||
const hosts = await getAuthenticatedGitHubHosts()
|
||||
expect(hosts.has('github.com')).toBe(true)
|
||||
expect(hosts.has('github.acme-corp.com')).toBe(true)
|
||||
await expect(
|
||||
isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1')
|
||||
).resolves.toBe(true)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.acme-corp.com'],
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('caches a non-empty result to avoid re-spawning gh per detection poll', async () => {
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
it('caches per runtime+host so detection polling does not re-spawn gh', async () => {
|
||||
mockHostAuthenticated()
|
||||
|
||||
await getAuthenticatedGitHubHosts()
|
||||
await getAuthenticatedGitHubHosts()
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers hosts from gh output even when gh exits non-zero', async () => {
|
||||
it('does not share cache state across WSL distros', async () => {
|
||||
mockHostAuthenticated()
|
||||
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Ubuntu' })
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Debian' })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('treats a listed host as authenticated even when gh exits non-zero', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
stdout:
|
||||
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)',
|
||||
stderr: ''
|
||||
stdout: '',
|
||||
stderr:
|
||||
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired'
|
||||
})
|
||||
)
|
||||
|
||||
const hosts = await getAuthenticatedGitHubHosts()
|
||||
expect(hosts.has('github.acme-corp.com')).toBe(true)
|
||||
await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('does not cache an empty result so a later gh login is discovered', async () => {
|
||||
it('does not cache a hard gh failure so a later probe can recover', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('not installed'), { stdout: '', stderr: '' })
|
||||
)
|
||||
expect((await getAuthenticatedGitHubHosts()).size).toBe(0)
|
||||
expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false)
|
||||
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
expect((await getAuthenticatedGitHubHosts()).has('github.acme-corp.com')).toBe(true)
|
||||
mockHostAuthenticated()
|
||||
expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,71 +6,94 @@ import {
|
||||
} from '../source-control/hosted-review-git-options'
|
||||
import { parseAuthStatus } from './auth-diagnose'
|
||||
import {
|
||||
ghRepoExecOptions,
|
||||
getRemoteUrlForRepo,
|
||||
githubRepoContext,
|
||||
parseGitHubRemoteIdentity
|
||||
parseGitHubRemoteIdentity,
|
||||
type LocalGitExecOptions
|
||||
} from './github-repository-identity'
|
||||
|
||||
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
|
||||
|
||||
// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a
|
||||
// host that `gh auth status` reports as logged-in is definitively a GitHub host.
|
||||
// This is the same signal `glab auth status` provides for GitLab self-hosted
|
||||
// detection, mirrored here so GHES is not left to fall through to Gitea (#8312).
|
||||
const AUTHENTICATED_HOSTS_TTL_MS = 60_000
|
||||
// host `gh auth status` reports as logged-in is definitively a GitHub host. This
|
||||
// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a
|
||||
// GHES remote is not left to fall through to Gitea (#8312).
|
||||
const HOST_AUTH_TTL_MS = 60_000
|
||||
|
||||
type AuthenticatedHostsCacheEntry = {
|
||||
hosts: ReadonlySet<string>
|
||||
type HostAuthCacheEntry = {
|
||||
authenticated: boolean
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const authenticatedHostsCache = new Map<string, AuthenticatedHostsCacheEntry>()
|
||||
const hostAuthCache = new Map<string, HostAuthCacheEntry>()
|
||||
|
||||
function connectionCacheKey(connectionId?: string | null): string {
|
||||
return connectionId ?? 'local'
|
||||
// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an
|
||||
// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the
|
||||
// runtime that executes gh, not shared under one "local" bucket. Mirrors the
|
||||
// runtime scoping used by owner/repo resolution.
|
||||
function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string {
|
||||
return connectionId ?? `local:${wslDistro ?? 'host'}`
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetAuthenticatedGitHubHostsCache(): void {
|
||||
authenticatedHostsCache.clear()
|
||||
export function _resetGitHubHostAuthCache(): void {
|
||||
hostAuthCache.clear()
|
||||
}
|
||||
|
||||
// Only gh's own stdout/stderr — not the Error.message — counts as an
|
||||
// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a
|
||||
// message and no command output, and must stay indeterminate rather than be
|
||||
// read as "host not authenticated".
|
||||
function ghCommandOutput(error: unknown): string {
|
||||
const execErr = error as { stdout?: unknown; stderr?: unknown }
|
||||
return [execErr?.stdout, execErr?.stderr]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the local `gh` CLI is authenticated to, lowercased. Cached briefly so a
|
||||
* `gh auth login --hostname <ghes>` performed after startup is picked up without
|
||||
* spawning `gh auth status` on every provider-detection poll. Failures are not
|
||||
* cached so a later probe (gh installed, tunnel ready) can still discover hosts.
|
||||
* Whether `gh` is authenticated to `host` from the repository's own runtime.
|
||||
*
|
||||
* The probe runs `gh auth status --hostname <host>` with the repo's execution
|
||||
* options (cwd / WSL distro, or SSH-local like the create path), so a GHES login
|
||||
* stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred
|
||||
* from it — is honored instead of the host/default-distro gh. Cached briefly per
|
||||
* runtime+host so provider-detection polling does not re-spawn gh each time.
|
||||
*/
|
||||
export async function getAuthenticatedGitHubHosts(
|
||||
connectionId?: string | null
|
||||
): Promise<ReadonlySet<string>> {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
export async function isGitHubHostAuthenticated(
|
||||
host: string,
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
const normalizedHost = host.toLowerCase()
|
||||
const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}`
|
||||
const now = Date.now()
|
||||
const cached = authenticatedHostsCache.get(key)
|
||||
const cached = hostAuthCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.hosts
|
||||
return cached.authenticated
|
||||
}
|
||||
let text = ''
|
||||
const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
let authenticated: boolean
|
||||
try {
|
||||
// Why: `gh auth status` reads gh's own config, so it is host-scoped rather
|
||||
// than cwd-scoped — no repo context is needed to enumerate logged-in hosts.
|
||||
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'])
|
||||
text = `${stdout}\n${stderr}`
|
||||
} catch (err) {
|
||||
// gh exits non-zero when any host has a token problem but still prints the
|
||||
// per-host status we parse; recover its output before giving up.
|
||||
const execErr = err as { stdout?: unknown; stderr?: unknown }
|
||||
text = `${String(execErr?.stdout ?? '')}\n${String(execErr?.stderr ?? '')}`.trim()
|
||||
if (!text) {
|
||||
return new Set()
|
||||
await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions)
|
||||
authenticated = true
|
||||
} catch (error) {
|
||||
const output = ghCommandOutput(error)
|
||||
if (!output) {
|
||||
// Indeterminate (gh missing / spawn failure) — do not cache so a later
|
||||
// probe (gh installed, tunnel ready, token added) can recover.
|
||||
return false
|
||||
}
|
||||
// gh exits non-zero when a host has a token problem but still prints the
|
||||
// per-host status; treat the host as GitHub only when it is actually listed.
|
||||
authenticated = parseAuthStatus(output).some(
|
||||
(account) => account.host.toLowerCase() === normalizedHost
|
||||
)
|
||||
}
|
||||
const hosts = new Set(parseAuthStatus(text).map((account) => account.host.toLowerCase()))
|
||||
if (hosts.size === 0) {
|
||||
return hosts
|
||||
}
|
||||
authenticatedHostsCache.set(key, { hosts, expiresAt: now + AUTHENTICATED_HOSTS_TTL_MS })
|
||||
return hosts
|
||||
hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS })
|
||||
return authenticated
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +108,8 @@ export async function getEnterpriseGitHubRepoSlug(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<GitHubEnterpriseRepoSlug | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
|
||||
const localGitOptions = getHostedReviewLocalGitOptions(options)
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
let remoteUrl: string | null
|
||||
try {
|
||||
remoteUrl = await getRemoteUrlForRepo(context, 'origin')
|
||||
@@ -96,8 +120,11 @@ export async function getEnterpriseGitHubRepoSlug(
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
return null
|
||||
}
|
||||
const authenticatedHosts = await getAuthenticatedGitHubHosts(connectionId)
|
||||
return authenticatedHosts.has(identity.host)
|
||||
? { owner: identity.owner, repo: identity.repo, host: identity.host }
|
||||
: null
|
||||
const authenticated = await isGitHubHostAuthenticated(
|
||||
identity.host,
|
||||
repoPath,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user