mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
co-authored by
Neil
Brennan Benson
parent
88e9517eb1
commit
bbafca4481
+102
@@ -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({
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+303
-57
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+101
-179
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
+211
-2
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+68
-56
@@ -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,
|
||||
|
||||
+10
-3
@@ -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 }
|
||||
}
|
||||
+15
@@ -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(
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+623
@@ -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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
+92
-31
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+203
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
+67
-60
@@ -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
|
||||
})
|
||||
|
||||
|
||||
+21
-11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user