Files
orca/src/main/runtime/runtime-github-review-mutation-commands.ts
T
c34b944136 feat(github): bind projects to a specific gh account (#13664)
* 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>
2026-09-18 20:39:52 -07:00

236 lines
6.3 KiB
TypeScript

import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types'
import type { GitHubReactionContent } from '../../shared/github/comment-types'
import type { Repo } from '../../shared/repo-types'
import type { LocalProjectGhExecOptions } from '../project-runtime-git-options'
import {
mergePR,
markPRReadyForReview,
removePRReviewers,
requestPRReviewers,
rerunPRChecks,
resolveReviewThread,
setPRAutoMerge,
setPRCommentReaction,
setPRFileViewed,
updatePRDetails,
updatePRState,
updatePRTitle
} from '../github/client'
type LocalGitArgs = [] | [LocalProjectGhExecOptions]
type RuntimeGitHubReviewMutationCommandsDeps = {
resolveRepo: (selector: string) => Promise<Repo>
getLocalGitArgs: (repo: Repo) => LocalGitArgs
}
export class RuntimeGitHubReviewMutationCommands {
constructor(private readonly deps: RuntimeGitHubReviewMutationCommandsDeps) {}
async rerunRepoPRChecks(
repoSelector: string,
prNumber: number,
options?: { headSha?: string; failedOnly?: boolean; prRepo?: GitHubOwnerRepo | null }
): Promise<Awaited<ReturnType<typeof rerunPRChecks>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return rerunPRChecks(
repo.path,
prNumber,
options,
repo.connectionId ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async setRepoPRCommentReaction(
repoSelector: string,
reactionSubjectId: string,
content: GitHubReactionContent,
reacted: boolean,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof setPRCommentReaction>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return setPRCommentReaction(
repo.path,
reactionSubjectId,
content,
reacted,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async resolveRepoReviewThread(
repoSelector: string,
threadId: string,
resolve: boolean,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof resolveReviewThread>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return resolveReviewThread(
repo.path,
threadId,
resolve,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async setRepoPRFileViewed(
repoSelector: string,
args: {
prRepo?: GitHubOwnerRepo | null
pullRequestId: string
path: string
viewed: boolean
}
): Promise<Awaited<ReturnType<typeof setPRFileViewed>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return setPRFileViewed({
repoPath: repo.path,
connectionId: repo.connectionId ?? null,
localGitOptions: this.deps.getLocalGitArgs(repo)[0],
...args
})
}
async updateRepoPRTitle(
repoSelector: string,
prNumber: number,
title: string,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof updatePRTitle>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return updatePRTitle(
repo.path,
prNumber,
title,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async updateRepoPRDetails(
repoSelector: string,
prNumber: number,
updates: { title?: string; body?: string },
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof updatePRDetails>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return updatePRDetails(
repo.path,
prNumber,
updates,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async mergeRepoPR(
repoSelector: string,
prNumber: number,
method?: 'merge' | 'squash' | 'rebase',
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof mergePR>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return mergePR(
repo.path,
prNumber,
method,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async setRepoPRAutoMerge(
repoSelector: string,
prNumber: number,
enabled: boolean,
method?: 'merge' | 'squash' | 'rebase',
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof setPRAutoMerge>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return setPRAutoMerge(
repo.path,
prNumber,
enabled,
method,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async markRepoPRReadyForReview(
repoSelector: string,
prNumber: number,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof markPRReadyForReview>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return markPRReadyForReview(
repo.path,
prNumber,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async updateRepoPRState(
repoSelector: string,
prNumber: number,
updates: GitHubPullRequestStateUpdate,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof updatePRState>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return updatePRState(
repo.path,
prNumber,
updates,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async requestRepoPRReviewers(
repoSelector: string,
prNumber: number,
reviewers: string[],
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof requestPRReviewers>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return requestPRReviewers(
repo.path,
prNumber,
reviewers,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
async removeRepoPRReviewers(
repoSelector: string,
prNumber: number,
reviewers: string[],
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof removePRReviewers>>> {
const repo = await this.deps.resolveRepo(repoSelector)
return removePRReviewers(
repo.path,
prNumber,
reviewers,
repo.connectionId ?? null,
prRepo ?? null,
...this.deps.getLocalGitArgs(repo)
)
}
}