diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index c91633c7043..a7e62798c59 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -445,6 +445,8 @@ export default function ChecksPanel(): React.JSX.Element { worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, branch, + linkedGitHubPR: activeWorktree?.linkedPR ?? null, + linkedGitLabMR: activeWorktree?.linkedGitLabMR ?? null, runtimeEnvironmentId, repoConnectionId, pushTarget: activeWorktreePushTarget @@ -948,6 +950,7 @@ export default function ChecksPanel(): React.JSX.Element { void fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber ?? pr.number }).finally(() => { @@ -1345,6 +1348,7 @@ export default function ChecksPanel(): React.JSX.Element { const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber }) @@ -1594,6 +1598,7 @@ export default function ChecksPanel(): React.JSX.Element { const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: linkedPR, fallbackPRNumber: fallbackGitHubPRNumber }) @@ -1608,6 +1613,7 @@ export default function ChecksPanel(): React.JSX.Element { }, [ activeGitLabReview, activeReview?.provider, + activeWorktreeId, branch, fallbackGitHubPRNumber, fetchGitLabDetails, @@ -2068,6 +2074,7 @@ export default function ChecksPanel(): React.JSX.Element { const refreshedPR = await fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + worktreeId: activeWorktreeId ?? undefined, linkedPRNumber }) if (!isCurrentRequestContext()) { @@ -2175,6 +2182,7 @@ export default function ChecksPanel(): React.JSX.Element { } }, [ + activeWorktreeId, branch, fetchHostedReviewForBranch, fetchPRChecks, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index afc277f1cee..d8292155d98 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -2033,6 +2033,7 @@ function SourceControlInner(): React.JSX.Element { fetchPRForBranch(activeRepo.path, branchName, { force: true, repoId: activeRepo.id, + worktreeId: activeWorktreeId ?? undefined, linkedPRNumber: result.number }) ]) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts index 1694907b604..8d2b01317c8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts @@ -43,6 +43,32 @@ describe('buildChecksPanelGitStatusContextKey', () => { }) ) }) + + it('changes when linked hosted review metadata changes', () => { + const base = { + repoId: 'repo-1', + worktreeId: 'worktree-1', + worktreePath: 'repo-worktree', + branch: 'feature/checks', + runtimeEnvironmentId: 'runtime-1', + repoConnectionId: 'ssh-1', + pushTarget: null + } + + expect( + buildChecksPanelGitStatusContextKey({ + ...base, + linkedGitHubPR: 12, + linkedGitLabMR: null + }) + ).not.toBe( + buildChecksPanelGitStatusContextKey({ + ...base, + linkedGitHubPR: null, + linkedGitLabMR: null + }) + ) + }) }) describe('readChecksPanelGitStatusSnapshot', () => { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts index ad3af8d0b8c..46eafdf2a0b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts @@ -5,6 +5,8 @@ export type ChecksPanelGitStatusContextInput = { worktreeId: string | null | undefined worktreePath: string | null | undefined branch: string + linkedGitHubPR?: number | null + linkedGitLabMR?: number | null runtimeEnvironmentId: string | null repoConnectionId: string | null pushTarget: GitPushTarget | null | undefined @@ -29,6 +31,10 @@ export function buildChecksPanelGitStatusContextKey( worktreeId: input.worktreeId ?? '', worktreePath: input.worktreePath ?? '', branch: input.branch, + // Why: this key gates right-sidebar async commits too; link/unlink must + // make pre-change PR refreshes stale even when repo/branch are unchanged. + linkedGitHubPR: input.linkedGitHubPR ?? null, + linkedGitLabMR: input.linkedGitLabMR ?? null, runtimeEnvironmentId: input.runtimeEnvironmentId ?? '', repoConnectionId: input.repoConnectionId ?? '', pushTarget: input.pushTarget diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index dc8f78af0cd..e4e98cf7734 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -1666,6 +1666,76 @@ describe('createGitHubSlice.fetchPRForBranch', () => { } }) + it('ignores a direct exact linked PR refresh after the worktree was unlinked', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/unlinked-direct-pr' + const worktreeId = 'wt-unlinked-direct-pr' + const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) + let resolveRefresh: ( + value: Awaited> + ) => void = () => {} + const refresh = new Promise>>((resolve) => { + resolveRefresh = resolve + }) + mockApi.gh.refreshPRNow.mockReturnValueOnce(refresh) + + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + [repoId]: [ + { + id: worktreeId, + repoId, + path: '/repo/worktrees/unlinked-direct-pr', + branch, + displayName: 'unlinked-direct-pr', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: 12 + } + ] + } + } as unknown as Partial) + + const request = store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + store.setState({ + worktreesByRepo: { + [repoId]: [ + { + id: worktreeId, + repoId, + path: '/repo/worktrees/unlinked-direct-pr', + branch, + displayName: 'unlinked-direct-pr', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: null + } + ] + }, + hostedReviewCache: {}, + prCache: {} + } as unknown as Partial) + resolveRefresh({ + kind: 'found', + pr: makePR({ number: 12, title: 'Stale exact linked PR' }), + fetchedAt: Date.now() + }) + + await expect(request).resolves.toBeNull() + expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() + expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() + }) + it('preserves cached PR data when a forced coordinator refresh errors', async () => { const store = createTestStore() const repoPath = '/repo' @@ -2097,6 +2167,51 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) }) + it('ignores a queued exact linked PR refresh after the worktree was unlinked', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/unlinked-event-pr' + const cacheKey = `${repoId}::${branch}` + const worktreeId = 'wt-unlinked-event-pr' + const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId) + + store.setState({ + worktreesByRepo: { + [repoId]: [ + { + id: worktreeId, + repoId, + path: '/repo/worktrees/unlinked-event-pr', + branch, + displayName: 'unlinked-event-pr', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: null + } + ] + }, + hostedReviewCache: {}, + prCache: {} + } as unknown as Partial) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + aliases: [{ cacheKey, repoId, repoPath, branch, worktreeId, linkedPRNumber: 12 }], + reason: 'visible', + requestStartedAt: Date.now() - 1_000, + outcome: { + kind: 'found', + pr: makePR({ number: 12, title: 'Stale queued linked PR' }), + fetchedAt: Date.now() + } + }) + + expect(store.getState().prCache[cacheKey]).toBeUndefined() + expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() + }) + it('uses the in-flight event entry to allow same-millisecond coordinator refreshes', () => { vi.useFakeTimers() vi.setSystemTime(100) diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 2a70b4a8534..f88d903c23e 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -758,6 +758,17 @@ function findWorktreeById(state: AppState, worktreeId: string): Worktree | null return null } +function isStaleExactLinkedPRLookup( + state: AppState, + worktreeId: string | undefined, + linkedPRNumber: number | null | undefined +): boolean { + if (!worktreeId || linkedPRNumber == null) { + return false + } + return findWorktreeById(state, worktreeId)?.linkedPR !== linkedPRNumber +} + function buildPRRefreshCandidate( state: AppState, worktree: Worktree, @@ -1275,6 +1286,7 @@ export type GitHubSlice = { repoPath: string, branch: string, options?: RepoScopedFetchOptions & { + worktreeId?: string linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: GitHubPRFallbackSource | null @@ -2363,6 +2375,7 @@ export const createGitHubSlice: StateCreator = (s repoKind: repo?.kind ?? 'git', branch, cacheKey, + worktreeId: options?.worktreeId, linkedPRNumber, fallbackPRNumber, fallbackPRSource, @@ -2391,8 +2404,15 @@ export const createGitHubSlice: StateCreator = (s return cached?.data ?? null } if (prRequestGenerations.get(cacheKey) === generation) { - set((s) => - setGitHubPRResultCaches(s, { + let skippedStaleLinkedPRLookup = false + set((s) => { + // Why: unlinking a PR while an exact linked-PR lookup is in flight + // must prevent that older result from restoring the manual link UI. + if (isStaleExactLinkedPRLookup(s, options?.worktreeId, linkedPRNumber)) { + skippedStaleLinkedPRLookup = true + return {} + } + return setGitHubPRResultCaches(s, { prCacheKey: cacheKey, repoPath, branch, @@ -2408,7 +2428,10 @@ export const createGitHubSlice: StateCreator = (s requestStartedAt, requestStartedEntry: requestStartedHostedReviewEntry }) - ) + }) + if (skippedStaleLinkedPRLookup) { + return null + } debouncedSaveCache(get()) } if ( @@ -2945,6 +2968,7 @@ export const createGitHubSlice: StateCreator = (s void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null @@ -2959,6 +2983,7 @@ export const createGitHubSlice: StateCreator = (s return get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null @@ -2984,6 +3009,7 @@ export const createGitHubSlice: StateCreator = (s for (const candidate of candidates) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null @@ -3084,6 +3110,11 @@ export const createGitHubSlice: StateCreator = (s return pr })() : null + // Why: queued local refreshes may finish after the user unlinks an + // exact PR; those older results must not restore the manual-link UI. + if (isStaleExactLinkedPRLookup(s, alias.worktreeId, alias.linkedPRNumber)) { + continue + } const nextCaches = applyGitHubPRResultToCaches({ prCache: nextPRCache, hostedReviewCache: nextHostedReviewCache, @@ -3243,6 +3274,7 @@ export const createGitHubSlice: StateCreator = (s if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null @@ -3315,6 +3347,7 @@ export const createGitHubSlice: StateCreator = (s void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null @@ -3501,6 +3534,7 @@ export const createGitHubSlice: StateCreator = (s void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, + worktreeId: candidate.worktreeId, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 51b766254f7..310907ade6a 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -2501,6 +2501,7 @@ describe('worktree remote runtime mutations', () => { expect(fetchPRForBranch).toHaveBeenCalledWith('/repos/orca', 'feature/pr-link', { force: true, repoId: 'repo1', + worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, fallbackPRSource: 'explicit' @@ -2546,6 +2547,7 @@ describe('worktree remote runtime mutations', () => { expect(fetchPRForBranch).toHaveBeenCalledWith('/repos/orca', 'feature/pr-link', { force: true, repoId: 'repo1', + worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, fallbackPRSource: 'explicit' @@ -2735,6 +2737,7 @@ describe('worktree remote runtime mutations', () => { expect(fetchPRForBranch).toHaveBeenCalledWith('/repos/orca', 'feature/pr-link', { force: true, repoId: 'repo1', + worktreeId: wt.id, linkedPRNumber: null, fallbackPRNumber: null, fallbackPRSource: 'explicit' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 12512bebccd..038398ba7df 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -2017,6 +2017,7 @@ export const createWorktreeSlice: StateCreator void fetchPRForBranch(repo.path, branch, { force: true, repoId: repo.id, + worktreeId, linkedPRNumber: alreadyLinked ? link.number : null, fallbackPRNumber: null, fallbackPRSource: alreadyLinked ? null : 'explicit'