From 7274fbe92ed92ff4eef36413958bf368b8d36f51 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:43:41 -0700 Subject: [PATCH] Persist linkedPR when branch-name discovery finds a PR A worktree only got linked to its PR via the terminal-output URL scanner (observeTerminalGitHubPullRequestLink). When a PR is created outside Orca (e.g. raw `gh pr create`) and then squash-merged with the remote branch deleted in the same session, that scanner loses the race and linkedPR is never set. Later refreshes look up by branch name, find nothing (branch is gone), and the checks panel shows "No pull request found". Persist the discovered PR number to linkedPR after a successful branch-name lookup so subsequent refreshes use `gh pr view `, which stays authoritative after the branch is deleted. Guarded to only fill an empty link, never overwrite an explicit one. Co-authored-by: Orca --- src/renderer/src/store/slices/github.test.ts | 123 ++++++++++++++++++- src/renderer/src/store/slices/github.ts | 29 +++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index c6d5025e5dc..ea6e1d13d80 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -82,11 +82,36 @@ function resetRemoteRuntimeMocks() { function createTestStore() { return create()( - (...a) => + (set, get, store) => ({ - ...createGitHubSlice(...a), - ...createHostedReviewSlice(...a) - }) as AppState + ...createGitHubSlice(set, get, store), + ...createHostedReviewSlice(set, get, store), + // Minimal stand-in for the worktrees slice: applies linkedPR updates so + // tests can assert branch-name discovery persists the worktree↔PR link. + updateWorktreeMeta: async ( + worktreeId: string, + updates: Partial, + options?: { shouldApply?: (worktree: Worktree | undefined) => boolean } + ) => { + set((s) => { + const next = { ...s.worktreesByRepo } + for (const [repoId, worktrees] of Object.entries(next)) { + const index = worktrees.findIndex((w) => w.id === worktreeId) + if (index === -1) { + continue + } + if (options?.shouldApply && !options.shouldApply(worktrees[index])) { + return s + } + const updated = [...worktrees] + updated[index] = { ...updated[index], ...updates } + next[repoId] = updated + return { worktreesByRepo: next } + } + return s + }) + } + }) as unknown as AppState ) } @@ -2133,6 +2158,96 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) + it('persists linkedPR when branch-name discovery finds a PR for an unlinked worktree', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/auto-link' + const worktreeId = 'wt-auto-link' + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: null })] + } + } as unknown as Partial) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 6599, title: 'Improve failed CI check details panel' }), + fetchedAt: Date.now() + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId + }) + + expect( + store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR + ).toBe(6599) + }) + + it('persists linkedPR even when the discovered PR is already merged', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/auto-link-merged' + const worktreeId = 'wt-auto-link-merged' + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: null })] + } + } as unknown as Partial) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 6599, state: 'merged' }), + fetchedAt: Date.now() + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId + }) + + expect( + store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR + ).toBe(6599) + }) + + it('does not overwrite an existing linkedPR on branch-name discovery', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/already-linked' + const worktreeId = 'wt-already-linked' + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [makePRRefreshWorktree({ id: worktreeId, repoId, branch, linkedPR: 42 })] + } + } as unknown as Partial) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ number: 99 }), + fetchedAt: Date.now() + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId + }) + + expect( + store.getState().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId)?.linkedPR + ).toBe(42) + }) + it('preserves cached PR data when a forced coordinator refresh errors', async () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 28618d3a8cd..971b6f092b1 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -3034,6 +3034,35 @@ export const createGitHubSlice: StateCreator = (s if (didUpdatePRCache) { debouncedSaveCache(get()) } + // Why: branch-name auto-discovery is the only reliable way Orca finds a + // PR created outside its own flow (e.g. raw `gh pr create`). Persisting + // the number to linkedPR keeps the worktree linked after the remote + // branch is deleted (e.g. on squash-merge), when branch lookups can no + // longer find it but `gh pr view ` still can. + const updateWorktreeMeta = get().updateWorktreeMeta + if ( + outcome.kind === 'found' && + pr && + linkedPRNumber == null && + options?.worktreeId && + typeof updateWorktreeMeta === 'function' + ) { + const worktreeId = options.worktreeId + const prNumber = pr.number + void updateWorktreeMeta( + worktreeId, + { linkedPR: prNumber }, + { + shouldApply: (currentWorktree) => + Boolean( + currentWorktree && + !currentWorktree.isBare && + !currentWorktree.isArchived && + currentWorktree.linkedPR == null + ) + } + ) + } } if ( shouldPreserveExistingPRForFallbackMiss({