mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Keep Create PR intent running when review lookup is unavailable (#11678)
* Fix Create PR preparation with unavailable lookup * test(source-control): align dirty+unavailable intent expectation Create PR preparation is allowed when review lookup is unavailable; only final create stays fail-closed. Update the local-blocker snapshot test to match. * Keep Create PR intent running when hosted review lookup fails A failed or timed-out hosted-review eligibility lookup no longer aborts Create PR intent mid-run. Local prep (stage/commit/push) continues, the branch-ahead refresh is deferred until after eligibility resolves, and the final create preflight still fails closed to prevent duplicate reviews. Also gate generated PR title/body on eligibility and thread the provider through the intent run token so an unavailable lookup falls back to the inferred remote host. * fix(source-control): align dirty+unavailable intent expectation Local preparation (stage/commit changes) is safe without review-lookup authority; remote actions stay blocked. Prevents dirty trees from dead-ending at sync-first when lookup is unavailable. * fix(source-control): distinguish loading state from unavailable lookup Require head branch presence in shouldAttemptCreateHostedReviewForIntent to separate real unavailable-lookup results from loading placeholders, which share the same outcome/reason pair but lack a branch name. * test(activity): drive portal readiness latch release with explicit rAF Wall-clock setTimeout waits for requestAnimationFrame were flaky under CI load (shard 15/16), leaving status stuck at loading instead of ready. * Distinguish expected absence from git errors in remote removal Why: swallowing all errors silently masks genuine git failures. Check presence explicitly instead, so setup/teardown can still skip when origin is absent while letting real errors surface.
This commit is contained in:
+11
-2
@@ -307,6 +307,8 @@ describe('Activity portal pane switching', () => {
|
||||
})
|
||||
|
||||
it('releases a latched readiness once the terminal attaches', async () => {
|
||||
// Drive rAF explicitly — wall-clock waits flake under CI load.
|
||||
const frames = installAnimationFrameController()
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => {
|
||||
@@ -352,15 +354,22 @@ describe('Activity portal pane switching', () => {
|
||||
root = createRoot(document.createElement('div'))
|
||||
await act(async () => {
|
||||
root.render(<ActivityTerminalSlot />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 180))
|
||||
})
|
||||
for (let frame = 0; frame < 40; frame += 1) {
|
||||
if (frames.pending() === 0 && statuses.at(-1) === 'unavailable') {
|
||||
break
|
||||
}
|
||||
await frames.flush()
|
||||
}
|
||||
expect(statuses.at(-1)).toBe('unavailable')
|
||||
|
||||
churning = false
|
||||
await act(async () => {
|
||||
buildRoot('ready')
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
})
|
||||
for (let frame = 0; frame < 10 && statuses.at(-1) !== 'ready'; frame += 1) {
|
||||
await frames.flush()
|
||||
}
|
||||
expect(statuses.at(-1)).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,11 +265,14 @@ import {
|
||||
getCreatePrIntentStagePaths,
|
||||
resolveCreatePrIntentReviewBase,
|
||||
resolveCreatePrIntentRemoteStep,
|
||||
shouldAttemptCreateHostedReviewForIntent,
|
||||
shouldGenerateHostedReviewDetailsForIntent,
|
||||
type CreatePrIntentRunToken
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
|
||||
import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action'
|
||||
import {
|
||||
buildCreatePrIntentUnavailableEligibility,
|
||||
buildLoadingHostedReviewCreationEligibility,
|
||||
buildLocalBlockerHostedReviewCreationEligibility,
|
||||
resolveHostedReviewCreationProviderForTarget
|
||||
@@ -1580,6 +1583,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
resolveHostedReviewCreationProviderForTarget(
|
||||
hostedReviewCreationProviderHintRef.current,
|
||||
{ repoId: activeRepoId, worktreeId: activeWorktreeId ?? null, branch: branchName },
|
||||
// Why: provisional already infers the remote host and defaults to github; never fall back to unsupported mid-load.
|
||||
provisionalHostedReviewProvider
|
||||
)
|
||||
)
|
||||
@@ -3409,7 +3413,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
token: CreatePrIntentRunToken,
|
||||
eligibility: HostedReviewCreationEligibility
|
||||
): Promise<boolean> => {
|
||||
if (!activeRepo || !token.branch || !eligibility.canCreate) {
|
||||
if (!activeRepo || !token.branch || !shouldAttemptCreateHostedReviewForIntent(eligibility)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3441,6 +3445,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
|
||||
if (
|
||||
shouldGenerateHostedReviewDetailsForIntent(eligibility) &&
|
||||
hasConfiguredSourceControlTextGenerationDefaults({
|
||||
actionId: 'pullRequest',
|
||||
settings,
|
||||
@@ -3651,23 +3656,43 @@ function SourceControlInner(): React.JSX.Element {
|
||||
if (!activeRepo || !token.branch) {
|
||||
return null
|
||||
}
|
||||
const result = await getHostedReviewCreationEligibility({
|
||||
repoPath: activeRepo.path,
|
||||
repoId: activeRepo.id,
|
||||
worktreePath: token.worktreePath,
|
||||
branch: token.branch,
|
||||
base: token.baseRef ?? null,
|
||||
hasUncommittedChanges,
|
||||
hasUpstream: upstreamStatus?.hasUpstream,
|
||||
ahead: upstreamStatus?.ahead,
|
||||
behind: upstreamStatus?.behind,
|
||||
linkedGitHubPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR
|
||||
})
|
||||
let result: HostedReviewCreationEligibility
|
||||
try {
|
||||
result = await getHostedReviewCreationEligibility({
|
||||
repoPath: activeRepo.path,
|
||||
repoId: activeRepo.id,
|
||||
worktreePath: token.worktreePath,
|
||||
branch: token.branch,
|
||||
base: token.baseRef ?? null,
|
||||
hasUncommittedChanges,
|
||||
hasUpstream: upstreamStatus?.hasUpstream,
|
||||
ahead: upstreamStatus?.ahead,
|
||||
behind: upstreamStatus?.behind,
|
||||
linkedGitHubPR,
|
||||
fallbackGitHubPR: fallbackGitHubPRNumber,
|
||||
linkedGitLabMR,
|
||||
linkedBitbucketPR,
|
||||
linkedAzureDevOpsPR,
|
||||
linkedGiteaPR
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[SourceControl] Create PR intent eligibility failed', error)
|
||||
// Why: when local status still yields a prep step (dirty/push/sync), keep the intent
|
||||
// moving. If nothing actionable can be synthesized, rethrow so the outer intent
|
||||
// catch surfaces a retry notice instead of leaving "Preparing…" stuck forever.
|
||||
const fallback = buildCreatePrIntentUnavailableEligibility(token.provider, {
|
||||
branch: token.branch,
|
||||
baseRef: token.baseRef,
|
||||
hasUncommittedChanges,
|
||||
hasUpstream: upstreamStatus?.hasUpstream,
|
||||
ahead: upstreamStatus?.ahead,
|
||||
behind: upstreamStatus?.behind
|
||||
})
|
||||
if (!fallback) {
|
||||
throw error
|
||||
}
|
||||
result = fallback
|
||||
}
|
||||
setHostedReviewCreationState({
|
||||
repoId: activeRepo.id,
|
||||
worktreeId: token.worktreeId,
|
||||
@@ -3739,6 +3764,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
branch: branchName,
|
||||
// Why: token carries the same provisional provider used for UI copy so a failed
|
||||
// eligibility IPC can synthesize local prep steps for the correct host.
|
||||
provider: provisionalHostedReviewProvider,
|
||||
// Why: intent crosses async commit/push steps, so the base stays tied to what was selected when the run started.
|
||||
baseRef: effectiveBaseRef ?? null
|
||||
})
|
||||
@@ -3939,19 +3967,25 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const branchAhead = await refreshBranchCompareForCreatePrIntent(token)
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
}
|
||||
let eligibility = await readHostedReviewCreationEligibilityForIntent({
|
||||
token,
|
||||
hasUncommittedChanges: latestStatusEntries.length > 0,
|
||||
upstreamStatus: latestUpstreamStatus
|
||||
})
|
||||
if (abortIfStale() || !eligibility) {
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
}
|
||||
if (eligibility.canCreate) {
|
||||
if (!eligibility) {
|
||||
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
|
||||
tone: 'destructive',
|
||||
message: translate(
|
||||
'auto.components.right.sidebar.SourceControl.d7492cafce',
|
||||
'Could not refresh Source Control. Retry Create PR.'
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (shouldAttemptCreateHostedReviewForIntent(eligibility)) {
|
||||
await createHostedReviewForCreatePrIntent(token, eligibility)
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
@@ -3963,6 +3997,13 @@ function SourceControlInner(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
|
||||
const branchAhead =
|
||||
eligibility.blockedReason === 'no_upstream'
|
||||
? await refreshBranchCompareForCreatePrIntent(token)
|
||||
: undefined
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
}
|
||||
const remoteStep = resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus: latestUpstreamStatus,
|
||||
hostedReviewCreation: eligibility,
|
||||
@@ -4047,19 +4088,23 @@ function SourceControlInner(): React.JSX.Element {
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
}
|
||||
if (eligibility?.canCreate) {
|
||||
if (eligibility && shouldAttemptCreateHostedReviewForIntent(eligibility)) {
|
||||
await createHostedReviewForCreatePrIntent(token, eligibility)
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: prefer the blocked-reason notice (incl. unavailable lookup) over a generic stop.
|
||||
const blockedNotice = resolveBlockedCreateReviewNoticeMessage(eligibility)
|
||||
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
|
||||
tone: 'muted',
|
||||
message: translate(
|
||||
'auto.components.right.sidebar.SourceControl.995c5e67ec',
|
||||
'Review setup needs attention.'
|
||||
)
|
||||
tone: blockedNotice ? 'destructive' : 'muted',
|
||||
message:
|
||||
blockedNotice ??
|
||||
translate(
|
||||
'auto.components.right.sidebar.SourceControl.995c5e67ec',
|
||||
'Review setup needs attention.'
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[SourceControl] Create PR intent failed', error)
|
||||
@@ -4106,6 +4151,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
readHostedReviewCreationEligibilityForIntent,
|
||||
refreshGitStatusForCreatePrIntent,
|
||||
refreshBranchCompareForCreatePrIntent,
|
||||
provisionalHostedReviewProvider,
|
||||
remoteStatus,
|
||||
runRemoteAction,
|
||||
setCreatePrIntentNoticeForWorktree,
|
||||
|
||||
+44
-3
@@ -8,7 +8,9 @@ import {
|
||||
getCreatePrIntentCommitFailureNoticeMessage,
|
||||
getCreatePrIntentStagePaths,
|
||||
resolveCreatePrIntentReviewBase,
|
||||
resolveCreatePrIntentRemoteStep
|
||||
resolveCreatePrIntentRemoteStep,
|
||||
shouldAttemptCreateHostedReviewForIntent,
|
||||
shouldGenerateHostedReviewDetailsForIntent
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
@@ -21,6 +23,7 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature',
|
||||
provider: 'github',
|
||||
baseRef: 'origin/main'
|
||||
})
|
||||
|
||||
@@ -44,7 +47,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature/pr'
|
||||
branch: 'feature/pr',
|
||||
provider: 'github'
|
||||
})
|
||||
|
||||
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
|
||||
@@ -63,7 +67,8 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: wt1Path,
|
||||
branch: 'feature/pr'
|
||||
branch: 'feature/pr',
|
||||
provider: 'github'
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -92,6 +97,7 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
provider: 'github',
|
||||
baseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
|
||||
@@ -283,6 +289,41 @@ describe('source-control Create PR intent flow helpers', () => {
|
||||
).toBe('blocked')
|
||||
})
|
||||
|
||||
it('uses main preflight as the final lookup authority after preparation', () => {
|
||||
const unavailable = {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'unavailable' as const,
|
||||
head: 'feature-branch'
|
||||
}
|
||||
expect(shouldAttemptCreateHostedReviewForIntent(unavailable)).toBe(true)
|
||||
// Loading placeholders share the unavailable/null-reason shape but carry no branch.
|
||||
expect(shouldAttemptCreateHostedReviewForIntent({ ...unavailable, head: undefined })).toBe(
|
||||
false
|
||||
)
|
||||
expect(shouldGenerateHostedReviewDetailsForIntent(unavailable)).toBe(false)
|
||||
expect(
|
||||
shouldGenerateHostedReviewDetailsForIntent({
|
||||
...unavailable,
|
||||
canCreate: true,
|
||||
reviewLookupOutcome: 'not_found'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldAttemptCreateHostedReviewForIntent({
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces the commit failure summary in the Create PR intent notice', () => {
|
||||
expect(
|
||||
getCreatePrIntentCommitFailureNoticeMessage(
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
isBehindOnlyUpstream,
|
||||
shouldForcePushWithLeaseForUpstream
|
||||
} from '../../../../shared/git-upstream-status'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import type {
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
@@ -24,6 +27,7 @@ export type CreatePrIntentRunToken = {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
branch: string
|
||||
provider: HostedReviewProvider
|
||||
baseRef?: string | null
|
||||
startedAt: number
|
||||
}
|
||||
@@ -162,6 +166,25 @@ export function resolveCreatePrIntentRemoteStep({
|
||||
return 'none'
|
||||
}
|
||||
|
||||
export function shouldAttemptCreateHostedReviewForIntent(
|
||||
eligibility: HostedReviewCreationEligibility
|
||||
): boolean {
|
||||
return (
|
||||
eligibility.canCreate ||
|
||||
// Why: `head` separates a real unavailable-lookup result from a loading
|
||||
// placeholder, which carries the same outcome/reason pair but no branch.
|
||||
(eligibility.reviewLookupOutcome === 'unavailable' &&
|
||||
eligibility.blockedReason === null &&
|
||||
Boolean(eligibility.head?.trim()))
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldGenerateHostedReviewDetailsForIntent(
|
||||
eligibility: HostedReviewCreationEligibility
|
||||
): boolean {
|
||||
return eligibility.canCreate
|
||||
}
|
||||
|
||||
export function getCreatePrIntentCommitFailureNoticeMessage(
|
||||
commitError: string | null | undefined,
|
||||
copy: {
|
||||
|
||||
+47
-1
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canClickBlockedCreateReviewReason,
|
||||
resolveBlockedCreateReviewNoticeMessage
|
||||
resolveBlockedCreateReviewNoticeMessage,
|
||||
resolveUnavailableCreateReviewLookupNoticeMessage
|
||||
} from './source-control-create-review-blocked-action'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
|
||||
@@ -62,6 +63,51 @@ describe('source-control-create-review-blocked-action', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a known dirty-tree prerequisite when review lookup is unavailable', () => {
|
||||
expect(
|
||||
resolveBlockedCreateReviewNoticeMessage(
|
||||
eligibility({
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
)
|
||||
).toBe('Create PR failed: commit or discard local changes before creating a pull request.')
|
||||
})
|
||||
|
||||
it('preserves authentication guidance when review lookup is unavailable', () => {
|
||||
expect(
|
||||
resolveBlockedCreateReviewNoticeMessage(
|
||||
eligibility({
|
||||
provider: 'gitlab',
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Create MR failed: GitLab is not authenticated. Next step: Run glab auth login in this environment.'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports unavailable review lookup authority when no local blocker is known', () => {
|
||||
expect(resolveUnavailableCreateReviewLookupNoticeMessage('gitlab')).toBe(
|
||||
'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.'
|
||||
)
|
||||
expect(
|
||||
resolveBlockedCreateReviewNoticeMessage(
|
||||
eligibility({
|
||||
provider: 'gitlab',
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Create MR failed: Orca could not confirm whether this branch already has a merge request. Retry once the GitLab lookup succeeds.'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when the blocked reason should remain non-clickable', () => {
|
||||
expect(
|
||||
resolveBlockedCreateReviewNoticeMessage(
|
||||
|
||||
+10
@@ -36,6 +36,13 @@ export function resolveHostedReviewAuthInstruction(provider: HostedReviewProvide
|
||||
return 'Run gh auth login'
|
||||
}
|
||||
|
||||
export function resolveUnavailableCreateReviewLookupNoticeMessage(
|
||||
provider: HostedReviewProvider
|
||||
): string {
|
||||
const copy = localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider))
|
||||
return `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.`
|
||||
}
|
||||
|
||||
export function resolveBlockedCreateReviewNoticeMessage(
|
||||
eligibility: HostedReviewCreationEligibility | null | undefined
|
||||
): string | null {
|
||||
@@ -43,6 +50,9 @@ export function resolveBlockedCreateReviewNoticeMessage(
|
||||
return null
|
||||
}
|
||||
const reason = eligibility.blockedReason
|
||||
if (eligibility.reviewLookupOutcome === 'unavailable' && reason === null) {
|
||||
return resolveUnavailableCreateReviewLookupNoticeMessage(eligibility.provider)
|
||||
}
|
||||
if (!canClickBlockedCreateReviewReason(reason)) {
|
||||
return null
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveDropdownItems,
|
||||
type DropdownActionInputs,
|
||||
type DropdownItem
|
||||
} from './source-control-dropdown-items'
|
||||
|
||||
// Why: a shared defaults object keeps each case row terse while making the
|
||||
// "this is the one knob that differs from the baseline" intent obvious.
|
||||
function inputs(overrides: Partial<DropdownActionInputs> = {}): DropdownActionInputs {
|
||||
return {
|
||||
stagedCount: 0,
|
||||
hasUnstagedChanges: false,
|
||||
hasStageableChanges: false,
|
||||
hasPartiallyStagedChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
isCommitting: false,
|
||||
isRemoteOperationActive: false,
|
||||
upstreamStatus: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveDropdownItems Create PR intent', () => {
|
||||
it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'push',
|
||||
provider: 'github' as const,
|
||||
upstreamStatus: { hasUpstream: true as const, ahead: 2, behind: 0 },
|
||||
blockedReason: 'needs_push' as const,
|
||||
expectedTitle: 'Push local commits before creating a pull request'
|
||||
},
|
||||
{
|
||||
name: 'force push',
|
||||
provider: 'gitlab' as const,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true as const,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
},
|
||||
blockedReason: 'needs_sync' as const,
|
||||
expectedTitle: 'Force push with lease before creating a merge request'
|
||||
}
|
||||
])(
|
||||
'keeps $name-before-review recovery available when review lookup is unavailable',
|
||||
({ provider, upstreamStatus, blockedReason, expectedTitle }) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
branchCommitsAhead: 2,
|
||||
upstreamStatus,
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason,
|
||||
nextAction: blockedReason === 'needs_push' ? 'push' : 'sync',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}
|
||||
})
|
||||
)
|
||||
const pushCreate = items.find((item): item is DropdownItem => item.kind === 'push_create_pr')
|
||||
|
||||
expect(pushCreate?.disabled).toBe(false)
|
||||
expect(pushCreate?.title).toBe(expectedTitle)
|
||||
expect(pushCreate?.hint).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses GitLab MR copy for create and push-before-create rows', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create MR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before MR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'enables push-before-PR recovery for %s review creation',
|
||||
(provider) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create PR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
|
||||
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
|
||||
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe(hint)
|
||||
})
|
||||
|
||||
it('uses GitLab auth copy when MR creation needs authentication', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment')
|
||||
})
|
||||
})
|
||||
@@ -663,125 +663,6 @@ describe('resolveDropdownItems', () => {
|
||||
'Try a fast-forward pull; git may reject local commits'
|
||||
)
|
||||
})
|
||||
|
||||
it('enables the push-before-PR recovery action when review creation is only blocked by unpushed commits', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('uses GitLab MR copy for create and push-before-create rows', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create MR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before MR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a merge request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['azure-devops', 'gitea'] as const)(
|
||||
'enables push-before-PR recovery for %s review creation',
|
||||
(provider) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.label).toBe('Create PR')
|
||||
expect(byKind.create_pr.hint).toBe('Push first')
|
||||
expect(byKind.create_pr.disabled).toBe(false)
|
||||
expect(byKind.push_create_pr.label).toBe('Push before PR')
|
||||
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
|
||||
expect(byKind.push_create_pr.disabled).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
|
||||
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
|
||||
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe(hint)
|
||||
})
|
||||
|
||||
it('uses GitLab auth copy when MR creation needs authentication', () => {
|
||||
const items = resolveDropdownItems(
|
||||
inputs({
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
provider: 'gitlab',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate',
|
||||
reviewLookupOutcome: 'not_found'
|
||||
}
|
||||
})
|
||||
)
|
||||
const byKind = Object.fromEntries(
|
||||
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
|
||||
)
|
||||
expect(byKind.create_pr.hint).toBe('Run glab auth login in this environment')
|
||||
})
|
||||
})
|
||||
|
||||
// Why: PR #8196 — drive the real push-target resolution the component uses so
|
||||
|
||||
+86
-3
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildCreatePrIntentUnavailableEligibility,
|
||||
buildLocalBlockerHostedReviewCreationEligibility,
|
||||
resolveHostedReviewCreationProviderForTarget
|
||||
} from './source-control-hosted-review-creation-eligibility-snapshot'
|
||||
@@ -32,7 +33,7 @@ describe('resolveHostedReviewCreationProviderForTarget', () => {
|
||||
})
|
||||
|
||||
describe('buildLocalBlockerHostedReviewCreationEligibility', () => {
|
||||
it('reports dirty without offering create intent when the review lookup failed', () => {
|
||||
it('reports dirty with unavailable lookup while still allowing prepare-only Create PR intent', () => {
|
||||
const eligibility = buildLocalBlockerHostedReviewCreationEligibility('github', {
|
||||
...featureBranch,
|
||||
hasUncommittedChanges: true,
|
||||
@@ -45,7 +46,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => {
|
||||
nextAction: 'commit',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
// Why: branch guidance can remain specific, but a failed lookup cannot authorize creation.
|
||||
// Why: branch prep is safe without lookup authority; final create stays fail-closed in main.
|
||||
expect(
|
||||
resolveCreatePrIntentEligibility({
|
||||
stagedCount: 1,
|
||||
@@ -56,7 +57,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => {
|
||||
hostedReviewCreation: eligibility,
|
||||
branchCommitsAhead: 0
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
).toEqual({ eligible: true, kind: 'dirty' })
|
||||
})
|
||||
|
||||
it('prefers dirty over no_upstream when both apply, matching main-process ordering', () => {
|
||||
@@ -122,6 +123,19 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the default branch is unknown', () => {
|
||||
expect(
|
||||
buildLocalBlockerHostedReviewCreationEligibility('github', {
|
||||
branch: 'main',
|
||||
baseRef: null,
|
||||
hasUncommittedChanges: true,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a detached HEAD', () => {
|
||||
expect(
|
||||
buildLocalBlockerHostedReviewCreationEligibility('github', {
|
||||
@@ -185,3 +199,72 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCreatePrIntentUnavailableEligibility', () => {
|
||||
it('keeps a rejected click-time probe moving through push and final preflight', () => {
|
||||
expect(
|
||||
buildCreatePrIntentUnavailableEligibility('github', {
|
||||
...featureBranch,
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 2,
|
||||
behind: 0
|
||||
})
|
||||
).toMatchObject({
|
||||
blockedReason: 'needs_push',
|
||||
nextAction: 'push',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
expect(
|
||||
buildCreatePrIntentUnavailableEligibility('github', {
|
||||
...featureBranch,
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).toMatchObject({
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('never synthesizes final intent eligibility for the default branch', () => {
|
||||
expect(
|
||||
buildCreatePrIntentUnavailableEligibility('github', {
|
||||
branch: 'main',
|
||||
baseRef: 'origin/main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('never synthesizes intent eligibility when the default branch is unknown', () => {
|
||||
expect(
|
||||
buildCreatePrIntentUnavailableEligibility('github', {
|
||||
branch: 'main',
|
||||
baseRef: null,
|
||||
hasUncommittedChanges: true,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('never synthesizes intent eligibility for an unsupported remote provider', () => {
|
||||
expect(
|
||||
buildCreatePrIntentUnavailableEligibility('bitbucket', {
|
||||
...featureBranch,
|
||||
hasUncommittedChanges: true,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+68
-28
@@ -5,6 +5,33 @@ import type {
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
|
||||
type UnavailableHostedReviewStatus = {
|
||||
branch: string | null | undefined
|
||||
baseRef: string | null | undefined
|
||||
hasUncommittedChanges: boolean
|
||||
hasUpstream: boolean | undefined
|
||||
ahead: number | undefined
|
||||
behind: number | undefined
|
||||
}
|
||||
|
||||
function resolveUnavailableHostedReviewBranch(
|
||||
provider: HostedReviewProvider,
|
||||
status: UnavailableHostedReviewStatus
|
||||
): string | null {
|
||||
const branch = status.branch?.trim() ?? ''
|
||||
const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim()
|
||||
if (
|
||||
branch === '' ||
|
||||
branch === 'HEAD' ||
|
||||
baseBranch === '' ||
|
||||
!supportsHostedReviewCreation(provider) ||
|
||||
branch.toLowerCase() === baseBranch.toLowerCase()
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return branch
|
||||
}
|
||||
|
||||
export function buildLoadingHostedReviewCreationEligibility(
|
||||
provider: HostedReviewProvider
|
||||
): HostedReviewCreationEligibility {
|
||||
@@ -41,44 +68,31 @@ export function resolveHostedReviewCreationProviderForTarget(
|
||||
* times out, so the UI can show branch guidance without treating the failed
|
||||
* lookup as authority to create a review.
|
||||
*
|
||||
* Mirrors the main process's own lookup-failure fallback exactly — both its
|
||||
* `canReturnLocalBlocker` guard and its blocker ordering
|
||||
* (`src/main/source-control/hosted-review-creation.ts`). The guard is
|
||||
* load-bearing: without it a failed probe on the default branch or a detached
|
||||
* HEAD would synthesize `dirty`/`commit` and surface an *enabled* Create PR
|
||||
* that commits onto the base branch, where the real probe returns
|
||||
* `default_branch`/`detached_head` (disabled). Returns null when no local
|
||||
* blocker is determinable (unknown upstream, ahead-only, fully synced) so the
|
||||
* caller can surface the retryable state — matching main, which won't offer a
|
||||
* push it can't first auth-check.
|
||||
* Blocker ordering matches main (`dirty` → `no_upstream` → `needs_sync`). The
|
||||
* branch/base guard is load-bearing and intentionally stricter than main when
|
||||
* base is unknown: without a known base, a failed probe must not synthesize
|
||||
* `dirty`/`commit` on what might be the default branch (main would return
|
||||
* `default_branch`/`detached_head` and keep Create disabled). Returns null
|
||||
* when no local blocker is determinable (unknown base, unknown upstream,
|
||||
* ahead-only, fully synced) so the caller can surface the retryable state —
|
||||
* matching main, which won't offer a push it can't first auth-check.
|
||||
*/
|
||||
export function buildLocalBlockerHostedReviewCreationEligibility(
|
||||
provider: HostedReviewProvider,
|
||||
status: {
|
||||
branch: string | null | undefined
|
||||
baseRef: string | null | undefined
|
||||
hasUncommittedChanges: boolean
|
||||
hasUpstream: boolean | undefined
|
||||
ahead: number | undefined
|
||||
behind: number | undefined
|
||||
}
|
||||
status: UnavailableHostedReviewStatus
|
||||
): HostedReviewCreationEligibility | null {
|
||||
const branch = status.branch?.trim() ?? ''
|
||||
const baseBranch = normalizeHostedReviewBaseRef(status.baseRef ?? '').trim()
|
||||
const canReturnLocalBlocker =
|
||||
branch !== '' &&
|
||||
branch !== 'HEAD' &&
|
||||
supportsHostedReviewCreation(provider) &&
|
||||
(baseBranch === '' || branch.toLowerCase() !== baseBranch.toLowerCase()) &&
|
||||
(status.hasUncommittedChanges || status.hasUpstream !== true || (status.behind ?? 0) > 0)
|
||||
if (!canReturnLocalBlocker) {
|
||||
const branch = resolveUnavailableHostedReviewBranch(provider, status)
|
||||
if (
|
||||
!branch ||
|
||||
(!status.hasUncommittedChanges && status.hasUpstream === true && (status.behind ?? 0) === 0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const base = {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false as const,
|
||||
defaultBaseRef: null,
|
||||
defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(),
|
||||
head: branch,
|
||||
// Why: local Git blockers cannot prove that a hosted review does not exist.
|
||||
reviewLookupOutcome: 'unavailable' as const
|
||||
@@ -96,3 +110,29 @@ export function buildLocalBlockerHostedReviewCreationEligibility(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildCreatePrIntentUnavailableEligibility(
|
||||
provider: HostedReviewProvider,
|
||||
status: UnavailableHostedReviewStatus
|
||||
): HostedReviewCreationEligibility | null {
|
||||
const localBlocker = buildLocalBlockerHostedReviewCreationEligibility(provider, status)
|
||||
if (localBlocker) {
|
||||
return localBlocker
|
||||
}
|
||||
const branch = resolveUnavailableHostedReviewBranch(provider, status)
|
||||
if (!branch || status.hasUpstream !== true) {
|
||||
return null
|
||||
}
|
||||
const base = {
|
||||
provider,
|
||||
review: null,
|
||||
canCreate: false as const,
|
||||
defaultBaseRef: normalizeHostedReviewBaseRef(status.baseRef ?? '').trim(),
|
||||
head: branch,
|
||||
reviewLookupOutcome: 'unavailable' as const
|
||||
}
|
||||
if ((status.ahead ?? 0) > 0) {
|
||||
return { ...base, blockedReason: 'needs_push', nextAction: 'push' }
|
||||
}
|
||||
return { ...base, blockedReason: null, nextAction: null }
|
||||
}
|
||||
|
||||
+26
@@ -177,6 +177,32 @@ describe('resolvePrimaryAction Create PR intent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Create PR intent for a dirty tree when review lookup is unavailable', () => {
|
||||
const input = inputs({
|
||||
hasUnstagedChanges: true,
|
||||
hasStageableChanges: true,
|
||||
upstreamStatus: upstreamInSync,
|
||||
hostedReviewCreation: {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'dirty',
|
||||
nextAction: 'commit',
|
||||
defaultBaseRef: 'main',
|
||||
reviewLookupOutcome: 'unavailable'
|
||||
}
|
||||
})
|
||||
|
||||
expect(resolvePrimaryAction(input)).toMatchObject({
|
||||
kind: 'create_pr_intent',
|
||||
disabled: false
|
||||
})
|
||||
expect(resolveCreatePrHeaderAction(input)).toMatchObject({
|
||||
kind: 'create_pr_intent',
|
||||
disabled: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
|
||||
const input = inputs({
|
||||
stagedCount: 1,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveCreateReviewIntentEligibility,
|
||||
type CreateReviewIntentKind
|
||||
} from './source-control-create-review-intent'
|
||||
import type { HostedReviewCreationBlockedReason } from './hosted-review'
|
||||
import type { GitUpstreamStatus } from './git-status-types'
|
||||
|
||||
function unavailableEligibility(blockedReason: HostedReviewCreationBlockedReason) {
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason,
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'main',
|
||||
reviewLookupOutcome: 'unavailable' as const
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveCreateReviewIntentEligibility', () => {
|
||||
it('rejects unavailable eligibility when the default branch is unknown', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 1,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
...unavailableEligibility('dirty'),
|
||||
defaultBaseRef: null
|
||||
}
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
})
|
||||
|
||||
it('rejects unavailable eligibility when the default branch is blank', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 1,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
...unavailableEligibility('dirty'),
|
||||
defaultBaseRef: ' '
|
||||
}
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
})
|
||||
|
||||
it('stays ineligible when no local blocker remains under unavailable lookup', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 0,
|
||||
hasStageableChanges: false,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: {
|
||||
...unavailableEligibility('dirty'),
|
||||
blockedReason: null
|
||||
}
|
||||
})
|
||||
).toEqual({ eligible: false, kind: null })
|
||||
})
|
||||
|
||||
it('keeps dirty local preparation eligible when review lookup is unavailable', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 0,
|
||||
hasStageableChanges: true,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: unavailableEligibility('dirty')
|
||||
})
|
||||
).toEqual({ eligible: true, kind: 'dirty' })
|
||||
})
|
||||
|
||||
it('still requires a message before committing staged changes without lookup authority', () => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount: 1,
|
||||
hasStageableChanges: false,
|
||||
hasMessage: false,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
|
||||
hostedReviewCreation: unavailableEligibility('dirty')
|
||||
})
|
||||
).toEqual({ eligible: true, kind: 'message_required' })
|
||||
})
|
||||
|
||||
it.each<{
|
||||
blockedReason: HostedReviewCreationBlockedReason
|
||||
blockedKind: CreateReviewIntentKind
|
||||
stagedCount?: number
|
||||
hasStageableChanges?: boolean
|
||||
branchCommitsAhead?: number
|
||||
upstreamStatus?: GitUpstreamStatus
|
||||
}>([
|
||||
{
|
||||
blockedReason: 'no_upstream',
|
||||
blockedKind: 'no_upstream',
|
||||
branchCommitsAhead: 1,
|
||||
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_push',
|
||||
blockedKind: 'needs_push',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_sync',
|
||||
blockedKind: 'needs_sync',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 0,
|
||||
behind: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
blockedReason: 'needs_sync',
|
||||
blockedKind: 'force_push',
|
||||
branchCommitsAhead: 1,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
}
|
||||
])(
|
||||
'keeps recoverable $blockedKind preparation eligible when review lookup is unavailable',
|
||||
({
|
||||
blockedReason,
|
||||
blockedKind,
|
||||
stagedCount = 0,
|
||||
hasStageableChanges = false,
|
||||
branchCommitsAhead,
|
||||
upstreamStatus
|
||||
}) => {
|
||||
expect(
|
||||
resolveCreateReviewIntentEligibility({
|
||||
stagedCount,
|
||||
hasStageableChanges,
|
||||
hasMessage: true,
|
||||
hasUnresolvedConflicts: false,
|
||||
upstreamStatus,
|
||||
hostedReviewCreation: unavailableEligibility(blockedReason),
|
||||
branchCommitsAhead
|
||||
})
|
||||
).toEqual({ eligible: true, kind: blockedKind })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -41,16 +41,20 @@ export function resolveCreateReviewIntentEligibility({
|
||||
!hasCurrentBranch ||
|
||||
!hostedReviewCreation ||
|
||||
hostedReviewCreation.canCreate ||
|
||||
// Fail closed when the existing-review lookup could not prove there is no
|
||||
// review: a local blocker (e.g. needs_push) returned after a failed lookup
|
||||
// must not offer a Create PR intent that would push under a false promise —
|
||||
// the main preflight would refuse the create anyway (invariant 8).
|
||||
hostedReviewCreation.reviewLookupOutcome === 'unavailable' ||
|
||||
!supportsHostedReviewCreation(hostedReviewCreation.provider)
|
||||
) {
|
||||
return { eligible: false, kind: null }
|
||||
}
|
||||
|
||||
if (
|
||||
hostedReviewCreation.reviewLookupOutcome === 'unavailable' &&
|
||||
!hostedReviewCreation.defaultBaseRef?.trim()
|
||||
) {
|
||||
return { eligible: false, kind: null }
|
||||
}
|
||||
|
||||
// Why: safe branch preparation can continue without lookup authority; the
|
||||
// main create preflight still fails closed before creating a duplicate.
|
||||
if (hostedReviewCreation.blockedReason === 'dirty') {
|
||||
if (stagedCount > 0 && !hasMessage) {
|
||||
return { eligible: true, kind: 'message_required' }
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { TestInfo } from '@stablyai/playwright-test'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
createStagedCommitMessageChange,
|
||||
openSourceControl,
|
||||
seedCreatePrComposer
|
||||
} from './helpers/source-control-ai-generation'
|
||||
|
||||
async function writeEvidence(
|
||||
testInfo: TestInfo,
|
||||
screenshotDir: string,
|
||||
filename: string,
|
||||
evidence: unknown
|
||||
): Promise<void> {
|
||||
const evidencePath = path.join(screenshotDir, filename)
|
||||
writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
await testInfo.attach(filename, {
|
||||
path: evidencePath,
|
||||
contentType: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
function removeOriginRemoteIfPresent(cwd: string): void {
|
||||
// Why: check presence instead of swallowing errors, so real Git failures still surface.
|
||||
const remotes = execFileSync('git', ['remote'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
if (!remotes.includes('origin')) {
|
||||
return
|
||||
}
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], { cwd, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
test.describe('Source Control Create PR intent worktree switching', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('keeps Create PR intent running after switching worktrees', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } =
|
||||
await seedCreatePrComposer(orcaPage)
|
||||
|
||||
const screenshotDir = path.join(
|
||||
process.cwd(),
|
||||
'validation-screenshots',
|
||||
`create-pr-intent-switch-${Date.now()}`
|
||||
)
|
||||
mkdirSync(screenshotDir, { recursive: true })
|
||||
await testInfo.attach('validation-screenshot-dir', {
|
||||
body: screenshotDir,
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
await orcaPage.evaluate(
|
||||
({ prWorktreeId, primaryBranch }) => {
|
||||
const store =
|
||||
window.__store ??
|
||||
(() => {
|
||||
throw new Error('window.__store is not available')
|
||||
})()
|
||||
const state = store.getState()
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === prWorktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error('Create PR intent worktree not found')
|
||||
}
|
||||
const repo = state.repos.find((entry) => entry.id === worktree.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('Create PR intent repo not found')
|
||||
}
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
|
||||
type CreatePrIntentHostedReviewCall = {
|
||||
repoPath: string
|
||||
input: {
|
||||
base?: string
|
||||
head?: string
|
||||
worktreePath?: string
|
||||
}
|
||||
}
|
||||
const testWindow = window as unknown as {
|
||||
__createPRIntentPayloads: CreatePrIntentHostedReviewCall[]
|
||||
__createPRIntentPushStarted: boolean
|
||||
__createPRIntentPushFinished: boolean
|
||||
}
|
||||
testWindow.__createPRIntentPayloads = []
|
||||
testWindow.__createPRIntentPushStarted = false
|
||||
testWindow.__createPRIntentPushFinished = false
|
||||
store.setState((current) => ({
|
||||
getHostedReviewCreationEligibility: async () => {
|
||||
// Why: eligibility stays blocked until the delayed push completes,
|
||||
// so this test exercises navigation during an in-flight intent run.
|
||||
if (!testWindow.__createPRIntentPushFinished) {
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push' as const,
|
||||
nextAction: 'push' as const,
|
||||
defaultBaseRef: primaryBranch,
|
||||
head: branch
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
defaultBaseRef: primaryBranch,
|
||||
title: 'Create PR intent after switching worktrees',
|
||||
body: 'The intent flow should continue after navigation.',
|
||||
head: branch
|
||||
}
|
||||
},
|
||||
fetchHostedReviewForBranch: async () => null,
|
||||
fetchPRForBranch: async () => null,
|
||||
pushBranch: async (worktreeId) => {
|
||||
if (worktreeId !== prWorktreeId) {
|
||||
throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`)
|
||||
}
|
||||
testWindow.__createPRIntentPushStarted = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
testWindow.__createPRIntentPushFinished = true
|
||||
},
|
||||
createHostedReview: async (repoPath, input) => {
|
||||
testWindow.__createPRIntentPayloads.push({ repoPath, input })
|
||||
return {
|
||||
ok: true as const,
|
||||
number: 74,
|
||||
url: 'https://github.com/acme/orca/pull/74'
|
||||
}
|
||||
},
|
||||
gitStatusByWorktree: {
|
||||
...current.gitStatusByWorktree,
|
||||
[worktree.id]: []
|
||||
},
|
||||
remoteStatusesByWorktree: {
|
||||
...current.remoteStatusesByWorktree,
|
||||
[worktree.id]: {
|
||||
hasUpstream: true,
|
||||
upstreamName: `origin/${branch}`,
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
{ prWorktreeId, primaryBranch }
|
||||
)
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first()
|
||||
await expect(createPr).toBeVisible({ timeout: 10_000 })
|
||||
await expect(createPr).toBeEnabled()
|
||||
await createPr.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __createPRIntentPushStarted: boolean })
|
||||
.__createPRIntentPushStarted
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await openSourceControl(orcaPage, primaryWorktreeId)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __createPRIntentPayloads: unknown[] })
|
||||
.__createPRIntentPayloads.length
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
return {
|
||||
activeWorktreeId: state?.activeWorktreeId,
|
||||
rightSidebarTab: state?.rightSidebarTab
|
||||
}
|
||||
})
|
||||
expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId)
|
||||
expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control')
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
const payloads = await orcaPage.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__createPRIntentPayloads: {
|
||||
repoPath: string
|
||||
input: { base?: string; head?: string; worktreePath?: string }
|
||||
}[]
|
||||
}
|
||||
).__createPRIntentPayloads
|
||||
)
|
||||
expect(payloads).toHaveLength(1)
|
||||
expect(payloads[0]).toMatchObject({
|
||||
input: {
|
||||
base: primaryBranch,
|
||||
head: 'e2e-secondary',
|
||||
worktreePath: prWorktreePath
|
||||
}
|
||||
})
|
||||
await orcaPage.screenshot({
|
||||
path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png')
|
||||
})
|
||||
await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', {
|
||||
expectedOriginalWorktreeId: prWorktreeId,
|
||||
expectedOtherWorktreeId: primaryWorktreeId,
|
||||
completedWhileSwitched: completedWhileSwitchedEvidence,
|
||||
payloads
|
||||
})
|
||||
})
|
||||
|
||||
test('carries unavailable dirty intent through push to the final create preflight', async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const { prWorktreeId, prWorktreePath } = await seedCreatePrComposer(orcaPage)
|
||||
const remoteRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-create-pr-remote-'))
|
||||
const remotePath = path.join(remoteRoot, 'origin.git')
|
||||
execFileSync('git', ['init', '--bare', remotePath])
|
||||
// Why: the seeded worktree may already define origin, so make the add idempotent.
|
||||
removeOriginRemoteIfPresent(prWorktreePath)
|
||||
execFileSync('git', ['remote', 'add', 'origin', remotePath], { cwd: prWorktreePath })
|
||||
registerPostElectronShutdownCleanup(async () => {
|
||||
removeOriginRemoteIfPresent(prWorktreePath)
|
||||
rmSync(remoteRoot, { recursive: true, force: true })
|
||||
})
|
||||
createStagedCommitMessageChange(prWorktreePath)
|
||||
|
||||
const finalCreateError = 'Unavailable lookup intent reached final create preflight'
|
||||
await orcaPage.evaluate(
|
||||
({ prWorktreeId, finalCreateError }) => {
|
||||
const store =
|
||||
window.__store ??
|
||||
(() => {
|
||||
throw new Error('window.__store is not available')
|
||||
})()
|
||||
const state = store.getState()
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === prWorktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error('Create PR intent worktree not found')
|
||||
}
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
const pushBranchAction = state.pushBranch
|
||||
const testWindow = window as unknown as {
|
||||
__unavailableIntentPushFinished: boolean
|
||||
}
|
||||
testWindow.__unavailableIntentPushFinished = false
|
||||
|
||||
store.setState((current) => ({
|
||||
repos: current.repos.map((repo) =>
|
||||
repo.id === worktree.repoId
|
||||
? {
|
||||
...repo,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/acme/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/acme/orca.git'
|
||||
}
|
||||
}
|
||||
: repo
|
||||
),
|
||||
remoteStatusesByWorktree: {
|
||||
...current.remoteStatusesByWorktree,
|
||||
[prWorktreeId]: {
|
||||
hasUpstream: true,
|
||||
upstreamName: `origin/${branch}`,
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
},
|
||||
getHostedReviewCreationEligibility: async () => {
|
||||
throw new Error('Hosted review eligibility timed out')
|
||||
},
|
||||
pushBranch: async (...args: Parameters<typeof pushBranchAction>) => {
|
||||
const [worktreeId] = args
|
||||
if (worktreeId !== prWorktreeId) {
|
||||
throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`)
|
||||
}
|
||||
await pushBranchAction(...args)
|
||||
testWindow.__unavailableIntentPushFinished = true
|
||||
},
|
||||
createHostedReview: async () => ({
|
||||
ok: false as const,
|
||||
code: 'validation' as const,
|
||||
error: finalCreateError
|
||||
})
|
||||
}))
|
||||
},
|
||||
{ prWorktreeId, finalCreateError }
|
||||
)
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
await expect(orcaPage.getByText('e2e-commit-message-generation.txt')).toBeVisible({
|
||||
timeout: 10_000
|
||||
})
|
||||
await orcaPage
|
||||
.getByRole('textbox', { name: 'Commit message' })
|
||||
.fill('Exercise unavailable Create PR intent')
|
||||
const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first()
|
||||
await expect(createPr).toBeEnabled()
|
||||
await createPr.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __unavailableIntentPushFinished: boolean })
|
||||
.__unavailableIntentPushFinished
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(orcaPage.getByText(finalCreateError)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
@@ -314,196 +314,6 @@ test.describe('Source Control AI PR generation worktree switching', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps Create PR intent running after switching worktrees', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const { primaryWorktreeId, prWorktreeId, prWorktreePath, primaryBranch } =
|
||||
await seedCreatePrComposer(orcaPage)
|
||||
|
||||
const screenshotDir = path.join(
|
||||
process.cwd(),
|
||||
'validation-screenshots',
|
||||
`create-pr-intent-switch-${Date.now()}`
|
||||
)
|
||||
mkdirSync(screenshotDir, { recursive: true })
|
||||
await testInfo.attach('validation-screenshot-dir', {
|
||||
body: screenshotDir,
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
await orcaPage.evaluate(
|
||||
({ prWorktreeId, primaryBranch }) => {
|
||||
const store =
|
||||
window.__store ??
|
||||
(() => {
|
||||
throw new Error('window.__store is not available')
|
||||
})()
|
||||
const state = store.getState()
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((entry) => entry.id === prWorktreeId)
|
||||
if (!worktree) {
|
||||
throw new Error('Create PR intent worktree not found')
|
||||
}
|
||||
const repo = state.repos.find((entry) => entry.id === worktree.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('Create PR intent repo not found')
|
||||
}
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
|
||||
type CreatePrIntentHostedReviewCall = {
|
||||
repoPath: string
|
||||
input: {
|
||||
base?: string
|
||||
head?: string
|
||||
worktreePath?: string
|
||||
}
|
||||
}
|
||||
const testWindow = window as unknown as {
|
||||
__createPRIntentPayloads: CreatePrIntentHostedReviewCall[]
|
||||
__createPRIntentPushStarted: boolean
|
||||
__createPRIntentPushFinished: boolean
|
||||
}
|
||||
testWindow.__createPRIntentPayloads = []
|
||||
testWindow.__createPRIntentPushStarted = false
|
||||
testWindow.__createPRIntentPushFinished = false
|
||||
store.setState((current) => ({
|
||||
getHostedReviewCreationEligibility: async () => {
|
||||
// Why: eligibility stays blocked until the delayed push completes,
|
||||
// so this test exercises navigation during an in-flight intent run.
|
||||
if (!testWindow.__createPRIntentPushFinished) {
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: false,
|
||||
blockedReason: 'needs_push' as const,
|
||||
nextAction: 'push' as const,
|
||||
defaultBaseRef: primaryBranch,
|
||||
head: branch
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: 'github' as const,
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
defaultBaseRef: primaryBranch,
|
||||
title: 'Create PR intent after switching worktrees',
|
||||
body: 'The intent flow should continue after navigation.',
|
||||
head: branch
|
||||
}
|
||||
},
|
||||
fetchHostedReviewForBranch: async () => null,
|
||||
fetchPRForBranch: async () => null,
|
||||
pushBranch: async (worktreeId) => {
|
||||
if (worktreeId !== prWorktreeId) {
|
||||
throw new Error(`Create PR intent pushed unexpected worktree ${worktreeId}`)
|
||||
}
|
||||
testWindow.__createPRIntentPushStarted = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
testWindow.__createPRIntentPushFinished = true
|
||||
},
|
||||
createHostedReview: async (repoPath, input) => {
|
||||
testWindow.__createPRIntentPayloads.push({ repoPath, input })
|
||||
return {
|
||||
ok: true as const,
|
||||
number: 74,
|
||||
url: 'https://github.com/acme/orca/pull/74'
|
||||
}
|
||||
},
|
||||
gitStatusByWorktree: {
|
||||
...current.gitStatusByWorktree,
|
||||
[worktree.id]: []
|
||||
},
|
||||
remoteStatusesByWorktree: {
|
||||
...current.remoteStatusesByWorktree,
|
||||
[worktree.id]: {
|
||||
hasUpstream: true,
|
||||
upstreamName: `origin/${branch}`,
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
{ prWorktreeId, primaryBranch }
|
||||
)
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
const createPr = orcaPage.getByRole('button', { name: 'Create PR' }).first()
|
||||
await expect(createPr).toBeVisible({ timeout: 10_000 })
|
||||
await expect(createPr).toBeEnabled()
|
||||
await createPr.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __createPRIntentPushStarted: boolean })
|
||||
.__createPRIntentPushStarted
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await openSourceControl(orcaPage, primaryWorktreeId)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __createPRIntentPayloads: unknown[] })
|
||||
.__createPRIntentPayloads.length
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
const completedWhileSwitchedEvidence = await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
return {
|
||||
activeWorktreeId: state?.activeWorktreeId,
|
||||
rightSidebarTab: state?.rightSidebarTab
|
||||
}
|
||||
})
|
||||
expect(completedWhileSwitchedEvidence.activeWorktreeId).toBe(primaryWorktreeId)
|
||||
expect(completedWhileSwitchedEvidence.rightSidebarTab).toBe('source-control')
|
||||
|
||||
await openSourceControl(orcaPage, prWorktreeId)
|
||||
const payloads = await orcaPage.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__createPRIntentPayloads: {
|
||||
repoPath: string
|
||||
input: { base?: string; head?: string; worktreePath?: string }
|
||||
}[]
|
||||
}
|
||||
).__createPRIntentPayloads
|
||||
)
|
||||
expect(payloads).toHaveLength(1)
|
||||
expect(payloads[0]).toMatchObject({
|
||||
input: {
|
||||
base: primaryBranch,
|
||||
head: 'e2e-secondary',
|
||||
worktreePath: prWorktreePath
|
||||
}
|
||||
})
|
||||
await orcaPage.screenshot({
|
||||
path: path.join(screenshotDir, '01-create-pr-intent-completed-after-switch.png')
|
||||
})
|
||||
await writeEvidence(testInfo, screenshotDir, 'create-pr-intent-switch-evidence.json', {
|
||||
expectedOriginalWorktreeId: prWorktreeId,
|
||||
expectedOtherWorktreeId: primaryWorktreeId,
|
||||
completedWhileSwitched: completedWhileSwitchedEvidence,
|
||||
payloads
|
||||
})
|
||||
})
|
||||
|
||||
test('hydrates pending PR generation after Source Control remounts', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
|
||||
Reference in New Issue
Block a user