fix: clear stale PR cache on unlink (#2000)
This commit is contained in:
parent
60be1605e6
commit
7254211058
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue