From 85b3319fea16d0d2ebaf09226133f3d277e533bf Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 12 May 2026 13:21:12 -0400 Subject: [PATCH] Improve GitHub PR refresh freshness Co-authored-by: Orca --- src/main/github/client.ts | 143 ++++-- src/main/github/pr-refresh-coordinator.ts | 447 ++++++++++++++++++ src/main/ipc/github.ts | 62 ++- src/preload/api-types.ts | 15 + src/preload/index.ts | 24 + src/renderer/src/App.tsx | 3 + .../src/components/sidebar/WorktreeCard.tsx | 11 +- .../components/sidebar/WorktreeCardMeta.tsx | 7 +- .../src/components/sidebar/WorktreeList.tsx | 43 ++ src/renderer/src/hooks/useIpcEvents.ts | 8 + src/renderer/src/store/slices/editor.ts | 16 + .../src/store/slices/github-checks.ts | 6 +- src/renderer/src/store/slices/github.ts | 277 +++++++++-- src/renderer/src/store/slices/ssh.ts | 9 +- src/shared/types.ts | 50 ++ 15 files changed, 1045 insertions(+), 76 deletions(-) create mode 100644 src/main/github/pr-refresh-coordinator.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index cde6e71f3be..930919cc295 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -6,6 +6,7 @@ import type { IssueSourcePreference, ListWorkItemsResult, PRInfo, + PRRefreshOutcome, PRMergeableState, PRCheckDetail, GitHubCommentResult, @@ -53,6 +54,51 @@ import { mapGraphQLReactionGroups, type GitHubGraphQLReactionGroup } from './com const ORCA_REPO = 'stablyai/orca' +type PRBranchData = { + number: number + title: string + state: string + url: string + statusCheckRollup: unknown[] + updatedAt: string + isDraft?: boolean + mergeable: string + baseRefName?: string + headRefName?: string + baseRefOid?: string + headRefOid?: string +} + +function classifyPRRefreshError( + err: unknown +): Extract['errorType'] { + const message = err instanceof Error ? err.message : String(err) + const lower = message.toLowerCase() + if (lower.includes('rate limit')) { + return 'rate_limited' + } + if ( + lower.includes('timeout') || + lower.includes('no such host') || + lower.includes('network') || + lower.includes('could not resolve host') + ) { + return 'network' + } + if (lower.includes('http 403') || lower.includes('resource not accessible')) { + return 'permission' + } + if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { + return 'repo_unavailable' + } + return /auth|login|credential/i.test(message) ? 'auth' : 'unknown' +} + +function isNoPullRequestError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return /no pull requests? found|could not find.*pull request/i.test(message) +} + /** * Check if the authenticated user has starred the Orca repo. * Returns true if starred, false if not, null if unable to determine (gh unavailable). @@ -949,26 +995,22 @@ export async function getPRForBranch( branch: string, linkedPRNumber?: number | null ): Promise { + const outcome = await getPRForBranchOutcome(repoPath, branch, linkedPRNumber) + return outcome.kind === 'found' ? outcome.pr : null +} + +export async function getPRForBranchOutcome( + repoPath: string, + branch: string, + linkedPRNumber?: number | null +): Promise { // Strip refs/heads/ prefix if present const branchName = branch.replace(/^refs\/heads\//, '') await acquire() try { const ownerRepo = await getOwnerRepo(repoPath) - let data: { - number: number - title: string - state: string - url: string - statusCheckRollup: unknown[] - updatedAt: string - isDraft?: boolean - mergeable: string - baseRefName?: string - headRefName?: string - baseRefOid?: string - headRefOid?: string - } | null = null + let data: PRBranchData | null = null // During a rebase the worktree is in detached HEAD and branch is empty. // An empty --head filter causes gh to return an arbitrary PR — skip the @@ -995,17 +1037,25 @@ export async function getPRForBranch( const list = JSON.parse(stdout) as NonNullable[] data = list[0] ?? null } else { - const { stdout } = await ghExecFileAsync( - [ - 'pr', - 'view', - branchName, - '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' - ], - { cwd: repoPath } - ) - data = JSON.parse(stdout) + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + branchName, + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: repoPath } + ) + data = JSON.parse(stdout) + } catch (err) { + if (isNoPullRequestError(err)) { + data = null + } else { + throw err + } + } } } @@ -1030,7 +1080,15 @@ export async function getPRForBranch( try { const { stdout } = await ghExecFileAsync(args, { cwd: repoPath }) data = JSON.parse(stdout) - } catch { + } catch (err) { + if (!isNoPullRequestError(err)) { + return { + kind: 'upstream-error', + errorType: classifyPRRefreshError(err), + message: err instanceof Error ? err.message : String(err), + fetchedAt: Date.now() + } + } // Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes // `gh pr view ` reject. Treat that as the no-PR case so // callers see the historical `null` semantics instead of a thrown @@ -1040,7 +1098,7 @@ export async function getPRForBranch( } if (!data) { - return null + return { kind: 'no-pr', fetchedAt: Date.now() } } const conflictSummary = @@ -1049,18 +1107,27 @@ export async function getPRForBranch( : undefined return { - number: data.number, - title: data.title, - state: mapPRState(data.state, data.isDraft), - url: data.url, - checksStatus: deriveCheckStatus(data.statusCheckRollup), - updatedAt: data.updatedAt, - mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN', - headSha: data.headRefOid, - conflictSummary + kind: 'found', + fetchedAt: Date.now(), + pr: { + number: data.number, + title: data.title, + state: mapPRState(data.state, data.isDraft), + url: data.url, + checksStatus: deriveCheckStatus(data.statusCheckRollup), + updatedAt: data.updatedAt, + mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN', + headSha: data.headRefOid, + conflictSummary + } + } + } catch (err) { + return { + kind: 'upstream-error', + errorType: classifyPRRefreshError(err), + message: err instanceof Error ? err.message : String(err), + fetchedAt: Date.now() } - } catch { - return null } finally { release() } diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts new file mode 100644 index 00000000000..94b98099a5e --- /dev/null +++ b/src/main/github/pr-refresh-coordinator.ts @@ -0,0 +1,447 @@ +/* eslint-disable max-lines -- Why: the coordinator keeps queueing, pacing, and +renderer broadcast rules together so freshness and rate-limit invariants are +reviewable in one place. */ +import { webContents } from 'electron' +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/types' +import { getPRForBranchOutcome } from './client' +import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' + +type QueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + windowId?: number +} + +const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const POST_PUSH_DELAY_MS = 2_500 +const BACKOFF_BASE_MS = 60_000 +const BACKOFF_MAX_MS = 15 * 60_000 + +let sequence = 0 +let draining = false +let drainTimer: ReturnType | null = null +const queue = new Map() +const backgroundStarts: number[] = [] +const errorBackoff = new Map() +let lastBackgroundStartAt = 0 +const visibleByWindow = new Map }>() + +function nextSequence(): number { + sequence += 1 + return sequence +} + +function broadcast(event: Omit, sequenceOverride?: number): void { + const payload: GitHubPRRefreshEvent = { ...event, sequence: sequenceOverride ?? nextSequence() } + for (const wc of webContents.getAllWebContents()) { + if (!wc.isDestroyed()) { + wc.send('gh:prRefreshEvent', payload) + } + } +} + +function refreshKey(candidate: GitHubPRRefreshCandidate): string { + if (typeof candidate.linkedPRNumber === 'number') { + return `${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${candidate.repoPath}::branch::${candidate.branch}` +} + +function isVisibleKey(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((wc) => !wc.isDestroyed()) + .map((wc) => wc.id) + ) + for (const windowId of Array.from(visibleByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + visibleByWindow.delete(windowId) + } + } + for (const visible of visibleByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false +} + +function isManual(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' +} + +function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshEvent['skippedReason'] | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (isManual(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null + } +} + +function scheduleVisibleFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number +): void { + if (!isVisibleKey(key)) { + return + } + if (outcome.kind === 'upstream-error') { + const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = + Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) + errorBackoff.set(key, { failures, retryAt }) + queue.set(key, { + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + windowId + }) + broadcast({ aliases, reason: 'visible', status: 'queued' }) + scheduleDrain(retryAt - Date.now()) + return + } + errorBackoff.delete(key) + enqueuePRRefresh(visibleCandidateAfterOutcome(candidate, outcome), 'visible', priority, windowId) +} + +function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return 15 * 60_000 + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return MIN_BACKGROUND_REFRESH_AGE_MS +} + +function noteBackgroundStart(): void { + const now = Date.now() + lastBackgroundStartAt = now + backgroundStarts.push(now) + while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { + backgroundStarts.shift() + } +} + +function nextBudgetDelay(): number { + const now = Date.now() + while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { + backgroundStarts.shift() + } + const spacingDelay = + lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) + : 0 + const windowDelay = + backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) +} + +function scheduleDrain(delay = 0): void { + if (drainTimer) { + clearTimeout(drainTimer) + } + drainTimer = setTimeout(() => { + drainTimer = null + void drainQueue() + }, delay) +} + +function queuedEntriesByPriority(): QueueEntry[] { + const now = Date.now() + return Array.from(queue.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) +} + +async function drainQueue(): Promise { + if (draining) { + return + } + draining = true + try { + while (queue.size > 0) { + const next = queuedEntriesByPriority()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + scheduleDrain(waitMs) + return + } + + const budgetDelay = isBackground(next.reason) ? nextBudgetDelay() : 0 + if (budgetDelay > 0) { + scheduleDrain(budgetDelay) + return + } + + queue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + continue + } + if (next.reason === 'visible' && !isVisibleKey(next.key)) { + broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) + continue + } + const requestSequence = nextSequence() + broadcast({ aliases, reason: next.reason, status: 'in-flight' }, requestSequence) + + if (isBackground(next.reason)) { + const rateLimit = await getRateLimit() + if (!rateLimit.ok) { + const retryAt = Date.now() + 30_000 + queue.set(next.key, { ...next, dueAt: retryAt }) + broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil: retryAt, + skippedReason: 'rate-limit' + }) + scheduleDrain(30_000) + continue + } + const graphqlGuard = rateLimitGuard('graphql') + const coreGuard = rateLimitGuard('core') + const blockedGuard = graphqlGuard.blocked + ? graphqlGuard + : coreGuard.blocked + ? coreGuard + : null + if (blockedGuard?.blocked) { + const retryAt = blockedGuard.resetAt * 1000 + queue.set(next.key, { ...next, dueAt: retryAt }) + broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil: retryAt, + skippedReason: 'rate-limit' + }) + scheduleDrain(Math.max(1_000, retryAt - Date.now())) + continue + } + noteBackgroundStart() + noteRateLimitSpend('graphql') + noteRateLimitSpend('core') + } + + const outcome = await getPRForBranchOutcome( + next.candidate.repoPath, + next.candidate.branch, + next.candidate.linkedPRNumber ?? null + ) + broadcast({ aliases, reason: next.reason, outcome }, requestSequence) + scheduleVisibleFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + next.windowId + ) + } + } finally { + draining = false + } +} + +export function enqueuePRRefresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority = 0, + windowId?: number +): void { + const alias: GitHubPRRefreshAlias = { + cacheKey: candidate.cacheKey, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId + } + const skippedReason = validateCandidate(candidate) + if (skippedReason) { + broadcast({ + aliases: [alias], + reason, + status: 'skipped', + skippedReason + }) + return + } + + const key = refreshKey(candidate) + const existing = queue.get(key) + const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null + const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) + if (existing) { + existing.aliases.set(alias.cacheKey, alias) + if (priority > existing.priority || isManual(reason)) { + existing.priority = priority + existing.reason = reason + existing.dueAt = Math.min(existing.dueAt, dueAt) + existing.candidate = candidate + existing.windowId = windowId ?? existing.windowId + } + } else { + queue.set(key, { + key, + candidate, + aliases: new Map([[alias.cacheKey, alias]]), + reason, + priority, + dueAt, + windowId + }) + } + broadcast({ aliases: [alias], reason, status: 'queued' }) + scheduleDrain() +} + +export function reportVisiblePRRefreshCandidates( + candidates: GitHubPRRefreshCandidate[], + generation: number, + windowId: number +): void { + const existingVisible = visibleByWindow.get(windowId) + if (existingVisible && generation < existingVisible.generation) { + return + } + visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + for (const [key, entry] of queue) { + if (entry.reason === 'visible' && !isVisibleKey(key)) { + queue.delete(key) + broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) + } + } + for (const candidate of candidates) { + enqueuePRRefresh(candidate, 'visible', 40, windowId) + } +} + +export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { + const alias: GitHubPRRefreshAlias = { + cacheKey: candidate.cacheKey, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId + } + const skippedReason = validateCandidate(candidate) + if (skippedReason) { + const outcome: PRRefreshOutcome = { + kind: 'upstream-error', + errorType: 'unknown', + message: `Cannot refresh PR for this worktree: ${skippedReason}`, + fetchedAt: Date.now() + } + broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + return outcome + } + + queue.delete(refreshKey(candidate)) + const requestSequence = nextSequence() + broadcast({ aliases: [alias], reason: 'manual', status: 'in-flight' }, requestSequence) + const outcome = await getPRForBranchOutcome( + candidate.repoPath, + candidate.branch, + candidate.linkedPRNumber ?? null + ) + broadcast({ aliases: [alias], reason: 'manual', outcome }, requestSequence) + scheduleVisibleFollowUp(refreshKey(candidate), candidate, outcome, 40, [alias]) + return outcome +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 4b3e9c20280..ca33de1599e 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -4,7 +4,13 @@ reviewable as one surface. Splitting by feature area would risk drifting validation/gate conventions across handler files. */ import { ipcMain, webContents } from 'electron' import { resolve } from 'path' -import type { Repo, GitHubIssueUpdate } from '../../shared/types' +import type { + Repo, + GitHubIssueUpdate, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/types' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { @@ -32,6 +38,11 @@ import { checkOrcaStarred, starOrca } from '../github/client' +import { + enqueuePRRefresh, + refreshPRNow, + reportVisiblePRRefreshCandidates +} from '../github/pr-refresh-coordinator' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' @@ -110,6 +121,17 @@ function assertRegisteredRepo(repoPath: string, store: Store): Repo { } export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { + function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } + }) + } + } + ipcMain.handle( 'gh:prForBranch', async (_event, args: { repoPath: string; branch: string; linkedPRNumber?: number | null }) => { @@ -130,6 +152,44 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:refreshPRNow', + async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + const repo = assertRegisteredRepo(args.candidate.repoPath, store) + const outcome = await refreshPRNow({ ...args.candidate, repoPath: repo.path }) + recordPRIfNeeded(repo, outcome) + return outcome + } + ) + + ipcMain.handle( + 'gh:enqueuePRRefresh', + ( + _event, + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + } + ) => { + const repo = assertRegisteredRepo(args.candidate.repoPath, store) + enqueuePRRefresh({ ...args.candidate, repoPath: repo.path }, args.reason, args.priority ?? 0) + return true + } + ) + + ipcMain.handle( + 'gh:reportVisiblePRRefreshCandidates', + (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const candidates = args.candidates.map((candidate) => { + const repo = assertRegisteredRepo(candidate.repoPath, store) + return { ...candidate, repoPath: repo.path } + }) + reportVisiblePRRefreshCandidates(candidates, args.generation, event.sender.id) + return true + } + ) + ipcMain.handle('gh:issue', (_event, args: { repoPath: string; number: number }) => { const repo = assertRegisteredRepo(args.repoPath, store) return getIssue(repo.path, args.number) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ea6ee2a1446..ca53ca648a4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -44,6 +44,9 @@ import type { MarkdownDocument, FloatingTerminalCwdRequest, GitHubIssueUpdate, + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, @@ -56,6 +59,7 @@ import type { PRCheckDetail, PRComment, PRInfo, + PRRefreshOutcome, Repo, ShellHydrationFailureReason, SparsePreset, @@ -560,6 +564,17 @@ export type PreloadApi = { branch: string linkedPRNumber?: number | null }) => Promise + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }) => Promise + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }) => Promise + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void) => () => void issue: (args: { repoPath: string; number: number }) => Promise workItem: (args: { repoPath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 4bdb579ec94..afa5774ffdf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,9 @@ import type { CustomPet, FsChangedPayload, GetRateLimitResult, + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, GitHubAssignableUser, GitHubCommentResult, GitHubWorkItem, @@ -649,6 +652,27 @@ const api = { linkedPRNumber?: number | null }): Promise => ipcRenderer.invoke('gh:prForBranch', args), + refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise => + ipcRenderer.invoke('gh:refreshPRNow', args), + + enqueuePRRefresh: (args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + }): Promise => ipcRenderer.invoke('gh:enqueuePRRefresh', args), + + reportVisiblePRRefreshCandidates: (args: { + candidates: GitHubPRRefreshCandidate[] + generation: number + }): Promise => ipcRenderer.invoke('gh:reportVisiblePRRefreshCandidates', args), + + onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: GitHubPRRefreshEvent): void => + callback(event) + ipcRenderer.on('gh:prRefreshEvent', listener) + return () => ipcRenderer.removeListener('gh:prRefreshEvent', listener) + }, + issue: (args: { repoPath: string; number: number }): Promise => ipcRenderer.invoke('gh:issue', args), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 24d5f9c6c49..138020bcdb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -177,6 +177,7 @@ function App(): React.JSX.Element { fetchSettings: s.fetchSettings, initGitHubCache: s.initGitHubCache, refreshAllGitHub: s.refreshAllGitHub, + reportVisibleGitHubPRRefreshCandidates: s.reportVisibleGitHubPRRefreshCandidates, hydrateWorkspaceSession: s.hydrateWorkspaceSession, hydrateTabsSession: s.hydrateTabsSession, hydrateEditorSession: s.hydrateEditorSession, @@ -655,6 +656,8 @@ function App(): React.JSX.Element { const handler = (): void => { if (document.visibilityState === 'visible') { actions.refreshAllGitHub() + } else { + actions.reportVisibleGitHubPRRefreshCandidates([], Date.now()) } } document.addEventListener('visibilitychange', handler) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2b1ad0e6b4e..0ae362f82e9 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -73,7 +73,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ }: WorktreeCardProps) { const openModal = useAppStore((s) => s.openModal) const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) + const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) const fetchIssue = useAppStore((s) => s.fetchIssue) const cardProps = useAppStore((s) => s.worktreeCardProperties) const handleEditIssue = useCallback( @@ -293,19 +293,20 @@ const WorktreeCard = React.memo(function WorktreeCard({ // This preference is purely presentational, so background refreshes would // spend rate limit budget on data the user cannot see. useEffect(() => { - if (repo && !isFolder && !worktree.isBare && prCacheKey && (showPR || showCI)) { + if (repo && !isFolder && !worktree.isBare && prCacheKey && isActive && (showPR || showCI)) { // Why: pass linkedPR so worktrees created from a PR (whose new local // branch differs from the PR's head ref) still resolve their PR via // a number-based fallback in the main process. - fetchPRForBranch(repo.path, branch, { linkedPRNumber: worktree.linkedPR ?? null }) + enqueueGitHubPRRefresh(worktree.id, 'active', 80) } }, [ repo, isFolder, worktree.isBare, + worktree.id, worktree.linkedPR, - fetchPRForBranch, - branch, + enqueueGitHubPRRefresh, + isActive, prCacheKey, showPR, showCI diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 94b87afee8c..e5c35bfcaa0 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -87,7 +87,7 @@ type PrSectionProps = { onClick: (e: React.MouseEvent) => void } -export function PrSection({ pr, onClick: _onClick }: PrSectionProps): React.JSX.Element { +export function PrSection({ pr, onClick }: PrSectionProps): React.JSX.Element { const state = pr.state const checksStatus = pr.checksStatus const hasChecks = checksStatus && checksStatus !== 'neutral' @@ -95,14 +95,17 @@ export function PrSection({ pr, onClick: _onClick }: PrSectionProps): React.JSX. { if (pr.url) { e.stopPropagation() + return } + e.preventDefault() + onClick(e) }} > (null) + const lastVisibleRefreshKeyRef = useRef('') + const reportVisibleGitHubPRRefreshCandidates = useAppStore( + (s) => s.reportVisibleGitHubPRRefreshCandidates + ) + const cardProps = useAppStore((s) => s.worktreeCardProperties) + const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration) // Drag is only meaningful when the user is grouping by repo. When inert // (groupBy !== 'repo'), the controller is still constructed for hook order @@ -383,6 +389,43 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const firstHeaderIndex = useMemo(() => rows.findIndex((r) => r.type === 'header'), [rows]) const virtualItems = virtualizer.getVirtualItems() + useEffect(() => { + if (!cardProps.includes('pr') && !cardProps.includes('ci')) { + if (lastVisibleRefreshKeyRef.current !== '__hidden__') { + lastVisibleRefreshKeyRef.current = '__hidden__' + reportVisibleGitHubPRRefreshCandidates([], Date.now()) + } + return + } + const scrollEl = scrollRef.current + if (!scrollEl) { + return + } + const viewportTop = scrollEl.scrollTop + const viewportBottom = viewportTop + scrollEl.clientHeight + const visibleRows = virtualItems + .filter((item) => item.start < viewportBottom && item.end > viewportTop) + .map((item) => rows[item.index]) + .filter((row): row is Extract => row?.type === 'item') + .filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch) + const visibleWorktreeIds = visibleRows.map((row) => row.worktree.id) + const visibleIdentity = visibleRows + .map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`) + .join('|') + const key = `${visibleIdentity}:${sshConnectedGeneration}:${cardProps.join(',')}` + if (!key || key === lastVisibleRefreshKeyRef.current) { + return + } + lastVisibleRefreshKeyRef.current = key + reportVisibleGitHubPRRefreshCandidates(visibleWorktreeIds, Date.now()) + }, [ + cardProps, + reportVisibleGitHubPRRefreshCandidates, + rows, + sshConnectedGeneration, + virtualItems + ]) + const activeDescendantId = activeWorktreeId != null && activeWorktreeRowIndex !== -1 && diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5c0a130faa6..ed28196ba0f 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -92,6 +92,14 @@ export function useIpcEvents(): void { }) ) + if (window.api.gh?.onPRRefreshEvent) { + unsubs.push( + window.api.gh.onPRRefreshEvent((event) => { + useAppStore.getState().applyGitHubPRRefreshEvent(event) + }) + ) + } + unsubs.push( window.api.ui.onOpenSettings(() => { useAppStore.getState().openSettingsPage() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index e2c0ec0fb30..30acb9ee393 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1933,6 +1933,10 @@ export const createEditorSlice: StateCreator = (s get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + const refreshGitHubForWorktree = get().refreshGitHubForWorktree + if (typeof refreshGitHubForWorktree === 'function') { + refreshGitHubForWorktree(worktreeId) + } }, pullBranch: async (worktreeId, worktreePath, connectionId) => { get().beginRemoteOperation('pull') @@ -1945,6 +1949,10 @@ export const createEditorSlice: StateCreator = (s get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + const refreshGitHubForWorktree = get().refreshGitHubForWorktree + if (typeof refreshGitHubForWorktree === 'function') { + refreshGitHubForWorktree(worktreeId) + } }, syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the @@ -1956,6 +1964,7 @@ export const createEditorSlice: StateCreator = (s // user invoked Sync; the underlying push is implementation detail. The // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false + let pushed = false try { await window.api.git.fetch({ worktreePath, connectionId }) await window.api.git.pull({ worktreePath, connectionId }) @@ -1974,6 +1983,7 @@ export const createEditorSlice: StateCreator = (s connectionId, pushTarget }) + pushed = true } catch (error) { // Why: format under the user-facing operation (sync) rather than // the inner step (push) — the user clicked Sync and shouldn't see @@ -1996,6 +2006,12 @@ export const createEditorSlice: StateCreator = (s get().endRemoteOperation() } void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + if (pushed) { + const refreshGitHubForWorktree = get().refreshGitHubForWorktree + if (typeof refreshGitHubForWorktree === 'function') { + refreshGitHubForWorktree(worktreeId) + } + } }, fetchBranch: async (worktreeId, worktreePath, connectionId) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the diff --git a/src/renderer/src/store/slices/github-checks.ts b/src/renderer/src/store/slices/github-checks.ts index ad4a2317b13..058ab4f8dde 100644 --- a/src/renderer/src/store/slices/github-checks.ts +++ b/src/renderer/src/store/slices/github-checks.ts @@ -37,7 +37,8 @@ export function syncPRChecksStatus( state: AppState, repoPath: string, branch: string | undefined, - checks: PRCheckDetail[] + checks: PRCheckDetail[], + headSha?: string ): Partial | null { const normalized = branch ? normalizeBranchName(branch) : '' if (!normalized) { @@ -49,6 +50,9 @@ export function syncPRChecksStatus( if (!prEntry?.data) { return null } + if (headSha && prEntry.data.headSha && prEntry.data.headSha !== headSha) { + return null + } const nextStatus = deriveCheckStatusFromChecks(checks) if (prEntry.data.checksStatus === nextStatus) { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index e77901aba9f..8301f50867a 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -8,6 +8,9 @@ import type { GitHubOwnerRepo, IssueSourcePreference, PRInfo, + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, IssueInfo, PRCheckDetail, PRComment, @@ -24,7 +27,7 @@ import type { GitHubProjectViewError } from '../../../../shared/github-project-types' import { sortWorkItemsByUpdatedAt, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items' -import { syncPRChecksStatus } from './github-checks' +import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: declared separately from CacheEntry (not a generified E parameter) @@ -260,6 +263,7 @@ export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } export type CacheEntry = { data: T | null fetchedAt: number + headSha?: string /** * Resolved issue/PR owner/repo slugs for this entry. Set only on entries * populated by `fetchWorkItems` — PR and issue single-item caches don't @@ -288,6 +292,13 @@ type FetchOptions = { force?: boolean } +type PRRefreshState = { + status: 'queued' | 'in-flight' | 'paused' | 'skipped' + reason: GitHubPRRefreshReason + updatedAt: number + pausedUntil?: number +} + const CACHE_TTL = 300_000 // 5 minutes (stale data shown instantly, then refreshed) const CHECKS_CACHE_TTL = 60_000 // 1 minute — checks change more frequently // Why: the NewWorkspace page's work-item list is a browse surface, not a @@ -355,6 +366,57 @@ function isFresh(entry: CacheEntry | undefined, ttl = CACHE_TTL): entry is return entry !== undefined && Date.now() - entry.fetchedAt < ttl } +function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { + for (const worktrees of Object.values(state.worktreesByRepo)) { + const worktree = worktrees.find((w) => w.id === worktreeId) + if (worktree) { + return worktree + } + } + return null +} + +function prCacheKey(repoPath: string, branch: string): string { + return `${repoPath}::${branch}` +} + +function buildPRRefreshCandidate( + state: AppState, + worktree: Worktree, + repoPath?: string +): GitHubPRRefreshCandidate | null { + const repo = state.repos.find((r) => r.id === worktree.repoId) + if (!repo) { + return null + } + const branch = worktree.branch.replace(/^refs\/heads\//, '') + const cacheKey = prCacheKey(repoPath ?? repo.path, branch) + const sshStatus = repo.connectionId + ? state.sshConnectionStates.get(repo.connectionId)?.status + : null + return { + repoId: repo.id, + repoPath: repoPath ?? repo.path, + repoKind: repo.kind ?? 'git', + branch, + cacheKey, + worktreeId: worktree.id, + linkedPRNumber: worktree.linkedPR ?? null, + isBare: worktree.isBare, + isArchived: worktree.isArchived, + connectionId: repo.connectionId ?? null, + connectionState: repo.connectionId + ? sshStatus === 'connected' + ? 'connected' + : 'disconnected' + : 'unknown', + cachedFetchedAt: state.prCache[cacheKey]?.fetchedAt ?? null, + cachedHasPR: state.prCache[cacheKey]?.data ? true : state.prCache[cacheKey] ? false : null, + cachedPRState: state.prCache[cacheKey]?.data?.state ?? null, + cachedChecksStatus: state.prCache[cacheKey]?.data?.checksStatus ?? null + } +} + /** * Evict the oldest entries from a cache record when it exceeds the max size. * Returns a pruned copy, or the original reference if no eviction was needed. @@ -400,6 +462,8 @@ export type GitHubSlice = { issueCache: Record> checksCache: Record> commentsCache: Record> + prRefreshSequences: Record + prRefreshStates: Record // Why: keyed by repoPath + limit + query so the NewWorkspace page can render // from cache instantly on mount (and on hover-prefetch from sidebar buttons) // while a background refresh keeps the list fresh. @@ -432,6 +496,13 @@ export type GitHubSlice = { refreshAllGitHub: () => void refreshGitHubForWorktree: (worktreeId: string) => void refreshGitHubForWorktreeIfStale: (worktreeId: string) => void + enqueueGitHubPRRefresh: ( + worktreeId: string, + reason: GitHubPRRefreshReason, + priority?: number + ) => void + reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void + applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void /** * Why: returns cached work items immediately (null if none) and fires a * background refresh when stale. Callers can render the cached list while @@ -576,6 +647,8 @@ export const createGitHubSlice: StateCreator = (s issueCache: {}, checksCache: {}, commentsCache: {}, + prRefreshSequences: {}, + prRefreshStates: {}, workItemsCache: {}, workItemsInvalidationNonce: 0, projectViewCache: {}, @@ -1179,7 +1252,7 @@ export const createGitHubSlice: StateCreator = (s }, fetchPRForBranch: async (repoPath, branch, options): Promise => { - const cacheKey = `${repoPath}::${branch}` + const cacheKey = prCacheKey(repoPath, branch) const cached = get().prCache[cacheKey] // Why: if a prior caller without a linkedPR cached `null` for this branch, // the worktree-card lookup (which has a linked PR fallback) would otherwise @@ -1201,22 +1274,37 @@ export const createGitHubSlice: StateCreator = (s const linkedPRNumber = options?.linkedPRNumber ?? null const request = (async () => { try { - const pr = await window.api.gh.prForBranch({ repoPath, branch, linkedPRNumber }) - if (prRequestGenerations.get(cacheKey) === generation) { - set((s) => ({ - prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: Date.now() } } - })) - debouncedSaveCache(get()) + const candidate: GitHubPRRefreshCandidate = { + repoId: '', + repoPath, + repoKind: 'git', + branch, + cacheKey, + linkedPRNumber, + cachedFetchedAt: cached?.fetchedAt ?? null } - return pr + const outcome = window.api.gh.refreshPRNow + ? await window.api.gh.refreshPRNow({ candidate }) + : await window.api.gh + .prForBranch({ repoPath, branch, linkedPRNumber }) + .then((pr) => + pr + ? ({ kind: 'found', pr, fetchedAt: Date.now() } as const) + : ({ kind: 'no-pr', fetchedAt: Date.now() } as const) + ) + const pr: PRInfo | null = + outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null + if (prRequestGenerations.get(cacheKey) === generation) { + if (outcome.kind !== 'upstream-error') { + set((s) => ({ + prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: outcome.fetchedAt } } + })) + debouncedSaveCache(get()) + } + } + return pr ?? null } catch (err) { console.error('Failed to fetch PR:', err) - if (prRequestGenerations.get(cacheKey) === generation) { - set((s) => ({ - prCache: { ...s.prCache, [cacheKey]: { data: null, fetchedAt: Date.now() } } - })) - debouncedSaveCache(get()) - } return null } finally { const activeRequest = inflightPRRequests.get(cacheKey) @@ -1272,10 +1360,21 @@ export const createGitHubSlice: StateCreator = (s fetchPRChecks: async (repoPath, prNumber, branch, headSha, options): Promise => { const cacheKey = `${repoPath}::pr-checks::${prNumber}` + const inflightKey = `${cacheKey}::${headSha ?? 'unknown'}` const cached = get().checksCache[cacheKey] - if (!options?.force && isFresh(cached, CHECKS_CACHE_TTL)) { + if ( + !options?.force && + isFresh(cached, CHECKS_CACHE_TTL) && + (!headSha || cached.headSha === headSha) + ) { const cachedChecks = cached.data ?? [] - const prStatusUpdate = syncPRChecksStatus(get(), repoPath, branch, cachedChecks) + const prStatusUpdate = syncPRChecksStatus( + get(), + repoPath, + branch, + cachedChecks, + cached.headSha + ) if (prStatusUpdate) { set(prStatusUpdate) debouncedSaveCache(get()) @@ -1283,7 +1382,7 @@ export const createGitHubSlice: StateCreator = (s return cachedChecks } - const inflightRequest = inflightChecksRequests.get(cacheKey) + const inflightRequest = inflightChecksRequests.get(inflightKey) if (inflightRequest) { return inflightRequest } @@ -1298,10 +1397,13 @@ export const createGitHubSlice: StateCreator = (s })) as PRCheckDetail[] set((s) => { const nextState: Partial = { - checksCache: { ...s.checksCache, [cacheKey]: { data: checks, fetchedAt: Date.now() } } + checksCache: { + ...s.checksCache, + [cacheKey]: { data: checks, fetchedAt: Date.now(), headSha } + } } - const prStatusUpdate = syncPRChecksStatus(s, repoPath, branch, checks) + const prStatusUpdate = syncPRChecksStatus(s, repoPath, branch, checks, headSha) if (prStatusUpdate?.prCache) { nextState.prCache = prStatusUpdate.prCache } @@ -1314,11 +1416,11 @@ export const createGitHubSlice: StateCreator = (s console.error('Failed to fetch PR checks:', err) return get().checksCache[cacheKey]?.data ?? [] } finally { - inflightChecksRequests.delete(cacheKey) + inflightChecksRequests.delete(inflightKey) } })() - inflightChecksRequests.set(cacheKey, request) + inflightChecksRequests.set(inflightKey, request) return request }, @@ -1391,12 +1493,111 @@ export const createGitHubSlice: StateCreator = (s return ok }, + enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => { + const state = get() + const worktree = findWorktreeById(state, worktreeId) + const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null + if (!candidate) { + return + } + const enqueue = window.api.gh.enqueuePRRefresh + if (enqueue) { + void enqueue({ candidate, reason, priority }).catch((err) => { + console.warn('Failed to enqueue PR refresh:', err) + }) + } + }, + + reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => { + const state = get() + const candidates = worktreeIds + .map((id) => { + const worktree = findWorktreeById(state, id) + return worktree ? buildPRRefreshCandidate(state, worktree) : null + }) + .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) + const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates + if (reportVisible) { + void reportVisible({ candidates, generation }).catch((err) => { + console.warn('Failed to report visible PR refresh candidates:', err) + }) + } + }, + + applyGitHubPRRefreshEvent: (event) => { + set((s) => { + const nextSequences = { ...s.prRefreshSequences } + const nextStates = { ...s.prRefreshStates } + let nextPRCache = s.prCache + let changed = false + + for (const alias of event.aliases) { + const previousSequence = nextSequences[alias.cacheKey] ?? 0 + if ( + event.outcome ? event.sequence < previousSequence : event.sequence <= previousSequence + ) { + continue + } + nextSequences[alias.cacheKey] = event.sequence + changed = true + + if (event.outcome) { + delete nextStates[alias.cacheKey] + if (event.outcome.kind === 'upstream-error') { + continue + } + const data = + event.outcome.kind === 'found' + ? (() => { + const pr = event.outcome.pr + const checksEntry = s.checksCache[`${alias.repoPath}::pr-checks::${pr.number}`] + if ( + checksEntry?.data && + checksEntry.headSha && + pr.headSha && + checksEntry.headSha === pr.headSha && + event.outcome.fetchedAt - checksEntry.fetchedAt < CHECKS_CACHE_TTL + ) { + return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) } + } + return pr + })() + : null + nextPRCache = { + ...nextPRCache, + [alias.cacheKey]: { data, fetchedAt: event.outcome.fetchedAt } + } + continue + } + + if (event.status) { + nextStates[alias.cacheKey] = { + status: event.status, + reason: event.reason, + updatedAt: Date.now(), + pausedUntil: event.pausedUntil + } + } + } + + return changed + ? { + prRefreshSequences: nextSequences, + prRefreshStates: nextStates, + prCache: nextPRCache + } + : {} + }) + if (event.outcome && event.outcome.kind !== 'upstream-error') { + debouncedSaveCache(get()) + } + }, + refreshAllGitHub: () => { - // Invalidate checks and comments caches so they refresh on next access. + // Invalidate comments cache so it refreshes on next access. // Also evict old entries from prCache and issueCache to prevent unbounded // growth across many repos and branches over a long-running session. set((s) => ({ - checksCache: {}, commentsCache: {}, prCache: evictStaleEntries(s.prCache), issueCache: evictStaleEntries(s.issueCache) @@ -1413,6 +1614,9 @@ export const createGitHubSlice: StateCreator = (s // Only re-fetch PR/issue entries that are already stale — skip fresh ones const state = get() const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const shouldRefreshPRs = + state.worktreeCardProperties.includes('pr') || state.worktreeCardProperties.includes('ci') for (const worktrees of Object.values(state.worktreesByRepo)) { for (const wt of worktrees) { @@ -1422,11 +1626,19 @@ export const createGitHubSlice: StateCreator = (s } const branch = wt.branch.replace(/^refs\/heads\//, '') - if (!wt.isBare && branch) { + if (shouldRefreshPRs && !wt.isBare && branch) { const prKey = `${repo.path}::${branch}` const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - void get().fetchPRForBranch(repo.path, branch) + const candidate = buildPRRefreshCandidate(state, wt) + if (candidate) { + stalePRCandidates.push({ + candidate, + score: + (state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) + + wt.lastActivityAt + }) + } } } if (wt.linkedIssue) { @@ -1438,6 +1650,9 @@ export const createGitHubSlice: StateCreator = (s } } } + for (const { candidate } of stalePRCandidates.sort((a, b) => b.score - a.score).slice(0, 5)) { + void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 }) + } }, refreshGitHubForWorktree: (worktreeId) => { @@ -1479,7 +1694,10 @@ export const createGitHubSlice: StateCreator = (s // Re-fetch (skip when branch is empty — detached HEAD during rebase) if (!worktree.isBare && branch) { - void get().fetchPRForBranch(repo.path, branch, { force: true }) + const candidate = buildPRRefreshCandidate(get(), worktree) + if (candidate) { + void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 }) + } } if (worktree.linkedIssue) { void get().fetchIssue(repo.path, worktree.linkedIssue) @@ -1611,7 +1829,10 @@ export const createGitHubSlice: StateCreator = (s const prStale = !prEntry || now - prEntry.fetchedAt >= CACHE_TTL if (!worktree.isBare && branch && prStale) { - void get().fetchPRForBranch(repo.path, branch, { force: true }) + const candidate = buildPRRefreshCandidate(state, worktree) + if (candidate) { + void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 }) + } } if (worktree.linkedIssue) { diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index 9078bd9da17..96b6365d2c1 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -52,8 +52,15 @@ export const createSshSlice: StateCreator = (set) => setSshConnectionState: (targetId, state) => set((s) => { const next = new Map(s.sshConnectionStates) + const previous = next.get(targetId) next.set(targetId, state) - return { sshConnectionStates: next } + return { + sshConnectionStates: next, + sshConnectedGeneration: + previous?.status !== 'connected' && state.status === 'connected' + ? s.sshConnectedGeneration + 1 + : s.sshConnectedGeneration + } }), setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }), diff --git a/src/shared/types.ts b/src/shared/types.ts index 29bd2de650e..27c694ae1fd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -490,6 +490,56 @@ export type PRInfo = { conflictSummary?: PRConflictSummary } +export type PRRefreshOutcome = + | { kind: 'found'; pr: PRInfo; fetchedAt: number } + | { kind: 'no-pr'; fetchedAt: number } + | { + kind: 'upstream-error' + errorType: + | 'rate_limited' + | 'auth' + | 'network' + | 'permission' + | 'repo_unavailable' + | 'gh_unavailable' + | 'unknown' + message: string + fetchedAt: number + } + +export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' + +export type GitHubPRRefreshAlias = { + cacheKey: string + repoPath: string + branch: string + worktreeId?: string +} + +export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { + linkedPRNumber?: number | null + repoKind: RepoKind + repoId: string + isBare?: boolean + isArchived?: boolean + connectionId?: string | null + connectionState?: 'connected' | 'disconnected' | 'unknown' + cachedFetchedAt?: number | null + cachedHasPR?: boolean | null + cachedPRState?: PRState | null + cachedChecksStatus?: CheckStatus | null +} + +export type GitHubPRRefreshEvent = { + sequence: number + reason: GitHubPRRefreshReason + aliases: GitHubPRRefreshAlias[] + outcome?: PRRefreshOutcome + status?: 'queued' | 'in-flight' | 'paused' | 'skipped' + pausedUntil?: number + skippedReason?: 'fresh' | 'not-git' | 'bare' | 'archived' | 'disconnected' | 'rate-limit' +} + export type PRCheckDetail = { name: string status: 'queued' | 'in_progress' | 'completed'