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

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

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

Verified end to end against real GitHub: extending an existing stack and creating a new one.
This commit is contained in:
Jinwoo Hong
2026-08-11 15:48:57 -07:00
committed by GitHub
parent 63271a5933
commit 077f5a11cd
34 changed files with 2876 additions and 538 deletions
@@ -0,0 +1,52 @@
import type { HostedReviewSummary } from '../../shared/hosted-review'
export type NumberedHostedReviewSummary = Omit<HostedReviewSummary, 'number'> & { number: number }
export type GitHubStackPullRequest = NumberedHostedReviewSummary & {
headRefName: string
baseRefName: string
}
export type GitHubStack = {
number: number
open: boolean
pull_requests: { number: number }[]
}
export function parseGitHubStackPullRequests(stdout: string): GitHubStackPullRequest[] {
const pullRequests = JSON.parse(stdout) as {
number?: unknown
html_url?: unknown
head?: { ref?: unknown }
base?: { ref?: unknown }
}[]
return pullRequests.flatMap((pullRequest) => {
const number = Number(pullRequest.number)
const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : ''
const headRefName = typeof pullRequest.head?.ref === 'string' ? pullRequest.head.ref : ''
const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : ''
return Number.isInteger(number) && number > 0 && url && headRefName && baseRefName
? [{ number, url, headRefName, baseRefName }]
: []
})
}
export function parseGitHubStacks(stdout: string): GitHubStack[] {
const stacks = JSON.parse(stdout) as {
number?: unknown
open?: unknown
pull_requests?: { number?: unknown }[]
}[]
return stacks.flatMap((stack) => {
const number = Number(stack.number)
const pullRequests = (stack.pull_requests ?? []).flatMap((pullRequest) => {
const pullRequestNumber = Number(pullRequest.number)
return Number.isInteger(pullRequestNumber) && pullRequestNumber > 0
? [{ number: pullRequestNumber }]
: []
})
return Number.isInteger(number) && number > 0
? [{ number, open: stack.open === true, pull_requests: pullRequests }]
: []
})
}
+273
View File
@@ -0,0 +1,273 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
repositoryMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) =>
context.connectionId ? {} : { cwd: context.repoPath },
githubRepoContext: (
repoPath: string,
connectionId?: string | null,
localGitOptions?: Record<string, unknown>
) => ({ repoPath, connectionId, localGitOptions })
}))
vi.mock('./github-api-repository', () => ({
getOriginGitHubApiRepository: repositoryMock,
githubHostExecOptions: (repository: { host?: string }) => ({ host: repository.host })
}))
import {
prepareGitHubStackedPullRequest,
registerGitHubStackedPullRequest
} from './stacked-pr-creation'
const repository = { owner: 'acme', repo: 'orca', host: 'github.com' }
const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' }
const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' }
function pullRequest(number: number, head: string, base: string) {
return {
number,
html_url: `https://github.com/acme/orca/pull/${number}`,
head: { ref: head },
base: { ref: base }
}
}
function stack(number: number, pullRequests: number[]) {
return {
number,
open: true,
pull_requests: pullRequests.map((pullRequestNumber) => ({ number: pullRequestNumber }))
}
}
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
repositoryMock.mockReset()
repositoryMock.mockResolvedValue(repository)
})
describe('prepareGitHubStackedPullRequest', () => {
it('resolves an open parent PR and an existing current PR', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')])
})
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) })
.mockResolvedValueOnce({ stdout: '[]' })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'origin/stack/parent',
head: 'refs/heads/stack/child',
title: 'Child'
})
expect(result).toMatchObject({
ok: true,
parentReview: { number: 41 },
currentReview: { number: 42 }
})
expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'repos/acme/orca/pulls?head=acme%3Astack%2Fparent&state=open&per_page=2'
])
expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'repos/acme/orca/pulls?head=acme%3Astack%2Fchild&base=stack%2Fparent&state=open&per_page=2'
])
})
it('allows an idempotent retry after the child was already registered', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')])
})
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: true, currentReview: { number: 42 } })
})
it('requires an open PR for the selected parent branch', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'feature/parent',
head: 'feature/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
if (!result.ok) {
expect(result.error).toContain('does not have an open pull request')
}
})
it('rejects a parent that is not the top of its stack', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 45])]) })
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
if (!result.ok) {
expect(result.error).toContain('top pull request')
}
})
it('does not offer stacks on GitHub Enterprise Server', async () => {
repositoryMock.mockResolvedValue({
owner: 'acme',
repo: 'orca',
host: 'github.acme.test'
})
const result = await prepareGitHubStackedPullRequest('/repo', {
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
})
expect(result).toMatchObject({ ok: false, code: 'validation' })
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
})
describe('registerGitHubStackedPullRequest', () => {
it('creates a new stack with the parent and current PR', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({ ok: true, number: 42, stackNumber: 50 })
expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([
'api',
'-X',
'POST',
'repos/acme/orca/stacks',
'-F',
'pull_requests[]=41',
'-F',
'pull_requests[]=42'
])
})
it('appends the current PR when the parent is the existing top', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [40, 41])]) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 50 }) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview,
connectionId: 'ssh-1'
})
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual([
'api',
'-X',
'POST',
'repos/acme/orca/stacks/50/add',
'-F',
'pull_requests[]=42'
])
expect(ghExecFileAsyncMock.mock.calls[2][1]).not.toHaveProperty('cwd')
})
it('treats an already registered parent-child pair as success', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not claim registration when the stack no longer holds the parent', async () => {
// A concurrent stack edit can drop the parent while the child sits at index 0.
// Reading index 0 off a findIndex miss would report that pair as registered.
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
error: 'The pull request already belongs to a different GitHub stack.',
createdReview: currentReview
})
})
it('preserves the created PR when registration fails', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockRejectedValueOnce(new Error('HTTP 422'))
const result = await registerGitHubStackedPullRequest({
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
createdReview: currentReview
})
})
})
+310
View File
@@ -0,0 +1,310 @@
import type {
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult
} from '../../shared/hosted-review'
import { isDefaultGitHubHost } from '../../shared/github-repository-identity-key'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils'
import {
getOriginGitHubApiRepository,
githubHostExecOptions,
type GitHubApiRepository
} from './github-api-repository'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import {
parseGitHubStackPullRequests,
parseGitHubStacks,
type GitHubStack,
type GitHubStackPullRequest,
type NumberedHostedReviewSummary
} from './github-stack-api-responses'
type StackedPullRequestPlan =
| {
ok: true
repository: GitHubApiRepository
parentReview: GitHubStackPullRequest
currentReview: GitHubStackPullRequest | null
}
| Extract<CreateStackedHostedReviewResult, { ok: false }>
function creationError(error: string): Extract<CreateStackedHostedReviewResult, { ok: false }> {
return { ok: false, code: 'validation', error }
}
function isStacksUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return message.includes('http 404') || message.includes('feature not supported')
}
function ghOptions(
repoPath: string,
repository: GitHubApiRepository,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
) {
return {
...ghRepoExecOptions(
githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
),
...githubHostExecOptions(repository),
timeout: 60_000
}
}
async function findOpenPullRequestsForBranch(
repoPath: string,
repository: GitHubApiRepository,
branch: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {},
base?: string
): Promise<GitHubStackPullRequest[]> {
const head = encodeURIComponent(`${repository.owner}:${branch}`)
const baseQuery = base ? `&base=${encodeURIComponent(base)}` : ''
const endpoint = `repos/${repository.owner}/${repository.repo}/pulls?head=${head}${baseQuery}&state=open&per_page=2`
const { stdout } = await ghExecFileAsync(
['api', endpoint],
ghOptions(repoPath, repository, connectionId, options)
)
return parseGitHubStackPullRequests(stdout)
}
async function getStacksForPullRequest(
repoPath: string,
repository: GitHubApiRepository,
pullRequestNumber: number,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<GitHubStack[]> {
const endpoint = `repos/${repository.owner}/${repository.repo}/stacks?pull_request=${pullRequestNumber}`
const { stdout } = await ghExecFileAsync(
['api', endpoint],
ghOptions(repoPath, repository, connectionId, options)
)
return parseGitHubStacks(stdout)
}
function validateParentStack(
parentReview: NumberedHostedReviewSummary,
stacks: GitHubStack[]
): Extract<CreateStackedHostedReviewResult, { ok: false }> | null {
if (stacks.length > 1) {
return creationError('The selected parent pull request belongs to multiple stacks.')
}
const stack = stacks[0]
if (!stack) {
return null
}
if (!stack.open) {
return creationError('The selected parent belongs to a closed stack.')
}
if (stack.pull_requests.at(-1)?.number !== parentReview.number) {
return creationError(
'Choose the top pull request in the stack as the base branch before adding another layer.'
)
}
return null
}
export async function prepareGitHubStackedPullRequest(
repoPath: string,
input: CreateStackedHostedReviewInput,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<StackedPullRequestPlan> {
if (input.provider !== 'github') {
return creationError('Stacked pull request creation is available only for GitHub repositories.')
}
const repository = await getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
if (!repository || !isDefaultGitHubHost(repository.host)) {
return creationError('GitHub stacked pull requests are available only on GitHub.com.')
}
const base = normalizeHostedReviewBaseRef(input.base).trim()
const head = input.head ? normalizeHostedReviewHeadRef(input.head).trim() : ''
if (!base || !head || base.toLowerCase() === head.toLowerCase()) {
return creationError('Choose a different parent branch before creating a stacked pull request.')
}
await acquire()
try {
const [parentPullRequests, currentPullRequests] = await Promise.all([
findOpenPullRequestsForBranch(repoPath, repository, base, connectionId, options),
findOpenPullRequestsForBranch(repoPath, repository, head, connectionId, options, base)
])
if (parentPullRequests.length === 0) {
return creationError(
`The parent branch ${base} does not have an open pull request. Create that pull request first.`
)
}
if (parentPullRequests.length !== 1) {
return creationError(`Orca found multiple open pull requests for the parent branch ${base}.`)
}
if (currentPullRequests.length > 1) {
return creationError(`Orca found multiple open pull requests for the current branch ${head}.`)
}
const parentReview = parentPullRequests[0]
const currentReview = currentPullRequests[0] ?? null
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(repoPath, repository, parentReview.number, connectionId, options),
currentReview
? getStacksForPullRequest(repoPath, repository, currentReview.number, connectionId, options)
: Promise.resolve([])
])
if (
currentReview &&
registeredStackNumber(parentReview, currentReview, parentStacks, currentStacks)
) {
return { ok: true, repository, parentReview, currentReview }
}
if (currentStacks.length > 0) {
return creationError('The pull request already belongs to a different GitHub stack.')
}
const parentError = validateParentStack(parentReview, parentStacks)
return (
parentError ?? {
ok: true,
repository,
parentReview,
currentReview
}
)
} catch (error) {
console.warn('GitHub stack creation preflight failed:', error)
return {
ok: false,
code: isStacksUnavailableError(error) ? 'validation' : 'unknown',
error: isStacksUnavailableError(error)
? 'GitHub stacked pull requests are not available for this repository.'
: 'Orca could not verify the parent pull request. Retry in a moment.'
}
} finally {
release()
}
}
function registeredStackNumber(
parentReview: NumberedHostedReviewSummary,
currentReview: NumberedHostedReviewSummary,
parentStacks: GitHubStack[],
currentStacks: GitHubStack[]
): number | null {
const parentStack = parentStacks[0]
const currentStack = currentStacks[0]
if (!parentStack || !currentStack || parentStack.number !== currentStack.number) {
return null
}
const parentPosition = parentStack.pull_requests.findIndex(
(pullRequest) => pullRequest.number === parentReview.number
)
// Why: a miss is -1, and -1 + 1 reads the first entry — which reports "already
// registered" whenever the current PR heads a stack the parent has left.
if (parentPosition < 0) {
return null
}
return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number
? parentStack.number
: null
}
export async function registerGitHubStackedPullRequest(args: {
repoPath: string
repository: GitHubApiRepository
parentReview: NumberedHostedReviewSummary
currentReview: NumberedHostedReviewSummary
connectionId?: string | null
options?: HostedReviewExecutionOptions
}): Promise<CreateStackedHostedReviewResult> {
const options = args.options ?? {}
await acquire()
try {
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(
args.repoPath,
args.repository,
args.parentReview.number,
args.connectionId,
options
),
getStacksForPullRequest(
args.repoPath,
args.repository,
args.currentReview.number,
args.connectionId,
options
)
])
const existingStackNumber = registeredStackNumber(
args.parentReview,
args.currentReview,
parentStacks,
currentStacks
)
if (existingStackNumber) {
return {
ok: true,
...args.currentReview,
stackNumber: existingStackNumber,
parentReview: args.parentReview
}
}
if (currentStacks.length > 0) {
return {
...creationError('The pull request already belongs to a different GitHub stack.'),
createdReview: args.currentReview
}
}
const parentError = validateParentStack(args.parentReview, parentStacks)
if (parentError) {
return { ...parentError, createdReview: args.currentReview }
}
const parentStack = parentStacks[0]
const endpoint = parentStack
? `repos/${args.repository.owner}/${args.repository.repo}/stacks/${parentStack.number}/add`
: `repos/${args.repository.owner}/${args.repository.repo}/stacks`
const pullRequests = parentStack
? [args.currentReview.number]
: [args.parentReview.number, args.currentReview.number]
const command = ['api', '-X', 'POST', endpoint]
for (const pullRequest of pullRequests) {
command.push('-F', `pull_requests[]=${pullRequest}`)
}
const { stdout } = await ghExecFileAsync(command, {
...ghOptions(args.repoPath, args.repository, args.connectionId, options),
idempotent: false
})
const stackNumber = Number((JSON.parse(stdout) as { number?: unknown }).number)
if (!Number.isInteger(stackNumber) || stackNumber <= 0) {
throw new Error('GitHub returned an invalid stack response.')
}
return {
ok: true,
...args.currentReview,
stackNumber,
parentReview: args.parentReview
}
} catch (error) {
console.warn('GitHub stack registration failed:', error)
return {
ok: false,
code: isStacksUnavailableError(error) ? 'validation' : 'unknown',
error: isStacksUnavailableError(error)
? 'The pull request was created, but GitHub stacks are not available for this repository.'
: 'The pull request was created, but GitHub could not add it to the stack. Retry to finish stack registration.',
createdReview: args.currentReview
}
} finally {
release()
}
}