mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
Prefer exact PR lookups and stale sidebar refresh
Prefer exact linked PR lookup when safe, add stale-while-revalidate for sidebar hosted review metadata, and preserve branch discovery correctness for stale linked PR metadata.
This commit is contained in:
@@ -656,7 +656,8 @@ function SourceControlInner(): React.JSX.Element {
|
||||
void fetchHostedReviewForBranch(activeRepo.path, branchName, {
|
||||
repoId: activeRepo.id,
|
||||
linkedGitHubPR,
|
||||
linkedGitLabMR
|
||||
linkedGitLabMR,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
}, [
|
||||
activeRepo,
|
||||
|
||||
@@ -211,7 +211,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
fetchHostedReviewForBranch(repo.path, branch, {
|
||||
repoId: repo.id,
|
||||
linkedGitHubPR: worktree.linkedPR ?? null,
|
||||
linkedGitLabMR: worktree.linkedGitLabMR ?? null
|
||||
linkedGitLabMR: worktree.linkedGitLabMR ?? null,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import { createHostedReviewSlice, refreshHostedReviewCard } from './hosted-review'
|
||||
import {
|
||||
createHostedReviewSlice,
|
||||
getHostedReviewCacheKey,
|
||||
refreshHostedReviewCard
|
||||
} from './hosted-review'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
|
||||
const runtimeRpc = vi.hoisted(() => ({
|
||||
@@ -52,6 +56,10 @@ describe('hosted review slice', () => {
|
||||
runtimeRpc.callRuntimeRpc.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('fetches and caches branch review status through the common IPC surface', async () => {
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review)
|
||||
const store = makeStore()
|
||||
@@ -201,4 +209,79 @@ describe('hosted review slice', () => {
|
||||
await expect(firstLinkedFetch).resolves.toEqual(review)
|
||||
await expect(secondLinkedFetch).resolves.toEqual(review)
|
||||
})
|
||||
|
||||
it('serves stale hosted review metadata while revalidating in the background', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const updatedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
title: 'Updated linked PR status',
|
||||
status: 'failure',
|
||||
updatedAt: '2026-05-10T00:01:01.000Z'
|
||||
}
|
||||
let resolveRefresh: (value: typeof updatedReview) => void = () => {}
|
||||
const refresh = new Promise<typeof updatedReview>((resolve) => {
|
||||
resolveRefresh = resolve
|
||||
})
|
||||
mockApi.hostedReview.forBranch
|
||||
.mockResolvedValueOnce(review)
|
||||
.mockReturnValueOnce(refresh as Promise<HostedReviewInfo>)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(review)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
const cacheKey = getHostedReviewCacheKey('/repo', 'feature/pr')
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(review)
|
||||
|
||||
resolveRefresh(updatedReview)
|
||||
await refresh
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getState().hostedReviewCache[cacheKey]?.data).toEqual(updatedReview)
|
||||
})
|
||||
|
||||
it('does not serve stale metadata when a stronger linked PR hint changes the lookup', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
const linkedReview: HostedReviewInfo = {
|
||||
...review,
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'Exact linked PR',
|
||||
url: 'https://github.com/acme/orca/pull/42'
|
||||
}
|
||||
mockApi.hostedReview.forBranch.mockResolvedValueOnce(review).mockResolvedValueOnce(linkedReview)
|
||||
const store = makeStore()
|
||||
|
||||
await expect(store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr')).resolves.toBe(
|
||||
review
|
||||
)
|
||||
vi.setSystemTime(60_001)
|
||||
await expect(
|
||||
store.getState().fetchHostedReviewForBranch('/repo', 'feature/pr', {
|
||||
linkedGitHubPR: 42,
|
||||
staleWhileRevalidate: true
|
||||
})
|
||||
).resolves.toEqual(linkedReview)
|
||||
|
||||
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl
|
||||
import type { AppState } from '../types'
|
||||
|
||||
type CacheEntry<T> = { data: T | null; fetchedAt: number; linkedReviewHintKey?: string }
|
||||
type FetchOptions = { force?: boolean; repoId?: string }
|
||||
type FetchOptions = { force?: boolean; repoId?: string; staleWhileRevalidate?: boolean }
|
||||
type LinkedReviewHints = {
|
||||
linkedGitHubPR?: number | null
|
||||
linkedGitLabMR?: number | null
|
||||
@@ -36,7 +36,7 @@ function isFresh<T>(entry: CacheEntry<T> | undefined): entry is CacheEntry<T> {
|
||||
return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS
|
||||
}
|
||||
|
||||
// Why: a branch-only null is weaker than a null after trying the persisted
|
||||
// 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 = [
|
||||
@@ -51,11 +51,11 @@ function linkedReviewHintKey(options?: LinkedReviewHints): string {
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function shouldRefetchNullForLinkedHint(
|
||||
function shouldRefetchForLinkedHint(
|
||||
cached: CacheEntry<HostedReviewInfo> | undefined,
|
||||
hintKey: string
|
||||
): boolean {
|
||||
return cached?.data === null && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey
|
||||
return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey
|
||||
}
|
||||
|
||||
function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean {
|
||||
@@ -175,7 +175,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
|
||||
const cacheKey = getHostedReviewCacheKey(repoPath, branch, settings, options?.repoId)
|
||||
const cached = get().hostedReviewCache[cacheKey]
|
||||
const hintKey = linkedReviewHintKey(options)
|
||||
const linkedRefetch = shouldRefetchNullForLinkedHint(cached, hintKey)
|
||||
const linkedRefetch = shouldRefetchForLinkedHint(cached, hintKey)
|
||||
if (!options?.force && !linkedRefetch && isFresh(cached)) {
|
||||
return cached.data
|
||||
}
|
||||
@@ -184,70 +184,88 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
|
||||
const inflightHasRequestedHint =
|
||||
inflightRequest !== undefined &&
|
||||
canReuseInflightHint(inflightRequest.linkedReviewHintKey, hintKey)
|
||||
const startRequest = (): Promise<HostedReviewInfo | null> => {
|
||||
const generation = (requestGenerations.get(cacheKey) ?? 0) + 1
|
||||
requestGenerations.set(cacheKey, generation)
|
||||
const request = (async () => {
|
||||
try {
|
||||
const args = {
|
||||
branch,
|
||||
...(options?.repoId !== undefined ? { repoId: options.repoId } : {}),
|
||||
linkedGitHubPR: options?.linkedGitHubPR ?? null,
|
||||
linkedGitLabMR: options?.linkedGitLabMR ?? null,
|
||||
linkedBitbucketPR: options?.linkedBitbucketPR ?? null,
|
||||
linkedGiteaPR: options?.linkedGiteaPR ?? null
|
||||
}
|
||||
const review =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<HostedReviewInfo | null>(
|
||||
target,
|
||||
'hostedReview.forBranch',
|
||||
{ repo: options?.repoId ?? repoPath, repoPath, ...args },
|
||||
// Why: remote dev boxes can be slower at `git`/`gh` lookups
|
||||
// than local desktop repos, especially on Windows filesystem
|
||||
// paths. The main-process queue caps concurrency, so a longer
|
||||
// timeout no longer risks a background socket stampede.
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: 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 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
const activeRequest = inflightHostedReviewRequests.get(cacheKey)
|
||||
if (activeRequest?.generation === generation) {
|
||||
inflightHostedReviewRequests.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inflightHostedReviewRequests.set(cacheKey, {
|
||||
promise: request,
|
||||
force: Boolean(options?.force),
|
||||
generation,
|
||||
linkedReviewHintKey: hintKey
|
||||
})
|
||||
return request
|
||||
}
|
||||
|
||||
if (
|
||||
!options?.force &&
|
||||
!linkedRefetch &&
|
||||
options?.staleWhileRevalidate &&
|
||||
cached !== undefined &&
|
||||
cached.data !== null
|
||||
) {
|
||||
// Why: sidebar PR metadata can stay visible while a quiet refresh updates
|
||||
// it; don't block card rendering on a quota-bound GitHub round trip.
|
||||
if (!inflightRequest || !inflightHasRequestedHint) {
|
||||
void startRequest()
|
||||
}
|
||||
return cached.data
|
||||
}
|
||||
|
||||
if (inflightRequest && (!options?.force || inflightRequest.force) && inflightHasRequestedHint) {
|
||||
return inflightRequest.promise
|
||||
}
|
||||
|
||||
const generation = (requestGenerations.get(cacheKey) ?? 0) + 1
|
||||
requestGenerations.set(cacheKey, generation)
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const args = {
|
||||
branch,
|
||||
...(options?.repoId !== undefined ? { repoId: options.repoId } : {}),
|
||||
linkedGitHubPR: options?.linkedGitHubPR ?? null,
|
||||
linkedGitLabMR: options?.linkedGitLabMR ?? null,
|
||||
linkedBitbucketPR: options?.linkedBitbucketPR ?? null,
|
||||
linkedGiteaPR: options?.linkedGiteaPR ?? null
|
||||
}
|
||||
const review =
|
||||
target.kind === 'environment'
|
||||
? await callRuntimeRpc<HostedReviewInfo | null>(
|
||||
target,
|
||||
'hostedReview.forBranch',
|
||||
{ repo: options?.repoId ?? repoPath, repoPath, ...args },
|
||||
// Why: remote dev boxes can be slower at `git`/`gh` lookups
|
||||
// than local desktop repos, especially on Windows filesystem
|
||||
// paths. The main-process queue caps concurrency, so a longer
|
||||
// timeout no longer risks a background socket stampede.
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
: 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 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}))
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
const activeRequest = inflightHostedReviewRequests.get(cacheKey)
|
||||
if (activeRequest?.generation === generation) {
|
||||
inflightHostedReviewRequests.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
inflightHostedReviewRequests.set(cacheKey, {
|
||||
promise: request,
|
||||
force: Boolean(options?.force),
|
||||
generation,
|
||||
linkedReviewHintKey: hintKey
|
||||
})
|
||||
return request
|
||||
return startRequest()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user