mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
@@ -1844,11 +1845,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,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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 and
|
||||
// `gh auth status` parsing run against controlled command output.
|
||||
vi.mock('../git/runner', () => ({
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_resetAuthenticatedGitHubHostsCache,
|
||||
getAuthenticatedGitHubHosts,
|
||||
getEnterpriseGitHubRepoSlug
|
||||
} 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: '' }
|
||||
})
|
||||
}
|
||||
|
||||
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: '' })
|
||||
}
|
||||
|
||||
describe('getEnterpriseGitHubRepoSlug', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetAuthenticatedGitHubHostsCache()
|
||||
})
|
||||
|
||||
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'])
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a GHES SCP-style SSH remote', async () => {
|
||||
mockOriginRemote('git@github.acme-corp.com:team/orca.git')
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
mockGhAuthStatus(['github.com'])
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unparseable remote', async () => {
|
||||
mockOriginRemote('not-a-remote-url')
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the origin remote lookup fails', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote'))
|
||||
|
||||
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthenticatedGitHubHosts', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
_resetAuthenticatedGitHubHostsCache()
|
||||
})
|
||||
|
||||
it('parses and lowercases the logged-in hosts', async () => {
|
||||
mockGhAuthStatus(['github.com', 'GitHub.Acme-Corp.com'])
|
||||
|
||||
const hosts = await getAuthenticatedGitHubHosts()
|
||||
expect(hosts.has('github.com')).toBe(true)
|
||||
expect(hosts.has('github.acme-corp.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('caches a non-empty result to avoid re-spawning gh per detection poll', async () => {
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
|
||||
await getAuthenticatedGitHubHosts()
|
||||
await getAuthenticatedGitHubHosts()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers hosts from gh output 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: ''
|
||||
})
|
||||
)
|
||||
|
||||
const hosts = await getAuthenticatedGitHubHosts()
|
||||
expect(hosts.has('github.acme-corp.com')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not cache an empty result so a later gh login is discovered', async () => {
|
||||
ghExecFileAsyncMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('not installed'), { stdout: '', stderr: '' })
|
||||
)
|
||||
expect((await getAuthenticatedGitHubHosts()).size).toBe(0)
|
||||
|
||||
mockGhAuthStatus(['github.acme-corp.com'])
|
||||
expect((await getAuthenticatedGitHubHosts()).has('github.acme-corp.com')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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 {
|
||||
getRemoteUrlForRepo,
|
||||
githubRepoContext,
|
||||
parseGitHubRemoteIdentity
|
||||
} 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
|
||||
|
||||
type AuthenticatedHostsCacheEntry = {
|
||||
hosts: ReadonlySet<string>
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const authenticatedHostsCache = new Map<string, AuthenticatedHostsCacheEntry>()
|
||||
|
||||
function connectionCacheKey(connectionId?: string | null): string {
|
||||
return connectionId ?? 'local'
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetAuthenticatedGitHubHostsCache(): void {
|
||||
authenticatedHostsCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function getAuthenticatedGitHubHosts(
|
||||
connectionId?: string | null
|
||||
): Promise<ReadonlySet<string>> {
|
||||
const key = connectionCacheKey(connectionId)
|
||||
const now = Date.now()
|
||||
const cached = authenticatedHostsCache.get(key)
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.hosts
|
||||
}
|
||||
let text = ''
|
||||
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()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 context = githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
|
||||
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 authenticatedHosts = await getAuthenticatedGitHubHosts(connectionId)
|
||||
return authenticatedHosts.has(identity.host)
|
||||
? { owner: identity.owner, repo: identity.repo, host: identity.host }
|
||||
: null
|
||||
}
|
||||
@@ -11,7 +11,8 @@ const {
|
||||
getMergeRequestForBranchMock,
|
||||
getProjectSlugMock,
|
||||
getPRForBranchOutcomeMock,
|
||||
getRepoSlugMock
|
||||
getRepoSlugMock,
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
} = vi.hoisted(() => ({
|
||||
createGitHubPullRequestMock: vi.fn(),
|
||||
createGitLabMergeRequestMock: vi.fn(),
|
||||
@@ -23,7 +24,8 @@ const {
|
||||
getMergeRequestForBranchMock: vi.fn(),
|
||||
getProjectSlugMock: vi.fn(),
|
||||
getPRForBranchOutcomeMock: vi.fn(),
|
||||
getRepoSlugMock: vi.fn()
|
||||
getRepoSlugMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitlab/client', () => ({
|
||||
@@ -42,6 +44,10 @@ vi.mock('../github/client', () => ({
|
||||
getPRForBranchOutcome: getPRForBranchOutcomeMock
|
||||
}))
|
||||
|
||||
vi.mock('../github/github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock
|
||||
}))
|
||||
|
||||
vi.mock('../bitbucket/client', () => ({
|
||||
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
|
||||
getBitbucketPullRequestForBranch: vi.fn(),
|
||||
@@ -88,6 +94,7 @@ describe('forge provider interface', () => {
|
||||
getProjectSlugMock.mockReset()
|
||||
getPRForBranchOutcomeMock.mockReset()
|
||||
getRepoSlugMock.mockReset()
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset()
|
||||
})
|
||||
|
||||
it('preserves the existing hosted provider detection order', async () => {
|
||||
@@ -101,6 +108,45 @@ describe('forge provider interface', () => {
|
||||
expect(getRepoSlugMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects a GitHub Enterprise Server remote as the GitHub provider, not Gitea', async () => {
|
||||
// Regression for #8312: a GHES host is not github.com, so github.com-only
|
||||
// slug parsing returns null. Detection must claim it via the enterprise
|
||||
// resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN.
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
|
||||
await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('github')
|
||||
await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({
|
||||
id: 'github'
|
||||
})
|
||||
// Gitea must never be consulted once GitHub claims the enterprise host.
|
||||
expect(getGiteaRepoSlugMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a genuinely non-GitHub remote for later providers when gh is not authenticated', async () => {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
// gh is not logged in to this host, so the enterprise resolver declines and
|
||||
// the Gitea provider is free to claim its own self-hosted remote.
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue({
|
||||
host: 'gitea.example.com',
|
||||
owner: 'team',
|
||||
repo: 'orca',
|
||||
apiBaseUrl: 'https://gitea.example.com/api/v1',
|
||||
webBaseUrl: 'https://gitea.example.com'
|
||||
})
|
||||
|
||||
await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitea')
|
||||
})
|
||||
|
||||
it('keeps review creation capability scoped to providers with creation support', async () => {
|
||||
expect(
|
||||
FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation])
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../gitea/client'
|
||||
import { createGiteaPullRequest } from '../gitea/pull-request-creation'
|
||||
import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client'
|
||||
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
|
||||
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
|
||||
import { createGitLabMergeRequest } from '../gitlab/merge-request-creation'
|
||||
import {
|
||||
@@ -122,8 +123,25 @@ function unwrapGitHubPRForBranchOutcome(
|
||||
const gitHubForgeProvider = {
|
||||
id: 'github',
|
||||
supportsReviewCreation: true,
|
||||
resolveRepository: (context) =>
|
||||
getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)),
|
||||
resolveRepository: async (context) => {
|
||||
const slug = await getRepoSlug(
|
||||
context.repoPath,
|
||||
context.connectionId,
|
||||
...hostedReviewExecutionArgs(context)
|
||||
)
|
||||
if (slug) {
|
||||
return slug
|
||||
}
|
||||
// Why: GHES remotes live on a custom host, so github.com-only slug parsing
|
||||
// misses them and detection would otherwise fall through to Gitea (#8312).
|
||||
// Claim the repo when gh is authenticated to its host — the same signal
|
||||
// GitLab uses for self-hosted instances.
|
||||
return getEnterpriseGitHubRepoSlug(
|
||||
context.repoPath,
|
||||
context.connectionId,
|
||||
...hostedReviewExecutionArgs(context)
|
||||
)
|
||||
},
|
||||
async getReviewForBranch(input) {
|
||||
const fallbackReviewNumber =
|
||||
input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null
|
||||
|
||||
@@ -17,7 +17,8 @@ const {
|
||||
glabExecFileAsyncMock,
|
||||
gitExecFileAsyncMock,
|
||||
getUpstreamStatusMock,
|
||||
getSshGitProviderMock
|
||||
getSshGitProviderMock,
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
} = vi.hoisted(() => ({
|
||||
createGitHubPullRequestMock: vi.fn(),
|
||||
createGitLabMergeRequestMock: vi.fn(),
|
||||
@@ -35,7 +36,8 @@ const {
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getUpstreamStatusMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
@@ -44,6 +46,10 @@ vi.mock('../github/client', () => ({
|
||||
getPRForBranch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/github-enterprise-repository', () => ({
|
||||
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock
|
||||
}))
|
||||
|
||||
vi.mock('../gitlab/client', () => ({
|
||||
getProjectSlug: getProjectSlugMock,
|
||||
getMergeRequestForBranch: vi.fn(),
|
||||
@@ -129,7 +135,8 @@ function resetMocks(): void {
|
||||
glabExecFileAsyncMock,
|
||||
gitExecFileAsyncMock,
|
||||
getUpstreamStatusMock,
|
||||
getSshGitProviderMock
|
||||
getSshGitProviderMock,
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
]) {
|
||||
mock.mockReset()
|
||||
}
|
||||
@@ -141,6 +148,22 @@ function mockGitHubProvider(): void {
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
// GHES: github.com-only slug parsing misses the custom host, so the enterprise
|
||||
// resolver claims the repo and reports the host for the gh auth probe (#8312).
|
||||
function mockGitHubEnterpriseProvider(): void {
|
||||
getProjectSlugMock.mockResolvedValue(null)
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
host: 'github.acme-corp.com'
|
||||
})
|
||||
}
|
||||
|
||||
function mockGitLabProvider(): void {
|
||||
@@ -349,6 +372,31 @@ describe('createHostedReview', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a pull request on a GitHub Enterprise Server remote (#8312)', async () => {
|
||||
mockGitHubEnterpriseProvider()
|
||||
|
||||
await expect(
|
||||
createHostedReview('/repo', {
|
||||
provider: 'github',
|
||||
base: 'main',
|
||||
head: 'feature',
|
||||
title: 'Feature'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
number: 12,
|
||||
url: 'https://github.com/acme/orca/pull/12'
|
||||
})
|
||||
|
||||
// The auth probe must target the GHES host, not the hardcoded github.com.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.acme-corp.com'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
expect(createGitHubPullRequestMock).toHaveBeenCalled()
|
||||
expect(createGiteaPullRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a GitLab merge request after fresh main-process validation passes', async () => {
|
||||
mockGitLabProvider()
|
||||
|
||||
@@ -636,6 +684,33 @@ describe('getHostedReviewCreationEligibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => {
|
||||
mockGitHubEnterpriseProvider()
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/create-pr',
|
||||
base: 'origin/main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'github',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null
|
||||
})
|
||||
|
||||
// The auth gate must probe the GHES host, not the hardcoded github.com.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'github.acme-corp.com'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => {
|
||||
const remoteGit = {
|
||||
exec: vi.fn(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../shared/hosted-review-creation-providers'
|
||||
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
|
||||
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
|
||||
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
|
||||
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
|
||||
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
|
||||
import type { GitUpstreamStatus } from '../../shared/types'
|
||||
@@ -59,10 +60,15 @@ async function isGitHubAuthenticated(
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
): Promise<boolean> {
|
||||
// Why: a GHES repo authenticates against its own custom host, so a hardcoded
|
||||
// github.com probe reports "not authenticated" for Enterprise users (#8312).
|
||||
// Resolve the repo's actual GitHub host, defaulting to github.com.
|
||||
const enterprise = await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)
|
||||
const hostname = enterprise?.host ?? 'github.com'
|
||||
await acquire()
|
||||
try {
|
||||
await ghExecFileAsync(
|
||||
['auth', 'status', '--hostname', 'github.com'],
|
||||
['auth', 'status', '--hostname', hostname],
|
||||
connectionId ? {} : { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) }
|
||||
)
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user