feat(github): create stacked pull requests

This commit is contained in:
Jinwoo-H
2026-08-11 01:17:20 -04:00
parent 1928ca501e
commit 291400ff4c
27 changed files with 1314 additions and 26 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 }]
: []
})
}
+252
View File
@@ -0,0 +1,252 @@
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('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
})
})
})
+305
View File
@@ -0,0 +1,305 @@
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
)
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',
@@ -6981,6 +6995,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({
@@ -6998,6 +7021,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 () => {
@@ -7060,6 +7093,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({
@@ -7088,6 +7130,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
@@ -670,6 +670,8 @@ import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-im
import type {
CreateHostedReviewInput,
CreateHostedReviewResult,
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
@@ -679,6 +681,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,
@@ -19644,6 +19647,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.
@@ -19,6 +19,8 @@ import {
import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation'
import { isGiteaReviewCreationAuthenticated } from '../gitea/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'
@@ -521,12 +523,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,
@@ -1979,6 +1981,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
@@ -1709,7 +1709,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.
@@ -474,6 +474,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'
@@ -1318,6 +1319,9 @@ export default function ChecksPanel(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
stacked: prStacked,
setStacked: setPrStacked,
stackedCreationSupported: prStackedCreationSupported,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
@@ -4029,7 +4033,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
pushed = true
}
const result = await createHostedReview(repo.path, {
const createInput = {
repoId: repo.id,
provider: hostedReviewCreateProvider,
base,
@@ -4039,7 +4043,10 @@ export default function ChecksPanel(): React.JSX.Element {
draft: prDraft,
worktreePath,
useTemplate: prCreationDefaults.useTemplate
})
}
const result = prStacked
? await createStackedHostedReview(repo.path, createInput)
: await createHostedReview(repo.path, createInput)
if (!isCurrentCreateRequest()) {
return
}
@@ -4060,7 +4067,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
return
}
if (result.existingReview?.url) {
if ('existingReview' in result && result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
@@ -4128,6 +4135,7 @@ export default function ChecksPanel(): React.JSX.Element {
branch,
createComposerOpen,
createHostedReview,
createStackedHostedReview,
createPrPushFirst,
handlePullRequestCreated,
hostedReviewCreateCopy.providerName,
@@ -4143,6 +4151,7 @@ export default function ChecksPanel(): React.JSX.Element {
prCreationDefaults.useTemplate,
prDraft,
prGenerating,
prStacked,
prTitle,
pushBeforeCreatePullRequest,
repo,
@@ -4300,6 +4309,9 @@ export default function ChecksPanel(): React.JSX.Element {
setBody={setPrBody}
draft={prDraft}
setDraft={setPrDraft}
stacked={prStacked}
setStacked={setPrStacked}
stackedCreationSupported={prStackedCreationSupported}
baseQuery={prBaseQuery}
setBaseQuery={setPrBaseQuery}
baseResults={prBaseResults}
@@ -50,6 +50,9 @@ export type CreateHostedReviewComposerProps = {
setBody: (value: string) => void
draft: boolean
setDraft: (value: boolean) => void
stacked: boolean
setStacked: (value: boolean) => void
stackedCreationSupported: boolean
baseQuery: string
setBaseQuery: (value: string) => void
baseResults: string[]
@@ -83,6 +86,9 @@ export function CreateHostedReviewComposer({
setBody,
draft,
setDraft,
stacked,
setStacked,
stackedCreationSupported,
baseQuery,
setBaseQuery,
baseResults,
@@ -229,6 +235,9 @@ export function CreateHostedReviewComposer({
setBody={setBody}
draft={draft}
setDraft={setDraft}
stacked={stacked}
setStacked={setStacked}
showStackedMode={provider === 'github' && stackedCreationSupported}
baseQuery={baseQuery}
setBaseQuery={setBaseQuery}
baseResults={baseResults}
@@ -266,6 +275,7 @@ export function CreateHostedReviewComposer({
isCreating,
pushBeforeCreate,
draft,
stacked,
shortLabel: copy.shortLabel
})}
</span>
@@ -335,16 +345,24 @@ 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.CreateHostedReviewComposer.pushCreateStackedPr',
'Push & Create stacked PR'
)
}
if (pushBeforeCreate) {
return translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.741ff8a0d2',
@@ -352,6 +370,17 @@ function getCreateButtonLabel({
{ value0: shortLabel }
)
}
if (stacked) {
return draft
? translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.createDraftStackedPr',
'Create draft stacked PR'
)
: translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.createStackedPr',
'Create stacked PR'
)
}
if (draft) {
return translate(
'auto.components.right.sidebar.SourceControl.aaf1451654',
@@ -1,7 +1,17 @@
import { ArrowDownUp, Check, ChevronDown, Sparkles, TriangleAlert } from 'lucide-react'
import {
ArrowDownUp,
Check,
ChevronDown,
CornerDownRight,
GitPullRequestArrow,
Layers3,
Sparkles,
TriangleAlert
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { LocalizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { stripBaseRef } from './useCreatePullRequestDialogFields'
type CreateHostedReviewComposerFieldsProps = {
@@ -14,6 +24,9 @@ type CreateHostedReviewComposerFieldsProps = {
setBody: (value: string) => void
draft: boolean
setDraft: (value: boolean) => void
stacked: boolean
setStacked: (value: boolean) => void
showStackedMode: boolean
baseQuery: string
setBaseQuery: (value: string) => void
baseResults: string[]
@@ -38,6 +51,9 @@ export function CreateHostedReviewComposerFields({
setBody,
draft,
setDraft,
stacked,
setStacked,
showStackedMode,
baseQuery,
setBaseQuery,
baseResults,
@@ -75,6 +91,41 @@ export function CreateHostedReviewComposerFields({
</span>
</div>
{showStackedMode ? (
<ToggleGroup
type="single"
value={stacked ? 'stacked' : 'regular'}
onValueChange={(value) => {
if (value) {
setStacked(value === 'stacked')
}
}}
variant="outline"
size="sm"
disabled={fieldsLocked}
aria-label={translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.reviewType',
'Pull request type'
)}
className="w-full"
>
<ToggleGroupItem value="regular" className="h-7 flex-1 text-[11px]">
<GitPullRequestArrow className="size-3" />
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.regularPr',
'Regular PR'
)}
</ToggleGroupItem>
<ToggleGroupItem value="stacked" className="h-7 flex-1 text-[11px]">
<Layers3 className="size-3" />
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.stackedPr',
'Stacked PR'
)}
</ToggleGroupItem>
</ToggleGroup>
) : null}
<div className="relative space-y-2">
<input
aria-label={translate(
@@ -159,6 +210,31 @@ export function CreateHostedReviewComposerFields({
</div>
</div>
{showStackedMode && stacked ? (
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5 text-[11px]">
<div className="flex min-w-0 items-center gap-1.5 text-foreground">
<GitPullRequestArrow className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-mono">{normalizedBase}</span>
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 pl-2 text-foreground">
<CornerDownRight className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate font-mono">{strippedBranch}</span>
<span className="shrink-0 text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.newPr',
'new PR'
)}
</span>
</div>
<p className="mt-1.5 text-muted-foreground">
{translate(
'auto.components.right.sidebar.CreateHostedReviewComposer.stackRequirement',
'The base branch must have an open PR and be the top of its stack.'
)}
</p>
</div>
) : null}
<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',
@@ -10,13 +10,17 @@ type RenderPullRequestComposerOptions = {
generating?: boolean
generateDisabled?: boolean
generateDisabledReason?: string
stacked?: boolean
stackedCreationSupported?: boolean
}
function renderPullRequestComposer({
aiGenerationEnabled = true,
generating = false,
generateDisabled = false,
generateDisabledReason
generateDisabledReason,
stacked = false,
stackedCreationSupported = true
}: RenderPullRequestComposerOptions = {}): string {
const sourceControlInputs = {
stagedCount: 1,
@@ -44,6 +48,9 @@ function renderPullRequestComposer({
setBody={vi.fn()}
draft={false}
setDraft={vi.fn()}
stacked={stacked}
setStacked={vi.fn()}
stackedCreationSupported={stackedCreationSupported}
baseQuery=""
setBaseQuery={vi.fn()}
baseResults={[]}
@@ -122,4 +129,21 @@ describe('CreateHostedReviewComposer generate tooltip', () => {
expect(button).toContain('data-slot="tooltip-trigger"')
expect(button).not.toContain('disabled=""')
})
it('shows the parent-child preview and stacked create action', () => {
const markup = renderPullRequestComposer({ stacked: true })
expect(markup).toContain('Stacked PR')
expect(markup).toContain('master')
expect(markup).toContain('branch-login-issue')
expect(markup).toContain('The base branch must have an open PR and be the top of its stack.')
expect(markup).toContain('Create stacked PR')
})
it('hides stacked creation when the executing host lacks the capability', () => {
const markup = renderPullRequestComposer({ stackedCreationSupported: false })
expect(markup).not.toContain('Stacked PR')
expect(markup).not.toContain('Regular PR')
})
})
@@ -910,6 +910,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)
@@ -3020,6 +3021,9 @@ function SourceControlInner(): React.JSX.Element {
setBody: setPrBody,
draft: prDraft,
setDraft: setPrDraft,
stacked: prStacked,
setStacked: setPrStacked,
stackedCreationSupported: prStackedCreationSupported,
baseQuery: prBaseQuery,
setBaseQuery: setPrBaseQuery,
baseResults: prBaseResults,
@@ -3335,7 +3339,7 @@ function SourceControlInner(): React.JSX.Element {
setCreatePrInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
try {
const result = await createHostedReview(activeRepo.path, {
const createInput = {
repoId: activeRepo.id,
provider: hostedReviewCreateProvider,
base,
@@ -3345,7 +3349,10 @@ function SourceControlInner(): React.JSX.Element {
draft: prDraft,
worktreePath,
useTemplate: resolvedPrCreationDefaults.useTemplate
})
}
const result = prStacked
? await createStackedHostedReview(activeRepo.path, createInput)
: await createHostedReview(activeRepo.path, createInput)
if (result.ok) {
setCreatePrIntentNoticeForWorktree(activeWorktreeId, null)
@@ -3360,7 +3367,7 @@ function SourceControlInner(): React.JSX.Element {
return
}
if (result.existingReview?.url) {
if ('existingReview' in result && result.existingReview?.url) {
const number = result.existingReview.number
toast.success(
number
@@ -3421,6 +3428,7 @@ function SourceControlInner(): React.JSX.Element {
activeWorktreeId,
branchName,
createHostedReview,
createStackedHostedReview,
handlePullRequestCreated,
hostedReviewCreation,
hostedReviewCreateCopy.providerName,
@@ -3431,6 +3439,7 @@ function SourceControlInner(): React.JSX.Element {
prBody,
prDraft,
prGenerating,
prStacked,
prTitle,
resolvedPrCreationDefaults.openAfterCreate,
resolvedPrCreationDefaults.useTemplate,
@@ -5827,6 +5836,9 @@ function SourceControlInner(): React.JSX.Element {
setBody={setPrBody}
draft={prDraft}
setDraft={setPrDraft}
stacked={prStacked}
setStacked={setPrStacked}
stackedCreationSupported={prStackedCreationSupported}
baseQuery={prBaseQuery}
setBaseQuery={setPrBaseQuery}
baseResults={prBaseResults}
@@ -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 {
@@ -166,6 +166,7 @@ export function useCreatePullRequestDialogFields({
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [draft, setDraft] = useState(false)
const [stacked, setStacked] = useState(false)
const [baseQuery, setBaseQuery] = useState('')
const [baseResults, setBaseResults] = useState<string[]>([])
const [baseSearchError, setBaseSearchError] = useState<string | null>(null)
@@ -314,6 +315,7 @@ export function useCreatePullRequestDialogFields({
setTitle(resolveCreateReviewDraftTitle({ branch, eligibilityTitle: eligibility.title }))
setBody(eligibility.body ?? '')
setDraft(resolvedPrDefaults.draft)
setStacked(false)
setBaseQuery('')
setBaseResults([])
setBaseSearchError(null)
@@ -608,6 +610,9 @@ export function useCreatePullRequestDialogFields({
setBody: setUserBody,
draft,
setDraft: setUserDraft,
stackedCreationSupported: eligibility?.stackedCreationSupported === true,
stacked: eligibility?.stackedCreationSupported === true && stacked,
setStacked,
fieldRevisions: fieldRevisionsRef.current,
applyGeneratedFields,
baseQuery,
+9 -1
View File
@@ -10647,7 +10647,15 @@
"db9cee18f7": "Create {{value0}}"
},
"CreateHostedReviewComposer": {
"741ff8a0d2": "Push & Create {{value0}}"
"741ff8a0d2": "Push & Create {{value0}}",
"pushCreateStackedPr": "Push & Create stacked PR",
"createDraftStackedPr": "Create draft stacked PR",
"createStackedPr": "Create stacked PR",
"reviewType": "Pull request type",
"regularPr": "Regular PR",
"stackedPr": "Stacked PR",
"newPr": "new PR",
"stackRequirement": "The base branch must have an open PR and be the top of its stack."
},
"CreatePullRequestGenerateButton": {
"4012459f8a": "Generate with AI",
@@ -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
@@ -80,6 +80,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'
@@ -99,6 +107,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'
@@ -144,6 +167,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 = {