Files
orca/src/main/source-control/forge-provider.test.ts
T
Brennan Benson 2f660a6028 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.
2026-07-13 18:06:06 -07:00

372 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
createGitHubPullRequestMock,
createGitLabMergeRequestMock,
createAzureDevOpsPullRequestMock,
createGiteaPullRequestMock,
getAzureDevOpsRepoSlugMock,
getBitbucketRepoSlugMock,
getGiteaRepoSlugMock,
getMergeRequestForBranchMock,
getProjectSlugMock,
getPRForBranchOutcomeMock,
getRepoSlugMock,
getEnterpriseGitHubRepoSlugMock
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
createGitLabMergeRequestMock: vi.fn(),
createAzureDevOpsPullRequestMock: vi.fn(),
createGiteaPullRequestMock: vi.fn(),
getAzureDevOpsRepoSlugMock: vi.fn(),
getBitbucketRepoSlugMock: vi.fn(),
getGiteaRepoSlugMock: vi.fn(),
getMergeRequestForBranchMock: vi.fn(),
getProjectSlugMock: vi.fn(),
getPRForBranchOutcomeMock: vi.fn(),
getRepoSlugMock: vi.fn(),
getEnterpriseGitHubRepoSlugMock: vi.fn()
}))
vi.mock('../gitlab/client', () => ({
getProjectSlug: getProjectSlugMock,
getMergeRequestForBranch: getMergeRequestForBranchMock,
getMergeRequest: vi.fn()
}))
vi.mock('../gitlab/merge-request-creation', () => ({
createGitLabMergeRequest: createGitLabMergeRequestMock
}))
vi.mock('../github/client', () => ({
createGitHubPullRequest: createGitHubPullRequestMock,
getRepoSlug: getRepoSlugMock,
getPRForBranchOutcome: getPRForBranchOutcomeMock
}))
vi.mock('../github/github-enterprise-repository', () => ({
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock
}))
vi.mock('../bitbucket/client', () => ({
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
getBitbucketPullRequestForBranch: vi.fn(),
getBitbucketPullRequest: vi.fn()
}))
vi.mock('../azure-devops/client', () => ({
getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock,
getAzureDevOpsPullRequestForBranch: vi.fn(),
getAzureDevOpsPullRequest: vi.fn()
}))
vi.mock('../azure-devops/pull-request-creation', () => ({
createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock
}))
vi.mock('../gitea/client', () => ({
getGiteaRepoSlug: getGiteaRepoSlugMock,
getGiteaPullRequestForBranch: vi.fn(),
getGiteaPullRequest: vi.fn()
}))
vi.mock('../gitea/pull-request-creation', () => ({
createGiteaPullRequest: createGiteaPullRequestMock
}))
import {
FORGE_PROVIDERS,
detectHostedReviewProvider,
getForgeProviderById,
getForgeProviderForRepository
} from './forge-provider'
describe('forge provider interface', () => {
beforeEach(() => {
createGitHubPullRequestMock.mockReset()
createGitLabMergeRequestMock.mockReset()
createAzureDevOpsPullRequestMock.mockReset()
createGiteaPullRequestMock.mockReset()
getAzureDevOpsRepoSlugMock.mockReset()
getBitbucketRepoSlugMock.mockReset()
getGiteaRepoSlugMock.mockReset()
getMergeRequestForBranchMock.mockReset()
getProjectSlugMock.mockReset()
getPRForBranchOutcomeMock.mockReset()
getRepoSlugMock.mockReset()
getEnterpriseGitHubRepoSlugMock.mockReset()
})
it('preserves the existing hosted provider detection order', async () => {
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'team/orca' })
getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca' })
await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitlab')
await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({
id: 'gitlab'
})
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])
).toEqual([
['gitlab', true],
['github', true],
['bitbucket', false],
['azure-devops', true],
['gitea', true]
])
createGitHubPullRequestMock.mockResolvedValue({
ok: true,
number: 12,
url: 'https://github.com/team/orca/pull/12'
})
const provider = getForgeProviderById('github')
await expect(
provider.createReview?.('/repo', {
provider: 'github',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
})
).resolves.toEqual({
ok: true,
number: 12,
url: 'https://github.com/team/orca/pull/12'
})
expect(createGitHubPullRequestMock).toHaveBeenCalledWith('/repo', {
provider: 'github',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
})
})
it('routes GitLab review creation through the shared provider contract', async () => {
createGitLabMergeRequestMock.mockResolvedValue({
ok: true,
number: 44,
url: 'https://gitlab.com/team/orca/-/merge_requests/44'
})
const provider = getForgeProviderById('gitlab')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'gitlab',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 44,
url: 'https://gitlab.com/team/orca/-/merge_requests/44'
})
expect(createGitLabMergeRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitlab',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('routes Azure DevOps review creation through the shared provider contract', async () => {
createAzureDevOpsPullRequestMock.mockResolvedValue({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
const provider = getForgeProviderById('azure-devops')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 88,
url: 'https://dev.azure.com/acme/Project/_git/orca/pullrequest/88'
})
expect(createAzureDevOpsPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'azure-devops',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('routes Gitea review creation through the shared provider contract', async () => {
createGiteaPullRequestMock.mockResolvedValue({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
const provider = getForgeProviderById('gitea')
await expect(
provider.createReview?.(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 19,
url: 'https://git.example.com/team/orca/pulls/19'
})
expect(createGiteaPullRequestMock).toHaveBeenCalledWith(
'/repo',
{
provider: 'gitea',
base: 'main',
head: 'feature/provider-interface',
title: 'Add provider interface'
},
'ssh-1'
)
})
it('adapts GitHub branch lookup through the shared provider contract', async () => {
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'found',
fetchedAt: 1,
pr: {
number: 7,
title: 'Provider branch',
state: 'open',
url: 'https://github.com/team/orca/pull/7',
checksStatus: 'success',
updatedAt: '2026-05-29T00:00:00.000Z',
mergeable: 'MERGEABLE'
}
})
await expect(
getForgeProviderById('github').getReviewForBranch({
repoPath: '/repo',
connectionId: 'ssh-1',
branch: '',
fallbackReviewNumber: 7
})
).resolves.toMatchObject({
provider: 'github',
number: 7,
status: 'success'
})
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith('/repo', '', null, 'ssh-1', 7, {
acceptMergedFallbackPR: true,
currentHeadOid: null
})
})
it('passes the worktree HEAD oid through to the GitHub lookup', async () => {
getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 })
await getForgeProviderById('github').getReviewForBranch({
repoPath: '/repo',
connectionId: null,
branch: 'feature/x',
githubCurrentHeadOid: 'abc1234'
})
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith('/repo', 'feature/x', null, null, null, {
currentHeadOid: 'abc1234'
})
})
it('returns null for a confirmed GitHub no-pr lookup', async () => {
getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 })
await expect(
getForgeProviderById('github').getReviewForBranch({
repoPath: '/repo',
connectionId: null,
branch: 'feature/x'
})
).resolves.toBeNull()
})
it('throws on a GitHub upstream error instead of reporting no review', async () => {
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'network',
message: 'connection reset',
fetchedAt: 1
})
await expect(
getForgeProviderById('github').getReviewForBranch({
repoPath: '/repo',
connectionId: null,
branch: 'feature/x'
})
).rejects.toThrow(/network/)
})
})