fix(source-control): generate PR details before Create PR on a ready branch (#24215)

* fix(source-control): generate PR details before Create PR on a ready branch

A pushed branch sent the placeholder title and empty body, skipping the
configured agent. A run Create PR submits unreviewed keeps the user's base
and never unchecks Draft; the Generate button is unchanged.

Fixes #22824

* docs(source-control): correct the auto-submitted Draft rule comment

* fix(source-control): never let generated details uncheck the user's Draft box

A Draft choice made before generation started was unprotected: the form only
keeps fields whose revision changed *while* a run was in flight, so a user who
checked Draft and then pressed Generate had the box silently unchecked by an
agent answering draft:false, and the next Create PR opened a real review ready
for review. Scope the rule to provenance rather than the in-flight window, and
apply it to reviewed runs and the prepare-branch route too, so the agent can
still flag unfinished work but never reverts a choice the user made.

* test(source-control): cover the Checks panel create-after-run and superseded-run guards

Two guards had no failing test. The Checks panel's Create PR must still send
the finished run's details while its last render shows the run as generating,
or the click silently creates nothing. Each panel's generation must return no
result when a later run replaced its record (Stop, then Generate finishing
first), or the stopped Create PR click would open a PR with the later run's
details.

* fix(source-control): don't reveal a background-created PR over another worktree

Create PR on a ready branch now waits for the agent, and the create still
runs if the sidebar panel closes meanwhile. When it finished, it always
opened the sidebar on Checks (and, with "open after create", the review),
even if the user had switched to another worktree, so Checks showed the
wrong worktree and the in-app link route switched the user back.

Reveal only when the created review's worktree is still selected, or the
panel that made it is still mounted, like the prepare-branch route.

* fix(source-control): keep Create PR in flight while the agent writes the details

Create PR on a ready branch generated the details and then created nothing,
with no message. When generation ended, Source Control's eligibility check
restarted and cleared the eligibility, and the create, which reads the panel's
latest state, returned early on the missing eligibility.

The click now stays in flight from the start of generation until the create
settles, as the prepare-branch route does, so the eligibility check stays
paused and the create sees the eligibility it started with. Repeated clicks
are counted so one returning early can't release another's hold.

* refactor(source-control): move Checks created-review linking into its own hook

No behavior change. The Checks panel's create hook was at its 400-line limit;
linking and refreshing a created review now lives in
use-checks-panel-created-review.tsx, the way Source Control keeps it in
use-hosted-review-created.ts.

* fix(source-control): finish a started Create PR run for the branch that was clicked

After the agent wrote the details, Create PR read the panel's latest render
instead of the state at the click. That render could have lost its
eligibility (no PR, no message), still show the run as generating, or show
another branch. Navigating away also gave opposite results: with the sidebar
open on another worktree no PR was made; after closing the sidebar first, it
was.

The create now always runs through the click's own closure, so a started run
creates the PR for the branch that was clicked wherever the user went, like
the prepare-branch route. The reveal still follows the selected worktree, so
nothing opens over another one. The "still shows generating" bypass in both
panels is gone: the click's render never shows the run as generating.

In the Checks panel, a create that returns after the panel moved to another
worktree still links the review to the clicked worktree and clears its
push-first flag; only the in-flight state, errors, opening the review and
the GitLab checks refresh are skipped, since they belong to whatever the
panel now shows. The same holds for a push-first create whose push finishes
after the panel moved.

* refactor(source-control): let the Create PR click own the in-flight flag

The click already kept Source Control's create in flight through generation,
so the composer stays up instead of closing while eligibility refreshes. It
did that with a per-worktree click counter next to the create's own in-flight
flag, so a repeated click that returned early couldn't end the first click's
hold.

Now the click owns the existing in-flight flag from the click until the
create settles, and a click while it is set is refused before it touches
anything, so the counter is gone. This matches what the UI already did:
Create PR is disabled while the flag is set, including after Stop until the
stopped request returns. Since the create now runs through the click's own
state, the hold only keeps the UI steady; it no longer decides whether a PR
is created.

* docs(source-control): describe the created-review foreground check by what callers pass

The Checks panel now passes whether it still shows the created review, not
just whether it is mounted, since a mounted panel can have moved to another
worktree while the create ran.

* fix(source-control): release Create PR as soon as Stop lands

After Stop, Source Control's Create PR stayed disabled ("Creating...") until
the stopped generation request came back. Locally that is under a second, but
when the cancel can't reach a slow or disconnected remote host the button
stayed disabled for up to the 75 s generation timeout.

A generation run's outcome now settles as soon as its record stops running,
not when the request returns. Stop marks the record canceled at once, so the
click that started the run ends, its hold is released, and the next click
submits the form as shown. The stopped request still finishes in the
background; its late result is dropped because the record is no longer
running for that request. Both panels' generation handlers use the same
helper.

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Kelvin Amoaba
2026-10-03 11:01:28 -07:00
committed by GitHub
co-authored by Neil Brennan Benson
parent 88e9517eb1
commit bbafca4481
26 changed files with 2257 additions and 413 deletions
@@ -214,6 +214,108 @@ describe('SourceControl pull request generation records', () => {
).toBe(true)
})
it.each([
{
name: 'an untouched unchecked Draft takes the agent draft',
seedDraft: false,
revision: 0,
agentDraft: true,
draft: true
},
{
name: 'a checked Draft stays checked',
seedDraft: true,
revision: 0,
agentDraft: false,
draft: true
},
{
name: 'a Draft the user unchecked stays unchecked',
seedDraft: false,
revision: 1,
agentDraft: true,
draft: false
}
])(
'a Create PR run never takes the generated base, and $name',
({ seedDraft, revision, agentDraft, draft }) => {
const record = runningRecord({
autoSubmit: true,
seed: { ...seed, draft: seedDraft },
seedFieldRevisions: { ...fieldRevisions, draft: revision }
})
const completed = resolvePullRequestGenerationSuccess({
record,
requestId: 3,
result: {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: agentDraft
}
})
expect(completed?.result).toEqual({
base: 'main',
title: 'Generated title',
body: 'Generated body',
draft
})
}
)
it('a reviewed run keeps the agent base and title for the form', () => {
const generated = {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: true
}
const completed = resolvePullRequestGenerationSuccess({
record: runningRecord(),
requestId: 3,
result: generated
})
expect(completed?.result).toEqual(generated)
})
it.each([
{
name: 'a Draft checked before the run stays checked',
seedDraft: true,
revision: 0,
draft: true
},
{
name: 'a Draft the user unchecked before the run stays unchecked',
seedDraft: false,
revision: 1,
draft: false
}
])(
'a reviewed run never reverts the user Draft choice: $name',
({ seedDraft, revision, draft }) => {
const completed = resolvePullRequestGenerationSuccess({
record: runningRecord({
seed: { ...seed, draft: seedDraft },
seedFieldRevisions: { ...fieldRevisions, draft: revision }
}),
requestId: 3,
result: {
base: 'develop',
title: 'Generated title',
body: 'Generated body',
draft: !seedDraft
}
})
expect(completed?.result?.draft).toBe(draft)
}
)
it('keeps PR generation results in the store after the composer unmounts', () => {
const store = createPullRequestGenerationTestStore()
const key = getPullRequestGenerationRecordKey({
@@ -100,6 +100,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders,
stackedCreationSupported: prStackedCreationSupported,
repoDefaultBaseRef: prRepoDefaultBaseRef,
baseQuery: prBaseQuery,
@@ -137,9 +138,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput
seed: activePullRequestGenerationRecord?.seed ?? null,
seedFieldRevisions: activePullRequestGenerationRecord?.seedFieldRevisions ?? null,
onSeedRestored: handlePullRequestGenerationSeedRestored,
onGenerate: (fields, fieldRevisions, overrides) => {
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
},
onGenerate: handleGeneratePullRequestFieldsForActive,
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
@@ -261,6 +260,7 @@ export function useChecksPanelComposerState(model: ChecksPanelComposerStateInput
setPrBody,
prDraft,
setPrDraft,
prFieldsAreSeedPlaceholders,
prStackedCreationSupported,
prRepoDefaultBaseRef,
prBaseQuery,
@@ -2,74 +2,100 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../../shared/constants'
import type { CreateHostedReviewResult } from '../../../../../shared/hosted-review'
import { getDefaultSourceControlAiSettings } from '../../../../../shared/source-control-ai-settings'
import type * as HttpLinkRouting from '@/lib/http-link-routing'
import { useChecksPanelCreateReview } from './use-checks-panel-create-review'
const { openHttpLink } = vi.hoisted(() => ({ openHttpLink: vi.fn() }))
vi.mock('@/lib/http-link-routing', async (importOriginal) => ({
...(await importOriginal<typeof HttpLinkRouting>()),
openHttpLink
}))
type CreateInput = Parameters<typeof useChecksPanelCreateReview>[0]
afterEach(cleanup)
afterEach(() => {
cleanup()
openHttpLink.mockClear()
useAppStore.setState({ activeWorktreeId: null })
})
function makeInput(overrides: Partial<CreateInput> = {}): CreateInput {
const createdReview: CreateHostedReviewResult = {
ok: true,
number: 42,
url: 'https://github.com/orca/app/pull/42'
}
return {
activePullRequestGenerationKey: null,
activeWorktreeId: null,
activeWorktreePath: '/workspace/repo',
branch: 'refs/heads/feature/create',
createComposerOpen: true,
createHostedReview: vi.fn(async () => createdReview),
createPrInFlightRef: { current: null },
createPrPushFirst: false,
createStackedHostedReview: vi.fn(),
fallbackGitHubPRNumber: null,
fetchGitLabDetails: vi.fn(),
fetchHostedReviewForBranch: vi.fn(),
handleGeneratePullRequestFields: vi.fn(async () => {}),
hostedReviewCreateCopy: {
providerName: 'GitHub',
reviewLabel: 'pull request',
shortLabel: 'PR',
titleLabel: 'Pull request'
} as CreateInput['hostedReviewCreateCopy'],
hostedReviewCreateProvider: 'github',
hostedReviewCreation: null,
linkedAzureDevOpsPR: null,
linkedBitbucketPR: null,
linkedGiteaPR: null,
linkedGitLabMR: null,
linkedPR: null,
mountedRef: { current: true },
ownerSettings: null,
panelContextKey: 'repo-1::worktree-1::feature/create',
panelContextKeyRef: { current: 'repo-1::worktree-1::feature/create' },
prAiGenerationEnabled: false,
prBase: 'refs/remotes/origin/main',
prBody: 'Create body',
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: false,
useTemplate: true
},
prDraft: true,
prFieldsAreSeedPlaceholders: false,
prGenerating: false,
prTitle: ' Create title ',
pushBeforeCreatePullRequest: vi.fn(async () => true),
refreshLinkedGitHubPullRequest: vi.fn(),
repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable<CreateInput['repo']>,
setCreatePrError: vi.fn(),
setGitStatusRefreshNonce: vi.fn(),
setIsCreatingPr: vi.fn(),
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
updateWorktreeMeta: vi.fn(),
...overrides
}
}
describe('useChecksPanelCreateReview provider flow', () => {
it('sends normalized GitHub create input and releases the in-flight gate after success', async () => {
const createdReview: CreateHostedReviewResult = {
ok: true,
number: 42,
url: 'https://github.com/orca/app/pull/42'
}
const createHostedReview: CreateInput['createHostedReview'] = vi.fn(async () => createdReview)
const refreshLinkedGitHubPullRequest: CreateInput['refreshLinkedGitHubPullRequest'] = vi.fn()
const setIsCreatingPr: CreateInput['setIsCreatingPr'] = vi.fn()
const createPrInFlightRef = { current: null as string | null }
const input: CreateInput = {
activePullRequestGenerationKey: null,
activeWorktreeId: null,
activeWorktreePath: '/workspace/repo',
branch: 'refs/heads/feature/create',
createComposerOpen: true,
const input = makeInput()
const {
createHostedReview,
createPrInFlightRef,
createPrPushFirst: false,
createStackedHostedReview: vi.fn(),
fallbackGitHubPRNumber: null,
fetchGitLabDetails: vi.fn(),
fetchHostedReviewForBranch: vi.fn(),
hostedReviewCreateCopy: {
providerName: 'GitHub',
reviewLabel: 'pull request',
shortLabel: 'PR',
titleLabel: 'Pull request'
} as CreateInput['hostedReviewCreateCopy'],
hostedReviewCreateProvider: 'github',
hostedReviewCreation: null,
linkedAzureDevOpsPR: null,
linkedBitbucketPR: null,
linkedGiteaPR: null,
linkedGitLabMR: null,
linkedPR: null,
panelContextKey: 'repo-1::worktree-1::feature/create',
panelContextKeyRef: { current: 'repo-1::worktree-1::feature/create' },
prBase: 'refs/remotes/origin/main',
prBody: 'Create body',
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: false,
useTemplate: true
},
prDraft: true,
prGenerating: false,
prTitle: ' Create title ',
pushBeforeCreatePullRequest: vi.fn(async () => true),
refreshLinkedGitHubPullRequest,
repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable<CreateInput['repo']>,
setCreatePrError: vi.fn(),
setGitStatusRefreshNonce: vi.fn(),
setIsCreatingPr,
setRightSidebarOpen: vi.fn(),
setRightSidebarTab: vi.fn(),
updatePullRequestGenerationRecord: vi.fn(),
updateWorktreeMeta: vi.fn()
}
setIsCreatingPr
} = input
const { result } = renderHook(() => useChecksPanelCreateReview(input))
await act(async () => result.current.handleCreatePullRequest(false))
@@ -90,4 +116,224 @@ describe('useChecksPanelCreateReview provider flow', () => {
expect(setIsCreatingPr).toHaveBeenLastCalledWith(false)
expect(createPrInFlightRef.current).toBeNull()
})
it('generates details, then creates with them, when the composer still holds placeholders', async () => {
const input = makeInput({
activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create',
handleGeneratePullRequestFields: vi.fn(async () => ({
result: { base: 'main', title: 'Add create flow', body: 'Details.', draft: false }
})),
ownerSettings: {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' }
},
prAiGenerationEnabled: true,
prFieldsAreSeedPlaceholders: true
})
const { result } = renderHook(() => useChecksPanelCreateReview(input))
await act(async () => result.current.handleCreatePullRequest(false))
expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/workspace/repo',
expect.objectContaining({
base: 'main',
title: 'Add create flow',
body: 'Details.',
draft: false
})
)
})
it('creates with the finished run even while the panel still shows it generating', async () => {
const generated = { base: 'main', title: 'Add create flow', body: 'Details.', draft: false }
let finish: () => void = () => {}
const input = makeInput({
activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create',
handleGeneratePullRequestFields: vi.fn(
() =>
new Promise<{ result: typeof generated }>((resolve) => {
finish = () => resolve({ result: generated })
})
),
ownerSettings: {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' }
},
prAiGenerationEnabled: true,
prFieldsAreSeedPlaceholders: true
})
const { result, rerender } = renderHook(
(props: CreateInput) => useChecksPanelCreateReview(props),
{ initialProps: input }
)
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest(false)
})
rerender({ ...input, prGenerating: true })
await act(async () => {
finish()
await click
})
expect(input.createHostedReview).toHaveBeenCalledWith(
'/workspace/repo',
expect.objectContaining({ title: 'Add create flow', body: 'Details.' })
)
})
it.each([
{ name: 'still selected', selectedAtFinish: 'worktree-1', reveals: true },
{ name: 'no longer selected', selectedAtFinish: 'worktree-2', reveals: false }
])(
'reveals the created PR only when its worktree is $name after the panel closed mid-run',
async ({ selectedAtFinish, reveals }) => {
useAppStore.setState({ activeWorktreeId: 'worktree-1' })
let finish: () => void = () => {}
const input = makeInput({
activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create',
activeWorktreeId: 'worktree-1',
handleGeneratePullRequestFields: vi.fn(
() =>
new Promise<{ result: { base: string; title: string; body: string; draft: boolean } }>(
(resolve) => {
finish = () =>
resolve({
result: { base: 'main', title: 'Add create flow', body: '', draft: false }
})
}
)
),
ownerSettings: {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' }
},
prAiGenerationEnabled: true,
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: true,
useTemplate: true
},
prFieldsAreSeedPlaceholders: true
})
const { result, unmount } = renderHook(() => useChecksPanelCreateReview(input))
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest(false)
})
unmount()
input.mountedRef.current = false
useAppStore.setState({ activeWorktreeId: selectedAtFinish })
await act(async () => {
finish()
await click
})
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', expect.anything())
expect(input.setRightSidebarTab).toHaveBeenCalledTimes(reveals ? 1 : 0)
expect(openHttpLink).toHaveBeenCalledTimes(reveals ? 1 : 0)
}
)
it.each(['github', 'gitlab'] as const)(
'links the %s review to the clicked worktree, without touching the panel, when the panel moved on mid-run',
async (provider) => {
useAppStore.setState({ activeWorktreeId: 'worktree-1' })
let finish: () => void = () => {}
const input = makeInput({
activePullRequestGenerationKey: 'worktree-1::repo-1::feature/create',
activeWorktreeId: 'worktree-1',
handleGeneratePullRequestFields: vi.fn(
() =>
new Promise<{ result: { base: string; title: string; body: string; draft: boolean } }>(
(resolve) => {
finish = () =>
resolve({
result: { base: 'main', title: 'Add create flow', body: '', draft: false }
})
}
)
),
hostedReviewCreateProvider: provider,
ownerSettings: {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' }
},
prAiGenerationEnabled: true,
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: true,
useTemplate: true
},
prFieldsAreSeedPlaceholders: true
})
const { result, rerender } = renderHook(
(props: CreateInput) => useChecksPanelCreateReview(props),
{ initialProps: input }
)
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest(false)
})
// The still-mounted panel switches to another worktree, as a worktree switch does.
useAppStore.setState({ activeWorktreeId: 'worktree-2' })
input.panelContextKeyRef.current = 'repo-1::worktree-2::feature/other'
rerender({
...input,
activePullRequestGenerationKey: 'worktree-2::repo-1::feature/other',
activeWorktreeId: 'worktree-2',
branch: 'refs/heads/feature/other',
createComposerOpen: false,
panelContextKey: 'repo-1::worktree-2::feature/other'
})
await act(async () => {
finish()
await click
})
expect(input.createHostedReview).toHaveBeenCalledWith(
'/workspace/repo',
expect.objectContaining({ head: 'feature/create', title: 'Add create flow' })
)
expect(input.updateWorktreeMeta).toHaveBeenCalledWith('worktree-1', expect.anything())
expect(input.updatePullRequestGenerationRecord).toHaveBeenCalledWith(
'worktree-1::repo-1::feature/create',
expect.any(Function)
)
expect(input.setIsCreatingPr).not.toHaveBeenCalled()
expect(input.setCreatePrError).not.toHaveBeenCalled()
expect(input.createPrInFlightRef.current).toBeNull()
expect(input.fetchGitLabDetails).not.toHaveBeenCalled()
expect(input.setRightSidebarTab).not.toHaveBeenCalled()
expect(openHttpLink).not.toHaveBeenCalled()
}
)
it('opens the created PR while the panel is still showing it, even when another worktree is selected', async () => {
useAppStore.setState({ activeWorktreeId: 'worktree-2' })
const input = makeInput({
activeWorktreeId: 'worktree-1',
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: true,
useTemplate: true
}
})
const { result } = renderHook(() => useChecksPanelCreateReview(input))
await act(async () => result.current.handleCreatePullRequest(false))
expect(input.setRightSidebarTab).toHaveBeenCalledWith('checks')
expect(openHttpLink).toHaveBeenCalledWith('https://github.com/orca/app/pull/42', {
worktreeId: 'worktree-1'
})
})
})
@@ -1,68 +1,54 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { openHttpLink } from '@/lib/http-link-routing'
import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link'
import { formatCreateError } from '../create-pull-request-review-copy'
import { stripBaseRef } from '../create-pull-request-base-ref-normalization'
import { normalizeHostedReviewHeadRef } from '../../../../../shared/hosted-review-refs'
import {
hostedReviewProviderSupportsDraft,
type HostedReviewProvider
} from '../../../../../shared/hosted-review'
import { hostedReviewProviderSupportsDraft } from '../../../../../shared/hosted-review'
import type { ChecksPanelReviewState } from './use-checks-panel-review-state'
import type { ChecksPanelControllerState } from './use-checks-panel-controller-state'
import type { ChecksPanelContextState } from './use-checks-panel-context-state'
import type { ChecksPanelPollingState } from './use-checks-panel-polling'
import type { ChecksPanelComposerState } from './use-checks-panel-composer-state'
import type { ChecksPanelBranchActionsState } from './use-checks-panel-branch-actions'
import type { ChecksPanelCheckAndReviewActionsState } from './use-checks-panel-check-and-review-actions'
import { clearPullRequestGenerationRequiresPushBeforeCreate } from '@/store/slices/pull-request-generation'
import { translate } from '@/i18n/i18n'
import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation'
import { useGenerateBeforeCreatePullRequest } from '../use-generate-before-create-pull-request'
import { createdReviewIsForeground } from '../created-review-foreground'
import {
useChecksPanelCreatedReview,
type ChecksPanelCreatedReviewInput
} from './use-checks-panel-created-review'
type ChecksPanelCreateReviewInput = Pick<
ChecksPanelReviewState,
| 'activePullRequestGenerationKey'
| 'createComposerOpen'
| 'createPrPushFirst'
| 'hostedReviewCreateCopy'
| 'hostedReviewCreateProvider'
| 'hostedReviewCreation'
| 'prCreationDefaults'
> &
type ChecksPanelCreateReviewInput = ChecksPanelCreatedReviewInput &
Pick<
ChecksPanelReviewState,
| 'activePullRequestGenerationKey'
| 'createComposerOpen'
| 'createPrPushFirst'
| 'hostedReviewCreateCopy'
| 'hostedReviewCreateProvider'
| 'hostedReviewCreation'
| 'prCreationDefaults'
> &
Pick<
ChecksPanelControllerState,
| 'activeWorktreeId'
| 'activeWorktreePath'
| 'branch'
| 'createHostedReview'
| 'createPrInFlightRef'
| 'createStackedHostedReview'
| 'fetchHostedReviewForBranch'
| 'mountedRef'
| 'ownerSettings'
| 'panelContextKey'
| 'panelContextKeyRef'
| 'repo'
| 'setCreatePrError'
| 'setGitStatusRefreshNonce'
| 'setIsCreatingPr'
| 'setRightSidebarOpen'
| 'setRightSidebarTab'
| 'updatePullRequestGenerationRecord'
| 'updateWorktreeMeta'
> &
Pick<
ChecksPanelContextState,
| 'fallbackGitHubPRNumber'
| 'linkedAzureDevOpsPR'
| 'linkedBitbucketPR'
| 'linkedGiteaPR'
| 'linkedGitLabMR'
| 'linkedPR'
> &
Pick<ChecksPanelPollingState, 'fetchGitLabDetails'> &
Pick<ChecksPanelComposerState, 'prBase' | 'prBody' | 'prDraft' | 'prGenerating' | 'prTitle'> &
Pick<ChecksPanelBranchActionsState, 'pushBeforeCreatePullRequest'> &
Pick<ChecksPanelCheckAndReviewActionsState, 'refreshLinkedGitHubPullRequest'>
Pick<ChecksPanelComposerState, 'handleGeneratePullRequestFields' | 'prAiGenerationEnabled'> &
Pick<ChecksPanelComposerState, 'prFieldsAreSeedPlaceholders'> &
Pick<ChecksPanelBranchActionsState, 'pushBeforeCreatePullRequest'>
export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput) {
const {
@@ -75,160 +61,91 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
createPrInFlightRef,
createPrPushFirst,
createStackedHostedReview,
fallbackGitHubPRNumber,
fetchGitLabDetails,
fetchHostedReviewForBranch,
handleGeneratePullRequestFields,
hostedReviewCreateCopy,
hostedReviewCreateProvider,
hostedReviewCreation,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
ownerSettings,
panelContextKey,
panelContextKeyRef,
prAiGenerationEnabled,
prBase,
prBody,
prCreationDefaults,
prDraft,
prFieldsAreSeedPlaceholders,
prGenerating,
prTitle,
pushBeforeCreatePullRequest,
refreshLinkedGitHubPullRequest,
repo,
setCreatePrError,
setGitStatusRefreshNonce,
setIsCreatingPr,
setRightSidebarOpen,
setRightSidebarTab,
updatePullRequestGenerationRecord,
updateWorktreeMeta
updatePullRequestGenerationRecord
} = model
const handlePullRequestCreated = useCallback(
async (result: {
provider: HostedReviewProvider
number: number
url: string
}): Promise<void> => {
if (!repo || !branch) {
return
}
setRightSidebarOpen(true)
setRightSidebarTab('checks')
try {
const createdLink = resolveCreatedHostedReviewLink(result.provider, result.number)
if (activeWorktreeId && result.provider !== 'unsupported') {
await updateWorktreeMeta(activeWorktreeId, createdLink.worktree)
}
const linkedReviewNumbers = {
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
...createdLink.lookup
}
if (result.provider === 'gitlab') {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
const refreshedGitLabReview =
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
await fetchGitLabDetails({
mrNumberOverride: result.number,
headShaOverride: refreshedGitLabReview?.headSha,
commitAsCurrent: true
})
return
}
if (result.provider !== 'github') {
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
return
}
await refreshLinkedGitHubPullRequest(result.number)
} catch {
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
}
},
[
branch,
fallbackGitHubPRNumber,
fetchGitLabDetails,
fetchHostedReviewForBranch,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,
setRightSidebarOpen,
setRightSidebarTab,
activeWorktreeId,
updateWorktreeMeta
]
)
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
if (!repo || !branch || !createComposerOpen || prGenerating || createPrInFlightRef.current) {
return
}
const handlePullRequestCreated = useChecksPanelCreatedReview(model)
const createPullRequest = useCallback(
async (stacked = false, generated?: PullRequestGenerationFields): Promise<void> => {
const requestContextKey = panelContextKey
// Why: a click-owned run can reach here after the panel moved on; it still creates and links the clicked branch's review, and drives only the panel that still shows it.
const panelShowsRequest = panelContextKeyRef.current === requestContextKey
if (
!repo ||
!branch ||
!createComposerOpen ||
prGenerating ||
(panelShowsRequest && createPrInFlightRef.current)
) {
return
}
const isCurrentCreateRequest = (): boolean =>
panelContextKeyRef.current === requestContextKey &&
createPrInFlightRef.current === requestContextKey
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
const showCreateError = (message: string): void => {
if (isCurrentCreateRequest()) {
setCreatePrError(message)
}
}
const fields = generated ?? { base: prBase, title: prTitle, body: prBody, draft: prDraft }
const base = stripBaseRef(fields.base).trim()
const title = fields.title.trim()
const worktreePath = activeWorktreePath ?? repo.path
if (!title) {
setCreatePrError(
translate(
const invalidFieldsError = !title
? translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{
value0: hostedReviewCreateCopy.reviewLabel
}
)
)
return
}
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()) {
setCreatePrError(
translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
)
: !base || stripBaseRef(base).toLowerCase() === stripBaseRef(branch).toLowerCase()
? translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
: null
if (invalidFieldsError) {
if (panelShowsRequest) {
setCreatePrError(invalidFieldsError)
}
return
}
createPrInFlightRef.current = requestContextKey
setIsCreatingPr(true)
setCreatePrError(null)
if (panelShowsRequest) {
createPrInFlightRef.current = requestContextKey
setIsCreatingPr(true)
setCreatePrError(null)
}
let pushed = false
try {
const shouldPushBeforeCreate =
createPrPushFirst || hostedReviewCreation?.blockedReason === 'needs_push'
if (shouldPushBeforeCreate) {
const ok = await pushBeforeCreatePullRequest()
if (!isCurrentCreateRequest()) {
return
}
if (!ok) {
setCreatePrError('Push failed. Resolve the push error, then try again.')
showCreateError('Push failed. Resolve the push error, then try again.')
return
}
pushed = true
@@ -239,24 +156,23 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
base,
head: normalizeHostedReviewHeadRef(branch),
title,
body: prBody,
draft: prDraft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider),
body: fields.body,
draft: fields.draft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider),
worktreePath,
useTemplate: prCreationDefaults.useTemplate
}
const result = stacked
? await createStackedHostedReview(repo.path, createInput)
: await createHostedReview(repo.path, createInput)
if (!isCurrentCreateRequest()) {
return
}
// Why: read before linking, which changes the panel's context key.
const panelShowsReview = model.mountedRef.current && isCurrentCreateRequest()
if (result.ok) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
})
if (prCreationDefaults.openAfterCreate) {
const foreground = createdReviewIsForeground(activeWorktreeId, panelShowsReview)
await handlePullRequestCreated(
{ provider: hostedReviewCreateProvider, number: result.number, url: result.url },
panelShowsReview
)
if (prCreationDefaults.openAfterCreate && foreground) {
openHttpLink(result.url, { worktreeId: activeWorktreeId })
}
if (activePullRequestGenerationKey) {
@@ -293,11 +209,10 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
}
)
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
})
await handlePullRequestCreated(
{ provider: hostedReviewCreateProvider, number, url: result.existingReview.url },
panelShowsReview
)
if (activePullRequestGenerationKey) {
updatePullRequestGenerationRecord(
activePullRequestGenerationKey,
@@ -313,19 +228,15 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
if ('createdReview' in result && result.createdReview?.url) {
const { number, url } = result.createdReview
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url
})
await handlePullRequestCreated(
{ provider: hostedReviewCreateProvider, number, url },
panelShowsReview
)
}
}
setCreatePrError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel))
showCreateError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel))
} catch (error) {
if (!isCurrentCreateRequest()) {
return
}
setCreatePrError(
showCreateError(
error instanceof Error
? error.message
: translate(
@@ -358,6 +269,7 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
hostedReviewCreation?.blockedReason,
model.mountedRef,
panelContextKey,
prBase,
prBody,
@@ -376,6 +288,16 @@ export function useChecksPanelCreateReview(model: ChecksPanelCreateReviewInput)
setCreatePrError
]
)
const { handleCreatePullRequest } = useGenerateBeforeCreatePullRequest({
aiGenerationEnabled: prAiGenerationEnabled,
canCreate: createComposerOpen,
createPullRequest,
fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders,
generatePullRequestFields: handleGeneratePullRequestFields,
generationKey: activePullRequestGenerationKey,
repo,
settings: ownerSettings
})
return { handlePullRequestCreated, handleCreatePullRequest }
}
@@ -0,0 +1,134 @@
import { useCallback } from 'react'
import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh'
import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link'
import type { HostedReviewProvider } from '../../../../../shared/hosted-review'
import type { ChecksPanelControllerState } from './use-checks-panel-controller-state'
import type { ChecksPanelContextState } from './use-checks-panel-context-state'
import type { ChecksPanelPollingState } from './use-checks-panel-polling'
import type { ChecksPanelCheckAndReviewActionsState } from './use-checks-panel-check-and-review-actions'
import { createdReviewIsForeground } from '../created-review-foreground'
export type ChecksPanelCreatedReviewInput = Pick<
ChecksPanelControllerState,
| 'activeWorktreeId'
| 'branch'
| 'fetchHostedReviewForBranch'
| 'repo'
| 'setRightSidebarOpen'
| 'setRightSidebarTab'
| 'updateWorktreeMeta'
> &
Pick<
ChecksPanelContextState,
| 'fallbackGitHubPRNumber'
| 'linkedAzureDevOpsPR'
| 'linkedBitbucketPR'
| 'linkedGiteaPR'
| 'linkedGitLabMR'
| 'linkedPR'
> &
Pick<ChecksPanelPollingState, 'fetchGitLabDetails'> &
Pick<ChecksPanelCheckAndReviewActionsState, 'refreshLinkedGitHubPullRequest'>
/** Links a review created from the Checks panel to its worktree and refreshes what shows it. */
export function useChecksPanelCreatedReview(model: ChecksPanelCreatedReviewInput) {
const {
activeWorktreeId,
branch,
fallbackGitHubPRNumber,
fetchGitLabDetails,
fetchHostedReviewForBranch,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,
setRightSidebarOpen,
setRightSidebarTab,
updateWorktreeMeta
} = model
return useCallback(
async (
result: {
provider: HostedReviewProvider
number: number
url: string
},
// Why: a create that outlives its panel context still links its worktree, but must not paint into the panel's new context.
panelShowsReview: boolean
): Promise<void> => {
if (!repo || !branch) {
return
}
if (createdReviewIsForeground(activeWorktreeId, panelShowsReview)) {
setRightSidebarOpen(true)
setRightSidebarTab('checks')
}
try {
const createdLink = resolveCreatedHostedReviewLink(result.provider, result.number)
if (activeWorktreeId && result.provider !== 'unsupported') {
await updateWorktreeMeta(activeWorktreeId, createdLink.worktree)
}
const linkedReviewNumbers = {
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
...createdLink.lookup
}
if (result.provider === 'gitlab') {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
if (!panelShowsReview) {
return
}
const refreshedGitLabReview =
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
await fetchGitLabDetails({
mrNumberOverride: result.number,
headShaOverride: refreshedGitLabReview?.headSha,
commitAsCurrent: true
})
return
}
if (result.provider !== 'github') {
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
return
}
await refreshLinkedGitHubPullRequest(result.number)
} catch {
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
}
},
[
activeWorktreeId,
branch,
fallbackGitHubPRNumber,
fetchGitLabDetails,
fetchHostedReviewForBranch,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,
setRightSidebarOpen,
setRightSidebarTab,
updateWorktreeMeta
]
)
}
@@ -3,12 +3,22 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeGitClient from '@/runtime/runtime-git-client'
import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context'
import { useAppStore } from '@/store'
import { resolvePullRequestGenerationCancel } from '@/store/slices/pull-request-generation'
const runtime = vi.hoisted(() => ({ cancel: vi.fn().mockResolvedValue(undefined) }))
const runtime = vi.hoisted(() => ({
cancel: vi.fn().mockResolvedValue(undefined),
generate: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
const original = await importOriginal<typeof RuntimeGitClient>()
return { ...original, cancelRuntimeGeneratePullRequestFields: runtime.cancel }
return {
...original,
cancelRuntimeGeneratePullRequestFields: runtime.cancel,
generateRuntimePullRequestFields: runtime.generate
}
})
import { useChecksPanelGeneration } from './use-checks-panel-generation'
@@ -18,6 +28,8 @@ type GenerationInput = Parameters<typeof useChecksPanelGeneration>[0]
afterEach(() => {
cleanup()
runtime.cancel.mockReset().mockResolvedValue(undefined)
runtime.generate.mockReset()
useAppStore.setState({ pullRequestGenerationRecords: {} })
})
describe('useChecksPanelGeneration cancellation ownership', () => {
@@ -75,3 +87,200 @@ describe('useChecksPanelGeneration cancellation ownership', () => {
expect(updateRecord).toHaveBeenCalledWith('repo-1::owner-worktree', expect.any(Function))
})
})
describe('useChecksPanelGeneration outcome', () => {
it.each([
{ name: 'keeps the base for a Create PR run', autoSubmit: true, stop: false, base: 'main' },
{
name: 'keeps the agent base for a reviewed run',
autoSubmit: false,
stop: false,
base: 'develop'
},
{ name: 'returns no result when Stop lands first', autoSubmit: true, stop: true, base: null }
])('$name', async ({ autoSubmit, stop, base }) => {
const generationKey = 'repo-1::worktree-1::feature'
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const input: GenerationInput = {
activePullRequestGenerationKey: generationKey,
activePullRequestGenerationRecord: null,
activeWorktreeId: 'worktree-1',
activeWorktreePath: '/workspace/repo',
allocatePullRequestGenerationRequestId: vi.fn(() => 11),
branch: 'feature',
handleBranchChangedByPullRequestGeneration: vi.fn(),
hostedReviewCreateProvider: 'github',
ownerSettings: null,
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: false,
useTemplate: true
},
prGenerationRecords: {},
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
}
const { result } = renderHook(() => useChecksPanelGeneration(input))
const fields = { base: 'main', title: 'Feature', body: '', draft: false }
const outcome = result.current.handleGeneratePullRequestFieldsForActive(
fields,
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit }
)
if (stop) {
updatePullRequestGenerationRecord(generationKey, resolvePullRequestGenerationCancel)
}
const generated = { base: 'develop', title: 'Add feature flag', body: 'Details.', draft: false }
answer({ success: true, fields: generated })
await expect(outcome).resolves.toEqual({ result: base ? { ...generated, base } : null })
})
it("returns no result when a later run replaced this one, not the later run's details", async () => {
const generationKey = 'repo-1::worktree-1::feature'
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useChecksPanelGeneration({
activePullRequestGenerationKey: generationKey,
activePullRequestGenerationRecord: null,
activeWorktreeId: 'worktree-1',
activeWorktreePath: '/workspace/repo',
allocatePullRequestGenerationRequestId: vi.fn(() => 11),
branch: 'feature',
handleBranchChangedByPullRequestGeneration: vi.fn(),
hostedReviewCreateProvider: 'github',
ownerSettings: null,
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: false,
useTemplate: true
},
prGenerationRecords: {},
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
})
)
const fields = { base: 'main', title: 'Feature', body: '', draft: false }
const outcome = result.current.handleGeneratePullRequestFieldsForActive(
fields,
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
// Stop, then a Generate click whose run finishes before the stopped one winds down.
const stopped = useAppStore.getState().pullRequestGenerationRecords[generationKey]
expect(stopped?.status).toBe('running')
setPullRequestGenerationRecord(generationKey, {
...stopped!,
context: { ...stopped!.context, requestId: 12 },
status: 'succeeded',
result: { ...fields, title: 'Later run' }
})
answer({ success: false, error: 'canceled', canceled: true })
await expect(outcome).resolves.toEqual({ result: null })
})
it('settles with no result as soon as Stop lands, without waiting for the stopped request, and drops its late result', async () => {
const generationKey = 'repo-1::worktree-1::feature'
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
// The cancel never reaches the host, so the stopped request stays pending.
runtime.cancel.mockReturnValue(new Promise(() => {}))
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useChecksPanelGeneration({
activePullRequestGenerationKey: generationKey,
activePullRequestGenerationRecord: null,
activeWorktreeId: 'worktree-1',
activeWorktreePath: '/workspace/repo',
allocatePullRequestGenerationRequestId: vi.fn(() => 11),
branch: 'feature',
handleBranchChangedByPullRequestGeneration: vi.fn(),
hostedReviewCreateProvider: 'github',
ownerSettings: null,
prCreationDefaults: {
draft: false,
generateDetailsOnOpen: false,
openAfterCreate: false,
useTemplate: true
},
prGenerationRecords: useAppStore((s) => s.pullRequestGenerationRecords),
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord
})
)
let outcome: Promise<unknown> = Promise.resolve()
act(() => {
outcome = result.current.handleGeneratePullRequestFieldsForActive(
{ base: 'main', title: 'Feature', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
})
act(() => result.current.handleCancelGeneratePullRequestFieldsForActive())
expect(runtime.cancel).toHaveBeenCalledTimes(1)
const stillPending = new Promise((resolve) => setTimeout(() => resolve('still pending'), 50))
await expect(Promise.race([outcome, stillPending])).resolves.toEqual({ result: null })
answer({
success: true,
fields: { base: 'develop', title: 'Late run', body: 'Late.', draft: false }
})
await act(async () => {})
expect(useAppStore.getState().pullRequestGenerationRecords[generationKey]).toMatchObject({
status: 'canceled',
result: null
})
})
})
@@ -1,4 +1,6 @@
import { useCallback } from 'react'
import type { PullRequestGenerationOutcome } from '../create-pull-request-dialog-field-model'
import { settlePullRequestGenerationRequest } from '../pull-request-generation-request-outcome'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import {
@@ -19,6 +21,7 @@ import {
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
type ChecksPanelGenerationInput = Pick<
ChecksPanelReviewState,
@@ -62,16 +65,17 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) {
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
options?: PullRequestGenerationOptions
): Promise<PullRequestGenerationOutcome | undefined> => {
if (!repo || !activePullRequestGenerationKey || !activeWorktreePath || !branch) {
return
return undefined
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
return undefined
}
const requestId = allocatePullRequestGenerationRequestId()
const context: PullRequestGenerationContext = {
@@ -88,7 +92,12 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) {
useAppStore.getState().pullRequestGenerationRecords[generationKey]
?.requiresPushBeforeCreate === true
// Why: ChecksPanel unsets the composer on navigate-away; persist the request so generation can finish in the background.
const runningRecord = createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
const runningRecord = createRunningPullRequestGenerationRecord(
context,
seed,
fieldRevisions,
options?.autoSubmit
)
setPullRequestGenerationRecord(
generationKey,
previousRequiresPushBeforeCreate
@@ -96,61 +105,64 @@ export function useChecksPanelGeneration(model: ChecksPanelGenerationInput) {
: runningRecord
)
try {
const result = await generateRuntimePullRequestFields(
{
// Why: route generation by the worktree owner captured at click time.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef(seed.base.trim()),
title: seed.title,
body: seed.body,
draft: seed.draft,
provider: hostedReviewCreateProvider,
useTemplate: prCreationDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await handleBranchChangedByPullRequestGeneration(generationKey, context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
const request = (async (): Promise<void> => {
try {
const result = await generateRuntimePullRequestFields(
{
// Why: route generation by the worktree owner captured at click time.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef(seed.base.trim()),
title: seed.title,
body: seed.body,
draft: seed.draft,
provider: hostedReviewCreateProvider,
useTemplate: prCreationDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await handleBranchChangedByPullRequestGeneration(generationKey, context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
})
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
})()
return await settlePullRequestGenerationRequest(generationKey, requestId, request)
},
[
activePullRequestGenerationKey,
@@ -6,7 +6,13 @@ import type {
import type { Repo } from '../../../../shared/repo-types'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
import type { PullRequestFieldRevisions } from '@/store/slices/pull-request-generation'
import type {
PullRequestFieldRevisions,
PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
export type PullRequestGenerationOutcome = { result: PullRequestGenerationFields | null }
export type PullRequestDraftFields = {
base: string
@@ -44,8 +50,9 @@ export type UseCreatePullRequestDialogFieldsOptions = {
onGenerate: (
fields: PullRequestDraftFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
) => void
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
options?: PullRequestGenerationOptions
) => void | Promise<PullRequestGenerationOutcome | undefined>
onCancelGenerate: () => void
}
}
@@ -1,4 +1,5 @@
import { humanizeBranchSlug } from '../../../../shared/branch-name-from-work'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
export function resolveCreateReviewDraftTitle({
@@ -16,3 +17,17 @@ export function resolveCreateReviewDraftTitle({
const branchLeaf = normalizedBranch.split('/').pop()?.replace(/_/g, '-') ?? ''
return humanizeBranchSlug(branchLeaf) || normalizedBranch
}
/** The title and body the composer seeds for a branch, before any edit or generation. */
export function resolveCreateReviewSeedText({
branch,
eligibility
}: {
branch: string
eligibility: Pick<HostedReviewCreationEligibility, 'title' | 'body'> | null
}): { title: string; body: string } {
return {
title: resolveCreateReviewDraftTitle({ branch, eligibilityTitle: eligibility?.title }),
body: eligibility?.body ?? ''
}
}
@@ -0,0 +1,10 @@
import { useAppStore } from '@/store'
// Why: a create that outlives its panel (e.g. after generation) must not reveal the review over a worktree the user has since switched to.
// A panel still showing the review counts too, since it can differ from the selected worktree (Checks follows the terminal cwd).
export function createdReviewIsForeground(
worktreeId: string | null,
panelShowsReview = false
): boolean {
return panelShowsReview || useAppStore.getState().activeWorktreeId === worktreeId
}
@@ -0,0 +1,45 @@
import { useAppStore } from '@/store'
import type { PullRequestGenerationRecords } from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOutcome } from './create-pull-request-dialog-field-model'
function isRequestRunning(
records: PullRequestGenerationRecords,
generationKey: string,
requestId: number
): boolean {
const record = records[generationKey]
return record?.context.requestId === requestId && record.status === 'running'
}
/**
* Settles a generation request's outcome once its record stops running rather than when the request
* returns: Stop cancels the record at once, and a cancel that cannot reach a slow host must not keep
* the run's caller (a Create PR click) waiting.
*/
export async function settlePullRequestGenerationRequest(
generationKey: string,
requestId: number,
request: Promise<void>
): Promise<PullRequestGenerationOutcome> {
let unsubscribe = (): void => {}
const stoppedRunning = new Promise<void>((resolve) => {
const resolveIfStopped = (records: PullRequestGenerationRecords): void => {
if (!isRequestRunning(records, generationKey, requestId)) {
resolve()
}
}
unsubscribe = useAppStore.subscribe((state) =>
resolveIfStopped(state.pullRequestGenerationRecords)
)
resolveIfStopped(useAppStore.getState().pullRequestGenerationRecords)
})
try {
// Why: the request keeps running after Stop; its late result is dropped by the record's requestId and status checks.
await Promise.race([request, stoppedRunning])
} finally {
unsubscribe()
}
const record = useAppStore.getState().pullRequestGenerationRecords[generationKey]
// Why: failed, stopped, or superseded runs carry no result for this request.
return { result: record?.context.requestId === requestId ? record.result : null }
}
@@ -390,6 +390,21 @@ describe('source-control Create PR intent flow helpers', () => {
})
})
it('never downgrades a "Create as draft" choice to a ready review', () => {
expect(
resolveCreatePrIntentGeneratedReviewFields(
{ base: 'main', title: 'Feature branch', body: '', draft: true },
{
success: true,
fields: { base: 'main', title: 'Generated title', body: 'Details', draft: false }
}
)
).toEqual({
ok: true,
fields: { base: 'main', title: 'Generated title', body: 'Details', draft: true }
})
})
it('surfaces the commit failure summary in the Create PR intent notice', () => {
expect(
getCreatePrIntentCommitFailureNoticeMessage(
@@ -216,7 +216,8 @@ export function resolveCreatePrIntentGeneratedReviewFields(
title: generated.fields.title.trim() || current.title,
// Why: a description is optional everywhere else (composer, GitHub/GitLab), so an intentionally empty generated body is a valid result, not a failure.
body: generated.fields.body,
draft: generated.fields.draft
// Why: there is no form to review this one, so the agent may flag unfinished work but never downgrade a "Create as draft" choice.
draft: current.draft || generated.fields.draft
}
}
}
@@ -70,6 +70,7 @@ export function useSourceControlCreateReviewComposer({
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders,
stackedCreationSupported: prStackedCreationSupported,
repoDefaultBaseRef: prRepoDefaultBaseRef,
baseQuery: prBaseQuery,
@@ -107,9 +108,7 @@ export function useSourceControlCreateReviewComposer({
seed: activePullRequestGenerationRecord?.seed ?? null,
seedFieldRevisions: activePullRequestGenerationRecord?.seedFieldRevisions ?? null,
onSeedRestored: handlePullRequestGenerationSeedRestored,
onGenerate: (fields, fieldRevisions, overrides) => {
void handleGeneratePullRequestFieldsForActive(fields, fieldRevisions, overrides)
},
onGenerate: handleGeneratePullRequestFieldsForActive,
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
@@ -202,6 +201,7 @@ export function useSourceControlCreateReviewComposer({
prBaseSearchPending,
prBody,
prDraft,
prFieldsAreSeedPlaceholders,
prGenerateDisabled,
prGenerateDisabledReason,
prGenerateError,
@@ -0,0 +1,623 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeGitClient from '@/runtime/runtime-git-client'
import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context'
import { useAppStore } from '@/store'
import {
createRunningPullRequestGenerationRecord,
resolvePullRequestGenerationFailure,
resolvePullRequestGenerationSuccess,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
import type { PullRequestGenerationOutcome } from '../../create-pull-request-dialog-field-model'
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { getDefaultSettings } from '../../../../../../shared/constants'
import type {
CreateHostedReviewResult,
HostedReviewCreationEligibility
} from '../../../../../../shared/hosted-review'
import {
DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
getDefaultSourceControlAiSettings
} from '../../../../../../shared/source-control-ai-settings'
import { useSourceControlHostedReviewCreation } from './use-hosted-review-creation'
import { useSourceControlHostedReviewEligibility } from './use-hosted-review-eligibility'
import { useSourceControlHostedReviewState } from './use-hosted-review-state'
import { useSourceControlPullRequestGeneration } from './use-pull-request-generation'
const runtime = vi.hoisted(() => ({ cancel: vi.fn(), generate: vi.fn() }))
vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
const original = await importOriginal<typeof RuntimeGitClient>()
return {
...original,
cancelRuntimeGeneratePullRequestFields: runtime.cancel,
generateRuntimePullRequestFields: runtime.generate
}
})
type Input = Parameters<typeof useSourceControlHostedReviewCreation>[0]
const generatedFields = {
base: 'develop',
title: 'Correct README install steps',
body: 'Fixes the typo.',
draft: true
}
const GENERATION_KEY = 'wt-1::repo-1::fix-readme-typo'
const runningRecordFor = (options: PullRequestGenerationOptions = {}) =>
createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-1',
worktreePath: '/repo',
requestId: 7,
repoId: 'repo-1',
branch: 'fix-readme-typo'
},
{ base: 'main', title: 'Fix readme typo', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
options.autoSubmit
)
const runningRecord = runningRecordFor()
const readyEligibility: HostedReviewCreationEligibility = {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found'
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
runtime.cancel.mockReset()
runtime.generate.mockReset()
useAppStore.setState({ pullRequestGenerationRecords: {}, activeWorktreeId: null })
})
function makeInput(overrides: Partial<Input> = {}): Input {
const createdReview: CreateHostedReviewResult = {
ok: true,
number: 42,
url: 'https://github.com/o/r/pull/42'
}
return {
activePullRequestGenerationKey: GENERATION_KEY,
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeWorktreeId: 'wt-1',
branchName: 'fix-readme-typo',
createHostedReview: vi.fn(async () => createdReview),
createPrInFlightRef: { current: {} },
createStackedHostedReview: vi.fn(),
handleGeneratePullRequestFields: generationReturning(generatedFields),
handlePullRequestCreated: vi.fn(async () => {}),
hostedReviewCreateCopy: localizedHostedReviewCopy('github'),
hostedReviewCreateProvider: 'github',
hostedReviewCreation: readyEligibility,
prAiGenerationEnabled: true,
prBase: 'main',
prBody: '',
prDraft: false,
prFieldsAreSeedPlaceholders: true,
prGenerating: false,
prTitle: 'Fix readme typo',
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setCreatePrInFlightByWorktree: vi.fn(),
setCreatePrIntentNoticeForWorktree: vi.fn(),
settings: {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' }
},
worktreePath: '/repo',
...overrides
}
}
// Like the store-routed generation: the record runs before the first await and settles to the outcome.
function startGeneration(options?: PullRequestGenerationOptions): void {
useAppStore.getState().setPullRequestGenerationRecord(GENERATION_KEY, runningRecordFor(options))
}
function settleGeneration(
result: PullRequestGenerationFields | null
): PullRequestGenerationOutcome {
useAppStore
.getState()
.updatePullRequestGenerationRecord(GENERATION_KEY, (record) =>
result
? resolvePullRequestGenerationSuccess({ record, requestId: 7, result })
: resolvePullRequestGenerationFailure({ record, requestId: 7, error: 'Agent failed' })
)
return {
result: useAppStore.getState().pullRequestGenerationRecords[GENERATION_KEY]?.result ?? null
}
}
function generationReturning(result: PullRequestGenerationFields | null) {
return vi.fn(async (_overrides?: unknown, options?: PullRequestGenerationOptions) => {
startGeneration(options)
return settleGeneration(result)
})
}
function deferredGeneration() {
let finish: (result: PullRequestGenerationFields | null) => void = () => {}
const generate = vi.fn((_overrides?: unknown, options?: PullRequestGenerationOptions) => {
startGeneration(options)
return new Promise<PullRequestGenerationOutcome>((resolve) => {
finish = (result) => resolve(settleGeneration(result))
})
})
return { generate, finish: (result: PullRequestGenerationFields | null) => finish(result) }
}
describe('useSourceControlHostedReviewCreation', () => {
it('generates details for untouched placeholders, then creates with them', async () => {
const input = makeInput()
const { result } = renderHook(() => useSourceControlHostedReviewCreation(input))
await act(async () => result.current.handleCreatePullRequest())
expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({
base: 'main',
title: 'Correct README install steps',
body: 'Fixes the typo.',
draft: true
})
)
})
it('does not create when generation fails, and the next click submits as shown, even after the panel reopens', async () => {
const input = makeInput({ handleGeneratePullRequestFields: generationReturning(null) })
const first = renderHook(() => useSourceControlHostedReviewCreation(input))
await act(async () => first.result.current.handleCreatePullRequest())
expect(input.createHostedReview).not.toHaveBeenCalled()
first.unmount()
const reopened = renderHook(() => useSourceControlHostedReviewCreation(input))
await act(async () => reopened.result.current.handleCreatePullRequest())
expect(input.handleGeneratePullRequestFields).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: 'Fix readme typo', body: '' })
)
})
it('starts one run for repeated clicks and creates once', async () => {
const { generate, finish } = deferredGeneration()
const input = makeInput({ handleGeneratePullRequestFields: generate })
const { result } = renderHook(() => useSourceControlHostedReviewCreation(input))
let firstClick: Promise<void> = Promise.resolve()
await act(async () => {
firstClick = result.current.handleCreatePullRequest()
await result.current.handleCreatePullRequest()
})
await act(async () => {
finish(generatedFields)
await firstClick
})
expect(generate).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
})
it('generates for another worktree while the first worktree is still generating', async () => {
const { generate: generateA, finish: finishA } = deferredGeneration()
const input = makeInput({ handleGeneratePullRequestFields: generateA })
const { result, rerender } = renderHook(
(props: Input) => useSourceControlHostedReviewCreation(props),
{ initialProps: input }
)
let clickA: Promise<void> = Promise.resolve()
act(() => {
clickA = result.current.handleCreatePullRequest()
})
const generateB = vi.fn(async () => ({ result: generatedFields }))
rerender({
...input,
activePullRequestGenerationKey: 'wt-2::repo-1::add-usage',
activeWorktreeId: 'wt-2',
branchName: 'add-usage',
handleGeneratePullRequestFields: generateB
})
await act(async () => result.current.handleCreatePullRequest())
await act(async () => {
finishA(null)
await clickA
})
expect(generateB).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: 'Correct README install steps', head: 'add-usage' })
)
})
it('submits as shown when clicked again after Stop', async () => {
const { generate, finish } = deferredGeneration()
const input = makeInput({ handleGeneratePullRequestFields: generate })
const { result } = renderHook(() => useSourceControlHostedReviewCreation(input))
let firstClick: Promise<void> = Promise.resolve()
act(() => {
firstClick = result.current.handleCreatePullRequest()
})
useAppStore
.getState()
.setPullRequestGenerationRecord(GENERATION_KEY, { ...runningRecord, status: 'canceled' })
await act(async () => {
finish(generatedFields)
await firstClick
})
await act(async () => result.current.handleCreatePullRequest())
expect(generate).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: 'Fix readme typo', body: '' })
)
})
it('still creates the clicked branch PR when the panel closes mid-run', async () => {
const { generate, finish } = deferredGeneration()
const input = makeInput({ handleGeneratePullRequestFields: generate })
const { result, rerender, unmount } = renderHook(
(props: Input) => useSourceControlHostedReviewCreation(props),
{ initialProps: input }
)
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest()
})
rerender({ ...input, prGenerating: true })
unmount()
await act(async () => {
finish(generatedFields)
await click
})
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ head: 'fix-readme-typo', title: 'Correct README install steps' })
)
})
it.each([
{ name: 'still selected', selectedAtFinish: 'wt-1', reveals: true },
{ name: 'no longer selected', selectedAtFinish: 'wt-2', reveals: false }
])(
'reveals the created PR only when its worktree is $name after the panel closed mid-run',
async ({ selectedAtFinish, reveals }) => {
const openUrl = vi.fn()
vi.stubGlobal('api', { shell: { openUrl } })
useAppStore.setState({ activeWorktreeId: 'wt-1' })
const { generate, finish } = deferredGeneration()
const input = makeInput({
handleGeneratePullRequestFields: generate,
resolvedPrCreationDefaults: {
...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
openAfterCreate: true
}
})
const { result, unmount } = renderHook(() => useSourceControlHostedReviewCreation(input))
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest()
})
unmount()
useAppStore.setState({ activeWorktreeId: selectedAtFinish })
await act(async () => {
finish(generatedFields)
await click
})
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.handlePullRequestCreated).toHaveBeenCalledWith(
expect.objectContaining({ number: 42 }),
expect.objectContaining({ worktreeId: 'wt-1', openChecks: reveals })
)
expect(openUrl).toHaveBeenCalledTimes(reveals ? 1 : 0)
}
)
it('creates after generation even though generation ending refreshes eligibility', async () => {
const { generate, finish } = deferredGeneration()
const base = makeInput({ handleGeneratePullRequestFields: generate })
// Why: the first probe confirms the branch; any refetch stays pending, as a real one does for a while.
const getEligibility = vi
.fn<() => Promise<HostedReviewCreationEligibility>>()
.mockResolvedValueOnce(readyEligibility)
.mockReturnValue(new Promise(() => {}))
// Wires the real eligibility probe to the real create, as the Source Control panel does.
const { result } = renderHook(() => {
const [inFlight, setCreatePrInFlightByWorktree] = useState<Record<string, boolean>>({})
const createPrInFlightRef = useRef<Record<string, boolean>>({})
const prGenerating = useAppStore(
(s) => s.pullRequestGenerationRecords[GENERATION_KEY]?.status === 'running'
)
const state = useSourceControlHostedReviewState({
activePrFromQueue: null,
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
branchName: 'fix-readme-typo',
hostedReviewCacheKey: null,
hostedReviewEntryData: null,
linkedPR: null,
suppressedGitHubPR: null
})
useSourceControlHostedReviewEligibility({
activeRepoConnectionId: null,
activeRepoExecutionHostId: null,
activeRepoId: 'repo-1',
activeRepoPath: '/repo',
activeWorktreeId: 'wt-1',
branchName: 'fix-readme-typo',
effectiveBaseRef: 'main',
fallbackGitHubPRNumber: null,
getHostedReviewCreationEligibility: getEligibility,
hasUncommittedEntries: false,
isBranchVisible: true,
isCreatePrIntentInFlight: false,
isCreatingPr: inFlight['wt-1'] === true,
isFolder: false,
linkedAzureDevOpsPR: null,
linkedBitbucketPR: null,
linkedGitHubPR: null,
linkedGitLabMR: null,
linkedGiteaPR: null,
prGenerating,
provisionalHostedReviewProvider: 'github',
remoteStatus: undefined,
hostedReviewCreationProviderHintRef: state.hostedReviewCreationProviderHintRef,
setHostedReviewCreationRequestState: state.setHostedReviewCreationRequestState,
setHostedReviewCreationState: state.setHostedReviewCreationState,
worktreePath: '/repo'
})
return useSourceControlHostedReviewCreation({
...base,
createPrInFlightRef,
hostedReviewCreation: state.hostedReviewCreation,
prGenerating,
setCreatePrInFlightByWorktree
})
})
await act(async () => {})
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest()
})
// A repeated click is refused while the first one is in flight.
await act(async () => result.current.handleCreatePullRequest())
// The run's record settles and the panel re-renders before the click's continuation resumes.
await act(async () => {
useAppStore
.getState()
.updatePullRequestGenerationRecord(GENERATION_KEY, (record) =>
resolvePullRequestGenerationSuccess({ record, requestId: 7, result: generatedFields })
)
})
await act(async () => {
finish(generatedFields)
await click
})
expect(base.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: 'Correct README install steps' })
)
})
it.each([
{ name: 'fails', result: null },
{ name: 'creates', result: generatedFields }
])('releases the in-flight hold when the run $name', async ({ result }) => {
const { generate, finish } = deferredGeneration()
const input = makeInput({ handleGeneratePullRequestFields: generate })
const { result: hook } = renderHook(() => useSourceControlHostedReviewCreation(input))
const inFlight = (): boolean | undefined =>
vi
.mocked(input.setCreatePrInFlightByWorktree)
.mock.calls.reduce<Record<string, boolean>>(
(state, [update]) => (typeof update === 'function' ? update(state) : update),
{}
)['wt-1']
let click: Promise<void> = Promise.resolve()
act(() => {
click = hook.current.handleCreatePullRequest()
})
expect(inFlight()).toBe(true)
await act(async () => {
finish(result)
await click
})
expect(inFlight()).toBe(false)
})
it('releases the hold as soon as Stop lands, so the next click submits as shown while the stopped request is still pending', async () => {
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
// The cancel never reaches the host, so the stopped request stays pending.
runtime.cancel.mockReturnValue(new Promise(() => {}))
const base = makeInput()
// Wires the real store-routed generation and Stop to the real create and its hold, as the Source Control panel does.
const { result } = renderHook(() => {
const [inFlight, setCreatePrInFlightByWorktree] = useState<Record<string, boolean>>({})
const createPrInFlightRef = useRef<Record<string, boolean>>({})
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const generation = useSourceControlPullRequestGeneration({
activeRepo: base.activeRepo,
activeRepoSettings: null,
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId:
useAppStore.getState().allocatePullRequestGenerationRequestId,
branchName: 'fix-readme-typo',
hostedReviewCreateProvider: 'github',
prGenerationRecords,
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord: useAppStore.getState().setPullRequestGenerationRecord,
updatePullRequestGenerationRecord: useAppStore.getState().updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
const creation = useSourceControlHostedReviewCreation({
...base,
activePullRequestGenerationKey: generation.activePullRequestGenerationKey,
createPrInFlightRef,
handleGeneratePullRequestFields: (overrides, options) =>
generation.handleGeneratePullRequestFieldsForActive(
{ base: base.prBase, title: base.prTitle, body: base.prBody, draft: base.prDraft },
{ base: 0, title: 0, body: 0, draft: 0 },
overrides,
options
),
prGenerating: generation.activePullRequestGenerationRecord?.status === 'running',
setCreatePrInFlightByWorktree
})
return { creation, generation, inFlight: inFlight['wt-1'] === true }
})
let firstClick: Promise<void> = Promise.resolve()
act(() => {
firstClick = result.current.creation.handleCreatePullRequest()
})
expect(runtime.generate).toHaveBeenCalledTimes(1)
expect(result.current.inFlight).toBe(true)
await act(async () =>
result.current.generation.handleCancelGeneratePullRequestFieldsForActive()
)
expect(result.current.inFlight).toBe(false)
await act(async () => result.current.creation.handleCreatePullRequest())
expect(runtime.generate).toHaveBeenCalledTimes(1)
expect(base.createHostedReview).toHaveBeenCalledTimes(1)
expect(base.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: 'Fix readme typo', body: '' })
)
// The stopped request's late result creates nothing.
await act(async () => {
answer({ success: true, fields: generatedFields })
await firstClick
})
expect(base.createHostedReview).toHaveBeenCalledTimes(1)
})
it('creates the clicked branch PR without revealing Checks when the panel moved to another worktree mid-run', async () => {
const openUrl = vi.fn()
vi.stubGlobal('api', { shell: { openUrl } })
useAppStore.setState({ activeWorktreeId: 'wt-1' })
const { generate, finish } = deferredGeneration()
const input = makeInput({
handleGeneratePullRequestFields: generate,
resolvedPrCreationDefaults: {
...DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
openAfterCreate: true
}
})
const { result, rerender } = renderHook(
(props: Input) => useSourceControlHostedReviewCreation(props),
{ initialProps: input }
)
let click: Promise<void> = Promise.resolve()
act(() => {
click = result.current.handleCreatePullRequest()
})
useAppStore.setState({ activeWorktreeId: 'wt-2' })
rerender({
...input,
activePullRequestGenerationKey: 'wt-2::repo-1::other-branch',
activeWorktreeId: 'wt-2',
branchName: 'other-branch',
hostedReviewCreation: null
})
await act(async () => {
finish(generatedFields)
await click
})
expect(input.createHostedReview).toHaveBeenCalledTimes(1)
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ head: 'fix-readme-typo', title: 'Correct README install steps' })
)
expect(input.handlePullRequestCreated).toHaveBeenCalledWith(
expect.objectContaining({ number: 42 }),
expect.objectContaining({ worktreeId: 'wt-1', branch: 'fix-readme-typo', openChecks: false })
)
expect(openUrl).not.toHaveBeenCalled()
const noticeTargets = vi
.mocked(input.setCreatePrIntentNoticeForWorktree)
.mock.calls.map(([worktreeId]) => worktreeId)
expect(new Set(noticeTargets)).toEqual(new Set(['wt-1']))
})
it.each([
{ name: 'the fields were edited', overrides: { prFieldsAreSeedPlaceholders: false } },
{ name: 'AI actions are off', overrides: { prAiGenerationEnabled: false } },
{
name: 'no PR agent is configured',
overrides: { settings: getDefaultSettings('/home/test') }
},
{
name: 'generation cannot start',
overrides: { handleGeneratePullRequestFields: vi.fn(async () => undefined) }
}
])('submits as shown when $name', async ({ overrides }) => {
const input = makeInput(overrides)
const { result } = renderHook(() => useSourceControlHostedReviewCreation(input))
await act(async () => result.current.handleCreatePullRequest())
expect(input.createHostedReview).toHaveBeenCalledWith(
'/repo',
expect.objectContaining({ title: input.prTitle, body: '' })
)
})
it('shows the blocked notice instead of generating when the branch is not ready', async () => {
const input = makeInput({
hostedReviewCreation: { ...readyEligibility, canCreate: false, blockedReason: 'needs_push' }
})
const { result } = renderHook(() => useSourceControlHostedReviewCreation(input))
await act(async () => result.current.handleCreatePullRequest())
expect(input.handleGeneratePullRequestFields).not.toHaveBeenCalled()
expect(input.createHostedReview).not.toHaveBeenCalled()
expect(input.setCreatePrIntentNoticeForWorktree).toHaveBeenCalledWith(
'wt-1',
expect.objectContaining({ tone: 'destructive' })
)
})
})
@@ -10,62 +10,76 @@ import type { SourceControlWorktreeContext } from '../listing/use-worktree-conte
import type { SourceControlWorktreeOperationState } from '../panel/use-worktree-operation-state'
import type { SourceControlCreateReviewComposer } from './use-create-review-composer'
import type { SourceControlHostedReviewCreated } from './use-hosted-review-created'
import type { HostedReviewCreatedContext } from './hosted-review-creation-state'
import type { SourceControlHostedReviewState } from './use-hosted-review-state'
import type { SourceControlPullRequestGeneration } from './use-pull-request-generation'
import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation'
import { useGenerateBeforeCreatePullRequest } from '../../use-generate-before-create-pull-request'
import { createdReviewIsForeground } from '../../created-review-foreground'
/**
* Submits the composer as a hosted review (optionally stacked) and reconciles the "already open" and
* partially-created-stack outcomes, both of which still leave a real review to link.
*/
export function useSourceControlHostedReviewCreation({
activePullRequestGenerationKey,
activeRepo,
activeWorktreeId,
branchName,
createHostedReview,
createPrInFlightRef,
createStackedHostedReview,
handleGeneratePullRequestFields,
handlePullRequestCreated,
hostedReviewCreateCopy,
hostedReviewCreateProvider,
hostedReviewCreation,
prAiGenerationEnabled,
prBase,
prBody,
prDraft,
prFieldsAreSeedPlaceholders,
prGenerating,
prTitle,
resolvedPrCreationDefaults,
setCreatePrInFlightByWorktree,
setCreatePrIntentNoticeForWorktree,
settings,
worktreePath
}: {
activePullRequestGenerationKey: SourceControlPullRequestGeneration['activePullRequestGenerationKey']
activeRepo: SourceControlWorktreeContext['activeRepo']
activeWorktreeId: string | null
branchName: string
createHostedReview: SourceControlStoreActions['createHostedReview']
createPrInFlightRef: SourceControlWorktreeOperationState['createPrInFlightRef']
createStackedHostedReview: SourceControlStoreActions['createStackedHostedReview']
handleGeneratePullRequestFields: SourceControlCreateReviewComposer['handleGeneratePullRequestFields']
handlePullRequestCreated: SourceControlHostedReviewCreated['handlePullRequestCreated']
hostedReviewCreateCopy: SourceControlHostedReviewState['hostedReviewCreateCopy']
hostedReviewCreateProvider: SourceControlHostedReviewState['hostedReviewCreateProvider']
hostedReviewCreation: SourceControlHostedReviewState['hostedReviewCreation']
prAiGenerationEnabled: SourceControlCreateReviewComposer['prAiGenerationEnabled']
prBase: SourceControlCreateReviewComposer['prBase']
prBody: SourceControlCreateReviewComposer['prBody']
prDraft: SourceControlCreateReviewComposer['prDraft']
prFieldsAreSeedPlaceholders: SourceControlCreateReviewComposer['prFieldsAreSeedPlaceholders']
prGenerating: boolean
prTitle: SourceControlCreateReviewComposer['prTitle']
resolvedPrCreationDefaults: SourceControlAi['resolvedPrCreationDefaults']
setCreatePrInFlightByWorktree: SourceControlWorktreeOperationState['setCreatePrInFlightByWorktree']
setCreatePrIntentNoticeForWorktree: SourceControlWorktreeOperationState['setCreatePrIntentNoticeForWorktree']
settings: SourceControlWorktreeContext['settings']
worktreePath: string | null
}) {
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
const createPullRequest = useCallback(
async (stacked = false, generated?: PullRequestGenerationFields): Promise<void> => {
if (
!activeRepo ||
!activeWorktreeId ||
!worktreePath ||
!hostedReviewCreation ||
prGenerating ||
createPrInFlightRef.current[activeWorktreeId]
prGenerating
) {
return
}
@@ -82,8 +96,9 @@ export function useSourceControlHostedReviewCreation({
return
}
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
const fields = generated ?? { base: prBase, title: prTitle, body: prBody, draft: prDraft }
const base = stripBaseRef(fields.base).trim()
const title = fields.title.trim()
if (!title) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
@@ -109,9 +124,14 @@ export function useSourceControlHostedReviewCreation({
return
}
createPrInFlightRef.current[activeWorktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
const createdContext = (): HostedReviewCreatedContext => ({
repoPath: activeRepo.path,
repoId: activeRepo.id,
branch: branchName,
worktreeId: activeWorktreeId,
openChecks: createdReviewIsForeground(activeWorktreeId)
})
try {
const createInput = {
repoId: activeRepo.id,
@@ -119,8 +139,8 @@ export function useSourceControlHostedReviewCreation({
base,
head: normalizeHostedReviewHeadRef(branchName),
title,
body: prBody,
draft: prDraft,
body: fields.body,
draft: fields.draft,
worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
}
@@ -130,12 +150,16 @@ export function useSourceControlHostedReviewCreation({
if (result.ok) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
})
if (resolvedPrCreationDefaults.openAfterCreate) {
const context = createdContext()
await handlePullRequestCreated(
{
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
},
context
)
if (context.openChecks && resolvedPrCreationDefaults.openAfterCreate) {
window.api.shell.openUrl(result.url)
}
return
@@ -168,11 +192,14 @@ export function useSourceControlHostedReviewCreation({
)
if (number) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
})
await handlePullRequestCreated(
{
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
},
createdContext()
)
return
}
}
@@ -183,11 +210,14 @@ export function useSourceControlHostedReviewCreation({
if ('createdReview' in result && result.createdReview?.url) {
const { number, url } = result.createdReview
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url
})
await handlePullRequestCreated(
{
provider: hostedReviewCreateProvider,
number,
url
},
createdContext()
)
}
}
@@ -207,9 +237,6 @@ export function useSourceControlHostedReviewCreation({
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
} finally {
createPrInFlightRef.current[activeWorktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
}
},
[
@@ -217,7 +244,6 @@ export function useSourceControlHostedReviewCreation({
activeWorktreeId,
branchName,
createHostedReview,
createPrInFlightRef,
createStackedHostedReview,
handlePullRequestCreated,
hostedReviewCreation,
@@ -232,12 +258,47 @@ export function useSourceControlHostedReviewCreation({
prTitle,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
setCreatePrInFlightByWorktree,
setCreatePrIntentNoticeForWorktree,
worktreePath
]
)
const { handleCreatePullRequest: generateThenCreatePullRequest } =
useGenerateBeforeCreatePullRequest({
aiGenerationEnabled: prAiGenerationEnabled,
canCreate: hostedReviewCreation?.canCreate === true,
createPullRequest,
fieldsAreSeedPlaceholders: prFieldsAreSeedPlaceholders,
generatePullRequestFields: handleGeneratePullRequestFields,
generationKey: activePullRequestGenerationKey,
repo: activeRepo,
settings
})
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
const worktreeId = activeWorktreeId
if (!worktreeId || createPrInFlightRef.current[worktreeId]) {
return
}
// Why: like the prepare-branch route, the click is in flight until its create settles, so the composer stays up through generation instead of closing while eligibility refreshes.
createPrInFlightRef.current[worktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: true }))
try {
await generateThenCreatePullRequest(stacked)
} finally {
createPrInFlightRef.current[worktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [worktreeId]: false }))
}
},
[
activeWorktreeId,
createPrInFlightRef,
generateThenCreatePullRequest,
setCreatePrInFlightByWorktree
]
)
return { handleCreatePullRequest }
}
@@ -0,0 +1,203 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as RuntimeGitClient from '@/runtime/runtime-git-client'
import type { RuntimeGeneratePullRequestFieldsResult } from '@/runtime/runtime-git-client-context'
import { useAppStore } from '@/store'
import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS } from '../../../../../../shared/source-control-ai-settings'
const runtime = vi.hoisted(() => ({
cancel: vi.fn().mockResolvedValue(undefined),
generate: vi.fn()
}))
vi.mock('@/runtime/runtime-git-client', async (importOriginal) => {
const original = await importOriginal<typeof RuntimeGitClient>()
return {
...original,
cancelRuntimeGeneratePullRequestFields: runtime.cancel,
generateRuntimePullRequestFields: runtime.generate
}
})
import { useSourceControlPullRequestGeneration } from './use-pull-request-generation'
afterEach(() => {
cleanup()
runtime.cancel.mockReset().mockResolvedValue(undefined)
runtime.generate.mockReset()
useAppStore.setState({ pullRequestGenerationRecords: {} })
})
describe('useSourceControlPullRequestGeneration outcome', () => {
it.each([
{ name: 'keeps the base for a Create PR run', autoSubmit: true, base: 'main' },
{ name: 'keeps the agent base for a reviewed run', autoSubmit: false, base: 'develop' }
])('$name', async ({ autoSubmit, base }) => {
const generated = { base: 'develop', title: 'Add feature flag', body: 'Details.', draft: false }
runtime.generate.mockResolvedValue({ success: true, fields: generated })
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoSettings: null,
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: {},
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
const outcome = await result.current.handleGeneratePullRequestFieldsForActive(
{ base: 'main', title: 'Feature', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit }
)
expect(outcome).toEqual({ result: { ...generated, base } })
})
it("returns no result when a later run replaced this one, not the later run's details", async () => {
const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature'])
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoSettings: null,
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: {},
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
const fields = { base: 'main', title: 'Feature', body: '', draft: false }
const outcome = result.current.handleGeneratePullRequestFieldsForActive(
fields,
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
// Stop, then a Generate click whose run finishes before the stopped one winds down.
const stopped = useAppStore.getState().pullRequestGenerationRecords[generationKey]
expect(stopped?.status).toBe('running')
setPullRequestGenerationRecord(generationKey, {
...stopped!,
context: { ...stopped!.context, requestId: 6 },
status: 'succeeded',
result: { ...fields, title: 'Later run' }
})
answer({ success: false, error: 'canceled', canceled: true })
await expect(outcome).resolves.toEqual({ result: null })
})
it('settles with no result as soon as Stop lands, without waiting for the stopped request, and drops its late result', async () => {
const generationKey = JSON.stringify(['repo-1', 'wt-1', 'feature'])
let answer: (result: RuntimeGeneratePullRequestFieldsResult) => void = () => {}
runtime.generate.mockImplementation(
() =>
new Promise<RuntimeGeneratePullRequestFieldsResult>((resolve) => {
answer = resolve
})
)
// The cancel never reaches the host, so the stopped request stays pending.
runtime.cancel.mockReturnValue(new Promise(() => {}))
const subscribe = useAppStore.subscribe
const released = vi.fn()
const subscribed = vi.spyOn(useAppStore, 'subscribe').mockImplementation((listener) => {
const unsubscribe = subscribe(listener)
return () => {
released()
unsubscribe()
}
})
const { setPullRequestGenerationRecord, updatePullRequestGenerationRecord } =
useAppStore.getState()
const { result } = renderHook(() =>
useSourceControlPullRequestGeneration({
activeRepo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
},
activeRepoSettings: null,
activeWorktreeId: 'wt-1',
allocatePullRequestGenerationRequestId: vi.fn(() => 5),
branchName: 'feature',
hostedReviewCreateProvider: 'github',
prGenerationRecords: useAppStore((s) => s.pullRequestGenerationRecords),
refreshGitStatusAfterPullRequestGeneration: vi.fn(),
resolvedPrCreationDefaults: DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS,
setPullRequestGenerationRecord,
updatePullRequestGenerationRecord,
worktreePath: '/repo'
})
)
let outcome: Promise<unknown> = Promise.resolve()
act(() => {
outcome = result.current.handleGeneratePullRequestFieldsForActive(
{ base: 'main', title: 'Feature', body: '', draft: false },
{ base: 0, title: 0, body: 0, draft: 0 },
undefined,
{ autoSubmit: true }
)
})
act(() => result.current.handleCancelGeneratePullRequestFieldsForActive())
expect(runtime.cancel).toHaveBeenCalledTimes(1)
const stillPending = new Promise((resolve) => setTimeout(() => resolve('still pending'), 50))
await expect(Promise.race([outcome, stillPending])).resolves.toEqual({ result: null })
// The run's store listener is gone once its outcome settles.
expect(subscribed).toHaveBeenCalledTimes(1)
expect(released).toHaveBeenCalledTimes(1)
subscribed.mockRestore()
answer({
success: true,
fields: { base: 'develop', title: 'Late run', body: 'Late.', draft: false }
})
await act(async () => {})
expect(useAppStore.getState().pullRequestGenerationRecords[generationKey]).toMatchObject({
status: 'canceled',
result: null
})
})
})
@@ -5,6 +5,8 @@ import {
generateRuntimePullRequestFields,
type RuntimeGeneratePullRequestFieldsOverrides
} from '@/runtime/runtime-git-client'
import type { PullRequestGenerationOutcome } from '../../create-pull-request-dialog-field-model'
import { settlePullRequestGenerationRequest } from '../../pull-request-generation-request-outcome'
import { useAppStore } from '@/store'
import {
createRunningPullRequestGenerationRecord,
@@ -18,6 +20,7 @@ import {
type PullRequestGenerationContext,
type PullRequestGenerationFields
} from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
import type { HostedReviewProvider } from '../../../../../../shared/hosted-review'
import type { SourceControlAi } from '../ai/use-ai'
import { stripBaseRef } from '../../create-pull-request-base-ref-normalization'
@@ -26,7 +29,7 @@ import type { SourceControlWorktreeContext } from '../listing/use-worktree-conte
import type { SourceControlStatusRefresh } from '../sync/use-status-refresh'
/**
* Runs AI generation of the PR title/body/base for the active branch through a store record, so a
* Runs AI generation of the PR details for the active branch through a store record, so a
* run started before a tab switch is still resumable when the composer remounts.
*/
export function useSourceControlPullRequestGeneration({
@@ -80,16 +83,17 @@ export function useSourceControlPullRequestGeneration({
async (
fields: PullRequestGenerationFields,
fieldRevisions: PullRequestFieldRevisions,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<void> => {
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
options?: PullRequestGenerationOptions
): Promise<PullRequestGenerationOutcome | undefined> => {
if (!activeRepo || !activePullRequestGenerationKey || !worktreePath || !branchName) {
return
return undefined
}
const generationKey = activePullRequestGenerationKey
if (
useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status === 'running'
) {
return
return undefined
}
const requestId = allocatePullRequestGenerationRequestId()
const context: PullRequestGenerationContext = {
@@ -105,67 +109,70 @@ export function useSourceControlPullRequestGeneration({
// Why: SourceControl can unmount on tab switches; the persisted record lets the PR composer resume on return.
setPullRequestGenerationRecord(
generationKey,
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions)
createRunningPullRequestGenerationRecord(context, seed, fieldRevisions, options?.autoSubmit)
)
try {
const result = await generateRuntimePullRequestFields(
{
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef(seed.base.trim()),
title: seed.title,
body: seed.body,
draft: seed.draft,
provider: hostedReviewCreateProvider,
useTemplate: resolvedPrCreationDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await refreshGitStatusAfterPullRequestGeneration(context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
const request = (async (): Promise<void> => {
try {
const result = await generateRuntimePullRequestFields(
{
// Why: route generation by the repo OWNER host, not the focused runtime.
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
},
{
base: stripBaseRef(seed.base.trim()),
title: seed.title,
body: seed.body,
draft: seed.draft,
provider: hostedReviewCreateProvider,
useTemplate: resolvedPrCreationDefaults.useTemplate
},
overrides
)
if (result.branchChangedByPreparation) {
await refreshGitStatusAfterPullRequestGeneration(context)
}
if (result.success) {
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
}
updatePullRequestGenerationRecord(generationKey, (record) => {
if (!result.success) {
return resolvePullRequestGenerationFailure({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
})
}
if (!record) {
return null
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
canceled: result.canceled,
error: result.canceled ? null : result.error
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
}
if (!record) {
return null
}
return resolvePullRequestGenerationSuccess({
record,
requestId,
result: {
base: stripBaseRef(result.fields.base),
title: result.fields.title,
body: result.fields.body,
draft: result.fields.draft
}
})
})
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
} catch (error) {
updatePullRequestGenerationRecord(generationKey, (record) =>
resolvePullRequestGenerationFailure({
record,
requestId,
error:
error instanceof Error ? error.message : 'Failed to generate pull request details'
})
)
}
})()
return await settlePullRequestGenerationRequest(generationKey, requestId, request)
},
[
activePullRequestGenerationKey,
@@ -154,24 +154,29 @@ export function useSourceControlReviewFlows(foundation: SourceControlPanelFounda
worktreePath
})
const hostedReviewCreationAction = useSourceControlHostedReviewCreation({
activePullRequestGenerationKey: pullRequestGeneration.activePullRequestGenerationKey,
activeRepo,
activeWorktreeId,
branchName,
createHostedReview,
createPrInFlightRef,
createStackedHostedReview,
handleGeneratePullRequestFields: createReviewComposer.handleGeneratePullRequestFields,
handlePullRequestCreated: hostedReviewCreated.handlePullRequestCreated,
hostedReviewCreateCopy,
hostedReviewCreateProvider,
hostedReviewCreation,
prAiGenerationEnabled: createReviewComposer.prAiGenerationEnabled,
prBase,
prBody,
prDraft,
prFieldsAreSeedPlaceholders: createReviewComposer.prFieldsAreSeedPlaceholders,
prGenerating,
prTitle,
resolvedPrCreationDefaults,
setCreatePrInFlightByWorktree,
setCreatePrIntentNoticeForWorktree,
settings,
worktreePath
})
@@ -10,9 +10,11 @@ import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-
import type { ResolveSourceControlAiResult } from '../../../../shared/source-control-ai'
import type { SourceControlAiPrCreationDefaults } from '../../../../shared/source-control-ai-types'
import type { PullRequestFieldRevisions } from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
import { stripBaseRef } from './create-pull-request-base-ref-normalization'
import type {
GenerationSeed,
PullRequestGenerationOutcome,
PullRequestDraftFields,
UseCreatePullRequestDialogFieldsOptions
} from './create-pull-request-dialog-field-model'
@@ -53,7 +55,10 @@ type CreatePullRequestFieldGenerationResult = {
effectiveGenerateError: string | null
generateDisabled: boolean
generateDisabledReason: string | undefined
handleGenerate: (overrides?: RuntimeGeneratePullRequestFieldsOverrides) => Promise<void>
handleGenerate: (
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
options?: PullRequestGenerationOptions
) => Promise<PullRequestGenerationOutcome | void>
handleCancelGenerate: () => void
}
@@ -99,17 +104,20 @@ export function useCreatePullRequestFieldGeneration({
const generateDisabled = !effectiveGenerating && Boolean(generateDisabledReason)
const handleGenerate = useCallback(
async (overrides?: RuntimeGeneratePullRequestFieldsOverrides): Promise<void> => {
async (
overrides?: RuntimeGeneratePullRequestFieldsOverrides,
options?: PullRequestGenerationOptions
): Promise<PullRequestGenerationOutcome | void> => {
if (!worktreePath || !base.trim() || effectiveGenerating || generateDisabled) {
return
}
if (generation) {
generation.onGenerate(
return await generation.onGenerate(
{ base, title, body, draft },
{ ...fieldRevisionsRef.current },
overrides
overrides,
options
)
return
}
const requestId = generationRequestIdRef.current + 1
generationRequestIdRef.current = requestId
@@ -149,31 +157,33 @@ export function useCreatePullRequestFieldGeneration({
}
const isCurrentRequest = generationRequestIdRef.current === requestId
if (!isCurrentRequest) {
return
return { result: null }
}
if (!result.success) {
if (result.canceled) {
setGenerateError(null)
return
return { result: null }
}
setGenerateError(result.error)
return
return { result: null }
}
const currentSeed = generationSeedRef.current
if (!currentSeed || currentSeed.requestId !== requestId) {
return
return { result: null }
}
applyGeneratedFields(result.fields, currentSeed.fieldRevisions)
const shownFields = applyGeneratedFields(result.fields, currentSeed.fieldRevisions)
useAppStore.getState().recordFeatureInteraction('ai-pr-generation')
setGenerateError(null)
return { result: shownFields }
} catch (error) {
if (generationRequestIdRef.current !== requestId) {
return
return { result: null }
}
setGenerateError(
error instanceof Error ? error.message : 'Failed to generate pull request details'
)
return { result: null }
} finally {
if (generationRequestIdRef.current === requestId) {
generateInFlightRef.current = false
@@ -6,7 +6,7 @@ import type {
PullRequestFieldName,
PullRequestFieldRevisions
} from '@/store/slices/pull-request-generation'
import { resolveCreateReviewDraftTitle } from './create-review-draft-title'
import { resolveCreateReviewSeedText } from './create-review-draft-title'
import { stripBaseRef } from './create-pull-request-base-ref-normalization'
import {
createInitialPullRequestFieldRevisions,
@@ -146,8 +146,9 @@ export function useCreatePullRequestFieldSeeding({
baseEditedByUserRef.current = false
syncedDefaultBaseRef.current = resolvedDefaultBaseRef || null
setBase(resolvedDefaultBaseRef)
setTitle(resolveCreateReviewDraftTitle({ branch, eligibilityTitle: eligibility.title }))
setBody(eligibility.body ?? '')
const seedText = resolveCreateReviewSeedText({ branch, eligibility })
setTitle(seedText.title)
setBody(seedText.body)
setDraft(resolvedPrDefaults.draft)
setBaseQuery('')
setBaseResults([])
@@ -0,0 +1,89 @@
import { useCallback } from 'react'
import { useAppStore, type AppState } from '@/store'
import type { PullRequestGenerationFields } from '@/store/slices/pull-request-generation'
import type { PullRequestGenerationOptions } from '@/store/slices/pull-request-generation-auto-submit'
import type { Repo } from '../../../../shared/repo-types'
import type { PullRequestGenerationOutcome } from './create-pull-request-dialog-field-model'
import { hasConfiguredSourceControlTextGenerationDefaults } from './source-control/ai/text-generation-defaults'
type CreatePullRequest = (stacked?: boolean, fields?: PullRequestGenerationFields) => Promise<void>
/**
* Create PR on a ready branch: when the composer still holds Orca's seeded placeholders and a PR
* agent is configured, run the composer's own generation and create from its result, like the
* prepare-branch path does.
*/
export function useGenerateBeforeCreatePullRequest({
aiGenerationEnabled,
canCreate,
createPullRequest,
fieldsAreSeedPlaceholders,
generatePullRequestFields,
generationKey,
repo,
settings
}: {
aiGenerationEnabled: boolean
canCreate: boolean
createPullRequest: CreatePullRequest
fieldsAreSeedPlaceholders: boolean
generatePullRequestFields: (
overrides: undefined,
options: PullRequestGenerationOptions
) => Promise<PullRequestGenerationOutcome | void>
generationKey: string | null
repo: Pick<Repo, 'sourceControlAi'> | null
settings: AppState['settings']
}) {
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
const status = generationKey
? useAppStore.getState().pullRequestGenerationRecords[generationKey]?.status
: undefined
// Why: the record is written before generation's first await, so repeated clicks see it.
if (status === 'running') {
return
}
if (
!generationKey ||
!canCreate ||
!aiGenerationEnabled ||
!fieldsAreSeedPlaceholders ||
// Why: after a failed or stopped run the next click submits as shown, so a broken agent never blocks Create PR.
status === 'failed' ||
status === 'canceled' ||
!hasConfiguredSourceControlTextGenerationDefaults({
actionId: 'pullRequest',
settings,
repo
})
) {
await createPullRequest(stacked)
return
}
const outcome = await generatePullRequestFields(undefined, { autoSubmit: true })
if (!outcome) {
await createPullRequest(stacked)
return
}
if (!outcome.result) {
return
}
// Why: the click owns the run, so create through its closure wherever the user went; a later render can show another branch or cleared eligibility.
// The result is what the composer fills in, so the PR matches the form.
await createPullRequest(stacked, outcome.result)
},
[
aiGenerationEnabled,
canCreate,
createPullRequest,
fieldsAreSeedPlaceholders,
generatePullRequestFields,
generationKey,
repo,
settings
]
)
return { handleCreatePullRequest }
}
@@ -3,7 +3,9 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { getDefaultSourceControlAiSettings } from '../../../../shared/source-control-ai-settings'
import { normalizeCreateReviewBaseSearchResults } from './create-pull-request-base-ref-normalization'
import { useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
@@ -67,6 +69,7 @@ type DialogFieldsRenderInput = {
generation?: DialogGeneration
worktreeId?: string | null
branch?: string
settings?: Parameters<typeof useCreatePullRequestDialogFields>[0]['settings']
}
function renderDialogFields(input: DialogFieldsRenderInput): {
@@ -90,7 +93,7 @@ function renderDialogFields(input: DialogFieldsRenderInput): {
branch: currentInput.branch ?? 'feature/base-change',
eligibility: currentInput.eligibility,
currentBaseRef: currentInput.currentBaseRef,
settings: null,
settings: currentInput.settings ?? null,
submitting: false,
generation: currentInput.generation
})
@@ -258,6 +261,87 @@ describe('useCreatePullRequestDialogFields', () => {
}
})
it('reports seed placeholders until the title or body is edited', async () => {
const harness = renderDialogFields({ eligibility: createEligibility() })
try {
await harness.rerender({ eligibility: createEligibility() })
expect(harness.current().fieldsAreSeedPlaceholders).toBe(true)
act(() => {
harness.current().setBody('My notes')
})
expect(harness.current().fieldsAreSeedPlaceholders).toBe(false)
} finally {
harness.unmount()
}
})
it('keeps the base during a run when the compare base changes', async () => {
const generation: DialogGeneration = {
generating: true,
generateError: null,
seedRestoreKey: 'repo-1:wt-1:feature:1:running',
seed: { base: 'main', title: 'Review title', body: 'Review body', draft: false },
seedFieldRevisions: { base: 0, title: 0, body: 0, draft: 0 },
onSeedRestored: vi.fn(),
onGenerate: () => undefined,
onCancelGenerate: () => undefined
}
const harness = renderDialogFields({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main',
generation
})
try {
await harness.rerender({
eligibility: createEligibility(),
currentBaseRef: 'refs/remotes/origin/main',
generation
})
await harness.rerender({
eligibility: createEligibility({ defaultBaseRef: 'refs/remotes/origin/release' }),
currentBaseRef: 'refs/remotes/origin/release',
generation
})
expect(harness.current().base).toBe('main')
expect(harness.current().fieldRevisions.base).toBe(0)
} finally {
harness.unmount()
}
})
it('passes run options to the external generation', async () => {
const generation: DialogGeneration = {
generating: false,
generateError: null,
onGenerate: vi.fn(),
onCancelGenerate: () => undefined
}
const settings = {
...getDefaultSettings('/home/test'),
sourceControlAi: { ...getDefaultSourceControlAiSettings(), agentId: 'cursor' as const }
}
const harness = renderDialogFields({ eligibility: createEligibility(), generation, settings })
try {
await harness.rerender({ eligibility: createEligibility(), generation, settings })
await act(async () => {
await harness.current().handleGenerate(undefined, { autoSubmit: true })
})
expect(generation.onGenerate).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Review title' }),
expect.any(Object),
undefined,
{ autoSubmit: true }
)
} finally {
harness.unmount()
}
})
it('restores an external generation seed once after remount', async () => {
const seedFieldRevisions = {
base: 0,
@@ -11,6 +11,7 @@ import {
resolveCreateReviewDefaultBaseRef,
stripBaseRef
} from './create-pull-request-base-ref-normalization'
import { resolveCreateReviewSeedText } from './create-review-draft-title'
import {
createInitialPullRequestFieldRevisions,
type GenerationSeed,
@@ -75,6 +76,7 @@ export function useCreatePullRequestDialogFields({
const hasExternalGeneration = Boolean(generation)
const currentEligibilityKey =
open && eligibility ? `${repoId}:${worktreeId ?? worktreePath}:${branch}` : null
const seedText = resolveCreateReviewSeedText({ branch, eligibility })
const resolvedDefaultBaseRef = resolveCreateReviewDefaultBaseRef({
currentBaseRef,
eligibilityDefaultBaseRef: eligibility?.defaultBaseRef
@@ -243,6 +245,7 @@ export function useCreatePullRequestDialogFields({
setBody: setUserBody,
draft,
setDraft: setUserDraft,
fieldsAreSeedPlaceholders: title === seedText.title && body === seedText.body,
stackedCreationSupported: eligibility?.stackedCreationSupported === true,
repoDefaultBaseRef,
fieldRevisions: fieldRevisionsRef.current,
@@ -0,0 +1,36 @@
import type {
PullRequestGenerationFields,
PullRequestGenerationRecord
} from './pull-request-generation'
// Why: an auto-submitted run (Create PR) is sent without the user reviewing it in the form.
export type PullRequestGenerationOptions = { autoSubmit?: boolean }
type ResolvableRecord = Pick<
PullRequestGenerationRecord,
'autoSubmit' | 'seed' | 'seedFieldRevisions'
>
/**
* Draft is a decision, not prose: the agent may flag unfinished work, but it never reverts a box the
* user set — a silently unchecked Draft opens a real review ready for everyone, which no edit undoes.
*/
export function resolveGeneratedDraft(
{ seed, seedFieldRevisions }: Omit<ResolvableRecord, 'autoSubmit'>,
result: PullRequestGenerationFields
): boolean {
return seed.draft || (seedFieldRevisions.draft === 0 && result.draft)
}
/**
* What a finished run is allowed to change. Every run honours the Draft choice; only a run sent
* unreviewed also keeps the user's base, as the prepare-branch route does — a Generate-button base
* lands in the form, where the user still sees it and can change it before creating.
*/
export function resolveGeneratedFields(
record: ResolvableRecord,
result: PullRequestGenerationFields
): PullRequestGenerationFields {
const draft = resolveGeneratedDraft(record, result)
return record.autoSubmit ? { ...result, base: record.seed.base, draft } : { ...result, draft }
}
@@ -1,6 +1,7 @@
import type { StateCreator } from 'zustand'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { AppState } from '../types'
import { resolveGeneratedFields } from './pull-request-generation-auto-submit'
export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
export type PullRequestFieldRevisions = Record<PullRequestFieldName, number>
@@ -38,6 +39,7 @@ export type PullRequestGenerationRecord = {
result: PullRequestGenerationFields | null
error: string | null
hydrated: boolean
autoSubmit?: boolean
}
export type PullRequestGenerationRecords = Record<string, PullRequestGenerationRecord>
@@ -135,7 +137,8 @@ export function getPullRequestGenerationSeedRestoreKey({
export function createRunningPullRequestGenerationRecord(
context: PullRequestGenerationContext,
seed: PullRequestGenerationFields,
seedFieldRevisions: PullRequestFieldRevisions
seedFieldRevisions: PullRequestFieldRevisions,
autoSubmit = false
): PullRequestGenerationRecord {
return {
context,
@@ -145,7 +148,8 @@ export function createRunningPullRequestGenerationRecord(
status: 'running',
result: null,
error: null,
hydrated: false
hydrated: false,
autoSubmit
}
}
@@ -164,7 +168,7 @@ export function resolvePullRequestGenerationSuccess({
return {
...record,
status: 'succeeded',
result,
result: resolveGeneratedFields(record, result),
error: null,
hydrated: false
}