mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +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. * 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.
372 lines
11 KiB
TypeScript
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/)
|
|
})
|
|
})
|