From 8ff8e8a5f31ae672a9f69caede35d1131e2b53a1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:08:36 -0700 Subject: [PATCH] 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 --- config/max-lines-baseline.txt | 1 - .../hosted-review-creation-blocking.ts | 102 +++++ .../hosted-review-creation-git-state.ts | 179 ++++++++ .../hosted-review-creation-provider.ts | 137 ++++++ .../source-control/hosted-review-creation.ts | 422 +----------------- 5 files changed, 432 insertions(+), 409 deletions(-) create mode 100644 src/main/source-control/hosted-review-creation-blocking.ts create mode 100644 src/main/source-control/hosted-review-creation-git-state.ts create mode 100644 src/main/source-control/hosted-review-creation-provider.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 7870a1bf8b2..fbc4a24ed38 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/main/source-control/hosted-review-creation-blocking.ts b/src/main/source-control/hosted-review-creation-blocking.ts new file mode 100644 index 00000000000..455d55049ed --- /dev/null +++ b/src/main/source-control/hosted-review-creation-blocking.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, + 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, 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.` + } +} diff --git a/src/main/source-control/hosted-review-creation-git-state.ts b/src/main/source-control/hosted-review-creation-git-state.ts new file mode 100644 index 00000000000..5c891e97f06 --- /dev/null +++ b/src/main/source-control/hosted-review-creation-git-state.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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')) + } +} diff --git a/src/main/source-control/hosted-review-creation-provider.ts b/src/main/source-control/hosted-review-creation-provider.ts new file mode 100644 index 00000000000..323497d2419 --- /dev/null +++ b/src/main/source-control/hosted-review-creation-provider.ts @@ -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 { + // 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 { + 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 { + 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) +} diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 9c9be75ab24..a3e06a3acc0 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -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 { - // 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - 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, 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,