feat(github): support stacked pull requests (#13730)

(cherry picked from commit 25f7870c58)
This commit is contained in:
Jinwoo Hong
2026-08-10 23:15:39 -07:00
committed by Jinjing
parent 2e3af8b1aa
commit 09635085d0
17 changed files with 2478 additions and 104 deletions
+581 -52
View File
@@ -91,7 +91,8 @@ vi.mock('./gh-utils', () => ({
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
gitExecFileAsync: gitExecFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
@@ -185,6 +186,7 @@ import {
_getTrackedUpstreamBranchCacheSizesForTests,
_resetOwnerRepoCache,
_resetMergeQueueCacheForTests,
_resetPRStackSummaryCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
@@ -192,6 +194,7 @@ import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-me
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
import { _resetGitHubPRStackCacheForTests } from './github-pr-stack'
// The origin-repository cache is module-level state; reset it so slugs
// resolved by one test cannot leak into the next.
@@ -272,6 +275,8 @@ describe('getPRForBranch', () => {
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
_resetPRStackSummaryCacheForTests()
_resetGitHubPRStackCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryCachesForTests()
resetMergedPRCommitMembershipCacheForTest()
@@ -430,7 +435,7 @@ describe('getPRForBranch', () => {
const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
@@ -452,6 +457,81 @@ describe('getPRForBranch', () => {
})
})
it('caches exact REST stack probes across linked PR refreshes', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'pr') {
return {
stdout: JSON.stringify({
number: 99,
title: 'Linked PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/99',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature',
headRefOid: 'head-oid'
})
}
}
return {
stdout: JSON.stringify({
number: 99,
title: 'Linked PR',
state: 'open',
stack: null
})
}
})
await getPRForBranch('/repo-root', 'feature', 99)
await getPRForBranch('/repo-root', 'feature', 99)
await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1')
expect(
ghExecFileAsyncMock.mock.calls.filter(
([args]) => args[0] === 'api' && args[1]?.includes('/99')
)
).toHaveLength(2)
expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3)
})
it('caches failed REST stack probes across linked PR refreshes', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'pr') {
return {
stdout: JSON.stringify({
number: 99,
title: 'Linked PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/99',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature',
headRefOid: 'head-oid'
})
}
}
throw new Error('GitHub is temporarily unavailable')
})
await getPRForBranch('/repo-root', 'feature', 99)
await getPRForBranch('/repo-root', 'feature', 99)
expect(
ghExecFileAsyncMock.mock.calls.filter(
([args]) => args[0] === 'api' && args[1]?.includes('/99')
)
).toHaveLength(1)
})
it('hydrates repository merge method settings for exact PR lookups', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
@@ -517,6 +597,54 @@ describe('getPRForBranch', () => {
)
})
it('isolates viewer-dependent merge metadata across SSH connections', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
let metadataProbe = 0
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('graphql')) {
metadataProbe += 1
return {
stdout: JSON.stringify({
data: {
repository: {
autoMergeAllowed: metadataProbe === 1,
mergeQueue: null
}
}
})
}
}
if (args[0] === 'pr') {
return {
stdout: JSON.stringify({
number: 99,
title: 'Linked PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/99',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
reviewDecision: 'APPROVED',
mergeStateStatus: 'CLEAN',
autoMergeRequest: null,
baseRefName: 'main',
headRefName: 'feature',
headRefOid: 'head-oid'
})
}
}
return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) }
})
const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1')
const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2')
expect(firstAccount?.autoMergeAllowed).toBe(true)
expect(secondAccount?.autoMergeAllowed).toBe(false)
expect(metadataProbe).toBe(2)
})
it('treats linked PR metadata as authoritative even when the branch head differs', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' })
@@ -558,7 +686,7 @@ describe('getPRForBranch', () => {
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(pr?.number).toBe(99)
})
@@ -1180,7 +1308,7 @@ describe('getPRForBranch', () => {
expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe(
undefined
)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('leaves linked merged divergence unset when the membership probe throws', async () => {
@@ -1222,7 +1350,7 @@ describe('getPRForBranch', () => {
expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe(
undefined
)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('prefers branch lookup over a fallback PR number', async () => {
@@ -2865,7 +2993,7 @@ describe('getPRForBranch', () => {
const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
@@ -4082,8 +4210,359 @@ describe('GitHub GraphQL rate-limit guard', () => {
)
})
it('returns GraphQL reaction subjects and viewer state for PR comments', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
comments: {
nodes: [
{
id: 'IC_1',
databaseId: 10,
author: { login: 'octo', avatarUrl: '', __typename: 'User' },
body: 'Issue comment',
createdAt: '2026-04-01T00:00:00Z',
url: 'https://example.test/issue-comment',
reactionGroups: [
{
content: 'THUMBS_UP',
viewerHasReacted: true,
reactors: { totalCount: 2 }
}
]
}
]
},
reviewThreads: {
nodes: [
{
id: 'PRRT_1',
isResolved: false,
line: 4,
startLine: null,
originalLine: 4,
originalStartLine: null,
comments: {
nodes: [
{
id: 'PRRC_1',
databaseId: 11,
author: { login: 'reviewer', avatarUrl: '', __typename: 'User' },
body: 'Inline comment',
createdAt: '2026-04-01T00:01:00Z',
url: 'https://example.test/review-comment',
path: 'src/app.ts',
reactionGroups: [
{
content: 'EYES',
viewerHasReacted: false,
reactors: { totalCount: 1 }
}
]
}
]
}
}
]
}
}
}
}
})
})
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
const comments = await getPRComments('/repo-root', 7, {
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' }
})
expect(comments).toEqual([
expect.objectContaining({
id: 10,
reactionSubjectId: 'IC_1',
reactions: [{ content: '+1', count: 2, viewerHasReacted: true }]
}),
expect.objectContaining({
id: 11,
reactionSubjectId: 'PRRC_1',
reactions: [{ content: 'eyes', count: 1, viewerHasReacted: false }]
})
])
})
it('adds and removes PR comment reactions through GraphQL', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '{}', stderr: '' })
await expect(setPRCommentReaction('/repo-root', 'IC_1', '+1', true)).resolves.toBe(true)
await expect(setPRCommentReaction('/repo-root', 'PRRC_1', 'eyes', false)).resolves.toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining(['subjectId=IC_1', 'content=THUMBS_UP']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[0]?.[0].join(' ')).toContain('addReaction')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['subjectId=PRRC_1', 'content=EYES']),
expect.any(Object)
)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0].join(' ')).toContain('removeReaction')
expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2)
})
it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Stack API',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/202',
statusCheckRollup: [],
updatedAt: '2026-08-10T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'stack/models',
headRefName: 'stack/api',
headRefOid: 'api-sha'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Stack API',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/202',
head: { ref: 'stack/api', sha: 'api-sha' },
base: { ref: 'stack/models', sha: 'models-sha' },
stack: {
number: 51,
position: 2,
size: 2,
base: { ref: 'main', sha: 'main-sha' }
}
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
stack: {
number: 51,
size: 2,
baseRefName: 'main',
entries: {
nodes: [
{
position: 1,
pullRequest: {
number: 201,
title: 'Stack models',
url: 'https://github.com/stablyai/orca/pull/201',
state: 'OPEN',
isDraft: false,
mergeable: 'MERGEABLE',
statusCheckRollup: { state: 'SUCCESS' }
}
},
{
position: 2,
pullRequest: {
number: 202,
title: 'Stack API',
url: 'https://github.com/stablyai/orca/pull/202',
state: 'OPEN',
isDraft: false,
mergeable: 'MERGEABLE',
statusCheckRollup: { state: 'SUCCESS' }
}
}
]
}
}
}
}
}
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
mergeQueue: null,
ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } }
}
}
})
})
const pr = await getPRForBranch('/repo-root', 'stack/api', 202)
expect(pr?.stack).toMatchObject({
number: 51,
position: 2,
size: 2,
baseRefName: 'main',
entries: [
{ number: 201, position: 1 },
{ number: 202, position: 2 }
]
})
expect(pr?.mergeQueueRequired).toBe(true)
const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find(
([args]) => args.includes('graphql') && args.includes('branch=main')
)
expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main']))
})
it('uses async merge only for GitHub-registered stacks', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Stack API',
state: 'open',
head: { ref: 'stack/api', sha: 'api-sha' },
base: { ref: 'stack/models', sha: 'models-sha' },
stack: {
number: 51,
position: 2,
size: 2,
base: { ref: 'main', sha: 'main-sha' }
}
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } })
})
await expect(
mergePR('/repo-root', 202, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca',
host: 'github.com'
})
).resolves.toEqual({ ok: true })
const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) =>
args.includes('repos/stablyai/orca/pulls/202/merge-async')
)
expect(mergeCall?.[0]).toEqual(
expect.arrayContaining([
'PUT',
'repos/stablyai/orca/pulls/202/merge-async',
'merge_action=merge_queue',
'sha=api-sha'
])
)
expect(mergeCall?.[0]).not.toContain('merge_method=squash')
expect(acquireMock).toHaveBeenCalledTimes(2)
expect(releaseMock).toHaveBeenCalledTimes(2)
expect(
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
).toBe(false)
})
it('never falls back to legacy merge after an async stack merge transport failure', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
state: 'open',
head: { ref: 'stack/api', sha: 'api-sha' },
base: { ref: 'stack/models', sha: 'models-sha' },
stack: {
number: 51,
position: 2,
size: 2,
base: { ref: 'main', sha: 'main-sha' }
}
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } })
})
.mockRejectedValueOnce(new Error('socket closed after request submission'))
await expect(
mergePR('/repo-root', 202, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca',
host: 'github.com'
})
).resolves.toEqual({ ok: false, error: 'socket closed after request submission' })
expect(
ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge')
).toBe(false)
})
it('keeps legacy merge for unregistered dependent PR chains', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Dependent API',
state: 'open',
head: { ref: 'feature/api', sha: 'api-sha' },
base: { ref: 'feature/models', sha: 'models-sha' },
stack: null
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Dependent API',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/202',
statusCheckRollup: [],
updatedAt: '2026-08-10T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'feature/models',
headRefOid: 'api-sha'
})
})
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(
mergePR('/repo-root', 202, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca',
host: 'github.com'
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) })
)
})
it('uses explicit PR repo for merge and title mutations', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'PR',
state: 'open',
head: { ref: 'feature', sha: 'head-oid' },
base: { ref: 'main', sha: 'base-oid' },
stack: null
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
@@ -4117,8 +4596,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
).resolves.toBe(true)
expect(getOwnerRepoMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], {
cwd: '/repo-root',
host: 'github.com'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
2,
[
'pr',
'view',
@@ -4131,7 +4614,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
cwd: '/repo-root',
@@ -4140,7 +4623,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
4,
['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'],
{ cwd: '/repo-root', host: 'github.com' }
)
@@ -4148,6 +4631,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
@@ -4168,13 +4652,16 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], {
host: 'github.com'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
2,
['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'],
{ host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
expect.arrayContaining([
'api',
'graphql',
@@ -4191,7 +4678,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
4,
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }),
@@ -4203,6 +4690,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
it('enables auto-merge without invoking the direct merge command', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
@@ -4229,8 +4717,39 @@ describe('GitHub GraphQL rate-limit guard', () => {
).toBe(false)
})
it('rejects auto-merge for GitHub-registered stacks', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 202,
title: 'Stack API',
state: 'open',
head: { ref: 'stack/api', sha: 'head-oid' },
base: { ref: 'stack/models', sha: 'models-sha' },
stack: {
number: 51,
position: 2,
size: 2,
base: { ref: 'main', sha: 'main-sha' }
}
})
})
await expect(
setPRAutoMerge('/repo-root', 202, true, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca',
host: 'github.com'
})
).resolves.toEqual({
ok: false,
error: 'GitHub does not support auto-merge for stacked pull requests.'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('translates the GitHub clean-status rejection into an actionable message', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
@@ -4250,6 +4769,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
})
@@ -4267,12 +4787,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']),
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
4,
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
cwd: '/repo-root',
@@ -4288,24 +4808,26 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
it('blocks direct merge when GitHub reports required approval', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'PR',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/7',
statusCheckRollup: [],
updatedAt: '2026-04-01T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
reviewDecision: 'REVIEW_REQUIRED',
mergeStateStatus: 'CLEAN',
autoMergeRequest: null,
baseRefName: 'main',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'PR',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/7',
statusCheckRollup: [],
updatedAt: '2026-04-01T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
reviewDecision: 'REVIEW_REQUIRED',
mergeStateStatus: 'CLEAN',
autoMergeRequest: null,
baseRefName: 'main',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
})
await expect(
mergePR('/repo-root', 7, 'squash', undefined, {
@@ -4318,8 +4840,8 @@ describe('GitHub GraphQL rate-limit guard', () => {
error: 'This pull request requires review approval before it can be merged.'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql')
})
it('detects merge queues once per base branch and blocks direct merges', async () => {
@@ -4340,10 +4862,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
headRefOid: 'head-oid'
}
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
})
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
await expect(
@@ -4364,10 +4888,10 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(
ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql'))
).toHaveLength(1)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual(
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual(
expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true'])
)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F')
expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F')
})
it('caches unknown merge queue probes after GraphQL failures', async () => {
@@ -4391,7 +4915,9 @@ describe('GitHub GraphQL rate-limit guard', () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
.mockRejectedValueOnce(new Error('network is down'))
.mockResolvedValueOnce({ stdout: '{}' })
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
.mockResolvedValueOnce({ stdout: '{}' })
await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({
mergeQueueRequired: null
@@ -4517,21 +5043,23 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
it('returns conflicting file details instead of running gh merge when PR is dirty', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'PR',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/7',
statusCheckRollup: [],
updatedAt: '2026-04-01T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
title: 'PR',
state: 'OPEN',
url: 'https://github.com/stablyai/orca/pull/7',
statusCheckRollup: [],
updatedAt: '2026-04-01T00:00:00Z',
isDraft: false,
mergeable: 'CONFLICTING',
baseRefName: 'main',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
})
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
@@ -4554,11 +5082,12 @@ describe('GitHub GraphQL rate-limit guard', () => {
'- src/conflict.ts'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not run merge conflict preflight for SSH-backed repos', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 7,
@@ -4584,9 +5113,9 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
+277 -27
View File
@@ -19,7 +19,8 @@ import type {
GitHubPullRequestStateUpdate,
GitHubRerunPRChecksResult,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings
GitHubPRMergeMethodSettings,
GitHubPRStack
} from '../../shared/types'
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
import {
@@ -118,6 +119,7 @@ import {
spendsSharedGitHubComQuota,
type RateLimitBucketKind
} from './rate-limit'
import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack'
type GhExecOptions = GitHubRepoExecOptions
type HostedReviewLocalGitOptions = ReturnType<typeof getHostedReviewLocalGitOptions>
@@ -156,11 +158,30 @@ const repositoryMergeMetadataCache = new Map<
string,
{ value: GitHubRepositoryMergeMetadata; expiresAt: number }
>()
const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000
const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512
const prStackSummaryCache = new Map<
string,
{ value: GitHubPRStack | undefined; expiresAt: number }
>()
const prStackSummaryInFlight = new Map<string, Promise<GitHubPRStack | undefined>>()
function githubPRStackExecutionScope(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): string {
return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}`
}
export function _resetMergeQueueCacheForTests(): void {
repositoryMergeMetadataCache.clear()
}
export function _resetPRStackSummaryCacheForTests(): void {
prStackSummaryCache.clear()
prStackSummaryInFlight.clear()
}
export function _getMergeQueueCacheSizeForTests(): number {
return repositoryMergeMetadataCache.size
}
@@ -2156,6 +2177,8 @@ type PullRequestLookupData = {
headRefName?: string
baseRefOid?: string
headRefOid?: string
stack?: GitHubPRStack
stackMetadataChecked?: boolean
}
type RestPullRequest = {
@@ -2171,6 +2194,12 @@ type RestPullRequest = {
mergeable_state?: string | null
base?: { ref?: string; sha?: string }
head?: { ref?: string; sha?: string }
stack?: {
number?: number
position?: number
size?: number
base?: { ref?: string; sha?: string }
} | null
}
const PR_LOOKUP_JSON_FIELDS =
@@ -2210,6 +2239,19 @@ function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableSta
}
function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
const stack =
typeof pr.stack?.number === 'number' &&
typeof pr.stack.position === 'number' &&
typeof pr.stack.size === 'number' &&
typeof pr.stack.base?.ref === 'string'
? {
number: pr.stack.number,
position: pr.stack.position,
size: pr.stack.size,
baseRefName: pr.stack.base.ref,
...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {})
}
: undefined
return {
number: pr.number,
title: pr.title,
@@ -2222,7 +2264,9 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
baseRefName: pr.base?.ref,
headRefName: pr.head?.ref,
baseRefOid: pr.base?.sha,
headRefOid: pr.head?.sha
headRefOid: pr.head?.sha,
stackMetadataChecked: true,
...(stack ? { stack } : {})
}
}
@@ -2292,9 +2336,10 @@ function cacheRepositoryMergeMetadata(
async function detectRepositoryMergeMetadata(
ownerRepo: GitHubApiRepository,
branchName: string | undefined,
ghOptions: GhExecOptions
ghOptions: GhExecOptions,
executionScope = 'default'
): Promise<GitHubRepositoryMergeMetadata> {
const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}`
const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}`
pruneRepositoryMergeMetadataCache()
const cached = repositoryMergeMetadataCache.get(cacheKey)
if (cached) {
@@ -2305,7 +2350,7 @@ async function detectRepositoryMergeMetadata(
return { mergeQueueRequired: null, autoMergeAllowed: null }
}
const query = branchName
? `query($owner: String!, $repo: String!, $branch: String!) {
? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) {
repository(owner: $owner, name: $repo) {
viewerDefaultMergeMethod
mergeCommitAllowed
@@ -2313,6 +2358,9 @@ async function detectRepositoryMergeMetadata(
squashMergeAllowed
autoMergeAllowed
mergeQueue(branch: $branch) { id }
ref(qualifiedName: $qualified) {
rules(first: 50) { nodes { type } }
}
}
}`
: `query($owner: String!, $repo: String!) {
@@ -2338,6 +2386,7 @@ async function detectRepositoryMergeMetadata(
]
if (branchName) {
args.push('-f', `branch=${branchName}`)
args.push('-f', `qualified=refs/heads/${branchName}`)
}
const { stdout } = await ghExecFileAsync(args, {
...ghOptions,
@@ -2352,6 +2401,7 @@ async function detectRepositoryMergeMetadata(
squashMergeAllowed?: unknown
autoMergeAllowed?: unknown
mergeQueue?: { id?: unknown } | null
ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null
} | null
}
}
@@ -2365,7 +2415,10 @@ async function detectRepositoryMergeMetadata(
})
: undefined
const value: GitHubRepositoryMergeMetadata = {
mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null,
mergeQueueRequired: branchName
? Boolean(repository?.mergeQueue) ||
Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE'))
: null,
autoMergeAllowed:
typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null,
...(mergeMethodSettings ? { mergeMethodSettings } : {})
@@ -2386,13 +2439,19 @@ async function detectRepositoryMergeMetadata(
async function hydratePullRequestLookupData(
ownerRepo: OwnerRepo,
data: PullRequestLookupData,
ghOptions: GhExecOptions
ghOptions: GhExecOptions,
executionScope: string
): Promise<PullRequestLookupData> {
const normalized = normalizePullRequestLookupData(data)
const hasRichMergeFields =
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
const mergeMetadata = hasRichMergeFields
? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions)
? await detectRepositoryMergeMetadata(
ownerRepo,
normalized.stack?.baseRefName ?? normalized.baseRefName,
ghOptions,
executionScope
)
: undefined
return {
...normalized,
@@ -2407,13 +2466,17 @@ async function hydratePullRequestLookupData(
async function hydrateBranchLookupWithExactPR(
ownerRepo: OwnerRepo,
branchData: PullRequestLookupData | null,
ghOptions: GhExecOptions
ghOptions: GhExecOptions,
executionScope: string
): Promise<PullRequestLookupData | null> {
if (!branchData) {
return null
}
try {
return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData
return (
(await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ??
branchData
)
} catch {
return branchData
}
@@ -2766,6 +2829,7 @@ async function lookupPRByBranchName(args: {
headRepo: OwnerRepo | null
branchName: string
ghOptions: GhExecOptions
executionScope: string
}): Promise<{
data: PullRequestLookupData | null
dataRepo: OwnerRepo | null
@@ -2785,7 +2849,12 @@ async function lookupPRByBranchName(args: {
)
: await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions)
// Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state.
const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions)
const data = await hydrateBranchLookupWithExactPR(
candidate,
branchData,
args.ghOptions,
args.executionScope
)
if (data) {
return { data, dataRepo: candidate }
}
@@ -2804,7 +2873,12 @@ async function lookupPRByBranchName(args: {
args.branchName,
args.ghOptions
)
const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions)
const data = await hydrateBranchLookupWithExactPR(
candidate,
branchData,
args.ghOptions,
args.executionScope
)
if (data) {
return { data, dataRepo: candidate }
}
@@ -2851,10 +2925,71 @@ async function getRestPRByNumber(
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
}
function prunePRStackSummaryCache(now = Date.now()): void {
for (const [key, cached] of prStackSummaryCache) {
if (cached.expiresAt <= now) {
prStackSummaryCache.delete(key)
}
}
while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prStackSummaryCache.keys().next().value
if (oldestKey === undefined) {
return
}
prStackSummaryCache.delete(oldestKey)
}
}
async function getCachedGitHubPRStackSummary(
ownerRepo: GitHubApiRepository,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>,
executionScope: string
): Promise<GitHubPRStack | undefined> {
const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}`
const now = Date.now()
prunePRStackSummaryCache(now)
const cached = prStackSummaryCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.value
}
const existing = prStackSummaryInFlight.get(key)
if (existing) {
return existing
}
const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack)
prStackSummaryInFlight.set(key, request)
try {
const value = await request
prStackSummaryCache.delete(key)
prStackSummaryCache.set(key, {
value,
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
})
prunePRStackSummaryCache()
return value
} catch (err) {
// Why: avoid repeating a failed REST probe on every review poll.
prStackSummaryCache.delete(key)
prStackSummaryCache.set(key, {
value: undefined,
expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS
})
prunePRStackSummaryCache()
throw err
} finally {
if (prStackSummaryInFlight.get(key) === request) {
prStackSummaryInFlight.delete(key)
}
}
}
async function getPRByNumber(
ownerRepo: GitHubApiRepository,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
ghOptions: ReturnType<typeof ghRepoExecOptions>,
executionScope: string,
knownPullRequestData?: PullRequestLookupData | null
): Promise<PullRequestLookupData | null> {
try {
const { stdout } = await ghExecFileAsync(
@@ -2869,10 +3004,16 @@ async function getPRByNumber(
],
{ ...ghOptions, ...githubHostExecOptions(ownerRepo) }
)
const exactData = JSON.parse(stdout) as PullRequestLookupData
return hydratePullRequestLookupData(
ownerRepo,
JSON.parse(stdout) as PullRequestLookupData,
ghOptions
{
...knownPullRequestData,
...exactData,
...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {})
},
ghOptions,
executionScope
)
} catch (err) {
// Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
@@ -2880,8 +3021,13 @@ async function getPRByNumber(
return null
}
try {
const restData = await getRestPRByNumber(ownerRepo, number, ghOptions)
return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null
const restData =
knownPullRequestData === undefined
? await getRestPRByNumber(ownerRepo, number, ghOptions)
: knownPullRequestData
return restData
? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope)
: null
} catch (restErr) {
if (isNotFoundGhError(restErr)) {
return null
@@ -2898,10 +3044,16 @@ async function lookupPRByNumber(args: {
candidates: OwnerRepo[]
number: number
ghOptions: ReturnType<typeof ghRepoExecOptions>
executionScope: string
}): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> {
for (const candidate of args.candidates) {
try {
const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions)
const linkedData = await getPRByNumber(
candidate,
args.number,
args.ghOptions,
args.executionScope
)
if (!linkedData) {
continue
}
@@ -3000,6 +3152,7 @@ export async function getPRForBranchOutcome(
const localGitOptions = localGitArgs[0] ?? {}
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(context)
const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions)
await acquire()
try {
@@ -3026,6 +3179,7 @@ export async function getPRForBranchOutcome(
let pendingBranchLookupError: unknown
let hasPendingBranchLookupError = false
let currentHeadOidForMergedImplicit: string | null | undefined
let usedExactNumberLookup = false
const explicitCurrentHeadOid =
typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0
@@ -3098,10 +3252,12 @@ export async function getPRForBranchOutcome(
}
if (typeof linkedPRNumber === 'number') {
usedExactNumberLookup = true
const exactLookup = await lookupPRByNumber({
candidates,
number: linkedPRNumber,
ghOptions
ghOptions,
executionScope
})
data = exactLookup.data
dataRepo = exactLookup.dataRepo
@@ -3111,7 +3267,8 @@ export async function getPRForBranchOutcome(
candidates,
headRepo,
branchName,
ghOptions
ghOptions,
executionScope
})
data = branchLookup.data
dataRepo = branchLookup.dataRepo
@@ -3143,7 +3300,8 @@ export async function getPRForBranchOutcome(
candidates,
headRepo: upstreamHeadRepo,
branchName: upstreamBranch.branchName,
ghOptions
ghOptions,
executionScope
})
data = upstreamLookup.data
dataRepo = upstreamLookup.dataRepo
@@ -3166,10 +3324,12 @@ export async function getPRForBranchOutcome(
dataHeadRepo = headRepo
}
if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') {
usedExactNumberLookup = true
const fallbackLookup = await lookupPRByNumber({
candidates,
number: fallbackPRNumber,
ghOptions
ghOptions,
executionScope
})
data = fallbackLookup.data
dataRepo = fallbackLookup.dataRepo
@@ -3215,7 +3375,42 @@ export async function getPRForBranchOutcome(
return { kind: 'no-pr', fetchedAt: Date.now() }
}
if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) {
try {
data.stack = await getCachedGitHubPRStackSummary(
dataRepo,
data.number,
ghOptions,
executionScope
)
data.stackMetadataChecked = true
} catch {
// Stack metadata is additive; exact PR lookup remains usable without it.
}
}
const mergeable = derivePullRequestMergeable(data)
const stack =
data.stack && dataRepo
? await hydrateGitHubPRStack(
dataRepo,
data.number,
data.stack,
ghOptions,
data.updatedAt,
executionScope
)
: data.stack
const stackMergeQueueRequired =
stack && dataRepo
? (
await detectRepositoryMergeMetadata(
dataRepo,
stack.baseRefName,
ghOptions,
executionScope
)
).mergeQueueRequired
: undefined
const conflictSummary =
!connectionId &&
mergeable === 'CONFLICTING' &&
@@ -3245,13 +3440,19 @@ export async function getPRForBranchOutcome(
...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}),
...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}),
...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}),
...(data.mergeQueueRequired !== undefined
? { mergeQueueRequired: data.mergeQueueRequired }
...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined
? {
mergeQueueRequired:
stackMergeQueueRequired !== undefined
? stackMergeQueueRequired
: data.mergeQueueRequired
}
: {}),
...(data.mergeMethodSettings !== undefined
? { mergeMethodSettings: data.mergeMethodSettings }
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
...(stack ? { stack } : {}),
headSha: data.headRefOid,
...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}),
...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}),
@@ -4652,7 +4853,35 @@ export async function mergePR(
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
await acquire()
let concurrencySlotHeld = true
try {
let stackSummary: GitHubPRStack | undefined
let stackHeadSha: string | undefined
try {
const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions)
stackSummary = restData?.stack
stackHeadSha = restData?.headRefOid
} catch {
// GitHub remains authoritative when stack metadata cannot be read.
}
if (stackSummary) {
const mergeMetadata = await detectRepositoryMergeMetadata(
ownerRepo,
stackSummary.baseRefName,
ghOptions,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
release()
concurrencySlotHeld = false
return await mergeGitHubPRStack({
repository: ownerRepo,
prNumber,
method,
mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge',
headSha: stackHeadSha,
ghOptions
})
}
const mergeBlocker = await getPRMergeBlocker(
repoPath,
prNumber,
@@ -4680,7 +4909,9 @@ export async function mergePR(
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: message }
} finally {
release()
if (concurrencySlotHeld) {
release()
}
}
}
@@ -4791,11 +5022,25 @@ async function enablePRAutoMerge(
ownerRepo: GitHubApiRepository | null,
ghOptions: GhExecOptions
): Promise<{ ok: true } | { ok: false; error: string }> {
if (ownerRepo) {
try {
const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions)
if (restData?.stack) {
return {
ok: false,
error: 'GitHub does not support auto-merge for stacked pull requests.'
}
}
} catch {
// GitHub remains authoritative when stack metadata cannot be read.
}
}
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
if (!pr?.id) {
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
}
if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) {
const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)
if (useMergeQueue) {
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
return { ok: true }
}
@@ -4842,7 +5087,12 @@ async function getPRMergeBlocker(
}
try {
const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions)
const pr = await getPRByNumber(
ownerRepo,
prNumber,
ghOptions,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
if (!pr) {
return null
}
@@ -0,0 +1,145 @@
import type { GitHubPRMergeMethod } from '../../shared/types'
import { ghExecFileAsync } from '../git/runner'
import { acquire, release } from './gh-utils'
import {
githubHostExecOptions,
type GitHubApiRepository,
type GitHubRepoExecOptions
} from './github-api-repository'
const POLL_INTERVAL_MS = 1_000
const MAX_POLLS = 180
type AsyncMergeResponse = {
status?: unknown
details?: {
message?: unknown
uuid?: unknown
}
}
type AsyncMergeResult =
| { kind: 'pending'; uuid: string }
| { kind: 'success' }
| { kind: 'failure'; message: string }
export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue'
function parseResponse(value: string): AsyncMergeResponse | null {
try {
return JSON.parse(value) as AsyncMergeResponse
} catch {
return null
}
}
function errorResponseBody(error: unknown): AsyncMergeResponse | null {
if (!error || typeof error !== 'object' || !('stdout' in error)) {
return null
}
const stdout = (error as { stdout?: unknown }).stdout
return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout))
}
function classifyResult(response: AsyncMergeResponse): AsyncMergeResult {
if (response.status === 'merged' || response.status === 'enqueued') {
return { kind: 'success' }
}
if (response.status === 'pending' && typeof response.details?.uuid === 'string') {
return { kind: 'pending', uuid: response.details.uuid }
}
const message =
typeof response.details?.message === 'string'
? response.details.message
: 'GitHub could not merge this stack.'
return { kind: 'failure', message }
}
function waitForNextPoll(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}
async function runStackMergeCommand(
command: string[],
options: NonNullable<Parameters<typeof ghExecFileAsync>[1]>
) {
await acquire()
try {
return await ghExecFileAsync(command, options)
} finally {
release()
}
}
export async function mergeGitHubPRStack(args: {
repository: GitHubApiRepository
prNumber: number
method: GitHubPRMergeMethod
mergeAction: GitHubPRStackMergeAction
headSha?: string
ghOptions: GitHubRepoExecOptions
}): Promise<{ ok: true } | { ok: false; error: string }> {
const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async`
const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`]
if (args.mergeAction === 'direct_merge') {
command.push('-f', `merge_method=${args.method}`)
}
if (args.headSha) {
command.push('-f', `sha=${args.headSha}`)
}
let submitted: AsyncMergeResponse | null
try {
const { stdout } = await runStackMergeCommand(command, {
...args.ghOptions,
...githubHostExecOptions(args.repository),
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
submitted = parseResponse(stdout)
} catch (error) {
submitted = errorResponseBody(error)
if (!submitted) {
throw error
}
}
if (!submitted) {
return { ok: false, error: 'GitHub returned an invalid stack merge response.' }
}
let result = classifyResult(submitted)
if (result.kind === 'success') {
return { ok: true }
}
if (result.kind === 'failure') {
return { ok: false, error: result.message }
}
const uuid = encodeURIComponent(result.uuid)
for (let poll = 0; poll < MAX_POLLS; poll++) {
await waitForNextPoll()
let response: AsyncMergeResponse | null
try {
const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], {
...args.ghOptions,
...githubHostExecOptions(args.repository)
})
response = parseResponse(stdout)
} catch (error) {
// Why: the merge was submitted; a transient poll failure cannot make it fail.
response = errorResponseBody(error)
if (!response) {
continue
}
}
if (!response) {
return { ok: false, error: 'GitHub returned an invalid stack merge response.' }
}
result = classifyResult(response)
if (result.kind === 'success') {
return { ok: true }
}
if (result.kind === 'failure') {
return { ok: false, error: result.message }
}
}
return { ok: false, error: 'GitHub is still merging this stack. Refresh to check its status.' }
}
+331
View File
@@ -0,0 +1,331 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRStack } from '../../shared/types'
const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
noteRateLimitSpendMock: vi.fn()
}))
vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock }))
vi.mock('./rate-limit', () => ({
repositoryRateLimitGuard: rateLimitGuardMock,
noteRepositoryRateLimitSpend: noteRateLimitSpendMock
}))
import {
_resetGitHubPRStackCacheForTests,
hydrateGitHubPRStack,
mergeGitHubPRStack
} from './github-pr-stack'
const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
const summary: GitHubPRStack = {
number: 51,
position: 2,
size: 3,
baseRefName: 'main',
baseSha: 'base-sha'
}
beforeEach(() => {
vi.useRealTimers()
ghExecFileAsyncMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
_resetGitHubPRStackCacheForTests()
})
describe('hydrateGitHubPRStack', () => {
it('maps and caches every entry in one GraphQL request', async () => {
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
stack: {
number: 51,
size: 3,
baseRefName: 'main',
entries: {
nodes: [
{
position: 2,
pullRequest: {
number: 202,
title: 'API',
url: 'https://github.com/stablyai/orca/pull/202',
updatedAt: '2026-08-10T00:00:00Z',
state: 'OPEN',
isDraft: false,
headRefName: 'stack/api',
headRefOid: 'api-sha',
mergeable: 'MERGEABLE',
reviewDecision: 'APPROVED',
mergeStateStatus: 'CLEAN',
statusCheckRollup: { state: 'SUCCESS' }
}
},
{
position: 1,
pullRequest: {
number: 201,
title: 'Models',
url: 'https://github.com/stablyai/orca/pull/201',
updatedAt: '2026-08-10T00:00:00Z',
state: 'OPEN',
isDraft: false,
mergeable: 'UNKNOWN',
statusCheckRollup: { state: 'PENDING' }
}
}
]
}
}
}
}
}
})
})
const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })
const sibling = await hydrateGitHubPRStack(
repository,
201,
{ ...summary, position: 1 },
{ cwd: '/other-worktree' }
)
expect(first).toMatchObject({
number: 51,
position: 2,
baseSha: 'base-sha',
entries: [
{ number: 201, position: 1, checksStatus: 'pending' },
{
number: 202,
position: 2,
checksStatus: 'success',
reviewDecision: 'APPROVED'
}
]
})
expect(sibling.position).toBe(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(noteRateLimitSpendMock).toHaveBeenCalledWith(
repository,
'graphql',
1,
expect.any(Object)
)
})
it('refreshes cached stack details when the current PR changed', async () => {
const response = (updatedAt: string, isDraft: boolean) => ({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
stack: {
number: 51,
size: 3,
baseRefName: 'main',
entries: {
nodes: [
{
position: 2,
pullRequest: {
number: 202,
title: 'API',
url: 'https://github.com/stablyai/orca/pull/202',
updatedAt,
state: 'OPEN',
isDraft,
mergeable: 'MERGEABLE'
}
}
]
}
}
}
}
}
})
})
ghExecFileAsyncMock
.mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true))
.mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false))
const draft = await hydrateGitHubPRStack(
repository,
202,
summary,
{ cwd: '/repo' },
'2026-08-10T00:00:00Z'
)
const ready = await hydrateGitHubPRStack(
repository,
202,
summary,
{ cwd: '/repo' },
'2026-08-10T00:01:00Z'
)
expect(draft.entries?.[0]?.state).toBe('draft')
expect(ready.entries?.[0]?.state).toBe('open')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('keeps the REST summary when GraphQL is unavailable', async () => {
ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable'))
await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual(
summary
)
})
it('isolates cached details across execution scopes', async () => {
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } }
}
}
}
})
})
await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host')
await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('mergeGitHubPRStack', () => {
it('submits the expected head and polls pending merges to completion', async () => {
vi.useFakeTimers()
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } })
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } })
})
const result = mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'squash',
mergeAction: 'direct_merge',
headSha: 'api-sha',
ghOptions: { cwd: '/repo' }
})
await vi.advanceTimersByTimeAsync(1_000)
await expect(result).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'PUT',
'repos/stablyai/orca/pulls/202/merge-async',
'merge_method=squash',
'merge_action=direct_merge',
'sha=api-sha'
]),
expect.objectContaining({ cwd: '/repo', host: 'github.com' })
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'],
expect.objectContaining({ cwd: '/repo', host: 'github.com' })
)
})
it('returns GitHub atomic failure details', async () => {
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({
status: 'failed',
details: { message: 'A pull request has merge conflicts.' }
})
})
await expect(
mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'rebase',
mergeAction: 'direct_merge',
ghOptions: { cwd: '/repo' }
})
).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' })
})
it('omits the unsupported merge method when queueing a stack', async () => {
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } })
})
await expect(
mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'squash',
mergeAction: 'merge_queue',
ghOptions: { cwd: '/repo' }
})
).resolves.toEqual({ ok: true })
const command = ghExecFileAsyncMock.mock.calls[0]?.[0]
expect(command).toContain('merge_action=merge_queue')
expect(command).not.toContain('merge_method=squash')
})
it('continues polling after a transient transport failure', async () => {
vi.useFakeTimers()
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } })
})
.mockRejectedValueOnce(new Error('temporary gateway failure'))
.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) })
const result = mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'squash',
mergeAction: 'direct_merge',
ghOptions: { cwd: '/repo' }
})
await vi.advanceTimersByTimeAsync(1_000)
await vi.advanceTimersByTimeAsync(1_000)
await expect(result).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
})
it('reports an in-progress merge after polling is exhausted', async () => {
vi.useFakeTimers()
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } })
})
const result = mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'squash',
mergeAction: 'direct_merge',
ghOptions: { cwd: '/repo' }
})
await vi.advanceTimersByTimeAsync(180_000)
await expect(result).resolves.toEqual({
ok: false,
error: 'GitHub is still merging this stack. Refresh to check its status.'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181)
})
})
+282
View File
@@ -0,0 +1,282 @@
import type {
CheckStatus,
GitHubPRStack,
GitHubPRStackEntry,
PRMergeableState,
PRReviewDecision,
PRState
} from '../../shared/types'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
import { ghExecFileAsync } from '../git/runner'
import {
githubHostExecOptions,
type GitHubApiRepository,
type GitHubRepoExecOptions
} from './github-api-repository'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit'
const STACK_CACHE_TTL_MS = 30_000
const STACK_CACHE_MAX_ENTRIES = 256
type CachedStackDetails = {
value: Omit<GitHubPRStack, 'position'> | null
expiresAt: number
}
type GraphQLStackEntry = {
position?: unknown
pullRequest?: {
number?: unknown
title?: unknown
url?: unknown
updatedAt?: unknown
state?: unknown
isDraft?: unknown
headRefName?: unknown
headRefOid?: unknown
mergeable?: unknown
reviewDecision?: unknown
mergeStateStatus?: unknown
statusCheckRollup?: { state?: unknown } | null
} | null
}
type GraphQLStackResponse = {
data?: {
repository?: {
pullRequest?: {
stack?: {
number?: unknown
size?: unknown
baseRefName?: unknown
entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null
} | null
} | null
} | null
}
}
const stackDetailsCache = new Map<string, CachedStackDetails>()
const stackDetailsInFlight = new Map<string, Promise<Omit<GitHubPRStack, 'position'> | null>>()
export function _resetGitHubPRStackCacheForTests(): void {
stackDetailsCache.clear()
stackDetailsInFlight.clear()
}
function stackCacheKey(
repository: GitHubApiRepository,
stackNumber: number,
executionScope: string
): string {
return `${executionScope}\0${githubRepoIdentityKey(repository)}:${stackNumber}`
}
function pruneStackCache(now = Date.now()): void {
for (const [key, cached] of stackDetailsCache) {
if (cached.expiresAt <= now) {
stackDetailsCache.delete(key)
}
}
while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) {
const oldestKey = stackDetailsCache.keys().next().value
if (oldestKey === undefined) {
return
}
stackDetailsCache.delete(oldestKey)
}
}
function mapStackPRState(value: unknown, isDraft: unknown): PRState {
if (value === 'MERGED') {
return 'merged'
}
if (value === 'CLOSED') {
return 'closed'
}
return isDraft === true ? 'draft' : 'open'
}
function mapStackCheckStatus(value: unknown): CheckStatus {
if (value === 'SUCCESS') {
return 'success'
}
if (value === 'FAILURE' || value === 'ERROR') {
return 'failure'
}
if (value === 'PENDING' || value === 'EXPECTED') {
return 'pending'
}
return 'neutral'
}
function mapStackMergeable(value: unknown): PRMergeableState {
return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN'
}
function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined {
if (value === null) {
return null
}
if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') {
return value
}
return undefined
}
function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null {
const pr = entry.pullRequest
if (
typeof entry.position !== 'number' ||
typeof pr?.number !== 'number' ||
typeof pr.title !== 'string' ||
typeof pr.url !== 'string'
) {
return null
}
const reviewDecision = mapReviewDecision(pr.reviewDecision)
return {
position: entry.position,
number: pr.number,
title: pr.title,
url: pr.url,
...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}),
state: mapStackPRState(pr.state, pr.isDraft),
checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state),
mergeable: mapStackMergeable(pr.mergeable),
...(reviewDecision !== undefined ? { reviewDecision } : {}),
...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}),
...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}),
...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {})
}
}
function parseStackDetails(
response: GraphQLStackResponse,
expectedStackNumber: number,
fallbackBaseRefName: string,
fallbackSize: number
): Omit<GitHubPRStack, 'position'> | null {
const stack = response.data?.repository?.pullRequest?.stack
if (!stack || stack.number !== expectedStackNumber) {
return null
}
const entries = (stack.entries?.nodes ?? [])
.flatMap((entry) => (entry ? [mapStackEntry(entry)] : []))
.filter((entry): entry is GitHubPRStackEntry => entry !== null)
.sort((a, b) => a.position - b.position)
return {
number: expectedStackNumber,
size: typeof stack.size === 'number' ? stack.size : fallbackSize,
baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName,
...(entries.length > 0 ? { entries } : {})
}
}
const STACK_DETAILS_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
stack {
number
size
baseRefName
entries(first: 100) {
nodes {
position
pullRequest {
number
title
url
updatedAt
state
isDraft
headRefName
headRefOid
mergeable
reviewDecision
mergeStateStatus
statusCheckRollup { state }
}
}
}
}
}
}
}`
async function fetchStackDetails(
repository: GitHubApiRepository,
prNumber: number,
summary: GitHubPRStack,
ghOptions: GitHubRepoExecOptions
): Promise<Omit<GitHubPRStack, 'position'> | null> {
if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) {
return null
}
noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${STACK_DETAILS_QUERY}`,
'-f',
`owner=${repository.owner}`,
'-f',
`repo=${repository.repo}`,
'-F',
`pr=${prNumber}`
],
{ ...ghOptions, ...githubHostExecOptions(repository) }
)
return parseStackDetails(
JSON.parse(stdout) as GraphQLStackResponse,
summary.number,
summary.baseRefName,
summary.size
)
}
export async function hydrateGitHubPRStack(
repository: GitHubApiRepository,
prNumber: number,
summary: GitHubPRStack,
ghOptions: GitHubRepoExecOptions,
prUpdatedAt?: string,
executionScope = 'local:host'
): Promise<GitHubPRStack> {
const key = stackCacheKey(repository, summary.number, executionScope)
const now = Date.now()
pruneStackCache(now)
const cached = stackDetailsCache.get(key)
const cachedPRUpdatedAt = cached?.value?.entries?.find(
(entry) => entry.number === prNumber
)?.updatedAt
const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt
if (cached && cached.expiresAt > now && cachedMatchesPR) {
return cached.value
? { ...cached.value, position: summary.position, baseSha: summary.baseSha }
: summary
}
const existing = stackDetailsInFlight.get(key)
if (existing) {
const value = await existing
return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary
}
const request = fetchStackDetails(repository, prNumber, summary, ghOptions).catch(() => null)
stackDetailsInFlight.set(key, request)
try {
const value = await request
stackDetailsCache.delete(key)
stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS })
pruneStackCache()
return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary
} finally {
if (stackDetailsInFlight.get(key) === request) {
stackDetailsInFlight.delete(key)
}
}
}
export { mergeGitHubPRStack } from './github-pr-stack-async-merge'
@@ -41,6 +41,7 @@ import {
import { isFolderRepo } from '../../../../shared/repo-kind'
import { githubProjectHost } from '../../../../shared/github-project-identity'
import HostedReviewActions from './HostedReviewActions'
import { GitHubPRStackMap, type GitHubPRStackMapNavigationModifiers } from './GitHubPRStackMap'
import {
PullRequestIcon,
prStateColor,
@@ -3687,6 +3688,18 @@ export default function ChecksPanel(): React.JSX.Element {
[activeReview, activeWorktreeId]
)
const handleOpenStackPR = useCallback(
(url: string, modifiers: GitHubPRStackMapNavigationModifiers) => {
openChecksPanelHostedReviewUrl({
url,
event: modifiers,
isMac: isMacPlatform(),
worktreeId: activeWorktreeId
})
},
[activeWorktreeId]
)
const handleUnlinkPullRequest = useCallback(() => {
if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) {
return
@@ -4390,6 +4403,14 @@ export default function ChecksPanel(): React.JSX.Element {
{detachedHeadDisplay && <DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />}
{activeReview.provider === 'github' && pr?.stack ? (
<GitHubPRStackMap
stack={pr.stack}
currentPRNumber={pr.number}
onOpenPullRequest={handleOpenStackPR}
/>
) : null}
{/* Review title */}
{editingTitle ? (
<div className="flex items-center gap-1">
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRStack } from '../../../../shared/types'
import { GitHubPRStackMap } from './GitHubPRStackMap'
const stack: GitHubPRStack = {
number: 51,
position: 2,
size: 3,
baseRefName: 'main',
entries: [
{
position: 1,
number: 201,
title: 'Models',
url: 'https://github.com/acme/repo/pull/201',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE',
reviewDecision: 'APPROVED'
},
{
position: 2,
number: 202,
title: 'API',
url: 'https://github.com/acme/repo/pull/202',
state: 'open',
checksStatus: 'failure',
mergeable: 'MERGEABLE'
},
{
position: 3,
number: 203,
title: 'UI',
url: 'https://github.com/acme/repo/pull/203',
state: 'draft',
checksStatus: 'neutral',
mergeable: 'UNKNOWN'
}
]
}
let container: HTMLDivElement
let root: Root
describe('GitHubPRStackMap', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('shows compact identity then expands top-to-bottom with the current PR highlighted', () => {
act(() => {
root.render(
<GitHubPRStackMap stack={stack} currentPRNumber={202} onOpenPullRequest={vi.fn()} />
)
})
expect(container.textContent).toContain('Stack #51')
expect(container.textContent).toContain('2 of 3 · main')
expect(container.querySelectorAll('button[data-stack-pr-number]')).toHaveLength(0)
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand stack #51"]'
)
act(() => trigger?.click())
const rows = [...container.querySelectorAll<HTMLButtonElement>('button[data-stack-pr-number]')]
expect(rows.map((row) => row.textContent)).toEqual([
expect.stringContaining('#203'),
expect.stringContaining('#202'),
expect.stringContaining('#201')
])
expect(rows[1]?.dataset.current).toBe('true')
expect(container.textContent).toContain('checks failed')
expect(container.textContent).toContain('main')
})
it('opens another PR without changing local branches', () => {
const onOpenPullRequest = vi.fn()
act(() => {
root.render(
<GitHubPRStackMap
stack={stack}
currentPRNumber={202}
onOpenPullRequest={onOpenPullRequest}
/>
)
})
act(() =>
container.querySelector<HTMLButtonElement>('button[aria-label="Expand stack #51"]')?.click()
)
act(() =>
container.querySelector<HTMLButtonElement>('button[data-stack-pr-number="203"]')?.click()
)
expect(onOpenPullRequest).toHaveBeenCalledTimes(1)
expect(onOpenPullRequest).toHaveBeenCalledWith('https://github.com/acme/repo/pull/203', {
metaKey: false,
ctrlKey: false,
shiftKey: false
})
})
})
@@ -0,0 +1,156 @@
import { useMemo, useState } from 'react'
import { ChevronDown, GitBranch, GitPullRequest } from 'lucide-react'
import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types'
import { cn } from '@/lib/utils'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { translate } from '@/i18n/i18n'
export type GitHubPRStackMapNavigationModifiers = Pick<
React.MouseEvent<HTMLButtonElement>,
'metaKey' | 'ctrlKey' | 'shiftKey'
>
function stackEntryStatus(entry: GitHubPRStackEntry): string {
if (entry.state === 'merged') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.8a9bdc36c0', 'merged')
}
if (entry.state === 'closed') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.3511405914', 'closed')
}
if (entry.state === 'draft') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.568c647ccd', 'draft')
}
if (entry.mergeable === 'CONFLICTING') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.bea9ade223', 'conflicts')
}
if (entry.checksStatus === 'failure') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.838aadf512', 'checks failed')
}
if (entry.checksStatus === 'pending') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.316039b5db', 'checks pending')
}
if (entry.reviewDecision === 'CHANGES_REQUESTED') {
return translate(
'auto.components.right.sidebar.GitHubPRStackMap.4b1e5ee9d3',
'changes requested'
)
}
if (entry.reviewDecision === 'REVIEW_REQUIRED') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.9a17b5255c', 'review needed')
}
if (entry.reviewDecision === 'APPROVED') {
return translate('auto.components.right.sidebar.GitHubPRStackMap.d3d97cf3f2', 'approved')
}
return translate('auto.components.right.sidebar.GitHubPRStackMap.e6cb964305', 'open')
}
export function GitHubPRStackMap({
stack,
currentPRNumber,
onOpenPullRequest
}: {
stack: GitHubPRStack
currentPRNumber: number
onOpenPullRequest: (url: string, modifiers: GitHubPRStackMapNavigationModifiers) => void
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const entries = useMemo(
() => [...(stack.entries ?? [])].sort((a, b) => b.position - a.position),
[stack.entries]
)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="overflow-hidden rounded-md border border-border"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[11px] hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={
open
? translate(
'auto.components.right.sidebar.GitHubPRStackMap.7737bd66be',
'Collapse stack #{{value0}}',
{ value0: stack.number }
)
: translate(
'auto.components.right.sidebar.GitHubPRStackMap.0c1645ebd0',
'Expand stack #{{value0}}',
{ value0: stack.number }
)
}
>
<GitPullRequest className="size-3.5 shrink-0 text-muted-foreground" />
<span className="font-semibold text-foreground">
{translate(
'auto.components.right.sidebar.GitHubPRStackMap.e3ee2daa32',
'Stack #{{value0}}',
{ value0: stack.number }
)}
</span>
<span className="text-muted-foreground">
{translate(
'auto.components.right.sidebar.GitHubPRStackMap.cb440931b7',
'{{value0}} of {{value1}} · {{value2}}',
{ value0: stack.position, value1: stack.size, value2: stack.baseRefName }
)}
</span>
<ChevronDown
className={cn(
'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none',
open && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t border-border">
{entries.length > 0 ? (
<div className="py-1">
{entries.map((entry) => {
const current = entry.number === currentPRNumber
return (
<button
key={entry.number}
type="button"
data-stack-pr-number={entry.number}
data-current={current ? 'true' : undefined}
className={cn(
'flex w-full min-w-0 items-center gap-2 px-2 py-1.5 text-left text-[11px] hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
current && 'bg-accent'
)}
onClick={(event) =>
onOpenPullRequest(entry.url, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey
})
}
>
<span className="w-8 shrink-0 font-medium text-foreground">#{entry.number}</span>
<span className="min-w-0 flex-1 truncate text-foreground">{entry.title}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{stackEntryStatus(entry)}
</span>
</button>
)
})}
<div className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-muted-foreground">
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate">{stack.baseRefName}</span>
</div>
</div>
) : (
<div className="px-2 py-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.GitHubPRStackMap.525259fa17',
'Stack details are temporarily unavailable.'
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
)
}
@@ -28,6 +28,11 @@ import {
RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS
} from './right-sidebar-primary-action-layout'
import { translate } from '@/i18n/i18n'
import {
getGitHubPRStackMergeBlocker,
getGitHubPRStackMergeScope,
isGitHubPRStackMergeQueueRequired
} from './github-pr-stack-merge'
export default function HostedReviewActions({
review,
@@ -48,11 +53,33 @@ export default function HostedReviewActions({
const isGitLab = review.provider === 'gitlab'
const shortLabel = isGitLab ? 'MR' : 'PR'
const reviewLabel = isGitLab ? 'merge request' : 'pull request'
const stackMergeScope = useMemo(
() => (githubPR?.stack ? getGitHubPRStackMergeScope(githubPR.stack, review.number) : null),
[githubPR?.stack, review.number]
)
const stackUsesMergeQueue = isGitHubPRStackMergeQueueRequired(
review.mergeQueueRequired,
githubPR?.mergeQueueRequired
)
const stackMergeLabel =
stackMergeScope && stackUsesMergeQueue
? stackMergeScope.count === 1
? translate(
'auto.components.right.sidebar.HostedReviewActions.9a41a687b7',
'Queue through #{{pr}} · {{count}} PR',
{ pr: review.number, count: stackMergeScope.count }
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.38a1bccb14',
'Queue through #{{pr}} · {{count}} PRs',
{ pr: review.number, count: stackMergeScope.count }
)
: stackMergeScope?.label
const mergePresentation = useMemo(() => {
if (isGitLab) {
return { ...presentGitLabMRMergeState(review), autoMergeAction: null }
}
return presentGitHubPRMergeState({
const presentation = presentGitHubPRMergeState({
...githubPR,
state: review.state,
mergeable: review.mergeable,
@@ -63,7 +90,30 @@ export default function HostedReviewActions({
autoMergeAllowed: review.autoMergeAllowed,
mergeQueueRequired: review.mergeQueueRequired
})
}, [githubPR, isGitLab, review])
if (!githubPR?.stack || !stackMergeScope) {
return presentation
}
const stackBlocker = getGitHubPRStackMergeBlocker(stackMergeScope)
return {
...presentation,
label: stackMergeLabel ?? stackMergeScope.label,
tooltip:
stackBlocker ??
(stackUsesMergeQueue
? translate(
'auto.components.right.sidebar.HostedReviewActions.3de88351c5',
'GitHub will add this pull request and every pull request below it to the merge queue.'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.a32fe6dba6',
'GitHub will merge this pull request and every pull request below it in the stack.'
)),
directMergeAvailable:
!stackBlocker &&
(stackMergeScope.complete || presentation.directMergeAvailable || stackUsesMergeQueue),
autoMergeAction: null
}
}, [githubPR, isGitLab, review, stackMergeLabel, stackMergeScope, stackUsesMergeQueue])
const mergeMethods = useMemo(
() => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)),
[githubPR?.mergeMethodSettings, isGitLab]
@@ -140,12 +190,22 @@ export default function HostedReviewActions({
)}
<span className={RIGHT_SIDEBAR_PRIMARY_BUTTON_LABEL_CLASS}>
{merging
? translate(
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
'Working...'
)
? stackMergeScope
? stackUsesMergeQueue
? translate(
'auto.components.right.sidebar.HostedReviewActions.73e0e1819d',
'Queueing stack...'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.e555a41d32',
'Merging stack...'
)
: translate(
'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d',
'Working...'
)
: mergePresentation.directMergeAvailable
? mergeMethods.defaultLabel
? (stackMergeLabel ?? mergeMethods.defaultLabel)
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
</span>
</Button>
@@ -198,16 +258,17 @@ export default function HostedReviewActions({
<DropdownMenuSeparator />
</>
)}
{mergeMethods.methods.map(({ method, label }) => (
<DropdownMenuItem
key={method}
disabled={directMergeDisabled}
onSelect={() => void handleMerge(method)}
>
<GitMerge className="size-3.5" />
{label}
</DropdownMenuItem>
))}
{(!stackMergeScope || !stackUsesMergeQueue) &&
mergeMethods.methods.map(({ method, label }) => (
<DropdownMenuItem
key={method}
disabled={directMergeDisabled}
onSelect={() => void handleMerge(method)}
>
<GitMerge className="size-3.5" />
{label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
@@ -0,0 +1,93 @@
import type { ConfirmationDialogOptions } from '@/components/confirmation-dialog-context'
import { translate } from '@/i18n/i18n'
import type { GitHubPRMergeMethod, GitHubPRStack } from '../../../../shared/types'
import { getGitHubPRStackMergeScope } from './github-pr-stack-merge'
export function buildGitHubPRStackMergeConfirmation({
stack,
currentPRNumber,
method,
usesMergeQueue
}: {
stack: GitHubPRStack
currentPRNumber: number
method: GitHubPRMergeMethod
usesMergeQueue: boolean
}): ConfirmationDialogOptions {
const scope = getGitHubPRStackMergeScope(stack, currentPRNumber)
const numbers = scope.entries.map((entry) => `#${entry.number}`).join(', ')
const included =
scope.complete && numbers
? translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.84f6f5b9eb',
'Included: {{numbers}}. ',
{ numbers }
)
: ''
if (usesMergeQueue) {
return {
title: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.541984b2eb',
'Queue through #{{pr}}?',
{ pr: currentPRNumber }
),
description:
scope.count === 1
? translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.4809f55cdb',
'{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.',
{ included, count: scope.count }
)
: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.be8f2621be',
'{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.',
{ included, count: scope.count }
),
confirmLabel:
scope.count === 1
? translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.92ca033e72',
'Queue {{count}} PR',
{ count: scope.count }
)
: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.478a527b15',
'Queue {{count}} PRs',
{ count: scope.count }
)
}
}
return {
title: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.1feef35ca4',
'Merge through #{{pr}}?',
{ pr: currentPRNumber }
),
description:
scope.count === 1
? translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.c3e036c99f',
'{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.',
{ included, count: scope.count, method }
)
: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.369aba4b32',
'{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.',
{ included, count: scope.count, method }
),
confirmLabel:
scope.count === 1
? translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.493c78f521',
'Merge {{count}} PR',
{ count: scope.count }
)
: translate(
'auto.components.right.sidebar.github.pr.stack.confirmation.eb7051d268',
'Merge {{count}} PRs',
{ count: scope.count }
)
}
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { GitHubPRStack } from '../../../../shared/types'
import {
getGitHubPRStackMergeBlocker,
getGitHubPRStackMergeScope,
isGitHubPRStackMergeQueueRequired
} from './github-pr-stack-merge'
function makeStack(): GitHubPRStack {
return {
number: 51,
position: 2,
size: 3,
baseRefName: 'main',
entries: [
{
position: 1,
number: 201,
title: 'Models',
url: 'https://example.test/201',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
},
{
position: 2,
number: 202,
title: 'API',
url: 'https://example.test/202',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
},
{
position: 3,
number: 203,
title: 'UI',
url: 'https://example.test/203',
state: 'draft',
checksStatus: 'neutral',
mergeable: 'UNKNOWN'
}
]
}
}
describe('GitHub stack merge scope', () => {
it('uses stack metadata when review metadata has not observed the merge queue', () => {
expect(isGitHubPRStackMergeQueueRequired(false, true)).toBe(true)
})
it('includes the current PR and downstack entries, never upstack entries', () => {
const scope = getGitHubPRStackMergeScope(makeStack(), 202)
expect(scope.entries.map((entry) => entry.number)).toEqual([201, 202])
expect(scope.complete).toBe(true)
expect(scope.label).toBe('Merge through #202 · 2 PRs')
expect(getGitHubPRStackMergeBlocker(scope)).toBeNull()
})
it('keeps the GitHub-reported merge count when entry details are incomplete', () => {
const stack = makeStack()
stack.entries = stack.entries?.filter((entry) => entry.position !== 1)
const scope = getGitHubPRStackMergeScope(stack, 202)
expect(scope.entries.map((entry) => entry.number)).toEqual([202])
expect(scope.count).toBe(2)
expect(scope.complete).toBe(false)
expect(scope.label).toBe('Merge through #202 · 2 PRs')
})
it('surfaces the first downstack blocker', () => {
const stack = makeStack()
stack.entries![0] = { ...stack.entries![0]!, mergeable: 'CONFLICTING' }
expect(getGitHubPRStackMergeBlocker(getGitHubPRStackMergeScope(stack, 202))).toBe(
'#201 has merge conflicts.'
)
})
})
@@ -0,0 +1,100 @@
import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
export type GitHubPRStackMergeScope = {
count: number
complete: boolean
entries: GitHubPRStackEntry[]
label: string
}
export function isGitHubPRStackMergeQueueRequired(
reviewMergeQueueRequired: boolean | null | undefined,
stackMergeQueueRequired: boolean | null | undefined
): boolean {
return reviewMergeQueueRequired === true || stackMergeQueueRequired === true
}
export function getGitHubPRStackMergeScope(
stack: GitHubPRStack,
currentPRNumber: number
): GitHubPRStackMergeScope {
const entries = [...(stack.entries ?? [])]
.filter((entry) => entry.position <= stack.position)
.sort((a, b) => a.position - b.position)
const count = stack.position
const complete =
entries.length === count && entries.every((entry, index) => entry.position === index + 1)
return {
count,
complete,
entries,
label:
count === 1
? translate(
'auto.components.right.sidebar.github.pr.stack.merge.55ae29b907',
'Merge through #{{pr}} · {{count}} PR',
{ pr: currentPRNumber, count }
)
: translate(
'auto.components.right.sidebar.github.pr.stack.merge.b8446f6ec2',
'Merge through #{{pr}} · {{count}} PRs',
{ pr: currentPRNumber, count }
)
}
}
export function getGitHubPRStackMergeBlocker(scope: GitHubPRStackMergeScope): string | null {
for (const entry of scope.entries) {
if (entry.state === 'draft') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.189d0ec614',
'#{{pr}} is still a draft.',
{ pr: entry.number }
)
}
if (entry.state === 'closed') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.640fb50d9c',
'#{{pr}} is closed.',
{ pr: entry.number }
)
}
if (entry.mergeable === 'CONFLICTING' || entry.mergeStateStatus === 'DIRTY') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.46ffcbda75',
'#{{pr}} has merge conflicts.',
{ pr: entry.number }
)
}
if (entry.reviewDecision === 'CHANGES_REQUESTED') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.6dabefd63e',
'#{{pr}} has requested changes.',
{ pr: entry.number }
)
}
if (entry.reviewDecision === 'REVIEW_REQUIRED') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.2bb21fc326',
'#{{pr}} still needs review approval.',
{ pr: entry.number }
)
}
if (entry.mergeStateStatus === 'BEHIND') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.c23faf74df',
'#{{pr}} must be updated.',
{ pr: entry.number }
)
}
if (entry.mergeStateStatus === 'BLOCKED') {
return translate(
'auto.components.right.sidebar.github.pr.stack.merge.f561e80968',
'#{{pr}} is blocked.',
{ pr: entry.number }
)
}
}
return null
}
@@ -30,7 +30,8 @@ export async function mergeGitHubHostedReview(args: {
method: args.method,
prRepo: args.prRepo ?? null
},
{ timeoutMs: 30_000 }
// Why: GitHub stack merges can run asynchronously for several minutes.
{ timeoutMs: 4 * 60_000 }
)
}
return window.api.gh.mergePR({
@@ -46,10 +46,14 @@ function makeRepo(overrides: Partial<Repo> = {}): Repo {
} as Repo
}
function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise<void> }): null {
function HookProbe(props: {
repo: Repo
onRefreshReview: () => Promise<void>
pullRequest?: PRInfo
}): null {
latest = useHostedReviewActions({
review,
githubPR,
githubPR: props.pullRequest ?? githubPR,
repo: props.repo,
isGitLab: false,
shortLabel: 'PR',
@@ -61,12 +65,16 @@ function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise<void> }):
return null
}
async function renderHook(repo: Repo, onRefreshReview = vi.fn().mockResolvedValue(undefined)) {
async function renderHook(
repo: Repo,
onRefreshReview = vi.fn().mockResolvedValue(undefined),
pullRequest?: PRInfo
) {
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(createElement(HookProbe, { repo, onRefreshReview }))
root?.render(createElement(HookProbe, { repo, onRefreshReview, pullRequest }))
})
return { onRefreshReview }
}
@@ -115,7 +123,7 @@ describe('useHostedReviewActions', () => {
method: 'squash',
prRepo
},
{ timeoutMs: 30_000 }
{ timeoutMs: 4 * 60_000 }
)
expect(window.api.gh.mergePR).not.toHaveBeenCalled()
expect(onRefreshReview).toHaveBeenCalledTimes(1)
@@ -140,4 +148,103 @@ describe('useHostedReviewActions', () => {
expect(runtimeRpcMocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(onRefreshReview).toHaveBeenCalledTimes(1)
})
it('confirms the downstack merge scope before merging a registered stack', async () => {
const stackedPR = {
...githubPR,
stack: {
number: 51,
position: 2,
size: 3,
baseRefName: 'main',
entries: [
{
position: 1,
number: 1014,
title: 'Models',
url: 'https://github.com/stablyai/orca/pull/1014',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
},
{
position: 2,
number: 1015,
title: 'API',
url: 'https://github.com/stablyai/orca/pull/1015',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
},
{
position: 3,
number: 1016,
title: 'UI',
url: 'https://github.com/stablyai/orca/pull/1016',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
}
]
}
} as PRInfo
await renderHook(makeRepo(), undefined, stackedPR)
await act(async () => {
await latest?.handleMerge('squash')
})
expect(confirmationMocks.confirm).toHaveBeenCalledWith({
title: 'Merge through #1015?',
description:
'Included: #1014, #1015. GitHub will merge 2 pull requests atomically using squash. If any cannot merge, none will.',
confirmLabel: 'Merge 2 PRs'
})
expect(window.api.gh.mergePR).toHaveBeenCalledTimes(1)
})
it('describes merge-queue stack behavior without promising atomicity or a method', async () => {
const stackedPR = {
...githubPR,
mergeQueueRequired: true,
stack: {
number: 51,
position: 2,
size: 2,
baseRefName: 'main',
entries: [
{
position: 1,
number: 1014,
title: 'Models',
url: 'https://github.com/stablyai/orca/pull/1014',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
},
{
position: 2,
number: 1015,
title: 'API',
url: 'https://github.com/stablyai/orca/pull/1015',
state: 'open',
checksStatus: 'success',
mergeable: 'MERGEABLE'
}
]
}
} as PRInfo
await renderHook(makeRepo(), undefined, stackedPR)
await act(async () => {
await latest?.handleMerge('squash')
})
expect(confirmationMocks.confirm).toHaveBeenCalledWith({
title: 'Queue through #1015?',
description:
'Included: #1014, #1015. GitHub will add 2 pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.',
confirmLabel: 'Queue 2 PRs'
})
})
})
@@ -10,6 +10,7 @@ import {
updateGitHubHostedReviewState
} from './hosted-review-github-actions'
import { translate } from '@/i18n/i18n'
import { buildGitHubPRStackMergeConfirmation } from './github-pr-stack-confirmation'
export type HostedReviewActionInfo = Pick<
HostedReviewInfo,
@@ -62,6 +63,21 @@ export function useHostedReviewActions({
const handleMerge = useCallback(
async (method: GitHubPRMergeMethod = defaultMergeMethod) => {
if (!isGitLab && githubPR?.stack) {
const usesMergeQueue =
review.mergeQueueRequired === true || githubPR.mergeQueueRequired === true
const confirmed = await confirm(
buildGitHubPRStackMergeConfirmation({
stack: githubPR.stack,
currentPRNumber: review.number,
method,
usesMergeQueue
})
)
if (!confirmed) {
return
}
}
setMerging(true)
setActionError(null)
try {
@@ -89,7 +105,18 @@ export function useHostedReviewActions({
setMerging(false)
}
},
[githubPR?.prRepo, isGitLab, defaultMergeMethod, onRefreshReview, repo, review.number]
[
confirm,
githubPR?.prRepo,
githubPR?.mergeQueueRequired,
githubPR?.stack,
isGitLab,
defaultMergeMethod,
onRefreshReview,
repo,
review.mergeQueueRequired,
review.number
]
)
const handleAutoMerge = useCallback(async () => {
+52 -1
View File
@@ -10657,7 +10657,13 @@
"b25f63edd7": "open",
"d2ca293f3d": "Working...",
"ef064cb7c3": "default",
"59b4dccf70": "destructive"
"59b4dccf70": "destructive",
"9a41a687b7": "Queue through #{{pr}} · {{count}} PR",
"38a1bccb14": "Queue through #{{pr}} · {{count}} PRs",
"3de88351c5": "GitHub will add this pull request and every pull request below it to the merge queue.",
"a32fe6dba6": "GitHub will merge this pull request and every pull request below it in the stack.",
"73e0e1819d": "Queueing stack...",
"e555a41d32": "Merging stack..."
},
"PortsPanel": {
"3ea4a02a8f": "Cancel",
@@ -11822,6 +11828,34 @@
"d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status."
}
}
},
"pr": {
"stack": {
"confirmation": {
"84f6f5b9eb": "Included: {{numbers}}. ",
"541984b2eb": "Queue through #{{pr}}?",
"4809f55cdb": "{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.",
"be8f2621be": "{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.",
"92ca033e72": "Queue {{count}} PR",
"478a527b15": "Queue {{count}} PRs",
"1feef35ca4": "Merge through #{{pr}}?",
"c3e036c99f": "{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.",
"369aba4b32": "{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.",
"493c78f521": "Merge {{count}} PR",
"eb7051d268": "Merge {{count}} PRs"
},
"merge": {
"55ae29b907": "Merge through #{{pr}} · {{count}} PR",
"b8446f6ec2": "Merge through #{{pr}} · {{count}} PRs",
"189d0ec614": "#{{pr}} is still a draft.",
"640fb50d9c": "#{{pr}} is closed.",
"46ffcbda75": "#{{pr}} has merge conflicts.",
"6dabefd63e": "#{{pr}} has requested changes.",
"2bb21fc326": "#{{pr}} still needs review approval.",
"c23faf74df": "#{{pr}} must be updated.",
"f561e80968": "#{{pr}} is blocked."
}
}
}
},
"PluginPanel": {
@@ -11840,6 +11874,23 @@
"mayBeSlower": "May be slower",
"slowest": "Slowest",
"unlimited": "Unlimited"
},
"GitHubPRStackMap": {
"3511405914": "closed",
"8a9bdc36c0": "merged",
"568c647ccd": "draft",
"bea9ade223": "conflicts",
"838aadf512": "checks failed",
"316039b5db": "checks pending",
"4b1e5ee9d3": "changes requested",
"9a17b5255c": "review needed",
"d3d97cf3f2": "approved",
"e6cb964305": "open",
"7737bd66be": "Collapse stack #{{value0}}",
"0c1645ebd0": "Expand stack #{{value0}}",
"e3ee2daa32": "Stack #{{value0}}",
"cb440931b7": "{{value0}} of {{value1}} · {{value2}}",
"525259fa17": "Stack details are temporarily unavailable."
}
}
},
+26
View File
@@ -1243,6 +1243,30 @@ export type GitHubPRMergeMethodSettings = {
allowedMethods: Record<GitHubPRMergeMethod, boolean>
}
export type GitHubPRStackEntry = {
position: number
number: number
title: string
url: string
updatedAt?: string
state: PRState
checksStatus: CheckStatus
mergeable: PRMergeableState
reviewDecision?: PRReviewDecision | null
mergeStateStatus?: string | null
headRefName?: string
headSha?: string
}
export type GitHubPRStack = {
number: number
position: number
size: number
baseRefName: string
baseSha?: string
entries?: GitHubPRStackEntry[]
}
export type PRInfo = {
number: number
title: string
@@ -1257,6 +1281,8 @@ export type PRInfo = {
mergeQueueRequired?: boolean | null
mergeMethodSettings?: GitHubPRMergeMethodSettings
mergeStateStatus?: string | null
/** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */
stack?: GitHubPRStack
// Why: check-runs are keyed by the PR head commit, not the mutable branch name.
// Keeping the head SHA in cached PR metadata lets the checks panel poll the
// correct commit without re-querying GitHub or guessing from local branch refs.