Split hosted review creation checks (#17152)

* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Fix F3-speech for #17123

* Fix F1-cycle for #17131
This commit is contained in:
Neil
2026-08-29 20:08:36 -07:00
committed by GitHub
parent ecee14bdbc
commit 8ff8e8a5f3
5 changed files with 432 additions and 409 deletions
-1
View File
@@ -26,7 +26,6 @@ inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
@@ -0,0 +1,102 @@
import type {
CreateHostedReviewResult,
HostedReviewCreationBlockedReason,
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../shared/hosted-review'
import { reviewCopy } from './hosted-review-creation-provider'
function blockedCreateResultForReason(
reason: NonNullable<HostedReviewCreationBlockedReason>,
provider: HostedReviewProvider,
submittedBase?: string | null
): CreateHostedReviewResult | null {
const copy = reviewCopy(provider)
const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : ''
const blockedCreateResultByReason = {
auth_required: {
ok: false,
code: 'auth_required',
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: ${copy.authInstruction} in this environment.`
},
unsupported_provider: {
ok: false,
code: 'unsupported_provider',
error: `Creating ${copy.reviewLabel}s requires a ${copy.providerName} remote.`
},
dirty: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: commit or discard local changes before creating a ${copy.reviewLabel}.`
},
detached_head: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: switch to a branch before creating a ${copy.reviewLabel}.`
},
default_branch: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: choose a feature branch before creating a ${copy.reviewLabel}.`
},
no_upstream: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: publish this branch before creating a ${copy.reviewLabel}.`
},
needs_push: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: push this branch before creating a ${copy.reviewLabel}.`
},
needs_sync: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: sync this branch before creating a ${copy.reviewLabel}.`
},
fork_head_unsupported: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: refresh source control status and try again.`
},
base_not_on_remote: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.`
}
} satisfies Partial<
Record<NonNullable<HostedReviewCreationBlockedReason>, CreateHostedReviewResult>
>
return blockedCreateResultByReason[reason] ?? null
}
export function blockedEligibilityToCreateResult(
eligibility: HostedReviewCreationEligibility,
submittedBase?: string | null
): CreateHostedReviewResult | null {
if (eligibility.canCreate) {
return null
}
if (eligibility.review?.url) {
const copy = reviewCopy(eligibility.provider)
return {
ok: false,
code: 'already_exists',
error: `A ${copy.reviewLabel} already exists for this branch.`,
existingReview: eligibility.review
}
}
if (eligibility.blockedReason) {
return blockedCreateResultForReason(
eligibility.blockedReason,
eligibility.provider,
submittedBase
)
}
const copy = reviewCopy(eligibility.provider)
return {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: refresh source control status and try again.`
}
}
@@ -0,0 +1,179 @@
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/git-status-types'
import { gitExecFileAsync } from '../github/gh-utils'
import { gitOptionalLocksDisabledEnv } from '../git/runner'
import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelain-v1-records'
import { resolveDefaultBaseRefViaExec } from '../git/repo'
import { getUpstreamStatus } from '../git/upstream'
import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from './hosted-review-git-options'
export function stripRefPrefix(ref: string): string {
return normalizeHostedReviewHeadRef(ref)
}
export function hostedReviewExecutionContext(
options: HostedReviewExecutionOptions = {}
): HostedReviewExecutionOptions {
const localGitExecOptions = getHostedReviewLocalGitOptions(options)
return Object.keys(localGitExecOptions).length > 0 ? { localGitExecOptions } : {}
}
async function runGitForHostedReview(
repoPath: string,
args: string[],
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<{ stdout: string; stderr?: string }> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
return provider.exec(args, repoPath)
}
return gitExecFileAsync(args, { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) })
}
export async function getDefaultBaseRef(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
runGitForHostedReview(repoPath, argv, connectionId, options)
)
}
/**
* Whether the candidate base resolves to a remote-tracking branch on the
* executing host.
*
* Why: matches under *any* remote (not just origin) and reads the local tracking snapshot, not the live remote.
*/
export async function baseRefExistsOnRemote(
candidate: string,
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const base = normalizeHostedReviewBaseRef(candidate).trim()
if (!base) {
return false
}
const run = (argv: string[]): Promise<{ stdout: string }> =>
runGitForHostedReview(repoPath, argv, connectionId, options)
const patterns = [`refs/remotes/*/${base}`]
// `*` does not cross `/`, so a remote-qualified candidate (e.g. `fork/main`) needs its exact tracking ref too.
if (base.includes('/')) {
patterns.push(`refs/remotes/${base}`)
}
try {
// for-each-ref exits 0 on no match: empty means absent, a thrown error means transport failure (preserve the candidate).
const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns])
return stdout.trim().length > 0
} catch {
return true
}
}
export async function getCurrentBranch(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<string> {
const { stdout } = await runGitForHostedReview(
repoPath,
['rev-parse', '--abbrev-ref', 'HEAD'],
connectionId,
options
)
return stripRefPrefix(stdout.trim())
}
export async function hasUncommittedChanges(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
// Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks.
// No shared-link exclusion here: remote worktree creation skips the symlink
// and shared-directory passes entirely, so a remote worktree never has one.
return (await provider.getStatus(repoPath)).entries.length > 0
}
// Why: `-z` keeps paths raw so the shared-link comparison below can't be
// defeated by Git quoting a path with spaces or non-ASCII bytes.
const { stdout } = await gitExecFileAsync(['status', '--porcelain', '-z'], {
cwd: repoPath,
...getHostedReviewLocalGitOptions(options),
// Why: don't take Git's optional index lock while the user may be running fetch/pull/rebase in a terminal.
env: gitOptionalLocksDisabledEnv()
})
const records = parsePorcelainV1Records(stdout)
if (records.length === 0) {
return false
}
return await anyRecordIsUserDirt(repoPath, records, options.sharedLinkPaths ?? [])
}
/** True when any record is real user work rather than a shared symlink Orca put
* in the worktree.
*
* Fails closed on purpose: anything not positively identified as an Orca-owned
* untracked symlink counts as dirty. A false "clean" would let a review be
* created off a branch missing the user's work. */
async function anyRecordIsUserDirt(
worktreePath: string,
records: readonly PorcelainV1Record[],
sharedLinkPaths: readonly string[]
): Promise<boolean> {
if (sharedLinkPaths.length === 0 || !records.some((record) => record.xy === '??')) {
return true
}
// Why: only entries that are configured AND really symlinks are excluded, so a
// regular file the user created at a configured name still blocks creation.
const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths))
return records.some((record) => record.xy !== '??' || !sharedLinks.has(record.path))
}
export async function getHostedReviewUpstreamStatus(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitUpstreamStatus> {
if (!connectionId) {
return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options))
}
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
}
try {
// Why: the relay blocks generic git.exec, so use its dedicated upstream RPC for SSH divergence.
return await provider.getUpstreamStatus(repoPath)
} catch (error) {
if (isNoUpstreamError(error)) {
return { hasUpstream: false, ahead: 0, behind: 0 }
}
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
}
}
@@ -0,0 +1,137 @@
import type { HostedReviewProvider } from '../../shared/hosted-review'
import type { HostedReviewCreationProvider } from '../../shared/hosted-review-creation-providers'
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation'
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
import { acquire, ghExecFileAsync, release } from '../github/gh-utils'
import { getProjectSlug } from '../gitlab/client'
import {
acquire as acquireGlab,
glabExecFileAsync,
glabRepoExecOptions,
release as releaseGlab
} from '../gitlab/gl-utils'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from './hosted-review-git-options'
async function isGitHubAuthenticated(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
// Why: a non-null enterprise slug already means gh is authenticated there, so skip a redundant probe (#8312).
if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) {
return true
}
await acquire()
try {
// Why: `host` scopes any rate-limit breaker trip to github.com — the host
// this probe actually targets — instead of a GH_HOST-derived scope.
await ghExecFileAsync(
['auth', 'status', '--hostname', 'github.com'],
connectionId
? { host: 'github.com' }
: { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), host: 'github.com' }
)
return true
} catch {
return false
} finally {
release()
}
}
async function isGitLabAuthenticated(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const projectRef = await getProjectSlug(repoPath, connectionId, options)
if (!projectRef) {
return false
}
await acquireGlab()
try {
await glabExecFileAsync(['auth', 'status', '--hostname', projectRef.host], {
...glabRepoExecOptions(repoPath, connectionId),
...(connectionId ? {} : getHostedReviewLocalGitOptions(options))
})
return true
} catch {
return false
} finally {
releaseGlab()
}
}
export function reviewCopy(provider: HostedReviewProvider): {
shortLabel: 'PR' | 'MR'
reviewLabel: 'pull request' | 'merge request'
providerName: string
authInstruction: string
} {
if (provider === 'gitlab') {
return {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authInstruction: 'Run glab auth login'
}
}
if (provider === 'azure-devops') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Azure DevOps',
authInstruction: 'Set ORCA_AZURE_DEVOPS_TOKEN'
}
}
if (provider === 'gitea') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Gitea',
authInstruction: 'Set ORCA_GITEA_TOKEN'
}
}
if (provider === 'bitbucket') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Bitbucket',
authInstruction: 'Connect Bitbucket in Settings > Integrations'
}
}
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authInstruction: 'Run gh auth login'
}
}
export async function isProviderAuthenticated(
provider: HostedReviewCreationProvider,
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
if (provider === 'gitlab') {
return isGitLabAuthenticated(repoPath, connectionId, options)
}
if (provider === 'azure-devops') {
return isAzureDevOpsReviewCreationAuthenticated()
}
if (provider === 'gitea') {
return isGiteaReviewCreationAuthenticated()
}
if (provider === 'bitbucket') {
// Why: falling through to the GitHub check made Create PR unusable for
// anyone with Bitbucket connected but no `gh auth login`.
return isBitbucketReviewCreationAuthenticated()
}
return isGitHubAuthenticated(repoPath, connectionId, options)
}
+14 -408
View File
@@ -1,46 +1,28 @@
/* eslint-disable max-lines -- Why: detection, eligibility, and creation preflight share one boundary so gating can't drift. */
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
HostedReviewCreationBlockedReason,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewLookupOutcome,
HostedReviewProvider
HostedReviewLookupOutcome
} from '../../shared/hosted-review'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import {
supportsHostedReviewCreation,
type HostedReviewCreationProvider
} from '../../shared/hosted-review-creation-providers'
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation'
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
import { supportsHostedReviewCreation } from '../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewBaseRef } from '../../shared/hosted-review-refs'
import { getRepoSlug } from '../github/client'
import { isDefaultGitHubHost } from '../../shared/github/repository-identity-key'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/git-status-types'
import { gitOptionalLocksDisabledEnv } from '../git/runner'
import { parsePorcelainV1Records, type PorcelainV1Record } from '../git/porcelain-v1-records'
import { findExistingWorktreeSymlinkPaths } from '../git/worktree-symlink-detection'
import { resolveDefaultBaseRefViaExec } from '../git/repo'
import { getUpstreamStatus } from '../git/upstream'
import { getProjectSlug } from '../gitlab/client'
import {
acquire as acquireGlab,
glabExecFileAsync,
glabRepoExecOptions,
release as releaseGlab
} from '../gitlab/gl-utils'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { detectHostedReviewProvider, getForgeProviderForRepository } from './forge-provider'
import { invalidateHostedReviewBranchCache } from './hosted-review-branch-cache'
import { getHostedReviewForBranch } from './hosted-review'
import { blockedEligibilityToCreateResult } from './hosted-review-creation-blocking'
import {
baseRefExistsOnRemote,
getCurrentBranch,
getDefaultBaseRef,
getHostedReviewUpstreamStatus,
hasUncommittedChanges,
hostedReviewExecutionContext,
stripRefPrefix
} from './hosted-review-creation-git-state'
import { isProviderAuthenticated, reviewCopy } from './hosted-review-creation-provider'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
@@ -52,382 +34,6 @@ type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs
enforceBaseOnRemote?: boolean
} & HostedReviewExecutionOptions
function stripRefPrefix(ref: string): string {
return normalizeHostedReviewHeadRef(ref)
}
function hostedReviewExecutionContext(
options: HostedReviewExecutionOptions = {}
): HostedReviewExecutionOptions {
const localGitExecOptions = getHostedReviewLocalGitOptions(options)
return Object.keys(localGitExecOptions).length > 0 ? { localGitExecOptions } : {}
}
async function isGitHubAuthenticated(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
// Why: a non-null enterprise slug already means gh is authenticated there, so skip a redundant probe (#8312).
if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) {
return true
}
await acquire()
try {
// Why: `host` scopes any rate-limit breaker trip to github.com — the host
// this probe actually targets — instead of a GH_HOST-derived scope.
await ghExecFileAsync(
['auth', 'status', '--hostname', 'github.com'],
connectionId
? { host: 'github.com' }
: { cwd: repoPath, ...getHostedReviewLocalGitOptions(options), host: 'github.com' }
)
return true
} catch {
return false
} finally {
release()
}
}
async function isGitLabAuthenticated(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const projectRef = await getProjectSlug(repoPath, connectionId, options)
if (!projectRef) {
return false
}
await acquireGlab()
try {
await glabExecFileAsync(['auth', 'status', '--hostname', projectRef.host], {
...glabRepoExecOptions(repoPath, connectionId),
...(connectionId ? {} : getHostedReviewLocalGitOptions(options))
})
return true
} catch {
return false
} finally {
releaseGlab()
}
}
async function runGitForHostedReview(
repoPath: string,
args: string[],
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<{ stdout: string; stderr?: string }> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
return provider.exec(args, repoPath)
}
return gitExecFileAsync(args, { cwd: repoPath, ...getHostedReviewLocalGitOptions(options) })
}
async function getDefaultBaseRef(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
runGitForHostedReview(repoPath, argv, connectionId, options)
)
}
/**
* Whether the candidate base resolves to a remote-tracking branch on the
* executing host.
*
* Why: matches under *any* remote (not just origin) and reads the local tracking snapshot, not the live remote.
*/
async function baseRefExistsOnRemote(
candidate: string,
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
const base = normalizeHostedReviewBaseRef(candidate).trim()
if (!base) {
return false
}
const run = (argv: string[]): Promise<{ stdout: string }> =>
runGitForHostedReview(repoPath, argv, connectionId, options)
const patterns = [`refs/remotes/*/${base}`]
// `*` does not cross `/`, so a remote-qualified candidate (e.g. `fork/main`) needs its exact tracking ref too.
if (base.includes('/')) {
patterns.push(`refs/remotes/${base}`)
}
try {
// for-each-ref exits 0 on no match: empty means absent, a thrown error means transport failure (preserve the candidate).
const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns])
return stdout.trim().length > 0
} catch {
return true
}
}
async function getCurrentBranch(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<string> {
const { stdout } = await runGitForHostedReview(
repoPath,
['rev-parse', '--abbrev-ref', 'HEAD'],
connectionId,
options
)
return stripRefPrefix(stdout.trim())
}
async function hasUncommittedChanges(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
// Why: the relay restricts generic git.exec, so use the structured status RPC for SSH dirty checks.
// No shared-link exclusion here: remote worktree creation skips the symlink
// and shared-directory passes entirely, so a remote worktree never has one.
return (await provider.getStatus(repoPath)).entries.length > 0
}
// Why: `-z` keeps paths raw so the shared-link comparison below can't be
// defeated by Git quoting a path with spaces or non-ASCII bytes.
const { stdout } = await gitExecFileAsync(['status', '--porcelain', '-z'], {
cwd: repoPath,
...getHostedReviewLocalGitOptions(options),
// Why: don't take Git's optional index lock while the user may be running fetch/pull/rebase in a terminal.
env: gitOptionalLocksDisabledEnv()
})
const records = parsePorcelainV1Records(stdout)
if (records.length === 0) {
return false
}
return await anyRecordIsUserDirt(repoPath, records, options.sharedLinkPaths ?? [])
}
/** True when any record is real user work rather than a shared symlink Orca put
* in the worktree.
*
* Fails closed on purpose: anything not positively identified as an Orca-owned
* untracked symlink counts as dirty. A false "clean" would let a review be
* created off a branch missing the user's work. */
async function anyRecordIsUserDirt(
worktreePath: string,
records: readonly PorcelainV1Record[],
sharedLinkPaths: readonly string[]
): Promise<boolean> {
if (sharedLinkPaths.length === 0 || !records.some((record) => record.xy === '??')) {
return true
}
// Why: only entries that are configured AND really symlinks are excluded, so a
// regular file the user created at a configured name still blocks creation.
const sharedLinks = new Set(await findExistingWorktreeSymlinkPaths(worktreePath, sharedLinkPaths))
return records.some((record) => record.xy !== '??' || !sharedLinks.has(record.path))
}
async function getHostedReviewUpstreamStatus(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitUpstreamStatus> {
if (!connectionId) {
return getUpstreamStatus(repoPath, undefined, getHostedReviewLocalGitOptions(options))
}
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
}
try {
// Why: the relay blocks generic git.exec, so use its dedicated upstream RPC for SSH divergence.
return await provider.getUpstreamStatus(repoPath)
} catch (error) {
if (isNoUpstreamError(error)) {
return { hasUpstream: false, ahead: 0, behind: 0 }
}
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
}
}
function reviewCopy(provider: HostedReviewProvider): {
shortLabel: 'PR' | 'MR'
reviewLabel: 'pull request' | 'merge request'
providerName: string
authInstruction: string
} {
if (provider === 'gitlab') {
return {
shortLabel: 'MR',
reviewLabel: 'merge request',
providerName: 'GitLab',
authInstruction: 'Run glab auth login'
}
}
if (provider === 'azure-devops') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Azure DevOps',
authInstruction: 'Set ORCA_AZURE_DEVOPS_TOKEN'
}
}
if (provider === 'gitea') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Gitea',
authInstruction: 'Set ORCA_GITEA_TOKEN'
}
}
if (provider === 'bitbucket') {
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'Bitbucket',
authInstruction: 'Connect Bitbucket in Settings > Integrations'
}
}
return {
shortLabel: 'PR',
reviewLabel: 'pull request',
providerName: 'GitHub',
authInstruction: 'Run gh auth login'
}
}
async function isProviderAuthenticated(
provider: HostedReviewCreationProvider,
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<boolean> {
if (provider === 'gitlab') {
return isGitLabAuthenticated(repoPath, connectionId, options)
}
if (provider === 'azure-devops') {
return isAzureDevOpsReviewCreationAuthenticated()
}
if (provider === 'gitea') {
return isGiteaReviewCreationAuthenticated()
}
if (provider === 'bitbucket') {
// Why: falling through to the GitHub check made Create PR unusable for
// anyone with Bitbucket connected but no `gh auth login`.
return isBitbucketReviewCreationAuthenticated()
}
return isGitHubAuthenticated(repoPath, connectionId, options)
}
function blockedCreateResultForReason(
reason: NonNullable<HostedReviewCreationBlockedReason>,
provider: HostedReviewProvider,
submittedBase?: string | null
): CreateHostedReviewResult | null {
const copy = reviewCopy(provider)
const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : ''
const blockedCreateResultByReason = {
auth_required: {
ok: false,
code: 'auth_required',
error: `Create ${copy.shortLabel} failed: ${copy.providerName} is not authenticated. Next step: ${copy.authInstruction} in this environment.`
},
unsupported_provider: {
ok: false,
code: 'unsupported_provider',
error: `Creating ${copy.reviewLabel}s requires a ${copy.providerName} remote.`
},
dirty: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: commit or discard local changes before creating a ${copy.reviewLabel}.`
},
detached_head: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: switch to a branch before creating a ${copy.reviewLabel}.`
},
default_branch: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: choose a feature branch before creating a ${copy.reviewLabel}.`
},
no_upstream: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: publish this branch before creating a ${copy.reviewLabel}.`
},
needs_push: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: push this branch before creating a ${copy.reviewLabel}.`
},
needs_sync: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: sync this branch before creating a ${copy.reviewLabel}.`
},
fork_head_unsupported: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: refresh source control status and try again.`
},
base_not_on_remote: {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.`
}
} satisfies Partial<
Record<NonNullable<HostedReviewCreationBlockedReason>, CreateHostedReviewResult>
>
return blockedCreateResultByReason[reason] ?? null
}
function blockedEligibilityToCreateResult(
eligibility: HostedReviewCreationEligibility,
submittedBase?: string | null
): CreateHostedReviewResult | null {
if (eligibility.canCreate) {
return null
}
if (eligibility.review?.url) {
const copy = reviewCopy(eligibility.provider)
return {
ok: false,
code: 'already_exists',
error: `A ${copy.reviewLabel} already exists for this branch.`,
existingReview: eligibility.review
}
}
if (eligibility.blockedReason) {
return blockedCreateResultForReason(
eligibility.blockedReason,
eligibility.provider,
submittedBase
)
}
const copy = reviewCopy(eligibility.provider)
return {
ok: false,
code: 'validation',
error: `Create ${copy.shortLabel} failed: refresh source control status and try again.`
}
}
async function validateCurrentBranchCanCreateReview(
repoPath: string,
connectionId: string | null | undefined,