mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
* fix(github): fail closed when the SSH git provider is gone getCurrentHeadOid and probeTrackedUpstreamBranches only routed through the SSH provider when one was registered. With connectionId set but the provider unregistered (dropped connection, not yet reattached) they fell through to client-side git with cwd pointing at the remote repoPath — on a machine with a same-named local path that silently answers for the wrong repository. getCurrentHeadOid feeds shouldHideMergedImplicitPR, so a wrong OID changes which PR the UI attributes to a worktree. Both now take their existing unknown path (null / probeFailed) instead, matching repo-default-branch.ts. Local and WSL routing is unchanged. * fix(github): preserve PR state when SSH probes fail * fix(github): keep failed SSH discovery unverifiable * fix(github): propagate SSH identity failures * fix(github): scope verified SSH identity probes * test(github): preserve tolerant resolver calls * fix(github): preserve indeterminate auth discovery * fix(github): isolate SSH repository probe generations * test(github): expose SSH probe generation in mocks
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
|
import type { IssueSourcePreference } from '../../shared/repo-types'
|
|
import {
|
|
githubRepoIdentityKey,
|
|
isDefaultGitHubHost
|
|
} from '../../shared/github/repository-identity-key'
|
|
import {
|
|
getOwnerRepoForRemote,
|
|
ghRepoExecOptions,
|
|
githubRepoContext,
|
|
type GitHubRemoteIdentityProbeOptions,
|
|
type LocalGitExecOptions
|
|
} from './gh-utils'
|
|
import {
|
|
getEnterpriseGitHubRepoSlug,
|
|
getEnterpriseGitHubRepoSlugForRemote,
|
|
isGitHubHostAuthenticated
|
|
} from './github-enterprise-repository'
|
|
import { githubHostExecOptions } from './github-repository-host'
|
|
import {
|
|
isValidGitHubApiRepository,
|
|
type GitHubApiRepositoryResolution
|
|
} from './github-api-repository-validation'
|
|
import {
|
|
githubApiRepositoryProbeCacheKey,
|
|
resolveGitHubApiRepositoryProbe
|
|
} from './github-api-repository-probe'
|
|
|
|
export {
|
|
githubHostExecOptions,
|
|
githubRepositorySlugArg,
|
|
githubRepositoryWebHost
|
|
} from './github-repository-host'
|
|
export type GitHubApiRepository = GitHubOwnerRepo
|
|
export type GitHubRepoExecOptions = ReturnType<typeof ghRepoExecOptions> & { host?: string }
|
|
export type GitHubRepoExecution = {
|
|
ownerRepo: GitHubApiRepository | null
|
|
ghOptions: GitHubRepoExecOptions
|
|
}
|
|
|
|
// Why: cache the uncached Enterprise remote probe used by hot paths.
|
|
const ORIGIN_REPO_CACHE_TTL_MS = 30_000
|
|
const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512
|
|
const originRepoCache = new Map<string, { value: GitHubApiRepository | null; expiresAt: number }>()
|
|
const originRepoInFlight = new Map<string, Promise<GitHubApiRepository | null>>()
|
|
|
|
/** @internal - exposed for tests only */
|
|
export function _resetOriginGitHubApiRepositoryCache(): void {
|
|
originRepoCache.clear()
|
|
originRepoInFlight.clear()
|
|
}
|
|
|
|
function pruneOriginRepoCache(now: number): void {
|
|
for (const [key, entry] of originRepoCache) {
|
|
if (entry.expiresAt <= now) {
|
|
originRepoCache.delete(key)
|
|
}
|
|
}
|
|
while (originRepoCache.size > ORIGIN_REPO_CACHE_MAX_ENTRIES) {
|
|
const oldestKey = originRepoCache.keys().next().value
|
|
if (oldestKey === undefined) {
|
|
return
|
|
}
|
|
originRepoCache.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Host-qualified repository identity for one remote: github.com remotes come
|
|
* from the cached slug parser; any other GitHub-shaped host is auth-gated so a
|
|
* non-GitHub forge never routes to the GitHub provider.
|
|
*/
|
|
export async function getGitHubApiRepositoryForRemote(
|
|
repoPath: string,
|
|
remoteName: string,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {},
|
|
probeOptions: GitHubRemoteIdentityProbeOptions = {}
|
|
): Promise<GitHubApiRepository | null> {
|
|
// Why: generic PR resolution prefers upstream, but this API represents the
|
|
// caller-selected remote exactly (#7331).
|
|
const requireVerifiedSshProbe = probeOptions.requireVerifiedSshProbe === true
|
|
const verifiedIdentityArgs = requireVerifiedSshProbe ? ([probeOptions] as const) : []
|
|
const ownerRepo = await getOwnerRepoForRemote(
|
|
repoPath,
|
|
remoteName,
|
|
connectionId,
|
|
localGitOptions,
|
|
...verifiedIdentityArgs
|
|
)
|
|
if (ownerRepo) {
|
|
return { ...ownerRepo, host: 'github.com' }
|
|
}
|
|
const cacheKey = githubApiRepositoryProbeCacheKey(
|
|
repoPath,
|
|
remoteName,
|
|
connectionId,
|
|
localGitOptions,
|
|
requireVerifiedSshProbe
|
|
)
|
|
const now = Date.now()
|
|
pruneOriginRepoCache(now)
|
|
const cached = originRepoCache.get(cacheKey)
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.value
|
|
}
|
|
const inFlight = originRepoInFlight.get(cacheKey)
|
|
if (inFlight) {
|
|
return inFlight
|
|
}
|
|
const probe = (async () => {
|
|
const enterpriseOptions =
|
|
Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}
|
|
const verifiedEnterpriseArgs = requireVerifiedSshProbe ? ([true] as const) : []
|
|
const slug =
|
|
remoteName === 'origin'
|
|
? await getEnterpriseGitHubRepoSlug(
|
|
repoPath,
|
|
connectionId,
|
|
enterpriseOptions,
|
|
...verifiedEnterpriseArgs
|
|
)
|
|
: await getEnterpriseGitHubRepoSlugForRemote(
|
|
repoPath,
|
|
remoteName,
|
|
connectionId,
|
|
enterpriseOptions,
|
|
...verifiedEnterpriseArgs
|
|
)
|
|
// Why: undefined means the gh auth inventory could not be read. Caching it
|
|
// as a negative would turn a transient spawn failure into a 30-second miss.
|
|
if (slug !== undefined) {
|
|
originRepoCache.set(cacheKey, {
|
|
value: slug,
|
|
expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS
|
|
})
|
|
pruneOriginRepoCache(Date.now())
|
|
}
|
|
return resolveGitHubApiRepositoryProbe(slug, requireVerifiedSshProbe)
|
|
})()
|
|
originRepoInFlight.set(cacheKey, probe)
|
|
try {
|
|
return await probe
|
|
} finally {
|
|
if (originRepoInFlight.get(cacheKey) === probe) {
|
|
originRepoInFlight.delete(cacheKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getOriginGitHubApiRepository(
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<GitHubApiRepository | null> {
|
|
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
|
}
|
|
|
|
/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */
|
|
export async function getIssueGitHubApiRepository(
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<GitHubApiRepository | null> {
|
|
const upstream = await getGitHubApiRepositoryForRemote(
|
|
repoPath,
|
|
'upstream',
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (upstream) {
|
|
return upstream
|
|
}
|
|
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
|
}
|
|
|
|
export type GitHubApiRepositoryCandidates = {
|
|
candidates: GitHubApiRepository[]
|
|
headRepo: GitHubApiRepository | null
|
|
}
|
|
|
|
/** Hosted mirror of resolvePRRepositoryCandidates: upstream first, then origin. */
|
|
export async function resolveGitHubApiRepositoryCandidates(
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<GitHubApiRepositoryCandidates> {
|
|
const [upstream, origin] = await Promise.all([
|
|
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
|
|
requireVerifiedSshProbe: true
|
|
}),
|
|
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions, {
|
|
requireVerifiedSshProbe: true
|
|
})
|
|
])
|
|
const seen = new Set<string>()
|
|
const candidates: GitHubApiRepository[] = []
|
|
for (const candidate of [upstream, origin]) {
|
|
if (!candidate) {
|
|
continue
|
|
}
|
|
const key = githubRepoIdentityKey(candidate)
|
|
if (seen.has(key)) {
|
|
continue
|
|
}
|
|
seen.add(key)
|
|
candidates.push(candidate)
|
|
}
|
|
return { candidates, headRepo: origin }
|
|
}
|
|
|
|
export type ResolvedGitHubApiRepositorySource = {
|
|
source: GitHubApiRepository | null
|
|
/** True when explicit upstream is gone and resolver fell back to origin. */
|
|
fellBack: boolean
|
|
}
|
|
|
|
/** Hosted mirror of resolveIssueSource — same preference semantics. */
|
|
export async function resolveIssueGitHubApiRepositorySource(
|
|
repoPath: string,
|
|
preference: IssueSourcePreference | undefined,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<ResolvedGitHubApiRepositorySource> {
|
|
if (preference === 'upstream') {
|
|
const upstream = await getGitHubApiRepositoryForRemote(
|
|
repoPath,
|
|
'upstream',
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (upstream) {
|
|
return { source: upstream, fellBack: false }
|
|
}
|
|
const origin = await getGitHubApiRepositoryForRemote(
|
|
repoPath,
|
|
'origin',
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
return { source: origin, fellBack: origin !== null }
|
|
}
|
|
if (preference === 'origin') {
|
|
return {
|
|
source: await getGitHubApiRepositoryForRemote(
|
|
repoPath,
|
|
'origin',
|
|
connectionId,
|
|
localGitOptions
|
|
),
|
|
fellBack: false
|
|
}
|
|
}
|
|
return {
|
|
source: await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
|
fellBack: false
|
|
}
|
|
}
|
|
|
|
export async function resolveGitHubApiRepository(
|
|
repoPath: string,
|
|
repository?: GitHubApiRepository | null,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<GitHubApiRepository | null> {
|
|
if (repository && !isValidGitHubApiRepository(repository)) {
|
|
return null
|
|
}
|
|
if (repository?.host) {
|
|
const host = repository.host.trim().toLowerCase()
|
|
if (!host) {
|
|
return null
|
|
}
|
|
if (isDefaultGitHubHost(host)) {
|
|
return { ...repository, host }
|
|
}
|
|
// Why: client-supplied hosts must match gh's local auth inventory before
|
|
// they can receive ambient Enterprise credentials from a host-pinned call.
|
|
const authenticated = await isGitHubHostAuthenticated(
|
|
host,
|
|
repoPath,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
return authenticated ? { ...repository, host } : null
|
|
}
|
|
const originRepository = await getOriginGitHubApiRepository(
|
|
repoPath,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (!repository) {
|
|
return originRepository
|
|
}
|
|
// Why: older clients only send owner/repo. The origin still supplies the
|
|
// execution host for fork-base slugs on the same GitHub Enterprise server.
|
|
if (originRepository?.host) {
|
|
return { ...repository, host: originRepository.host }
|
|
}
|
|
// Why: a host-less identity can honor ambient GH_HOST even with a local cwd.
|
|
// Only a resolved origin may supply the execution host for legacy clients.
|
|
return null
|
|
}
|
|
|
|
export async function resolveGitHubRepoExecution(
|
|
repoPath: string,
|
|
repository?: GitHubApiRepositoryResolution,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<GitHubRepoExecution> {
|
|
// Why: issue-scoped paths retain their upstream-first resolver while sharing
|
|
// the same repo-scoped and host-scoped gh execution option construction.
|
|
const requestedRepository = typeof repository === 'function' ? await repository() : repository
|
|
// Why: normalize host-less resolver results without replacing an
|
|
// authoritative null with the generic origin fallback.
|
|
const ownerRepo =
|
|
typeof repository === 'function' && !requestedRepository
|
|
? null
|
|
: await resolveGitHubApiRepository(
|
|
repoPath,
|
|
requestedRepository,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
return {
|
|
ownerRepo,
|
|
ghOptions: {
|
|
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
|
|
...githubHostExecOptions(ownerRepo)
|
|
}
|
|
}
|
|
}
|