mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
* 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>
314 lines
9.8 KiB
TypeScript
314 lines
9.8 KiB
TypeScript
import { glabExecFileAsync } from '../git/runner'
|
|
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
|
|
import { shouldProbeGitRemote } from '../git/remote-name-listing'
|
|
import { isTransientGitProbeError, readRemoteUrl } from '../git/remote-url-probe'
|
|
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
|
|
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
|
import type { IssueSourcePreference } from '../../shared/repo-types'
|
|
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
|
|
import {
|
|
_resetGlabUnauthenticatedHosts,
|
|
isGlabHostKnownUnauthenticated,
|
|
parseGlabAuthStatusHosts,
|
|
rememberGlabHostUnauthenticated,
|
|
rememberGlabKnownHost,
|
|
type LocalGitExecOptions
|
|
} from './gitlab-known-host-probe'
|
|
import {
|
|
DEFAULT_GITLAB_HOSTS,
|
|
normalizeGitLabHost,
|
|
parseGitLabProjectRef,
|
|
parseRemoteProjectRefCandidate,
|
|
type ProjectRef
|
|
} from './project-ref-parser'
|
|
|
|
export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef }
|
|
export type { ProjectRef }
|
|
export {
|
|
_resetKnownHostsCache,
|
|
getGlabKnownHosts,
|
|
parseGlabAuthStatusHosts
|
|
} from './gitlab-known-host-probe'
|
|
export type { LocalGitExecOptions } from './gitlab-known-host-probe'
|
|
|
|
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
|
|
|
|
type CachedProjectRef = { value: ProjectRef | null; expiresAt: number }
|
|
|
|
const projectRefCache = new Map<string, CachedProjectRef>()
|
|
|
|
/** @internal - exposed for tests only */
|
|
export function _resetProjectRefCache(): void {
|
|
projectRefCache.clear()
|
|
clearProjectRefInFlight()
|
|
_resetGlabUnauthenticatedHosts()
|
|
}
|
|
|
|
/** @internal - exposed for tests only */
|
|
export function _getProjectRefCacheSize(): number {
|
|
return projectRefCache.size
|
|
}
|
|
|
|
function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void {
|
|
// Why: "not GitLab" only holds until someone configures `origin` or logs into
|
|
// `glab` — a repo probed before either kept hosted-review detection stale for
|
|
// the life of the process. Negatives expire the way every other forge's do;
|
|
// positives still stay (see `createRemoteRefProbeCache`).
|
|
projectRefCache.set(cacheKey, {
|
|
value,
|
|
expiresAt: value === null ? Date.now() + NEGATIVE_ENTRY_TTL_MS : Number.POSITIVE_INFINITY
|
|
})
|
|
while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) {
|
|
const oldestKey = projectRefCache.keys().next().value
|
|
if (oldestKey === undefined) {
|
|
return
|
|
}
|
|
projectRefCache.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
export async function getProjectRefForRemote(
|
|
repoPath: string,
|
|
remoteName: string,
|
|
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<ProjectRef | null> {
|
|
// Why: a reconnect replaces the host an answer came from under the same id, so
|
|
// the generation is part of the signature; `knownHosts` carries the glab auth
|
|
// state, so logging into a self-hosted instance re-asks rather than reusing a
|
|
// ref resolved while that host was unknown.
|
|
const runtimeKey = connectionId
|
|
? `${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
|
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
|
const cacheKey = `${runtimeKey}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
|
|
const cached = projectRefCache.get(cacheKey)
|
|
if (cached) {
|
|
if (cached.expiresAt > Date.now()) {
|
|
return cached.value
|
|
}
|
|
projectRefCache.delete(cacheKey)
|
|
}
|
|
|
|
return runProjectRefProbeOnce(cacheKey, (ownsKey) =>
|
|
resolveProjectRefForRemote(
|
|
repoPath,
|
|
remoteName,
|
|
knownHosts,
|
|
connectionId,
|
|
cacheKey,
|
|
ownsKey,
|
|
localGitOptions
|
|
)
|
|
)
|
|
}
|
|
|
|
async function resolveProjectRefForRemote(
|
|
repoPath: string,
|
|
remoteName: string,
|
|
knownHosts: readonly string[],
|
|
connectionId: string | null | undefined,
|
|
cacheKey: string,
|
|
ownsKey: () => boolean,
|
|
localGitOptions: LocalGitExecOptions
|
|
): Promise<ProjectRef | null> {
|
|
// Why: a probe abandoned as stale still runs, and the repo state it read is
|
|
// older than whatever its successor already published. It may answer its own
|
|
// callers; it may not overwrite the cache.
|
|
const publish = (value: ProjectRef | null): void => {
|
|
if (ownsKey()) {
|
|
rememberProjectRefCacheEntry(cacheKey, value)
|
|
}
|
|
}
|
|
try {
|
|
const stdout = await readRemoteUrl(
|
|
{
|
|
repoPath,
|
|
connectionId,
|
|
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
|
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
|
|
},
|
|
remoteName
|
|
)
|
|
if (stdout === null) {
|
|
return null
|
|
}
|
|
const result = parseGitLabProjectRef(stdout, knownHosts)
|
|
if (result) {
|
|
publish(result)
|
|
return result
|
|
}
|
|
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
|
|
if (
|
|
remoteCandidate &&
|
|
(await isGlabConfiguredForRemoteHost(
|
|
repoPath,
|
|
remoteCandidate,
|
|
connectionId,
|
|
localGitOptions
|
|
))
|
|
) {
|
|
rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions)
|
|
publish(remoteCandidate)
|
|
return remoteCandidate
|
|
}
|
|
} catch (error) {
|
|
// Why: a wedged or killed probe is not evidence the remote is not GitLab —
|
|
// caching it would misdetect the forge until the negative expires (P1-D).
|
|
// SSH failures stay uncached outright rather than adopting the generic
|
|
// cache's stable-missing-remote exception: keeping a connected host's
|
|
// detection fresh is worth the extra probe.
|
|
if (connectionId || isTransientGitProbeError(error)) {
|
|
return null
|
|
}
|
|
}
|
|
publish(null)
|
|
return null
|
|
}
|
|
|
|
export async function getProjectRef(
|
|
repoPath: string,
|
|
knownHosts?: readonly string[],
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<ProjectRef | null> {
|
|
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId, localGitOptions)
|
|
}
|
|
|
|
export async function getIssueProjectRef(
|
|
repoPath: string,
|
|
knownHosts?: readonly string[],
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<ProjectRef | null> {
|
|
const originPromise = getProjectRefForRemote(
|
|
repoPath,
|
|
'origin',
|
|
knownHosts,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions)) {
|
|
const upstream = await getProjectRefForRemote(
|
|
repoPath,
|
|
'upstream',
|
|
knownHosts,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (upstream) {
|
|
return upstream
|
|
}
|
|
}
|
|
return originPromise
|
|
}
|
|
|
|
export type ResolvedIssueSource = {
|
|
source: ProjectRef | null
|
|
/** True when explicit upstream is gone and resolver fell back to origin. */
|
|
fellBack: boolean
|
|
}
|
|
|
|
export async function resolveIssueSource(
|
|
repoPath: string,
|
|
preference: IssueSourcePreference | undefined,
|
|
knownHosts?: readonly string[],
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<ResolvedIssueSource> {
|
|
if (preference === 'upstream') {
|
|
const upstream = await getProjectRefForRemote(
|
|
repoPath,
|
|
'upstream',
|
|
knownHosts,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
if (upstream) {
|
|
return { source: upstream, fellBack: false }
|
|
}
|
|
const origin = await getProjectRefForRemote(
|
|
repoPath,
|
|
'origin',
|
|
knownHosts,
|
|
connectionId,
|
|
localGitOptions
|
|
)
|
|
return { source: origin, fellBack: origin !== null }
|
|
}
|
|
if (preference === 'origin') {
|
|
return {
|
|
source: await getProjectRefForRemote(
|
|
repoPath,
|
|
'origin',
|
|
knownHosts,
|
|
connectionId,
|
|
localGitOptions
|
|
),
|
|
fellBack: false
|
|
}
|
|
}
|
|
return {
|
|
source: await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions),
|
|
fellBack: false
|
|
}
|
|
}
|
|
|
|
export function glabRepoExecOptions(
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): { cwd?: string; wslDistro?: string; admissionTier?: GitAdmissionTier } {
|
|
return connectionId
|
|
? {}
|
|
: {
|
|
cwd: repoPath,
|
|
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
|
|
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
|
|
}
|
|
}
|
|
|
|
export function glabHostnameArgs(
|
|
projectRef: Pick<ProjectRef, 'host'> | null | undefined,
|
|
connectionId?: string | null
|
|
): string[] {
|
|
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
|
|
}
|
|
|
|
async function isGlabConfiguredForRemoteHost(
|
|
repoPath: string,
|
|
projectRef: Pick<ProjectRef, 'host'>,
|
|
connectionId?: string | null,
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): Promise<boolean> {
|
|
// Why: this probe is per host, but the project-ref miss that reaches it is per
|
|
// repo — without the memo, every non-GitLab repo re-spawns `glab` each time
|
|
// its negative expires.
|
|
if (isGlabHostKnownUnauthenticated(projectRef.host, connectionId, localGitOptions)) {
|
|
return false
|
|
}
|
|
try {
|
|
const result = await glabExecFileAsync(
|
|
['auth', 'status', '--hostname', projectRef.host],
|
|
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
|
|
)
|
|
if (result === undefined) {
|
|
rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions)
|
|
return false
|
|
}
|
|
return true
|
|
} catch (error) {
|
|
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
|
|
const output =
|
|
[execLike.stdout, execLike.stderr, execLike.message]
|
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
.join('\n') || String(error)
|
|
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
|
|
if (hosts.includes(normalizeGitLabHost(projectRef.host))) {
|
|
return true
|
|
}
|
|
rememberGlabHostUnauthenticated(projectRef.host, connectionId, localGitOptions)
|
|
return false
|
|
}
|
|
}
|