mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
Improve GitHub PR refresh freshness
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+105
-38
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user