diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index c457efdf660..61d166b621c 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -125,4 +125,51 @@ describe('pr-refresh-coordinator', () => { expect(inFlightEvents.map((event) => event.reason)).toEqual(['visible', 'active']) expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) + + it('preserves coalesced aliases across visible follow-up refreshes', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ checksStatus: 'pending' }), + fetchedAt: Date.now() + }) + .mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ checksStatus: 'success' }), + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates( + [ + makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a' + }), + makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b' + }) + ], + 1, + 1 + ) + await vi.runOnlyPendingTimersAsync() + await vi.advanceTimersByTimeAsync(90_000) + + const outcomeEvents = sendMock.mock.calls + .map(([, event]) => event) + .filter((event) => event.outcome) + + expect(outcomeEvents).toHaveLength(2) + expect(outcomeEvents[1].aliases.map((alias) => alias.cacheKey).sort()).toEqual([ + '/repo::feature/a', + '/repo::feature/b' + ]) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index a4ce93ceaff..6a67db63721 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -22,6 +22,11 @@ type QueueEntry = { windowId?: number } +type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 const MIN_BACKGROUND_SPACING_MS = 10_000 @@ -38,6 +43,11 @@ const backgroundStarts: number[] = [] const errorBackoff = new Map() let lastBackgroundStartAt = 0 const visibleByWindow = new Map }>() +let outcomeObserver: PRRefreshOutcomeObserver | null = null + +export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + outcomeObserver = observer +} function nextSequence(): number { sequence += 1 @@ -187,7 +197,21 @@ function scheduleVisibleFollowUp( return } errorBackoff.delete(key) - enqueuePRRefresh(visibleCandidateAfterOutcome(candidate, outcome), 'visible', priority, windowId) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const dueAt = freshRetryAt(followUpCandidate) ?? Date.now() + // Why: coalesced linked-PR refreshes may represent several local branches. + // Preserve every alias for the next visible follow-up so all cache entries + // keep receiving periodic updates. + queue.set(key, { + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + windowId + }) + scheduleDrain(Math.max(0, dueAt - Date.now())) } function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { @@ -340,6 +364,7 @@ async function drainQueue(): Promise { next.candidate.branch, next.candidate.linkedPRNumber ?? null ) + outcomeObserver?.(next.candidate, outcome) broadcast({ aliases, reason: next.reason, outcome }, requestSequence) scheduleVisibleFollowUp( next.key, diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index ca33de1599e..a148aa2d6f2 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -41,7 +41,8 @@ import { import { enqueuePRRefresh, refreshPRNow, - reportVisiblePRRefreshCandidates + reportVisiblePRRefreshCandidates, + setPRRefreshOutcomeObserver } from '../github/pr-refresh-coordinator' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' @@ -132,6 +133,15 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } } + setPRRefreshOutcomeObserver((candidate, outcome) => { + const repo = + store.getRepos().find((r) => r.id === candidate.repoId) ?? + store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) + if (repo) { + recordPRIfNeeded(repo, outcome) + } + }) + ipcMain.handle( 'gh:prForBranch', async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => { @@ -173,7 +183,11 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) => { const repo = assertRegisteredRepo(args.candidate.repoPath, store) - enqueuePRRefresh({ ...args.candidate, repoPath: repo.path }, args.reason, args.priority ?? 0) + enqueuePRRefresh( + { ...args.candidate, repoPath: repo.path, repoId: repo.id }, + args.reason, + args.priority ?? 0 + ) return true } ) @@ -183,7 +197,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { const candidates = args.candidates.map((candidate) => { const repo = assertRegisteredRepo(candidate.repoPath, store) - return { ...candidate, repoPath: repo.path } + return { ...candidate, repoPath: repo.path, repoId: repo.id } }) reportVisiblePRRefreshCandidates(candidates, args.generation, event.sender.id) return true diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 5cddaae2a19..628a7ad9efb 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -101,13 +101,22 @@ export default function ChecksPanel(): React.JSX.Element { // differs from the PR's head ref) resolve via the number-based fallback. const linkedPR = activeWorktree?.linkedPR ?? null useEffect(() => { - if (repo && !isFolder && branch) { - void fetchPRForBranch(repo.path, branch, { linkedPRNumber: linkedPR }) + if (isPanelVisible && repo && !isFolder && branch) { + if (activeWorktreeId) { + enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30) + } } - }, [repo, isFolder, branch, linkedPR, fetchPRForBranch]) + }, [repo, isFolder, branch, activeWorktreeId, enqueueGitHubPRRefresh, isPanelVisible]) useEffect(() => { - if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') { + if ( + !repo || + isFolder || + !branch || + !pr || + pr.mergeable !== 'CONFLICTING' || + !activeWorktreeId + ) { conflictSummaryRefreshKeyRef.current = null return } @@ -122,8 +131,8 @@ export default function ChecksPanel(): React.JSX.Element { // them so we don't keep rendering cached branch summaries or empty file // lists from an older payload. conflictSummaryRefreshKeyRef.current = refreshKey - void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }) - }, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch]) + void enqueueGitHubPRRefresh(activeWorktreeId, 'active', 80) + }, [repo, isFolder, branch, pr, activeWorktreeId, enqueueGitHubPRRefresh]) // Fetch checks via cached store method const fetchChecks = useCallback( diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 0668ce47187..0ea21168b43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -199,7 +199,7 @@ function SourceControlInner(): React.JSX.Element { const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const inFlightRemoteOpKind = useAppStore((s) => s.inFlightRemoteOpKind) const prCache = useAppStore((s) => s.prCache) - const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) + const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) const updateRepo = useAppStore((s) => s.updateRepo) const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest) const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult) @@ -366,19 +366,24 @@ function SourceControlInner(): React.JSX.Element { const prCacheKey = activeRepo && branchName ? `${activeRepo.path}::${branchName}` : null const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null - const linkedPR = activeWorktree?.linkedPR ?? null useEffect(() => { - if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') { + if ( + !isBranchVisible || + !activeRepo || + isFolder || + !branchName || + branchName === 'HEAD' || + !activeWorktreeId + ) { return } // Why: the Source Control panel renders the branch's PR badge directly. // When a terminal checkout moves this worktree onto a new branch, we need - // to fetch that branch's PR immediately instead of waiting for the user to - // reselect the worktree or open the separate Checks panel. Pass linkedPR - // so create-from-PR worktrees resolve via the number-based fallback. - void fetchPRForBranch(activeRepo.path, branchName, { linkedPRNumber: linkedPR }) - }, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder, linkedPR]) + // to refresh that branch's PR without bypassing the coordinator's + // rate-limit guard. + enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30) + }, [activeRepo, activeWorktreeId, branchName, enqueueGitHubPRRefresh, isBranchVisible, isFolder]) const grouped = useMemo(() => { const groups = {