diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 0607e88f7bf..6a4140676e2 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2225,6 +2225,71 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() }) + it('unlinks a stale open PR and re-resolves the current branch with one follow-up lookup', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/current' + const worktreeId = 'wt-stale-open-pr' + const stalePR = makePR({ + number: 12, + title: 'Stale linked PR', + headSha: 'old-head', + headRefName: 'feature/old' + }) + const currentPR = makePR({ + number: 13, + title: 'Current branch PR', + headSha: 'current-head', + headRefName: branch + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow + .mockResolvedValueOnce({ kind: 'found', pr: stalePR, fetchedAt: 2 }) + .mockResolvedValueOnce({ kind: 'found', pr: currentPR, fetchedAt: 3 }) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + ).resolves.toEqual(stalePR) + + await vi.waitFor(() => { + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(2) + expect(store.getState().prCache[`${repoId}::${branch}`]?.data).toEqual(currentPR) + }) + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { + suppressHostedReviewRefresh: true, + shouldApply: expect.any(Function) + } + ) + expect(mockApi.gh.refreshPRNow.mock.calls[1]?.[0]).toMatchObject({ + candidate: expect.objectContaining({ + branch, + linkedPRNumber: null, + worktreeId + }) + }) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + }) + it('clears a linked merged PR on a fresh cache hit that already carries a head-scoped divergence signal', 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 953444934b8..46fcde9db73 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -3281,6 +3281,9 @@ export const createGitHubSlice: StateCreator = (s options.worktreeId, { linkedPR: null }, { + // Why: the branch-scoped PR refetch below updates both GitHub + // caches; the generic metadata refresh would duplicate provider work. + suppressHostedReviewRefresh: true, shouldApply: () => shouldApplyBranchMismatchedLinkedPRClear({ worktree: diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 16b25ae9655..3b4e3ac974b 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -42,6 +42,8 @@ export type WorktreeMetaUpdateGuard = (worktree: Worktree | DetectedWorktree | u export type WorktreeMetaUpdateOptions = { shouldApply?: WorktreeMetaUpdateGuard + /** Skip the automatic review refetch when the caller owns an equivalent refresh. */ + suppressHostedReviewRefresh?: boolean } export type WorktreeRenameRequest = { diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index d22c15877c8..dc965de03ba 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4474,6 +4474,40 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toBeUndefined() }) + it('skips duplicate hosted-review work when the unlinking caller owns the refresh', async () => { + const store = createTestStore() + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/review-branch', + linkedPR: 2548, + pushTarget: { remoteName: 'fork', branchName: 'owner/old-pr' } + }) + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [wt] }, + fetchHostedReviewForBranch + } as Partial) + + await store + .getState() + .updateWorktreeMeta(wt.id, { linkedPR: null }, { suppressHostedReviewRefresh: true }) + + expect(fetchHostedReviewForBranch).not.toHaveBeenCalled() + expect(mockApi.worktrees.updateMeta).toHaveBeenCalledWith({ + worktreeId: wt.id, + updates: { linkedPR: null, pushTarget: undefined } + }) + expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ + linkedPR: null, + pushTarget: undefined + }) + }) + it('clears an older GitHub link and target when replacing it with a GitLab MR', async () => { const store = createTestStore() const oldPushTarget = { remoteName: 'fork', branchName: 'owner/old-pr' } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index d9cb0d09afe..3a02135f6b4 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3967,7 +3967,12 @@ export const createWorktreeSlice: StateCreator try { await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) - if (reviewRepo && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function') { + if ( + !options?.suppressHostedReviewRefresh && + reviewRepo && + reviewBranch && + typeof get().fetchHostedReviewForBranch === 'function' + ) { // Why: the old cache entry may have been populated by the previous // provider link. Refetch against the post-update links so stale lookups // cannot keep showing the removed review.