Improve GitHub PR refresh freshness

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-14 02:49:16 -04:00
co-authored by Orca
parent d3d263ba08
commit 85b3319fea
15 changed files with 1045 additions and 76 deletions
+105 -38
View File
@@ -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<PRRefreshOutcome, { kind: 'upstream-error' }>['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<PRInfo | null> {
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<PRRefreshOutcome> {
// 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<typeof data>[]
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 <number>` 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()
}
+447
View File
@@ -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<string, GitHubPRRefreshAlias>
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<typeof setTimeout> | null = null
const queue = new Map<string, QueueEntry>()
const backgroundStarts: number[] = []
const errorBackoff = new Map<string, { failures: number; retryAt: number }>()
let lastBackgroundStartAt = 0
const visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
function nextSequence(): number {
sequence += 1
return sequence
}
function broadcast(event: Omit<GitHubPRRefreshEvent, 'sequence'>, 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<void> {
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<PRRefreshOutcome> {
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
}
+61 -1
View File
@@ -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)
+15
View File
@@ -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<PRInfo | null>
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }) => Promise<PRRefreshOutcome>
enqueuePRRefresh: (args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}) => Promise<boolean>
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
}) => Promise<boolean>
onPRRefreshEvent: (callback: (event: GitHubPRRefreshEvent) => void) => () => void
issue: (args: { repoPath: string; number: number }) => Promise<IssueInfo | null>
workItem: (args: {
repoPath: string
+24
View File
@@ -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<unknown> => ipcRenderer.invoke('gh:prForBranch', args),
refreshPRNow: (args: { candidate: GitHubPRRefreshCandidate }): Promise<unknown> =>
ipcRenderer.invoke('gh:refreshPRNow', args),
enqueuePRRefresh: (args: {
candidate: GitHubPRRefreshCandidate
reason: GitHubPRRefreshReason
priority?: number
}): Promise<unknown> => ipcRenderer.invoke('gh:enqueuePRRefresh', args),
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
}): Promise<unknown> => 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<unknown> =>
ipcRenderer.invoke('gh:issue', args),
+3
View File
@@ -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)
@@ -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
@@ -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.
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>
<a
href={pr.url}
href={pr.url ?? '#'}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 min-w-0 cursor-pointer group/meta -mx-1.5 px-1.5 py-0.5 rounded transition-colors hover:bg-background/40"
onClick={(e) => {
if (pr.url) {
e.stopPropagation()
return
}
e.preventDefault()
onClick(e)
}}
>
<PullRequestIcon
@@ -133,6 +133,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
scrollOffsetRef
}: VirtualizedWorktreeViewportProps) {
const scrollRef = useRef<HTMLDivElement>(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' }> => 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 &&
+8
View File
@@ -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()
+16
View File
@@ -1933,6 +1933,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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
@@ -37,7 +37,8 @@ export function syncPRChecksStatus(
state: AppState,
repoPath: string,
branch: string | undefined,
checks: PRCheckDetail[]
checks: PRCheckDetail[],
headSha?: string
): Partial<AppState> | 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) {
+249 -28
View File
@@ -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<T> (not a generified E parameter)
@@ -260,6 +263,7 @@ export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo }
export type CacheEntry<T> = {
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<T>(entry: CacheEntry<T> | 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<string, CacheEntry<IssueInfo>>
checksCache: Record<string, CacheEntry<PRCheckDetail[]>>
commentsCache: Record<string, CacheEntry<PRComment[]>>
prRefreshSequences: Record<string, number>
prRefreshStates: Record<string, PRRefreshState>
// 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<AppState, [], [], GitHubSlice> = (s
issueCache: {},
checksCache: {},
commentsCache: {},
prRefreshSequences: {},
prRefreshStates: {},
workItemsCache: {},
workItemsInvalidationNonce: 0,
projectViewCache: {},
@@ -1179,7 +1252,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
},
fetchPRForBranch: async (repoPath, branch, options): Promise<PRInfo | null> => {
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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (s
fetchPRChecks: async (repoPath, prNumber, branch, headSha, options): Promise<PRCheckDetail[]> => {
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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (s
})) as PRCheckDetail[]
set((s) => {
const nextState: Partial<AppState> = {
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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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<AppState, [], [], GitHubSlice> = (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) {
+8 -1
View File
@@ -52,8 +52,15 @@ export const createSshSlice: StateCreator<AppState, [], [], SshSlice> = (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 }),
+50
View File
@@ -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'