mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
fix(git): skip upstream remote probes when the remote is absent (#18455)
* fix(git): skip upstream remote probes when the remote is absent Issue and PR resolvers listed remotes by probing `git remote get-url upstream` on every poll, including origin-only clones where that remote cannot exist. List remotes once, cache against git config, and skip the probe unless `upstream` is present. * fix(git): avoid stale remote probe cache entries * fix(github): observe origin repository probe failures * fix(github): observe verified origin probe failures * fix(github): skip missing upstream probe for PR lists * test(github): scope the #9171 lazy-resolution guard to default-branch commands The guard asserted that no git command runs for an open PR, using "no git at all" as a proxy for "no default-branch resolution". Remote-name listing is a separate concern, so allow it and keep every other command forbidden; the symbolic-ref/rev-parse resolution this issue is about stays unreachable. --------- Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -4,27 +4,19 @@ 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 { shouldProbeGitRemote } from '../git/remote-name-listing'
|
||||
import { ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from './gh-utils'
|
||||
import { 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'
|
||||
_resetOriginGitHubApiRepositoryCache,
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getOriginGitHubApiRepository
|
||||
} from './github-api-repository-remote-probe'
|
||||
|
||||
export {
|
||||
githubHostExecOptions,
|
||||
@@ -40,123 +32,10 @@ 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)
|
||||
export {
|
||||
_resetOriginGitHubApiRepositoryCache,
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getOriginGitHubApiRepository
|
||||
}
|
||||
|
||||
/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */
|
||||
@@ -165,16 +44,26 @@ export async function getIssueGitHubApiRepository(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
const upstream = await getGitHubApiRepositoryForRemote(
|
||||
const originPromise = getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
).then(
|
||||
(value) => ({ status: 'fulfilled' as const, value }),
|
||||
(reason: unknown) => ({ status: 'rejected' as const, reason })
|
||||
)
|
||||
const upstream = (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions))
|
||||
? await getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
|
||||
: null
|
||||
if (upstream) {
|
||||
return upstream
|
||||
}
|
||||
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
const origin = await originPromise
|
||||
if (origin.status === 'rejected') {
|
||||
throw origin.reason
|
||||
}
|
||||
return origin.value
|
||||
}
|
||||
|
||||
export type GitHubApiRepositoryCandidates = {
|
||||
@@ -188,14 +77,36 @@ export async function resolveGitHubApiRepositoryCandidates(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepositoryCandidates> {
|
||||
const [upstream, origin] = await Promise.all([
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
|
||||
const originPromise = getGitHubApiRepositoryForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions, {
|
||||
requireVerifiedSshProbe: true
|
||||
})
|
||||
}
|
||||
).then(
|
||||
(value) => ({ status: 'fulfilled' as const, value }),
|
||||
(reason: unknown) => ({ status: 'rejected' as const, reason })
|
||||
)
|
||||
const probeUpstream = await shouldProbeGitRemote(
|
||||
repoPath,
|
||||
'upstream',
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const [upstream, originResult] = await Promise.all([
|
||||
probeUpstream
|
||||
? getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
|
||||
requireVerifiedSshProbe: true
|
||||
})
|
||||
: null,
|
||||
originPromise
|
||||
])
|
||||
if (originResult.status === 'rejected') {
|
||||
throw originResult.reason
|
||||
}
|
||||
const origin = originResult.value
|
||||
const seen = new Set<string>()
|
||||
const candidates: GitHubApiRepository[] = []
|
||||
for (const candidate of [upstream, origin]) {
|
||||
|
||||
Reference in New Issue
Block a user