diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 413117a7e32..e9c8f5751af 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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 diff --git a/src/main/github/client.ts b/src/main/github/client.ts index f4d80db2a67..3380a6a5a44 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { 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 { // 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() } } diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 3200587fc7a..20d7b19effc 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -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 diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index b8396fc1068..8af4ee10b4b 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -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 { 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) diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index cc2f3de60f8..4ec609ded99 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -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({ diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7136677fe57..f4c4b1a6401 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -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. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d35350d9ff1..2013f3ac7e6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>> { 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 ) } diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index 3953a7dd968..6c8b8bd9995 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -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({ diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index c08bebf1c7f..3acd3d20535 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -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 + }) }) }) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index 32899c1b657..17046782499 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -120,7 +120,10 @@ const gitHubForgeProvider = { input.linkedReviewNumber ?? null, input.connectionId, fallbackReviewNumber, - ...executionArgs + { + ...executionArgs[0], + acceptMergedFallbackPR: true + } ) : executionArgs.length > 0 ? await getPRForBranch( diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 1bf1e4289fd..aec645492d8 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -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 () => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8c07f5bb289..4e923a6e808 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1147,6 +1147,7 @@ export type PreloadApi = { branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean }) => Promise refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise enqueuePRRefresh: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index a9982eb0b04..ef94052fc72 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -943,6 +943,7 @@ const api = { branch: string linkedPRNumber?: number | null fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean }): Promise => ipcRenderer.invoke('gh:prForBranch', args), refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 35606a3b335..039550b7299 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -2659,7 +2659,9 @@ export const createGitHubSlice: StateCreator = (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 = (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) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 78c8b32c5d0..e53721f8072 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2331,7 +2331,8 @@ describe('web GitHub preload API', () => { repo: 'id:repo-1', branch: 'feature', linkedPRNumber: null, - fallbackPRNumber: 9 + fallbackPRNumber: 9, + acceptMergedFallbackPR: true } } ]) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 789fcbf57f9..fabba875736 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1675,12 +1675,17 @@ function createGitHubApi(): WebGitHubApi { prForBranch: (args) => route>(GITHUB_WEB_RPC_METHODS.prForBranch, args), refreshPRNow: async ({ candidate }) => { + const acceptMergedFallbackPR = + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null const pr = await route>(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() }