mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
@@ -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">
|
||||
|
||||
+44
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user