mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Unify owner validation across project pickers and repository overrides. Preserve EMU usernames in API and auth-status branch-prefix resolution, with regression coverage. Co-authored-by: Neil <neil@stably.ai>
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as SshGitDispatch from '../providers/ssh-git-dispatch'
|
|
import type * as GitHubEnterpriseRepository from './github-enterprise-repository'
|
|
import type * as GhUtils from './gh-utils'
|
|
|
|
const {
|
|
getEnterpriseGitHubRepoSlugMock,
|
|
getOwnerRepoMock,
|
|
getOwnerRepoForRemoteMock,
|
|
getSshGitProviderGenerationMock,
|
|
isGitHubHostAuthenticatedMock,
|
|
shouldProbeGitRemoteMock
|
|
} = vi.hoisted(() => ({
|
|
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
|
getOwnerRepoMock: vi.fn(),
|
|
getOwnerRepoForRemoteMock: vi.fn(),
|
|
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
|
isGitHubHostAuthenticatedMock: vi.fn(),
|
|
shouldProbeGitRemoteMock: vi.fn(async () => true)
|
|
}))
|
|
|
|
vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({
|
|
...(await importOriginal<typeof SshGitDispatch>()),
|
|
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
|
}))
|
|
|
|
vi.mock('./gh-utils', async (importOriginal) => ({
|
|
...(await importOriginal<typeof GhUtils>()),
|
|
getOwnerRepo: getOwnerRepoMock,
|
|
// Why: origin resolution uses getOwnerRepoForRemote, not getOwnerRepo.
|
|
getOwnerRepoForRemote: getOwnerRepoForRemoteMock
|
|
}))
|
|
|
|
vi.mock('./github-enterprise-repository', async (importOriginal) => ({
|
|
...(await importOriginal<typeof GitHubEnterpriseRepository>()),
|
|
getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock,
|
|
isGitHubHostAuthenticated: isGitHubHostAuthenticatedMock
|
|
}))
|
|
|
|
vi.mock('../git/remote-name-listing', () => ({
|
|
shouldProbeGitRemote: shouldProbeGitRemoteMock
|
|
}))
|
|
|
|
import {
|
|
_resetOriginGitHubApiRepositoryCache,
|
|
getGitHubApiRepositoryForRemote,
|
|
getIssueGitHubApiRepository,
|
|
getOriginGitHubApiRepository,
|
|
githubHostExecOptions,
|
|
resolveGitHubApiRepository,
|
|
resolveGitHubApiRepositoryCandidates,
|
|
resolveGitHubRepoExecution
|
|
} from './github-api-repository'
|
|
|
|
beforeEach(() => {
|
|
_resetOriginGitHubApiRepositoryCache()
|
|
getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null)
|
|
getOwnerRepoMock.mockReset().mockResolvedValue(null)
|
|
getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null)
|
|
getSshGitProviderGenerationMock.mockReset().mockReturnValue(0)
|
|
isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false)
|
|
shouldProbeGitRemoteMock.mockReset().mockResolvedValue(true)
|
|
})
|
|
|
|
describe('githubHostExecOptions', () => {
|
|
it('pins every known repository host', () => {
|
|
expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.com' })).toEqual({
|
|
host: 'github.com'
|
|
})
|
|
expect(
|
|
githubHostExecOptions({ owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' })
|
|
).toEqual({ host: 'github.acme-corp.com' })
|
|
})
|
|
|
|
it('does not invent a host when repository identity is unavailable or legacy', () => {
|
|
expect(githubHostExecOptions({ owner: 'acme', repo: 'widgets' })).toEqual({})
|
|
expect(githubHostExecOptions(null)).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe('resolveGitHubRepoExecution', () => {
|
|
it('combines local repository and GitHub host execution options', async () => {
|
|
const ownerRepo = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com:8443' }
|
|
isGitHubHostAuthenticatedMock.mockResolvedValue(true)
|
|
|
|
await expect(
|
|
resolveGitHubRepoExecution('/repo', ownerRepo, null, { wslDistro: 'Ubuntu' })
|
|
).resolves.toEqual({
|
|
ownerRepo,
|
|
ghOptions: {
|
|
cwd: '/repo',
|
|
wslDistro: 'Ubuntu',
|
|
host: 'github.acme-corp.com:8443'
|
|
}
|
|
})
|
|
expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith(
|
|
'github.acme-corp.com:8443',
|
|
'/repo',
|
|
null,
|
|
{ wslDistro: 'Ubuntu' }
|
|
)
|
|
})
|
|
|
|
it('rejects an explicit Enterprise host absent from the local gh auth inventory', async () => {
|
|
await expect(
|
|
resolveGitHubApiRepository(
|
|
'/remote/repo',
|
|
{
|
|
owner: 'acme',
|
|
repo: 'widgets',
|
|
host: 'evil.example.test'
|
|
},
|
|
'ssh-1'
|
|
)
|
|
).resolves.toBeNull()
|
|
|
|
expect(isGitHubHostAuthenticatedMock).toHaveBeenCalledWith(
|
|
'evil.example.test',
|
|
'/remote/repo',
|
|
'ssh-1',
|
|
{}
|
|
)
|
|
})
|
|
|
|
it.each([
|
|
{ owner: 'acme%2Fadmin', repo: 'widgets' },
|
|
{ owner: 'acme', repo: '..' }
|
|
])('rejects repository overrides that could alter a gh REST path: %o', async (repository) => {
|
|
await expect(
|
|
resolveGitHubApiRepository('/repo', {
|
|
...repository,
|
|
host: 'github.com'
|
|
})
|
|
).resolves.toBeNull()
|
|
|
|
expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each(['acme', 'octocat_acme'])(
|
|
'normalizes github.com for %s without an auth inventory probe',
|
|
async (owner) => {
|
|
await expect(
|
|
resolveGitHubApiRepository('/repo', {
|
|
owner,
|
|
repo: 'widgets',
|
|
host: ' GitHub.COM '
|
|
})
|
|
).resolves.toEqual({ owner, repo: 'widgets', host: 'github.com' })
|
|
|
|
expect(isGitHubHostAuthenticatedMock).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
|
|
it('backfills the origin host for a host-less caller-specific resolver', async () => {
|
|
const ownerRepo = { owner: 'upstream', repo: 'widgets' }
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'widgets' })
|
|
|
|
await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({
|
|
ownerRepo: { ...ownerRepo, host: 'github.com' },
|
|
ghOptions: { cwd: '/repo', host: 'github.com' }
|
|
})
|
|
})
|
|
|
|
it('backfills an Enterprise origin host for a host-less caller-specific resolver', async () => {
|
|
const ownerRepo = { owner: 'upstream', repo: 'widgets' }
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue({
|
|
owner: 'fork',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
})
|
|
|
|
await expect(resolveGitHubRepoExecution('/repo', async () => ownerRepo)).resolves.toEqual({
|
|
ownerRepo: { ...ownerRepo, host: 'github.acme-corp.com' },
|
|
ghOptions: { cwd: '/repo', host: 'github.acme-corp.com' }
|
|
})
|
|
})
|
|
|
|
it('rejects a host-less caller-specific resolver for a connection-backed repository', async () => {
|
|
await expect(
|
|
resolveGitHubRepoExecution(
|
|
'/remote/repo',
|
|
async () => ({ owner: 'upstream', repo: 'widgets' }),
|
|
'ssh-1'
|
|
)
|
|
).resolves.toEqual({ ownerRepo: null, ghOptions: {} })
|
|
})
|
|
|
|
it('rejects a host-less caller-specific resolver for an unresolved local repository', async () => {
|
|
await expect(
|
|
resolveGitHubRepoExecution('/repo', async () => ({ owner: 'upstream', repo: 'widgets' }))
|
|
).resolves.toEqual({ ownerRepo: null, ghOptions: { cwd: '/repo' } })
|
|
})
|
|
|
|
it('preserves an authoritative null from a caller-specific resolver', async () => {
|
|
getOwnerRepoMock.mockResolvedValue({ owner: 'origin', repo: 'widgets' })
|
|
|
|
await expect(resolveGitHubRepoExecution('/repo', async () => null)).resolves.toEqual({
|
|
ownerRepo: null,
|
|
ghOptions: { cwd: '/repo' }
|
|
})
|
|
|
|
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('origin repository cache', () => {
|
|
it('isolates Enterprise identity across SSH provider generations', async () => {
|
|
const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }
|
|
const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' }
|
|
getEnterpriseGitHubRepoSlugMock
|
|
.mockResolvedValueOnce(beforeReconnect)
|
|
.mockResolvedValueOnce(afterReconnect)
|
|
|
|
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
|
beforeReconnect
|
|
)
|
|
getSshGitProviderGenerationMock.mockReturnValue(1)
|
|
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
|
afterReconnect
|
|
)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => {
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined)
|
|
|
|
await expect(
|
|
getGitHubApiRepositoryForRemote(
|
|
'/remote/repo',
|
|
'origin',
|
|
'ssh-1',
|
|
{},
|
|
{
|
|
requireVerifiedSshProbe: true
|
|
}
|
|
)
|
|
).rejects.toThrow('GitHub repository identity is unverifiable.')
|
|
})
|
|
|
|
it('does not reuse a tolerant SSH miss for verified candidate discovery', async () => {
|
|
const enterprise = {
|
|
owner: 'acme',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(enterprise)
|
|
|
|
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toBeNull()
|
|
await expect(
|
|
getGitHubApiRepositoryForRemote(
|
|
'/remote/repo',
|
|
'origin',
|
|
'ssh-1',
|
|
{},
|
|
{
|
|
requireVerifiedSshProbe: true
|
|
}
|
|
)
|
|
).resolves.toEqual(enterprise)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not cache an indeterminate Enterprise auth probe', async () => {
|
|
const enterprise = {
|
|
owner: 'acme',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
getEnterpriseGitHubRepoSlugMock
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(enterprise)
|
|
|
|
await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull()
|
|
await expect(getOriginGitHubApiRepository('/repo')).resolves.toEqual(enterprise)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('still caches a definitive negative Enterprise probe', async () => {
|
|
await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull()
|
|
await expect(getOriginGitHubApiRepository('/repo')).resolves.toBeNull()
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not reuse a cached answer after the SSH provider reconnects', async () => {
|
|
const oldRepository = {
|
|
owner: 'old-owner',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
const newRepository = {
|
|
owner: 'new-owner',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
getSshGitProviderGenerationMock.mockReturnValue(1)
|
|
getEnterpriseGitHubRepoSlugMock
|
|
.mockResolvedValueOnce(oldRepository)
|
|
.mockResolvedValueOnce(newRepository)
|
|
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(oldRepository)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
|
|
|
getSshGitProviderGenerationMock.mockReturnValue(2)
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('does not reuse a cached negative after the SSH provider reconnects', async () => {
|
|
const repository = {
|
|
owner: 'acme',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
getSshGitProviderGenerationMock.mockReturnValue(1)
|
|
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(repository)
|
|
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toBeNull()
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
|
|
|
getSshGitProviderGenerationMock.mockReturnValue(2)
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(repository)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('isolates an in-flight probe from an older SSH provider generation', async () => {
|
|
const oldRepository = {
|
|
owner: 'old-owner',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
const newRepository = {
|
|
owner: 'new-owner',
|
|
repo: 'widgets',
|
|
host: 'github.acme-corp.com'
|
|
}
|
|
let resolveOldProbe: (repository: typeof oldRepository) => void = () => {}
|
|
const oldProbeResult = new Promise<typeof oldRepository>((resolve) => {
|
|
resolveOldProbe = resolve
|
|
})
|
|
getEnterpriseGitHubRepoSlugMock
|
|
.mockImplementationOnce(() => oldProbeResult)
|
|
.mockResolvedValueOnce(newRepository)
|
|
getSshGitProviderGenerationMock.mockReturnValue(1)
|
|
|
|
const oldProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
|
|
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1))
|
|
|
|
getSshGitProviderGenerationMock.mockReturnValue(2)
|
|
const newProbe = getOriginGitHubApiRepository('/repo', 'ssh-1')
|
|
await vi.waitFor(() => expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2))
|
|
await expect(newProbe).resolves.toEqual(newRepository)
|
|
|
|
resolveOldProbe(oldRepository)
|
|
await expect(oldProbe).resolves.toEqual(oldRepository)
|
|
await expect(getOriginGitHubApiRepository('/repo', 'ssh-1')).resolves.toEqual(newRepository)
|
|
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
describe('skip missing upstream remote probes', () => {
|
|
it('starts the issue origin probe before checking whether upstream exists', async () => {
|
|
let releaseRemoteProbe: (value: boolean) => void = () => undefined
|
|
shouldProbeGitRemoteMock.mockReturnValue(
|
|
new Promise<boolean>((resolve) => {
|
|
releaseRemoteProbe = resolve
|
|
})
|
|
)
|
|
let originStarted = false
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => {
|
|
if (remote === 'origin') {
|
|
originStarted = true
|
|
return { owner: 'fork', repo: 'orca' }
|
|
}
|
|
return { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
|
|
const resultPromise = getIssueGitHubApiRepository('/repo')
|
|
expect(originStarted).toBe(true)
|
|
|
|
releaseRemoteProbe(true)
|
|
await expect(resultPromise).resolves.toEqual({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
host: 'github.com'
|
|
})
|
|
})
|
|
|
|
it('does not probe upstream for issue identity when that remote is absent', async () => {
|
|
shouldProbeGitRemoteMock.mockResolvedValue(false)
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) =>
|
|
remote === 'origin' ? { owner: 'acme', repo: 'widgets' } : null
|
|
)
|
|
|
|
await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({
|
|
owner: 'acme',
|
|
repo: 'widgets',
|
|
host: 'github.com'
|
|
})
|
|
expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).toEqual(['origin'])
|
|
})
|
|
|
|
it('still probes upstream for issue identity when that remote is present', async () => {
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) =>
|
|
remote === 'upstream' ? { owner: 'stablyai', repo: 'orca' } : { owner: 'fork', repo: 'orca' }
|
|
)
|
|
|
|
await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
host: 'github.com'
|
|
})
|
|
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo', 'upstream', undefined, {})
|
|
})
|
|
|
|
it('observes a rejected origin probe when upstream resolves the issue repository', async () => {
|
|
const originError = new Error('origin probe failed')
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => {
|
|
if (remote === 'origin') {
|
|
throw originError
|
|
}
|
|
return { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
|
|
await expect(getIssueGitHubApiRepository('/repo')).resolves.toEqual({
|
|
owner: 'stablyai',
|
|
repo: 'orca',
|
|
host: 'github.com'
|
|
})
|
|
})
|
|
|
|
it('preserves a rejected origin probe when upstream cannot resolve the issue repository', async () => {
|
|
const originError = new Error('origin probe failed')
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => {
|
|
if (remote === 'origin') {
|
|
throw originError
|
|
}
|
|
return null
|
|
})
|
|
|
|
await expect(getIssueGitHubApiRepository('/repo')).rejects.toBe(originError)
|
|
})
|
|
|
|
it('does not probe upstream for PR candidates when that remote is absent', async () => {
|
|
shouldProbeGitRemoteMock.mockResolvedValue(false)
|
|
getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'fork', repo: 'orca' })
|
|
|
|
await expect(resolveGitHubApiRepositoryCandidates('/repo')).resolves.toEqual({
|
|
candidates: [{ owner: 'fork', repo: 'orca', host: 'github.com' }],
|
|
headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' }
|
|
})
|
|
expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).toEqual(['origin'])
|
|
})
|
|
|
|
it('observes and propagates a verified origin probe failure while listing remotes', async () => {
|
|
let releaseRemoteProbe: (value: boolean) => void = () => undefined
|
|
shouldProbeGitRemoteMock.mockReturnValue(
|
|
new Promise<boolean>((resolve) => {
|
|
releaseRemoteProbe = resolve
|
|
})
|
|
)
|
|
const originError = new Error('origin probe failed')
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) => {
|
|
if (remote === 'origin') {
|
|
throw originError
|
|
}
|
|
return { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
|
|
const resultPromise = resolveGitHubApiRepositoryCandidates('/repo')
|
|
await vi.waitFor(() =>
|
|
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
|
|
'/repo',
|
|
'origin',
|
|
undefined,
|
|
{},
|
|
{ requireVerifiedSshProbe: true }
|
|
)
|
|
)
|
|
releaseRemoteProbe(true)
|
|
|
|
await expect(resultPromise).rejects.toBe(originError)
|
|
})
|
|
|
|
it('still probes upstream for PR candidates when that remote is present', async () => {
|
|
getOwnerRepoForRemoteMock.mockImplementation(async (_path, remote) =>
|
|
remote === 'upstream' ? { owner: 'Acme', repo: 'Orca' } : { owner: 'acme', repo: 'orca' }
|
|
)
|
|
|
|
await expect(resolveGitHubApiRepositoryCandidates('/repo')).resolves.toEqual({
|
|
candidates: [{ owner: 'Acme', repo: 'Orca', host: 'github.com' }],
|
|
headRepo: { owner: 'acme', repo: 'orca', host: 'github.com' }
|
|
})
|
|
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
|
|
'/repo',
|
|
'upstream',
|
|
undefined,
|
|
{},
|
|
{ requireVerifiedSshProbe: true }
|
|
)
|
|
})
|
|
})
|