Fix GitHub merge queue PR status (#2856)

* Fix GitHub merge queue PR status

* Fix merge queue GraphQL field encoding

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jonathan Avila 2026-05-29 20:22:41 -04:00 committed by GitHub
parent 4180352856
commit 7242b41698
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1214 additions and 222 deletions

View File

@ -278,9 +278,18 @@ describe('GitHub issue source split', () => {
const item = await getWorkItem('/repo-root', 42, 'pr')
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/42'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'view',
'42',
'--repo',
'fork/orca',
'--json',
expect.stringContaining('reviewDecision')
],
{ cwd: '/repo-root' }
)
expect(item?.type).toBe('pr')
})
@ -311,9 +320,19 @@ describe('GitHub issue source split', () => {
['api', 'repos/stablyai/orca/issues/42'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/fork/orca/pulls/42'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'pr',
'view',
'42',
'--repo',
'fork/orca',
'--json',
expect.stringContaining('reviewDecision')
],
{ cwd: '/repo-root' }
)
expect(item?.type).toBe('pr')
})

View File

@ -91,8 +91,10 @@ import {
getPullRequestPushTarget,
mergePR,
resolveReviewThread,
setPRAutoMerge,
updatePRTitle,
_resetOwnerRepoCache
_resetOwnerRepoCache,
_resetMergeQueueCacheForTests
} from './client'
describe('getPRForBranch', () => {
@ -120,6 +122,7 @@ describe('getPRForBranch', () => {
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
})
it('queries GitHub by head branch when the remote is on github.com', async () => {
@ -230,7 +233,7 @@ describe('getPRForBranch', () => {
'--repo',
'stablyai/orca',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -243,7 +246,7 @@ describe('getPRForBranch', () => {
'--repo',
'fork/orca',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -282,7 +285,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -373,7 +376,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -464,7 +467,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -513,30 +516,48 @@ describe('getPRForBranch', () => {
it('prefers branch lookup over a fallback PR number', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 43,
title: 'Branch PR wins',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/43',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
head: { ref: 'feature/test', sha: 'branch-head-oid' },
base: { ref: 'main', sha: 'branch-base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 43,
title: 'Branch PR wins',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/43',
updated_at: '2026-03-28T00:00:00Z',
draft: false,
mergeable: true,
head: { ref: 'feature/test', sha: 'branch-head-oid' },
base: { ref: 'main', sha: 'branch-base-oid' }
}
])
})
title: 'Hydrated branch PR wins',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/43',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'branch-base-oid',
headRefOid: 'branch-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'],
{ cwd: '/repo-root' }
)
expect(pr).toMatchObject({ number: 43, title: 'Branch PR wins' })
expect(pr).toMatchObject({ number: 43, title: 'Hydrated branch PR wins' })
})
it('uses a fallback PR number only after branch lookup misses', async () => {
@ -576,7 +597,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -613,13 +634,54 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(77)
})
it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => {
getOwnerRepoMock.mockResolvedValueOnce(null)
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 77,
title: 'Linked fallback PR',
state: 'OPEN',
url: 'https://example.com/pr/77',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
reviewDecision: '',
autoMergeRequest: { enabledAt: '2026-03-28T00:00:00Z' },
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'view',
'77',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/non-github-repo' }
)
expect(pr).toMatchObject({
number: 77,
reviewDecision: null,
autoMergeEnabled: true
})
expect(pr?.mergeQueueRequired).toBeUndefined()
})
it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => {
getOwnerRepoMock.mockResolvedValueOnce(null)
ghExecFileAsyncMock.mockResolvedValueOnce({
@ -647,7 +709,7 @@ describe('getPRForBranch', () => {
'view',
'feature/test',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/non-github-repo' }
)
@ -823,7 +885,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -871,7 +933,7 @@ describe('getPRForBranch', () => {
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -1039,6 +1101,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
})
it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => {
@ -1140,7 +1203,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
'--repo',
'stablyai/orca',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
@ -1159,6 +1222,149 @@ 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: '' })
await expect(
setPRAutoMerge('/remote/repo-root', 7, true, 'ssh-1', {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
await expect(
setPRAutoMerge('/remote/repo-root', 7, false, 'ssh-1', {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['pr', 'merge', '7', '--auto', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
})
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'
})
})
await expect(
mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' })
).resolves.toEqual({
ok: false,
error: 'This pull request requires review approval before it can be merged.'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql')
})
it('detects merge queues once per base branch and blocks direct merges', async () => {
const prView = {
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: 'APPROVED',
mergeStateStatus: 'CLEAN',
autoMergeRequest: null,
baseRefName: 'true',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
})
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
await expect(
mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' })
).resolves.toEqual({
ok: false,
error:
'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.'
})
await expect(
mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' })
).resolves.toMatchObject({ ok: false })
expect(
ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql'))
).toHaveLength(1)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual(
expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true'])
)
expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F')
})
it('caches unknown merge queue probes after GraphQL failures', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
const prView = {
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: 'APPROVED',
mergeStateStatus: 'CLEAN',
autoMergeRequest: null,
baseRefName: 'main',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
.mockRejectedValueOnce(new Error('network is down'))
.mockResolvedValueOnce({ stdout: JSON.stringify(prView) })
await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({
mergeQueueRequired: null
})
await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({
mergeQueueRequired: null
})
expect(
ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql'))
).toHaveLength(1)
})
it('returns conflicting file details instead of running gh merge when PR is dirty', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
@ -1197,14 +1403,31 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
it('does not run merge conflict preflight for SSH-backed repos', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
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'
})
})
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(
mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' })
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })

View File

@ -9,6 +9,7 @@ import type {
PRConflictSummary,
PRRefreshOutcome,
PRMergeableState,
PRReviewDecision,
PRCheckDetail,
PRCheckRunDetails,
GitHubCommentResult,
@ -83,6 +84,13 @@ import {
type GhExecOptions = ReturnType<typeof ghRepoExecOptions>
const ORCA_REPO = 'stablyai/orca'
const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000
const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000
const mergeQueueRequiredCache = new Map<string, { value: boolean | null; expiresAt: number }>()
export function _resetMergeQueueCacheForTests(): void {
mergeQueueRequiredCache.clear()
}
async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise<void> {
await getRateLimit()
@ -346,7 +354,7 @@ const WORK_ITEM_PR_LIST_JSON_FIELDS =
// GraphQL work across every row. Requested reviewers are kept in the list
// payload because the Tasks table renders that column on first paint.
const WORK_ITEM_PR_DETAIL_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify'
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify'
function mapIssueWorkItem(item: Record<string, unknown>): MainWorkItem {
return {
@ -509,6 +517,16 @@ function normalizePRMergeable(value: unknown): PRMergeableState | undefined {
return undefined
}
function normalizeReviewDecision(value: unknown): PRReviewDecision | null {
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
? value
: null
}
function isAutoMergeEnabled(value: unknown): boolean {
return typeof value === 'object' && value !== null
}
function checkRollupEntries(value: unknown): unknown[] {
if (Array.isArray(value)) {
return value
@ -637,7 +655,7 @@ function mapPullRequestWorkItem(
...(deletions !== undefined ? { deletions } : {}),
...(changedFiles !== undefined ? { changedFiles } : {}),
...('reviewDecision' in item
? { reviewDecision: typeof item.reviewDecision === 'string' ? item.reviewDecision : null }
? { reviewDecision: normalizeReviewDecision(item.reviewDecision) }
: {}),
...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined
? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) }
@ -650,6 +668,9 @@ function mapPullRequestWorkItem(
? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) }
: {}),
...(mergeable ? { mergeable } : {}),
...('autoMergeRequest' in item
? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) }
: {}),
...('mergeStateStatus' in item
? {
mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null
@ -696,11 +717,31 @@ async function fetchPullRequestWorkItem(
): Promise<MainWorkItem> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
if (ownerRepo) {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>, ownerRepo)
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
WORK_ITEM_PR_DETAIL_JSON_FIELDS
],
ghOptions
)
const item = JSON.parse(stdout) as Record<string, unknown>
const mapped = mapPullRequestWorkItem(item, ownerRepo)
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
const mergeQueueRequired = await detectMergeQueueRequired(ownerRepo, baseRefName, ghOptions)
return { ...mapped, mergeQueueRequired }
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>, ownerRepo)
}
}
const { stdout } = await ghExecFileAsync(
@ -1647,6 +1688,11 @@ type PullRequestLookupData = {
updatedAt: string
isDraft?: boolean
mergeable: string
reviewDecision?: PRReviewDecision | null
autoMergeRequest?: unknown
autoMergeEnabled?: boolean
mergeQueueRequired?: boolean | null
mergeStateStatus?: string | null
baseRefName?: string
headRefName?: string
baseRefOid?: string
@ -1669,6 +1715,8 @@ type RestPullRequest = {
}
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'
function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState {
@ -1699,6 +1747,108 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData {
}
}
function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData {
return {
...data,
reviewDecision:
data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined,
autoMergeEnabled:
data.autoMergeEnabled ??
('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined)
}
}
function cacheMergeQueueRequired(cacheKey: string, value: boolean | null, ttlMs: number): void {
mergeQueueRequiredCache.set(cacheKey, {
value,
expiresAt: Date.now() + ttlMs
})
}
async function detectMergeQueueRequired(
ownerRepo: OwnerRepo,
branchName: string | undefined,
ghOptions: GhExecOptions
): Promise<boolean | null> {
if (!branchName) {
return null
}
const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${branchName}`
const cached = mergeQueueRequiredCache.get(cacheKey)
if (cached && cached.expiresAt > Date.now()) {
return cached.value
}
const guard = rateLimitGuard('graphql')
if (guard.blocked) {
return null
}
const query = `query($owner: String!, $repo: String!, $branch: String!) {
repository(owner: $owner, name: $repo) {
mergeQueue(branch: $branch) { id }
}
}`
try {
noteRateLimitSpend('graphql')
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-f',
`branch=${branchName}`
],
ghOptions
)
const parsed = JSON.parse(stdout) as {
data?: { repository?: { mergeQueue?: { id?: unknown } | null } | null }
}
const value = Boolean(parsed.data?.repository?.mergeQueue)
cacheMergeQueueRequired(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS)
return value
} catch {
// Why: failed merge-queue probes should stay conservative without
// retrying GraphQL on every status poll while GitHub/network is unhappy.
cacheMergeQueueRequired(cacheKey, null, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS)
return null
}
}
async function hydratePullRequestLookupData(
ownerRepo: OwnerRepo,
data: PullRequestLookupData,
ghOptions: GhExecOptions
): Promise<PullRequestLookupData> {
const normalized = normalizePullRequestLookupData(data)
const hasRichMergeFields =
'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data
return {
...normalized,
mergeQueueRequired: hasRichMergeFields
? await detectMergeQueueRequired(ownerRepo, normalized.baseRefName, ghOptions)
: undefined
}
}
async function hydrateBranchLookupWithExactPR(
ownerRepo: OwnerRepo,
branchData: PullRequestLookupData | null,
ghOptions: GhExecOptions
): Promise<PullRequestLookupData | null> {
if (!branchData) {
return null
}
try {
return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData
} catch {
return branchData
}
}
async function getRestPRForBranch(
prRepo: OwnerRepo,
headOwner: string,
@ -1733,7 +1883,7 @@ async function getFallbackPRListForBranch(
'--limit',
'1',
'--json',
PR_LOOKUP_JSON_FIELDS
PR_BRANCH_LIST_JSON_FIELDS
],
ghOptions
)
@ -1771,7 +1921,11 @@ async function getPRByNumber(
],
ghOptions
)
return JSON.parse(stdout) as PullRequestLookupData
return hydratePullRequestLookupData(
ownerRepo,
JSON.parse(stdout) as PullRequestLookupData,
ghOptions
)
} catch (err) {
// Why: deleted or manually edited linked PR metadata should fall back to
// branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
@ -1779,7 +1933,8 @@ async function getPRByNumber(
return null
}
try {
return await getRestPRByNumber(ownerRepo, number, ghOptions)
const restData = await getRestPRByNumber(ownerRepo, number, ghOptions)
return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null
} catch (restErr) {
if (isNotFoundGhError(restErr)) {
return null
@ -1821,7 +1976,10 @@ async function lookupPRByNumber(args: {
['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS],
args.ghOptions
)
return { data: JSON.parse(stdout), dataRepo: null }
return {
data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData),
dataRepo: null
}
} catch (err) {
if (isNoPullRequestError(err)) {
// Why: stale cached fallback numbers should not turn every poll into an
@ -1908,9 +2066,12 @@ export async function getPRForBranchOutcome(
if (candidates.length > 0) {
for (const candidate of candidates) {
try {
data = headRepo
const branchData = headRepo
? await getRestPRForBranch(candidate, headRepo.owner, branchName, ghOptions)
: await getFallbackPRListForBranch(candidate, branchName, ghOptions)
// Why: REST/list branch lookup identifies the PR cheaply; exact
// `gh pr view` carries review, merge queue, and auto-merge state.
data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions)
if (data) {
dataRepo = candidate
break
@ -1919,7 +2080,13 @@ export async function getPRForBranchOutcome(
if (headRepo) {
throw err
} else {
data = await getRestPRForBranch(candidate, candidate.owner, branchName, ghOptions)
const branchData = await getRestPRForBranch(
candidate,
candidate.owner,
branchName,
ghOptions
)
data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions)
if (data) {
dataRepo = candidate
break
@ -1933,7 +2100,7 @@ export async function getPRForBranchOutcome(
['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS],
ghOptions
)
data = JSON.parse(stdout)
data = normalizePullRequestLookupData(JSON.parse(stdout))
} catch (err) {
if (isNoPullRequestError(err)) {
data = null
@ -1976,6 +2143,12 @@ export async function getPRForBranchOutcome(
checksStatus: deriveCheckStatus(data.statusCheckRollup),
updatedAt: data.updatedAt,
mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN',
...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}),
...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}),
...(data.mergeQueueRequired !== undefined
? { mergeQueueRequired: data.mergeQueueRequired }
: {}),
...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}),
headSha: data.headRefOid,
prRepo: dataRepo ?? undefined,
headRepo: headRepo ?? undefined,
@ -2889,6 +3062,35 @@ export async function mergePR(
}
}
export async function setPRAutoMerge(
repoPath: string,
prNumber: number,
enabled: boolean,
connectionId?: string | null,
prRepo?: OwnerRepo | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId))
await acquire()
try {
const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto']
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifyGhError(message).message }
} finally {
release()
}
}
async function getPRMergeBlocker(
repoPath: string,
prNumber: number,
@ -2896,15 +3098,33 @@ async function getPRMergeBlocker(
ghOptions: GhExecOptions,
connectionId?: string | null
): Promise<string | null> {
// Why: conflict summaries shell out to local git; SSH repo paths are remote-only
// until that helper is routed through the SSH git provider.
if (!ownerRepo || connectionId) {
if (!ownerRepo) {
return null
}
try {
const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions)
if (pr?.mergeable !== 'CONFLICTING' || !pr.baseRefName || !pr.baseRefOid || !pr.headRefOid) {
if (!pr) {
return null
}
if (pr.reviewDecision === 'REVIEW_REQUIRED') {
return 'This pull request requires review approval before it can be merged.'
}
if (pr.reviewDecision === 'CHANGES_REQUESTED') {
return 'This pull request has requested changes and cannot be merged yet.'
}
if (pr.mergeQueueRequired === true) {
return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.'
}
// Why: conflict summaries shell out to local git; SSH repo paths are remote-only
// until that helper is routed through the SSH git provider.
if (
connectionId ||
pr.mergeable !== 'CONFLICTING' ||
!pr.baseRefName ||
!pr.baseRefOid ||
!pr.headRefOid
) {
return null
}

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: GitHub IPC tests share one mocked Electron
handler harness; keeping the related route wiring together avoids duplicated setup. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
@ -8,6 +10,7 @@ const {
listWorkItemsMock,
getAuthenticatedViewerMock,
mergePRMock,
setPRAutoMergeMock,
checkOrcaStarredMock,
starOrcaMock,
trackMock,
@ -21,6 +24,7 @@ const {
listWorkItemsMock: vi.fn(),
getAuthenticatedViewerMock: vi.fn(),
mergePRMock: vi.fn(),
setPRAutoMergeMock: vi.fn(),
checkOrcaStarredMock: vi.fn(),
starOrcaMock: vi.fn(),
trackMock: vi.fn(),
@ -44,6 +48,7 @@ vi.mock('../github/client', () => ({
listWorkItems: listWorkItemsMock,
getAuthenticatedViewer: getAuthenticatedViewerMock,
mergePR: mergePRMock,
setPRAutoMerge: setPRAutoMergeMock,
checkOrcaStarred: checkOrcaStarredMock,
starOrca: starOrcaMock
}))
@ -88,6 +93,7 @@ describe('registerGitHubHandlers', () => {
listWorkItemsMock.mockReset()
getAuthenticatedViewerMock.mockReset()
mergePRMock.mockReset()
setPRAutoMergeMock.mockReset()
checkOrcaStarredMock.mockReset()
starOrcaMock.mockReset()
trackMock.mockReset()
@ -282,6 +288,28 @@ describe('registerGitHubHandlers', () => {
})
})
it('threads SSH connectionId through pull request auto-merge', async () => {
repos[0].connectionId = 'openclaw-2'
setPRAutoMergeMock.mockResolvedValue({ ok: true })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:setPRAutoMerge'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
enabled: true,
prRepo: { owner: 'acme', repo: 'orca' }
}
)
expect(setPRAutoMergeMock).toHaveBeenCalledWith('/workspace/repo', 42, true, 'openclaw-2', {
owner: 'acme',
repo: 'orca'
})
})
it('forwards the authenticated viewer lookup', async () => {
getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' })

View File

@ -39,6 +39,7 @@ import {
addPRReviewCommentReply,
updatePRTitle,
mergePR,
setPRAutoMerge,
updatePRState,
rerunPRChecks,
requestPRReviewers,
@ -683,6 +684,35 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
}
)
ipcMain.handle(
'gh:setPRAutoMerge',
async (
event,
args: {
repoPath: string
prNumber: number
enabled: boolean
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredRepo(args, store)
const result = await setPRAutoMerge(
repo.path,
args.prNumber,
args.enabled,
repoConnectionId(repo),
args.prRepo ?? null
)
if (result.ok) {
broadcastWorkItemMutated(
{ repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber },
event.sender.id
)
}
return result
}
)
ipcMain.handle(
'gh:updatePRState',
async (

View File

@ -170,6 +170,7 @@ import {
updatePRTitle,
updatePRDetails,
mergePR,
setPRAutoMerge,
updatePRState,
requestPRReviewers,
removePRReviewers,
@ -6424,6 +6425,16 @@ export class OrcaRuntimeService {
return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null)
}
async setRepoPRAutoMerge(
repoSelector: string,
prNumber: number,
enabled: boolean,
prRepo?: GitHubOwnerRepo | null
): Promise<Awaited<ReturnType<typeof setPRAutoMerge>>> {
const repo = await this.resolveRepoSelector(repoSelector)
return setPRAutoMerge(repo.path, prNumber, enabled, repo.connectionId ?? null, prRepo ?? null)
}
async updateRepoPRState(
repoSelector: string,
prNumber: number,

View File

@ -371,6 +371,29 @@ describe('github RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('sets PR auto-merge on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
setRepoPRAutoMerge: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.setPRAutoMerge', {
repo: 'repo-1',
prNumber: 7,
enabled: true,
prRepo: { owner: 'acme', repo: 'widgets' }
})
)
expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, {
owner: 'acme',
repo: 'widgets'
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('routes PR reviewer mutations on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -123,6 +123,12 @@ const MergePr = RepoSelector.extend({
prRepo: SlugRepo.nullable().optional()
})
const SetPrAutoMerge = RepoSelector.extend({
prNumber: z.number().int().positive(),
enabled: z.boolean(),
prRepo: SlugRepo.nullable().optional()
})
const UpdatePrState = RepoSelector.extend({
prNumber: z.number().int().positive(),
updates: z.object({
@ -440,6 +446,17 @@ export const GITHUB_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null)
}),
defineMethod({
name: 'github.setPRAutoMerge',
params: SetPrAutoMerge,
handler: async (params, { runtime }) =>
runtime.setRepoPRAutoMerge(
params.repo,
params.prNumber,
params.enabled,
params.prRepo ?? null
)
}),
defineMethod({
name: 'github.updatePRState',
params: UpdatePrState,

View File

@ -169,6 +169,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'github.listLabels',
'github.listWorkItems',
'github.mergePR',
'github.setPRAutoMerge',
'github.requestPRReviewers',
'github.project.listAccessible',
'github.project.listAssignableUsersBySlug',

View File

@ -1005,6 +1005,13 @@ export type PreloadApi = {
method?: 'merge' | 'squash' | 'rebase'
prRepo?: GitHubOwnerRepo | null
}) => Promise<{ ok: true } | { ok: false; error: string }>
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
prNumber: number
enabled: boolean
prRepo?: GitHubOwnerRepo | null
}) => Promise<{ ok: true } | { ok: false; error: string }>
updatePRState: (args: {
repoPath: string
repoId?: string

View File

@ -1074,6 +1074,15 @@ const api = {
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:mergePR', args),
setPRAutoMerge: (args: {
repoPath: string
repoId?: string
prNumber: number
enabled: boolean
prRepo?: { owner: string; repo: string } | null
}): Promise<{ ok: true } | { ok: false; error: string }> =>
ipcRenderer.invoke('gh:setPRAutoMerge', args),
updatePRState: (args: {
repoPath: string
repoId?: string

View File

@ -61,6 +61,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
@ -107,6 +108,7 @@ import {
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
} from '@/components/github-pr-reviewer-display'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { getConnectionId } from '@/lib/connection-context'
@ -365,31 +367,6 @@ function getStateTone(item: GitHubWorkItem): string {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
}
function getPRMergeTooltip(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'This pull request is already merged'
}
if (item.state === 'closed') {
return 'This pull request is closed'
}
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge status is unavailable for this PR'
}
if (item.mergeable === 'CONFLICTING') {
return 'GitHub reports merge conflicts'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'Update the branch before merging'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'GitHub reports this pull request is blocked'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'GitHub says this PR can merge'
}
return 'GitHub has not reported a final merge status'
}
function WorkItemStateBadge({
item,
className
@ -2830,14 +2807,10 @@ function PRActionsPanel({
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const confirm = useConfirmationDialog()
const actionItem = { ...item, state: localState }
const mergePresentation = presentGitHubPRMergeState(actionItem)
const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin)
const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed'
const mergeDisabled =
!repoPath ||
mergePending ||
localState === 'closed' ||
localState === 'merged' ||
item.mergeable === 'CONFLICTING'
const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable
const patchProjectRowIfNeeded = useCallback(
(state: GitHubWorkItem['state']) => {
@ -2916,7 +2889,8 @@ function PRActionsPanel({
repoPath,
repoId: repoId ?? undefined,
prNumber: item.number,
method
method,
prRepo: item.prRepo ?? null
})
if (!result.ok) {
toast.error(result.error)
@ -2932,6 +2906,33 @@ function PRActionsPanel({
}
}
const handleAutoMerge = async (): Promise<void> => {
if (!repoPath || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMergePending(true)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath,
repoId: repoId ?? undefined,
prNumber: item.number,
enabled,
prRepo: item.prRepo ?? null
})
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled')
onMutated()
} catch {
toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge')
} finally {
setMergePending(false)
}
}
return (
<aside className="rounded-lg border border-border/50 bg-card/50 p-3 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
@ -2960,16 +2961,27 @@ function PRActionsPanel({
) : (
<GitMerge className="size-3.5" />
)}
Merge
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable ? 'Merge' : mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{!repoPath ? 'Merge requires a registered local repo' : getPRMergeTooltip(actionItem)}
{!repoPath ? 'Merge requires a registered local repo' : mergePresentation.tooltip}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-52">
{mergePresentation.autoMergeAction && (
<DropdownMenuItem
disabled={!repoPath || mergePending}
onSelect={() => void handleAutoMerge()}
>
<GitMerge className="size-4" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
)}
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('squash')}>
<GitMerge className="size-4" />
Squash and merge

View File

@ -61,6 +61,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
@ -108,6 +109,7 @@ import {
getGitHubPRReviewerRows,
normalizeGitHubReviewerLogins
} from '@/components/github-pr-reviewer-display'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection'
import { getConnectionId } from '@/lib/connection-context'
@ -365,31 +367,6 @@ function getStateTone(item: GitHubWorkItem): string {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
}
function getPRMergeTooltip(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'This pull request is already merged'
}
if (item.state === 'closed') {
return 'This pull request is closed'
}
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge status is unavailable for this PR'
}
if (item.mergeable === 'CONFLICTING') {
return 'GitHub reports merge conflicts'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'Update the branch before merging'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'GitHub reports this pull request is blocked'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'GitHub says this PR can merge'
}
return 'GitHub has not reported a final merge status'
}
function WorkItemStateBadge({
item,
className
@ -2959,14 +2936,10 @@ function PRActionsPanel({
const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent)
const confirm = useConfirmationDialog()
const actionItem = { ...item, state: localState }
const mergePresentation = presentGitHubPRMergeState(actionItem)
const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin)
const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed'
const mergeDisabled =
!repoPath ||
mergePending ||
localState === 'closed' ||
localState === 'merged' ||
item.mergeable === 'CONFLICTING'
const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable
const patchProjectRowIfNeeded = useCallback(
(state: GitHubWorkItem['state']) => {
@ -3045,7 +3018,8 @@ function PRActionsPanel({
repoPath,
repoId: repoId ?? undefined,
prNumber: item.number,
method
method,
prRepo: item.prRepo ?? null
})
if (!result.ok) {
toast.error(result.error)
@ -3061,6 +3035,33 @@ function PRActionsPanel({
}
}
const handleAutoMerge = async (): Promise<void> => {
if (!repoPath || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMergePending(true)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath,
repoId: repoId ?? undefined,
prNumber: item.number,
enabled,
prRepo: item.prRepo ?? null
})
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled')
onMutated()
} catch {
toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge')
} finally {
setMergePending(false)
}
}
return (
<aside className="rounded-lg border border-border/50 bg-card p-3 shadow-xs">
<div className="mb-3 flex items-center justify-between gap-2">
@ -3089,16 +3090,27 @@ function PRActionsPanel({
) : (
<GitMerge className="size-3.5" />
)}
Merge
{mergePresentation.autoMergeAction?.label ??
(mergePresentation.directMergeAvailable ? 'Merge' : mergePresentation.label)}
<ChevronDown className="size-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{!repoPath ? 'Merge requires a registered local repo' : getPRMergeTooltip(actionItem)}
{!repoPath ? 'Merge requires a registered local repo' : mergePresentation.tooltip}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-52">
{mergePresentation.autoMergeAction && (
<DropdownMenuItem
disabled={!repoPath || mergePending}
onSelect={() => void handleAutoMerge()}
>
<GitMerge className="size-4" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
)}
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('squash')}>
<GitMerge className="size-4" />
Squash and merge

View File

@ -133,6 +133,7 @@ import {
type TaskPageRepoSourceState
} from '@/components/task-page-cache-selectors'
import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import type {
GitHubOwnerRepo,
GitHubAssignableUser,
@ -1165,75 +1166,6 @@ function sameOptionalGitHubOwnerRepo(
: sameGitHubOwnerRepo(leftValue, rightValue)
}
function getMergeLabel(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'Merged'
}
if (item.state === 'closed') {
return 'Closed'
}
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge'
}
if (item.mergeable === 'CONFLICTING') {
return 'Conflicts'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'Behind'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'Blocked'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'Able to merge'
}
return 'Unknown'
}
function getMergeTone(item: GitHubWorkItem): string {
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
}
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
return 'border-border/60 bg-background/70 text-muted-foreground'
}
function getMergeTooltip(item: GitHubWorkItem): string {
if (item.state === 'merged') {
return 'This pull request is already merged'
}
if (item.state === 'closed') {
return 'This pull request is closed'
}
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge status is unavailable for this PR'
}
if (item.mergeable === 'CONFLICTING') {
return 'GitHub reports merge conflicts'
}
if (item.mergeStateStatus === 'BEHIND') {
return 'Update the branch before merging'
}
if (item.mergeStateStatus === 'BLOCKED') {
return 'GitHub reports this pull request is blocked'
}
if (item.checksSummary?.state === 'pending') {
return 'GitHub says this PR can merge, but checks are still running'
}
if (item.checksSummary?.state === 'success') {
return 'GitHub says this PR can merge and checks passed'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'GitHub says this PR can merge'
}
return 'GitHub has not reported a final merge status'
}
function mergeReviewerSuggestions(
users: GitHubAssignableUser[],
seedUsers: GitHubAssignableUser[]
@ -1735,12 +1667,8 @@ function PRMergeCell({
if (item.type !== 'pr') {
return <span className="text-[11px] text-muted-foreground">Issue</span>
}
const mergeDisabled =
!repo ||
merging ||
item.state === 'closed' ||
item.state === 'merged' ||
item.mergeable === 'CONFLICTING'
const mergePresentation = presentGitHubPRMergeState(item)
const mergeDisabled = !repo || merging || !mergePresentation.directMergeAvailable
const handleMerge = async (method: 'merge' | 'squash' | 'rebase'): Promise<void> => {
if (!repo || mergeDisabled) {
@ -1762,7 +1690,8 @@ function PRMergeCell({
repoPath: repo.path,
repoId: repo.id,
prNumber: item.number,
method
method,
prRepo: item.prRepo ?? null
})
if (result.ok) {
toast.success('Pull request merged')
@ -1777,6 +1706,33 @@ function PRMergeCell({
}
}
const handleAutoMerge = async (): Promise<void> => {
if (!repo || !mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMerging(true)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: item.number,
enabled,
prRepo: item.prRepo ?? null
})
if (result.ok) {
toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled')
onRefresh()
} else {
toast.error(result.error)
}
} catch {
toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge')
} finally {
setMerging(false)
}
}
return (
<DropdownMenu modal={false}>
<Tooltip>
@ -1787,7 +1743,7 @@ function PRMergeCell({
onClick={(event) => event.stopPropagation()}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110',
getMergeTone(item)
mergePresentation.tone
)}
>
{merging ? (
@ -1795,16 +1751,23 @@ function PRMergeCell({
) : (
<GitMerge className="size-3" />
)}
<span className="truncate">{getMergeLabel(item)}</span>
<span className="truncate">{mergePresentation.label}</span>
<ChevronDown className="size-2.5 opacity-60" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{getMergeTooltip(item)}
{mergePresentation.tooltip}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" onClick={(event) => event.stopPropagation()}>
{mergePresentation.autoMergeAction && (
<DropdownMenuItem disabled={!repo || merging} onSelect={() => void handleAutoMerge()}>
<GitMerge className="size-4" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
)}
{mergePresentation.autoMergeAction && <DropdownMenuSeparator />}
<DropdownMenuItem disabled={mergeDisabled} onSelect={() => void handleMerge('squash')}>
<GitMerge className="size-4" />
Squash and merge

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { presentGitHubPRMergeState, type GitHubPRMergeStateInput } from './github-pr-merge-state'
function pr(overrides: Partial<GitHubPRMergeStateInput> = {}): GitHubPRMergeStateInput {
return {
state: 'open',
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
checksSummary: { state: 'success', total: 1, passed: 1, failed: 0, pending: 0 },
...overrides
}
}
describe('presentGitHubPRMergeState', () => {
it('blocks direct merge when approval is required or changes are requested', () => {
expect(presentGitHubPRMergeState(pr({ reviewDecision: 'REVIEW_REQUIRED' }))).toMatchObject({
label: 'Approval required',
directMergeAvailable: false
})
expect(presentGitHubPRMergeState(pr({ reviewDecision: 'CHANGES_REQUESTED' }))).toMatchObject({
label: 'Changes requested',
directMergeAvailable: false
})
})
it('offers merge-queue auto-merge only after explicit queue detection', () => {
expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: true }))).toMatchObject({
label: 'Merge when ready',
directMergeAvailable: false,
autoMergeAction: { kind: 'enable', label: 'Merge when ready' }
})
expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: null })).autoMergeAction).toBeNull()
})
it('offers disable auto-merge when GitHub reports auto-merge is already enabled', () => {
expect(presentGitHubPRMergeState(pr({ autoMergeEnabled: true }))).toMatchObject({
autoMergeAction: { kind: 'disable', label: 'Disable auto-merge' }
})
})
it('blocks conflicts and behind branches, but not optional aggregate check failures', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
).toMatchObject({ label: 'Conflicts', directMergeAvailable: false })
expect(presentGitHubPRMergeState(pr({ mergeStateStatus: 'BEHIND' }))).toMatchObject({
label: 'Behind',
directMergeAvailable: false
})
expect(
presentGitHubPRMergeState(
pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } })
)
).toMatchObject({ label: 'Checks pending', directMergeAvailable: true })
expect(
presentGitHubPRMergeState(
pr({ checksSummary: { state: 'failure', total: 1, passed: 0, failed: 1, pending: 0 } })
)
).toMatchObject({ label: 'Checks failed', directMergeAvailable: true })
expect(presentGitHubPRMergeState(pr())).toMatchObject({
label: 'Able to merge',
directMergeAvailable: true
})
})
it('suppresses auto-merge actions for non-open PR states', () => {
expect(
presentGitHubPRMergeState(pr({ state: 'closed', mergeQueueRequired: true })).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(pr({ state: 'merged', autoMergeEnabled: true })).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(pr({ state: 'draft', mergeQueueRequired: true })).autoMergeAction
).toBeNull()
})
})

View File

@ -0,0 +1,196 @@
import type {
CheckStatus,
GitHubPRCheckSummary,
PRMergeableState,
PRReviewDecision,
PRState
} from '../../../shared/types'
export type GitHubPRMergeStateInput = {
state: PRState | 'open' | 'closed' | 'merged' | 'draft'
mergeable?: PRMergeableState
mergeStateStatus?: string | null
reviewDecision?: PRReviewDecision | null
checksStatus?: CheckStatus
checksSummary?: GitHubPRCheckSummary
autoMergeEnabled?: boolean
mergeQueueRequired?: boolean | null
}
export type GitHubPRAutoMergeAction = {
kind: 'enable' | 'disable'
label: string
tooltip: string
}
export type GitHubPRMergeStatePresentation = {
label: string
tone: string
tooltip: string
directMergeAvailable: boolean
autoMergeAction: GitHubPRAutoMergeAction | null
}
const MUTED_TONE = 'border-border/60 bg-background/70 text-muted-foreground'
const SUCCESS_TONE =
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
const WARNING_TONE = 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200'
const DANGER_TONE = 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
function checksState(item: GitHubPRMergeStateInput): CheckStatus | 'none' | undefined {
if (item.checksSummary) {
return item.checksSummary.state
}
return item.checksStatus
}
function hasFullMergeMetadata(item: GitHubPRMergeStateInput): boolean {
return item.mergeable !== undefined || item.mergeStateStatus !== undefined
}
export function presentGitHubPRMergeState(
item: GitHubPRMergeStateInput
): GitHubPRMergeStatePresentation {
const autoMergeAction =
item.state !== 'open'
? null
: item.autoMergeEnabled === true
? {
kind: 'disable' as const,
label: 'Disable auto-merge',
tooltip: 'GitHub auto-merge is enabled for this pull request'
}
: item.mergeQueueRequired === true
? {
kind: 'enable' as const,
label: 'Merge when ready',
tooltip: 'Add this pull request to the GitHub merge queue'
}
: null
if (item.state === 'merged') {
return {
label: 'Merged',
tone: MUTED_TONE,
tooltip: 'This pull request is already merged',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.state === 'closed') {
return {
label: 'Closed',
tone: DANGER_TONE,
tooltip: 'This pull request is closed',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.state === 'draft') {
return {
label: 'Draft',
tone: MUTED_TONE,
tooltip: 'This pull request is still a draft',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.reviewDecision === 'REVIEW_REQUIRED') {
return {
label: 'Approval required',
tone: WARNING_TONE,
tooltip: 'GitHub requires review approval before this pull request can merge',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return {
label: 'Changes requested',
tone: DANGER_TONE,
tooltip: 'GitHub reports requested changes on this pull request',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.mergeQueueRequired === true) {
return {
label: item.autoMergeEnabled ? 'Auto-merge on' : 'Merge when ready',
tone: WARNING_TONE,
tooltip: 'This base branch uses GitHub merge queue',
directMergeAvailable: false,
autoMergeAction
}
}
if (!hasFullMergeMetadata(item)) {
return {
label: 'Merge',
tone: MUTED_TONE,
tooltip: 'Merge status is unavailable for this PR',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.mergeable === 'CONFLICTING') {
return {
label: 'Conflicts',
tone: DANGER_TONE,
tooltip: 'GitHub reports merge conflicts',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.mergeStateStatus === 'BEHIND') {
return {
label: 'Behind',
tone: WARNING_TONE,
tooltip: 'Update the branch before merging',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.mergeStateStatus === 'BLOCKED') {
return {
label: 'Blocked',
tone: DANGER_TONE,
tooltip: 'GitHub reports this pull request is blocked',
directMergeAvailable: false,
autoMergeAction
}
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
const checkState = checksState(item)
const checkStatus =
checkState === 'failure'
? {
label: 'Checks failed',
tone: DANGER_TONE,
tooltip: 'GitHub says this PR can merge, but some checks failed'
}
: checkState === 'pending'
? {
label: 'Checks pending',
tone: WARNING_TONE,
tooltip: 'GitHub says this PR can merge, but checks are still running'
}
: null
return {
label: checkStatus?.label ?? 'Able to merge',
tone: checkStatus?.tone ?? SUCCESS_TONE,
tooltip:
checkStatus?.tooltip ??
(checkState === 'success'
? 'GitHub says this PR can merge and checks passed'
: 'GitHub says this PR can merge'),
directMergeAvailable: true,
autoMergeAction
}
}
return {
label: 'Unknown',
tone: MUTED_TONE,
tooltip: 'GitHub has not reported a final merge status',
directMergeAvailable: false,
autoMergeAction
}
}

View File

@ -20,6 +20,7 @@ import {
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state'
import type { PRInfo, Repo, Worktree } from '../../../../shared/types'
import { runWorktreeDelete } from '../sidebar/delete-worktree-flow'
@ -50,6 +51,16 @@ export default function PRActions({
const [stateUpdating, setStateUpdating] = useState<'open' | 'closed' | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const mergePresentation = presentGitHubPRMergeState(pr)
const isUpdatingPRState = stateUpdating !== null
const primaryMergeDisabled =
merging ||
isUpdatingPRState ||
(!mergePresentation.directMergeAvailable && !mergePresentation.autoMergeAction)
const directMergeDisabled =
merging || isUpdatingPRState || !mergePresentation.directMergeAvailable
const menuDisabled = merging || isUpdatingPRState
const handleMerge = useCallback(
async (method: 'merge' | 'squash' | 'rebase' = 'squash') => {
setMerging(true)
@ -76,6 +87,33 @@ export default function PRActions({
[repo.id, repo.path, pr.number, pr.prRepo, onRefreshPR]
)
const handleAutoMerge = useCallback(async () => {
if (!mergePresentation.autoMergeAction) {
return
}
const enabled = mergePresentation.autoMergeAction.kind === 'enable'
setMerging(true)
setActionError(null)
try {
const result = await window.api.gh.setPRAutoMerge({
repoPath: repo.path,
repoId: repo.id,
prNumber: pr.number,
enabled,
prRepo: pr.prRepo ?? null
})
if (!result.ok) {
setActionError(result.error)
} else {
await onRefreshPR()
}
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Auto-merge update failed')
} finally {
setMerging(false)
}
}, [mergePresentation.autoMergeAction, onRefreshPR, pr.number, pr.prRepo, repo.id, repo.path])
const handlePRStateChange = useCallback(
async (nextState: 'open' | 'closed') => {
if (stateUpdating) {
@ -136,14 +174,6 @@ export default function PRActions({
runWorktreeDelete(worktree.id)
}, [worktree.id])
// Why: merging a PR with unresolved conflicts would fail on GitHub anyway;
// disabling the button prevents a confusing error and signals the user must
// resolve conflicts first.
const hasConflicts = pr.mergeable === 'CONFLICTING'
const isUpdatingPRState = stateUpdating !== null
const mergeDisabled = merging || isUpdatingPRState || hasConflicts
const menuDisabled = merging || isUpdatingPRState
if (pr.state === 'open') {
return (
<div className="space-y-1.5">
@ -153,7 +183,7 @@ export default function PRActions({
<TooltipTrigger asChild>
{/* Why: wrapping in a <span> so the tooltip trigger receives pointer
events even when the merge button inside is disabled. */}
<span className={cn('flex flex-1', hasConflicts && 'cursor-not-allowed')}>
<span className={cn('flex flex-1', primaryMergeDisabled && 'cursor-not-allowed')}>
<Button
type="button"
size="xs"
@ -162,21 +192,29 @@ export default function PRActions({
'bg-green-600 text-white hover:bg-green-700',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
onClick={() => void handleMerge('squash')}
disabled={mergeDisabled}
onClick={() =>
mergePresentation.autoMergeAction && !mergePresentation.directMergeAvailable
? void handleAutoMerge()
: void handleMerge('squash')
}
disabled={primaryMergeDisabled}
>
{merging ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<GitMerge className="size-3.5" />
)}
{merging ? 'Merging\u2026' : 'Squash and merge'}
{merging
? 'Working...'
: mergePresentation.directMergeAvailable
? 'Squash and merge'
: (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)}
</Button>
</span>
</TooltipTrigger>
{hasConflicts && (
{primaryMergeDisabled && (
<TooltipContent side="bottom" sideOffset={4}>
Merge conflicts must be resolved before merging
{mergePresentation.tooltip}
</TooltipContent>
)}
</Tooltip>
@ -202,10 +240,22 @@ export default function PRActions({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{mergePresentation.autoMergeAction && (
<>
<DropdownMenuItem
disabled={menuDisabled}
onSelect={() => void handleAutoMerge()}
>
<GitMerge className="size-3.5" />
{mergePresentation.autoMergeAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{MERGE_METHODS.map((method) => (
<DropdownMenuItem
key={method}
disabled={mergeDisabled}
disabled={directMergeDisabled}
onSelect={() => void handleMerge(method)}
>
<GitMerge className="size-3.5" />
@ -246,7 +296,7 @@ export default function PRActions({
) : (
<CircleDot className="size-3.5" />
)}
{stateUpdating === 'open' ? 'Reopening' : 'Reopen PR'}
{stateUpdating === 'open' ? 'Reopening...' : 'Reopen PR'}
</Button>
{actionError && <div className="text-[10px] text-rose-500 break-words">{actionError}</div>}
</div>
@ -268,7 +318,7 @@ export default function PRActions({
) : (
<Trash2 className="size-3.5" />
)}
{isDeletingWorktree ? 'Deleting' : 'Delete Workspace'}
{isDeletingWorktree ? 'Deleting...' : 'Delete Workspace'}
</Button>
)
}

View File

@ -140,6 +140,27 @@ describe('task page cache selectors', () => {
expect(next).toEqual([refreshedFirst, refreshedSecond])
})
it('merges landing refresh auto-merge state changes without reordering GitHub rows', () => {
const first = {
...workItem('pr-1', 'repo-1'),
type: 'pr' as const,
state: 'open' as const,
autoMergeEnabled: false,
mergeQueueRequired: null,
updatedAt: '2026-01-01'
}
const refreshedFirst = {
...first,
autoMergeEnabled: true,
mergeQueueRequired: true
}
const next = reconcileTaskPageItemsAfterLandingRefresh([first], [refreshedFirst])
expect(next).toEqual([refreshedFirst])
expect(shouldReplaceTaskPageItemsAfterRefresh([first], [refreshedFirst])).toBe(false)
})
it('replaces GitHub landing refresh rows when membership changes', () => {
const first = workItem('issue-1', 'repo-1')
const second = workItem('issue-2', 'repo-1')

View File

@ -133,6 +133,8 @@ function taskPageWorkItemStatusSignature(item: GitHubWorkItem): string {
item.checksSummary?.failed ?? null,
item.checksSummary?.pending ?? null,
item.mergeable ?? null,
item.autoMergeEnabled ?? null,
item.mergeQueueRequired ?? null,
item.mergeStateStatus ?? null,
item.updatedAt
])

View File

@ -454,6 +454,7 @@ describe('web GitHub preload API', () => {
'requestPRReviewers',
'resolveProjectRef',
'resolveReviewThread',
'setPRAutoMerge',
'setPRFileViewed',
'starOrca',
'updateIssue',
@ -659,6 +660,12 @@ describe('web GitHub preload API', () => {
expectedMethod: 'github.mergePR',
expectedParams: withRepo({ repoPath, prNumber: 7, method: 'squash' })
},
{
key: 'setPRAutoMerge',
args: { repoPath, prNumber: 7, enabled: true },
expectedMethod: 'github.setPRAutoMerge',
expectedParams: withRepo({ repoPath, prNumber: 7, enabled: true })
},
{
key: 'updatePRState',
args: { repoPath, prNumber: 7, updates: { state: 'closed' } },

View File

@ -107,6 +107,7 @@ type WebGitHubRouteKey =
| 'setPRFileViewed'
| 'updatePRTitle'
| 'mergePR'
| 'setPRAutoMerge'
| 'updatePRState'
| 'requestPRReviewers'
| 'removePRReviewers'
@ -153,6 +154,7 @@ type WebGitHubRuntimeMethod =
| 'github.setPRFileViewed'
| 'github.updatePRTitle'
| 'github.mergePR'
| 'github.setPRAutoMerge'
| 'github.updatePRState'
| 'github.requestPRReviewers'
| 'github.removePRReviewers'
@ -234,6 +236,7 @@ export const GITHUB_WEB_RPC_METHODS = {
setPRFileViewed: 'github.setPRFileViewed',
updatePRTitle: 'github.updatePRTitle',
mergePR: 'github.mergePR',
setPRAutoMerge: 'github.setPRAutoMerge',
updatePRState: 'github.updatePRState',
requestPRReviewers: 'github.requestPRReviewers',
removePRReviewers: 'github.removePRReviewers',
@ -1294,6 +1297,8 @@ function createGitHubApi(): WebGitHubApi {
updatePRTitle: (args) =>
route<WebGitHubResult<'updatePRTitle'>>(GITHUB_WEB_RPC_METHODS.updatePRTitle, args),
mergePR: (args) => route<WebGitHubResult<'mergePR'>>(GITHUB_WEB_RPC_METHODS.mergePR, args),
setPRAutoMerge: (args) =>
route<WebGitHubResult<'setPRAutoMerge'>>(GITHUB_WEB_RPC_METHODS.setPRAutoMerge, args),
updatePRState: (args) =>
route<WebGitHubResult<'updatePRState'>>(GITHUB_WEB_RPC_METHODS.updatePRState, args),
requestPRReviewers: (args) =>

View File

@ -73,7 +73,18 @@ export function hostedReviewSummaryFromGitHubPRInfo(
author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null,
updatedAt: args.pr.updatedAt,
mergeable: args.pr.mergeable,
...(args.pr.mergeStateStatus !== undefined
? { mergeStateStatus: args.pr.mergeStateStatus }
: {}),
checksStatus: deriveChecksStatus(args.pr.checksStatus, args.checks),
reviewDecision:
args.pr.reviewDecision === 'APPROVED'
? 'approved'
: args.pr.reviewDecision === 'CHANGES_REQUESTED'
? 'changes_requested'
: args.pr.reviewDecision === 'REVIEW_REQUIRED'
? 'review_required'
: undefined,
threadSummary:
unresolvedCount === null
? undefined
@ -97,6 +108,10 @@ export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo {
status: pr.checksStatus,
updatedAt: pr.updatedAt,
mergeable: pr.mergeable,
...(pr.reviewDecision !== undefined ? { reviewDecision: pr.reviewDecision } : {}),
...(pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: pr.autoMergeEnabled } : {}),
...(pr.mergeQueueRequired !== undefined ? { mergeQueueRequired: pr.mergeQueueRequired } : {}),
...(pr.mergeStateStatus !== undefined ? { mergeStateStatus: pr.mergeStateStatus } : {}),
...(pr.headSha ? { headSha: pr.headSha } : {}),
...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {})
}

View File

@ -95,9 +95,30 @@ describe('reviewReadyToMerge', () => {
expect(reviewReadyToMerge(baseSummary({ threadSummary: { unresolvedCount: 2 } }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ threadSummary: undefined }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ mergeable: 'UNKNOWN' }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'review_required' }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'changes_requested' }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BEHIND' }))).toBe(false)
expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BLOCKED' }))).toBe(false)
})
it('accepts neutral checks when all other gates pass', () => {
expect(reviewReadyToMerge(baseSummary({ checksStatus: 'neutral' }))).toBe(true)
})
it('scopes GitHub merge-state blockers to GitHub summaries', () => {
expect(
reviewReadyToMerge(
baseSummary({
identity: {
provider: 'gitlab',
host: 'gitlab.com',
owner: 'acme',
repo: 'orca',
number: 42
},
mergeStateStatus: 'BLOCKED'
})
)
).toBe(true)
})
})

View File

@ -99,6 +99,18 @@ export function reviewReadyToMerge(summary: HostedReviewQueueSummary): boolean {
if (summary.mergeable !== 'MERGEABLE') {
return false
}
if (
summary.identity.provider === 'github' &&
(summary.mergeStateStatus === 'BEHIND' || summary.mergeStateStatus === 'BLOCKED')
) {
return false
}
if (
summary.reviewDecision === 'review_required' ||
summary.reviewDecision === 'changes_requested'
) {
return false
}
if (summary.checksStatus !== 'success' && summary.checksStatus !== 'neutral') {
return false
}

View File

@ -1,4 +1,4 @@
import type { CheckStatus, PRConflictSummary, PRMergeableState } from './types'
import type { CheckStatus, PRConflictSummary, PRMergeableState, PRReviewDecision } from './types'
export type HostedReviewProvider =
| 'github'
@ -19,6 +19,10 @@ export type HostedReviewInfo = {
status: CheckStatus
updatedAt: string
mergeable: PRMergeableState
reviewDecision?: PRReviewDecision | null
autoMergeEnabled?: boolean
mergeQueueRequired?: boolean | null
mergeStateStatus?: string | null
headSha?: string
conflictSummary?: PRConflictSummary
}
@ -156,6 +160,7 @@ export type HostedReviewQueueSummary = {
updatedAt: string
lastViewedAt?: number
mergeable: PRMergeableState
mergeStateStatus?: string | null
checksStatus: CheckStatus
reviewDecision?: HostedReviewDecision
threadSummary?: HostedReviewThreadSummary

View File

@ -688,6 +688,7 @@ export type IssueState = 'open' | 'closed'
export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral'
export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
export type PRReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
export type PRConflictSummary = {
baseRef: string
@ -706,6 +707,10 @@ export type PRInfo = {
checksStatus: CheckStatus
updatedAt: string
mergeable: PRMergeableState
reviewDecision?: PRReviewDecision | null
autoMergeEnabled?: boolean
mergeQueueRequired?: boolean | null
mergeStateStatus?: string | null
// 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.
@ -963,12 +968,14 @@ export type GitHubWorkItem = {
additions?: number
deletions?: number
changedFiles?: number
reviewDecision?: string | null
reviewDecision?: PRReviewDecision | null
reviewRequests?: GitHubAssignableUser[]
latestReviews?: GitHubPRReviewSummary[]
assignees?: GitHubAssignableUser[]
checksSummary?: GitHubPRCheckSummary
mergeable?: PRMergeableState
autoMergeEnabled?: boolean
mergeQueueRequired?: boolean | null
mergeStateStatus?: string | null
maintainerCanModify?: boolean
// Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner).