mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat(github): support stacked pull requests (#13730)
(cherry picked from commit 25f7870c58)
This commit is contained in:
+581
-52
@@ -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
@@ -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.' }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user