mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(source-control): route GitHub Enterprise Server remotes to the GitHub provider for PR creation (#8312) (#8603)
* fix(source-control): route GHES remotes to the GitHub provider for PR creation A GitHub Enterprise Server user could not submit a PR — Orca demanded ORCA_GITEA_TOKEN — while issue sync worked fine (#8312). Root cause: GitHub owner/repo resolution (parseGitHubOwnerRepo) hard-rejects any host that is not literally github.com. A GHES remote lives on a custom host, so GitHub's forge resolveRepository returned null and provider detection fell through the list to Gitea, whose KNOWN_NON_GITEA_HOSTS denylist cannot enumerate arbitrary GHES domains. Issue sync was unaffected because gh issue/pr list run with cwd=repoPath and let gh resolve the GHES host natively. Fix mirrors GitLab self-hosted detection (getGlabKnownHosts): a new getEnterpriseGitHubRepoSlug resolves a custom-host origin to owner/repo only when gh is authenticated to that host — gh only ever manages GitHub/GHES credentials, so a logged-in host is definitively GitHub. Wired into: - forge-provider GitHub resolveRepository (fallback after github.com miss), so detection claims GHES before Gitea is consulted; - createGitHubPullRequest owner/repo resolution; - isGitHubAuthenticated, which now probes the repo's real host instead of a hardcoded --hostname github.com. github.com repos keep the cached getRepoSlug fast path and never spawn the extra gh auth probe. * 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. * perf(source-control): drop redundant GHES gh auth probe in eligibility Review follow-up. Detection only routes a GHES remote to the GitHub provider after getEnterpriseGitHubRepoSlug has confirmed gh is authenticated to its host, so isGitHubAuthenticated can trust a non-null slug as authenticated and skip a second, rate-limited `gh auth status` spawn per eligibility poll. Reaching the github.com probe now implies the remote is github.com. Tests assert the enterprise path fires no redundant gh 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({
|
||||
|
||||
+27
-11
@@ -79,6 +79,7 @@ import {
|
||||
rememberGhCwdResolutionFailure
|
||||
} from './gh-cwd-repo-negative-cache'
|
||||
import type { GitHubRepoContext } from './github-repository-identity'
|
||||
import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository'
|
||||
export { _resetOwnerRepoCache } from './gh-utils'
|
||||
export {
|
||||
getIssue,
|
||||
@@ -1750,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
|
||||
@@ -1771,7 +1785,7 @@ async function findOpenPRByHeadBase(args: {
|
||||
'pr',
|
||||
'list',
|
||||
'--repo',
|
||||
`${args.ownerRepo.owner}/${args.ownerRepo.repo}`,
|
||||
args.repoArg,
|
||||
'--head',
|
||||
args.head,
|
||||
'--base',
|
||||
@@ -1844,11 +1858,11 @@ export async function createGitHubPullRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const ownerRepo = await getOwnerRepo(
|
||||
repoPath,
|
||||
connectionId,
|
||||
...hostedReviewLocalGitOptionArgs(options)
|
||||
)
|
||||
// Why: github.com-only slug parsing returns null for GHES, so fall back to the
|
||||
// enterprise resolver (gh-authenticated custom host) before giving up (#8312).
|
||||
const ownerRepo =
|
||||
(await getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))) ??
|
||||
(await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options))
|
||||
if (!ownerRepo) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1856,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
|
||||
@@ -1888,7 +1904,7 @@ export async function createGitHubPullRequest(
|
||||
'pr',
|
||||
'create',
|
||||
'--repo',
|
||||
`${ownerRepo.owner}/${ownerRepo.repo}`,
|
||||
repoArg,
|
||||
'--base',
|
||||
base,
|
||||
'--title',
|
||||
@@ -1917,7 +1933,7 @@ export async function createGitHubPullRequest(
|
||||
const found = head
|
||||
? await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
repoArg,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
@@ -1941,7 +1957,7 @@ export async function createGitHubPullRequest(
|
||||
) {
|
||||
const existing = await findOpenPRByHeadBase({
|
||||
repoPath,
|
||||
ownerRepo,
|
||||
repoArg,
|
||||
head,
|
||||
base,
|
||||
connectionId,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: 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', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetGitHubHostAuthCache,
|
||||
getEnterpriseGitHubRepoSlug,
|
||||
isGitHubHostAuthenticated
|
||||
} from './github-enterprise-repository'
|
||||
|
||||
function mockOriginRemote(url: string): void {
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: `${url}\n`, stderr: '' }
|
||||
}
|
||||
return { stdout: '', 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()
|
||||
_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')
|
||||
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',
|
||||
repo: 'orca',
|
||||
host: '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 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 () => {
|
||||
mockOriginRemote('https://github.com/team/orca.git')
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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')
|
||||
mockHostNotAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unparseable remote', async () => {
|
||||
mockOriginRemote('not-a-remote-url')
|
||||
mockHostAuthenticated()
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the origin remote lookup fails', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote'))
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGitHubHostAuthenticated', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetGitHubHostAuthCache()
|
||||
})
|
||||
|
||||
it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => {
|
||||
mockHostAuthenticated()
|
||||
|
||||
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 per runtime+host so detection polling does not re-spawn gh', async () => {
|
||||
mockHostAuthenticated()
|
||||
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
|
||||
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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: '',
|
||||
stderr:
|
||||
'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true)
|
||||
})
|
||||
|
||||
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 isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false)
|
||||
|
||||
mockHostAuthenticated()
|
||||
expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ghExecFileAsync } from '../git/runner'
|
||||
import type { GitHubOwnerRepo } from '../../shared/types'
|
||||
import {
|
||||
getHostedReviewLocalGitOptions,
|
||||
type HostedReviewExecutionOptions
|
||||
} from '../source-control/hosted-review-git-options'
|
||||
import { parseAuthStatus } from './auth-diagnose'
|
||||
import {
|
||||
ghRepoExecOptions,
|
||||
getRemoteUrlForRepo,
|
||||
githubRepoContext,
|
||||
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 `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 HostAuthCacheEntry = {
|
||||
authenticated: boolean
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const hostAuthCache = new Map<string, HostAuthCacheEntry>()
|
||||
|
||||
// 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 _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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 = hostAuthCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.authenticated
|
||||
}
|
||||
const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
|
||||
let authenticated: boolean
|
||||
try {
|
||||
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
|
||||
)
|
||||
}
|
||||
hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS })
|
||||
return authenticated
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom
|
||||
* host the user is gh-authenticated to. Returns null for github.com (already
|
||||
* handled by {@link getOwnerRepo}) and for hosts gh is not logged in to
|
||||
* (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider
|
||||
* without a GitHub provider stealing another forge's remote.
|
||||
*/
|
||||
export async function getEnterpriseGitHubRepoSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<GitHubEnterpriseRepoSlug | null> {
|
||||
const localGitOptions = getHostedReviewLocalGitOptions(options)
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
let remoteUrl: string | null
|
||||
try {
|
||||
remoteUrl = await getRemoteUrlForRepo(context, 'origin')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
|
||||
if (!identity || identity.host === 'github.com') {
|
||||
return 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