import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe' import { readRemoteUrl } from '../git/remote-url-probe' import type { GhAccountBinding } from '../../shared/github/account-binding' import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' import { getSshGitProvider, getSshGitProviderGeneration, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity, type GitHubRemoteIdentity } from './github-remote-identity-parsing' import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution' import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error' import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' export type OwnerRepo = GitHubOwnerRepo export type { GitHubRemoteIdentity } export { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } export type GitHubRepoContext = { repoPath: string connectionId?: string | null wslDistro?: string admissionTier?: GitAdmissionTier /** SSH keeps the binding even when cwd is omitted from gh options. */ ghAccount?: GhAccountBinding } export type LocalGitExecOptions = { wslDistro?: string admissionTier?: GitAdmissionTier ghAccount?: GhAccountBinding } export type GitHubRemoteIdentityProbeOptions = { requireVerifiedSshProbe?: boolean } export function githubRepoContext( repoPath: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): GitHubRepoContext { return { repoPath, connectionId: connectionId ?? null, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), ...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {}), ...(localGitOptions.ghAccount ? { ghAccount: localGitOptions.ghAccount } : {}) } } export function ghRepoExecOptions(context: GitHubRepoContext): { cwd?: string encoding?: BufferEncoding wslDistro?: string admissionTier?: GitAdmissionTier ghAccount?: GhAccountBinding } { const account = context.ghAccount ? { ghAccount: context.ghAccount } : {} return context.connectionId ? { ...account } : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}), ...(context.admissionTier ? { admissionTier: context.admissionTier } : {}), ...account } } const OWNER_REPO_POSITIVE_CACHE_TTL_MS = 30_000 const OWNER_REPO_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 // Signed entries revalidate against Git config before reuse. const OWNER_REPO_SIGNED_CACHE_TTL_MS = 5 * 60_000 const OWNER_REPO_CACHE_MAX_ENTRIES = 512 type OwnerRepoCacheEntry = { value: OwnerRepo | null expiresAt: number configSignature?: string } const ownerRepoCache = new Map() const ownerRepoInFlight: CoalescedProbes = new Map() /** @internal - exposed for tests only */ export function _resetOwnerRepoCache(): void { ownerRepoCache.clear() ownerRepoInFlight.clear() } /** @internal - exposed for tests only */ export function _getOwnerRepoCacheSize(): number { return ownerRepoCache.size } function pruneOwnerRepoCache(now: number): void { for (const [key, entry] of ownerRepoCache) { if (entry.expiresAt <= now) { ownerRepoCache.delete(key) } } while (ownerRepoCache.size > OWNER_REPO_CACHE_MAX_ENTRIES) { const oldestKey = ownerRepoCache.keys().next().value if (oldestKey === undefined) { return } ownerRepoCache.delete(oldestKey) } } export async function getRemoteUrlForRepo( context: GitHubRepoContext, remoteName: string ): Promise { return readRemoteUrl(context, remoteName) } function getOwnerRepoCacheTtl(value: OwnerRepo | null, configSignature?: string): number { if (configSignature) { return value ? OWNER_REPO_SIGNED_CACHE_TTL_MS : OWNER_REPO_NEGATIVE_CACHE_TTL_MS } return OWNER_REPO_POSITIVE_CACHE_TTL_MS } export async function getOwnerRepoForRemote( repoPath: string, remoteName: string, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {}, probeOptions: GitHubRemoteIdentityProbeOptions = {} ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) if ( probeOptions.requireVerifiedSshProbe && context.connectionId && !getSshGitProvider(context.connectionId) ) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } const runtimeKey = context.connectionId ? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}` : `local:${context.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` const now = Date.now() pruneOwnerRepoCache(now) const cached = ownerRepoCache.get(cacheKey) if (cached && cached.expiresAt > now) { // Revalidate signed hits so remote changes are immediately visible. if (cached.configSignature !== undefined) { const currentSignature = await readLocalGitConfigSignature(context) if (currentSignature !== cached.configSignature) { ownerRepoCache.delete(cacheKey) } else { return cached.value } } else { return cached.value } } if (cached && cached.expiresAt <= now) { ownerRepoCache.delete(cacheKey) } const nextConfigSignature = await readLocalGitConfigSignature(context) const refreshedNow = Date.now() const refreshedCached = ownerRepoCache.get(cacheKey) if (refreshedCached && refreshedCached.expiresAt > refreshedNow) { return refreshedCached.value } // Why: startup can resolve issue sources, PR candidates, and repo metadata // for the same repo concurrently. Coalesce missing-remote probes — but only // onto one young enough to still answer, so a wedged probe cannot pin the // repo's identity for the life of the process (P1-D). const inFlightKey = `${cacheKey}\0${probeOptions.requireVerifiedSshProbe ? 'verified' : 'tolerant'}` return runCoalescedProbe(ownerRepoInFlight, inFlightKey, () => resolveOwnerRepoForRemote( context, remoteName, cacheKey, nextConfigSignature, probeOptions.requireVerifiedSshProbe === true ) ) } async function resolveOwnerRepoForRemote( context: GitHubRepoContext, remoteName: string, cacheKey: string, configSignature: string | undefined, requireVerifiedSshProbe: boolean ): Promise { const now = Date.now() try { const remoteUrl = await getRemoteUrlForRepo(context, remoteName) if (!remoteUrl) { if ( requireVerifiedSshProbe && context.connectionId && !getSshGitProvider(context.connectionId) ) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } // Empty remote URL is stable until git config changes. ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + getOwnerRepoCacheTtl(null, configSignature), ...(configSignature ? { configSignature } : {}) }) pruneOwnerRepoCache(now) return null } // Why: PR mutations need the effective host behind an SSH alias. const classification = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context) if (classification.kind === 'github') { // Signed identities stay valid until Git config changes. ownerRepoCache.set(cacheKey, { value: classification.ownerRepo, expiresAt: now + getOwnerRepoCacheTtl(classification.ownerRepo, configSignature), ...(configSignature ? { configSignature } : {}) }) pruneOwnerRepoCache(now) return classification.ownerRepo } if (classification.kind === 'indeterminate') { // Why: a failed ssh -G probe is not a stable "not GitHub" result. if (requireVerifiedSshProbe && context.connectionId) { throw new Error('Remote repository identity is unverifiable.') } return null } const stableConfigSignature = classification.cacheWithGitConfigSignature ? configSignature : undefined ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + getOwnerRepoCacheTtl(null, stableConfigSignature), ...(stableConfigSignature ? { configSignature: stableConfigSignature } : {}) }) pruneOwnerRepoCache(now) return null } catch (error) { // Why: only stable "no such remote" misses are safe to hold for minutes. // Transient git lock/IO failures must retry on the next lookup. if (!isStableMissingGitRemoteError(error)) { if (requireVerifiedSshProbe && context.connectionId) { throw error } return null } } // Why: a missing remote is stable until `.git/config` changes. // Holding that negative longer avoids Git process churn across PR polling. ownerRepoCache.set(cacheKey, { value: null, expiresAt: now + getOwnerRepoCacheTtl(null, configSignature), ...(configSignature ? { configSignature } : {}) }) pruneOwnerRepoCache(now) return null }