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
12 changed files with 583 additions and 111 deletions
@@ -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
@@ -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 : (
@@ -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)
})
})
@@ -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
}
}
+1 -1
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",
+1 -1
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",
+1 -1
View File
@@ -8070,7 +8070,7 @@
"74c6885b8a": "その他のコメント操作",
"cbcc4ab3db": "最初の 100 件のチェックを表示しています",
"0dca6bfab5": "オープンチェックの詳細",
"991f50c7e4": "チェックが設定されていません",
"991f50c7e4": "まだチェックは報告されていません",
"9ad98f2a17": "保留中",
"5e52f4ef7f": "失敗した",
"02ca4f9074": "合格",
+1 -1
View File
@@ -8070,7 +8070,7 @@
"74c6885b8a": "추가 댓글 작업",
"cbcc4ab3db": "처음 100개 검사 표시",
"0dca6bfab5": "검사 세부 정보 열기",
"991f50c7e4": "구성된 검사가 없습니다.",
"991f50c7e4": "아직 보고된 체크가 없습니다.",
"9ad98f2a17": "보류 중",
"5e52f4ef7f": "실패",
"02ca4f9074": "통과",
+1 -1
View File
@@ -8070,7 +8070,7 @@
"74c6885b8a": "更多评论动作",
"cbcc4ab3db": "显示前 100 项检查",
"0dca6bfab5": "打开检查详细信息",
"991f50c7e4": "未配置检查",
"991f50c7e4": "尚未报告任何检查",
"9ad98f2a17": "待办的",
"5e52f4ef7f": "失败",
"02ca4f9074": "通过",
@@ -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
})
})
})
@@ -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', () => {
+57 -15
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