mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
* feat(github): bind projects to a specific gh account Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree issue/PR search, work items, hosted-review reads and mutations) run as the bound account via ephemeral child-env token injection instead of the globally active gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails closed when the bound account or host is unavailable; Project View stays ambient by design. Repository settings gains a section for selecting or clearing a keyring-backed account (shadcn `Select`), with mixed-version "not enforced" handling for older remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift guard and its tests; es/ja/ko/zh catalogs carry the section's strings. `getLocalProjectGhExecOptions` centralizes the binding lookup so every gh execution path picks it up, including the Electron `hostedReview:*` handlers that previously stayed on the ambient login. `gh auth token` (a keyring read) is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a bound-token resolve into a false "unavailable". The `ghAccount` update field and the two binding RPC methods live in the shared RPC params contract; the generated catalog is regenerated. Fixes #13612 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA * fix(settings): make GitHub account refresh secondary * fix(github): satisfy strict casting quality checks * test(rpc): use runtime fixture for repo binding * fix(github): preserve project account for PR worktree lookups * test(rpc): avoid incomplete runtime settings fixture * fix(i18n): add GitHub account refresh label * fix(i18n): refresh runtime required catalog * fix(windows): preserve mobile patch bytes --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai>
269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
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<string, OwnerRepoCacheEntry>()
|
|
const ownerRepoInFlight: CoalescedProbes<OwnerRepo | null> = 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<string | null> {
|
|
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<OwnerRepo | null> {
|
|
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<OwnerRepo | null> {
|
|
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
|
|
}
|