mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +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>
105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
/**
|
|
* Repo-scoped inventory + validation for Settings GitHub account binding.
|
|
*/
|
|
import { diagnoseGhAuth } from './auth-diagnose'
|
|
import {
|
|
getGhMultiAccountCapability,
|
|
invalidateGhMultiAccountCapability,
|
|
normalizeGhCapabilityTarget,
|
|
type GhCapabilityTarget
|
|
} from './gh-capability-state'
|
|
import { invalidateGhAccountTokenCache, resolveGhAccountToken } from './gh-account-token'
|
|
import { normalizeGhAccountBinding } from '../../shared/github/account-binding'
|
|
import type {
|
|
GhAccountBindingInventory,
|
|
GhAccountBindingValidationResult
|
|
} from '../../shared/github/auth-types'
|
|
import type { LocalGitExecOptions } from './github-repository-identity'
|
|
|
|
function capabilityTargetFromLocalGitOptions(
|
|
localGitOptions: LocalGitExecOptions = {}
|
|
): GhCapabilityTarget {
|
|
return normalizeGhCapabilityTarget(
|
|
localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}
|
|
)
|
|
}
|
|
|
|
/** Lists keyring logins on the repo's execution host, filtered to `requiredHost` when the repo pins one. */
|
|
export async function listGhAccountBindingInventory(
|
|
localGitOptions: LocalGitExecOptions = {},
|
|
options: { refreshCapability?: boolean; requiredHost?: string } = {}
|
|
): Promise<GhAccountBindingInventory> {
|
|
const target = capabilityTargetFromLocalGitOptions(localGitOptions)
|
|
if (options.refreshCapability) {
|
|
invalidateGhMultiAccountCapability(target)
|
|
invalidateGhAccountTokenCache()
|
|
}
|
|
const requiredHost = options.requiredHost?.trim().toLowerCase() || undefined
|
|
const [capability, diagnostic] = await Promise.all([
|
|
getGhMultiAccountCapability(target),
|
|
diagnoseGhAuth(requiredHost, {
|
|
cwd: target.cwd,
|
|
wslDistro: target.wslDistro
|
|
})
|
|
])
|
|
const accounts = requiredHost
|
|
? diagnostic.accounts.filter((entry) => entry.host.trim().toLowerCase() === requiredHost)
|
|
: diagnostic.accounts
|
|
return {
|
|
capability,
|
|
accounts
|
|
}
|
|
}
|
|
|
|
/** Fails closed: a binding is only `ok` when the CLI supports multi-account and the login exists on this host. */
|
|
export async function validateGhAccountBinding(
|
|
bindingInput: { host: string; user: string },
|
|
localGitOptions: LocalGitExecOptions = {},
|
|
options: { requiredHost?: string } = {}
|
|
): Promise<GhAccountBindingValidationResult> {
|
|
const binding = normalizeGhAccountBinding(bindingInput)
|
|
if (!binding) {
|
|
return { ok: false, error: 'invalid_binding' }
|
|
}
|
|
|
|
const requiredHost = options.requiredHost?.trim().toLowerCase() || undefined
|
|
if (requiredHost && binding.host !== requiredHost) {
|
|
return { ok: false, error: 'gh_bound_account_unavailable' }
|
|
}
|
|
|
|
const inventory = await listGhAccountBindingInventory(localGitOptions, { requiredHost })
|
|
if (inventory.capability === 'unsupported') {
|
|
return { ok: false, error: 'gh_multi_account_unsupported' }
|
|
}
|
|
if (inventory.capability === 'unknown') {
|
|
return { ok: false, error: 'gh_multi_account_capability_unknown' }
|
|
}
|
|
|
|
const account = inventory.accounts.find(
|
|
(entry) =>
|
|
entry.host.trim().toLowerCase() === binding.host &&
|
|
entry.user.trim().toLowerCase() === binding.user.toLowerCase()
|
|
)
|
|
if (!account) {
|
|
return { ok: false, error: 'gh_bound_account_unavailable' }
|
|
}
|
|
if (account.source !== 'keyring') {
|
|
return { ok: false, error: 'gh_bound_account_not_keyring' }
|
|
}
|
|
|
|
try {
|
|
await resolveGhAccountToken(binding, capabilityTargetFromLocalGitOptions(localGitOptions))
|
|
return { ok: true, binding }
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
typeof error === 'object' &&
|
|
'code' in error &&
|
|
error.code === 'gh_multi_account_unsupported'
|
|
) {
|
|
return { ok: false, error: 'gh_multi_account_unsupported' }
|
|
}
|
|
return { ok: false, error: 'gh_bound_account_unavailable' }
|
|
}
|
|
}
|