fix: clear stale PR cache on unlink (#2000)

This commit is contained in:
Jinjing 2026-05-15 17:36:16 -07:00 committed by GitHub
parent 60be1605e6
commit 7254211058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 122 additions and 3 deletions

View File

@ -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<AppState>)
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<AppState>)
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

View File

@ -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<AppState, [], [], WorktreeSlice>
},
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<AppState, [], [], WorktreeSlice>
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))