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:
parent
4180352856
commit
7242b41698
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 } : {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue