Fix stale GitHub status cache updates (#2483)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-21 02:38:32 -04:00 committed by GitHub
parent 7099e40239
commit 8cf39f3a2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2438 additions and 200 deletions

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: coordinator tests cover queueing, coalescing,
request timestamps, and follow-up scheduling against shared module state. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types'
@ -289,6 +291,68 @@ describe('pr-refresh-coordinator', () => {
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('includes request start time on manual refresh events', async () => {
const { refreshPRNow } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now() + 5
})
await refreshPRNow(makeCandidate())
const events = sendMock.mock.calls.map(([, event]) => event)
const inFlight = events.find((event) => event.status === 'in-flight')
const outcome = events.find((event) => event.outcome)
expect(inFlight?.requestStartedAt).toBe(1_000)
expect(outcome?.requestStartedAt).toBe(1_000)
expect(outcome?.sequence).toBe(inFlight?.sequence)
})
it('does not coalesce local and SSH refreshes for the same branch', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ number: 12 }),
fetchedAt: Date.now()
})
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ number: 44 }),
fetchedAt: Date.now()
})
enqueuePRRefresh(makeCandidate({ cacheKey: 'local::repo-1::feature/test' }), 'active', 80, 1)
enqueuePRRefresh(
makeCandidate({
cacheKey: 'ssh:ssh-1::repo-1::feature/test',
connectionId: 'ssh-1'
}),
'active',
80,
1
)
await vi.runOnlyPendingTimersAsync()
await vi.runOnlyPendingTimersAsync()
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(
1,
'/repo',
'feature/test',
null,
null
)
expect(getPRForBranchOutcomeMock).toHaveBeenNthCalledWith(
2,
'/repo',
'feature/test',
null,
'ssh-1'
)
})
it('preserves coalesced aliases across visible follow-up refreshes', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock

View File

@ -65,10 +65,11 @@ function broadcast(event: Omit<GitHubPRRefreshEvent, 'sequence'>, sequenceOverri
}
function refreshKey(candidate: GitHubPRRefreshCandidate): string {
const connectionScope = candidate.connectionId ?? 'local'
if (typeof candidate.linkedPRNumber === 'number') {
return `${candidate.repoPath}::pr::${candidate.linkedPRNumber}`
return `${connectionScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}`
}
return `${candidate.repoPath}::branch::${candidate.branch}`
return `${connectionScope}::${candidate.repoPath}::branch::${candidate.branch}`
}
function isVisibleKey(key: string): boolean {
@ -381,7 +382,11 @@ async function drainQueue(): Promise<void> {
continue
}
const requestSequence = nextSequence()
broadcast({ aliases, reason: next.reason, status: 'in-flight' }, requestSequence)
const requestStartedAt = Date.now()
broadcast(
{ aliases, reason: next.reason, status: 'in-flight', requestStartedAt },
requestSequence
)
if (isBackground(next.reason)) {
const rateLimit = await getRateLimit()
@ -430,7 +435,7 @@ async function drainQueue(): Promise<void> {
next.candidate.connectionId ?? null
)
outcomeObserver?.(next.candidate, outcome)
broadcast({ aliases, reason: next.reason, outcome }, requestSequence)
broadcast({ aliases, reason: next.reason, outcome, requestStartedAt }, requestSequence)
scheduleVisibleFollowUp(
next.key,
next.candidate,
@ -456,7 +461,8 @@ export function enqueuePRRefresh(
repoId: candidate.repoId,
repoPath: candidate.repoPath,
branch: candidate.branch,
worktreeId: candidate.worktreeId
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId ?? null
}
const key = refreshKey(candidate)
const skippedReason = validateCandidate(candidate)
@ -539,7 +545,8 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
repoId: candidate.repoId,
repoPath: candidate.repoPath,
branch: candidate.branch,
worktreeId: candidate.worktreeId
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId ?? null
}
const key = refreshKey(candidate)
const existing = queue.get(key)
@ -562,7 +569,8 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
queue.delete(key)
const requestSequence = nextSequence()
broadcast({ aliases, reason: 'manual', status: 'in-flight' }, requestSequence)
const requestStartedAt = Date.now()
broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence)
const outcome = await getPRForBranchOutcome(
candidate.repoPath,
candidate.branch,
@ -570,7 +578,7 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise
candidate.connectionId ?? null
)
outcomeObserver?.(candidate, outcome)
broadcast({ aliases, reason: 'manual', outcome }, requestSequence)
broadcast({ aliases, reason: 'manual', outcome, requestStartedAt }, requestSequence)
scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases)
return outcome
}

View File

@ -1,5 +1,6 @@
import type { HostedReviewInfo } from '../../shared/hosted-review'
import type { MRInfo, PRInfo } from '../../shared/types'
import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github'
import {
getAzureDevOpsPullRequest,
getAzureDevOpsPullRequestForBranch,
@ -22,18 +23,7 @@ import { getPRForBranch, getRepoSlug } from '../github/client'
import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client'
function mapGitHubReview(pr: PRInfo): HostedReviewInfo {
return {
provider: 'github',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
status: pr.checksStatus,
updatedAt: pr.updatedAt,
mergeable: pr.mergeable,
...(pr.headSha ? { headSha: pr.headSha } : {}),
...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {})
}
return hostedReviewInfoFromGitHubPRInfo(pr)
}
function mapGitLabReviewState(state: MRInfo['state']): HostedReviewInfo['state'] {

View File

@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-react'
import { useAppStore } from '@/store'
import { prChecksCacheSuffix, prCommentsCacheSuffix } from '@/store/slices/github'
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/github-cache-key'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -42,6 +43,7 @@ export default function ChecksPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const repo = useRepoById(activeWorktree?.repoId ?? null)
const settings = useAppStore((s) => s.settings)
const prCache = useAppStore((s) => s.prCache)
const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch)
const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch)
@ -118,8 +120,11 @@ export default function ChecksPanel(): React.JSX.Element {
// Find active worktree and repo
const branch = activeWorktree ? activeWorktree.branch.replace(/^refs\/heads\//, '') : ''
const isFolder = repo ? isFolderRepo(repo) : false
const prCacheKey = repo && branch ? `${repo.id}::${branch}` : ''
const refreshContextKey = `${activeWorktreeId ?? ''}::${repo?.id ?? ''}::${branch}`
const prCacheKey =
repo && branch
? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId)
: ''
const refreshContextKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}`
if (refreshContextKey !== refreshContextKeyRef.current) {
refreshContextKeyRef.current = refreshContextKey
refreshRequestKeyRef.current = null
@ -144,9 +149,25 @@ export default function ChecksPanel(): React.JSX.Element {
prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined
)
const checksCacheKey =
repo && prNumber ? `${repo.id}::${prChecksCacheSuffix(prNumber, pr?.prRepo)}` : ''
repo && prNumber
? getGitHubRepoCacheKey(
repo.path,
repo.id,
prChecksCacheSuffix(prNumber, pr?.prRepo),
settings,
repo.connectionId
)
: ''
const commentsCacheKey =
repo && prNumber ? `${repo.id}::${prCommentsCacheSuffix(prNumber, pr?.prRepo)}` : ''
repo && prNumber
? getGitHubRepoCacheKey(
repo.path,
repo.id,
prCommentsCacheSuffix(prNumber, pr?.prRepo),
settings,
repo.connectionId
)
: ''
const checksFetchedAt = useAppStore((s) =>
checksCacheKey ? s.checksCache[checksCacheKey]?.fetchedAt : undefined
)
@ -161,7 +182,9 @@ export default function ChecksPanel(): React.JSX.Element {
const linkedGitLabMR = activeWorktree?.linkedGitLabMR ?? null
const activeWorktreePath = activeWorktree?.path ?? null
const stateRequestKey =
repo && branch ? checksPanelAsyncResultKey(repo.id, branch, prNumber, pr?.prRepo) : ''
repo && branch
? checksPanelAsyncResultKey(prCacheKey, branch, prNumber, pr?.prRepo, pr?.headSha)
: ''
asyncResultKeyRef.current = stateRequestKey
const isCurrentAsyncResult = useCallback(
@ -235,7 +258,7 @@ export default function ChecksPanel(): React.JSX.Element {
return
}
const refreshKey = `${repo.path}::${branch}::${pr.number}`
const refreshKey = `${prCacheKey}::${branch}::${pr.number}`
if (conflictSummaryRefreshKeyRef.current === refreshKey) {
return
}
@ -258,7 +281,7 @@ export default function ChecksPanel(): React.JSX.Element {
setConflictDetailsRefreshing(false)
}
})
}, [repo, isFolder, branch, pr, activeWorktreeId, linkedPR, fetchPRForBranch])
}, [repo, isFolder, branch, pr, prCacheKey, activeWorktreeId, linkedPR, fetchPRForBranch])
// Fetch checks via cached store method
const fetchChecks = useCallback(
@ -272,7 +295,13 @@ export default function ChecksPanel(): React.JSX.Element {
}
setChecksLoading(true)
try {
const requestKey = checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, pr?.prRepo)
const requestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
targetPRNumber,
pr?.prRepo,
pr?.headSha
)
const result = await fetchPRChecks(
repo.path,
targetPRNumber,
@ -300,7 +329,7 @@ export default function ChecksPanel(): React.JSX.Element {
} catch (err) {
if (
!isCurrentAsyncResult(
checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, pr?.prRepo)
checksPanelAsyncResultKey(prCacheKey, branch, targetPRNumber, pr?.prRepo, pr?.headSha)
)
) {
return
@ -310,14 +339,23 @@ export default function ChecksPanel(): React.JSX.Element {
} finally {
if (
isCurrentAsyncResult(
checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, pr?.prRepo)
checksPanelAsyncResultKey(prCacheKey, branch, targetPRNumber, pr?.prRepo, pr?.headSha)
)
) {
setChecksLoading(false)
}
}
},
[repo, prNumber, branch, pr?.headSha, pr?.prRepo, fetchPRChecks, isCurrentAsyncResult]
[
repo,
prNumber,
branch,
pr?.headSha,
pr?.prRepo,
prCacheKey,
fetchPRChecks,
isCurrentAsyncResult
]
)
// Fetch checks on mount + poll with exponential backoff
@ -372,7 +410,13 @@ export default function ChecksPanel(): React.JSX.Element {
}
setCommentsLoading(true)
try {
const requestKey = checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, targetPRRepo)
const requestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
targetPRNumber,
targetPRRepo,
pr?.headSha
)
const result = await fetchPRComments(repo.path, targetPRNumber, {
force,
repoId: repo.id,
@ -385,7 +429,7 @@ export default function ChecksPanel(): React.JSX.Element {
} catch (err) {
if (
!isCurrentAsyncResult(
checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, targetPRRepo)
checksPanelAsyncResultKey(prCacheKey, branch, targetPRNumber, targetPRRepo, pr?.headSha)
)
) {
return
@ -395,14 +439,23 @@ export default function ChecksPanel(): React.JSX.Element {
} finally {
if (
isCurrentAsyncResult(
checksPanelAsyncResultKey(repo.id, branch, targetPRNumber, targetPRRepo)
checksPanelAsyncResultKey(prCacheKey, branch, targetPRNumber, targetPRRepo, pr?.headSha)
)
) {
setCommentsLoading(false)
}
}
},
[repo, prNumber, pr?.prRepo, fetchPRComments, branch, isCurrentAsyncResult]
[
repo,
prNumber,
pr?.headSha,
pr?.prRepo,
prCacheKey,
fetchPRComments,
branch,
isCurrentAsyncResult
]
)
useEffect(() => {
@ -431,14 +484,20 @@ export default function ChecksPanel(): React.JSX.Element {
return () => {
cancelled = true
}
}, [repo, prNumber, pr?.prRepo, isPanelVisible, fetchPRComments])
}, [repo, prNumber, pr?.prRepo, prCacheKey, isPanelVisible, fetchPRComments])
const handleRefresh = useCallback(async () => {
if (!repo || !branch) {
return
}
const initialRequestKey = checksPanelAsyncResultKey(repo.id, branch, prNumber, pr?.prRepo)
const refreshRequestKey = `${activeWorktreeId ?? ''}::${repo.id}::${branch}::${Date.now()}::${Math.random()}`
const initialRequestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
prNumber,
pr?.prRepo,
pr?.headSha
)
const refreshRequestKey = `${activeWorktreeId ?? ''}::${prCacheKey}::${branch}::${Date.now()}::${Math.random()}`
refreshRequestKeyRef.current = refreshRequestKey
const isCurrentRequest = (): boolean => refreshRequestKeyRef.current === refreshRequestKey
setIsRefreshing(true)
@ -463,10 +522,11 @@ export default function ChecksPanel(): React.JSX.Element {
}
if (refreshedPR) {
const prRequestKey = checksPanelAsyncResultKey(
repo.id,
prCacheKey,
branch,
refreshedPR.number,
refreshedPR.prRepo
refreshedPR.prRepo,
refreshedPR.headSha
)
if (!isCurrentAsyncResult(initialRequestKey) && !isCurrentRequest()) {
return
@ -554,7 +614,9 @@ export default function ChecksPanel(): React.JSX.Element {
branch,
activeWorktreeId,
prNumber,
pr?.headSha,
pr?.prRepo,
prCacheKey,
linkedPR,
linkedGitLabMR,
fetchPRForBranch,
@ -868,7 +930,13 @@ export default function ChecksPanel(): React.JSX.Element {
if (!repo || !branch) {
return
}
const initialRequestKey = checksPanelAsyncResultKey(repo.id, branch, prNumber, pr?.prRepo)
const initialRequestKey = checksPanelAsyncResultKey(
prCacheKey,
branch,
prNumber,
pr?.prRepo,
pr?.headSha
)
setRightSidebarOpen(true)
setRightSidebarTab('checks')
try {
@ -886,10 +954,11 @@ export default function ChecksPanel(): React.JSX.Element {
})
if (refreshedPR) {
const requestKey = checksPanelAsyncResultKey(
repo.id,
prCacheKey,
branch,
refreshedPR.number,
refreshedPR.prRepo
refreshedPR.prRepo,
refreshedPR.headSha
)
if (!isCurrentAsyncResult(initialRequestKey) && !isCurrentAsyncResult(requestKey)) {
return
@ -934,7 +1003,9 @@ export default function ChecksPanel(): React.JSX.Element {
fetchPRForBranch,
isCurrentAsyncResult,
linkedGitLabMR,
prCacheKey,
prNumber,
pr?.headSha,
pr?.prRepo,
repo,
setRightSidebarOpen,

View File

@ -35,6 +35,7 @@ import { useAppStore } from '@/store'
import { resolveRemoteOperationErrorMessage } from '@/store/slices/editor'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
@ -926,12 +927,27 @@ function SourceControlInner(): React.JSX.Element {
const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD'
const hostedReviewCacheKey =
activeRepo && branchName
? getHostedReviewCacheKey(activeRepo.path, branchName, settings, activeRepo.id)
? getHostedReviewCacheKey(
activeRepo.path,
branchName,
settings,
activeRepo.id,
activeRepo.connectionId
)
: null
const hostedReviewEntry = hostedReviewCacheKey
? hostedReviewCache[hostedReviewCacheKey]
: undefined
const activePrCacheKey = activeRepo && branchName ? `${activeRepo.id}::${branchName}` : null
const activePrCacheKey =
activeRepo && branchName
? getGitHubPRCacheKey(
activeRepo.path,
activeRepo.id,
branchName,
settings,
activeRepo.connectionId
)
: null
const activePrFromQueue = activePrCacheKey ? (prCache[activePrCacheKey]?.data ?? null) : null
const hostedReview: HostedReviewInfo | null = hostedReviewCacheKey
? activePrFromQueue

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getActiveChecksStatus } from './active-checks-status'
import type { AppState } from '../../store/types'
import type { PRInfo } from '../../../../shared/types'
function makePR(status: PRInfo['checksStatus']): PRInfo {
return {
number: 12,
title: 'Test PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/12',
checksStatus: status,
updatedAt: '2026-05-20T00:00:00Z',
mergeable: 'MERGEABLE'
}
}
describe('getActiveChecksStatus', () => {
it('prefers repo-id scoped status over stale path-scoped status for the active worktree', () => {
const state = {
activeWorktreeId: 'wt-1',
repos: [{ id: 'repo-1', path: '/repo' }],
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
branch: 'refs/heads/feature/test'
}
]
},
prCache: {
'repo-1::feature/test': { data: makePR('success'), fetchedAt: 2 },
'/repo::feature/test': { data: makePR('failure'), fetchedAt: 999 }
}
} as unknown as Pick<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo' | 'prCache'>
expect(getActiveChecksStatus(state)).toBe('success')
})
})

View File

@ -0,0 +1,44 @@
import type { AppState } from '../../store/types'
import { getRepoMapFromState, getWorktreeMapFromState } from '../../store/selectors'
import type { CheckStatus } from '../../../../shared/types'
import { getGitHubPRCacheKey } from '../../store/slices/github-cache-key'
type ActiveChecksStatusState = Pick<
AppState,
'activeWorktreeId' | 'worktreesByRepo' | 'repos' | 'prCache'
> &
Partial<Pick<AppState, 'settings'>>
function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
export function getActiveChecksStatus(state: ActiveChecksStatusState): CheckStatus | null {
const activeWorktree = state.activeWorktreeId
? (getWorktreeMapFromState(state).get(state.activeWorktreeId) ?? null)
: null
if (!activeWorktree) {
return null
}
const activeRepo = getRepoMapFromState(state).get(activeWorktree.repoId)
if (!activeRepo) {
return null
}
const branch = branchDisplayName(activeWorktree.branch)
if (!branch) {
return null
}
// Why: PR refreshes are written under repo-id scoped keys so repo path
// changes and legacy duplicates cannot leave the activity indicator stale.
const prCacheKey = getGitHubPRCacheKey(
activeRepo.path,
activeRepo.id,
branch,
state.settings,
activeRepo.connectionId
)
return state.prCache[prCacheKey]?.data?.checksStatus ?? null
}

View File

@ -7,13 +7,13 @@ import {
describe('checksPanelAsyncResultKey', () => {
it('builds a stable repo-scoped key', () => {
expect(checksPanelAsyncResultKey('repo-id', 'feature/test', 12)).toBe(
'repo-id::feature/test::none::12'
'repo-id::feature/test::none::12::none'
)
})
it('uses explicit none marker when PR is absent', () => {
expect(checksPanelAsyncResultKey('repo-id', 'feature/test', null)).toBe(
'repo-id::feature/test::none::none'
'repo-id::feature/test::none::none::none'
)
})
@ -23,7 +23,13 @@ describe('checksPanelAsyncResultKey', () => {
owner: 'Acme',
repo: 'Widgets'
})
).toBe('repo-id::feature/test::acme/widgets::12')
).toBe('repo-id::feature/test::acme/widgets::12::none')
})
it('includes PR head SHA so stale checks cannot commit after a new head is discovered', () => {
expect(checksPanelAsyncResultKey('repo-id', 'feature/test', 12, null, 'head-a')).toBe(
'repo-id::feature/test::none::12::head-a'
)
})
})

View File

@ -11,9 +11,12 @@ export function checksPanelAsyncResultKey(
repoId: string,
branch: string,
prNumber: number | null,
prRepo?: GitHubOwnerRepo | null
prRepo?: GitHubOwnerRepo | null,
headSha?: string | null
): string {
return `${repoId}::${branch}::${normalizedPRRepoIdentity(prRepo)}::${prNumber ?? 'none'}`
return `${repoId}::${branch}::${normalizedPRRepoIdentity(prRepo)}::${prNumber ?? 'none'}::${
headSha ?? 'none'
}`
}
export function shouldCommitChecksPanelAsyncResult(

View File

@ -1,13 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { cn } from '@/lib/utils'
import { useSidebarResize } from '@/hooks/useSidebarResize'
import type { ActivityBarPosition } from '@/store/slices/editor'
import type { CheckStatus } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import {
ContextMenu,
@ -27,6 +25,7 @@ import {
TopActivityOverflowMenu,
type ActivityBarItem
} from './activity-bar-buttons'
import { getActiveChecksStatus } from './active-checks-status'
const MIN_WIDTH = 220
// Why: long file names (e.g. construction drawing sheets, multi-part document
@ -38,31 +37,6 @@ const MIN_NON_SIDEBAR_AREA = 320
const ABSOLUTE_FALLBACK_MAX_WIDTH = 2000
const ACTIVITY_BAR_SIDE_WIDTH = 40
function branchDisplayName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function getActiveChecksStatus(state: ReturnType<typeof useAppStore.getState>): CheckStatus | null {
const activeWorktree = state.activeWorktreeId
? findWorktreeById(state.worktreesByRepo, state.activeWorktreeId)
: null
if (!activeWorktree) {
return null
}
const activeRepo = getRepoMapFromState(state).get(activeWorktree.repoId)
if (!activeRepo) {
return null
}
const branch = branchDisplayName(activeWorktree.branch)
if (!branch) {
return null
}
const prCacheKey = `${activeRepo.path}::${branch}`
return state.prCache[prCacheKey]?.data?.checksStatus ?? null
}
const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
const isWindows =

View File

@ -172,7 +172,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
const branch = branchDisplayName(worktree.branch)
const isFolder = repo ? isFolderRepo(repo) : false
const hostedReviewCacheKey =
repo && branch ? getHostedReviewCacheKey(repo.path, branch, settings, repo.id) : ''
repo && branch
? getHostedReviewCacheKey(repo.path, branch, settings, repo.id, repo.connectionId)
: ''
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.id}::${worktree.linkedIssue}` : ''
const linearIssueCacheKey = worktree.linkedLinearIssue
? `selected::${worktree.linkedLinearIssue}`

View File

@ -521,6 +521,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
const settings = useAppStore((s) => s.settings)
// Drag is only meaningful when repo headers are using manual order. The
// controller is still constructed for hook order stability when inert.
@ -830,7 +831,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
targetWorktree,
repoMap,
prCache,
workspaceStatuses
workspaceStatuses,
settings
)
if (groupKey && collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
@ -877,7 +879,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearPendingRevealWorktreeId,
toggleGroup,
collapsedGroups,
workspaceStatuses
workspaceStatuses,
settings
])
const prCacheLen = useAppStore((s) => Object.keys(s.prCache).length)
@ -964,7 +967,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
repoGroupOrdering,
worktreeLineageById,
worktreeMap,
true
true,
settings
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
if (worktreeRows.length === 0) {
return
@ -1009,7 +1013,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
repoOrder,
workspaceStatuses,
worktreeLineageById,
worktreeMap
worktreeMap,
settings
]
)
@ -2282,6 +2287,7 @@ const WorktreeList = React.memo(function WorktreeList({
const prCache = useAppStore((s) =>
groupBy === 'pr-status' || cardProps.includes('pr') ? s.prCache : null
)
const settings = useAppStore((s) => s.settings)
const sortEpoch = useAppStore((s) => s.sortEpoch)
@ -2581,7 +2587,8 @@ const WorktreeList = React.memo(function WorktreeList({
repoGroupOrdering,
worktreeLineageById,
worktreeMap,
true
true,
settings
),
[
groupBy,
@ -2593,7 +2600,8 @@ const WorktreeList = React.memo(function WorktreeList({
workspaceStatuses,
repoGroupOrdering,
worktreeLineageById,
worktreeMap
worktreeMap,
settings
]
)
// Why: header/mode changes can shift entire groups, so remount the

View File

@ -46,13 +46,65 @@ const repoMap = new Map([[repo.id, repo]])
describe('getPRGroupKey', () => {
it('puts merged PRs in the done group', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
'repo-1::feature/super-critical': {
data: { state: 'merged' }
}
}
expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('done')
})
it('prefers repo-scoped PR status over stale legacy path-scoped status', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { state: 'closed' }
},
'repo-1::feature/super-critical': {
data: { state: 'merged' }
}
}
expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('done')
})
it('falls back to legacy path-scoped PR status when no repo-scoped entry exists', () => {
const prCache = {
'/tmp/orca::feature/super-critical': {
data: { state: 'closed' }
}
}
expect(getPRGroupKey(worktree, repoMap, prCache)).toBe('closed')
})
it('does not fall back to local PR cache while runtime scoped data is loading', () => {
const prCache = {
'repo-1::feature/super-critical': {
data: { state: 'merged' }
}
}
expect(
getPRGroupKey(worktree, repoMap, prCache, {
activeRuntimeEnvironmentId: 'env-1'
} as never)
).toBe('in-progress')
})
it('uses SSH-scoped PR cache entries instead of local entries for SSH repos', () => {
const sshRepo = { ...repo, connectionId: 'ssh-1' }
const sshRepoMap = new Map([[sshRepo.id, sshRepo]])
const prCache = {
'repo-1::feature/super-critical': {
data: { state: 'merged' }
},
'ssh:ssh-1::repo-1::feature/super-critical': {
data: { state: 'closed' }
}
}
expect(getPRGroupKey(worktree, sshRepoMap, prCache)).toBe('closed')
})
})
describe('getGroupKeyForWorktree', () => {

View File

@ -21,6 +21,8 @@ import {
} from './workspace-status-icons'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type { SortBy } from './smart-sort'
import type { AppState } from '@/store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
export { branchName }
@ -144,15 +146,34 @@ export function getLineageRenderInfo(
export function getPRGroupKey(
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null
prCache: Record<string, unknown> | null,
settings?: AppState['settings']
): PRGroupKey {
const repo = repoMap.get(worktree.repoId)
const branch = branchName(worktree.branch)
const cacheKey = repo && branch ? `${repo.path}::${branch}` : ''
const prEntry =
cacheKey && prCache
? (prCache[cacheKey] as { data?: { state?: string } } | undefined)
: undefined
const repoScopedCacheKey =
repo && branch
? getGitHubPRCacheKey(repo.path, repo.id, branch, settings, repo.connectionId)
: ''
const canUseLegacyPRCache =
repo !== undefined && !settings?.activeRuntimeEnvironmentId?.trim() && !repo.connectionId
const legacyRepoScopedCacheKey =
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, repo.id, branch) : ''
const legacyPathScopedCacheKey =
canUseLegacyPRCache && branch ? getLegacyGitHubPRCacheKey(repo.path, undefined, branch) : ''
// Why: PR refreshes now write repo-id scoped entries; legacy path entries may
// still exist from persisted cache, but must not override fresher repo data.
const prEntry = prCache
? ((repoScopedCacheKey
? (prCache[repoScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined) ??
(legacyRepoScopedCacheKey
? (prCache[legacyRepoScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined) ??
(legacyPathScopedCacheKey
? (prCache[legacyPathScopedCacheKey] as { data?: { state?: string } } | undefined)
: undefined))
: undefined
const pr = prEntry?.data
if (!pr) {
@ -329,7 +350,8 @@ export function buildRows(
worktreeMap: Map<string, Worktree> = new Map(
worktrees.map((worktree) => [worktree.id, worktree])
),
nestLineage = false
nestLineage = false,
settings?: AppState['settings']
): Row[] {
const result: Row[] = []
@ -378,7 +400,7 @@ export function buildRows(
label =
workspaceStatuses.find((status) => status.id === workspaceStatus)?.label ?? workspaceStatus
} else {
const prGroup = getPRGroupKey(w, repoMap, prCache)
const prGroup = getPRGroupKey(w, repoMap, prCache, settings)
key = `pr:${prGroup}`
label = PR_GROUP_META[prGroup].label
}
@ -491,7 +513,8 @@ export function getGroupKeyForWorktree(
worktree: Worktree,
repoMap: Map<string, Repo>,
prCache: Record<string, unknown> | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
settings?: AppState['settings']
): string | null {
if (groupBy === 'none') {
return ALL_GROUP_KEY
@ -502,5 +525,5 @@ export function getGroupKeyForWorktree(
if (groupBy === 'repo') {
return `repo:${worktree.repoId}`
}
return `pr:${getPRGroupKey(worktree, repoMap, prCache)}`
return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}`
}

View File

@ -0,0 +1,45 @@
import type { AppState } from '../types'
export function getGitHubRepoCacheKey(
repoPath: string,
repoId: string | undefined,
suffix: string,
settings?: AppState['settings'],
connectionId?: string | null
): string {
const runtimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim()
const owner = repoId ?? repoPath
// Why: runtime/SSH lookups can observe different remotes than the local repo
// path, so cache keys include the active remote execution boundary.
if (runtimeEnvironmentId) {
return `runtime:${runtimeEnvironmentId}::${owner}::${suffix}`
}
const sshConnectionId = connectionId?.trim()
return sshConnectionId ? `ssh:${sshConnectionId}::${owner}::${suffix}` : `${owner}::${suffix}`
}
export function getLegacyGitHubRepoCacheKey(
repoPath: string,
repoId: string | undefined,
suffix: string
): string {
return `${repoId ?? repoPath}::${suffix}`
}
export function getGitHubPRCacheKey(
repoPath: string,
repoId: string | undefined,
branch: string,
settings?: AppState['settings'],
connectionId?: string | null
): string {
return getGitHubRepoCacheKey(repoPath, repoId, branch, settings, connectionId)
}
export function getLegacyGitHubPRCacheKey(
repoPath: string,
repoId: string | undefined,
branch: string
): string {
return getLegacyGitHubRepoCacheKey(repoPath, repoId, branch)
}

View File

@ -1,5 +1,6 @@
import type { AppState } from '../types'
import type { PRCheckDetail, CheckStatus, GitHubOwnerRepo } from '../../../../shared/types'
import { getGitHubPRCacheKey } from './github-cache-key'
export function normalizeBranchName(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
@ -40,14 +41,16 @@ export function syncPRChecksStatus(
branch: string | undefined,
checks: PRCheckDetail[],
headSha?: string,
prRepo?: GitHubOwnerRepo | null
prRepo?: GitHubOwnerRepo | null,
settings?: AppState['settings'],
connectionId?: string | null
): Partial<AppState> | null {
const normalized = branch ? normalizeBranchName(branch) : ''
if (!normalized) {
return null
}
const prCacheKey = `${repoId ?? repoPath}::${normalized}`
const prCacheKey = getGitHubPRCacheKey(repoPath, repoId, normalized, settings, connectionId)
const prEntry = state.prCache[prCacheKey]
if (!prEntry?.data) {
return null

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,9 @@ import type {
import { sortWorkItemsByUpdatedAt, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github'
import { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from './github-cache-key'
// ─── ProjectV2 cache types ────────────────────────────────────────────
// Why: declared separately from CacheEntry<T> (not a generified E parameter)
@ -84,9 +87,10 @@ function queryOverrideKeyPart(queryOverride: string | undefined): string {
function getRuntimeRepoTarget(
state: AppState,
repoPath: string
repoPath: string,
settings: AppState['settings'] = state.settings
): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null {
const target = getActiveRuntimeTarget(state.settings)
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return null
}
@ -346,6 +350,10 @@ type InflightWorkItems = {
}
const inflightWorkItemsRequests = new Map<string, InflightWorkItems>()
const prRequestGenerations = new Map<string, number>()
const prRefreshStartedHostedReviewEntries = new Map<
string,
AppState['hostedReviewCache'][string] | undefined
>()
// Why: cap in-flight cross-repo fan-out and hover-prefetches at the renderer
// boundary — the main-side gate is behind the IPC queue, so it can't see a
@ -384,6 +392,26 @@ function repoScopedCacheKey(repoPath: string, repoId: string | undefined, suffix
return `${repoId ?? repoPath}::${suffix}`
}
function runtimeScopedRepoCacheKey(
repoPath: string,
repoId: string | undefined,
suffix: string,
settings?: AppState['settings'],
connectionId?: string | null
): string {
return getGitHubRepoCacheKey(repoPath, repoId, suffix, settings, connectionId)
}
function prCacheKey(
repoPath: string,
repoId: string | undefined,
branch: string,
settings?: AppState['settings'],
connectionId?: string | null
): string {
return getGitHubPRCacheKey(repoPath, repoId, branch, settings, connectionId)
}
function repoCacheKeyPrefixes(repoId: string, repoPath?: string): string[] {
const prefixes = [`${repoId}::`]
if (repoPath && repoPath !== repoId) {
@ -479,7 +507,13 @@ function buildPRRefreshCandidate(
return null
}
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const cacheKey = repoScopedCacheKey(repoPath ?? repo.path, repo.id, branch)
const cacheKey = prCacheKey(
repoPath ?? repo.path,
repo.id,
branch,
state.settings,
repo.connectionId
)
const sshStatus = repo.connectionId
? state.sshConnectionStates.get(repo.connectionId)?.status
: null
@ -506,6 +540,224 @@ function buildPRRefreshCandidate(
}
}
function shouldClearHostedReviewForNoGitHubPR(
entry: AppState['hostedReviewCache'][string] | undefined
): boolean {
// Why: a GitHub-only miss should not create or refresh provider-neutral
// branch misses that suppress discovery for GitLab/other hosted reviews.
if (!entry) {
return false
}
if (entry.data?.provider === 'github') {
return true
}
return entry.data === null && isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey)
}
function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean {
return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false
}
function linkedReviewHintKeyForNoGitHubPR(
entry: AppState['hostedReviewCache'][string] | undefined
): string | undefined {
if (entry?.data?.provider === 'github') {
return isGitHubLinkedReviewHintKey(entry.linkedReviewHintKey)
? entry.linkedReviewHintKey
: linkedReviewHintKey({ linkedGitHubPR: entry.data.number })
}
return entry?.linkedReviewHintKey
}
function hasNewerHostedReviewCacheEntry(
cache: AppState['hostedReviewCache'],
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: AppState['hostedReviewCache'][string] | undefined
): boolean {
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
function syncHostedReviewCacheFromGitHubPRResult(args: {
cache: AppState['hostedReviewCache']
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
pr: PRInfo | null
fetchedAt: number
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}): { cache: AppState['hostedReviewCache']; accepted: boolean } {
const hostedReviewCacheKey = getHostedReviewCacheKey(
args.repoPath,
args.branch,
args.settings,
args.repoId,
args.connectionId
)
if (
args.requestStartedAt !== undefined &&
hasNewerHostedReviewCacheEntry(
args.cache,
hostedReviewCacheKey,
args.requestStartedAt,
args.requestStartedEntry
)
) {
return { cache: args.cache, accepted: false }
}
const hostedReviewEntry = args.cache[hostedReviewCacheKey]
if (
args.requestStartedAt === undefined &&
hostedReviewEntry !== undefined &&
hostedReviewEntry.fetchedAt >= args.fetchedAt
) {
return { cache: args.cache, accepted: false }
}
if (args.pr && hostedReviewEntry?.data && hostedReviewEntry.data.provider !== 'github') {
return { cache: args.cache, accepted: false }
}
if (!args.pr && !shouldClearHostedReviewForNoGitHubPR(hostedReviewEntry)) {
return { cache: args.cache, accepted: hostedReviewEntry?.data == null }
}
return {
cache: {
...args.cache,
[hostedReviewCacheKey]: {
data: args.pr ? hostedReviewInfoFromGitHubPRInfo(args.pr) : null,
fetchedAt: args.fetchedAt,
linkedReviewHintKey: args.pr
? linkedReviewHintKey({ linkedGitHubPR: args.pr.number })
: linkedReviewHintKeyForNoGitHubPR(hostedReviewEntry)
}
},
accepted: true
}
}
function shouldWritePRCacheForHostedReviewSync(args: {
hostedReviewSyncAccepted: boolean
}): boolean {
// Why: PR-status grouping reads prCache while cards read hostedReviewCache.
// If a GitHub PR result was rejected for the card, don't let grouping drift.
return args.hostedReviewSyncAccepted
}
function applyPRCacheResult(
cache: AppState['prCache'],
cacheKey: string,
pr: PRInfo | null,
fetchedAt: number,
accepted: boolean
): AppState['prCache'] {
if (accepted) {
return { ...cache, [cacheKey]: { data: pr, fetchedAt } }
}
if (!cache[cacheKey]) {
return cache
}
const next = { ...cache }
delete next[cacheKey]
return next
}
function prRefreshStartedEntryKey(sequence: number, cacheKey: string): string {
return `${sequence}::${cacheKey}`
}
function setGitHubPRResultCaches(
state: AppState,
args: {
prCacheKey: string
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
pr: PRInfo | null
fetchedAt: number
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}
): Partial<AppState> {
const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({
cache: state.hostedReviewCache,
repoPath: args.repoPath,
branch: args.branch,
settings: args.settings,
repoId: args.repoId,
connectionId: args.connectionId,
pr: args.pr,
fetchedAt: args.fetchedAt,
requestStartedAt: args.requestStartedAt,
requestStartedEntry: args.requestStartedEntry
})
return {
prCache: applyPRCacheResult(
state.prCache,
args.prCacheKey,
args.pr,
args.fetchedAt,
shouldWritePRCacheForHostedReviewSync({
hostedReviewSyncAccepted: hostedReviewSync.accepted
})
),
...(hostedReviewSync.cache === state.hostedReviewCache
? {}
: { hostedReviewCache: hostedReviewSync.cache })
}
}
function applyGitHubPRResultToCaches(args: {
prCache: AppState['prCache']
hostedReviewCache: AppState['hostedReviewCache']
prCacheKey: string
repoPath: string
branch: string
settings: AppState['settings']
repoId?: string
connectionId?: string | null
pr: PRInfo | null
fetchedAt: number
requestStartedAt?: number
requestStartedEntry?: AppState['hostedReviewCache'][string]
}): {
prCache: AppState['prCache']
hostedReviewCache: AppState['hostedReviewCache']
} {
const hostedReviewSync = syncHostedReviewCacheFromGitHubPRResult({
cache: args.hostedReviewCache,
repoPath: args.repoPath,
branch: args.branch,
settings: args.settings,
repoId: args.repoId,
connectionId: args.connectionId,
pr: args.pr,
fetchedAt: args.fetchedAt,
requestStartedAt: args.requestStartedAt,
requestStartedEntry: args.requestStartedEntry
})
return {
prCache: applyPRCacheResult(
args.prCache,
args.prCacheKey,
args.pr,
args.fetchedAt,
shouldWritePRCacheForHostedReviewSync({
hostedReviewSyncAccepted: hostedReviewSync.accepted
})
),
hostedReviewCache: hostedReviewSync.cache
}
}
/**
* Evict the oldest entries from a cache record when it exceeds the max size.
* Returns a pruned copy, or the original reference if no eviction was needed.
@ -1427,8 +1679,16 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const cacheKey = repoScopedCacheKey(repoPath, repoId, branch)
const requestSettings = get().settings
const cacheKey = prCacheKey(repoPath, repoId, branch, requestSettings, repo?.connectionId)
const cached = get().prCache[cacheKey]
const hostedReviewCacheKey = getHostedReviewCacheKey(
repoPath,
branch,
requestSettings,
repoId,
repo?.connectionId
)
// Why: if a prior caller without a linkedPR cached `null` for this branch,
// the worktree-card lookup (which has a linked PR fallback) would otherwise
// return null forever. Refetch when the cached miss could now resolve via
@ -1444,12 +1704,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
const generation = (prRequestGenerations.get(cacheKey) ?? 0) + 1
const requestStartedAt = Date.now()
const requestStartedHostedReviewEntry = get().hostedReviewCache[hostedReviewCacheKey]
prRequestGenerations.set(cacheKey, generation)
const linkedPRNumber = options?.linkedPRNumber ?? null
const request = (async () => {
try {
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath)
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings)
const outcome = runtimeRepo
? await callRuntimeRpc<PRInfo | null>(
runtimeRepo.target,
@ -1488,9 +1750,20 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return cached?.data ?? null
}
if (prRequestGenerations.get(cacheKey) === generation) {
set((s) => ({
prCache: { ...s.prCache, [cacheKey]: { data: pr, fetchedAt: outcome.fetchedAt } }
}))
set((s) =>
setGitHubPRResultCaches(s, {
prCacheKey: cacheKey,
repoPath,
branch,
settings: requestSettings,
repoId,
connectionId: repo?.connectionId,
pr,
fetchedAt: outcome.fetchedAt,
requestStartedAt,
requestStartedEntry: requestStartedHostedReviewEntry
})
)
debouncedSaveCache(get())
}
return pr ?? null
@ -1558,14 +1831,26 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prRepo,
options
): Promise<PRCheckDetail[]> => {
const repoId = options?.repoId ?? get().repos?.find((repo) => repo.path === repoPath)?.id
const cacheKey = repoScopedCacheKey(
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = get().settings
const cacheKey = runtimeScopedRepoCacheKey(
repoPath,
repoId,
prChecksCacheSuffix(prNumber, prRepo, headSha)
prChecksCacheSuffix(prNumber, prRepo, headSha),
requestSettings,
repo?.connectionId
)
const legacyCacheKey = headSha
? repoScopedCacheKey(repoPath, repoId, prChecksCacheSuffix(prNumber, prRepo))
? runtimeScopedRepoCacheKey(
repoPath,
repoId,
prChecksCacheSuffix(prNumber, prRepo),
requestSettings,
repo?.connectionId
)
: cacheKey
const inflightKey = cacheKey
const cached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
@ -1582,7 +1867,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
branch,
cachedChecks,
cached.headSha,
prRepo
prRepo,
requestSettings,
repo?.connectionId
)
if (prStatusUpdate) {
set(prStatusUpdate)
@ -1598,7 +1885,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const request = (async () => {
try {
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath)
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings)
const checks = runtimeRepo
? await callRuntimeRpc<PRCheckDetail[]>(
runtimeRepo.target,
@ -1635,7 +1922,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
branch,
checks,
headSha,
prRepo
prRepo,
requestSettings,
repo?.connectionId
)
if (prStatusUpdate?.prCache) {
nextState.prCache = prStatusUpdate.prCache
@ -1662,11 +1951,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
},
fetchPRComments: async (repoPath, prNumber, options): Promise<PRComment[]> => {
const repoId = options?.repoId ?? get().repos?.find((repo) => repo.path === repoPath)?.id
const cacheKey = repoScopedCacheKey(
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = get().settings
const cacheKey = runtimeScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo)
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId
)
const cached = get().commentsCache[cacheKey]
if (!options?.force && isFresh(cached)) {
@ -1680,13 +1975,26 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const request = (async () => {
try {
const comments = (await window.api.gh.prComments({
repoPath,
repoId,
prNumber,
prRepo: options?.prRepo ?? null,
noCache: options?.force
})) as PRComment[]
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings)
const comments = runtimeRepo
? await callRuntimeRpc<PRComment[]>(
runtimeRepo.target,
'github.prComments',
{
repo: runtimeRepo.repo.id,
prNumber,
prRepo: options?.prRepo ?? null,
noCache: options?.force
},
{ timeoutMs: 30_000 }
)
: ((await window.api.gh.prComments({
repoPath,
repoId,
prNumber,
prRepo: options?.prRepo ?? null,
noCache: options?.force
})) as PRComment[])
set((s) => ({
commentsCache: {
...s.commentsCache,
@ -1707,11 +2015,17 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
},
resolveReviewThread: async (repoPath, prNumber, threadId, resolve, options) => {
const repoId = options?.repoId ?? get().repos?.find((repo) => repo.path === repoPath)?.id
const cacheKey = repoScopedCacheKey(
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const requestSettings = get().settings
const cacheKey = runtimeScopedRepoCacheKey(
repoPath,
repoId,
prCommentsCacheSuffix(prNumber, options?.prRepo)
prCommentsCacheSuffix(prNumber, options?.prRepo),
requestSettings,
repo?.connectionId
)
// Optimistic update: toggle isResolved on all comments in this thread immediately
@ -1729,7 +2043,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}))
}
const ok = await window.api.gh.resolveReviewThread({ repoPath, repoId, threadId, resolve })
const runtimeRepo = getRuntimeRepoTarget(get(), repoPath, requestSettings)
const ok = runtimeRepo
? await callRuntimeRpc<boolean>(
runtimeRepo.target,
'github.resolveReviewThread',
{ repo: runtimeRepo.repo.id, threadId, resolve },
{ timeoutMs: 30_000 }
)
: await window.api.gh.resolveReviewThread({ repoPath, repoId, threadId, resolve })
if (!ok && prev) {
// Revert optimistic update on failure
set((s) => ({
@ -1749,6 +2071,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (!candidate) {
return
}
if (getRuntimeRepoTarget(state, candidate.repoPath)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: bypassesGitHubPRRefreshFreshness(reason),
repoId: candidate.repoId,
linkedPRNumber: candidate.linkedPRNumber ?? null
})
return
}
const enqueue = window.api.gh.enqueuePRRefresh
if (enqueue) {
void enqueue({ candidate, reason, priority })
@ -1776,6 +2106,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return worktree ? buildPRRefreshCandidate(state, worktree) : null
})
.filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null)
if (getActiveRuntimeTarget(state.settings).kind === 'environment') {
for (const candidate of candidates) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
linkedPRNumber: candidate.linkedPRNumber ?? null
})
}
return
}
const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates
if (reportVisible) {
void reportVisible({ candidates, generation }).catch((err) => {
@ -1790,9 +2129,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
applyGitHubPRRefreshEvent: (event) => {
set((s) => {
// Why: local main-process refresh events are keyed only by repo/branch;
// applying them while a runtime is active can leak local PR state into SSH.
if (getActiveRuntimeTarget(s.settings).kind === 'environment') {
return {}
}
const nextSequences = { ...s.prRefreshSequences }
const nextStates = { ...s.prRefreshStates }
let nextPRCache = s.prCache
let nextHostedReviewCache = s.hostedReviewCache ?? {}
let changed = false
for (const alias of event.aliases) {
@ -1806,6 +2151,9 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
changed = true
if (event.outcome) {
const startedEntryKey = prRefreshStartedEntryKey(event.sequence, alias.cacheKey)
const requestStartedEntry = prRefreshStartedHostedReviewEntries.get(startedEntryKey)
prRefreshStartedHostedReviewEntries.delete(startedEntryKey)
delete nextStates[alias.cacheKey]
if (event.outcome.kind === 'upstream-error') {
nextStates[alias.cacheKey] = {
@ -1823,17 +2171,21 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const checksCacheKeys = [
...(alias.repoId
? [
repoScopedCacheKey(
runtimeScopedRepoCacheKey(
alias.repoPath,
alias.repoId,
prChecksCacheSuffix(pr.number, pr.prRepo)
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId
)
]
: []),
repoScopedCacheKey(
runtimeScopedRepoCacheKey(
alias.repoPath,
undefined,
prChecksCacheSuffix(pr.number, pr.prRepo)
prChecksCacheSuffix(pr.number, pr.prRepo),
s.settings,
alias.connectionId
),
`${alias.repoPath}::pr-checks::${pr.number}`
]
@ -1852,14 +2204,39 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
return pr
})()
: null
nextPRCache = {
...nextPRCache,
[alias.cacheKey]: { data, fetchedAt: event.outcome.fetchedAt }
}
const nextCaches = applyGitHubPRResultToCaches({
prCache: nextPRCache,
hostedReviewCache: nextHostedReviewCache,
prCacheKey: alias.cacheKey,
repoPath: alias.repoPath,
branch: alias.branch,
settings: s.settings,
repoId: alias.repoId,
connectionId: alias.connectionId,
pr: data,
fetchedAt: event.outcome.fetchedAt,
requestStartedAt: event.requestStartedAt,
requestStartedEntry
})
nextPRCache = nextCaches.prCache
nextHostedReviewCache = nextCaches.hostedReviewCache
continue
}
if (event.status) {
if (event.status === 'in-flight' && event.requestStartedAt !== undefined) {
const hostedReviewCacheKey = getHostedReviewCacheKey(
alias.repoPath,
alias.branch,
s.settings,
alias.repoId,
alias.connectionId
)
prRefreshStartedHostedReviewEntries.set(
prRefreshStartedEntryKey(event.sequence, alias.cacheKey),
s.hostedReviewCache[hostedReviewCacheKey]
)
}
nextStates[alias.cacheKey] = {
status: event.status,
reason: event.reason,
@ -1873,7 +2250,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
? {
prRefreshSequences: nextSequences,
prRefreshStates: nextStates,
prCache: nextPRCache
prCache: nextPRCache,
hostedReviewCache: nextHostedReviewCache
}
: {}
})
@ -1924,7 +2302,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const branch = wt.branch.replace(/^refs\/heads\//, '')
if (shouldRefreshPRs && !wt.isBare && branch) {
const prKey = repoScopedCacheKey(repo.path, repo.id, branch)
const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId)
const prEntry = state.prCache[prKey]
if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) {
const candidate = buildPRRefreshCandidate(state, wt)
@ -1951,7 +2329,14 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
.sort((a, b) => b.score - a.score)
.slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5)
for (const { candidate } of candidatesToRefresh) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 })
if (getRuntimeRepoTarget(state, candidate.repoPath)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
linkedPRNumber: candidate.linkedPRNumber ?? null
})
} else {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 })
}
}
},
@ -1975,7 +2360,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
// Invalidate this worktree's cache entries
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const prKey = repoScopedCacheKey(repo.path, repo.id, branch)
const prKey = prCacheKey(repo.path, repo.id, branch, state.settings, repo.connectionId)
const issueKey = worktree.linkedIssue
? repoScopedCacheKey(repo.path, repo.id, String(worktree.linkedIssue))
: ''
@ -1998,7 +2383,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (!worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(get(), worktree)
if (candidate) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 })
if (getRuntimeRepoTarget(get(), candidate.repoPath)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
linkedPRNumber: candidate.linkedPRNumber ?? null
})
} else {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 })
}
}
}
if (worktree.linkedIssue) {
@ -2170,7 +2563,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
if (shouldRefreshPR && !worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(state, worktree)
if (candidate) {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 })
if (getRuntimeRepoTarget(state, candidate.repoPath)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
linkedPRNumber: candidate.linkedPRNumber ?? null
})
} else {
void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 })
}
}
}

View File

@ -0,0 +1,42 @@
import type { GlobalSettings } from '../../../../shared/types'
export type LinkedReviewHints = {
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
export function getHostedReviewCacheKey(
repoPath: string,
branch: string,
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
repoId?: string | null,
connectionId?: string | null
): string {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
const sshConnectionId = connectionId?.trim()
const scope = environmentId
? `runtime:${environmentId}`
: sshConnectionId
? `ssh:${sshConnectionId}`
: 'local'
return `${scope}::${repoId ?? repoPath}::${branch}`
}
// Why: a branch-keyed lookup can describe a different PR than the persisted
// linked review number. Track that distinction without changing the cache key.
export function linkedReviewHintKey(options?: LinkedReviewHints): string {
const hints = [
['github', options?.linkedGitHubPR ?? null],
['gitlab', options?.linkedGitLabMR ?? null],
['bitbucket', options?.linkedBitbucketPR ?? null],
['azure-devops', options?.linkedAzureDevOpsPR ?? null],
['gitea', options?.linkedGiteaPR ?? null]
] as const
return hints
.filter(([, number]) => number !== null)
.map(([provider, number]) => `${provider}:${number}`)
.join('|')
}

View File

@ -0,0 +1,257 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
import { createHostedReviewSlice, getHostedReviewCacheKey } from './hosted-review'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
const runtimeRpc = vi.hoisted(() => ({
callRuntimeRpc: vi.fn()
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: runtimeRpc.callRuntimeRpc,
getActiveRuntimeTarget: (
settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined
) => {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
}
}))
const mockApi = {
hostedReview: {
forBranch: vi.fn(),
getCreationEligibility: vi.fn(),
create: vi.fn()
}
}
globalThis.window = { api: mockApi } as never
function makeStore(settings: AppState['settings'] = null) {
return create<
Pick<
AppState,
| 'hostedReviewCache'
| 'fetchHostedReviewForBranch'
| 'getHostedReviewCreationEligibility'
| 'createHostedReview'
| 'settings'
| 'repos'
>
>()((...args) => ({
settings,
repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
}))
}
const review: HostedReviewInfo = {
provider: 'gitlab',
number: 5,
title: 'Shared MR status',
state: 'open',
url: 'https://gitlab.com/g/p/-/merge_requests/5',
status: 'success',
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
}
function makeGitHubReview(title: string): HostedReviewInfo {
return {
...review,
provider: 'github',
number: 42,
title,
url: 'https://github.com/acme/orca/pull/42'
}
}
describe('hosted review cache race protection', () => {
beforeEach(() => {
mockApi.hostedReview.forBranch.mockReset()
mockApi.hostedReview.getCreationEligibility.mockReset()
mockApi.hostedReview.create.mockReset()
runtimeRpc.callRuntimeRpc.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not let an older successful fetch overwrite a newer external cache write', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const olderReview: HostedReviewInfo = { ...review, title: 'Older hosted review status' }
const newerReview = makeGitHubReview('Newer GitHub refresh status')
let resolveFetch: (value: HostedReviewInfo) => void = () => {}
const fetch = new Promise<HostedReviewInfo>((resolve) => {
resolveFetch = resolve
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/race')
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/race')
vi.setSystemTime(200)
store.setState({
hostedReviewCache: {
[cacheKey]: {
data: newerReview,
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:42'
}
}
})
vi.setSystemTime(300)
resolveFetch(olderReview)
await expect(request).resolves.toEqual(olderReview)
expect(store.getState().hostedReviewCache[cacheKey]).toEqual({
data: newerReview,
fetchedAt: 200,
linkedReviewHintKey: 'github:42'
})
})
it('does not let an older failed fetch overwrite a newer external cache write', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const newerReview = makeGitHubReview('Newer GitHub refresh status')
let rejectFetch: (error: Error) => void = () => {}
const fetch = new Promise<HostedReviewInfo>((_resolve, reject) => {
rejectFetch = reject
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/error-race')
try {
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/error-race')
vi.setSystemTime(200)
store.setState({
hostedReviewCache: {
[cacheKey]: {
data: newerReview,
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:42'
}
}
})
vi.setSystemTime(300)
rejectFetch(new Error('older lookup failed'))
await expect(request).resolves.toBeNull()
expect(store.getState().hostedReviewCache[cacheKey]).toEqual({
data: newerReview,
fetchedAt: 200,
linkedReviewHintKey: 'github:42'
})
} finally {
consoleError.mockRestore()
}
})
it('does not let a same-millisecond external cache write after request start be overwritten', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const olderReview: HostedReviewInfo = {
...review,
title: 'Older same-ms hosted review status'
}
const newerReview = makeGitHubReview('Newer same-ms GitHub refresh status')
let resolveFetch: (value: HostedReviewInfo) => void = () => {}
const fetch = new Promise<HostedReviewInfo>((resolve) => {
resolveFetch = resolve
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(fetch)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-race')
const request = store.getState().fetchHostedReviewForBranch('/repo', 'feature/same-ms-race')
store.setState({
hostedReviewCache: {
[cacheKey]: {
data: newerReview,
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:42'
}
}
})
resolveFetch(olderReview)
await expect(request).resolves.toEqual(olderReview)
expect(store.getState().hostedReviewCache[cacheKey]).toEqual({
data: newerReview,
fetchedAt: 100,
linkedReviewHintKey: 'github:42'
})
})
it('does not block a pre-existing same-millisecond cache entry from being refreshed', async () => {
vi.useFakeTimers()
vi.setSystemTime(100)
const staleReview: HostedReviewInfo = {
...review,
title: 'Pre-existing same-ms hosted review status'
}
const freshReview: HostedReviewInfo = {
...review,
title: 'Fresh same-ms hosted review status'
}
mockApi.hostedReview.forBranch.mockResolvedValueOnce(freshReview)
const store = makeStore()
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/same-ms-existing')
store.setState({
hostedReviewCache: {
[cacheKey]: {
data: staleReview,
fetchedAt: Date.now()
}
}
})
await expect(
store
.getState()
.fetchHostedReviewForBranch('/repo', 'feature/same-ms-existing', { force: true })
).resolves.toEqual(freshReview)
expect(store.getState().hostedReviewCache[cacheKey]).toEqual({
data: freshReview,
fetchedAt: 100,
linkedReviewHintKey: ''
})
})
it('does not reuse a provider-scoped inflight request for neutral discovery', async () => {
const githubReview = makeGitHubReview('Linked GitHub PR status')
let resolveGitHubLookup: (value: HostedReviewInfo | null) => void = () => {}
const githubLookup = new Promise<HostedReviewInfo | null>((resolve) => {
resolveGitHubLookup = resolve
})
mockApi.hostedReview.forBranch.mockReturnValueOnce(githubLookup).mockResolvedValueOnce(review)
const store = makeStore()
const linkedRequest = store.getState().fetchHostedReviewForBranch('/repo', 'feature/inflight', {
linkedGitHubPR: 42
})
const neutralRequest = store.getState().fetchHostedReviewForBranch('/repo', 'feature/inflight')
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
expect(mockApi.hostedReview.forBranch).toHaveBeenNthCalledWith(2, {
branch: 'feature/inflight',
linkedAzureDevOpsPR: null,
linkedBitbucketPR: null,
linkedGitHubPR: null,
linkedGitLabMR: null,
linkedGiteaPR: null,
repoPath: '/repo'
})
resolveGitHubLookup(githubReview)
await expect(linkedRequest).resolves.toEqual(githubReview)
await expect(neutralRequest).resolves.toEqual(review)
})
})

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: hosted-review tests cover runtime routing,
hinted cache revalidation, provider discovery, and PR cache reconciliation. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { AppState } from '../types'
@ -38,10 +40,12 @@ function makeStore(settings: AppState['settings'] = null) {
| 'createHostedReview'
| 'settings'
| 'repos'
| 'prCache'
>
>()((...args) => ({
settings,
repos: [{ id: 'repo-1', path: '/repo', connectionId: null } as AppState['repos'][number]],
prCache: {},
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
}))
}
@ -94,6 +98,67 @@ describe('hosted review slice', () => {
})
})
it('clears stale GitHub PR cache when branch review lookup finds a non-GitHub review', async () => {
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review)
const store = makeStore()
store.setState({
prCache: {
'repo-1::feature/gitlab': {
data: {
number: 12,
title: 'Old GitHub PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/12',
checksStatus: 'pending',
updatedAt: '2026-03-28T00:00:00Z',
mergeable: 'UNKNOWN',
headSha: 'head-oid'
},
fetchedAt: 1
},
'/repo::feature/gitlab': {
data: {
number: 99,
title: 'Old path-scoped GitHub PR',
state: 'closed',
url: 'https://github.com/acme/orca/pull/99',
checksStatus: 'failure',
updatedAt: '2026-03-28T00:00:00Z',
mergeable: 'UNKNOWN',
headSha: 'old-head-oid'
},
fetchedAt: 1
}
}
} as unknown as Partial<AppState>)
await expect(
store.getState().fetchHostedReviewForBranch('/repo', 'feature/gitlab')
).resolves.toEqual(review)
expect(store.getState().prCache['repo-1::feature/gitlab']).toBeUndefined()
expect(store.getState().prCache['/repo::feature/gitlab']).toBeUndefined()
})
it('uses SSH-scoped hosted review cache entries for SSH-backed repos', async () => {
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review)
const store = makeStore()
store.setState({
repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-1' } as AppState['repos'][number]]
} as Partial<AppState>)
await expect(
store.getState().fetchHostedReviewForBranch('/repo', 'feature/gitlab', {
repoId: 'repo-1'
})
).resolves.toEqual(review)
expect(store.getState().hostedReviewCache['ssh:ssh-1::repo-1::feature/gitlab']).toMatchObject({
data: review
})
expect(store.getState().hostedReviewCache['local::repo-1::feature/gitlab']).toBeUndefined()
})
it('routes active runtime review lookups through runtime RPC', async () => {
runtimeRpc.callRuntimeRpc.mockResolvedValueOnce(review)
const store = makeStore({

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: hosted-review cache identity, runtime dispatch,
and race protection are kept together so branch review lookup invariants stay testable. */
import type { StateCreator } from 'zustand'
import type {
CreateHostedReviewInput,
@ -6,19 +8,19 @@ import type {
HostedReviewCreationEligibilityArgs,
HostedReviewInfo
} from '../../../../shared/hosted-review'
import type { GlobalSettings } from '../../../../shared/types'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { AppState } from '../types'
import {
getHostedReviewCacheKey,
linkedReviewHintKey,
type LinkedReviewHints
} from './hosted-review-cache-identity'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity'
type CacheEntry<T> = { data: T | null; fetchedAt: number; linkedReviewHintKey?: string }
type FetchOptions = { force?: boolean; repoId?: string; staleWhileRevalidate?: boolean }
type LinkedReviewHints = {
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
const CACHE_TTL_MS = 60_000
@ -37,22 +39,6 @@ function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS
}
// Why: a branch-keyed lookup can describe a different PR than the persisted
// linked review number. Track that distinction without changing the cache key.
function linkedReviewHintKey(options?: LinkedReviewHints): string {
const hints = [
['github', options?.linkedGitHubPR ?? null],
['gitlab', options?.linkedGitLabMR ?? null],
['bitbucket', options?.linkedBitbucketPR ?? null],
['azure-devops', options?.linkedAzureDevOpsPR ?? null],
['gitea', options?.linkedGiteaPR ?? null]
] as const
return hints
.filter(([, number]) => number !== null)
.map(([provider, number]) => `${provider}:${number}`)
.join('|')
}
function shouldRefetchForLinkedHint(
cached: CacheEntry<HostedReviewInfo> | undefined,
hintKey: string
@ -60,19 +46,41 @@ function shouldRefetchForLinkedHint(
return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey
}
function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean {
return nextHintKey === '' || inflightHintKey === nextHintKey
function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean {
return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false
}
export function getHostedReviewCacheKey(
repoPath: string,
branch: string,
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null,
repoId?: string | null
): string {
const target = getActiveRuntimeTarget(settings)
const scope = target.kind === 'environment' ? `runtime:${target.environmentId}` : 'local'
return `${scope}::${repoId ?? repoPath}::${branch}`
function shouldRefetchGitHubScopedResultForNoHint(
cached: CacheEntry<HostedReviewInfo> | undefined,
hintKey: string
): boolean {
// Why: a GitHub-scoped result does not prove the branch's publishing remote
// has no GitLab/other review for neutral lookup.
return (
cached !== undefined &&
hintKey === '' &&
isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey)
)
}
function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean {
return inflightHintKey === nextHintKey
}
function hasNewerHostedReviewCacheEntry(
cache: HostedReviewSlice['hostedReviewCache'],
cacheKey: string,
requestStartedAt: number,
requestStartedEntry: CacheEntry<HostedReviewInfo> | undefined
): boolean {
// Why: GitHub refresh events can update this shared cache while a branch
// lookup is in flight; older lookups must not resurrect stale results.
const entry = cache[cacheKey]
return (
entry !== undefined &&
(entry.fetchedAt > requestStartedAt ||
(entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry))
)
}
export type HostedReviewSlice = {
@ -87,13 +95,7 @@ export type HostedReviewSlice = {
fetchHostedReviewForBranch: (
repoPath: string,
branch: string,
options?: FetchOptions & {
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
linkedBitbucketPR?: number | null
linkedAzureDevOpsPR?: number | null
linkedGiteaPR?: number | null
}
options?: FetchOptions & LinkedReviewHints
) => Promise<HostedReviewInfo | null>
}
@ -186,11 +188,22 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
): Promise<HostedReviewInfo | null> => {
const settings = get().settings
const target = getActiveRuntimeTarget(settings)
const cacheKey = getHostedReviewCacheKey(repoPath, branch, settings, options?.repoId)
const repo = get().repos?.find((candidate) =>
options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath
)
const repoId = options?.repoId ?? repo?.id
const cacheKey = getHostedReviewCacheKey(
repoPath,
branch,
settings,
options?.repoId,
repo?.connectionId
)
const cached = get().hostedReviewCache[cacheKey]
const hintKey = linkedReviewHintKey(options)
const linkedRefetch = shouldRefetchForLinkedHint(cached, hintKey)
if (!options?.force && !linkedRefetch && isFresh(cached)) {
const scopedResultRefetch = shouldRefetchGitHubScopedResultForNoHint(cached, hintKey)
if (!options?.force && !linkedRefetch && !scopedResultRefetch && isFresh(cached)) {
return cached.data
}
@ -200,6 +213,8 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
canReuseInflightHint(inflightRequest.linkedReviewHintKey, hintKey)
const startRequest = (): Promise<HostedReviewInfo | null> => {
const generation = (requestGenerations.get(cacheKey) ?? 0) + 1
const requestStartedAt = Date.now()
const requestStartedEntry = get().hostedReviewCache[cacheKey]
requestGenerations.set(cacheKey, generation)
const request = (async () => {
try {
@ -226,23 +241,66 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
)
: await window.api.hostedReview.forBranch({ repoPath, ...args })
if (requestGenerations.get(cacheKey) === generation) {
set((state) => ({
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: review, fetchedAt: Date.now(), linkedReviewHintKey: hintKey }
set((state) => {
if (
hasNewerHostedReviewCacheEntry(
state.hostedReviewCache,
cacheKey,
requestStartedAt,
requestStartedEntry
)
) {
return {}
}
}))
const prCacheKeys = [
getGitHubPRCacheKey(repoPath, repoId, branch, settings, repo?.connectionId),
getLegacyGitHubPRCacheKey(repoPath, repoId, branch),
getLegacyGitHubPRCacheKey(repoPath, undefined, branch)
]
const currentPRCache = state.prCache ?? {}
const prCache =
review &&
review.provider !== 'github' &&
prCacheKeys.some((key) => currentPRCache[key])
? (() => {
const next = { ...currentPRCache }
for (const key of prCacheKeys) {
delete next[key]
}
return next
})()
: currentPRCache
return {
...(prCache === currentPRCache ? {} : { prCache }),
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: review, fetchedAt: Date.now(), linkedReviewHintKey: hintKey }
}
}
})
}
return review
} catch (error) {
console.error('Failed to fetch hosted review:', error)
if (requestGenerations.get(cacheKey) === generation) {
set((state) => ({
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: null, fetchedAt: Date.now(), linkedReviewHintKey: hintKey }
set((state) => {
if (
hasNewerHostedReviewCacheEntry(
state.hostedReviewCache,
cacheKey,
requestStartedAt,
requestStartedEntry
)
) {
return {}
}
}))
return {
hostedReviewCache: {
...state.hostedReviewCache,
[cacheKey]: { data: null, fetchedAt: Date.now(), linkedReviewHintKey: hintKey }
}
}
})
}
return null
} finally {
@ -265,6 +323,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
if (
!options?.force &&
!linkedRefetch &&
!scopedResultRefetch &&
options?.staleWhileRevalidate &&
cached !== undefined &&
cached.data !== null

View File

@ -1014,7 +1014,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched)
const cacheKey =
reviewRepo && reviewBranch
? getHostedReviewCacheKey(reviewRepo.path, reviewBranch, s.settings, reviewRepo.id)
? getHostedReviewCacheKey(
reviewRepo.path,
reviewBranch,
s.settings,
reviewRepo.id,
reviewRepo.connectionId
)
: null
const hostedReviewCache = s.hostedReviewCache ?? {}
if (nextWorktrees === s.worktreesByRepo && !cacheKey) {

View File

@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { hostedReviewSummaryFromGitHubPRInfo } from './hosted-review-github'
import {
hostedReviewInfoFromGitHubPRInfo,
hostedReviewSummaryFromGitHubPRInfo
} from './hosted-review-github'
import type { PRInfo } from './types'
const pr: PRInfo = {
@ -95,4 +98,18 @@ describe('hostedReviewSummaryFromGitHubPRInfo', () => {
}).threadSummary
).toEqual({ unresolvedCount: 0, dataCompleteness: 'partial' })
})
it('maps PRInfo into sidebar hosted review metadata', () => {
const review = hostedReviewInfoFromGitHubPRInfo(pr)
expect(review).toMatchObject({
provider: 'github',
number: 12,
title: 'Add queue badges',
state: 'open',
status: 'pending',
mergeable: 'MERGEABLE',
headSha: 'abc123'
})
})
})

View File

@ -1,5 +1,5 @@
import type { PRCheckDetail, PRComment, PRInfo } from './types'
import type { HostedReviewQueueSummary } from './hosted-review'
import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review'
export type HostedReviewFromGitHubPRInfoArgs = {
pr: PRInfo
@ -86,3 +86,18 @@ export function hostedReviewSummaryFromGitHubPRInfo(
draft: args.pr.state === 'draft'
}
}
export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo {
return {
provider: 'github',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
status: pr.checksStatus,
updatedAt: pr.updatedAt,
mergeable: pr.mergeable,
...(pr.headSha ? { headSha: pr.headSha } : {}),
...(pr.conflictSummary ? { conflictSummary: pr.conflictSummary } : {})
}
}

View File

@ -641,6 +641,7 @@ export type GitHubPRRefreshAlias = {
repoPath: string
branch: string
worktreeId?: string
connectionId?: string | null
}
export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & {
@ -670,6 +671,7 @@ type GitHubPRRefreshEventBase = {
sequence: number
reason: GitHubPRRefreshReason
aliases: GitHubPRRefreshAlias[]
requestStartedAt?: number
}
export type GitHubPRRefreshEvent =