From 7254211058cb2e7dc7b221d6957294fcde4aa508 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 17:36:16 -0700 Subject: [PATCH] fix: clear stale PR cache on unlink (#2000) --- .../src/store/slices/worktrees.test.ts | 76 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 49 +++++++++++- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index e06d23c68..f2eddb900 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -39,6 +39,7 @@ const mockApi = { globalThis.window = { api: mockApi } import { createWorktreeSlice } from './worktrees' +import { getHostedReviewCacheKey } from './hosted-review' function resetRemoteRuntimeMocks() { clearRuntimeCompatibilityCacheForTests() @@ -1099,6 +1100,81 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.updateMeta).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo.repo1[0]?.comment).toBe('remote note') }) + + it('clears stale hosted review cache and force-refetches when removing linked PR metadata', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/pr-branch', + linkedPR: 456 + }) + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + const cacheKey = getHostedReviewCacheKey('/repo1', 'pr-branch', undefined, 'repo1') + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { repo1: [wt] }, + hostedReviewCache: { + [cacheKey]: { + data: { + provider: 'github', + number: 456, + title: 'Linked PR', + state: 'open', + url: 'https://github.com/acme/repo/pull/456', + status: 'success', + updatedAt: '2026-05-15T00:00:00.000Z', + mergeable: 'MERGEABLE' + }, + fetchedAt: Date.now() + } + }, + fetchHostedReviewForBranch + } as Partial) + + await store.getState().updateWorktreeMeta(wt.id, { linkedPR: null }) + + expect(store.getState().worktreesByRepo.repo1[0]?.linkedPR).toBeNull() + expect(store.getState().hostedReviewCache[cacheKey]).toBeUndefined() + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'pr-branch', { + repoId: 'repo1', + linkedGitHubPR: null, + linkedGitLabMR: null, + force: true + }) + }) + + it('preserves linked GitLab MR fallback when removing linked GitHub PR metadata', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1', + branch: 'refs/heads/review-branch', + linkedPR: 456, + linkedGitLabMR: 789 + }) + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue(null) + 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 }) + + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'review-branch', { + repoId: 'repo1', + linkedGitHubPR: null, + linkedGitLabMR: 789, + force: true + }) + }) }) // Why: ghostty "show until interact" model — BEL must raise the sidebar dot diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 7912964c2..75f9d7de5 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -16,6 +16,7 @@ import { import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { getHostedReviewCacheKey } from './hosted-review' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean { @@ -762,6 +763,14 @@ export const createWorktreeSlice: StateCreator }, updateWorktreeMeta: async (worktreeId, updates) => { + const existingWorktree = findWorktreeById(get().worktreesByRepo, worktreeId) + const shouldRefreshHostedReview = + updates.linkedPR === null && existingWorktree?.linkedPR !== null + const reviewRepo = shouldRefreshHostedReview + ? get().repos.find((repo) => repo.id === existingWorktree?.repoId) + : undefined + const reviewBranch = existingWorktree?.branch.replace(/^refs\/heads\//, '') + // Why: editing a comment is meaningful interaction with the worktree. // Without refreshing lastActivityAt, the time-decay score has decayed // since the previous sort, so a re-sort causes the worktree to drop in @@ -771,13 +780,47 @@ export const createWorktreeSlice: StateCreator set((s) => { const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched) - return nextWorktrees === s.worktreesByRepo - ? {} - : { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + const cacheKey = + reviewRepo && reviewBranch + ? getHostedReviewCacheKey(reviewRepo.path, reviewBranch, s.settings, reviewRepo.id) + : null + const hostedReviewCache = s.hostedReviewCache ?? {} + if (nextWorktrees === s.worktreesByRepo && !cacheKey) { + return {} + } + + const nextHostedReviewCache = + cacheKey && hostedReviewCache[cacheKey] + ? (() => { + const next = { ...hostedReviewCache } + delete next[cacheKey] + return next + })() + : hostedReviewCache + + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextHostedReviewCache !== hostedReviewCache + ? { hostedReviewCache: nextHostedReviewCache } + : {}) + } }) try { await persistWorktreeMeta(get().settings, worktreeId, enriched) + if (reviewRepo && reviewBranch && typeof get().fetchHostedReviewForBranch === 'function') { + // Why: the old cache entry may have been populated solely by linkedPR. + // Force a no-linked refetch so an in-flight linked lookup cannot keep + // showing the manually removed PR. + void get().fetchHostedReviewForBranch(reviewRepo.path, reviewBranch, { + repoId: reviewRepo.id, + linkedGitHubPR: null, + linkedGitLabMR: existingWorktree?.linkedGitLabMR ?? null, + force: true + }) + } } catch (err) { console.error('Failed to update worktree meta:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))