mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat(github): create stacked pull requests
This commit is contained in:
@@ -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 }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
+25
-1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user