Keep PR checks status in sync

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-24 21:03:03 -07:00
co-authored by Orca
parent bad9afe090
commit a68836bcc5
12 changed files with 623 additions and 21 deletions
@@ -14,6 +14,7 @@ import {
} from 'lucide-react'
import { useAppStore, type AppState } from '@/store'
import {
getGitHubPRRefreshStateExpiryAt,
mergePRCommentIntoList,
prChecksCacheSuffix,
prCommentsCacheSuffix
@@ -94,6 +95,7 @@ import {
getChecksPanelEmptyStateCopy,
shouldShowChecksPanelPublishBranchAction
} from './checks-panel-empty-state'
import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review'
import {
cancelRuntimeGeneratePullRequestFields,
generateRuntimePullRequestFields,
@@ -372,6 +374,7 @@ export default function ChecksPanel(): React.JSX.Element {
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
const expireGitHubPRRefreshState = useAppStore((s) => s.expireGitHubPRRefreshState)
const getHostedReviewCreationEligibility = useAppStore(
(s) => s.getHostedReviewCreationEligibility
)
@@ -559,6 +562,7 @@ export default function ChecksPanel(): React.JSX.Element {
// Done during render (not useEffect) so the reset takes effect on the same
// paint as the context change; useEffect would leave one stale render.
const [prevPanelContextKey, setPrevPanelContextKey] = useState(panelContextKey)
const [prRefreshStateNow, setPrRefreshStateNow] = useState(() => Date.now())
if (panelContextKey !== prevPanelContextKey) {
setPrevPanelContextKey(panelContextKey)
setEditingTitle(false)
@@ -572,6 +576,7 @@ export default function ChecksPanel(): React.JSX.Element {
setIsRefreshing(false)
setEmptyRefreshing(false)
setConflictDetailsRefreshing(false)
setPrRefreshStateNow(Date.now())
createPrInFlightRef.current = null
setIsCreatingPr(false)
setCreatePrError(null)
@@ -619,10 +624,16 @@ export default function ChecksPanel(): React.JSX.Element {
refreshContextKeyRef.current = refreshContextKey
refreshRequestKeyRef.current = null
}
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
const prCacheEntry = prCacheKey ? prCache[prCacheKey] : undefined
const pr: PRInfo | null = prCacheEntry?.data ?? null
const hostedReview = useAppStore((s) =>
hostedReviewCacheKey ? (s.hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null
)
const hasAmbiguousGitHubHostedReview = hasAmbiguousGitHubHostedReviewForChecksPanel({
hostedReview,
prCacheEntry,
prCacheKey
})
// Fetch PR data when the active worktree/branch changes.
// Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR
// number from metadata or the visible cache whenever we have one.
@@ -640,10 +651,36 @@ export default function ChecksPanel(): React.JSX.Element {
const isGitLabReviewContext = Boolean(activeGitLabReview || linkedGitLabMR !== null)
const activeConflictReview = activeReview?.mergeable === 'CONFLICTING' ? activeReview : null
const prRefreshState = useAppStore((s) =>
prCacheKey ? s.prRefreshStates[prCacheKey] : undefined
prCacheKey ? s.getEffectiveGitHubPRRefreshState(prCacheKey, prRefreshStateNow) : undefined
)
const prNumber = pr?.number ?? null
useEffect(() => {
const expiryAt = getGitHubPRRefreshStateExpiryAt(prRefreshState)
if (!prCacheKey || expiryAt === null) {
return
}
const timeout = window.setTimeout(
() => {
setPrRefreshStateNow(Date.now())
const storeState = useAppStore.getState()
const rawState = storeState.prRefreshStates[prCacheKey]
if (!rawState) {
return
}
// Why: time alone does not publish Zustand updates; this timeout clears
// abandoned active refresh UI without treating expiry as no-PR evidence.
storeState.expireGitHubPRRefreshState(prCacheKey, {
sequence: storeState.prRefreshSequences[prCacheKey] ?? 0,
status: rawState.status,
updatedAt: rawState.updatedAt
})
},
Math.max(0, expiryAt - Date.now() + 1)
)
return () => window.clearTimeout(timeout)
}, [prCacheKey, prRefreshState])
// Why: select only timestamps (not whole cache records) so the entry-refresh
// effect doesn't re-run on every cache mutation. See
// docs/refresh-on-checks-tab.md.
@@ -1807,13 +1844,29 @@ export default function ChecksPanel(): React.JSX.Element {
}
return
}
const refreshedPR = await fetchPRForBranch(repo.path, branch, {
force: true,
repoId: repo.id,
worktreeId: activeWorktreeId ?? undefined,
linkedPRNumber: linkedPR,
fallbackPRNumber: fallbackGitHubPRNumber
})
const refreshStoreState = useAppStore.getState()
const rawPRRefreshState = refreshStoreState.prRefreshStates[prCacheKey]
const startedPRRefreshToken = rawPRRefreshState
? {
sequence: refreshStoreState.prRefreshSequences[prCacheKey] ?? 0,
status: rawPRRefreshState.status,
updatedAt: rawPRRefreshState.updatedAt
}
: null
let refreshedPR: PRInfo | null = null
try {
refreshedPR = await fetchPRForBranch(repo.path, branch, {
force: true,
repoId: repo.id,
worktreeId: activeWorktreeId ?? undefined,
linkedPRNumber: linkedPR,
fallbackPRNumber: fallbackGitHubPRNumber
})
} finally {
if (startedPRRefreshToken) {
expireGitHubPRRefreshState(prCacheKey, startedPRRefreshToken)
}
}
if (!isCurrentRequest()) {
return
}
@@ -1941,6 +1994,7 @@ export default function ChecksPanel(): React.JSX.Element {
fetchPRChecks,
fetchPRComments,
fetchHostedReviewForBranch,
expireGitHubPRRefreshState,
isCurrentAsyncResult
])
@@ -3297,7 +3351,8 @@ export default function ChecksPanel(): React.JSX.Element {
hasUpstream: publishActionRemoteStatus?.hasUpstream,
hasCurrentBranch: Boolean(branch),
reviewLabel: emptyReviewLabel,
reviewShortLabel: emptyReviewShortLabel
reviewShortLabel: emptyReviewShortLabel,
hasAmbiguousGitHubHostedReview
})
return (
<div className="px-4 py-6">
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review'
describe('hasAmbiguousGitHubHostedReviewForChecksPanel', () => {
it('treats missing PR cache as ambiguous when a GitHub hosted review exists', () => {
expect(
hasAmbiguousGitHubHostedReviewForChecksPanel({
hostedReview: { provider: 'github' },
prCacheEntry: undefined,
prCacheKey: 'repo::feature'
})
).toBe(true)
})
it('treats cached no-PR data as ambiguous when a GitHub hosted review exists', () => {
expect(
hasAmbiguousGitHubHostedReviewForChecksPanel({
hostedReview: { provider: 'github' },
prCacheEntry: { data: null },
prCacheKey: 'repo::feature'
})
).toBe(true)
})
it('does not treat existing PR cache data as ambiguous', () => {
expect(
hasAmbiguousGitHubHostedReviewForChecksPanel({
hostedReview: { provider: 'github' },
prCacheEntry: { data: { number: 12 } },
prCacheKey: 'repo::feature'
})
).toBe(false)
})
it('does not treat non-GitHub hosted reviews as ambiguous', () => {
expect(
hasAmbiguousGitHubHostedReviewForChecksPanel({
hostedReview: { provider: 'gitlab' },
prCacheEntry: { data: null },
prCacheKey: 'repo::feature'
})
).toBe(false)
})
})
@@ -0,0 +1,19 @@
type HostedReviewProviderLike = {
provider?: string | null
}
type PRCacheEntryLike = {
data?: unknown
}
export function hasAmbiguousGitHubHostedReviewForChecksPanel(input: {
hostedReview: HostedReviewProviderLike | null | undefined
prCacheEntry: PRCacheEntryLike | null | undefined
prCacheKey: string
}): boolean {
return (
input.hostedReview?.provider === 'github' &&
input.prCacheKey !== '' &&
input.prCacheEntry?.data == null
)
}
@@ -104,6 +104,81 @@ describe('getChecksPanelEmptyStateCopy', () => {
description: 'Create a merge request to start checks and review.'
})
})
it('uses neutral copy when GitHub hosted-review data exists without PR cache', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: undefined,
hostedReviewBlockedReason: null,
hasUpstream: true,
hasAmbiguousGitHubHostedReview: true
})
).toEqual({
title: 'Pull request status unavailable',
description: 'Refresh GitHub status for this branch to load checks and review.'
})
})
it('does not show no-PR copy for paused ambiguous GitHub hosted-review data', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'paused',
hostedReviewBlockedReason: null,
hasUpstream: true,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe('Pull request status unavailable')
})
it('keeps refresh errors for ambiguous GitHub hosted-review data', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: 'error',
hostedReviewBlockedReason: null,
hasUpstream: true,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe('Could not refresh pull request')
})
it('uses neutral copy for ambiguous GitHub hosted-review data before unpublished branch copy', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: undefined,
hostedReviewBlockedReason: 'no_upstream',
hasUpstream: false,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe('Pull request status unavailable')
})
it('uses neutral copy for ambiguous GitHub hosted-review data before remote fallback publish copy', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: undefined,
hostedReviewBlockedReason: undefined,
hasUpstream: false,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe('Pull request status unavailable')
})
it('uses neutral copy for ambiguous GitHub hosted-review data before unpushed commit copy', () => {
expect(
getChecksPanelEmptyStateCopy({
operationLabel: null,
prRefreshStatus: undefined,
hostedReviewBlockedReason: 'needs_push',
hasUpstream: true,
hasAmbiguousGitHubHostedReview: true
}).title
).toBe('Pull request status unavailable')
})
})
describe('shouldShowChecksPanelPublishBranchAction', () => {
@@ -11,6 +11,7 @@ type ChecksPanelEmptyStateInput = {
hasCurrentBranch?: boolean
reviewLabel?: 'pull request' | 'merge request'
reviewShortLabel?: 'PR' | 'MR'
hasAmbiguousGitHubHostedReview?: boolean
}
type ChecksPanelEmptyStateCopy = {
@@ -39,6 +40,24 @@ export function getChecksPanelEmptyStateCopy(
}
const blockedReason = input.hostedReviewBlockedReason
if (
input.hasAmbiguousGitHubHostedReview === true &&
(input.prRefreshStatus === 'paused' ||
input.prRefreshStatus === 'skipped' ||
input.prRefreshStatus === undefined)
) {
return {
title: translate(
'auto.components.right.sidebar.checks.panel.empty.state.3322603418',
'Pull request status unavailable'
),
description: translate(
'auto.components.right.sidebar.checks.panel.empty.state.b597440265',
'Refresh GitHub status for this branch to load checks and review.'
)
}
}
if (
shouldShowChecksPanelPublishBranchAction({
hostedReviewBlockedReason: blockedReason,
+3 -1
View File
@@ -8911,6 +8911,7 @@
},
"empty": {
"state": {
"3322603418": "Pull request status unavailable",
"5b0cfae9a5": "Create a {{value0}} to start checks and review.",
"13e1c7d5ed": "No {{value0}} found",
"d372072df1": "GitHub refresh is paused by the current rate-limit budget",
@@ -8925,7 +8926,8 @@
"f8543140cc": "Publish this branch before creating a {{value0}}.",
"41252bc53f": "Branch not published",
"05e4aec17b": "{{value0}} checks will be available after the operation completes",
"d77c513c1e": "{{value0}} in progress"
"d77c513c1e": "{{value0}} in progress",
"b597440265": "Refresh GitHub status for this branch to load checks and review."
}
}
}
+3 -1
View File
@@ -8911,6 +8911,7 @@
},
"empty": {
"state": {
"3322603418": "Pull request status unavailable",
"5b0cfae9a5": "Cree un {{value0}} para iniciar comprobaciones y revisiones.",
"13e1c7d5ed": "No se encontró {{value0}}",
"d372072df1": "La actualización de GitHub está pausada por el presupuesto de límite de velocidad actual",
@@ -8925,7 +8926,8 @@
"f8543140cc": "Publique esta rama antes de crear un {{value0}}.",
"41252bc53f": "Sucursal no publicada",
"05e4aec17b": "{{value0}} cheques estarán disponibles una vez completada la operación.",
"d77c513c1e": "{{value0}} en progreso"
"d77c513c1e": "{{value0}} en progreso",
"b597440265": "Refresh GitHub status for this branch to load checks and review."
}
}
}
+3 -1
View File
@@ -8911,6 +8911,7 @@
},
"empty": {
"state": {
"3322603418": "Pull request status unavailable",
"5b0cfae9a5": "{{value0}} を作成してチェックとレビューを開始します。",
"13e1c7d5ed": "{{value0}} が見つかりませんでした",
"d372072df1": "現在のレート制限予算により GitHub の更新が一時停止されています",
@@ -8925,7 +8926,8 @@
"f8543140cc": "{{value0}} を作成する前に、このブランチを公開してください。",
"41252bc53f": "ブランチは公開されていません",
"05e4aec17b": "{{value0}} チェックは操作の完了後に利用可能になります",
"d77c513c1e": "{{value0}} が進行中です"
"d77c513c1e": "{{value0}} が進行中です",
"b597440265": "Refresh GitHub status for this branch to load checks and review."
}
}
}
+3 -1
View File
@@ -8911,6 +8911,7 @@
},
"empty": {
"state": {
"3322603418": "Pull request status unavailable",
"5b0cfae9a5": "체크와 리뷰를 시작하려면 {{value0}}을(를) 만드세요.",
"13e1c7d5ed": "{{value0}}을(를) 찾을 수 없습니다.",
"d372072df1": "현재 속도 제한 예산으로 인해 GitHub 새로 고침이 일시 중지되었습니다.",
@@ -8925,7 +8926,8 @@
"f8543140cc": "{{value0}}을 만들기 전에 이 브랜치를 게시하세요.",
"41252bc53f": "게시되지 않은 브랜치",
"05e4aec17b": "{{value0}} 검사는 작업이 완료된 후에 사용할 수 있습니다.",
"d77c513c1e": "{{value0}} 진행 중"
"d77c513c1e": "{{value0}} 진행 중",
"b597440265": "Refresh GitHub status for this branch to load checks and review."
}
}
}
+3 -1
View File
@@ -8911,6 +8911,7 @@
},
"empty": {
"state": {
"3322603418": "Pull request status unavailable",
"5b0cfae9a5": "创建 {{value0}} 以开始检查和评审。",
"13e1c7d5ed": "未找到 {{value0}}",
"d372072df1": "GitHub 刷新因当前速率限制预算而暂停",
@@ -8925,7 +8926,8 @@
"f8543140cc": "在创建 {{value0}} 之前发布此分支。",
"41252bc53f": "分支未发布",
"05e4aec17b": "{{value0}} 检查将在操作完成后可用",
"d77c513c1e": "{{value0}} 正在进行中"
"d77c513c1e": "{{value0}} 正在进行中",
"b597440265": "Refresh GitHub status for this branch to load checks and review."
}
}
}
@@ -25,14 +25,15 @@ const MAX_ENTRIES = 2000
// prRefreshStates entry shapes (the module's PRRefreshState type is not exported).
type SeedState = {
status: 'in-flight' | 'error'
status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error'
reason: GitHubPRRefreshReason
updatedAt: number
pausedUntil?: number
message?: string
}
function inFlightState(): SeedState {
return { status: 'in-flight', reason: 'visible', updatedAt: 0 }
return { status: 'in-flight', reason: 'visible', updatedAt: Date.now() }
}
function errorState(): SeedState {
@@ -150,4 +151,263 @@ describe('prRefreshStates stays bounded (leak regression)', () => {
expect(states['seed-1']).toBeUndefined()
expect(states['newcomer']).toBeDefined()
})
it('hides expired active states through the effective reader without mutating during read', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
store.setState({
prRefreshStates: {
stale: { status: 'in-flight', reason: 'visible', updatedAt: 1_000_000 - 121_000 },
fresh: { status: 'in-flight', reason: 'visible', updatedAt: 1_000_000 - 10_000 }
}
})
expect(store.getState().getEffectiveGitHubPRRefreshState('stale', 1_000_000)).toBeUndefined()
expect(store.getState().getEffectiveGitHubPRRefreshState('fresh', 1_000_000)).toMatchObject({
status: 'in-flight'
})
expect(store.getState().prRefreshStates.stale).toBeDefined()
} finally {
vi.useRealTimers()
}
})
it('expires paused states after pausedUntil grace and rejects missing pause timestamps', () => {
const store = createTestStore()
store.setState({
prRefreshStates: {
currentPause: {
status: 'paused',
reason: 'visible',
updatedAt: 1_000,
pausedUntil: 2_000
},
missingPause: { status: 'paused', reason: 'visible', updatedAt: 1_000 }
}
})
expect(store.getState().getEffectiveGitHubPRRefreshState('currentPause', 2_004)).toMatchObject({
status: 'paused'
})
expect(store.getState().getEffectiveGitHubPRRefreshState('currentPause', 7_001)).toBeUndefined()
expect(store.getState().getEffectiveGitHubPRRefreshState('missingPause', 1_500)).toBeUndefined()
})
it('prunes expired active states on refreshAll without writing a PR miss', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
store.setState({
prRefreshStates: {
stale: { status: 'queued', reason: 'visible', updatedAt: 1_000_000 - 121_000 },
settled: { status: 'error', reason: 'visible', updatedAt: 1, message: 'boom' }
},
prCache: {
stale: { data: null, fetchedAt: 1 }
},
repos: [],
worktreesByRepo: {}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
expect(store.getState().prRefreshStates.stale).toBeUndefined()
expect(store.getState().prRefreshStates.settled).toBeDefined()
expect(store.getState().prCache.stale).toEqual({ data: null, fetchedAt: 1 })
} finally {
vi.useRealTimers()
}
})
it('manual expiry does not remove a newer active state for the same cache key', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const token = {
sequence: 1,
status: 'in-flight' as const,
updatedAt: 1_000_000 - 121_000
}
store.setState({
prRefreshSequences: { branch: 2 },
prRefreshStates: {
branch: { status: 'in-flight', reason: 'manual', updatedAt: 1_000_000 }
}
})
store.getState().expireGitHubPRRefreshState('branch', token, 1_000_000)
expect(store.getState().prRefreshStates.branch).toMatchObject({
status: 'in-flight',
updatedAt: 1_000_000
})
} finally {
vi.useRealTimers()
}
})
it('manual expiry does not notify subscribers when the token no longer matches', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const subscriber = vi.fn()
const unsubscribe = store.subscribe(subscriber)
store.setState({
prRefreshSequences: { branch: 2 },
prRefreshStates: {
branch: { status: 'in-flight', reason: 'manual', updatedAt: 1_000_000 }
}
})
subscriber.mockClear()
store.getState().expireGitHubPRRefreshState(
'branch',
{
sequence: 1,
status: 'in-flight',
updatedAt: 1_000_000 - 121_000
},
1_000_000
)
expect(subscriber).not.toHaveBeenCalled()
unsubscribe()
} finally {
vi.useRealTimers()
}
})
it('manual expiry does not notify subscribers for an active state that is still fresh', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const subscriber = vi.fn()
store.setState({
prRefreshSequences: { branch: 1 },
prRefreshStates: {
branch: { status: 'in-flight', reason: 'manual', updatedAt: 1_000_000 }
}
})
const unsubscribe = store.subscribe(subscriber)
store.getState().expireGitHubPRRefreshState(
'branch',
{
sequence: 1,
status: 'in-flight',
updatedAt: 1_000_000
},
1_000_000
)
expect(subscriber).not.toHaveBeenCalled()
unsubscribe()
} finally {
vi.useRealTimers()
}
})
it('manual expiry notifies subscribers when it removes the captured stale active state', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const subscriber = vi.fn()
store.setState({
prRefreshSequences: { branch: 1 },
prRefreshStates: {
branch: { status: 'in-flight', reason: 'manual', updatedAt: 1_000_000 - 121_000 }
}
})
const unsubscribe = store.subscribe(subscriber)
store.getState().expireGitHubPRRefreshState(
'branch',
{
sequence: 1,
status: 'in-flight',
updatedAt: 1_000_000 - 121_000
},
1_000_000
)
expect(subscriber).toHaveBeenCalledTimes(1)
expect(store.getState().prRefreshStates.branch).toBeUndefined()
unsubscribe()
} finally {
vi.useRealTimers()
}
})
it('expires the stale active state captured by a rejected forced PR refresh', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/rejected-refresh'
const cacheKey = `${repoPath}::${branch}`
mockApi.gh.refreshPRNow.mockRejectedValueOnce(new Error('stale repo path'))
store.setState({
prRefreshSequences: { [cacheKey]: 1 },
prRefreshStates: {
[cacheKey]: { status: 'in-flight', reason: 'manual', updatedAt: 1_000_000 - 121_000 }
}
})
await expect(
store.getState().fetchPRForBranch(repoPath, branch, { force: true })
).resolves.toBeNull()
expect(store.getState().prRefreshStates[cacheKey]).toBeUndefined()
expect(store.getState().prCache[cacheKey]).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('accepts a late terminal outcome after the active status timed out', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
try {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/late-outcome'
const cacheKey = `${repoPath}::${branch}`
store.getState().applyGitHubPRRefreshEvent(statusEvent(cacheKey, 1))
vi.setSystemTime(1_000_000 + 121_000)
expect(store.getState().getEffectiveGitHubPRRefreshState(cacheKey)).toBeUndefined()
store.getState().applyGitHubPRRefreshEvent({
sequence: 1,
reason: 'visible',
aliases: [{ cacheKey, repoPath, branch }],
outcome: {
kind: 'found',
pr: {
number: 12,
title: 'Late PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/12',
checksStatus: 'pending',
updatedAt: '2026-03-28T00:00:00Z',
mergeable: 'UNKNOWN'
},
fetchedAt: 1_000_000 + 121_000
}
})
expect(store.getState().prCache[cacheKey]?.data).toMatchObject({ title: 'Late PR' })
expect(store.getState().prRefreshStates[cacheKey]).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
})
+124 -4
View File
@@ -629,7 +629,7 @@ type RepoScopedFetchOptions = FetchOptions & {
repoId?: string
}
type PRRefreshState = {
export type PRRefreshState = {
status: 'queued' | 'in-flight' | 'paused' | 'skipped' | 'error'
reason: GitHubPRRefreshReason
updatedAt: number
@@ -637,6 +637,15 @@ type PRRefreshState = {
message?: string
}
type PRRefreshStateClearToken = {
sequence: number
status: PRRefreshState['status']
updatedAt: number
}
const PR_REFRESH_ACTIVE_STALE_MS = 120_000
const PR_REFRESH_PAUSED_GRACE_MS = 5_000
function bypassesGitHubPRRefreshFreshness(reason: GitHubPRRefreshReason): boolean {
return reason === 'manual' || reason === 'active' || reason === 'post-push'
}
@@ -1464,6 +1473,64 @@ function capPrRefreshSequences(
// realistic usage never reaches. Evicted entries self-heal on the next refresh event.
const MAX_PR_REFRESH_STATE_ENTRIES = 2000
const SETTLED_PR_REFRESH_STATUSES = new Set<PRRefreshState['status']>(['error', 'skipped'])
const ACTIVE_PR_REFRESH_STATUSES = new Set<PRRefreshState['status']>([
'queued',
'in-flight',
'paused'
])
function isPRRefreshStateExpired(state: PRRefreshState, now: number): boolean {
const expiryAt = getGitHubPRRefreshStateExpiryAt(state)
return expiryAt !== null && now > expiryAt
}
export function getGitHubPRRefreshStateExpiryAt(state: PRRefreshState | undefined): number | null {
if (!state) {
return null
}
if (state.status === 'queued' || state.status === 'in-flight') {
return Number.isFinite(state.updatedAt) ? state.updatedAt + PR_REFRESH_ACTIVE_STALE_MS : 0
}
if (state.status === 'paused') {
return Number.isFinite(state.pausedUntil)
? (state.pausedUntil ?? 0) + PR_REFRESH_PAUSED_GRACE_MS
: 0
}
return null
}
function isExpiredActivePRRefreshState(state: PRRefreshState, now: number): boolean {
return ACTIVE_PR_REFRESH_STATUSES.has(state.status) && isPRRefreshStateExpired(state, now)
}
export function getEffectiveGitHubPRRefreshState(
states: Record<string, PRRefreshState>,
cacheKey: string,
now = Date.now()
): PRRefreshState | undefined {
const state = states[cacheKey]
if (!state || isExpiredActivePRRefreshState(state, now)) {
return undefined
}
return state
}
function pruneExpiredPRRefreshStates(
states: Record<string, PRRefreshState>,
now = Date.now()
): Record<string, PRRefreshState> {
let next: Record<string, PRRefreshState> | null = null
for (const [cacheKey, state] of Object.entries(states)) {
if (!isExpiredActivePRRefreshState(state, now)) {
continue
}
if (!next) {
next = { ...states }
}
delete next[cacheKey]
}
return next ?? states
}
function capPrRefreshStates(
states: Record<string, PRRefreshState>,
@@ -1614,6 +1681,12 @@ export type GitHubSlice = {
reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void
bumpGitHubPRVisibleRefreshGeneration: () => void
applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void
getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined
expireGitHubPRRefreshState: (
cacheKey: string,
token: PRRefreshStateClearToken,
now?: number
) => void
/**
* Why: returns cached work items immediately (null if none) and fires a
* background refresh when stale. Callers can render the cached list while
@@ -1806,6 +1879,40 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
workItemsInvalidationNonce: 0,
projectViewCache: {},
getEffectiveGitHubPRRefreshState: (cacheKey, now) =>
getEffectiveGitHubPRRefreshState(get().prRefreshStates, cacheKey, now),
expireGitHubPRRefreshState: (cacheKey, token, now = Date.now()) => {
const currentState = get()
const currentRefreshState = currentState.prRefreshStates[cacheKey]
if (
!currentRefreshState ||
!ACTIVE_PR_REFRESH_STATUSES.has(currentRefreshState.status) ||
!isExpiredActivePRRefreshState(currentRefreshState, now) ||
(currentState.prRefreshSequences[cacheKey] ?? 0) !== token.sequence ||
currentRefreshState.status !== token.status ||
currentRefreshState.updatedAt !== token.updatedAt
) {
return
}
set((s) => {
const state = s.prRefreshStates[cacheKey]
if (
!state ||
!ACTIVE_PR_REFRESH_STATUSES.has(state.status) ||
!isExpiredActivePRRefreshState(state, now) ||
(s.prRefreshSequences[cacheKey] ?? 0) !== token.sequence ||
state.status !== token.status ||
state.updatedAt !== token.updatedAt
) {
return s
}
const nextStates = { ...s.prRefreshStates }
delete nextStates[cacheKey]
return { prRefreshStates: nextStates }
})
},
fetchProjectViewTable: async (args, options) => {
const target = getActiveRuntimeTarget(get().settings)
const sourceScope = projectViewSourceScope(get().settings)
@@ -2709,6 +2816,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1
const requestStartedAt = Date.now()
const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey]
const requestStartedPRRefreshState = get().prRefreshStates[cacheKey]
const requestStartedPRRefreshToken = requestStartedPRRefreshState
? {
sequence: get().prRefreshSequences[cacheKey] ?? 0,
status: requestStartedPRRefreshState.status,
updatedAt: requestStartedPRRefreshState.updatedAt
}
: null
prRequestGenerations.set(cacheKey, generation)
const request = (async () => {
@@ -2828,6 +2943,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prRequestGenerations.delete(cacheKey)
}
}
if (requestStartedPRRefreshToken) {
get().expireGitHubPRRefreshState(cacheKey, requestStartedPRRefreshToken)
}
}
})()
@@ -3511,10 +3629,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
applyGitHubPRRefreshEvent: (event) => {
set((s) => {
const nextSequences = { ...s.prRefreshSequences }
const nextStates = { ...s.prRefreshStates }
const prunedStates = pruneExpiredPRRefreshStates(s.prRefreshStates)
const nextStates = { ...prunedStates }
let nextPRCache = s.prCache
let nextHostedReviewCache = s.hostedReviewCache ?? {}
let changed = false
let changed = prunedStates !== s.prRefreshStates
for (const alias of event.aliases) {
const aliasExecutionHostId = getRefreshAliasExecutionHostId(alias)
@@ -3706,7 +3825,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
issueCache: evictStaleEntries(s.issueCache),
checksCache: evictStaleEntries(s.checksCache),
workItemsCache: evictStaleEntries(s.workItemsCache),
projectViewCache: evictStaleEntries(s.projectViewCache)
projectViewCache: evictStaleEntries(s.projectViewCache),
prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates)
}))
// Why: prRequestGenerations tracks only live inflight fetches and is