feat(github): create stacked pull requests (#13750)

Adds GitHub stacked pull request creation: a contextual "Stack this PR above #N" option that appears only when the selected base branch has an open PR, plus the main-process stack preflight and registration.

Also reworks the create-review composer for cohesion: shadcn Checkbox and Label primitives, base label above a full-width searchable combobox with attached results, keyboard navigation, and a unified field skin, spacing and typography scale.

Verified end to end against real GitHub: extending an existing stack and creating a new one.
This commit is contained in:
Jinwoo Hong
2026-08-11 15:48:57 -07:00
committed by GitHub
parent 63271a5933
commit 077f5a11cd
34 changed files with 2876 additions and 538 deletions
@@ -0,0 +1,52 @@
import type { HostedReviewSummary } from '../../shared/hosted-review'
export type NumberedHostedReviewSummary = Omit<HostedReviewSummary, 'number'> & { number: number }
export type GitHubStackPullRequest = NumberedHostedReviewSummary & {
headRefName: string
baseRefName: string
}
export type GitHubStack = {
number: number
open: boolean
pull_requests: { number: number }[]
}
export function parseGitHubStackPullRequests(stdout: string): GitHubStackPullRequest[] {
const pullRequests = JSON.parse(stdout) as {
number?: unknown
html_url?: unknown
head?: { ref?: unknown }
base?: { ref?: unknown }
}[]
return pullRequests.flatMap((pullRequest) => {
const number = Number(pullRequest.number)
const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : ''
const headRefName = typeof pullRequest.head?.ref === 'string' ? pullRequest.head.ref : ''
const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : ''
return Number.isInteger(number) && number > 0 && url && headRefName && baseRefName
? [{ number, url, headRefName, baseRefName }]
: []
})
}
export function parseGitHubStacks(stdout: string): GitHubStack[] {
const stacks = JSON.parse(stdout) as {
number?: unknown
open?: unknown
pull_requests?: { number?: unknown }[]
}[]
return stacks.flatMap((stack) => {
const number = Number(stack.number)
const pullRequests = (stack.pull_requests ?? []).flatMap((pullRequest) => {
const pullRequestNumber = Number(pullRequest.number)
return Number.isInteger(pullRequestNumber) && pullRequestNumber > 0
? [{ number: pullRequestNumber }]
: []
})
return Number.isInteger(number) && number > 0
? [{ number, open: stack.open === true, pull_requests: pullRequests }]
: []
})
}
+273
View File
@@ -0,0 +1,273 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
repositoryMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) =>
context.connectionId ? {} : { cwd: context.repoPath },
githubRepoContext: (
repoPath: string,
connectionId?: string | null,
localGitOptions?: Record<string, unknown>
) => ({ repoPath, connectionId, localGitOptions })
}))
vi.mock('./github-api-repository', () => ({
getOriginGitHubApiRepository: repositoryMock,
githubHostExecOptions: (repository: { host?: string }) => ({ host: repository.host })
}))
import {
prepareGitHubStackedPullRequest,
registerGitHubStackedPullRequest
} from './stacked-pr-creation'
const repository = { owner: 'acme', repo: 'orca', host: 'github.com' }
const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' }
const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' }
function pullRequest(number: number, head: string, base: string) {
return {
number,
html_url: `https://github.com/acme/orca/pull/${number}`,
head: { ref: head },
base: { ref: base }
}
}
function stack(number: number, pullRequests: number[]) {
return {
number,
open: true,
pull_requests: pullRequests.map((pullRequestNumber) => ({ number: pullRequestNumber }))
}
}
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
repositoryMock.mockReset()
repositoryMock.mockResolvedValue(repository)
})
describe('prepareGitHubStackedPullRequest', () => {
it('resolves an open parent PR and an existing current PR', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')])
})
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) })
.mockResolvedValueOnce({ stdout: '[]' })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'origin/stack/parent',
head: 'refs/heads/stack/child',
title: 'Child'
})
expect(result).toMatchObject({
ok: true,
parentReview: { number: 41 },
currentReview: { number: 42 }
})
expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'repos/acme/orca/pulls?head=acme%3Astack%2Fparent&state=open&per_page=2'
])
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'repos/acme/orca/pulls?head=acme%3Astack%2Fchild&base=stack%2Fparent&state=open&per_page=2'
])
})
it('allows an idempotent retry after the child was already registered', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')])
})
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: true, currentReview: { number: 42 } })
})
it('requires an open PR for the selected parent branch', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'feature/parent',
head: 'feature/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
if (!result.ok) {
expect(result.error).toContain('does not have an open pull request')
}
})
it('rejects a parent that is not the top of its stack', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 45])]) })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
if (!result.ok) {
expect(result.error).toContain('top pull request')
}
})
it('does not offer stacks on GitHub Enterprise Server', async () => {
repositoryMock.mockResolvedValue({
owner: 'acme',
repo: 'orca',
host: 'github.acme.test'
})
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
})
describe('registerGitHubStackedPullRequest', () => {
it('creates a new stack with the parent and current PR', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({ ok: true, number: 42, stackNumber: 50 })
expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([
'api',
'-X',
'POST',
'repos/acme/orca/stacks',
'-F',
'pull_requests[]=41',
'-F',
'pull_requests[]=42'
])
})
it('appends the current PR when the parent is the existing top', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview,
connectionId: 'ssh-1'
})
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([
'api',
'-X',
'POST',
'repos/acme/orca/stacks/50/add',
'-F',
'pull_requests[]=42'
])
expect(ghExecFileAsyncMock.mock.calls[2][1]).not.toHaveProperty('cwd')
})
it('treats an already registered parent-child pair as success', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not claim registration when the stack no longer holds the parent', async () => {
// A concurrent stack edit can drop the parent while the child sits at index 0.
// Reading index 0 off a findIndex miss would report that pair as registered.
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
error: 'The pull request already belongs to a different GitHub stack.',
createdReview: currentReview
})
})
it('preserves the created PR when registration fails', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockRejectedValueOnce(new Error('HTTP 422'))
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
createdReview: currentReview
})
})
})
+310
View File
@@ -0,0 +1,310 @@
import type {
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult
} from '../../shared/hosted-review'
import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils'
import {
getOriginGitHubApiRepository,
githubHostExecOptions,
type GitHubApiRepository
} from './github-api-repository'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import {
parseGitHubStackPullRequests,
parseGitHubStacks,
type GitHubStack,
type GitHubStackPullRequest,
type NumberedHostedReviewSummary
} from './github-stack-api-responses'
type StackedPullRequestPlan =
| {
ok: true
repository: GitHubApiRepository
parentReview: GitHubStackPullRequest
currentReview: GitHubStackPullRequest | null
}
| Extract<CreateStackedHostedReviewResult, { ok: false }>
function creationError(error: string): Extract<CreateStackedHostedReviewResult, { ok: false }> {
return { ok: false, code: 'validation', error }
}
function isStacksUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return message.includes('http 404') || message.includes('feature not supported')
}
function ghOptions(
repoPath: string,
repository: GitHubApiRepository,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
) {
return {
...ghRepoExecOptions(
githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
),
...githubHostExecOptions(repository),
timeout: 60_000
}
}
async function findOpenPullRequestsForBranch(
repoPath: string,
repository: GitHubApiRepository,
branch: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {},
base?: string
): Promise<GitHubStackPullRequest[]> {
const head = encodeURIComponent(`${repository.owner}:${branch}`)
const baseQuery = base ? `&base=${encodeURIComponent(base)}` : ''
const endpoint = `repos/${repository.owner}/${repository.repo}/pulls?head=${head}${baseQuery}&state=open&per_page=2`
const { stdout } = await ghExecFileAsync(
['api', endpoint],
ghOptions(repoPath, repository, connectionId, options)
)
return parseGitHubStackPullRequests(stdout)
}
async function getStacksForPullRequest(
repoPath: string,
repository: GitHubApiRepository,
pullRequestNumber: number,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitHubStack[]> {
const endpoint = `repos/${repository.owner}/${repository.repo}/stacks?pull_request=${pullRequestNumber}`
const { stdout } = await ghExecFileAsync(
['api', endpoint],
ghOptions(repoPath, repository, connectionId, options)
)
return parseGitHubStacks(stdout)
}
function validateParentStack(
parentReview: NumberedHostedReviewSummary,
stacks: GitHubStack[]
): Extract<CreateStackedHostedReviewResult, { ok: false }> | null {
if (stacks.length > 1) {
return creationError('The selected parent pull request belongs to multiple stacks.')
}
const stack = stacks[0]
if (!stack) {
return null
}
if (!stack.open) {
return creationError('The selected parent belongs to a closed stack.')
}
if (stack.pull_requests.at(-1)?.number !== parentReview.number) {
return creationError(
'Choose the top pull request in the stack as the base branch before adding another layer.'
)
}
return null
}
export async function prepareGitHubStackedPullRequest(
repoPath: string,
input: CreateStackedHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<StackedPullRequestPlan> {
if (input.provider !== 'github') {
return creationError('Stacked pull request creation is available only for GitHub repositories.')
}
const repository = await getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
if (!repository || !isDefaultGitHubHost(repository.host)) {
return creationError('GitHub stacked pull requests are available only on GitHub.com.')
}
const base = normalizeHostedReviewBaseRef(input.base).trim()
const head = input.head ? normalizeHostedReviewHeadRef(input.head).trim() : ''
if (!base || !head || base.toLowerCase() === head.toLowerCase()) {
return creationError('Choose a different parent branch before creating a stacked pull request.')
}
await acquire()
try {
const [parentPullRequests, currentPullRequests] = await Promise.all([
findOpenPullRequestsForBranch(repoPath, repository, base, connectionId, options),
findOpenPullRequestsForBranch(repoPath, repository, head, connectionId, options, base)
])
if (parentPullRequests.length === 0) {
return creationError(
`The parent branch ${base} does not have an open pull request. Create that pull request first.`
)
}
if (parentPullRequests.length !== 1) {
return creationError(`Orca found multiple open pull requests for the parent branch ${base}.`)
}
if (currentPullRequests.length > 1) {
return creationError(`Orca found multiple open pull requests for the current branch ${head}.`)
}
const parentReview = parentPullRequests[0]
const currentReview = currentPullRequests[0] ?? null
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(repoPath, repository, parentReview.number, connectionId, options),
currentReview
? getStacksForPullRequest(repoPath, repository, currentReview.number, connectionId, options)
: Promise.resolve([])
])
if (
currentReview &&
registeredStackNumber(parentReview, currentReview, parentStacks, currentStacks)
) {
return { ok: true, repository, parentReview, currentReview }
}
if (currentStacks.length > 0) {
return creationError('The pull request already belongs to a different GitHub stack.')
}
const parentError = validateParentStack(parentReview, parentStacks)
return (
parentError ?? {
ok: true,
repository,
parentReview,
currentReview
}
)
} catch (error) {
console.warn('GitHub stack creation preflight failed:', error)
return {
ok: false,
code: isStacksUnavailableError(error) ? 'validation' : 'unknown',
error: isStacksUnavailableError(error)
? 'GitHub stacked pull requests are not available for this repository.'
: 'Orca could not verify the parent pull request. Retry in a moment.'
}
} finally {
release()
}
}
function registeredStackNumber(
parentReview: NumberedHostedReviewSummary,
currentReview: NumberedHostedReviewSummary,
parentStacks: GitHubStack[],
currentStacks: GitHubStack[]
): number | null {
const parentStack = parentStacks[0]
const currentStack = currentStacks[0]
if (!parentStack || !currentStack || parentStack.number !== currentStack.number) {
return null
}
const parentPosition = parentStack.pull_requests.findIndex(
(pullRequest) => pullRequest.number === parentReview.number
)
// Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already
// registered" whenever the current PR heads a stack the parent has left.
if (parentPosition < 0) {
return null
}
return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number
? parentStack.number
: null
}
export async function registerGitHubStackedPullRequest(args: {
repoPath: string
repository: GitHubApiRepository
parentReview: NumberedHostedReviewSummary
currentReview: NumberedHostedReviewSummary
connectionId?: string | null
options?: HostedReviewExecutionOptions
}): Promise<CreateStackedHostedReviewResult> {
const options = args.options ?? {}
await acquire()
try {
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(
args.repoPath,
args.repository,
args.parentReview.number,
args.connectionId,
options
),
getStacksForPullRequest(
args.repoPath,
args.repository,
args.currentReview.number,
args.connectionId,
options
)
])
const existingStackNumber = registeredStackNumber(
args.parentReview,
args.currentReview,
parentStacks,
currentStacks
)
if (existingStackNumber) {
return {
ok: true,
...args.currentReview,
stackNumber: existingStackNumber,
parentReview: args.parentReview
}
}
if (currentStacks.length > 0) {
return {
...creationError('The pull request already belongs to a different GitHub stack.'),
createdReview: args.currentReview
}
}
const parentError = validateParentStack(args.parentReview, parentStacks)
if (parentError) {
return { ...parentError, createdReview: args.currentReview }
}
const parentStack = parentStacks[0]
const endpoint = parentStack
? `repos/${args.repository.owner}/${args.repository.repo}/stacks/${parentStack.number}/add`
: `repos/${args.repository.owner}/${args.repository.repo}/stacks`
const pullRequests = parentStack
? [args.currentReview.number]
: [args.parentReview.number, args.currentReview.number]
const command = ['api', '-X', 'POST', endpoint]
for (const pullRequest of pullRequests) {
command.push('-F', `pull_requests[]=${pullRequest}`)
}
const { stdout } = await ghExecFileAsync(command, {
...ghOptions(args.repoPath, args.repository, args.connectionId, options),
idempotent: false
})
const stackNumber = Number((JSON.parse(stdout) as { number?: unknown }).number)
if (!Number.isInteger(stackNumber) || stackNumber <= 0) {
throw new Error('GitHub returned an invalid stack response.')
}
return {
ok: true,
...args.currentReview,
stackNumber,
parentReview: args.parentReview
}
} catch (error) {
console.warn('GitHub stack registration failed:', error)
return {
ok: false,
code: isStacksUnavailableError(error) ? 'validation' : 'unknown',
error: isStacksUnavailableError(error)
? 'The pull request was created, but GitHub stacks are not available for this repository.'
: 'The pull request was created, but GitHub could not add it to the stack. Retry to finish stack registration.',
createdReview: args.currentReview
}
} finally {
release()
}
}
+36
View File
@@ -13,6 +13,7 @@ function setPlatform(platform: NodeJS.Platform): void {
const {
handleMock,
createHostedReviewMock,
createStackedHostedReviewMock,
getHostedReviewCreationEligibilityMock,
getHostedReviewForBranchMock,
resolveRegisteredWorktreePathMock,
@@ -20,6 +21,7 @@ const {
} = vi.hoisted(() => ({
handleMock: vi.fn(),
createHostedReviewMock: vi.fn(),
createStackedHostedReviewMock: vi.fn(),
getHostedReviewCreationEligibilityMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
resolveRegisteredWorktreePathMock: vi.fn(),
@@ -37,6 +39,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({
getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock
}))
vi.mock('../source-control/stacked-hosted-review-creation', () => ({
createStackedHostedReview: createStackedHostedReviewMock
}))
vi.mock('../source-control/hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
@@ -87,6 +93,7 @@ describe('registerHostedReviewHandlers', () => {
setPlatform(ORIGINAL_PLATFORM)
handleMock.mockReset()
createHostedReviewMock.mockReset()
createStackedHostedReviewMock.mockReset()
getHostedReviewCreationEligibilityMock.mockReset()
getHostedReviewForBranchMock.mockReset()
resolveRegisteredWorktreePathMock.mockReset()
@@ -384,6 +391,35 @@ describe('registerHostedReviewHandlers', () => {
)
})
it('routes stacked creation through its dedicated SSH-safe handler', async () => {
createStackedHostedReviewMock.mockResolvedValueOnce({
ok: true,
number: 43,
url: 'https://github.com/acme/orca/pull/43',
stackNumber: 50,
parentReview: { number: 42, url: 'https://github.com/acme/orca/pull/42' }
})
registerHostedReviewHandlers(store as never, stats as never)
await handlers['hostedReview:createStacked'](null, {
repoPath,
repoId: repo.id,
worktreePath,
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(createStackedHostedReviewMock).toHaveBeenCalledWith(
worktreePath,
expect.objectContaining({ base: 'stack/parent', head: 'stack/child' }),
'ssh-1',
{}
)
expect(createHostedReviewMock).not.toHaveBeenCalled()
})
it('rejects creation when repoId and repoPath point at different registered repos', async () => {
store.getRepo.mockImplementation((repoId: string) =>
repoId === repo.id ? { ...repo, path: '/other/repo' } : null
+42
View File
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { posix, resolve } from 'node:path'
import type {
CreateHostedReviewArgs,
CreateStackedHostedReviewArgs,
HostedReviewCreationEligibilityArgs,
HostedReviewForBranchArgs
} from '../../shared/hosted-review'
@@ -12,6 +13,7 @@ import {
createHostedReview,
getHostedReviewCreationEligibility
} from '../source-control/hosted-review-creation'
import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import { resolveRegisteredWorktreePath } from './filesystem-auth'
import { listRepoWorktrees } from '../repo-worktrees'
@@ -160,4 +162,44 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
}
return result
})
ipcMain.handle(
'hostedReview:createStacked',
async (_event, args: CreateStackedHostedReviewArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store, args.repoId)
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
const sharedLinkPaths = repo.connectionId ? [] : getWorktreeSharedLinkPaths(repo)
const executionOptions = {
...(Object.keys(localGitOptions).length > 0
? { localGitExecOptions: localGitOptions }
: {}),
...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {})
}
const input = {
provider: args.provider,
base: args.base,
head: args.head,
title: args.title,
body: args.body,
draft: args.draft,
...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {})
}
const result = await createStackedHostedReview(
worktreePath,
input,
repo.connectionId ?? null,
executionOptions
)
if (result.ok && !stats.hasCountedPR(result.url)) {
stats.record({
type: 'pr_created',
at: Date.now(),
repoId: repo.id,
meta: { prNumber: result.number, prUrl: result.url }
})
}
return result
}
)
}
+52
View File
@@ -234,6 +234,7 @@ const {
invalidateAuthorizedRootsCacheMock,
prepareLocalWorktreeRootForRepoMock,
createHostedReviewMock,
createStackedHostedReviewMock,
getHostedReviewCreationEligibilityMock,
getHostedReviewForBranchMock,
getPRForBranchMock,
@@ -341,6 +342,7 @@ const {
invalidateAuthorizedRootsCacheMock: vi.fn(),
prepareLocalWorktreeRootForRepoMock: vi.fn(),
createHostedReviewMock: vi.fn(),
createStackedHostedReviewMock: vi.fn(),
getHostedReviewCreationEligibilityMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
getPRForBranchMock: vi.fn().mockResolvedValue(null),
@@ -513,6 +515,10 @@ vi.mock('../source-control/hosted-review-creation', () => ({
getHostedReviewCreationEligibility: getHostedReviewCreationEligibilityMock
}))
vi.mock('../source-control/stacked-hosted-review-creation', () => ({
createStackedHostedReview: createStackedHostedReviewMock
}))
vi.mock('../source-control/hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
@@ -732,6 +738,14 @@ function resetRuntimeTestMocks(): void {
number: 1,
url: 'https://example.com/pull/1'
})
createStackedHostedReviewMock.mockReset()
createStackedHostedReviewMock.mockResolvedValue({
ok: true,
number: 2,
url: 'https://example.com/pull/2',
stackNumber: 10,
parentReview: { number: 1, url: 'https://example.com/pull/1' }
})
getHostedReviewCreationEligibilityMock.mockReset()
getHostedReviewCreationEligibilityMock.mockResolvedValue({
provider: 'github',
@@ -7005,6 +7019,15 @@ describe('OrcaRuntimeService', () => {
body: '',
draft: false
})
await runtime.createStackedHostedReview({
repoSelector: `id:${TEST_REPO_ID}`,
provider: 'github',
base: 'stack/parent',
head: 'feature/ssh',
title: 'Feature SSH',
body: '',
draft: false
})
expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -7022,6 +7045,16 @@ describe('OrcaRuntimeService', () => {
}),
'ssh-1'
)
expect(createStackedHostedReviewMock).toHaveBeenCalledWith(
'/remote/repo',
expect.objectContaining({
provider: 'github',
base: 'stack/parent',
head: 'feature/ssh'
}),
'ssh-1',
{}
)
})
it('routes local WSL project hosted review flows through runtime git options', async () => {
@@ -7084,6 +7117,15 @@ describe('OrcaRuntimeService', () => {
body: '',
draft: false
})
await runtime.createStackedHostedReview({
repoSelector: `id:${TEST_REPO_ID}`,
provider: 'github',
base: 'stack/parent',
head: 'feature/wsl',
title: 'Feature WSL',
body: '',
draft: false
})
expect(getHostedReviewCreationEligibilityMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -7112,6 +7154,16 @@ describe('OrcaRuntimeService', () => {
null,
{ localGitExecOptions: { wslDistro: 'Ubuntu' } }
)
expect(createStackedHostedReviewMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
expect.objectContaining({
provider: 'github',
base: 'stack/parent',
head: 'feature/wsl'
}),
null,
{ localGitExecOptions: { wslDistro: 'Ubuntu' } }
)
})
it('treats SSH worktree drift as unknown without local git probes', async () => {
+33
View File
@@ -676,6 +676,8 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
@@ -685,6 +687,7 @@ import {
createHostedReview as createHostedReviewFromRepo,
getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo
} from '../source-control/hosted-review-creation'
import { createStackedHostedReview as createStackedHostedReviewFromRepo } from '../source-control/stacked-hosted-review-creation'
import {
getLocalProjectGitExecOptions,
getLocalProjectWorktreeGitOptions,
@@ -19688,6 +19691,36 @@ export class OrcaRuntimeService {
return result
}
async createStackedHostedReview(
args: CreateStackedHostedReviewInput & { repoSelector: string; worktreeSelector?: string }
): Promise<CreateStackedHostedReviewResult> {
const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
const executionOptions = this.getHostedReviewExecutionOptions(repo)
const result = await createStackedHostedReviewFromRepo(
repoPath,
{
provider: args.provider,
base: args.base,
head: args.head,
title: args.title,
body: args.body,
draft: args.draft,
...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {})
},
repo.connectionId ?? null,
executionOptions ?? {}
)
if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) {
this.stats.record({
type: 'pr_created',
at: Date.now(),
repoId: repo.id,
meta: { prNumber: result.number, prUrl: result.url }
})
}
return result
}
async listGitLabRepoWorkItems(
repoSelector: string,
state?: MRListState,
@@ -162,4 +162,39 @@ describe('hosted review RPC methods', () => {
result: { ok: true, number: 51 }
})
})
it('dispatches stacked creation through a distinct runtime method', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createStackedHostedReview: vi.fn().mockResolvedValue({
ok: true,
number: 52,
url: 'https://github.com/acme/orca/pull/52',
stackNumber: 60,
parentReview: { number: 51, url: 'https://github.com/acme/orca/pull/51' }
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: HOSTED_REVIEW_METHODS })
const response = await dispatcher.dispatch(
makeRequest('hostedReview.createStacked', {
repo: 'repo-1',
worktree: 'path:/worktrees/child',
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
)
expect(runtime.createStackedHostedReview).toHaveBeenCalledWith({
repoSelector: 'repo-1',
worktreeSelector: 'path:/worktrees/child',
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(response).toMatchObject({ ok: true, result: { ok: true, stackNumber: 60 } })
})
})
@@ -105,5 +105,21 @@ export const HOSTED_REVIEW_METHODS: RpcMethod[] = [
draft: params.draft,
useTemplate: params.useTemplate
})
}),
defineMethod({
name: 'hostedReview.createStacked',
params: HostedReviewCreate,
handler: async (params, { runtime }) =>
runtime.createStackedHostedReview({
repoSelector: params.repo,
worktreeSelector: params.worktree,
provider: params.provider,
base: params.base,
head: params.head,
title: params.title,
body: params.body,
draft: params.draft,
useTemplate: params.useTemplate
})
})
]
+1
View File
@@ -316,6 +316,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'host.wsl.isAvailable',
'host.wsl.listDistros',
'hostedReview.create',
'hostedReview.createStacked',
'hostedReview.forBranch',
'hostedReview.getCreationEligibility',
'linear.getCustomView',
@@ -472,29 +472,31 @@ describe('getHostedReviewCreationEligibility', () => {
blockedReason: null,
nextAction: null,
defaultBaseRef: 'origin/main',
head: 'feature/create-pr'
head: 'feature/create-pr',
stackedCreationSupported: true
})
})
it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => {
mockGitHubEnterpriseProvider()
await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
const result = await getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
expect(result).toMatchObject({
provider: 'github',
canCreate: true,
blockedReason: null,
nextAction: null
})
expect(result).not.toHaveProperty('stackedCreationSupported')
// Enterprise auth was already confirmed during detection; the gate must not
// fire a redundant gh probe.
@@ -20,6 +20,8 @@ import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-r
import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation'
import { isBitbucketReviewCreationAuthenticated } from '../bitbucket/pull-request-creation'
import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository'
import { getRepoSlug } from '../github/client'
import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/types'
@@ -535,12 +537,19 @@ export async function getHostedReviewCreationEligibility(
: lookupFailed
? 'unavailable'
: 'not_found'
const githubRepository =
provider === 'github'
? await getRepoSlug(args.repoPath, args.connectionId, args).catch(() => null)
: null
const baseResult = {
provider,
review: review ? { number: review.number, url: review.url } : null,
reviewLookupOutcome,
defaultBaseRef,
head: branch || null
head: branch || null,
...(githubRepository && isDefaultGitHubHost(githubRepository.host)
? { stackedCreationSupported: true }
: {})
}
if (!branch || branch === 'HEAD') {
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { prepareMock, registerMock, createMock } = vi.hoisted(() => ({
prepareMock: vi.fn(),
registerMock: vi.fn(),
createMock: vi.fn()
}))
vi.mock('../github/stacked-pr-creation', () => ({
prepareGitHubStackedPullRequest: prepareMock,
registerGitHubStackedPullRequest: registerMock
}))
vi.mock('./hosted-review-creation', () => ({ createHostedReview: createMock }))
import { createStackedHostedReview } from './stacked-hosted-review-creation'
const input = {
provider: 'github' as const,
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
}
const repository = { owner: 'acme', repo: 'orca', host: 'github.com' }
const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' }
const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' }
beforeEach(() => {
prepareMock.mockReset()
registerMock.mockReset()
createMock.mockReset()
})
describe('createStackedHostedReview', () => {
it('creates the current PR before registering the stack', async () => {
prepareMock.mockResolvedValue({
ok: true,
repository,
parentReview,
currentReview: null
})
createMock.mockResolvedValue({ ok: true, ...currentReview })
registerMock.mockResolvedValue({
ok: true,
...currentReview,
parentReview,
stackNumber: 50
})
const result = await createStackedHostedReview('/repo', input, 'ssh-1')
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(createMock).toHaveBeenCalledWith('/repo', input, 'ssh-1', {})
expect(registerMock).toHaveBeenCalledWith(
expect.objectContaining({ parentReview, currentReview, connectionId: 'ssh-1' })
)
})
it('retries registration without creating a duplicate PR', async () => {
prepareMock.mockResolvedValue({
ok: true,
repository,
parentReview,
currentReview
})
registerMock.mockResolvedValue({
ok: true,
...currentReview,
parentReview,
stackNumber: 50
})
await createStackedHostedReview('/repo', input)
expect(createMock).not.toHaveBeenCalled()
expect(registerMock).toHaveBeenCalledOnce()
})
it('does not create a PR when the parent topology is invalid', async () => {
prepareMock.mockResolvedValue({
ok: false,
code: 'validation',
error: 'Choose the top pull request.'
})
const result = await createStackedHostedReview('/repo', input)
expect(result).toMatchObject({ ok: false, code: 'validation' })
expect(createMock).not.toHaveBeenCalled()
expect(registerMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,61 @@
import type {
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult,
HostedReviewSummary
} from '../../shared/hosted-review'
import {
prepareGitHubStackedPullRequest,
registerGitHubStackedPullRequest
} from '../github/stacked-pr-creation'
import { createHostedReview } from './hosted-review-creation'
import type { HostedReviewExecutionOptions } from './hosted-review-git-options'
export async function createStackedHostedReview(
repoPath: string,
input: CreateStackedHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<CreateStackedHostedReviewResult> {
const plan = await prepareGitHubStackedPullRequest(repoPath, input, connectionId, options)
if (!plan.ok) {
return plan
}
let currentReview: (HostedReviewSummary & { number: number }) | null = plan.currentReview
if (!currentReview) {
const created = await createHostedReview(repoPath, input, connectionId, options)
if (!created.ok) {
if (!created.existingReview?.number) {
return created
}
return {
ok: false,
code: 'validation',
error:
'An open pull request already exists for this branch but does not target the selected parent branch.',
createdReview: {
number: created.existingReview.number,
url: created.existingReview.url
}
}
} else {
currentReview = { number: created.number, url: created.url }
}
}
if (!currentReview) {
return {
ok: false,
code: 'unknown_completion',
error: 'Pull request creation may have completed. Retry to finish stack registration.'
}
}
return registerGitHubStackedPullRequest({
repoPath,
repository: plan.repository,
parentReview: plan.parentReview,
currentReview,
connectionId,
options
})
}
+3
View File
@@ -2,6 +2,8 @@
import type {
CreateHostedReviewArgs,
CreateHostedReviewResult,
CreateStackedHostedReviewArgs,
CreateStackedHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewForBranchArgs,
@@ -1986,6 +1988,7 @@ export type PreloadApi = {
args: HostedReviewCreationEligibilityArgs
) => Promise<HostedReviewCreationEligibility>
create: (args: CreateHostedReviewArgs) => Promise<CreateHostedReviewResult>
createStacked: (args: CreateStackedHostedReviewArgs) => Promise<CreateStackedHostedReviewResult>
}
// ── GitLab — parallel to gh, MR/issue surface only in v1 ────────
// Shapes mirror gh.* except where GitLab's API differs (MR states, host-qualified project path, `glab api -i` paging).
+3 -1
View File
@@ -1715,7 +1715,9 @@ const api = {
ipcRenderer.invoke('hostedReview:forBranch', args),
getCreationEligibility: (args: unknown): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:getCreationEligibility', args),
create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('hostedReview:create', args)
create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('hostedReview:create', args),
createStacked: (args: unknown): Promise<unknown> =>
ipcRenderer.invoke('hostedReview:createStacked', args)
},
// Why: GitLab bindings live in `./gitlab` so `gl.*` changes don't conflict on every upstream sync of this central file.
@@ -189,6 +189,7 @@ import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import { useHostedReviewStackParent } from './useHostedReviewStackParent'
import { resolveCreatedHostedReviewLink } from './source-control-created-review-link'
import { formatCreateError } from './create-pull-request-review-copy'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
@@ -476,6 +477,7 @@ export default function ChecksPanel(): React.JSX.Element {
(s) => s.getHostedReviewCreationEligibility
)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const createStackedHostedReview = useAppStore((s) => s.createStackedHostedReview)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
const conflictOperation = useAppStore((s) =>
activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown'
@@ -1320,10 +1322,13 @@ export default function ChecksPanel(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
stackedCreationSupported: prStackedCreationSupported,
repoDefaultBaseRef: prRepoDefaultBaseRef,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
setBaseResults: setPrBaseResults,
baseSearchPending: prBaseSearchPending,
baseSearchError: prBaseSearchError,
generating: prGenerating,
generateError: prGenerateError,
@@ -1360,6 +1365,17 @@ export default function ChecksPanel(): React.JSX.Element {
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
const stackParentReview = useHostedReviewStackParent({
enabled: hostedReviewCreateProvider === 'github' && prStackedCreationSupported,
repoPath: repo?.path ?? '',
repoId: repo?.id ?? null,
base: prBase,
// Why: the repo default, not eligibility's defaultBaseRef — that one resolves to
// the worktree's own base, which is exactly the branch a stacked PR targets.
repoDefaultBase: prRepoDefaultBaseRef,
head: branch,
fetchHostedReviewForBranch
})
useEffect(() => {
// Why: PR generation can finish while this composer is hidden by a worktree switch; hydrate once the original composer is visible again.
if (
@@ -3970,121 +3986,86 @@ export default function ChecksPanel(): React.JSX.Element {
]
)
const handleCreatePullRequest = useCallback(async (): Promise<void> => {
if (!repo || !branch || !createComposerOpen || prGenerating || createPrInFlightRef.current) {
return
}
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
if (!repo || !branch || !createComposerOpen || prGenerating || createPrInFlightRef.current) {
return
}
const requestContextKey = panelContextKey
const isCurrentCreateRequest = (): boolean =>
panelContextKeyRef.current === requestContextKey &&
createPrInFlightRef.current === requestContextKey
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
const worktreePath = activeWorktreePath ?? repo.path
if (!title) {
setCreatePrError(
translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{
value0: hostedReviewCreateCopy.reviewLabel
const requestContextKey = panelContextKey
const isCurrentCreateRequest = (): boolean =>
panelContextKeyRef.current === requestContextKey &&
createPrInFlightRef.current === requestContextKey
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
const worktreePath = activeWorktreePath ?? repo.path
if (!title) {
setCreatePrError(
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 }
)
)
return
}
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
}
)
)
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 }
)
)
return
}
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 (!ok) {
setCreatePrError('Push failed. Resolve the push error, then try again.')
return
}
pushed = true
}
const createInput = {
repoId: repo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branch),
title,
body: prBody,
draft: prDraft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider),
worktreePath,
useTemplate: prCreationDefaults.useTemplate
}
const result = stacked
? await createStackedHostedReview(repo.path, createInput)
: await createHostedReview(repo.path, createInput)
if (!isCurrentCreateRequest()) {
return
}
if (!ok) {
setCreatePrError('Push failed. Resolve the push error, then try again.')
return
}
pushed = true
}
const result = await createHostedReview(repo.path, {
repoId: repo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branch),
title,
body: prBody,
draft: prDraft && hostedReviewProviderSupportsDraft(hostedReviewCreateProvider),
worktreePath,
useTemplate: prCreationDefaults.useTemplate
})
if (!isCurrentCreateRequest()) {
return
}
if (result.ok) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
})
if (prCreationDefaults.openAfterCreate) {
openHttpLink(result.url, { worktreeId: activeWorktreeId })
}
if (activePullRequestGenerationKey) {
updatePullRequestGenerationRecord(
activePullRequestGenerationKey,
clearPullRequestGenerationRequiresPushBeforeCreate
)
}
return
}
if (result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
? translate(
'auto.components.right.sidebar.ChecksPanel.b6ce28da5b',
'{{value0}} #{{value1}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
)
: translate(
'auto.components.right.sidebar.ChecksPanel.cf9e69f3be',
'{{value0}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.ChecksPanel.192e686e57',
'Open on {{value0}}',
{ value0: hostedReviewCreateCopy.providerName }
),
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
}
}
)
if (number) {
if (result.ok) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
number: result.number,
url: result.url
})
if (prCreationDefaults.openAfterCreate) {
openHttpLink(result.url, { worktreeId: activeWorktreeId })
}
if (activePullRequestGenerationKey) {
updatePullRequestGenerationRecord(
activePullRequestGenerationKey,
@@ -4093,55 +4074,110 @@ export default function ChecksPanel(): React.JSX.Element {
}
return
}
if ('existingReview' in result && result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
? translate(
'auto.components.right.sidebar.ChecksPanel.b6ce28da5b',
'{{value0}} #{{value1}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
)
: translate(
'auto.components.right.sidebar.ChecksPanel.cf9e69f3be',
'{{value0}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.ChecksPanel.192e686e57',
'Open on {{value0}}',
{ value0: hostedReviewCreateCopy.providerName }
),
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
}
}
)
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
})
if (activePullRequestGenerationKey) {
updatePullRequestGenerationRecord(
activePullRequestGenerationKey,
clearPullRequestGenerationRequiresPushBeforeCreate
)
}
return
}
}
// Why: stacked creation can create the pull request and still fail to register
// the stack. Link the review that exists before surfacing the stack failure, or
// the workspace stays unaware of a PR the user can already see on GitHub.
if ('createdReview' in result && result.createdReview?.url) {
const { number, url } = result.createdReview
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url
})
}
}
setCreatePrError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel))
} catch (error) {
if (!isCurrentCreateRequest()) {
return
}
setCreatePrError(
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
)
} finally {
if (createPrInFlightRef.current === requestContextKey) {
createPrInFlightRef.current = null
setIsCreatingPr(false)
setGitStatusRefreshNonce((value) => value + 1)
}
}
setCreatePrError(formatCreateError(result, pushed, hostedReviewCreateCopy.shortLabel))
} catch (error) {
if (!isCurrentCreateRequest()) {
return
}
setCreatePrError(
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
)
} finally {
if (createPrInFlightRef.current === requestContextKey) {
createPrInFlightRef.current = null
setIsCreatingPr(false)
setGitStatusRefreshNonce((value) => value + 1)
}
}
}, [
activeWorktreePath,
activeWorktreeId,
activePullRequestGenerationKey,
branch,
createComposerOpen,
createHostedReview,
createPrPushFirst,
handlePullRequestCreated,
hostedReviewCreateCopy.providerName,
hostedReviewCreateCopy.reviewLabel,
hostedReviewCreateCopy.shortLabel,
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
hostedReviewCreation?.blockedReason,
panelContextKey,
prBase,
prBody,
prCreationDefaults.openAfterCreate,
prCreationDefaults.useTemplate,
prDraft,
prGenerating,
prTitle,
pushBeforeCreatePullRequest,
repo,
updatePullRequestGenerationRecord
])
},
[
activeWorktreePath,
activeWorktreeId,
activePullRequestGenerationKey,
branch,
createComposerOpen,
createHostedReview,
createStackedHostedReview,
createPrPushFirst,
handlePullRequestCreated,
hostedReviewCreateCopy.providerName,
hostedReviewCreateCopy.reviewLabel,
hostedReviewCreateCopy.shortLabel,
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
hostedReviewCreation?.blockedReason,
panelContextKey,
prBase,
prBody,
prCreationDefaults.openAfterCreate,
prCreationDefaults.useTemplate,
prDraft,
prGenerating,
prTitle,
pushBeforeCreatePullRequest,
repo,
updatePullRequestGenerationRecord
]
)
// ── Empty state ──
if (!activeWorktree) {
@@ -4283,10 +4319,12 @@ export default function ChecksPanel(): React.JSX.Element {
{!operationInProgress && createComposerOpen ? (
<div className="mt-4 border-t border-border pt-3">
<CreateHostedReviewComposer
key={panelContextKey}
className="p-0"
provider={hostedReviewCreateProvider}
branch={branch}
base={prBase}
repoDefaultBase={prRepoDefaultBaseRef}
setBase={handlePrBaseChange}
title={prTitle}
setTitle={handlePrTitleChange}
@@ -4294,10 +4332,13 @@ export default function ChecksPanel(): React.JSX.Element {
setBody={setPrBody}
draft={prDraft}
setDraft={setPrDraft}
stackedCreationSupported={prStackedCreationSupported}
stackParentReview={stackParentReview}
baseQuery={prBaseQuery}
setBaseQuery={setPrBaseQuery}
baseResults={prBaseResults}
setBaseResults={setPrBaseResults}
baseSearchPending={prBaseSearchPending}
baseSearchError={prBaseSearchError}
aiGenerationEnabled={sourceControlAiActionsVisible && prAiGenerationEnabled}
generating={prGenerating}
@@ -4323,7 +4364,7 @@ export default function ChecksPanel(): React.JSX.Element {
}}
onGenerate={() => void handleGeneratePullRequestFields()}
onCancelGenerate={handleCancelGeneratePullRequestFields}
onPrimaryAction={() => void handleCreatePullRequest()}
onPrimaryAction={(stacked) => void handleCreatePullRequest(stacked)}
/>
</div>
) : null}
@@ -0,0 +1,292 @@
import { Check, ChevronDown } from 'lucide-react'
import { useId, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { LocalizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { COMPOSER_FIELD_CLASS } from './create-hosted-review-composer-field-class'
import { CreateHostedReviewComposerMessage } from './CreateHostedReviewComposerMessage'
import { stripBaseRef } from './useCreatePullRequestDialogFields'
type CreateHostedReviewBasePickerProps = {
copy: LocalizedHostedReviewCopy
base: string
setBase: (value: string) => void
/** The repo's default branch, where an emptied field lands. Null until resolved. */
repoDefaultBase: string | null
editing: boolean
setEditing: (value: boolean) => void
baseQuery: string
setBaseQuery: (value: string) => void
baseResults: string[]
setBaseResults: (value: string[]) => void
baseSearchPending: boolean
baseSearchError: string | null
fieldsLocked: boolean
strippedBranch: string
baseSameAsBranch: boolean
}
/**
* The composer's merge target: a labelled full-width combobox whose results stay
* attached to it, plus the base-scoped errors.
*/
export function CreateHostedReviewBasePicker({
copy,
base,
setBase,
repoDefaultBase,
editing,
setEditing,
baseQuery,
setBaseQuery,
baseResults,
setBaseResults,
baseSearchPending,
baseSearchError,
fieldsLocked,
strippedBranch,
baseSameAsBranch
}: CreateHostedReviewBasePickerProps): React.JSX.Element {
const [activeResult, setActiveResult] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
// Why: marks a blur that an explicit Enter/Escape already resolved.
const settledRef = useRef(false)
const fieldId = useId()
const resultsId = useId()
const trimmedQuery = baseQuery.trim()
const trimmedRepoDefault = repoDefaultBase?.trim() ?? ''
const showResults = editing && baseResults.length > 0
// Why: emptying the field is how you say "not this branch"; name where it lands so
// the reset isn't an invisible behaviour.
const showRepoDefaultHint = editing && trimmedQuery.length === 0 && trimmedRepoDefault.length > 0
// Why: only claim "no branches match" once a search has actually settled, so the
// debounce window can't report an absence the app hasn't observed yet.
const showNoResults =
editing &&
baseResults.length === 0 &&
trimmedQuery.length >= 2 &&
!baseSearchPending &&
!baseSearchError
const closeSearch = (): void => {
setEditing(false)
setBaseQuery('')
setBaseResults([])
setActiveResult(-1)
}
const commitSearch = (value: string): void => {
// Why: an emptied field commits the repo default rather than silently restoring
// the branch the user just cleared. With no default resolved yet there is nothing
// honest to fall back to, so the committed base stands.
const nextBase = value.trim() || trimmedRepoDefault
if (nextBase) {
setBase(nextBase)
}
settledRef.current = true
closeSearch()
inputRef.current?.blur()
}
const cancelSearch = (): void => {
settledRef.current = true
closeSearch()
inputRef.current?.blur()
}
const handleBlur = (): void => {
// Why: Enter and Escape blur the input themselves; without this they would
// re-enter the commit path below with the pre-close query still in scope.
if (settledRef.current) {
settledRef.current = false
closeSearch()
return
}
// Why: clicking away from an emptied field means what pressing Enter on it
// means — land on the repo default instead of restoring what was cleared.
// A partial query still cancels; only an empty one is an instruction.
if (trimmedQuery.length === 0 && trimmedRepoDefault) {
setBase(trimmedRepoDefault)
}
closeSearch()
}
const moveActiveResult = (delta: number): void => {
if (baseResults.length === 0) {
return
}
setActiveResult((current) => {
const next = current + delta
if (next < 0) {
return baseResults.length - 1
}
return next >= baseResults.length ? 0 : next
})
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveActiveResult(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Enter') {
event.preventDefault()
commitSearch(baseResults[activeResult] ?? baseQuery)
return
}
if (event.key === 'Escape') {
event.preventDefault()
cancelSearch()
}
}
return (
// Why: the base owns the full sidebar width — branch names are long — and the
// head branch rides the label row instead of costing another line.
<div className="space-y-1.5">
<div className="flex min-w-0 items-baseline justify-between gap-2">
{/* Why: the label holds its line and the head branch absorbs the squeeze —
a wrapping two-word label next to a one-line ref reads as broken. */}
<Label
htmlFor={fieldId}
className="shrink-0 whitespace-nowrap text-[11px] font-medium text-muted-foreground"
>
{translate(
'auto.components.right.sidebar.CreateHostedReviewBasePicker.205ef284fa',
'Base branch'
)}
</Label>
<span className="min-w-0 truncate text-[11px] text-muted-foreground" title={strippedBranch}>
{translate(
'auto.components.right.sidebar.CreateHostedReviewBasePicker.bb4b41d563',
'from {{value0}}',
{ value0: strippedBranch }
)}
</span>
</div>
<div className="relative">
<Input
id={fieldId}
ref={inputRef}
aria-label={translate(
'auto.components.right.sidebar.SourceControl.6055949c50',
'{{value0}} base branch',
{ value0: copy.titleLabel }
)}
role="combobox"
aria-autocomplete="list"
aria-expanded={showResults}
// Why: the listbox only exists while results show, and an aria-controls
// IDREF that resolves to nothing is worse than no reference at all.
aria-controls={showResults ? resultsId : undefined}
aria-activedescendant={
showResults && activeResult >= 0 ? `${resultsId}-${activeResult}` : undefined
}
aria-invalid={baseSameAsBranch || undefined}
// Why: an input can't ellipsize, and base refs routinely overflow the sidebar.
title={editing ? undefined : base}
value={editing ? baseQuery : base}
disabled={fieldsLocked}
onFocus={(event) => {
// Why: a programmatic blur that never fired would otherwise leave the
// flag set and swallow the next real one.
settledRef.current = false
setEditing(true)
setBaseQuery(event.currentTarget.value)
}}
onBlur={handleBlur}
onChange={(event) => {
setBaseQuery(event.target.value)
setActiveResult(-1)
}}
onKeyDown={handleKeyDown}
// Why: the placeholder is where an emptied field lands, so it has to be the
// repo's real default — a hardcoded "main" lies on a trunk-named repo.
placeholder={
trimmedRepoDefault ||
translate('auto.components.right.sidebar.SourceControl.e64a632456', 'main')
}
className={cn(COMPOSER_FIELD_CLASS, 'pl-2 pr-7')}
/>
<ChevronDown
className="pointer-events-none absolute right-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
</div>
{showResults ? (
<div
id={resultsId}
role="listbox"
className="max-h-40 overflow-auto rounded-md border border-input bg-popover p-1 shadow-xs scrollbar-sleek"
>
{baseResults.map((ref, index) => {
const selected = stripBaseRef(base) === ref
return (
<button
key={ref}
id={`${resultsId}-${index}`}
type="button"
role="option"
aria-selected={selected}
data-selected={index === activeResult ? 'true' : undefined}
disabled={fieldsLocked}
className={cn(
'flex h-7 w-full items-center justify-between gap-2 rounded-sm px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
selected && 'text-foreground'
)}
onMouseDown={(event) => {
event.preventDefault()
commitSearch(ref)
}}
>
<span className="truncate">{ref}</span>
{selected ? <Check className="size-3 shrink-0" aria-hidden="true" /> : null}
</button>
)
})}
</div>
) : null}
{showRepoDefaultHint ? (
<p className="px-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewBasePicker.da4d57c9c2',
'Leave empty to use {{value0}}.',
{ value0: trimmedRepoDefault }
)}
</p>
) : null}
{showNoResults ? (
<p className="px-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewBasePicker.5a9315b61a',
'No branches match “{{value0}}”. Press Enter to use it anyway.',
{ value0: trimmedQuery }
)}
</p>
) : null}
{/* Why: base problems belong to the base field, not to the block of
operation errors above the submit button. */}
{baseSameAsBranch ? (
<CreateHostedReviewComposerMessage>
{translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: copy.reviewLabel }
)}
</CreateHostedReviewComposerMessage>
) : null}
{baseSearchError ? (
<CreateHostedReviewComposerMessage>{baseSearchError}</CreateHostedReviewComposerMessage>
) : null}
</div>
)
}
@@ -1,3 +1,4 @@
import { useState } from 'react'
import {
ChevronDown,
GitMerge,
@@ -26,8 +27,10 @@ import {
type HostedReviewProvider
} from '../../../../shared/hosted-review'
import { stripBaseRef } from './useCreatePullRequestDialogFields'
import type { HostedReviewStackParent } from './useHostedReviewStackParent'
import type { DropdownActionKind, DropdownEntry } from './source-control-dropdown-items'
import { CreateHostedReviewComposerFields } from './CreateHostedReviewComposerFields'
import { getCreateButtonLabel } from './create-hosted-review-button-label'
import {
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS,
RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS,
@@ -47,16 +50,20 @@ export type CreateHostedReviewComposerProps = {
branch: string
base: string
setBase: (value: string) => void
repoDefaultBase: string | null
title: string
setTitle: (value: string) => void
body: string
setBody: (value: string) => void
draft: boolean
setDraft: (value: boolean) => void
stackedCreationSupported: boolean
stackParentReview: HostedReviewStackParent | null
baseQuery: string
setBaseQuery: (value: string) => void
baseResults: string[]
setBaseResults: (value: string[]) => void
baseSearchPending: boolean
baseSearchError: string | null
aiGenerationEnabled: boolean
generating: boolean
@@ -70,7 +77,7 @@ export type CreateHostedReviewComposerProps = {
dropdownItems?: DropdownEntry[]
onGenerate: () => void
onCancelGenerate: () => void
onPrimaryAction: () => void
onPrimaryAction: (stacked: boolean) => void
onDropdownAction?: (kind: DropdownActionKind) => void
}
@@ -80,16 +87,20 @@ export function CreateHostedReviewComposer({
branch,
base,
setBase,
repoDefaultBase,
title,
setTitle,
body,
setBody,
draft,
setDraft,
stackedCreationSupported,
stackParentReview,
baseQuery,
setBaseQuery,
baseResults,
setBaseResults,
baseSearchPending,
baseSearchError,
aiGenerationEnabled,
generating,
@@ -111,7 +122,20 @@ export function CreateHostedReviewComposer({
const supportsDraft = hostedReviewProviderSupportsDraft(provider)
const effectiveDraft = supportsDraft && draft
const ReviewIcon = provider === 'gitlab' ? GitMerge : GitPullRequestArrow
const stackedModeAvailable = provider === 'github' && stackedCreationSupported
const normalizedBase = stripBaseRef(base)
const stackSelectionKey = stackParentReview
? `${normalizedBase}:${stackParentReview.number}`
: null
const [stackSelection, setStackSelection] = useState({ key: '', enabled: false })
const effectiveStacked =
stackedModeAvailable &&
stackSelectionKey !== null &&
stackSelection.key === stackSelectionKey &&
stackSelection.enabled
const setStacked = (enabled: boolean): void => {
setStackSelection({ key: stackSelectionKey ?? '', enabled })
}
const strippedBranch = stripBaseRef(branch)
const baseSameAsBranch = normalizedBase.toLowerCase() === strippedBranch.toLowerCase()
const createDisabled =
@@ -197,7 +221,9 @@ export function CreateHostedReviewComposer({
return (
<div className={cn('px-3 pb-2', className)}>
<div className="space-y-2.5">
{/* Why: one gap between groups, tighter gaps inside them — the form reads as
content → merge target → options → action instead of a stack of boxes. */}
<div className="space-y-3">
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-xs">
<ReviewIcon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
@@ -229,6 +255,7 @@ export function CreateHostedReviewComposer({
copy={copy}
base={base}
setBase={setBase}
repoDefaultBase={repoDefaultBase}
title={title}
setTitle={setTitle}
body={body}
@@ -236,10 +263,14 @@ export function CreateHostedReviewComposer({
draft={draft}
setDraft={setDraft}
supportsDraft={supportsDraft}
stacked={effectiveStacked}
setStacked={setStacked}
stackParentReview={stackedModeAvailable ? stackParentReview : null}
baseQuery={baseQuery}
setBaseQuery={setBaseQuery}
baseResults={baseResults}
setBaseResults={setBaseResults}
baseSearchPending={baseSearchPending}
baseSearchError={baseSearchError}
generateError={generateError}
createError={createError}
@@ -250,14 +281,14 @@ export function CreateHostedReviewComposer({
baseSameAsBranch={baseSameAsBranch}
/>
<div className={cn(RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS, 'pt-0.5')}>
<div className={RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS}>
<Button
type="button"
size="xs"
disabled={createDisabled}
onClick={() => onPrimaryAction()}
onClick={() => onPrimaryAction(effectiveStacked)}
className={cn(
'h-7 px-3 text-xs',
'h-8 px-3 text-xs',
showDropdown && 'rounded-r-none',
RIGHT_SIDEBAR_MORPHING_PRIMARY_BUTTON_CLASS
)}
@@ -273,6 +304,7 @@ export function CreateHostedReviewComposer({
isCreating,
pushBeforeCreate,
draft: effectiveDraft,
stacked: effectiveStacked,
shortLabel: copy.shortLabel
})}
</span>
@@ -284,7 +316,7 @@ export function CreateHostedReviewComposer({
type="button"
size="xs"
className={cn(
'h-7 rounded-l-none border-l border-primary-foreground/20 px-1.5 shrink-0',
'h-8 rounded-l-none border-l border-primary-foreground/20 px-1.5 shrink-0',
createDisabled && 'opacity-50'
)}
aria-label={translate(
@@ -337,36 +369,3 @@ export function CreateHostedReviewComposer({
</div>
)
}
function getCreateButtonLabel({
isCreating,
pushBeforeCreate,
draft,
shortLabel
}: {
isCreating: boolean
pushBeforeCreate: boolean
draft: boolean
shortLabel: string
}): string {
if (isCreating) {
return translate('auto.components.right.sidebar.SourceControl.26511c22b4', 'Creating...')
}
if (pushBeforeCreate) {
return translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.741ff8a0d2',
'Push & Create {{value0}}',
{ value0: shortLabel }
)
}
if (draft) {
return translate(
'auto.components.right.sidebar.SourceControl.aaf1451654',
'Create draft {{value0}}',
{ value0: shortLabel }
)
}
return translate('auto.components.right.sidebar.SourceControl.5acbcedc1a', 'Create {{value0}}', {
value0: shortLabel
})
}
@@ -1,13 +1,25 @@
import { ArrowDownUp, Check, ChevronDown, Sparkles, TriangleAlert } from 'lucide-react'
import { CornerDownRight, GitPullRequestArrow, Sparkles } from 'lucide-react'
import { useId, useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { LocalizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { stripBaseRef } from './useCreatePullRequestDialogFields'
import { CreateHostedReviewBasePicker } from './CreateHostedReviewBasePicker'
import { CreateHostedReviewComposerMessage } from './CreateHostedReviewComposerMessage'
import {
COMPOSER_CHECKBOX_CLASS,
COMPOSER_FIELD_CLASS,
COMPOSER_TEXTAREA_CLASS
} from './create-hosted-review-composer-field-class'
import type { HostedReviewStackParent } from './useHostedReviewStackParent'
type CreateHostedReviewComposerFieldsProps = {
copy: LocalizedHostedReviewCopy
base: string
setBase: (value: string) => void
repoDefaultBase: string | null
title: string
setTitle: (value: string) => void
body: string
@@ -15,10 +27,14 @@ type CreateHostedReviewComposerFieldsProps = {
draft: boolean
setDraft: (value: boolean) => void
supportsDraft: boolean
stacked: boolean
setStacked: (value: boolean) => void
stackParentReview: HostedReviewStackParent | null
baseQuery: string
setBaseQuery: (value: string) => void
baseResults: string[]
setBaseResults: (value: string[]) => void
baseSearchPending: boolean
baseSearchError: string | null
generateError: string | null
createError: string | null
@@ -33,6 +49,7 @@ export function CreateHostedReviewComposerFields({
copy,
base,
setBase,
repoDefaultBase,
title,
setTitle,
body,
@@ -40,10 +57,14 @@ export function CreateHostedReviewComposerFields({
draft,
setDraft,
supportsDraft,
stacked,
setStacked,
stackParentReview,
baseQuery,
setBaseQuery,
baseResults,
setBaseResults,
baseSearchPending,
baseSearchError,
generateError,
createError,
@@ -53,32 +74,16 @@ export function CreateHostedReviewComposerFields({
strippedBranch,
baseSameAsBranch
}: CreateHostedReviewComposerFieldsProps): React.JSX.Element {
// Why: owned here, not in the picker — the stack option steps aside while the
// base list is open so results never overlap an option about that same base.
const [baseEditing, setBaseEditing] = useState(false)
const draftFieldId = useId()
const stackFieldId = useId()
return (
<>
{/* Why: a single line that shows the head->base flow plain-language so
the user can sanity-check the merge direction at a glance. */}
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="truncate font-mono text-foreground" title={strippedBranch}>
{strippedBranch}
</span>
<ArrowDownUp className="size-3 rotate-90 shrink-0 opacity-60" aria-hidden="true" />
<span
className={cn(
'truncate font-mono',
baseSameAsBranch ? 'text-destructive' : 'text-foreground'
)}
title={
normalizedBase ||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')
}
>
{normalizedBase ||
translate('auto.components.right.sidebar.SourceControl.7a09d7f9d2', 'base')}
</span>
</div>
<div className="relative space-y-2">
<input
<div className="relative space-y-1.5">
<Input
aria-label={translate(
'auto.components.right.sidebar.SourceControl.a6eda33521',
'{{value0}} title',
@@ -88,7 +93,7 @@ export function CreateHostedReviewComposerFields({
disabled={fieldsLocked}
onChange={(event) => setTitle(event.target.value)}
placeholder={translate('auto.components.right.sidebar.SourceControl.7d6a8f0082', 'Title')}
className="h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
className={cn(COMPOSER_FIELD_CLASS, 'px-2 font-medium')}
/>
<textarea
@@ -105,7 +110,7 @@ export function CreateHostedReviewComposerFields({
'auto.components.right.sidebar.SourceControl.a0dc20fc93',
'Description (optional)'
)}
className="min-h-[7.5rem] w-full resize-y rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60 scrollbar-sleek"
className={cn(COMPOSER_TEXTAREA_CLASS, 'min-h-[7rem] resize-y scrollbar-sleek')}
/>
{generating ? (
@@ -115,7 +120,7 @@ export function CreateHostedReviewComposerFields({
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/40"
aria-hidden="true"
>
<div className="pointer-events-auto flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-[11px] text-muted-foreground shadow-sm">
<div className="pointer-events-auto flex items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-[11px] text-muted-foreground shadow-xs">
<Sparkles className="size-3 animate-pulse text-foreground" />
<span>
{translate(
@@ -128,145 +133,120 @@ export function CreateHostedReviewComposerFields({
) : null}
</div>
{/* Why: base picker as its own labeled row so the title input can use
the full width. The dropdown chevron makes the picker affordance
obvious; the inline label clarifies that this is the merge target. */}
<div className="flex items-center gap-2">
<span className="shrink-0 text-[11px] text-muted-foreground">
{translate('auto.components.right.sidebar.SourceControl.1f7119f604', 'Base')}
</span>
<div className="relative min-w-0 flex-1">
<input
aria-label={translate(
'auto.components.right.sidebar.SourceControl.6055949c50',
'{{value0}} base branch',
{ value0: copy.titleLabel }
)}
value={baseQuery || base}
disabled={fieldsLocked}
onChange={(event) => {
setBaseQuery(event.target.value)
setBase(event.target.value)
}}
placeholder={translate(
'auto.components.right.sidebar.SourceControl.e64a632456',
'main'
)}
className="h-7 w-full min-w-0 rounded-md border border-border bg-background px-2 pr-6 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
/>
<ChevronDown
className="pointer-events-none absolute right-1.5 top-1.5 size-3.5 text-muted-foreground"
aria-hidden="true"
/>
</div>
<CreateHostedReviewBasePicker
copy={copy}
base={base}
setBase={setBase}
repoDefaultBase={repoDefaultBase}
editing={baseEditing}
setEditing={setBaseEditing}
baseQuery={baseQuery}
setBaseQuery={setBaseQuery}
baseResults={baseResults}
setBaseResults={setBaseResults}
baseSearchPending={baseSearchPending}
baseSearchError={baseSearchError}
fieldsLocked={fieldsLocked}
strippedBranch={strippedBranch}
baseSameAsBranch={baseSameAsBranch}
/>
<div className="space-y-2.5">
{stackParentReview && !baseEditing ? (
<div className="space-y-1.5">
<div className="flex items-start gap-2">
<Checkbox
id={stackFieldId}
checked={stacked}
disabled={fieldsLocked}
onCheckedChange={(value) => setStacked(value === true)}
className={cn(COMPOSER_CHECKBOX_CLASS, 'mt-px')}
/>
{/* Why: the base field one row up already names the parent branch, so the
helper explains the effect instead of repeating the ref. */}
<Label
htmlFor={stackFieldId}
className="min-w-0 flex-1 flex-col items-start gap-0.5 text-xs leading-snug"
>
<span className="text-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposerFields.90cabf6cfc',
'Stack this PR above #{{value0}}',
{ value0: stackParentReview.number }
)}
</span>
{stacked ? null : (
<span className="text-[11px] font-normal text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposerFields.ff81473a57',
"Creates a GitHub Stack or extends the parent's existing stack."
)}
</span>
)}
</Label>
</div>
{stacked ? (
// Why: a single hairline instead of a nested card — the relation reads as
// detail of the checkbox above it, not as its own framed section.
<div className="ml-6 space-y-1 border-l border-border pl-2 text-[11px]">
<div className="flex min-w-0 items-center gap-1.5">
<GitPullRequestArrow
className="size-3 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="shrink-0 text-muted-foreground">
#{stackParentReview.number}
</span>
<span className="truncate text-foreground">{normalizedBase}</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
<CornerDownRight
className="size-3 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="truncate text-foreground">{strippedBranch}</span>
<span className="shrink-0 text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposerFields.29732f2fb0',
'new PR'
)}
</span>
</div>
</div>
) : null}
</div>
) : null}
{supportsDraft ? (
<div className="flex items-center gap-2">
<Checkbox
id={draftFieldId}
checked={draft}
disabled={fieldsLocked}
onCheckedChange={(value) => setDraft(value === true)}
className={COMPOSER_CHECKBOX_CLASS}
/>
<Label htmlFor={draftFieldId} className="min-w-0 flex-1 truncate text-xs">
{translate(
'auto.components.right.sidebar.SourceControl.78ddfd0bb4',
'Create as draft'
)}
</Label>
</div>
) : null}
</div>
{supportsDraft ? (
<label
className={cn(
'flex h-7 items-center gap-2 rounded-md border border-border bg-background px-2 text-xs text-foreground transition-colors',
fieldsLocked
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:bg-accent hover:text-accent-foreground'
)}
>
<input
type="checkbox"
checked={draft}
disabled={fieldsLocked}
onChange={(event) => setDraft(event.target.checked)}
className="size-3.5 shrink-0 rounded border-border accent-primary"
/>
<span className="min-w-0 flex-1 truncate">
{translate('auto.components.right.sidebar.SourceControl.78ddfd0bb4', 'Create as draft')}
</span>
</label>
) : null}
{baseResults.length > 0 ? (
<div className="max-h-28 overflow-auto rounded-md border border-border p-1 scrollbar-sleek">
{baseResults.map((ref) => (
<button
key={ref}
type="button"
disabled={fieldsLocked}
className={cn(
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left font-mono text-xs hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent',
stripBaseRef(base) === ref && 'bg-accent text-accent-foreground'
)}
onClick={() => {
if (fieldsLocked) {
return
}
setBase(ref)
setBaseQuery('')
setBaseResults([])
}}
>
<span className="truncate">{ref}</span>
{stripBaseRef(base) === ref ? <Check className="size-3" /> : null}
</button>
))}
{generateError || createError ? (
<div className="space-y-1">
{generateError ? (
<CreateHostedReviewComposerMessage>{generateError}</CreateHostedReviewComposerMessage>
) : null}
{createError ? (
<CreateHostedReviewComposerMessage>{createError}</CreateHostedReviewComposerMessage>
) : null}
</div>
) : null}
<CreateHostedReviewComposerMessages
copy={copy}
baseSameAsBranch={baseSameAsBranch}
baseSearchError={baseSearchError}
generateError={generateError}
createError={createError}
/>
</>
)
}
function CreateHostedReviewComposerMessages({
copy,
baseSameAsBranch,
baseSearchError,
generateError,
createError
}: {
copy: LocalizedHostedReviewCopy
baseSameAsBranch: boolean
baseSearchError: string | null
generateError: string | null
createError: string | null
}): React.JSX.Element {
return (
<>
{baseSameAsBranch ? (
<CreateHostedReviewComposerMessage>
{translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: copy.reviewLabel }
)}
</CreateHostedReviewComposerMessage>
) : null}
{baseSearchError ? (
<CreateHostedReviewComposerMessage>{baseSearchError}</CreateHostedReviewComposerMessage>
) : null}
{generateError ? (
<CreateHostedReviewComposerMessage>{generateError}</CreateHostedReviewComposerMessage>
) : null}
{createError ? (
<CreateHostedReviewComposerMessage>{createError}</CreateHostedReviewComposerMessage>
) : null}
</>
)
}
function CreateHostedReviewComposerMessage({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
return (
<p className="flex items-start gap-1 text-[11px] text-destructive">
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
<span>{children}</span>
</p>
)
}
@@ -0,0 +1,15 @@
import { TriangleAlert } from 'lucide-react'
/** Inline blocking message for the create-review composer. */
export function CreateHostedReviewComposerMessage({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
return (
<p className="flex items-start gap-1 text-[11px] text-destructive">
<TriangleAlert className="mt-px size-3 shrink-0" aria-hidden="true" />
<span>{children}</span>
</p>
)
}
@@ -1,7 +1,12 @@
// @vitest-environment happy-dom
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import { cleanup, fireEvent, render as renderDom, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import type { HostedReviewStackParent } from './useHostedReviewStackParent'
import { resolveDropdownItems } from './source-control-dropdown-items'
import { resolvePrimaryAction } from './source-control-primary-action'
@@ -10,14 +15,38 @@ type RenderPullRequestComposerOptions = {
generating?: boolean
generateDisabled?: boolean
generateDisabledReason?: string
stackedCreationSupported?: boolean
stackParentReview?: HostedReviewStackParent | null
base?: string
setBase?: (value: string) => void
baseQuery?: string
setBaseQuery?: (value: string) => void
baseResults?: string[]
setBaseResults?: (value: string[]) => void
baseSearchPending?: boolean
repoDefaultBase?: string | null
onPrimaryAction?: (stacked: boolean) => void
}
function renderPullRequestComposer({
const EMPTY_BASE_RESULTS: string[] = []
function pullRequestComposerElement({
aiGenerationEnabled = true,
generating = false,
generateDisabled = false,
generateDisabledReason
}: RenderPullRequestComposerOptions = {}): string {
generateDisabledReason,
stackedCreationSupported = true,
stackParentReview = null,
base = 'master',
setBase = vi.fn(),
baseQuery = '',
setBaseQuery = vi.fn(),
baseResults = [],
setBaseResults = vi.fn(),
baseSearchPending = false,
repoDefaultBase = 'main',
onPrimaryAction = vi.fn()
}: RenderPullRequestComposerOptions = {}): React.JSX.Element {
const sourceControlInputs = {
stagedCount: 1,
hasUnstagedChanges: false,
@@ -31,23 +60,27 @@ function renderPullRequestComposer({
}
const primaryAction = resolvePrimaryAction(sourceControlInputs)
return renderToStaticMarkup(
return (
<TooltipProvider>
<CreateHostedReviewComposer
provider="github"
branch="branch-login-issue"
base="master"
setBase={vi.fn()}
title=""
base={base}
setBase={setBase}
repoDefaultBase={repoDefaultBase}
title="Ready to create"
setTitle={vi.fn()}
body=""
setBody={vi.fn()}
draft={false}
setDraft={vi.fn()}
baseQuery=""
setBaseQuery={vi.fn()}
baseResults={[]}
setBaseResults={vi.fn()}
stackedCreationSupported={stackedCreationSupported}
stackParentReview={stackParentReview}
baseQuery={baseQuery}
setBaseQuery={setBaseQuery}
baseResults={baseResults}
setBaseResults={setBaseResults}
baseSearchPending={baseSearchPending}
baseSearchError={null}
aiGenerationEnabled={aiGenerationEnabled}
generating={generating}
@@ -60,13 +93,50 @@ function renderPullRequestComposer({
dropdownItems={resolveDropdownItems(sourceControlInputs)}
onGenerate={vi.fn()}
onCancelGenerate={vi.fn()}
onPrimaryAction={vi.fn()}
onPrimaryAction={onPrimaryAction}
onDropdownAction={vi.fn()}
/>
</TooltipProvider>
)
}
function renderPullRequestComposer(options: RenderPullRequestComposerOptions = {}): string {
return renderToStaticMarkup(pullRequestComposerElement(options))
}
function InteractiveBaseComposer({
baseResults = EMPTY_BASE_RESULTS,
baseSearchPending = false,
stackParentReview = null,
repoDefaultBase = 'main',
initialBase = 'main',
onPrimaryAction
}: {
baseResults?: string[]
baseSearchPending?: boolean
stackParentReview?: HostedReviewStackParent | null
repoDefaultBase?: string | null
initialBase?: string
onPrimaryAction?: (stacked: boolean) => void
}) {
const [base, setBase] = useState(initialBase)
const [baseQuery, setBaseQuery] = useState('')
const [results, setBaseResults] = useState(baseResults)
return pullRequestComposerElement({
base,
setBase,
baseQuery,
setBaseQuery,
baseResults: results,
setBaseResults,
baseSearchPending,
stackParentReview,
repoDefaultBase,
...(onPrimaryAction ? { onPrimaryAction } : {})
})
}
function elementByLabel(markup: string, tagName: string, label: string): string {
const element = [...markup.matchAll(new RegExp(`<${tagName}\\b[\\s\\S]*?</${tagName}>`, 'g'))]
.map((match) => match[0])
@@ -80,6 +150,8 @@ function elementByLabel(markup: string, tagName: string, label: string): string
}
describe('CreateHostedReviewComposer generate tooltip', () => {
afterEach(cleanup)
it('renders hosted review labels without leaking interpolation placeholders', () => {
const markup = renderPullRequestComposer()
@@ -122,4 +194,244 @@ describe('CreateHostedReviewComposer generate tooltip', () => {
expect(button).toContain('data-slot="tooltip-trigger"')
expect(button).not.toContain('disabled=""')
})
it('does not ask for a PR type without an open parent review', () => {
const markup = renderPullRequestComposer()
expect(markup).not.toContain('Regular PR')
expect(markup).not.toContain('Stacked PR')
expect(markup).not.toContain('Stack this PR above')
})
it('shows the parent-child preview and stack create action for an open parent review', () => {
const onPrimaryAction = vi.fn()
const { container } = renderDom(
pullRequestComposerElement({
stackParentReview: { number: 13741, url: 'https://github.com/stablyai/orca/pull/13741' },
onPrimaryAction
})
)
// Unchecked, the helper explains the effect rather than repeating the base ref.
expect(container.innerHTML).toContain(
"Creates a GitHub Stack or extends the parent's existing stack."
)
fireEvent.click(screen.getByRole('checkbox', { name: /Stack this PR above #13741/ }))
const markup = container.innerHTML
expect(markup).toContain('#13741')
expect(markup).toContain('master')
expect(markup).toContain('branch-login-issue')
expect(markup).toContain('Create PR in stack')
fireEvent.click(screen.getByRole('button', { name: /Create PR in stack/ }))
expect(onPrimaryAction).toHaveBeenCalledWith(true)
})
it('drives both options through the shadcn Checkbox primitive', () => {
const { container } = renderDom(
pullRequestComposerElement({
stackParentReview: { number: 13741, url: 'https://github.com/stablyai/orca/pull/13741' }
})
)
expect(container.querySelectorAll('[data-slot="checkbox"]')).toHaveLength(2)
// Radix keeps a hidden native input for form participation; nothing browser-native renders.
for (const native of container.querySelectorAll('input[type="checkbox"]')) {
expect(native.getAttribute('aria-hidden')).toBe('true')
}
})
it('labels the base field above a full-width combobox and names the head branch', () => {
renderDom(<InteractiveBaseComposer />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
expect(screen.getByText('Base branch').getAttribute('for')).toBe(input.id)
expect(input.className).toContain('w-full')
expect(screen.getByText('from branch-login-issue')).toBeTruthy()
})
it('marks the base field invalid when it matches the head branch', () => {
const { container } = renderDom(pullRequestComposerElement({ base: 'branch-login-issue' }))
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(container.innerHTML).toContain(
'Choose a different base branch before creating a pull request.'
)
})
it('moves through base results with the arrow keys and commits the highlighted ref', () => {
renderDom(
<InteractiveBaseComposer
baseResults={['release/candidate', 'release/next', 'release/prev']}
/>
)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'ArrowDown' })
expect(input.getAttribute('aria-activedescendant')).toBe(
screen.getByRole('option', { name: 'release/next' }).id
)
fireEvent.keyDown(input, { key: 'ArrowUp' })
fireEvent.keyDown(input, { key: 'Enter' })
expect((input as HTMLInputElement).value).toBe('release/candidate')
})
it('withholds the empty-result message until the base search settles', () => {
const { rerender } = renderDom(<InteractiveBaseComposer baseSearchPending />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'nope' } })
expect(screen.queryByText(/No branches match/)).toBeNull()
rerender(<InteractiveBaseComposer baseSearchPending={false} />)
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'nope' } })
expect(screen.getByText(/No branches match “nope”/)).toBeTruthy()
})
it('commits the repo default when the base field is cleared', () => {
renderDom(<InteractiveBaseComposer initialBase="feature/parent" repoDefaultBase="main" />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
expect(screen.getByText('Leave empty to use main.')).toBeTruthy()
fireEvent.keyDown(input, { key: 'Enter' })
expect((input as HTMLInputElement).value).toBe('main')
})
it('commits the repo default when an emptied field loses focus', () => {
renderDom(<InteractiveBaseComposer initialBase="feature/parent" repoDefaultBase="main" />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect((input as HTMLInputElement).value).toBe('main')
})
it('cancels a partial query on blur instead of committing it', () => {
renderDom(<InteractiveBaseComposer initialBase="feature/parent" repoDefaultBase="main" />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'rele' } })
fireEvent.blur(input)
expect((input as HTMLInputElement).value).toBe('feature/parent')
})
it('restores the committed base when an emptied field is cancelled', () => {
renderDom(<InteractiveBaseComposer initialBase="feature/parent" repoDefaultBase="main" />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect((input as HTMLInputElement).value).toBe('feature/parent')
})
it('keeps the committed base when no repo default has resolved yet', () => {
renderDom(<InteractiveBaseComposer initialBase="feature/parent" repoDefaultBase={null} />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
expect(screen.queryByText(/Leave empty to use/)).toBeNull()
fireEvent.keyDown(input, { key: 'Enter' })
expect((input as HTMLInputElement).value).toBe('feature/parent')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect((input as HTMLInputElement).value).toBe('feature/parent')
})
it('drops a stack choice when the base moves off the parent it was made for', () => {
// The choice is keyed to base+parent, so a base change can never submit a stacked
// create for a parent the composer is no longer showing.
const onPrimaryAction = vi.fn()
renderDom(
<InteractiveBaseComposer
initialBase="feature/parent"
baseResults={['release/candidate']}
stackParentReview={{ number: 13741, url: 'https://github.com/stablyai/orca/pull/13741' }}
onPrimaryAction={onPrimaryAction}
/>
)
fireEvent.click(screen.getByRole('checkbox', { name: /Stack this PR above #13741/ }))
expect(screen.getByRole('button', { name: /Create PR in stack/ })).toBeTruthy()
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'release/candidate' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.queryByRole('button', { name: /in stack/ })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /^Create PR$/ }))
expect(onPrimaryAction).toHaveBeenCalledWith(false)
})
it('hides the stack option while the base search is open', () => {
renderDom(
<InteractiveBaseComposer
baseResults={['release/candidate']}
stackParentReview={{ number: 13741, url: 'https://github.com/stablyai/orca/pull/13741' }}
/>
)
expect(screen.getByRole('checkbox', { name: /Stack this PR above #13741/ })).toBeTruthy()
fireEvent.focus(screen.getByRole('combobox', { name: 'Pull Request base branch' }))
expect(screen.queryByRole('checkbox', { name: /Stack this PR above/ })).toBeNull()
})
it('hides stacked creation when the executing host lacks the capability', () => {
const markup = renderPullRequestComposer({
stackedCreationSupported: false,
stackParentReview: { number: 13741, url: 'https://github.com/stablyai/orca/pull/13741' }
})
expect(markup).not.toContain('Stack this PR above #13741')
})
it('keeps temporary base search text separate from the committed branch', () => {
renderDom(<InteractiveBaseComposer />)
const input = screen.getByRole('combobox', { name: 'Pull Request base branch' })
fireEvent.focus(input)
fireEvent.change(input, { target: { value: '' } })
expect((input as HTMLInputElement).value).toBe('')
fireEvent.change(input, { target: { value: 'release/candidate' } })
expect((input as HTMLInputElement).value).toBe('release/candidate')
fireEvent.keyDown(input, { key: 'Escape' })
expect((input as HTMLInputElement).value).toBe('main')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'release/candidate' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect((input as HTMLInputElement).value).toBe('release/candidate')
})
it('places base search results directly under the combobox', () => {
const { container } = renderDom(<InteractiveBaseComposer baseResults={['release/candidate']} />)
fireEvent.focus(screen.getByRole('combobox', { name: 'Pull Request base branch' }))
const markup = container.innerHTML
expect(markup.indexOf('release/candidate')).toBeLessThan(markup.indexOf('Create as draft'))
})
})
@@ -246,6 +246,7 @@ import {
} from './source-control-pull-policy-error-notice'
import { SourceControlTextGenerationDialog } from './SourceControlTextGenerationDialog'
import { CreateHostedReviewComposer } from './CreateHostedReviewComposer'
import { useHostedReviewStackParent } from './useHostedReviewStackParent'
import { resolveCreatedHostedReviewLink } from './source-control-created-review-link'
import {
hasConfiguredCommitMessageGenerationDefaults,
@@ -911,6 +912,7 @@ function SourceControlInner(): React.JSX.Element {
(s) => s.getHostedReviewCreationEligibility
)
const createHostedReview = useAppStore((s) => s.createHostedReview)
const createStackedHostedReview = useAppStore((s) => s.createStackedHostedReview)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh)
@@ -3013,10 +3015,13 @@ function SourceControlInner(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
stackedCreationSupported: prStackedCreationSupported,
repoDefaultBaseRef: prRepoDefaultBaseRef,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
setBaseResults: setPrBaseResults,
baseSearchPending: prBaseSearchPending,
baseSearchError: prBaseSearchError,
generating: prGenerating,
generateError: prGenerateError,
@@ -3053,6 +3058,17 @@ function SourceControlInner(): React.JSX.Element {
onCancelGenerate: handleCancelGeneratePullRequestFieldsForActive
}
})
const stackParentReview = useHostedReviewStackParent({
enabled: hostedReviewCreateProvider === 'github' && prStackedCreationSupported,
repoPath: activeRepo?.path ?? '',
repoId: activeRepo?.id ?? null,
base: prBase,
// Why: the repo default, not eligibility's defaultBaseRef — that one resolves to
// the worktree's own base, which is exactly the branch a stacked PR targets.
repoDefaultBase: prRepoDefaultBaseRef,
head: branchName,
fetchHostedReviewForBranch
})
const handleGeneratePullRequestFieldsClick = useCallback((): void => {
if (!sourceControlAiActionsVisible) {
@@ -3273,163 +3289,184 @@ function SourceControlInner(): React.JSX.Element {
worktreePath
])
const handleCreatePullRequest = useCallback(async (): Promise<void> => {
if (
!activeRepo ||
!activeWorktreeId ||
!worktreePath ||
!hostedReviewCreation ||
prGenerating ||
createPrInFlightRef.current[activeWorktreeId]
) {
return
}
if (!hostedReviewCreation.canCreate) {
// Why: blocked Create Review clicks are intentional; the inline notice tells the user which prerequisite to clear.
const message = resolveBlockedCreateReviewNoticeMessage(hostedReviewCreation)
if (message) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message
})
const handleCreatePullRequest = useCallback(
async (stacked = false): Promise<void> => {
if (
!activeRepo ||
!activeWorktreeId ||
!worktreePath ||
!hostedReviewCreation ||
prGenerating ||
createPrInFlightRef.current[activeWorktreeId]
) {
return
}
return
}
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
if (!title) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
createPrInFlightRef.current[activeWorktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
try {
const result = await createHostedReview(activeRepo.path, {
repoId: activeRepo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branchName),
title,
body: prBody,
draft: prDraft,
worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
})
if (result.ok) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number: result.number,
url: result.url
})
if (resolvedPrCreationDefaults.openAfterCreate) {
window.api.shell.openUrl(result.url)
if (!hostedReviewCreation.canCreate) {
// Why: blocked Create Review clicks are intentional; the inline notice tells the user which prerequisite to clear.
const message = resolveBlockedCreateReviewNoticeMessage(hostedReviewCreation)
if (message) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message
})
}
return
}
if (result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
? translate(
'auto.components.right.sidebar.SourceControl.eef5446523',
'{{value0}} #{{value1}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
)
: translate(
'auto.components.right.sidebar.SourceControl.d6fb1df5fe',
'{{value0}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.SourceControl.812cb992ee',
'Open on {{value0}}',
{ value0: hostedReviewCreateCopy.providerName }
),
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
}
}
)
if (number) {
const base = stripBaseRef(prBase).trim()
const title = prTitle.trim()
if (!title) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.f3a8b2c1d0e5',
'Enter a {{value0}} title.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
if (!base || stripBaseRef(base).toLowerCase() === stripBaseRef(branchName).toLowerCase()) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: translate(
'auto.components.right.sidebar.SourceControl.ae743199cd',
'Choose a different base branch before creating a {{value0}}.',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
return
}
createPrInFlightRef.current[activeWorktreeId] = true
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
try {
const createInput = {
repoId: activeRepo.id,
provider: hostedReviewCreateProvider,
base,
head: normalizeHostedReviewHeadRef(branchName),
title,
body: prBody,
draft: prDraft,
worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
}
const result = stacked
? await createStackedHostedReview(activeRepo.path, createInput)
: await createHostedReview(activeRepo.path, createInput)
if (result.ok) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
number: result.number,
url: result.url
})
if (resolvedPrCreationDefaults.openAfterCreate) {
window.api.shell.openUrl(result.url)
}
return
}
}
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: result.error
})
} catch (error) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message:
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
} finally {
createPrInFlightRef.current[activeWorktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
}
}, [
activeRepo,
activeWorktreeId,
branchName,
createHostedReview,
handlePullRequestCreated,
hostedReviewCreation,
hostedReviewCreateCopy.providerName,
hostedReviewCreateCopy.reviewLabel,
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
prBase,
prBody,
prDraft,
prGenerating,
prTitle,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
setCreatePrIntentNoticeForWorktree,
worktreePath
])
if ('existingReview' in result && result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
? translate(
'auto.components.right.sidebar.SourceControl.eef5446523',
'{{value0}} #{{value1}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel, value1: number }
)
: translate(
'auto.components.right.sidebar.SourceControl.d6fb1df5fe',
'{{value0}} is already open',
{ value0: hostedReviewCreateCopy.titleLabel }
),
{
action: {
label: translate(
'auto.components.right.sidebar.SourceControl.812cb992ee',
'Open on {{value0}}',
{ value0: hostedReviewCreateCopy.providerName }
),
onClick: () => window.api.shell.openUrl(result.existingReview!.url)
}
}
)
if (number) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url: result.existingReview.url
})
return
}
}
// Why: stacked creation can create the pull request and still fail to register
// the stack. Link the review that exists before surfacing the stack failure, or
// the workspace stays unaware of a PR the user can already see on GitHub.
if ('createdReview' in result && result.createdReview?.url) {
const { number, url } = result.createdReview
if (number) {
await handlePullRequestCreated({
provider: hostedReviewCreateProvider,
number,
url
})
}
}
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message: result.error
})
} catch (error) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, {
tone: 'destructive',
message:
error instanceof Error
? error.message
: translate(
'auto.components.right.sidebar.SourceControl.e2b7a1c0d9f4',
'Failed to create {{value0}}',
{ value0: hostedReviewCreateCopy.reviewLabel }
)
})
} finally {
createPrInFlightRef.current[activeWorktreeId] = false
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
}
},
[
activeRepo,
activeWorktreeId,
branchName,
createHostedReview,
createStackedHostedReview,
handlePullRequestCreated,
hostedReviewCreation,
hostedReviewCreateCopy.providerName,
hostedReviewCreateCopy.reviewLabel,
hostedReviewCreateCopy.titleLabel,
hostedReviewCreateProvider,
prBase,
prBody,
prDraft,
prGenerating,
prTitle,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
setCreatePrIntentNoticeForWorktree,
worktreePath
]
)
const createHostedReviewForCreatePrIntent = useCallback(
async (
@@ -5810,9 +5847,11 @@ function SourceControlInner(): React.JSX.Element {
{shouldRenderCommitArea(unresolvedConflicts.length, conflictOperation) &&
(directCreatePrAction ? (
<CreateHostedReviewComposer
key={`${activeRepo?.id ?? ''}:${activeWorktreeId ?? worktreePath ?? ''}:${branchName}`}
provider={hostedReviewCreateProvider}
branch={branchName}
base={prBase}
repoDefaultBase={prRepoDefaultBaseRef}
setBase={setPrBase}
title={prTitle}
setTitle={setPrTitle}
@@ -5820,10 +5859,13 @@ function SourceControlInner(): React.JSX.Element {
setBody={setPrBody}
draft={prDraft}
setDraft={setPrDraft}
stackedCreationSupported={prStackedCreationSupported}
stackParentReview={stackParentReview}
baseQuery={prBaseQuery}
setBaseQuery={setPrBaseQuery}
baseResults={prBaseResults}
setBaseResults={setPrBaseResults}
baseSearchPending={prBaseSearchPending}
baseSearchError={prBaseSearchError}
aiGenerationEnabled={sourceControlAiActionsVisible && prAiGenerationEnabled}
generating={prGenerating}
@@ -5838,8 +5880,8 @@ function SourceControlInner(): React.JSX.Element {
dropdownItems={dropdownItems}
onGenerate={handleGeneratePullRequestFieldsClick}
onCancelGenerate={handleCancelGeneratePullRequestFields}
onPrimaryAction={() => {
void handleCreatePullRequest()
onPrimaryAction={(stacked) => {
void handleCreatePullRequest(stacked)
}}
onDropdownAction={handleActionInvoke}
/>
@@ -0,0 +1,54 @@
import { translate } from '@/i18n/i18n'
/** Resolves the composer's primary-action label for the current submit shape. */
export function getCreateButtonLabel({
isCreating,
pushBeforeCreate,
draft,
stacked,
shortLabel
}: {
isCreating: boolean
pushBeforeCreate: boolean
draft: boolean
stacked: boolean
shortLabel: string
}): string {
if (isCreating) {
return translate('auto.components.right.sidebar.SourceControl.26511c22b4', 'Creating...')
}
if (pushBeforeCreate && stacked) {
return translate(
'auto.components.right.sidebar.create.hosted.review.button.label.96ae7358e0',
'Push & Create PR in stack'
)
}
if (pushBeforeCreate) {
return translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.741ff8a0d2',
'Push & Create {{value0}}',
{ value0: shortLabel }
)
}
if (stacked) {
return draft
? translate(
'auto.components.right.sidebar.create.hosted.review.button.label.8e8149a0bf',
'Create draft PR in stack'
)
: translate(
'auto.components.right.sidebar.create.hosted.review.button.label.8df1a05952',
'Create PR in stack'
)
}
if (draft) {
return translate(
'auto.components.right.sidebar.SourceControl.aaf1451654',
'Create draft {{value0}}',
{ value0: shortLabel }
)
}
return translate('auto.components.right.sidebar.SourceControl.5acbcedc1a', 'Create {{value0}}', {
value0: shortLabel
})
}
@@ -0,0 +1,13 @@
// Why: no padding here — a field that adds `pr-*` for a trailing icon would tie with
// `px-*` on specificity and lose, so each field sets its own. Why `md:text-xs`: the
// Input primitive steps up to 14px at md, too loud for a sidebar full of branch refs.
export const COMPOSER_FIELD_CLASS = 'h-8 text-xs md:text-xs'
// Why: mirrors the Input primitive's skin so the description can't drift from the
// title and base fields it sits between; there is no Textarea primitive to extend.
export const COMPOSER_TEXTAREA_CLASS =
'w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-2 py-1.5 text-xs shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30'
// Why: the Checkbox primitive's default hairline is tuned for card/popover surfaces;
// on the sidebar it needs the same border token the fields use to stay visible.
export const COMPOSER_CHECKBOX_CLASS = 'shrink-0 border-input'
@@ -1,4 +1,7 @@
import type { CreateHostedReviewResult } from '../../../../shared/hosted-review'
import type {
CreateHostedReviewResult,
CreateStackedHostedReviewResult
} from '../../../../shared/hosted-review'
import { translate } from '@/i18n/i18n'
export type { LocalizedHostedReviewCopy as CreatePullRequestReviewCopy } from '@/i18n/hosted-review-localized-copy'
@@ -6,7 +9,7 @@ export type { LocalizedHostedReviewCopy as CreatePullRequestReviewCopy } from '@
export { localizedHostedReviewCopy as reviewCopy } from '@/i18n/hosted-review-localized-copy'
export function formatCreateError(
result: CreateHostedReviewResult,
result: CreateHostedReviewResult | CreateStackedHostedReviewResult,
pushed: boolean,
shortLabel: string
): string {
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
const getRuntimeRepoBaseRefDefault = vi.fn()
vi.mock('@/runtime/runtime-repo-client', () => ({
getRuntimeRepoBaseRefDefault: (...args: unknown[]) => getRuntimeRepoBaseRefDefault(...args),
searchRuntimeRepoBaseRefDetails: vi.fn(async () => [])
}))
const { useCreatePullRequestDialogFields } = await import('./useCreatePullRequestDialogFields')
type DialogFields = ReturnType<typeof useCreatePullRequestDialogFields>
function eligibilityFor(defaultBaseRef: string): HostedReviewCreationEligibility {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
reviewLookupOutcome: 'not_found',
defaultBaseRef,
title: 'Review title',
body: 'Review body'
}
}
function renderFields(initialRepoId: string): {
current: () => DialogFields
switchRepo: (repoId: string, defaultBaseRef: string) => Promise<void>
unmount: () => void
} {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root: Root = createRoot(container)
let latest: DialogFields | null = null
let repoId = initialRepoId
let eligibility = eligibilityFor('refs/remotes/origin/main')
function Harness(): null {
latest = useCreatePullRequestDialogFields({
open: true,
repoId,
worktreeId: `wt-${repoId}`,
worktreePath: '/repo/wt',
branch: 'feature/child',
eligibility,
settings: null,
submitting: false
})
return null
}
const render = async (): Promise<void> => {
await act(async () => {
root.render(React.createElement(Harness))
await Promise.resolve()
await Promise.resolve()
})
}
return {
current: () => {
if (!latest) {
throw new Error('dialog fields were not rendered')
}
return latest
},
switchRepo: async (nextRepoId, defaultBaseRef) => {
repoId = nextRepoId
eligibility = eligibilityFor(defaultBaseRef)
await render()
},
unmount: () => {
act(() => root.unmount())
container.remove()
}
}
}
beforeEach(() => {
getRuntimeRepoBaseRefDefault.mockReset()
})
describe('useCreatePullRequestDialogFields repo default base ref', () => {
it('reports the repo default branch, not the worktree base from eligibility', async () => {
getRuntimeRepoBaseRefDefault.mockResolvedValue({
defaultBaseRef: 'refs/remotes/origin/main',
remoteCount: 1
})
const harness = renderFields('repo-1')
try {
await harness.switchRepo('repo-1', 'refs/remotes/origin/feature/parent')
expect(harness.current().repoDefaultBaseRef).toBe('main')
// Eligibility still drives the field itself; only the "is this the repo
// default?" answer comes from the repo lookup.
expect(harness.current().base).toBe('feature/parent')
} finally {
harness.unmount()
}
})
it('probes the repo default once per repo', async () => {
getRuntimeRepoBaseRefDefault.mockResolvedValue({
defaultBaseRef: 'refs/remotes/origin/main',
remoteCount: 1
})
const harness = renderFields('repo-1')
try {
await harness.switchRepo('repo-1', 'refs/remotes/origin/feature/parent')
await harness.switchRepo('repo-1', 'refs/remotes/origin/feature/other')
expect(getRuntimeRepoBaseRefDefault).toHaveBeenCalledTimes(1)
expect(harness.current().repoDefaultBaseRef).toBe('main')
} finally {
harness.unmount()
}
})
it('drops a previous repo default instead of applying it to the next repo', async () => {
getRuntimeRepoBaseRefDefault.mockResolvedValue({
defaultBaseRef: 'refs/remotes/origin/main',
remoteCount: 1
})
const harness = renderFields('repo-1')
try {
await harness.switchRepo('repo-1', 'refs/remotes/origin/main')
expect(harness.current().repoDefaultBaseRef).toBe('main')
getRuntimeRepoBaseRefDefault.mockReturnValue(new Promise(() => {}))
await harness.switchRepo('repo-2', 'refs/remotes/origin/trunk')
expect(harness.current().repoDefaultBaseRef).toBeNull()
} finally {
harness.unmount()
}
})
})
@@ -166,8 +166,14 @@ export function useCreatePullRequestDialogFields({
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [draft, setDraft] = useState(false)
// Why: stamped with the repo it came from — this hook outlives a repo switch, and a
// previous repo's default branch would silently suppress the stacked-PR lookup.
const [repoDefault, setRepoDefault] = useState<{ repoId: string; baseRef: string } | null>(null)
const [baseQuery, setBaseQuery] = useState('')
const [baseResults, setBaseResults] = useState<string[]>([])
// Why: lets the picker withhold "no branches match" until a search settles, so
// the debounce and an SSH round-trip can't read as an observed absence.
const [baseSearchPending, setBaseSearchPending] = useState(false)
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
const [generating, setGenerating] = useState(false)
const [generateError, setGenerateError] = useState<string | null>(null)
@@ -397,31 +403,46 @@ export function useCreatePullRequestDialogFields({
const effectiveGenerating = generation?.generating ?? generating
const effectiveGenerateError = generation?.generateError ?? generateError
const repoDefaultBaseRef = repoDefault?.repoId === repoId ? repoDefault.baseRef : null
// Why: resolved separately from eligibility's defaultBaseRef, which reports the
// worktree's own base. Consumers that need "is this the repo's default branch?"
// must ask this one, not that one.
useEffect(() => {
if (!open || base) {
// Why: the repo default doesn't move while a repo stays open, so skip the probe
// once it is known — on a remote runtime it is an RPC round-trip per composer open.
if (!open || repoDefaultBaseRef) {
return
}
let stale = false
void getRuntimeRepoBaseRefDefault(settings, repoId)
.then((result) => {
if (!stale && result.defaultBaseRef) {
setBase(stripBaseRef(result.defaultBaseRef))
setRepoDefault({ repoId, baseRef: stripBaseRef(result.defaultBaseRef) })
}
})
.catch(() => undefined)
return () => {
stale = true
}
}, [base, open, repoId, settings])
}, [open, repoDefaultBaseRef, repoId, settings])
useEffect(() => {
if (!open || base || !repoDefaultBaseRef) {
return
}
setBase(repoDefaultBaseRef)
}, [base, open, repoDefaultBaseRef])
useEffect(() => {
if (!open || baseQuery.trim().length < 2) {
setBaseResults([])
setBaseSearchPending(false)
setBaseSearchError(null)
return
}
let stale = false
setBaseSearchPending(true)
const timer = window.setTimeout(() => {
void searchRuntimeRepoBaseRefDetails(settings, repoId, baseQuery.trim(), 20)
.then((results) => {
@@ -436,6 +457,11 @@ export function useCreatePullRequestDialogFields({
setBaseSearchError('Branch discovery failed.')
}
})
.finally(() => {
if (!stale) {
setBaseSearchPending(false)
}
})
}, 200)
return () => {
stale = true
@@ -608,12 +634,15 @@ export function useCreatePullRequestDialogFields({
setBody: setUserBody,
draft,
setDraft: setUserDraft,
stackedCreationSupported: eligibility?.stackedCreationSupported === true,
repoDefaultBaseRef,
fieldRevisions: fieldRevisionsRef.current,
applyGeneratedFields,
baseQuery,
setBaseQuery,
baseResults,
setBaseResults,
baseSearchPending,
baseSearchError,
generating: effectiveGenerating,
generateError: effectiveGenerateError,
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { useHostedReviewStackParent } from './useHostedReviewStackParent'
function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 13741,
title: 'Parent review',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/13741',
status: 'success',
updatedAt: '2026-08-11T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
const baseOptions = {
enabled: true,
repoPath: '/repo/orca',
repoId: 'repo-1',
base: 'feature/parent',
repoDefaultBase: 'main',
head: 'feature/child'
}
afterEach(() => {
vi.useRealTimers()
})
describe('useHostedReviewStackParent', () => {
it('debounces the active branch lookup and preserves repo routing context', async () => {
vi.useFakeTimers()
const fetchHostedReviewForBranch = vi.fn(async () => makeReview())
const { result } = renderHook(() =>
useHostedReviewStackParent({ ...baseOptions, fetchHostedReviewForBranch })
)
await act(async () => vi.advanceTimersByTime(299))
expect(fetchHostedReviewForBranch).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTime(1))
expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo/orca', 'feature/parent', {
repoId: 'repo-1',
active: true
})
expect(result.current).toEqual({
number: 13741,
url: 'https://github.com/stablyai/orca/pull/13741'
})
})
it('looks up the worktree base branch a child forked from', async () => {
vi.useFakeTimers()
const fetchHostedReviewForBranch = vi.fn(async () => makeReview())
const { result } = renderHook(() =>
useHostedReviewStackParent({
...baseOptions,
// The worktree was created off feature/parent, so eligibility reports it as
// the default base; the repo default is still main and stacking still applies.
base: 'feature/parent',
repoDefaultBase: 'main',
fetchHostedReviewForBranch
})
)
await act(async () => vi.runAllTimers())
expect(fetchHostedReviewForBranch).toHaveBeenCalledTimes(1)
expect(result.current?.number).toBe(13741)
})
it.each([
{ base: 'origin/main', repoDefaultBase: 'main', head: 'feature/child' },
{ base: 'refs/heads/feature/child', repoDefaultBase: 'main', head: 'feature/child' },
{ base: '', repoDefaultBase: 'main', head: 'feature/child' }
])('skips ineligible base $base', async (options) => {
vi.useFakeTimers()
const fetchHostedReviewForBranch = vi.fn(async () => makeReview())
renderHook(() =>
useHostedReviewStackParent({
...baseOptions,
...options,
fetchHostedReviewForBranch
})
)
await act(async () => vi.runAllTimers())
expect(fetchHostedReviewForBranch).not.toHaveBeenCalled()
})
it.each([
makeReview({ provider: 'gitlab' }),
makeReview({ state: 'closed' }),
makeReview({ state: 'merged' })
])('rejects a non-open GitHub review', async (review) => {
vi.useFakeTimers()
const fetchHostedReviewForBranch = vi.fn(async () => review)
const { result } = renderHook(() =>
useHostedReviewStackParent({ ...baseOptions, fetchHostedReviewForBranch })
)
await act(async () => vi.runAllTimers())
expect(result.current).toBeNull()
})
it.each(['open', 'draft'] as const)('accepts a %s GitHub review', async (state) => {
vi.useFakeTimers()
const fetchHostedReviewForBranch = vi.fn(async () => makeReview({ state }))
const { result } = renderHook(() =>
useHostedReviewStackParent({ ...baseOptions, fetchHostedReviewForBranch })
)
await act(async () => vi.runAllTimers())
expect(result.current?.number).toBe(13741)
})
it('ignores a stale result after the base changes', async () => {
vi.useFakeTimers()
let resolveFirst: (review: HostedReviewInfo) => void = () => undefined
let resolveSecond: (review: HostedReviewInfo) => void = () => undefined
const first = new Promise<HostedReviewInfo>((resolve) => {
resolveFirst = resolve
})
const second = new Promise<HostedReviewInfo>((resolve) => {
resolveSecond = resolve
})
const fetchHostedReviewForBranch = vi.fn((_repoPath: string, branch: string) =>
branch === 'feature/first' ? first : second
)
const { result, rerender } = renderHook(
({ base }) =>
useHostedReviewStackParent({ ...baseOptions, base, fetchHostedReviewForBranch }),
{ initialProps: { base: 'feature/first' } }
)
await act(async () => vi.advanceTimersByTime(300))
rerender({ base: 'feature/second' })
await act(async () => vi.advanceTimersByTime(300))
await act(async () => resolveSecond(makeReview({ number: 22, url: 'https://example.test/22' })))
expect(result.current?.number).toBe(22)
await act(async () => resolveFirst(makeReview({ number: 11, url: 'https://example.test/11' })))
expect(result.current?.number).toBe(22)
})
})
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs'
export type HostedReviewStackParent = Pick<HostedReviewInfo, 'number' | 'url'>
type FetchHostedReviewForBranch = (
repoPath: string,
branch: string,
options?: { repoId?: string; active?: boolean }
) => Promise<HostedReviewInfo | null>
type UseHostedReviewStackParentOptions = {
enabled: boolean
repoPath: string
repoId?: string | null
base: string
/**
* The repo's own default branch — not the worktree's base. Stacking on the
* repo default is meaningless, so that one case skips the lookup; every other
* base still gets one, including the worktree base a child branch forked from
* (the case stacking exists for).
*/
repoDefaultBase?: string | null
head: string
fetchHostedReviewForBranch: FetchHostedReviewForBranch
}
type SettledLookup = {
key: string
review: HostedReviewStackParent | null
}
const LOOKUP_DEBOUNCE_MS = 300
export function useHostedReviewStackParent({
enabled,
repoPath,
repoId,
base,
repoDefaultBase,
head,
fetchHostedReviewForBranch
}: UseHostedReviewStackParentOptions): HostedReviewStackParent | null {
const normalizedBase = normalizeHostedReviewBaseRef(base).trim()
const normalizedDefault = normalizeHostedReviewBaseRef(repoDefaultBase ?? '').trim()
const normalizedHead = normalizeHostedReviewBaseRef(head).trim()
const canLookup =
enabled &&
repoPath.length > 0 &&
normalizedBase.length > 0 &&
normalizedBase.toLowerCase() !== normalizedDefault.toLowerCase() &&
normalizedBase.toLowerCase() !== normalizedHead.toLowerCase()
const lookupKey = canLookup ? `${repoId ?? repoPath}:${normalizedBase}` : null
const [settled, setSettled] = useState<SettledLookup | null>(null)
useEffect(() => {
if (!lookupKey) {
return
}
let cancelled = false
const timer = setTimeout(() => {
void fetchHostedReviewForBranch(repoPath, normalizedBase, {
...(repoId ? { repoId } : {}),
active: true
}).then(
(review) => {
if (cancelled) {
return
}
const openGitHubReview =
review?.provider === 'github' && (review.state === 'open' || review.state === 'draft')
? { number: review.number, url: review.url }
: null
setSettled({ key: lookupKey, review: openGitHubReview })
},
() => {
if (!cancelled) {
setSettled({ key: lookupKey, review: null })
}
}
)
}, LOOKUP_DEBOUNCE_MS)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [fetchHostedReviewForBranch, lookupKey, normalizedBase, repoId, repoPath])
return lookupKey && settled?.key === lookupKey ? settled.review : null
}
+22
View File
@@ -11735,6 +11735,17 @@
}
}
}
},
"hosted": {
"review": {
"button": {
"label": {
"96ae7358e0": "Push & Create PR in stack",
"8e8149a0bf": "Create draft PR in stack",
"8df1a05952": "Create PR in stack"
}
}
}
}
},
"AiVaultPanel": {
@@ -12052,6 +12063,17 @@
"e3ee2daa32": "Stack #{{value0}}",
"cb440931b7": "{{value0}} of {{value1}} · {{value2}}",
"525259fa17": "Stack details are temporarily unavailable."
},
"CreateHostedReviewBasePicker": {
"205ef284fa": "Base branch",
"bb4b41d563": "from {{value0}}",
"5a9315b61a": "No branches match “{{value0}}”. Press Enter to use it anyway.",
"da4d57c9c2": "Leave empty to use {{value0}}."
},
"CreateHostedReviewComposerFields": {
"90cabf6cfc": "Stack this PR above #{{value0}}",
"ff81473a57": "Creates a GitHub Stack or extends the parent's existing stack.",
"29732f2fb0": "new PR"
}
}
},
@@ -27,7 +27,8 @@ const mockApi = {
hostedReview: {
forBranch: vi.fn(),
getCreationEligibility: vi.fn(),
create: vi.fn()
create: vi.fn(),
createStacked: vi.fn()
}
}
@@ -41,6 +42,7 @@ function makeStore(settings: AppState['settings'] = null) {
| 'fetchHostedReviewForBranch'
| 'getHostedReviewCreationEligibility'
| 'createHostedReview'
| 'createStackedHostedReview'
| 'settings'
| 'repos'
| 'prCache'
@@ -80,6 +82,7 @@ describe('hosted review slice', () => {
mockApi.hostedReview.forBranch.mockReset()
mockApi.hostedReview.getCreationEligibility.mockReset()
mockApi.hostedReview.create.mockReset()
mockApi.hostedReview.createStacked.mockReset()
runtimeRpc.callRuntimeRpc.mockReset()
})
@@ -388,6 +391,35 @@ describe('hosted review slice', () => {
})
})
it('routes stacked pull request creation through its dedicated IPC method', async () => {
mockApi.hostedReview.createStacked.mockResolvedValueOnce({
ok: true,
number: 42,
url: 'https://github.com/acme/orca/pull/42',
stackNumber: 50,
parentReview: { number: 41, url: 'https://github.com/acme/orca/pull/41' }
})
const store = makeStore()
await store.getState().createStackedHostedReview('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child',
worktreePath: '/worktrees/child'
})
expect(mockApi.hostedReview.createStacked).toHaveBeenCalledWith(
expect.objectContaining({
repoPath: '/repo',
repoId: 'repo-1',
base: 'stack/parent',
head: 'stack/child'
})
)
expect(mockApi.hostedReview.create).not.toHaveBeenCalled()
})
it('forwards SSH connectionId when checking pull request creation eligibility', async () => {
mockApi.hostedReview.getCreationEligibility.mockResolvedValueOnce({
provider: 'github',
@@ -490,6 +522,39 @@ describe('hosted review slice', () => {
)
})
it('uses a distinct runtime method for stacked pull request creation', async () => {
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
ok: true,
number: 42,
url: 'https://github.com/acme/orca/pull/42',
stackNumber: 50,
parentReview: { number: 41, url: 'https://github.com/acme/orca/pull/41' }
})
const store = makeStore({
activeRuntimeEnvironmentId: 'env-win'
} as AppState['settings'])
await store.getState().createStackedHostedReview('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child',
worktreePath: 'C:\\worktrees\\child'
})
expect(runtimeRpc.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-win' },
'hostedReview.createStacked',
expect.objectContaining({
repo: 'repo-1',
worktree: 'path:C:\\worktrees\\child',
base: 'stack/parent',
head: 'stack/child'
}),
{ timeoutMs: 90_000 }
)
})
it('uses the selected worktree selector for runtime pull request creation eligibility', async () => {
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce({
provider: 'github',
@@ -4,6 +4,8 @@ import type { StateCreator } from 'zustand'
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
@@ -39,6 +41,9 @@ type FetchOptions = {
active?: boolean
}
type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null }
type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & {
repoId?: string | null
}
const CACHE_TTL_MS = 60_000
const HOSTED_REVIEW_CACHE_MAX = 500
@@ -242,6 +247,10 @@ export type HostedReviewSlice = {
repoPath: string,
input: CreateHostedReviewStoreInput
) => Promise<CreateHostedReviewResult>
createStackedHostedReview: (
repoPath: string,
input: CreateStackedHostedReviewStoreInput
) => Promise<CreateStackedHostedReviewResult>
fetchHostedReviewForBranch: (
repoPath: string,
branch: string,
@@ -339,6 +348,33 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
})
},
createStackedHostedReview: async (repoPath, input) => {
const settings = get().settings
const repo = findHostedReviewRepoByPath(get().repos, repoPath, input.repoId)
const ownerSettings = settingsForHostedReviewActionOwner(settings, repo)
const target = getActiveRuntimeTarget(ownerSettings)
const { repoId: inputRepoId, ...hostedReviewInput } = input
if (target.kind === 'environment') {
const { worktreePath, ...runtimeInput } = hostedReviewInput
return callRuntimeRpc<CreateStackedHostedReviewResult>(
target,
'hostedReview.createStacked',
{
repo: repo?.id ?? repoPath,
...(worktreePath ? { worktree: `path:${worktreePath}` } : {}),
...runtimeInput
},
{ timeoutMs: 90_000 }
)
}
return window.api.hostedReview.createStacked({
repoPath,
repoId: repo?.id ?? inputRepoId ?? undefined,
connectionId: repo?.connectionId ?? null,
...hostedReviewInput
})
},
fetchHostedReviewForBranch: async (
repoPath,
branch,
+25
View File
@@ -94,6 +94,14 @@ export type CreateHostedReviewArgs = CreateHostedReviewInput & {
connectionId?: string | null
}
export type CreateStackedHostedReviewInput = CreateHostedReviewInput
export type CreateStackedHostedReviewArgs = CreateStackedHostedReviewInput & {
repoPath: string
repoId?: string
connectionId?: string | null
}
export type CreateHostedReviewErrorCode =
| 'auth_required'
| 'unsupported_provider'
@@ -113,6 +121,21 @@ export type CreateHostedReviewResult =
existingReview?: HostedReviewSummary
}
export type CreateStackedHostedReviewResult =
| {
ok: true
number: number
url: string
stackNumber: number
parentReview: HostedReviewSummary
}
| {
ok: false
code: CreateHostedReviewErrorCode
error: string
createdReview?: HostedReviewSummary
}
export type HostedReviewCreationBlockedReason =
| 'dirty'
| 'detached_head'
@@ -158,6 +181,8 @@ export type HostedReviewCreationEligibility = {
head?: string | null
title?: string | null
body?: string | null
/** Present only when the executing host supports GitHub stack creation. */
stackedCreationSupported?: boolean
}
export type HostedReviewCreationEligibilityArgs = {