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:
Neil 2026-05-15 22:42:17 -07:00 committed by GitHub
parent 5b88551e3b
commit 4bfd1eb157
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 584 additions and 99 deletions

View File

@ -50,10 +50,16 @@ vi.mock('./gh-utils', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
ghRepoExecOptions: ghRepoExecOptionsMock,
githubRepoContext: githubRepoContextMock,
classifyGhError: (stderr: string) =>
stderr.toLowerCase().includes('not found') || stderr.includes('HTTP 404')
? { type: 'not_found', message: stderr }
: { type: 'unknown', message: stderr },
classifyGhError: (stderr: string) => {
const lower = stderr.toLowerCase()
if (lower.includes('not found') || stderr.includes('HTTP 404')) {
return { type: 'not_found', message: stderr }
}
if (lower.includes('rate limit')) {
return { type: 'rate_limited', message: stderr }
}
return { type: 'unknown', message: stderr }
},
parseGitHubOwnerRepo: (remoteUrl: string) => {
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
return match ? { owner: match[1], repo: match[2] } : null
@ -146,6 +152,302 @@ describe('getPRForBranch', () => {
expect(pr?.mergeable).toBe('MERGEABLE')
})
it('prefers exact linked PR lookup when the repo identity is known', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 99,
title: 'Linked PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/99',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'someone/fix',
baseRefOid: 'base-oid',
headRefOid: 'linked-head-oid'
})
})
const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'view',
'99',
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr).toMatchObject({
number: 99,
title: 'Linked PR',
state: 'open',
headSha: 'linked-head-oid'
})
})
it('uses branch discovery when exact linked PR metadata resolves to a different PR', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 99,
title: 'Stale linked PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/99',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'someone/other-work',
baseRefOid: 'base-oid',
headRefOid: 'stale-linked-head'
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'current-worktree-head'
}
])
})
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'pr',
'list',
'--repo',
'acme/widgets',
'--head',
'feature/test',
'--state',
'all',
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
})
it('falls back to branch discovery when exact linked PR metadata is stale', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'pr',
'view',
'99',
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'pr',
'list',
'--repo',
'acme/widgets',
'--head',
'feature/test',
'--state',
'all',
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
})
it('continues to branch discovery when exact linked PR REST fallback also misses', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest'))
.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR after stale linked miss',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
[
'pr',
'list',
'--repo',
'acme/widgets',
'--head',
'feature/test',
'--state',
'all',
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
})
it('continues to branch discovery when exact linked PR REST fallback has an unclassified failure', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL: server exploded'))
.mockRejectedValueOnce(new Error('HTTP 500: server error'))
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Branch PR after exact lookup outage',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
statusCheckRollup: [],
updatedAt: '2026-03-28T00:00:00Z',
isDraft: false,
mergeable: 'MERGEABLE',
baseRefName: 'main',
headRefName: 'feature/test',
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
])
})
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
[
'pr',
'list',
'--repo',
'acme/widgets',
'--head',
'feature/test',
'--state',
'all',
'--limit',
'1',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(pr?.number).toBe(42)
})
it('does not spend branch discovery calls when exact linked PR REST fallback is rate limited', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
.mockRejectedValueOnce(new Error('REST API rate limit already exceeded'))
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'pr',
'view',
'99',
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(pr).toBeNull()
})
it('falls back to REST branch lookup when gh pr list is GraphQL rate limited', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
@ -354,8 +656,8 @@ describe('getPRForBranch', () => {
it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
.mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
@ -374,9 +676,23 @@ describe('getPRForBranch', () => {
const pr = await getPRForBranch('/repo-root', 'feature/test', 99)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(3, ['api', 'repos/acme/widgets/pulls/99'], {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'pr',
'view',
'99',
'--repo',
'acme/widgets',
'--json',
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(pr).toMatchObject({
number: 99,
state: 'merged',

View File

@ -1302,20 +1302,84 @@ async function getRestPRByNumber(
return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest)
}
async function getPRByNumber(
ownerRepo: OwnerRepo,
number: number,
ghOptions: ReturnType<typeof ghRepoExecOptions>
): Promise<PullRequestLookupData | null> {
try {
const { stdout } = await ghExecFileAsync(
[
'pr',
'view',
String(number),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
PR_LOOKUP_JSON_FIELDS
],
ghOptions
)
return JSON.parse(stdout) as PullRequestLookupData
} catch (err) {
// Why: deleted or manually edited linked PR metadata should fall back to
// branch discovery; quota/auth/network failures get one cheaper REST exact lookup.
if (isNotFoundGhError(err)) {
return null
}
try {
return await getRestPRByNumber(ownerRepo, number, ghOptions)
} catch (restErr) {
if (isNotFoundGhError(restErr)) {
return null
}
if (!shouldStopAfterExactLookupError(restErr)) {
return null
}
throw restErr
}
}
}
async function exactPRMatchesWorktreeHead(
repoPath: string,
branchName: string,
data: PullRequestLookupData,
connectionId?: string | null
): Promise<boolean> {
if (!branchName || data.headRefName === branchName) {
return true
}
if (connectionId || !data.headRefOid) {
return false
}
try {
const { stdout } = await gitExecFileAsync(['rev-parse', 'HEAD'], { cwd: repoPath })
return stdout.trim() === data.headRefOid
} catch {
return false
}
}
function isNotFoundGhError(err: unknown): boolean {
const stderr = err instanceof Error ? err.message : String(err)
return classifyGhError(stderr).type === 'not_found'
}
function shouldStopAfterExactLookupError(err: unknown): boolean {
const stderr = err instanceof Error ? err.message : String(err)
const type = classifyGhError(stderr).type
return type === 'rate_limited' || type === 'permission_denied' || type === 'network_error'
}
/**
* Get PR info for a given branch using gh CLI.
* Returns null if gh is not installed, or no PR exists for the branch.
*
* When `linkedPRNumber` is provided and the branch lookup yields nothing,
* falls back to looking up the PR by number. This handles "create from PR"
* worktrees, whose branch is a fresh local branch (not the PR's head ref)
* the branch-keyed lookup misses, but the user still expects the linked PR
* to surface on the worktree card.
* When `linkedPRNumber` is provided and the repo identity is known, starts
* with a direct PR-number lookup. This handles "create from PR" worktrees,
* whose branch is a fresh local branch, and avoids spending a branch-list
* request before asking for the exact PR the worktree already stores.
*/
export async function getPRForBranch(
repoPath: string,
@ -1332,11 +1396,22 @@ export async function getPRForBranch(
try {
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
let data: PullRequestLookupData | null = null
let exactLinkedData: PullRequestLookupData | null = null
if (ownerRepo && typeof linkedPRNumber === 'number') {
data = await getPRByNumber(ownerRepo, linkedPRNumber, ghOptions)
if (data && !(await exactPRMatchesWorktreeHead(repoPath, branchName, data, connectionId))) {
// Why: linked PR metadata is user-editable. If the stored number still
// resolves but no longer matches this worktree, let branch lookup correct it.
exactLinkedData = data
data = null
}
}
// During a rebase the worktree is in detached HEAD and branch is empty.
// An empty --head filter causes gh to return an arbitrary PR — skip the
// branch lookup and rely on the linkedPR fallback below if available.
if (branchName) {
if (!data && branchName) {
if (ownerRepo) {
try {
const { stdout } = await ghExecFileAsync(
@ -1375,33 +1450,24 @@ export async function getPRForBranch(
}
}
if (!data && typeof linkedPRNumber === 'number') {
const args = ownerRepo
? [
'pr',
'view',
String(linkedPRNumber),
'--repo',
`${ownerRepo.owner}/${ownerRepo.repo}`,
'--json',
PR_LOOKUP_JSON_FIELDS
]
: ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS]
if (!data && !ownerRepo && typeof linkedPRNumber === 'number') {
const args = ['pr', 'view', String(linkedPRNumber), '--json', PR_LOOKUP_JSON_FIELDS]
try {
const { stdout } = await ghExecFileAsync(args, ghOptions)
data = JSON.parse(stdout)
} catch (err) {
} catch {
// Why: a stale linkedPRNumber (PR deleted, wrong repo, …) makes
// `gh pr view <number>` reject. Treat that as the no-PR case so
// callers see the historical `null` semantics instead of a thrown
// error every poll cycle.
data =
ownerRepo && !isNotFoundGhError(err)
? await getRestPRByNumber(ownerRepo, linkedPRNumber, ghOptions)
: null
data = null
}
}
if (!data && exactLinkedData) {
data = exactLinkedData
}
if (!data) {
return null
}

View File

@ -656,7 +656,8 @@ function SourceControlInner(): React.JSX.Element {
void fetchHostedReviewForBranch(activeRepo.path, branchName, {
repoId: activeRepo.id,
linkedGitHubPR,
linkedGitLabMR
linkedGitLabMR,
staleWhileRevalidate: true
})
}, [
activeRepo,

View File

@ -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
})
}
}, [

View File

@ -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)
})
})

View File

@ -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()
}
})