mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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,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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user