mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Enhance PR auto-merge controls and switch to GraphQL mutation
- Centralize and align auto-merge eligibility logic across web and mobile clients. - Use the `enablePullRequestAutoMerge` GraphQL mutation instead of `gh pr merge --auto` to prevent immediate merges on clean branches. - Fall back to `gh pr merge --auto` when a merge queue is required on the base branch. - Hide the auto-merge control when only optional checks are pending.
This commit is contained in:
@@ -135,6 +135,7 @@ describe('GitHub PR local runtime routing', () => {
|
||||
if (args[0] === 'pr' && args[1] === 'view') {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
id: 'PR_kwDO123',
|
||||
number: 7,
|
||||
title: 'PR',
|
||||
state: 'OPEN',
|
||||
|
||||
@@ -2410,7 +2410,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
})
|
||||
|
||||
it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
})
|
||||
.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await expect(
|
||||
setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', {
|
||||
@@ -2427,13 +2431,27 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
|
||||
['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'],
|
||||
{}
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.arrayContaining([
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
'pullRequestId=PR_kwDO123',
|
||||
'-f',
|
||||
'mergeMethod=SQUASH',
|
||||
'-f',
|
||||
'expectedHeadOid=head-oid'
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
|
||||
})
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
|
||||
@@ -2442,6 +2460,70 @@ describe('GitHub GraphQL rate-limit guard', () => {
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
|
||||
})
|
||||
|
||||
it('enables auto-merge without invoking the direct merge command', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
|
||||
})
|
||||
.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await expect(
|
||||
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some(
|
||||
(call) =>
|
||||
call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
|
||||
})
|
||||
.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await expect(
|
||||
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
|
||||
owner: 'stablyai',
|
||||
repo: 'orca'
|
||||
})
|
||||
).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
|
||||
expect.objectContaining({
|
||||
cwd: '/repo-root',
|
||||
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
|
||||
})
|
||||
)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks direct merge when GitHub reports required approval', async () => {
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
|
||||
+105
-2
@@ -1988,6 +1988,12 @@ const PR_LOOKUP_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
const PR_BRANCH_LIST_JSON_FIELDS =
|
||||
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
|
||||
const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
|
||||
const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
|
||||
merge: 'MERGE',
|
||||
squash: 'SQUASH',
|
||||
rebase: 'REBASE'
|
||||
}
|
||||
|
||||
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
|
||||
acceptMergedFallbackPR?: boolean
|
||||
@@ -3751,10 +3757,10 @@ export async function setPRAutoMerge(
|
||||
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
|
||||
await acquire()
|
||||
try {
|
||||
const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto']
|
||||
if (enabled) {
|
||||
args.push(`--${method}`)
|
||||
return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions)
|
||||
}
|
||||
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
@@ -3772,6 +3778,103 @@ export async function setPRAutoMerge(
|
||||
}
|
||||
}
|
||||
|
||||
type PRAutoMergeIdentity = {
|
||||
id?: string
|
||||
headRefOid?: string
|
||||
baseRefName?: string
|
||||
}
|
||||
|
||||
async function getPRAutoMergeIdentity(
|
||||
prNumber: number,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<PRAutoMergeIdentity | null> {
|
||||
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(args, ghOptions)
|
||||
const data = JSON.parse(stdout) as PRAutoMergeIdentity
|
||||
return {
|
||||
id: typeof data.id === 'string' ? data.id : undefined,
|
||||
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
|
||||
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function runPRAutoMergeCommand(
|
||||
prNumber: number,
|
||||
method: GitHubPRMergeMethod,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<void> {
|
||||
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
|
||||
if (ownerRepo) {
|
||||
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
}
|
||||
|
||||
async function shouldUseMergeQueueAutoMerge(
|
||||
pr: PRAutoMergeIdentity,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<boolean> {
|
||||
if (!ownerRepo || !pr.baseRefName) {
|
||||
return false
|
||||
}
|
||||
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
|
||||
return mergeMetadata.mergeQueueRequired === true
|
||||
}
|
||||
|
||||
async function enablePRAutoMerge(
|
||||
prNumber: number,
|
||||
method: GitHubPRMergeMethod,
|
||||
ownerRepo: OwnerRepo | null,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
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)) {
|
||||
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
|
||||
return { ok: true }
|
||||
}
|
||||
const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) {
|
||||
enablePullRequestAutoMerge(input: {
|
||||
pullRequestId: $pullRequestId,
|
||||
mergeMethod: $mergeMethod,
|
||||
expectedHeadOid: $expectedHeadOid
|
||||
}) {
|
||||
pullRequest { id }
|
||||
}
|
||||
}`
|
||||
const args = [
|
||||
'api',
|
||||
'graphql',
|
||||
'-f',
|
||||
`query=${query}`,
|
||||
'-f',
|
||||
`pullRequestId=${pr.id}`,
|
||||
'-f',
|
||||
`mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}`
|
||||
]
|
||||
if (pr.headRefOid) {
|
||||
args.push('-f', `expectedHeadOid=${pr.headRefOid}`)
|
||||
}
|
||||
// Why: `gh pr merge --auto` can perform an immediate merge; this mutation
|
||||
// only creates GitHub's auto-merge request and lets branch requirements gate it.
|
||||
await ghExecFileAsync(args, {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function getPRMergeBlocker(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
|
||||
Reference in New Issue
Block a user