Optimize CI checks caching and polling backoff behavior (#5252)

* Optimize CI checks caching and polling backoff behavior

- Prevent backing off the polling interval when no checks are reported
  yet, keeping polling at the baseline rate to detect newly started jobs.
- Reduce empty checks cache TTL to 10 seconds to allow faster recovery
  from a "no checks" state while preserving the 60-second TTL for active jobs.
- Avoid bypassing the GitHub CLI cache on tab entry, reserving forced
  fetches only for explicit manual user refreshes.
- Update "No checks configured" copy to "No checks reported yet" to avoid
  misleading users before their CI pipelines have started running.
- Ensure manual checks refreshes do not inherit in-flight automatic requests
  which might be backed by cached CLI data.
- Lookup head-specific cache keys during PR refresh events to prevent
  incorrectly deriving neutral check statuses.

* Clarify polling reset comment in ChecksPanel

Update the comment to accurately refer to the entry refresh rather
than a forced fetch establishing a fresh baseline.
This commit is contained in:
Jinjing 2026-06-12 00:16:36 -07:00 committed by GitHub
parent 4278fdc1e8
commit 9c8d9be8dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 583 additions and 111 deletions

View File

@ -89,6 +89,10 @@ import {
restorePRCommentThreadSnapshot
} from './pr-comment-thread-resolution'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
import {
CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
nextChecksPanelPollInterval
} from './checks-panel-polling'
import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
@ -449,7 +453,7 @@ export default function ChecksPanel(): React.JSX.Element {
const [titleSaving, setTitleSaving] = useState(false)
const titleInputRef = useRef<HTMLInputElement>(null)
const titleInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollIntervalRef = useRef(30_000) // start at 30s, backs off to 120s
const pollIntervalRef = useRef(CHECKS_PANEL_BASE_POLL_INTERVAL_MS)
const mountedRef = useMountedRef()
const confirm = useConfirmationDialog()
const prevChecksRef = useRef<string>('')
@ -584,7 +588,7 @@ export default function ChecksPanel(): React.JSX.Element {
setHostedReviewCreationSnapshot(null)
setGitStatusSnapshot(null)
setGitStatusRefreshNonce((value) => value + 1)
pollIntervalRef.current = 30_000
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
conflictSummaryRefreshKeyRef.current = null
refreshRequestKeyRef.current = null
@ -1362,14 +1366,13 @@ export default function ChecksPanel(): React.JSX.Element {
}
setChecks(result)
// Exponential backoff: if checks haven't changed, double the interval (cap 120s).
// If they changed, reset to 30s.
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
} catch (err) {
if (
!isCurrentAsyncResult(
@ -1442,12 +1445,13 @@ export default function ChecksPanel(): React.JSX.Element {
const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? [])
setChecks(result)
setComments(gitLabMRCommentsToPRComments(details?.comments))
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
} catch (err) {
if (!isCurrentAsyncResult(requestKey)) {
return
@ -1484,7 +1488,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
// Reset backoff state on PR change
pollIntervalRef.current = 30_000
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
// Why: PR check status is user-visible when the panel is open. Keep visible
// unfocused windows fresh, but stop timers and API work while hidden.
@ -1499,7 +1503,7 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
pollIntervalRef.current = 30_000
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
return installWindowVisibilityTimeoutPoller({
run: () => fetchGitLabDetails(),
@ -1751,14 +1755,13 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
setChecks(result)
const signature = JSON.stringify(
result.map((c) => `${c.name}:${c.status}:${c.conclusion}`)
)
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
prevChecksRef.current = signature
const poll = nextChecksPanelPollInterval({
checks: result,
previousSignature: prevChecksRef.current,
currentIntervalMs: pollIntervalRef.current
})
pollIntervalRef.current = poll.intervalMs
prevChecksRef.current = poll.signature
},
(err) => {
if (!isCurrentRequest() || !isCurrentAsyncResult(prRequestKey)) {
@ -1836,9 +1839,8 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
// Why: entering the Checks tab is automatic UI behavior, not an explicit
// user refresh. Route PR refresh through the coordinator so rate-limit
// guards still apply; only force detail panes that the entry freshness rule
// already proved stale, so tab entry stays fresh without broad fan-out.
// user refresh. Keep check loads cacheable so the GitHub CLI cache can
// absorb visible polling; only the manual refresh button bypasses it.
if (isGitLabReviewContext) {
void fetchHostedReviewForBranch(repo.path, branch, {
force: true,
@ -1854,7 +1856,7 @@ export default function ChecksPanel(): React.JSX.Element {
}
enqueueGitHubPRRefresh(activeWorktreeId, 'active', 80)
if (options.refreshChecks) {
void fetchChecks({ force: true })
void fetchChecks()
}
if (options.refreshComments) {
void fetchComments({ force: true })
@ -1918,9 +1920,9 @@ export default function ChecksPanel(): React.JSX.Element {
const refreshComments =
prNumber !== null && (commentsFetchedAt === undefined || commentsFetchedAt < cutoff)
// Reset polling attention state so the forced fetch's signature establishes
// a fresh baseline rather than colliding with the previous PR's backoff.
pollIntervalRef.current = 30_000
// Reset polling attention state so this entry refresh establishes a fresh
// baseline rather than colliding with the previous PR's backoff.
pollIntervalRef.current = CHECKS_PANEL_BASE_POLL_INTERVAL_MS
prevChecksRef.current = ''
handleEntryRefresh({ refreshChecks, refreshComments })
}, [entryKey, prFetchedAt, checksFetchedAt, commentsFetchedAt, prNumber, handleEntryRefresh])
@ -3300,7 +3302,7 @@ export default function ChecksPanel(): React.JSX.Element {
</>
)}
{/* Why: when the hosted review has merge conflicts and no checks have been fetched,
showing "No checks configured" is misleading checks may exist but
showing an empty checks state is misleading checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
{!(activeConflictReview && checks.length === 0 && !checksLoading) && (
<ChecksList

View File

@ -1232,7 +1232,7 @@ export function ChecksList({
<div className="flex items-center justify-center py-8 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.checks.panel.content.991f50c7e4',
'No checks configured'
'No checks reported yet'
)}
</div>
) : !checksExpanded ? null : (

View File

@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import type { PRCheckDetail } from '../../../../shared/types'
import {
CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
CHECKS_PANEL_MAX_POLL_INTERVAL_MS,
nextChecksPanelPollInterval
} from './checks-panel-polling'
describe('nextChecksPanelPollInterval', () => {
it('keeps repeated empty results at the baseline poll interval', () => {
expect(
nextChecksPanelPollInterval({
checks: [],
previousSignature: '[]',
currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
})
).toEqual({ intervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS, signature: '[]' })
})
it('backs off repeated non-empty results up to the maximum interval', () => {
const checks: PRCheckDetail[] = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
]
const { signature } = nextChecksPanelPollInterval({
checks,
previousSignature: '',
currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
})
expect(
nextChecksPanelPollInterval({
checks,
previousSignature: signature,
currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
}).intervalMs
).toBe(CHECKS_PANEL_BASE_POLL_INTERVAL_MS * 2)
expect(
nextChecksPanelPollInterval({
checks,
previousSignature: signature,
currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
}).intervalMs
).toBe(CHECKS_PANEL_MAX_POLL_INTERVAL_MS)
})
it('resets changed non-empty results to the baseline poll interval', () => {
const previous: PRCheckDetail[] = [
{ name: 'build', status: 'queued', conclusion: null, url: null }
]
const next: PRCheckDetail[] = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
]
const { signature } = nextChecksPanelPollInterval({
checks: previous,
previousSignature: '',
currentIntervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS
})
expect(
nextChecksPanelPollInterval({
checks: next,
previousSignature: signature,
currentIntervalMs: CHECKS_PANEL_MAX_POLL_INTERVAL_MS
}).intervalMs
).toBe(CHECKS_PANEL_BASE_POLL_INTERVAL_MS)
})
})

View File

@ -0,0 +1,26 @@
import type { PRCheckDetail } from '../../../../shared/types'
export const CHECKS_PANEL_BASE_POLL_INTERVAL_MS = 30_000
export const CHECKS_PANEL_MAX_POLL_INTERVAL_MS = 120_000
export function nextChecksPanelPollInterval(input: {
checks: PRCheckDetail[]
previousSignature: string
currentIntervalMs: number
}): { intervalMs: number; signature: string } {
const signature = JSON.stringify(
input.checks.map((check) => `${check.name}:${check.status}:${check.conclusion}`)
)
if (input.checks.length === 0) {
return { intervalMs: CHECKS_PANEL_BASE_POLL_INTERVAL_MS, signature }
}
return {
intervalMs:
signature === input.previousSignature
? Math.min(input.currentIntervalMs * 2, CHECKS_PANEL_MAX_POLL_INTERVAL_MS)
: CHECKS_PANEL_BASE_POLL_INTERVAL_MS,
signature
}
}

View File

@ -8070,7 +8070,7 @@
"74c6885b8a": "More comment actions",
"cbcc4ab3db": "Showing first 100 checks",
"0dca6bfab5": "Open check details",
"991f50c7e4": "No checks configured",
"991f50c7e4": "No checks reported yet",
"9ad98f2a17": "pending",
"5e52f4ef7f": "failing",
"02ca4f9074": "passing",

View File

@ -8070,7 +8070,7 @@
"74c6885b8a": "Más acciones de comentarios",
"cbcc4ab3db": "Mostrando los primeros 100 cheques",
"0dca6bfab5": "Abrir detalles del cheque",
"991f50c7e4": "No hay controles configurados",
"991f50c7e4": "Aún no se han reportado controles",
"9ad98f2a17": "pendiente",
"5e52f4ef7f": "defecto",
"02ca4f9074": "paso",

View File

@ -8070,7 +8070,7 @@
"74c6885b8a": "その他のコメント操作",
"cbcc4ab3db": "最初の 100 件のチェックを表示しています",
"0dca6bfab5": "オープンチェックの詳細",
"991f50c7e4": "チェックが設定されていません",
"991f50c7e4": "まだチェックは報告されていません",
"9ad98f2a17": "保留中",
"5e52f4ef7f": "失敗した",
"02ca4f9074": "合格",

View File

@ -8070,7 +8070,7 @@
"74c6885b8a": "추가 댓글 작업",
"cbcc4ab3db": "처음 100개 검사 표시",
"0dca6bfab5": "검사 세부 정보 열기",
"991f50c7e4": "구성된 검사가 없습니다.",
"991f50c7e4": "아직 보고된 체크가 없습니다.",
"9ad98f2a17": "보류 중",
"5e52f4ef7f": "실패",
"02ca4f9074": "통과",

View File

@ -8070,7 +8070,7 @@
"74c6885b8a": "更多评论动作",
"cbcc4ab3db": "显示前 100 项检查",
"0dca6bfab5": "打开检查详细信息",
"991f50c7e4": "未配置检查",
"991f50c7e4": "尚未报告任何检查",
"9ad98f2a17": "待办的",
"5e52f4ef7f": "失败",
"02ca4f9074": "通过",

View File

@ -0,0 +1,390 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import { createGitHubSlice, prChecksCacheSuffix } from './github'
import { createHostedReviewSlice } from './hosted-review'
import { getHostedReviewCacheKey } from './hosted-review-cache-identity'
import type { AppState } from '../types'
import type { PRCheckDetail, PRInfo } from '../../../../shared/types'
const mockApi = {
gh: {
prChecks: vi.fn()
},
cache: {
setGitHub: vi.fn()
}
}
// @ts-expect-error test window mock
globalThis.window = { api: mockApi }
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => {}
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
function createTestStore() {
return create<AppState>()(
(...a) =>
({
...createGitHubSlice(...a),
...createHostedReviewSlice(...a)
}) as AppState
)
}
function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 12,
title: 'Test PR',
state: 'open',
url: 'https://example.com/pr/12',
checksStatus: 'pending',
updatedAt: '2026-03-28T00:00:00Z',
mergeable: 'UNKNOWN',
headSha: 'head-oid',
...overrides
}
}
beforeEach(() => {
mockApi.gh.prChecks.mockReset()
mockApi.gh.prChecks.mockResolvedValue([])
mockApi.cache.setGitHub.mockReset()
mockApi.cache.setGitHub.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
describe('createGitHubSlice.fetchPRChecks checks cache freshness', () => {
it('expires empty checks cache entries after the shorter empty TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
mockApi.gh.prChecks.mockResolvedValue([])
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
vi.setSystemTime(11_001)
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
})
it('keeps repeated automatic empty checks refreshes cacheable', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
mockApi.gh.prChecks.mockResolvedValue([])
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
vi.setSystemTime(11_001)
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(1, {
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: false
})
expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: false
})
})
it('keeps non-empty checks cache entries fresh for the normal checks TTL', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
mockApi.gh.prChecks.mockResolvedValue([
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
])
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
vi.setSystemTime(11_001)
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
})
it('dedupes simultaneous cacheable empty checks requests', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
const request = deferred<PRCheckDetail[]>()
mockApi.gh.prChecks.mockReturnValueOnce(request.promise)
const first = store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
const second = store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
request.resolve([])
await expect(first).resolves.toEqual([])
await expect(second).resolves.toEqual([])
})
it('does not dedupe forced checks onto an in-flight cacheable request', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
const cacheableRequest = deferred<PRCheckDetail[]>()
const forcedChecks = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null } as const
]
mockApi.gh.prChecks
.mockReturnValueOnce(cacheableRequest.promise)
.mockResolvedValueOnce(forcedChecks)
const cacheable = store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
const forced = store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
cacheableRequest.resolve([])
await expect(cacheable).resolves.toEqual([])
await expect(forced).resolves.toEqual(forcedChecks)
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: true
})
})
it('dedupes simultaneous forced checks requests', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
const request = deferred<PRCheckDetail[]>()
const checks = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null } as const
]
mockApi.gh.prChecks.mockReturnValueOnce(request.promise)
const first = store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
const second = store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { force: true, repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(1)
request.resolve(checks)
await expect(first).resolves.toEqual(checks)
await expect(second).resolves.toEqual(checks)
expect(mockApi.gh.prChecks).toHaveBeenCalledWith({
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: true
})
})
it('treats explicit noCache checks requests as fresh requests', async () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-id'
const branch = 'feature/test'
mockApi.gh.prChecks.mockResolvedValue([
{ name: 'build', status: 'completed', conclusion: 'success', url: null }
])
await store.getState().fetchPRChecks(repoPath, 12, branch, undefined, null, { repoId })
await store
.getState()
.fetchPRChecks(repoPath, 12, branch, undefined, null, { noCache: true, repoId })
expect(mockApi.gh.prChecks).toHaveBeenCalledTimes(2)
expect(mockApi.gh.prChecks).toHaveBeenNthCalledWith(2, {
repoPath,
repoId,
prNumber: 12,
headSha: undefined,
prRepo: null,
noCache: true
})
})
it('preserves cached checks when the checks IPC fails', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const checksCacheKey = `${repoPath}::pr-checks::12`
const cachedChecks = [
{ name: 'build', status: 'completed', conclusion: 'failure', url: null } as const
]
store.setState({
checksCache: {
[checksCacheKey]: {
data: cachedChecks,
fetchedAt: 1,
headSha: 'abc123head'
}
}
} as unknown as Partial<AppState>)
mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
await expect(
store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true })
).resolves.toEqual(cachedChecks)
expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks)
expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1)
})
it('does not return cached checks for a different requested head SHA after IPC failure', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const checksCacheKey = `${repoPath}::pr-checks::12`
const oldHeadChecks = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null } as const
]
store.setState({
checksCache: {
[checksCacheKey]: {
data: oldHeadChecks,
fetchedAt: 1,
headSha: 'old-head'
}
}
} as unknown as Partial<AppState>)
mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
await expect(
store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true })
).resolves.toEqual([])
expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks)
expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head')
})
})
describe('createGitHubSlice.applyGitHubPRRefreshEvent checks cache reuse', () => {
it('does not derive neutral PR status from stale empty checks during refresh events', () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-1'
const branch = 'feature/stale-empty-checks'
const cacheKey = `${repoId}::${branch}`
const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId)
const checksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}`
store.setState({
checksCache: {
[checksCacheKey]: {
data: [],
fetchedAt: 1_000,
headSha: 'head-oid'
}
}
} as unknown as Partial<AppState>)
store.getState().applyGitHubPRRefreshEvent({
sequence: 1,
aliases: [{ cacheKey, repoId, repoPath, branch }],
reason: 'visible',
outcome: {
kind: 'found',
pr: makePR({ number: 12, checksStatus: 'pending', headSha: 'head-oid' }),
fetchedAt: 21_000
}
})
expect(store.getState().prCache[cacheKey]).toMatchObject({
data: expect.objectContaining({ checksStatus: 'pending' }),
fetchedAt: 21_000
})
expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({
data: expect.objectContaining({ provider: 'github', status: 'pending' }),
fetchedAt: 21_000
})
})
it('reuses fresh head-specific checks cache entries during refresh events', () => {
const store = createTestStore()
const repoPath = '/repo'
const repoId = 'repo-1'
const branch = 'feature/fresh-head-checks'
const cacheKey = `${repoId}::${branch}`
const hostedReviewCacheKey = getHostedReviewCacheKey(repoPath, branch, null, repoId)
const checksCacheKey = `${repoId}::${prChecksCacheSuffix(12, null, 'head-oid')}`
store.setState({
checksCache: {
[checksCacheKey]: {
data: [{ name: 'build', status: 'completed', conclusion: 'success', url: null }],
fetchedAt: 20_000,
headSha: 'head-oid'
}
}
} as unknown as Partial<AppState>)
store.getState().applyGitHubPRRefreshEvent({
sequence: 1,
aliases: [{ cacheKey, repoId, repoPath, branch }],
reason: 'visible',
outcome: {
kind: 'found',
pr: makePR({ number: 12, checksStatus: 'pending', headSha: 'head-oid' }),
fetchedAt: 21_000
}
})
expect(store.getState().prCache[cacheKey]).toMatchObject({
data: expect.objectContaining({ checksStatus: 'success' }),
fetchedAt: 21_000
})
expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({
data: expect.objectContaining({ provider: 'github', status: 'success' }),
fetchedAt: 21_000
})
})
})

View File

@ -847,62 +847,6 @@ describe('createGitHubSlice.fetchPRComments', () => {
vi.useRealTimers()
}
})
it('preserves cached checks when the checks IPC fails', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const checksCacheKey = `${repoPath}::pr-checks::12`
const cachedChecks = [
{ name: 'build', status: 'completed', conclusion: 'failure', url: null } as const
]
store.setState({
checksCache: {
[checksCacheKey]: {
data: cachedChecks,
fetchedAt: 1,
headSha: 'abc123head'
}
}
} as unknown as Partial<AppState>)
mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
await expect(
store.getState().fetchPRChecks(repoPath, 12, branch, 'abc123head', null, { force: true })
).resolves.toEqual(cachedChecks)
expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(cachedChecks)
expect(store.getState().checksCache[checksCacheKey]?.fetchedAt).toBe(1)
})
it('does not return cached checks for a different requested head SHA after IPC failure', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const checksCacheKey = `${repoPath}::pr-checks::12`
const oldHeadChecks = [
{ name: 'build', status: 'completed', conclusion: 'success', url: null } as const
]
store.setState({
checksCache: {
[checksCacheKey]: {
data: oldHeadChecks,
fetchedAt: 1,
headSha: 'old-head'
}
}
} as unknown as Partial<AppState>)
mockApi.gh.prChecks.mockRejectedValueOnce(new Error('rate limited'))
await expect(
store.getState().fetchPRChecks(repoPath, 12, branch, 'new-head', null, { force: true })
).resolves.toEqual([])
expect(store.getState().checksCache[checksCacheKey]?.data).toEqual(oldHeadChecks)
expect(store.getState().checksCache[checksCacheKey]?.headSha).toBe('old-head')
})
})
describe('createGitHubSlice.fetchPRCheckDetails', () => {

View File

@ -427,6 +427,7 @@ function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolea
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
const EMPTY_CHECKS_CACHE_TTL = 10_000
// Why: the NewWorkspace page's work-item list is a browse surface, not a
// source of truth, so 60s staleness is fine — stale data renders instantly
// while a background refresh keeps it current.
@ -441,7 +442,11 @@ const inflightPRRequests = new Map<
{ promise: Promise<PRInfo | null>; force: boolean; generation: number; lookupHintKey: string }
>()
const inflightIssueRequests = new Map<string, Promise<IssueInfo | null>>()
const inflightChecksRequests = new Map<string, Promise<PRCheckDetail[]>>()
type InflightChecksRequest = {
promise: Promise<PRCheckDetail[]>
noCache: boolean
}
const inflightChecksRequests = new Map<string, InflightChecksRequest>()
const inflightCommentsRequests = new Map<string, Promise<PRComment[]>>()
type InflightWorkItems = {
promise: Promise<GitHubWorkItem[]>
@ -654,6 +659,10 @@ function isFresh<T>(entry: CacheEntry<T> | undefined, ttl = CACHE_TTL): entry is
return entry !== undefined && Date.now() - entry.fetchedAt < ttl
}
function checksCacheTtl(entry: CacheEntry<PRCheckDetail[]> | undefined): number {
return entry?.data?.length === 0 ? EMPTY_CHECKS_CACHE_TTL : CHECKS_CACHE_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)
@ -2411,10 +2420,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
: cacheKey
const inflightKey = cacheKey
const requestNoCache = options?.noCache === true || options?.force === true
const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (
!options?.force &&
isFresh(cached, CHECKS_CACHE_TTL) &&
!requestNoCache &&
isFresh(cached, checksCacheTtl(cached)) &&
(!headSha || cached.headSha === headSha)
) {
const cachedChecks = cached.data ?? []
@ -2438,7 +2448,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const inflightRequest = inflightChecksRequests.get(inflightKey)
if (inflightRequest) {
return inflightRequest
if (!requestNoCache || inflightRequest.noCache) {
return inflightRequest.promise
}
// Why: manual refreshes must not inherit an automatic request that may
// still be served from the GitHub CLI cache. Wait, then issue fresh.
await inflightRequest.promise.catch(() => {})
const latestInflightRequest = inflightChecksRequests.get(inflightKey)
if (latestInflightRequest?.noCache) {
return latestInflightRequest.promise
}
}
const request = (async () => {
@ -2453,7 +2472,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: requestNoCache
},
{ timeoutMs: 30_000 }
)
@ -2463,7 +2482,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: options?.force
noCache: requestNoCache
})) as PRCheckDetail[])
set((s) => {
const nextState: Partial<AppState> = {
@ -2505,7 +2524,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})()
inflightChecksRequests.set(inflightKey, request)
inflightChecksRequests.set(inflightKey, { promise: request, noCache: requestNoCache })
return request
},
@ -2924,6 +2943,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const checksCacheKeys = [
...(alias.repoId
? [
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
@ -2933,6 +2963,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
)
]
: []),
...(pr.headSha
? [
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
prChecksCacheSuffix(pr.number, pr.prRepo, pr.headSha),
s.settings,
alias.connectionId
)
]
: []),
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
@ -2944,14 +2985,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
]
const checksEntry = checksCacheKeys
.map((key) => s.checksCache[key])
.find((entry) => entry?.data)
if (
checksEntry?.data &&
checksEntry.headSha &&
pr.headSha &&
checksEntry.headSha === pr.headSha &&
event.outcome.fetchedAt - checksEntry.fetchedAt < CHECKS_CACHE_TTL
) {
.find(
(entry) =>
entry?.data &&
entry.headSha &&
pr.headSha &&
entry.headSha === pr.headSha &&
event.outcome.fetchedAt - entry.fetchedAt < checksCacheTtl(entry)
)
if (checksEntry?.data) {
return { ...pr, checksStatus: deriveCheckStatusFromChecks(checksEntry.data) }
}
return pr