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({