Accept merged fallback PRs during branch lookup (#5908)

Ensure that when a visible fallback PR has been merged (e.g., outside
Orca with a deleted head branch), it is still accepted and refreshed by
branch lookup instead of being discarded as an implicit merged PR.

* Add `acceptMergedFallbackPR` option to GitHub branch lookups
* Enable this option during manual and background refreshes of fallback PRs
* Plumb the new option through preload APIs, IPC handlers, and RPC protocols
This commit is contained in:
Jinjing
2026-06-20 03:30:18 -07:00
committed by GitHub
parent 1419193bea
commit 97dc6d63e3
16 changed files with 253 additions and 25 deletions
+102
View File
@@ -823,6 +823,76 @@ describe('getPRForBranch', () => {
expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR' })
})
it('returns a merged PR when branch lookup and fallback point at the same PR', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 5511,
title: 'Merged current PR',
state: 'closed',
merged_at: '2026-06-16T17:15:33Z',
html_url: 'https://github.com/acme/widgets/pull/5511',
updated_at: '2026-06-16T17:15:33Z',
draft: false,
mergeable_state: 'clean',
head: { ref: 'feature/test', sha: 'merged-head-oid' },
base: { ref: 'main', sha: 'base-oid' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged current PR',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'merged-head-oid'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged current PR',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'merged-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 5511)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
[
'pr',
'view',
'5511',
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr).toMatchObject({ number: 5511, state: 'merged', title: 'Merged current PR' })
})
it('does not carry a merged upstream branch head repo into a fallback PR number', async () => {
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
candidates: [{ owner: 'stablyai', repo: 'orca' }],
@@ -925,6 +995,38 @@ describe('getPRForBranch', () => {
expect(pr).toBeNull()
})
it('returns a merged fallback PR when visible fallback lifecycle is accepted', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 5511,
title: 'Merged visible fallback PR',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/5511',
statusCheckRollup: [],
updatedAt: '2026-06-16T17:15:33Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'deleted-head',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'deleted-head', null, null, 5511, {
acceptMergedFallbackPR: true
})
expect(pr).toMatchObject({
number: 5511,
state: 'merged',
title: 'Merged visible fallback PR'
})
})
it('falls back to the tracked upstream branch when the local branch name differs', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
+20 -3
View File
@@ -1989,6 +1989,10 @@ const PR_LOOKUP_JSON_FIELDS =
const PR_BRANCH_LIST_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
acceptMergedFallbackPR?: boolean
}
function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState {
const mergeableState = pr.mergeable_state?.toLowerCase()
if (mergeableState === 'dirty') {
@@ -2455,7 +2459,7 @@ export async function getPRForBranch(
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: HostedReviewExecutionOptions = {}
options: GitHubPRBranchLookupOptions = {}
): Promise<PRInfo | null> {
const outcome = await getPRForBranchOutcome(
repoPath,
@@ -2474,7 +2478,7 @@ export async function getPRForBranchOutcome(
linkedPRNumber?: number | null,
connectionId?: string | null,
fallbackPRNumber?: number | null,
options: HostedReviewExecutionOptions = {}
options: GitHubPRBranchLookupOptions = {}
): Promise<PRRefreshOutcome> {
// Strip refs/heads/ prefix if present
const branchName = branch.replace(/^refs\/heads\//, '')
@@ -2549,7 +2553,9 @@ export async function getPRForBranchOutcome(
}
}
}
let mergedBranchLookupNumber: number | null = null
if (data && isMergedImplicitPR(data, linkedPRNumber)) {
mergedBranchLookupNumber = data.number
data = null
dataRepo = null
dataHeadRepo = headRepo
@@ -2566,7 +2572,18 @@ export async function getPRForBranchOutcome(
if (!data) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
if (isMergedImplicitPR(data, linkedPRNumber)) {
const fallbackConfirmedMergedBranch =
typeof fallbackPRNumber === 'number' &&
mergedBranchLookupNumber === fallbackPRNumber &&
data.number === fallbackPRNumber
// Why: a currently visible PR can be merged outside Orca; when the caller
// marks the fallback as visible review state, keep its lifecycle fresh even
// if GitHub no longer reports it by branch (for example deleted heads).
if (
isMergedImplicitPR(data, linkedPRNumber) &&
!fallbackConfirmedMergedBranch &&
options.acceptMergedFallbackPR !== true
) {
return { kind: 'no-pr', fetchedAt: Date.now() }
}
@@ -309,6 +309,31 @@ describe('pr-refresh-coordinator', () => {
expect(outcome?.sequence).toBe(inFlight?.sequence)
})
it('accepts merged fallback PRs for visible fallback refreshes', async () => {
const { refreshPRNow } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ state: 'merged' }),
fetchedAt: Date.now()
})
await refreshPRNow(
makeCandidate({
fallbackPRNumber: 12,
fallbackPRSource: 'pr-cache'
})
)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith(
'/repo',
'feature/test',
null,
null,
12,
{ acceptMergedFallbackPR: true }
)
})
it('does not coalesce local and SSH refreshes for the same branch', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
+26 -9
View File
@@ -10,8 +10,7 @@ import type {
GitHubPRRefreshSkippedReason,
PRRefreshOutcome
} from '../../shared/types'
import type { HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options'
import { getPRForBranchOutcome } from './client'
import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client'
import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit'
type QueueEntry = {
@@ -30,12 +29,30 @@ type PRRefreshOutcomeObserver = (
outcome: PRRefreshOutcome
) => void
type PRBranchLookupCandidate = Pick<
GitHubPRRefreshCandidate,
'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource'
>
function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean {
return (
candidate.linkedPRNumber == null &&
candidate.fallbackPRNumber != null &&
candidate.fallbackPRSource != null
)
}
function hostedReviewOptionArgs(
localGitOptions?: GitHubPRRefreshCandidate['localGitOptions']
): [] | [HostedReviewExecutionOptions] {
return localGitOptions?.wslDistro
? [{ localGitExecOptions: { wslDistro: localGitOptions.wslDistro } }]
: []
candidate: PRBranchLookupCandidate
): [] | [GitHubPRBranchLookupOptions] {
const options: GitHubPRBranchLookupOptions = {}
if (candidate.localGitOptions?.wslDistro) {
options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro }
}
if (shouldAcceptMergedFallbackPR(candidate)) {
options.acceptMergedFallbackPR = true
}
return Object.keys(options).length > 0 ? [options] : []
}
const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000
@@ -532,7 +549,7 @@ async function drainQueue(): Promise<void> {
next.candidate.linkedPRNumber ?? null,
next.candidate.connectionId ?? null,
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(next.candidate.localGitOptions)
...hostedReviewOptionArgs(next.candidate)
)
outcomeObserver?.(next.candidate, outcome)
broadcast({ aliases, reason: next.reason, outcome, requestStartedAt }, requestSequence)
@@ -659,7 +676,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
candidate.linkedPRNumber ?? null,
candidate.connectionId ?? null,
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate.localGitOptions)
...hostedReviewOptionArgs(candidate)
)
outcomeObserver?.(candidate, outcome)
broadcast({ aliases, reason: 'manual', outcome, requestStartedAt }, requestSequence)
+20
View File
@@ -381,6 +381,26 @@ describe('gitlab client — MR operations', () => {
)
})
it('preserves merged state when falling back to a linked MR iid', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify({
iid: 10,
title: 'Merged linked MR',
state: 'merged',
pipeline: { status: 'success' }
})
})
const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 10)
expect(mr).toMatchObject({
number: 10,
state: 'merged',
pipelineStatus: 'success'
})
})
it('routes local WSL merge-request branch lookup through the selected distro', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
+14 -1
View File
@@ -51,6 +51,7 @@ import {
checkOrcaStarred,
starOrca
} from '../github/client'
import type { GitHubPRBranchLookupOptions } from '../github/client'
import {
clearVisiblePRRefreshWindow,
enqueuePRRefresh,
@@ -201,19 +202,31 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
}
) => {
const repo = assertRegisteredRepo(args, store)
const localGitOptions = localGitOptionArgs(store, repo)[0]
const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] =
localGitOptions ? [{ localGitExecOptions: localGitOptions }] : []
const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0]
? { ...hostedReviewOptionArgs[0] }
: args.acceptMergedFallbackPR === true
? {}
: undefined
if (lookupOptions && args.acceptMergedFallbackPR === true) {
lookupOptions.acceptMergedFallbackPR = true
}
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions
? [lookupOptions]
: []
const pr = await getPRForBranch(
repo.path,
args.branch,
args.linkedPRNumber ?? null,
repoConnectionId(repo),
args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs
...lookupOptionArgs
)
// Emit pr_created when a PR is first detected for a branch.
// Why here: the renderer polls gh:prForBranch to check PR status per worktree.
+11 -3
View File
@@ -333,6 +333,7 @@ import {
listLabels,
listAssignableUsers
} from '../github/client'
import type { GitHubPRBranchLookupOptions } from '../github/client'
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
import { fetchPrHeadTrackingRef } from '../github/pr-head-tracking-ref'
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
@@ -9964,17 +9965,24 @@ export class OrcaRuntimeService {
repoSelector: string,
branch: string,
linkedPRNumber?: number | null,
fallbackPRNumber?: number | null
fallbackPRNumber?: number | null,
acceptMergedFallbackPR?: boolean
): Promise<Awaited<ReturnType<typeof getPRForBranch>>> {
const repo = await this.resolveRepoSelector(repoSelector)
const options = this.getHostedReviewExecutionOptions(repo)
const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {}
const lookupOptions = { ...options }
if (acceptMergedFallbackPR === true) {
lookupOptions.acceptMergedFallbackPR = true
}
const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] =
Object.keys(lookupOptions).length > 0 ? [lookupOptions] : []
return getPRForBranch(
repo.path,
branch,
linkedPRNumber ?? null,
repo.connectionId ?? null,
linkedPRNumber == null ? (fallbackPRNumber ?? null) : null,
options
...lookupOptionArgs
)
}
+4 -2
View File
@@ -52,7 +52,8 @@ const SlugAssignableUsers = SlugRepo.extend({
const PrForBranch = RepoSelector.extend({
branch: requiredString('Missing branch'),
linkedPRNumber: z.number().int().positive().nullable().optional(),
fallbackPRNumber: z.number().int().positive().nullable().optional()
fallbackPRNumber: z.number().int().positive().nullable().optional(),
acceptMergedFallbackPR: z.boolean().optional()
})
const Issue = RepoSelector.extend({
@@ -359,7 +360,8 @@ export const GITHUB_METHODS: RpcMethod[] = [
params.repo,
params.branch,
params.linkedPRNumber,
params.fallbackPRNumber
params.fallbackPRNumber,
params.acceptMergedFallbackPR
)
}),
defineMethod({
@@ -269,6 +269,8 @@ describe('forge provider interface', () => {
number: 7,
status: 'success'
})
expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, 'ssh-1', 7)
expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, 'ssh-1', 7, {
acceptMergedFallbackPR: true
})
})
})
+4 -1
View File
@@ -120,7 +120,10 @@ const gitHubForgeProvider = {
input.linkedReviewNumber ?? null,
input.connectionId,
fallbackReviewNumber,
...executionArgs
{
...executionArgs[0],
acceptMergedFallbackPR: true
}
)
: executionArgs.length > 0
? await getPRForBranch(
@@ -194,7 +194,9 @@ describe('getHostedReviewForBranch', () => {
number: 42,
status: 'success'
})
expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, undefined, 42)
expect(getPRForBranchMock).toHaveBeenCalledWith('/repo', '', null, undefined, 42, {
acceptMergedFallbackPR: true
})
})
it('falls through to Bitbucket when origin is not GitLab or GitHub', async () => {
+1
View File
@@ -1147,6 +1147,7 @@ export type PreloadApi = {
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
}) => Promise<PRInfo | null>
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise<PRRefreshOutcome>
enqueuePRRefresh: (args: {
+1
View File
@@ -943,6 +943,7 @@ const api = {
branch: string
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
acceptMergedFallbackPR?: boolean
}): Promise<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
+11 -2
View File
@@ -2659,7 +2659,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
repo: runtimeRepo.repo.id,
branch,
linkedPRNumber,
...(fallbackPRNumber !== null ? { fallbackPRNumber } : {})
...(fallbackPRNumber !== null
? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null }
: {})
},
{ timeoutMs: 30_000 }
).then((pr) =>
@@ -2690,7 +2692,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return window.api.gh.refreshPRNow
? await window.api.gh.refreshPRNow({ candidate })
: await window.api.gh
.prForBranch({ repoPath, repoId, branch, linkedPRNumber, fallbackPRNumber })
.prForBranch({
repoPath,
repoId,
branch,
linkedPRNumber,
fallbackPRNumber,
acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null
})
.then((pr) =>
pr
? ({ kind: 'found', pr, fetchedAt: Date.now() } as const)
+2 -1
View File
@@ -2331,7 +2331,8 @@ describe('web GitHub preload API', () => {
repo: 'id:repo-1',
branch: 'feature',
linkedPRNumber: null,
fallbackPRNumber: 9
fallbackPRNumber: 9,
acceptMergedFallbackPR: true
}
}
])
+6 -1
View File
@@ -1675,12 +1675,17 @@ function createGitHubApi(): WebGitHubApi {
prForBranch: (args) =>
route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, args),
refreshPRNow: async ({ candidate }) => {
const acceptMergedFallbackPR =
candidate.linkedPRNumber == null &&
candidate.fallbackPRNumber != null &&
candidate.fallbackPRSource != null
const pr = await route<WebGitHubResult<'prForBranch'>>(GITHUB_WEB_RPC_METHODS.prForBranch, {
repoPath: candidate.repoPath,
repoId: candidate.repoId,
branch: candidate.branch,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {})
})
return pr
? { kind: 'found', pr, fetchedAt: Date.now() }