mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user