Track and sync base ref throughout Create PR intent flow (#5907)
* Track and sync base ref throughout create PR intent flow * Capture and track the review base ref (`baseRef`) directly inside the Create PR intent run token to keep async steps anchored to the base selected when the run started. * Resolve intent flow and eligibility bases safely using the compare-base picker to override stale default refs. * Keep the PR dialog's base field in sync with the active compare base while untouched by the user. * Ignore stale generated PR fields if base ref syncs while in flight Track user-initiated edits to the base field separately using a ref. When the base ref is synced automatically from Source Control, mark the base field dirty to increment its revision. This ensures stale generated fields from an in-flight request cannot overwrite or revert the newly updated base.
This commit is contained in:
parent
97dc6d63e3
commit
6dd82b974d
|
|
@ -236,6 +236,7 @@ import {
|
|||
createPrIntentGitStatusMatchesToken,
|
||||
createPrIntentRunTokenMatches,
|
||||
getCreatePrIntentStagePaths,
|
||||
resolveCreatePrIntentReviewBase,
|
||||
resolveCreatePrIntentRemoteStep,
|
||||
type CreatePrIntentRunToken
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
|
|
@ -979,7 +980,8 @@ function SourceControlInner(): React.JSX.Element {
|
|||
repoId: null as string | null,
|
||||
worktreeId: null as string | null,
|
||||
worktreePath: null as string | null,
|
||||
branch: null as string | null
|
||||
branch: null as string | null,
|
||||
baseRef: null as string | null
|
||||
})
|
||||
const [createPrIntentInFlightByWorktree, setCreatePrIntentInFlightByWorktree] = useState<
|
||||
Record<string, boolean>
|
||||
|
|
@ -1089,14 +1091,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
|
||||
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
|
||||
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
useEffect(() => {
|
||||
createPrIntentCurrentTargetRef.current = {
|
||||
repoId: activeRepo?.id ?? null,
|
||||
worktreeId: activeWorktreeId ?? null,
|
||||
worktreePath,
|
||||
branch: branchName
|
||||
}
|
||||
}, [activeRepo?.id, activeWorktreeId, branchName, worktreePath])
|
||||
const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
|
|
@ -1343,6 +1337,15 @@ function SourceControlInner(): React.JSX.Element {
|
|||
pinnedBaseRef,
|
||||
effectiveBaseRef
|
||||
})
|
||||
useEffect(() => {
|
||||
createPrIntentCurrentTargetRef.current = {
|
||||
repoId: activeRepo?.id ?? null,
|
||||
worktreeId: activeWorktreeId ?? null,
|
||||
worktreePath,
|
||||
branch: branchName,
|
||||
baseRef: effectiveBaseRef ?? null
|
||||
}
|
||||
}, [activeRepo?.id, activeWorktreeId, branchName, effectiveBaseRef, worktreePath])
|
||||
|
||||
const linkedGitHubPR = activeWorktree?.linkedPR ?? null
|
||||
const fallbackGitHubPRNumber = linkedGitHubPR == null ? (activePrFromQueue?.number ?? null) : null
|
||||
|
|
@ -2698,6 +2701,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
worktreePath: worktreePath ?? '',
|
||||
branch: branchName,
|
||||
eligibility: hostedReviewCreation,
|
||||
currentBaseRef: effectiveBaseRef,
|
||||
repo: activeRepo ?? null,
|
||||
settings: activeRepoSettings,
|
||||
submitting: isCreatingPr,
|
||||
|
|
@ -3070,9 +3074,11 @@ function SourceControlInner(): React.JSX.Element {
|
|||
return false
|
||||
}
|
||||
|
||||
const base = stripBaseRef(
|
||||
eligibility.defaultBaseRef ?? effectiveBaseRef ?? prBase ?? ''
|
||||
).trim()
|
||||
const base = resolveCreatePrIntentReviewBase({
|
||||
currentBaseRef: token.baseRef,
|
||||
eligibilityDefaultBaseRef: eligibility.defaultBaseRef,
|
||||
composerBaseRef: prBase
|
||||
}).trim()
|
||||
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(token.branch).toLowerCase()) {
|
||||
setCreatePrIntentNoticeForWorktree(token.worktreeId, {
|
||||
tone: 'destructive',
|
||||
|
|
@ -3256,7 +3262,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
[
|
||||
activeRepo,
|
||||
createHostedReview,
|
||||
effectiveBaseRef,
|
||||
createPrIntentActiveTargetConflicts,
|
||||
createPrIntentRunStillOwnsWorktree,
|
||||
getCreatePrIntentOperationTarget,
|
||||
|
|
@ -3274,11 +3279,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
|
||||
const refreshBranchCompareForCreatePrIntent = useCallback(
|
||||
async (token: CreatePrIntentRunToken): Promise<number | undefined> => {
|
||||
if (!effectiveBaseRef) {
|
||||
const baseRef = token.baseRef?.trim()
|
||||
if (!baseRef) {
|
||||
return undefined
|
||||
}
|
||||
const requestKey = `${token.worktreeId}:${effectiveBaseRef}:${Date.now()}:create-pr-intent`
|
||||
beginGitBranchCompareRequest(token.worktreeId, requestKey, effectiveBaseRef)
|
||||
const requestKey = `${token.worktreeId}:${baseRef}:${Date.now()}:create-pr-intent`
|
||||
beginGitBranchCompareRequest(token.worktreeId, requestKey, baseRef)
|
||||
const result = await getRuntimeGitBranchCompare(
|
||||
{
|
||||
// Why: the intent flow may continue after a worktree switch; use the
|
||||
|
|
@ -3288,12 +3294,12 @@ function SourceControlInner(): React.JSX.Element {
|
|||
worktreePath: token.worktreePath,
|
||||
connectionId: getConnectionId(token.worktreeId) ?? undefined
|
||||
},
|
||||
effectiveBaseRef
|
||||
baseRef
|
||||
)
|
||||
setGitBranchCompareResult(token.worktreeId, requestKey, result)
|
||||
return result.summary.status === 'ready' ? (result.summary.commitsAhead ?? 0) : undefined
|
||||
},
|
||||
[activeRepoSettings, beginGitBranchCompareRequest, effectiveBaseRef, setGitBranchCompareResult]
|
||||
[activeRepoSettings, beginGitBranchCompareRequest, setGitBranchCompareResult]
|
||||
)
|
||||
|
||||
const readHostedReviewCreationEligibilityForIntent = useCallback(
|
||||
|
|
@ -3314,7 +3320,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
repoId: activeRepo.id,
|
||||
worktreePath: token.worktreePath,
|
||||
branch: token.branch,
|
||||
base: effectiveBaseRef ?? null,
|
||||
base: token.baseRef ?? null,
|
||||
hasUncommittedChanges,
|
||||
hasUpstream: upstreamStatus?.hasUpstream,
|
||||
ahead: upstreamStatus?.ahead,
|
||||
|
|
@ -3336,7 +3342,6 @@ function SourceControlInner(): React.JSX.Element {
|
|||
},
|
||||
[
|
||||
activeRepo,
|
||||
effectiveBaseRef,
|
||||
fallbackGitHubPRNumber,
|
||||
getHostedReviewCreationEligibility,
|
||||
linkedAzureDevOpsPR,
|
||||
|
|
@ -3398,7 +3403,10 @@ function SourceControlInner(): React.JSX.Element {
|
|||
repoId: activeRepo.id,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
branch: branchName
|
||||
branch: branchName,
|
||||
// Why: Create PR intent crosses async commit/push steps; the review
|
||||
// target must stay tied to the base selected when the run started.
|
||||
baseRef: effectiveBaseRef ?? null
|
||||
})
|
||||
const operationTarget = getCreatePrIntentOperationTarget(token)
|
||||
const runIsCurrent = (): boolean =>
|
||||
|
|
@ -3622,7 +3630,7 @@ function SourceControlInner(): React.JSX.Element {
|
|||
const remoteOk = await runRemoteAction(remoteStep, {
|
||||
target: operationTarget,
|
||||
remoteStatus: latestUpstreamStatus,
|
||||
baseRef: effectiveBaseRef
|
||||
baseRef: token.baseRef
|
||||
})
|
||||
if (abortIfStale()) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -6,25 +6,33 @@ import {
|
|||
createPrIntentGitStatusMatchesToken,
|
||||
createPrIntentRunTokenMatches,
|
||||
getCreatePrIntentStagePaths,
|
||||
resolveCreatePrIntentReviewBase,
|
||||
resolveCreatePrIntentRemoteStep
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
|
||||
describe('source-control Create PR intent flow helpers', () => {
|
||||
it('matches async completions only to the original repo, worktree, path, and branch', () => {
|
||||
it('matches async completions only to the original repo, worktree, path, branch, and base', () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
|
||||
try {
|
||||
const token = createCreatePrIntentRunToken({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
branch: 'feature'
|
||||
branch: 'feature',
|
||||
baseRef: 'origin/main'
|
||||
})
|
||||
|
||||
expect(token.startedAt).toBe(123)
|
||||
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
|
||||
expect(
|
||||
createPrIntentRunTokenMatches(token, { ...token, baseRef: 'refs/remotes/origin/main' })
|
||||
).toBe(true)
|
||||
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
|
||||
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
|
||||
expect(createPrIntentRunTokenMatches(token, { ...token, baseRef: 'upstream/main' })).toBe(
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
|
|
@ -76,6 +84,47 @@ describe('source-control Create PR intent flow helpers', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats same-worktree base changes as intent conflicts', () => {
|
||||
const worktreePath = join(sep, 'repo', 'wt-1')
|
||||
const token = createCreatePrIntentRunToken({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
baseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
|
||||
expect(
|
||||
createPrIntentCurrentTargetConflictsWithToken(token, {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
baseRef: 'remotes/origin/main'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
createPrIntentCurrentTargetConflictsWithToken(token, {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
baseRef: 'upstream/main'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
createPrIntentCurrentTargetConflictsWithToken(token, {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath,
|
||||
branch: 'feature/pr',
|
||||
baseRef: 'origin/release'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stages only safe unstaged and untracked paths', () => {
|
||||
const unresolved = {
|
||||
path: 'conflicted.ts',
|
||||
|
|
@ -93,6 +142,24 @@ describe('source-control Create PR intent flow helpers', () => {
|
|||
).toEqual(['safe.ts', 'new.ts'])
|
||||
})
|
||||
|
||||
it('prefers the current compare base over stale eligibility defaults', () => {
|
||||
expect(
|
||||
resolveCreatePrIntentReviewBase({
|
||||
currentBaseRef: 'refs/remotes/origin/release',
|
||||
eligibilityDefaultBaseRef: 'refs/remotes/origin/main',
|
||||
composerBaseRef: 'main'
|
||||
})
|
||||
).toBe('release')
|
||||
|
||||
expect(
|
||||
resolveCreatePrIntentReviewBase({
|
||||
currentBaseRef: null,
|
||||
eligibilityDefaultBaseRef: 'refs/remotes/upstream/develop',
|
||||
composerBaseRef: 'main'
|
||||
})
|
||||
).toBe('develop')
|
||||
})
|
||||
|
||||
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
|
||||
expect(
|
||||
resolveCreatePrIntentRemoteStep({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import {
|
||||
normalizeHostedReviewBaseRef,
|
||||
normalizeHostedReviewHeadRef
|
||||
} from '../../../../shared/hosted-review-refs'
|
||||
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
|
||||
import { getStageAllPaths } from './discard-all-sequence'
|
||||
|
||||
|
|
@ -11,6 +14,7 @@ export type CreatePrIntentRunToken = {
|
|||
worktreeId: string
|
||||
worktreePath: string
|
||||
branch: string
|
||||
baseRef?: string | null
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
|
|
@ -19,12 +23,32 @@ export type CreatePrIntentCurrentTarget = {
|
|||
worktreeId?: string | null
|
||||
worktreePath?: string | null
|
||||
branch?: string | null
|
||||
baseRef?: string | null
|
||||
}
|
||||
|
||||
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
|
||||
return { ...input, startedAt: Date.now() }
|
||||
}
|
||||
|
||||
function normalizeCreatePrIntentBaseIdentityRef(ref: string | null | undefined): string {
|
||||
const trimmed = ref?.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
// Why: compare bases are local git refs; origin/main and upstream/main must
|
||||
// stay distinct even though hosted review APIs receive only branch names.
|
||||
if (trimmed.startsWith('refs/remotes/')) {
|
||||
return trimmed.slice('refs/remotes/'.length)
|
||||
}
|
||||
if (trimmed.startsWith('remotes/')) {
|
||||
return trimmed.slice('remotes/'.length)
|
||||
}
|
||||
if (trimmed.startsWith('refs/heads/')) {
|
||||
return trimmed.slice('refs/heads/'.length)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function createPrIntentRunTokenMatches(
|
||||
token: CreatePrIntentRunToken,
|
||||
current: CreatePrIntentCurrentTarget
|
||||
|
|
@ -33,7 +57,9 @@ export function createPrIntentRunTokenMatches(
|
|||
token.repoId === current.repoId &&
|
||||
token.worktreeId === current.worktreeId &&
|
||||
token.worktreePath === current.worktreePath &&
|
||||
token.branch === current.branch
|
||||
token.branch === current.branch &&
|
||||
normalizeCreatePrIntentBaseIdentityRef(token.baseRef) ===
|
||||
normalizeCreatePrIntentBaseIdentityRef(current.baseRef)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +93,22 @@ export function getCreatePrIntentStagePaths(grouped: {
|
|||
]
|
||||
}
|
||||
|
||||
export function resolveCreatePrIntentReviewBase({
|
||||
currentBaseRef,
|
||||
eligibilityDefaultBaseRef,
|
||||
composerBaseRef
|
||||
}: {
|
||||
currentBaseRef?: string | null
|
||||
eligibilityDefaultBaseRef?: string | null
|
||||
composerBaseRef?: string | null
|
||||
}): string {
|
||||
// Why: the compare-base picker is the user's latest target; eligibility can
|
||||
// lag behind while Create PR intent is preparing the branch.
|
||||
return normalizeHostedReviewBaseRef(
|
||||
currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || composerBaseRef?.trim() || ''
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveCreatePrIntentRemoteStep({
|
||||
upstreamStatus,
|
||||
hostedReviewCreation,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import React from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeCreateReviewBaseSearchResults } from './useCreatePullRequestDialogFields'
|
||||
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
|
||||
import {
|
||||
normalizeCreateReviewBaseSearchResults,
|
||||
useCreatePullRequestDialogFields
|
||||
} from './useCreatePullRequestDialogFields'
|
||||
|
||||
describe('normalizeCreateReviewBaseSearchResults', () => {
|
||||
it('uses detailed local branch names for base refs from arbitrary remotes', () => {
|
||||
|
|
@ -32,3 +41,193 @@ describe('normalizeCreateReviewBaseSearchResults', () => {
|
|||
).toEqual(['main', 'release/1.0'])
|
||||
})
|
||||
})
|
||||
|
||||
function createEligibility(
|
||||
overrides: Partial<HostedReviewCreationEligibility> = {}
|
||||
): HostedReviewCreationEligibility {
|
||||
return {
|
||||
provider: 'github',
|
||||
review: null,
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
defaultBaseRef: 'refs/remotes/origin/main',
|
||||
title: 'Review title',
|
||||
body: 'Review body',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type DialogFields = ReturnType<typeof useCreatePullRequestDialogFields>
|
||||
|
||||
type DialogFieldsRenderInput = {
|
||||
eligibility: HostedReviewCreationEligibility
|
||||
currentBaseRef?: string | null
|
||||
}
|
||||
|
||||
function renderDialogFields(input: DialogFieldsRenderInput): {
|
||||
current: () => DialogFields
|
||||
rerender: (nextInput: DialogFieldsRenderInput) => Promise<void>
|
||||
unmount: () => void
|
||||
} {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root: Root = createRoot(container)
|
||||
let latest: DialogFields | null = null
|
||||
let currentInput = input
|
||||
|
||||
function Harness(): null {
|
||||
latest = useCreatePullRequestDialogFields({
|
||||
open: true,
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo/wt',
|
||||
branch: 'feature/base-change',
|
||||
eligibility: currentInput.eligibility,
|
||||
currentBaseRef: currentInput.currentBaseRef,
|
||||
settings: null,
|
||||
submitting: false
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function current(): DialogFields {
|
||||
if (!latest) {
|
||||
throw new Error('dialog fields were not rendered')
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
async function render(): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
current,
|
||||
rerender: async (nextInput) => {
|
||||
currentInput = nextInput
|
||||
await render()
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCreatePullRequestDialogFields', () => {
|
||||
it('updates an untouched base field when the creation default changes for the same branch', async () => {
|
||||
const harness = renderDialogFields({ eligibility: createEligibility() })
|
||||
try {
|
||||
await harness.rerender({ eligibility: createEligibility() })
|
||||
expect(harness.current().base).toBe('main')
|
||||
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility({
|
||||
defaultBaseRef: 'refs/remotes/origin/release'
|
||||
})
|
||||
})
|
||||
|
||||
expect(harness.current().base).toBe('release')
|
||||
expect(harness.current().title).toBe('Review title')
|
||||
expect(harness.current().body).toBe('Review body')
|
||||
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility({
|
||||
defaultBaseRef: 'refs/remotes/origin/develop'
|
||||
})
|
||||
})
|
||||
|
||||
expect(harness.current().base).toBe('develop')
|
||||
} finally {
|
||||
harness.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('prefers the selected current base ref over stale eligibility defaults', async () => {
|
||||
const harness = renderDialogFields({
|
||||
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
|
||||
currentBaseRef: 'refs/remotes/origin/release'
|
||||
})
|
||||
try {
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/main' }),
|
||||
currentBaseRef: 'refs/remotes/origin/release'
|
||||
})
|
||||
|
||||
expect(harness.current().base).toBe('release')
|
||||
} finally {
|
||||
harness.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a user-edited base when the default base ref changes', async () => {
|
||||
const harness = renderDialogFields({
|
||||
eligibility: createEligibility(),
|
||||
currentBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
try {
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility(),
|
||||
currentBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
act(() => {
|
||||
harness.current().setBase('custom-target')
|
||||
})
|
||||
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility({
|
||||
defaultBaseRef: 'refs/remotes/origin/release'
|
||||
}),
|
||||
currentBaseRef: 'refs/remotes/origin/release'
|
||||
})
|
||||
|
||||
expect(harness.current().base).toBe('custom-target')
|
||||
} finally {
|
||||
harness.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a synced base when stale generated fields arrive', async () => {
|
||||
const harness = renderDialogFields({
|
||||
eligibility: createEligibility(),
|
||||
currentBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
try {
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility(),
|
||||
currentBaseRef: 'refs/remotes/origin/main'
|
||||
})
|
||||
const seedRevisions = { ...harness.current().fieldRevisions }
|
||||
|
||||
await harness.rerender({
|
||||
eligibility: createEligibility(),
|
||||
currentBaseRef: 'refs/remotes/origin/release'
|
||||
})
|
||||
expect(harness.current().base).toBe('release')
|
||||
|
||||
act(() => {
|
||||
harness.current().applyGeneratedFields(
|
||||
{
|
||||
base: 'main',
|
||||
title: 'Generated title',
|
||||
body: 'Generated body',
|
||||
draft: true
|
||||
},
|
||||
seedRevisions
|
||||
)
|
||||
})
|
||||
|
||||
expect(harness.current().base).toBe('release')
|
||||
expect(harness.current().title).toBe('Generated title')
|
||||
expect(harness.current().body).toBe('Generated body')
|
||||
expect(harness.current().draft).toBe(true)
|
||||
} finally {
|
||||
harness.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ type UseCreatePullRequestDialogFieldsOptions = {
|
|||
worktreePath: string
|
||||
branch: string
|
||||
eligibility: HostedReviewCreationEligibility | null
|
||||
currentBaseRef?: string | null
|
||||
repo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
settings: AppState['settings']
|
||||
submitting: boolean
|
||||
|
|
@ -78,6 +79,16 @@ export function stripBaseRef(ref: string): string {
|
|||
return normalizeHostedReviewBaseRef(ref)
|
||||
}
|
||||
|
||||
function resolveCreateReviewDefaultBaseRef({
|
||||
currentBaseRef,
|
||||
eligibilityDefaultBaseRef
|
||||
}: {
|
||||
currentBaseRef?: string | null
|
||||
eligibilityDefaultBaseRef?: string | null
|
||||
}): string {
|
||||
return stripBaseRef(currentBaseRef?.trim() || eligibilityDefaultBaseRef?.trim() || '')
|
||||
}
|
||||
|
||||
export function normalizeCreateReviewBaseSearchResults(
|
||||
results: readonly BaseRefSearchResult[]
|
||||
): string[] {
|
||||
|
|
@ -103,6 +114,7 @@ export function useCreatePullRequestDialogFields({
|
|||
worktreePath,
|
||||
branch,
|
||||
eligibility,
|
||||
currentBaseRef,
|
||||
repo,
|
||||
settings,
|
||||
submitting,
|
||||
|
|
@ -124,6 +136,8 @@ export function useCreatePullRequestDialogFields({
|
|||
}
|
||||
const initializedFromEligibilityRef = useRef<string | null>(null)
|
||||
const [initializedEligibilityKey, setInitializedEligibilityKey] = useState<string | null>(null)
|
||||
const syncedDefaultBaseRef = useRef<string | null>(null)
|
||||
const baseEditedByUserRef = useRef(false)
|
||||
const autoGeneratedForKeyRef = useRef<string | null>(null)
|
||||
const generateInFlightRef = useRef(false)
|
||||
const generationRequestIdRef = useRef(0)
|
||||
|
|
@ -143,6 +157,10 @@ export function useCreatePullRequestDialogFields({
|
|||
const hasExternalGeneration = Boolean(generation)
|
||||
const currentEligibilityKey =
|
||||
open && eligibility ? `${repoId}:${worktreeId ?? worktreePath}:${branch}` : null
|
||||
const resolvedDefaultBaseRef = resolveCreateReviewDefaultBaseRef({
|
||||
currentBaseRef,
|
||||
eligibilityDefaultBaseRef: eligibility?.defaultBaseRef
|
||||
})
|
||||
|
||||
const markFieldDirty = useCallback((field: PullRequestFieldName): void => {
|
||||
fieldRevisionsRef.current = {
|
||||
|
|
@ -153,6 +171,7 @@ export function useCreatePullRequestDialogFields({
|
|||
|
||||
const setUserBase = useCallback(
|
||||
(value: string): void => {
|
||||
baseEditedByUserRef.current = true
|
||||
markFieldDirty('base')
|
||||
setBase(value)
|
||||
},
|
||||
|
|
@ -225,6 +244,8 @@ export function useCreatePullRequestDialogFields({
|
|||
generateInFlightRef.current = false
|
||||
generationSeedRef.current = null
|
||||
initializedFromEligibilityRef.current = null
|
||||
syncedDefaultBaseRef.current = null
|
||||
baseEditedByUserRef.current = false
|
||||
setInitializedEligibilityKey(null)
|
||||
autoGeneratedForKeyRef.current = null
|
||||
setGenerating(false)
|
||||
|
|
@ -263,8 +284,9 @@ export function useCreatePullRequestDialogFields({
|
|||
setInitializedEligibilityKey(initializationKey)
|
||||
autoGeneratedForKeyRef.current = null
|
||||
fieldRevisionsRef.current = createInitialPullRequestFieldRevisions()
|
||||
const initialBase = eligibility.defaultBaseRef ?? ''
|
||||
setBase(stripBaseRef(initialBase))
|
||||
baseEditedByUserRef.current = false
|
||||
syncedDefaultBaseRef.current = resolvedDefaultBaseRef || null
|
||||
setBase(resolvedDefaultBaseRef)
|
||||
setTitle(eligibility.title ?? '')
|
||||
setBody(eligibility.body ?? '')
|
||||
setDraft(resolvedPrDefaults.draft)
|
||||
|
|
@ -279,11 +301,38 @@ export function useCreatePullRequestDialogFields({
|
|||
hasExternalGeneration,
|
||||
open,
|
||||
repoId,
|
||||
resolvedDefaultBaseRef,
|
||||
resolvedPrDefaults.draft,
|
||||
worktreeId,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!open ||
|
||||
!eligibility ||
|
||||
!initializedFromEligibilityRef.current ||
|
||||
!resolvedDefaultBaseRef
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (syncedDefaultBaseRef.current === resolvedDefaultBaseRef) {
|
||||
return
|
||||
}
|
||||
syncedDefaultBaseRef.current = resolvedDefaultBaseRef
|
||||
if (baseEditedByUserRef.current) {
|
||||
return
|
||||
}
|
||||
// Why: the Source Control compare-base picker can change the intended
|
||||
// review target while generation is in flight; bump the revision so stale
|
||||
// generated details cannot retarget an untouched base back to the old ref.
|
||||
markFieldDirty('base')
|
||||
setBase(resolvedDefaultBaseRef)
|
||||
setBaseQuery('')
|
||||
setBaseResults([])
|
||||
setBaseSearchError(null)
|
||||
}, [eligibility, markFieldDirty, open, resolvedDefaultBaseRef])
|
||||
|
||||
const effectiveGenerating = generation?.generating ?? generating
|
||||
const effectiveGenerateError = generation?.generateError ?? generateError
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue