Refresh visible reviews automatically and stop settled merged polling (#25788)

Refresh only actual on-screen review cards and the selected visible review panel, with a single metadata owner per execution host. Open reviews refresh every 60 seconds when selected and 120 seconds otherwise. Settled merged reviews stop automatic refresh; pending merged checks continue, hidden rows stop, and work changes or stale re-exposure discover fresh state.

Remove the polling setting. Preserve provider/SSH/runtime ownership, mixed-version cache behavior, failure backoff, bounded foreground admission, request coalescing, and mutation invalidation. Cover older persisted caches and unknown HEADs.

Validated with 1,628 focused tests, typechecking, lint/code-quality gates, hidden Electron viewport checks, and parallel Codex/Claude Opus 5.5 adversarial reviews. Measurements and policy details are in PR #25788.

Fixes #25746

Co-authored-by: ggbdpq <ggbdpq@gmail.com>
This commit is contained in:
Neil
2026-10-06 00:53:49 -07:00
committed by GitHub
co-authored by ggbdpq
parent 9905765e3b
commit a2f197fde7
100 changed files with 5190 additions and 1795 deletions
+23
View File
@@ -208,6 +208,29 @@ function expectGraphQLRollupCall(callIndex = 1, noCache = false): void {
}
describe('getPRChecks', () => {
it('shares concurrent checks reads and honors explicit uncached refreshes', async () => {
vi.clearAllMocks()
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
acquireMock.mockResolvedValue(undefined)
const response = graphQLChecksResponse()
let finish: ((value: typeof response) => void) | undefined
ghExecFileAsyncMock.mockImplementation(
() =>
new Promise<typeof response>((resolve) => {
finish = resolve
})
)
const first = getPRChecks('/repo', 12)
const second = getPRChecks('/repo', 12)
await vi.waitFor(() => expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1))
finish?.(response)
await expect(Promise.all([first, second])).resolves.toEqual([[], []])
ghExecFileAsyncMock.mockResolvedValue(response)
await getPRChecks('/repo', 12, undefined, undefined, { noCache: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[1][0]).not.toContain('--cache')
})
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
import type { GitHubRepoContext, LocalGitExecOptions } from './github-repository-identity'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { hostedReviewInfoFromGitHubPRInfo } from '../../shared/hosted-review-github'
import { makePR } from './pr-refresh-coordinator-test-harness'
import {
__resetHostedReviewBranchCacheForTests,
invalidateHostedReviewBranchCache,
withHostedReviewBranchCache
} from '../source-control/hosted-review-branch-cache'
const mocks = vi.hoisted(() => ({ resolve: vi.fn(), acquire: vi.fn(), release: vi.fn() }))
vi.mock('./gh-utils', () => ({
acquire: mocks.acquire,
release: mocks.release,
githubRepoContext: (
repoPath: string,
connectionId: string | null,
options: LocalGitExecOptions
) => ({
repoPath,
connectionId,
...options
}),
ghRepoExecOptions: (context: GitHubRepoContext) => context
}))
vi.mock('./client/lookup/branch-lookup-resolution', () => ({
resolvePRForBranchOutcome: mocks.resolve
}))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 }))
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
const outcome: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 }
beforeEach(() => {
vi.clearAllMocks()
__resetHostedReviewBranchCacheForTests()
mocks.acquire.mockResolvedValue(undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('PR lookup coalescing', () => {
it('shares pending reads across callers and releases them after settlement', async () => {
let finish: ((value: PRRefreshOutcome) => void) | undefined
mocks.resolve.mockImplementation(
() =>
new Promise<PRRefreshOutcome>((resolve) => {
finish = resolve
})
)
const first = getPRForBranchOutcome('/repo', 'refs/heads/topic')
const second = getPRForBranchOutcome('/repo', 'topic')
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1))
finish?.(outcome)
expect(await Promise.all([first, second])).toEqual([outcome, outcome])
mocks.resolve.mockResolvedValue(outcome)
await getPRForBranchOutcome('/repo', 'topic')
expect(mocks.resolve).toHaveBeenCalledTimes(2)
})
it('shares a background read with a foreground caller of the same lookup', async () => {
mocks.resolve.mockResolvedValue(outcome)
await Promise.all([
getPRForBranchOutcome('/repo', 'topic', null, null, null, {
localGitExecOptions: { admissionTier: 'background' }
}),
getPRForBranchOutcome('/repo', 'topic', null, null, null, {
localGitExecOptions: { admissionTier: 'interactive' }
})
])
expect(mocks.resolve).toHaveBeenCalledTimes(1)
})
it('isolates heads, fallback hints, accounts, execution hosts, and credentials', async () => {
mocks.resolve.mockResolvedValue(outcome)
const requests = [
getPRForBranchOutcome('/repo', 'topic'),
getPRForBranchOutcome('/repo', 'topic', 12),
getPRForBranchOutcome('/repo', 'topic', null, 'ssh-1'),
getPRForBranchOutcome('/repo', 'topic', null, null, 12),
getPRForBranchOutcome('/repo', 'topic', null, null, null, { currentHeadOid: 'other-head' }),
getPRForBranchOutcome('/repo', 'topic', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
}),
getPRForBranchOutcome('/repo', 'topic', null, null, null, {
localGitExecOptions: { ghAccount: { host: 'github.com', user: 'other' } }
})
]
vi.stubEnv('GH_TOKEN', 'test-only-other-token')
requests.push(getPRForBranchOutcome('/repo', 'topic'))
await Promise.all(requests)
expect(mocks.resolve).toHaveBeenCalledTimes(8)
})
it('cleans up errors so later reads can recover', async () => {
mocks.resolve.mockRejectedValueOnce(new Error('network down')).mockResolvedValue(outcome)
const results = await Promise.all([
getPRForBranchOutcome('/repo', 'topic'),
getPRForBranchOutcome('/repo', 'topic')
])
expect(results.every((result) => result.kind === 'upstream-error')).toBe(true)
expect(mocks.resolve).toHaveBeenCalledTimes(1)
await expect(getPRForBranchOutcome('/repo', 'topic')).resolves.toEqual(outcome)
expect(mocks.resolve).toHaveBeenCalledTimes(2)
})
it.each([
{ label: 'native', connectionId: null, options: {} },
{ label: 'WSL', connectionId: null, options: { localGitExecOptions: { wslDistro: 'Ubuntu' } } },
{ label: 'SSH', connectionId: 'host / encoded', options: {} }
])(
'does not adopt a pre-creation no-PR read after $label invalidation',
async ({ connectionId, options }) => {
const created: PRRefreshOutcome = { kind: 'found', pr: makePR({ number: 13 }), fetchedAt: 2 }
const createdReview = hostedReviewInfoFromGitHubPRInfo(created.pr)
let finish: (value: PRRefreshOutcome) => void = () => {}
mocks.resolve
.mockImplementationOnce(
() =>
new Promise<PRRefreshOutcome>((resolve) => {
finish = resolve
})
)
.mockResolvedValue(created)
const executionHostId = getRepoExecutionHostId({ connectionId })
const identity = { repoPath: '/repo', executionHostId, branch: 'topic', ...options }
const lookup = async () => {
const result = await getPRForBranchOutcome(
'/repo',
'topic',
null,
connectionId,
null,
options
)
if (result.kind === 'upstream-error') {
throw new Error(result.message)
}
return result.kind === 'found' ? hostedReviewInfoFromGitHubPRInfo(result.pr) : null
}
const beforeCreation = withHostedReviewBranchCache(identity, { headOid: null }, lookup)
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1))
invalidateHostedReviewBranchCache('/repo', executionHostId)
const afterCreation = withHostedReviewBranchCache(identity, { headOid: null }, lookup)
try {
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(2))
await expect(afterCreation).resolves.toEqual(createdReview)
} finally {
finish(outcome)
await Promise.all([beforeCreation, afterCreation])
}
await expect(
withHostedReviewBranchCache(identity, { headOid: null }, lookup)
).resolves.toEqual(createdReview)
expect(mocks.resolve).toHaveBeenCalledTimes(2)
}
)
it('keeps native, WSL, and SSH pending reads scoped during invalidation', async () => {
const completions: ((value: PRRefreshOutcome) => void)[] = []
mocks.resolve.mockImplementation(
() =>
new Promise<PRRefreshOutcome>((resolve) => {
completions.push(resolve)
})
)
const native = () => getPRForBranchOutcome('/repo', 'topic')
const wsl = () =>
getPRForBranchOutcome('/repo', 'topic', null, null, null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
const ssh = () => getPRForBranchOutcome('/repo', 'topic', null, 'host / encoded')
const reads = [native(), wsl(), ssh()]
try {
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(3))
invalidateHostedReviewBranchCache('/other', 'local')
reads.push(native(), wsl(), ssh())
invalidateHostedReviewBranchCache(
'/repo',
getRepoExecutionHostId({ connectionId: 'host / encoded' })
)
reads.push(native(), wsl(), ssh())
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(4))
invalidateHostedReviewBranchCache('/repo', 'local')
reads.push(native(), wsl(), ssh())
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(6))
} finally {
for (const finish of completions) {
finish(outcome)
}
await Promise.all(reads)
}
})
})
+30 -1
View File
@@ -1,3 +1,6 @@
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
import { githubReadExecutionScope } from '../../github-read-execution-scope'
import type { PRCheckDetail } from '../../../../shared/github/check-types'
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
@@ -120,7 +123,7 @@ export async function getPRChecksViaRestFallback(
* Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses
* arrive in one cached request; suite-only approval blockers are included too.
*/
export async function getPRChecks(
async function readPRChecks(
repoPath: string,
prNumber: number,
headSha?: string,
@@ -231,3 +234,29 @@ export async function getPRChecks(
throw err
}
}
const checksReads: CoalescedProbes<PRCheckDetail[]> = new Map()
export function getPRChecks(
repoPath: string,
prNumber: number,
headSha?: string,
prRepo?: GitHubApiRepository | null,
options?: { noCache?: boolean },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRCheckDetail[]> {
const key = JSON.stringify([
repoPath,
prNumber,
headSha ?? null,
prRepo ?? null,
Boolean(options?.noCache),
connectionId ?? null,
connectionId ? getSshGitProviderGeneration(connectionId) : null,
githubReadExecutionScope(localGitOptions)
])
return runCoalescedProbe(checksReads, key, () =>
readPRChecks(repoPath, prNumber, headSha, prRepo, options, connectionId, localGitOptions)
)
}
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { createHash } from 'node:crypto'
import { githubReadExecutionScope as workItemSearchScope } from '../../github-read-execution-scope'
export { githubReadExecutionScope as workItemSearchScope } from '../../github-read-execution-scope'
import { BoundedMap } from '../../../../shared/bounded-map'
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
import { createGhRateLimitBlockedError } from '../../../git/gh-rate-limit-breaker'
@@ -48,22 +49,6 @@ const responses = new BoundedMap<string, { at: number; value: unknown }>({
sizeOf: (value, key) => Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(value))
})
export function workItemSearchScope(
options: GitHubRepoExecOptions,
environment: NodeJS.ProcessEnv = options.env ?? process.env
): string {
// gh wrappers and credential selection can depend on cwd and the inherited environment.
return createHash('sha256')
.update(
JSON.stringify([
options,
process.cwd(),
Object.entries(environment).sort(([a], [b]) => a.localeCompare(b))
])
)
.digest('hex')
}
export function requestWorkItemSearch<T>(request: SearchRequest): Promise<SearchResponse<T>> {
const environment = { ...(request.environment ?? request.options.env ?? process.env) }
const scope = workItemSearchScope(request.options, environment)
@@ -1,9 +1,19 @@
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
hostedReviewRepoScope,
scopeGeneration
} from '../../../source-control/hosted-review-scope-generations'
import { runCoalescedProbe, type CoalescedProbes } from '../../../git/coalesced-probe'
import { getSshGitProviderGeneration } from '../../../providers/ssh-git-dispatch'
import { githubReadExecutionScope } from '../../github-read-execution-scope'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-request-refresh-types'
import { acquire, release, ghRepoExecOptions, githubRepoContext } from '../../gh-utils'
import { hostedReviewLocalGitOptionArgs, githubPRStackExecutionScope } from './../github-exec-scope'
import type { GitHubPRBranchLookupOptions } from './pull-request-lookup-data'
import { prRefreshUpstreamError } from './../gh-error-predicates'
import { resolvePRForBranchOutcome } from './branch-lookup-resolution'
const reads: CoalescedProbes<PRRefreshOutcome> = new Map()
export async function getPRForBranchOutcome(
repoPath: string,
branch: string,
@@ -23,22 +33,41 @@ export async function getPRForBranchOutcome(
const ghOptions = ghRepoExecOptions(context)
const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions)
await acquire()
try {
return await resolvePRForBranchOutcome({
repoPath,
branchName,
linkedPRNumber,
connectionId,
fallbackPRNumber,
options,
localGitOptions,
ghOptions,
executionScope
})
} catch (err) {
return prRefreshUpstreamError(err)
} finally {
release()
}
const key = JSON.stringify([
executionScope,
connectionId ? getSshGitProviderGeneration(connectionId) : null,
repoPath,
scopeGeneration(hostedReviewRepoScope(repoPath, getRepoExecutionHostId({ connectionId }))),
branchName,
linkedPRNumber ?? null,
fallbackPRNumber ?? null,
options.acceptMergedFallbackPR ?? false,
options.currentHeadOid ?? null,
githubReadExecutionScope(ghOptions)
])
return runCoalescedProbe(
reads,
key,
async () => {
await acquire()
try {
return await resolvePRForBranchOutcome({
repoPath,
branchName,
linkedPRNumber,
connectionId,
fallbackPRNumber,
options,
localGitOptions,
ghOptions,
executionScope
})
} catch (err) {
return prRefreshUpstreamError(err)
} finally {
release()
}
},
2 * 60_000
)
}
+7 -1
View File
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import type { PRConflictSummary } from '../../../../shared/github/pull-request-types'
import { getPRConflictSummary } from '../../conflict-summary'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
@@ -59,7 +60,7 @@ export async function mergePR(
)
release()
concurrencySlotHeld = false
return await mergeGitHubPRStack({
const result = await mergeGitHubPRStack({
repository: ownerRepo,
prNumber,
method,
@@ -67,6 +68,10 @@ export async function mergePR(
headSha: restData.headRefOid,
ghOptions
})
if (result.ok) {
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
}
return result
}
const mergeBlocker = await getPRMergeBlocker(
repoPath,
@@ -89,6 +94,7 @@ export async function mergePR(
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import type { GitHubPRMergeMethod } from '../../../../shared/github/pull-request-types'
import {
ghExecFileAsync,
@@ -165,13 +166,17 @@ export async function setPRAutoMerge(
await acquire()
try {
if (enabled) {
return await enablePRAutoMerge(
const result = await enablePRAutoMerge(
prNumber,
method,
ownerRepo,
ghOptions,
githubPRStackExecutionScope(connectionId, localGitOptions)
)
if (result.ok) {
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
}
return result
}
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
if (ownerRepo) {
@@ -181,6 +186,7 @@ export async function setPRAutoMerge(
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import {
ghExecFileAsync,
acquire,
@@ -35,6 +36,7 @@ export async function updatePRTitle(
await ghExecFileAsync(args, {
...ghOptions
})
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return true
} catch (err) {
console.warn('updatePRTitle failed:', err)
@@ -89,6 +91,7 @@ export async function updatePRDetails(
],
ghOptions
)
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import {
acquire,
classifyPullRequestUpdateError,
@@ -30,6 +31,7 @@ export async function markPRReadyForReview(
['pr', 'ready', String(prNumber), '--repo', `${ownerRepo.owner}/${ownerRepo.repo}`],
ghOptions
)
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
export async function requestPRReviewers(
@@ -31,6 +32,7 @@ export async function requestPRReviewers(
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -72,6 +74,7 @@ export async function removePRReviewers(
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -1,3 +1,4 @@
import { invalidateReviewLookupsAfterPRMutation } from '../../pr-mutation-review-invalidation'
import type { GitHubPullRequestStateUpdate } from '../../../../shared/issue-mutation-types'
import {
ghExecFileAsync,
@@ -35,6 +36,7 @@ export async function updatePRState(
...ghOptions
}
)
invalidateReviewLookupsAfterPRMutation(repoPath, connectionId)
return { ok: true }
} catch (err) {
const message =
@@ -330,6 +330,29 @@ describe('isGitHubHostAuthenticated', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('retains a known authenticated host for 15 minutes', async () => {
mockHostAuthenticated()
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
await vi.advanceTimersByTimeAsync(14 * 60_000)
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('rechecks host authentication after the credential environment changes', async () => {
mockHostAuthenticated()
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
vi.stubEnv('GH_ENTERPRISE_TOKEN', 'test-only-changed-token')
try {
await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.unstubAllEnvs()
}
})
it('coalesces concurrent probes for the same runtime and host', async () => {
let finishProbe: (() => void) | undefined
ghExecFileAsyncMock.mockImplementation(
@@ -1,3 +1,4 @@
import { githubReadExecutionScope } from './github-read-execution-scope'
import { ghExecFileAsync } from '../git/runner'
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import {
@@ -31,7 +32,8 @@ export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
// host `gh auth status` reports as logged-in is definitively a GitHub host. This
// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a
// GHES remote is not left to fall through to Gitea (#8312).
const HOST_AUTH_TTL_MS = 60_000
const HOST_AUTH_TTL_MS = 15 * 60_000
const HOST_AUTH_MISS_TTL_MS = 60_000
const HOST_AUTH_CACHE_MAX_ENTRIES = 512
type HostAuthCacheEntry = {
@@ -145,7 +147,7 @@ async function resolveAuthenticatedGitHubHost(
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null | undefined> {
const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase()
const cacheKey = `${runtimeCacheKey(repoPath, connectionId, localGitOptions.wslDistro)}\0${normalizedHost}`
const cacheKey = `${runtimeCacheKey(repoPath, connectionId, localGitOptions.wslDistro)}\0${normalizedHost}\0${githubReadExecutionScope({ ghAccount: localGitOptions.ghAccount })}`
const now = Date.now()
pruneHostAuthCache(now)
const cached = hostAuthCache.get(cacheKey)
@@ -179,7 +181,7 @@ async function resolveAuthenticatedGitHubHost(
}
hostAuthCache.set(cacheKey, {
authenticatedHost,
expiresAt: Date.now() + HOST_AUTH_TTL_MS
expiresAt: Date.now() + (authenticatedHost ? HOST_AUTH_TTL_MS : HOST_AUTH_MISS_TTL_MS)
})
pruneHostAuthCache(Date.now())
return authenticatedHost
@@ -0,0 +1,19 @@
import { createHash } from 'node:crypto'
import type { GitHubRepoExecOptions } from './github-api-repository'
export function githubReadExecutionScope(
options: GitHubRepoExecOptions,
environment: NodeJS.ProcessEnv = options.env ?? process.env
): string {
const { admissionTier: _admissionTier, ...executionOptions } = options
// gh wrappers and credential selection can depend on cwd and the inherited environment.
return createHash('sha256')
.update(
JSON.stringify([
executionOptions,
process.cwd(),
Object.entries(environment).sort(([a], [b]) => a.localeCompare(b))
])
)
.digest('hex')
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
import type { Repo } from '../../shared/repo-types'
import type { GitHubRepoContext, LocalGitExecOptions } from './github-repository-identity'
import type { Store } from '../persistence'
import { __resetHostedReviewBranchCacheForTests } from '../source-control/hosted-review-branch-cache'
const mocks = vi.hoisted(() => ({
resolve: vi.fn(),
exec: vi.fn(),
handlers: new Map<string, (event: unknown, args: unknown) => Promise<unknown>>()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => Promise<unknown>) =>
mocks.handlers.set(channel, handler)
}
}))
vi.mock('./gh-utils', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
ghExecFileAsync: mocks.exec,
classifyPullRequestUpdateError: (message: string) => ({ message }),
classifyGhError: (message: string) => ({ message }),
githubRepoContext: (
repoPath: string,
connectionId: string | null,
options: LocalGitExecOptions
) => ({ repoPath, connectionId, ...options }),
ghRepoExecOptions: (context: GitHubRepoContext) => context
}))
vi.mock('./github-api-repository', () => ({
resolveGitHubRepoExecution: vi.fn().mockResolvedValue({
ownerRepo: { owner: 'acme', repo: 'widgets' },
ghOptions: {}
})
}))
vi.mock('./client/lookup/pr-number-lookup', () => ({
getRestPRByNumber: vi.fn().mockResolvedValue({ stack: null }),
getPRByNumber: vi.fn().mockResolvedValue(null)
}))
vi.mock('./client/lookup/branch-lookup-resolution', () => ({
resolvePRForBranchOutcome: mocks.resolve
}))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProviderGeneration: () => 1 }))
vi.mock('../ipc/github-work-item-mutation-events', () => ({
broadcastGitHubWorkItemMutation: vi.fn()
}))
vi.mock('../project-runtime-git-options', () => ({ getLocalProjectGhExecOptions: () => ({}) }))
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
import { registerGitHubPRMutationHandlers } from '../ipc/github-pr-mutation-handlers'
import { RuntimeGitHubReviewMutationCommands } from '../runtime/runtime-github-review-mutation-commands'
const local: Repo = { id: 'local', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }
const ssh: Repo = { ...local, id: 'ssh', connectionId: 'ssh-1' }
const otherSsh: Repo = { ...local, id: 'other-ssh', connectionId: 'ssh-2' }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: handlers only call getRepos.
const store = { getRepos: () => [local, ssh, otherSsh] } as unknown as Store
registerGitHubPRMutationHandlers(store)
const runtime = new RuntimeGitHubReviewMutationCommands({
resolveRepo: async (selector) => [local, ssh, otherSsh].find((repo) => repo.id === selector)!,
getLocalGitArgs: () => []
})
function ipc(channel: string, repo: Repo, args: Record<string, unknown>): Promise<unknown> {
return mocks.handlers.get(channel)!(
{ sender: { id: 1 } },
{ repoPath: repo.path, repoId: repo.id, ...args }
)
}
const mutations: { label: string; run: (repo: Repo) => Promise<unknown> }[] = [
{ label: 'IPC merge', run: (repo) => ipc('gh:mergePR', repo, { prNumber: 7 }) },
{
label: 'IPC close',
run: (repo) => ipc('gh:updatePRState', repo, { prNumber: 7, updates: { state: 'closed' } })
},
{ label: 'IPC ready', run: (repo) => ipc('gh:markPRReadyForReview', repo, { prNumber: 7 }) },
{
label: 'IPC auto-merge',
run: (repo) => ipc('gh:setPRAutoMerge', repo, { prNumber: 7, enabled: false })
},
{ label: 'IPC title', run: (repo) => ipc('gh:updatePRTitle', repo, { prNumber: 7, title: 'T' }) },
{
label: 'IPC reviewers',
run: (repo) => ipc('gh:requestPRReviewers', repo, { prNumber: 7, reviewers: ['octo'] })
},
{ label: 'RPC merge', run: (repo) => runtime.mergeRepoPR(repo.id, 7) },
{
label: 'RPC reopen',
run: (repo) => runtime.updateRepoPRState(repo.id, 7, { state: 'open' })
},
{ label: 'RPC ready', run: (repo) => runtime.markRepoPRReadyForReview(repo.id, 7) },
{ label: 'RPC details', run: (repo) => runtime.updateRepoPRDetails(repo.id, 7, { body: 'B' }) },
{ label: 'RPC reviewers', run: (repo) => runtime.removeRepoPRReviewers(repo.id, 7, ['octo']) }
]
let finishHeld: (value: PRRefreshOutcome) => void = () => {}
const stale: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 1 }
const fresh: PRRefreshOutcome = { kind: 'no-pr', fetchedAt: 2 }
function holdLookup(repo: Repo): Promise<PRRefreshOutcome> {
mocks.resolve.mockImplementationOnce(
() =>
new Promise<PRRefreshOutcome>((resolve) => {
finishHeld = resolve
})
)
return getPRForBranchOutcome(repo.path, 'topic', null, repo.connectionId ?? null)
}
beforeEach(() => {
vi.clearAllMocks()
__resetHostedReviewBranchCacheForTests()
mocks.exec.mockResolvedValue({ stdout: '', stderr: '' })
mocks.resolve.mockResolvedValue(fresh)
})
describe('PR mutation review-lookup fencing', () => {
it.each(mutations)('$label starts a fresh lookup after success', async ({ run }) => {
for (const repo of [local, ssh]) {
mocks.resolve.mockClear()
const held = holdLookup(repo)
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1))
expect([true, { ok: true }]).toContainEqual(await run(repo))
const after = getPRForBranchOutcome(repo.path, 'topic', null, repo.connectionId ?? null)
finishHeld(stale)
expect(await Promise.all([held, after])).toEqual([stale, fresh])
expect(mocks.resolve).toHaveBeenCalledTimes(2)
}
})
it.each(mutations)('$label keeps sharing the in-flight lookup after failure', async ({ run }) => {
mocks.exec.mockRejectedValue(new Error('denied'))
const held = holdLookup(local)
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1))
await run(local)
const after = getPRForBranchOutcome(local.path, 'topic', null, null)
finishHeld(stale)
expect(await Promise.all([held, after])).toEqual([stale, stale])
expect(mocks.resolve).toHaveBeenCalledTimes(1)
})
it('fences only the host that ran the mutation', async () => {
const heldLocal = holdLookup(local)
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(1))
const finishLocal = finishHeld
const heldOther = holdLookup(otherSsh)
await vi.waitFor(() => expect(mocks.resolve).toHaveBeenCalledTimes(2))
const finishOther = finishHeld
await runtime.mergeRepoPR(ssh.id, 7)
await ipc('gh:mergePR', ssh, { prNumber: 7 })
const afterLocal = getPRForBranchOutcome(local.path, 'topic', null, null)
const afterOther = getPRForBranchOutcome(otherSsh.path, 'topic', null, 'ssh-2')
finishLocal(stale)
finishOther(stale)
expect(await Promise.all([heldLocal, afterLocal, heldOther, afterOther])).toEqual([
stale,
stale,
stale,
stale
])
expect(mocks.resolve).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,10 @@
import { getRepoExecutionHostId } from '../../shared/execution-host'
import { invalidateHostedReviewBranchCache } from '../source-control/hosted-review-branch-cache'
// A post-mutation refresh must not join an older lookup or reuse its cached answer.
export function invalidateReviewLookupsAfterPRMutation(
repoPath: string,
connectionId: string | null | undefined
): void {
invalidateHostedReviewBranchCache(repoPath, getRepoExecutionHostId({ connectionId }))
}
+44 -28
View File
@@ -1,3 +1,4 @@
import { reviewRefreshIntervalMs } from '../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
@@ -6,7 +7,6 @@ import type {
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import type { GitHubPRBranchLookupOptions } from './client'
import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing'
export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500
export const POST_PUSH_DELAY_MS = 2_500
@@ -106,14 +106,18 @@ export function shouldSkipFresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason
): boolean {
if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) {
if (
bypassesFreshnessDelay(reason) ||
candidate.cachedFetchedAt == null ||
hasStaleHead(candidate)
) {
return false
}
return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate)
}
export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null {
return candidate.cachedFetchedAt == null
return candidate.cachedFetchedAt == null || hasStaleHead(candidate)
? null
: candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate)
}
@@ -144,6 +148,7 @@ export function visibleCandidateAfterOutcome(
return {
...candidate,
cachedFetchedAt: outcome.fetchedAt,
cachedHeadOid: candidate.currentHeadOid ?? null,
cachedHasPR: outcome.kind === 'found',
cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null,
cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null,
@@ -152,31 +157,23 @@ export function visibleCandidateAfterOutcome(
}
}
function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number {
if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') {
return 30 * 60_000
}
if (candidate.cachedHasPR === false) {
return NO_REVIEW_REFRESH_INTERVAL_MS
}
if (
candidate.cachedHasPR === true &&
candidate.cachedPRState === 'open' &&
candidate.cachedMergeable === 'UNKNOWN' &&
!hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus)
) {
return 10_000
}
if (candidate.cachedChecksStatus === 'success') {
return 10 * 60_000
}
if (candidate.cachedChecksStatus === 'failure') {
return 3 * 60_000
}
if (candidate.cachedChecksStatus === 'pending') {
return 90_000
}
return 60_000
export function hasStaleHead(candidate: GitHubPRRefreshCandidate): boolean {
return (
candidate.currentHeadOid != null &&
candidate.cachedHeadOid != null &&
candidate.currentHeadOid !== candidate.cachedHeadOid
)
}
export function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number {
return (
reviewRefreshIntervalMs({
state: candidate.cachedPRState,
checksStatus: candidate.cachedChecksStatus,
hasReview: candidate.cachedHasPR,
selected: candidate.isSelected
}) ?? Number.POSITIVE_INFINITY
)
}
function hasResolvedMergeStateStatus(status: string | null | undefined): boolean {
@@ -191,3 +188,22 @@ export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean
!hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus)
)
}
export function sameAliasRequestIdentity(
left: GitHubPRRefreshAlias,
right: GitHubPRRefreshAlias
): boolean {
return (
left.cacheKey === right.cacheKey &&
left.repoId === right.repoId &&
left.repoPath === right.repoPath &&
left.branch === right.branch &&
left.worktreeId === right.worktreeId &&
left.connectionId === right.connectionId &&
left.executionHostId === right.executionHostId &&
left.linkedPRNumber === right.linkedPRNumber &&
left.fallbackPRNumber === right.fallbackPRNumber &&
left.fallbackPRSource === right.fallbackPRSource &&
left.currentHeadOid === right.currentHeadOid
)
}
@@ -326,7 +326,7 @@ describe('pr-refresh-coordinator', () => {
1
)
await vi.runOnlyPendingTimersAsync()
await vi.advanceTimersByTimeAsync(90_000)
await vi.advanceTimersByTimeAsync(120_000)
const outcomeEvents = sendMock.mock.calls
.map(([, event]) => event)
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks))
vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks))
vi.mock('./github-api-repository', () =>
moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks))
const { getPRForBranchOutcomeMock, getOriginGitHubApiRepositoryMock, sendMock } = coordinatorMocks
function found(state: 'open' | 'closed' | 'merged'): PRRefreshOutcome {
return { kind: 'found', pr: makePR({ state, checksStatus: 'success' }), fetchedAt: Date.now() }
}
describe('main refresh completion ordering', () => {
beforeEach(() => moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks))
afterEach(() => vi.useRealTimers())
it.each([
{ older: 'open', newer: 'merged' },
{ older: 'closed', newer: 'open' }
] as const)(
'ignores expired coalesced $older reads after a newer $newer result',
async ({ older, newer }) => {
const reads: CoalescedProbes<PRRefreshOutcome> = new Map()
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
const provider = vi.fn<() => Promise<PRRefreshOutcome>>()
provider
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => found(newer))
getPRForBranchOutcomeMock.mockImplementation(() =>
runCoalescedProbe(reads, 'same-provider-lookup', provider, 120_000)
)
const {
reportVisiblePRRefreshCandidates,
refreshPRNow,
setPRRefreshOutcomeObserver,
_getPRRefreshQueueSizeForTests
} = await import('./pr-refresh-coordinator')
const observer = vi.fn()
setPRRefreshOutcomeObserver(observer)
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(120_000)
await refreshPRNow(candidate)
expect(provider).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_000)
finishOlder(found(older))
await vi.advanceTimersByTimeAsync(0)
expect(observer).toHaveBeenCalledTimes(1)
expect(observer.mock.calls[0]?.[1]).toMatchObject({ pr: { state: newer } })
expect(_getPRRefreshQueueSizeForTests()).toBe(newer === 'merged' ? 0 : 1)
await vi.advanceTimersByTimeAsync(59_000)
expect(provider).toHaveBeenCalledTimes(newer === 'merged' ? 2 : 3)
if (newer === 'merged') {
await vi.advanceTimersByTimeAsync(900_000)
expect(provider).toHaveBeenCalledTimes(2)
}
}
)
it.each(['network', 'rate_limited'] as const)(
'ignores a late %s error after a newer settled result',
async (errorType) => {
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => found('merged'))
const {
reportVisiblePRRefreshCandidates,
refreshPRNow,
_getPRRefreshErrorBackoffCountForTests,
_getPRRefreshQueueSizeForTests
} = await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishOlder({
kind: 'upstream-error',
errorType,
message: 'old failure',
fetchedAt: Date.now(),
retryDisabledUntil: Date.now() + 300_000
})
await vi.advanceTimersByTimeAsync(0)
expect(_getPRRefreshErrorBackoffCountForTests()).toBe(0)
expect(_getPRRefreshQueueSizeForTests()).toBe(0)
await refreshPRNow(candidate)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
}
)
it('fences older direct refresh completions without suppressing their caller result', async () => {
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => found('merged'))
const { refreshPRNow, setPRRefreshOutcomeObserver } = await import('./pr-refresh-coordinator')
const observer = vi.fn()
setPRRefreshOutcomeObserver(observer)
const candidate = makeCandidate()
const older = refreshPRNow(candidate)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishOlder(found('closed'))
await expect(older).resolves.toMatchObject({ pr: { state: 'closed' } })
expect(observer).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls.filter(([, event]) => event.outcome)).toHaveLength(2)
})
it('preserves a newer failure gate when an older successful lookup completes', async () => {
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'wait',
fetchedAt: Date.now(),
retryDisabledUntil: Date.now() + 300_000
}))
const { reportVisiblePRRefreshCandidates, refreshPRNow } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishOlder(found('open'))
await vi.advanceTimersByTimeAsync(60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await expect(refreshPRNow(candidate)).resolves.toMatchObject({ errorType: 'rate_limited' })
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('keeps a shared provider read coalesced while only the newest caller adopts it', async () => {
const reads: CoalescedProbes<PRRefreshOutcome> = new Map()
let finish: (outcome: PRRefreshOutcome) => void = () => {}
const provider = vi.fn(() => new Promise<PRRefreshOutcome>((resolve) => (finish = resolve)))
getPRForBranchOutcomeMock.mockImplementation(() =>
runCoalescedProbe(reads, 'same-provider-lookup', provider, 120_000)
)
const { reportVisiblePRRefreshCandidates, refreshPRNow, setPRRefreshOutcomeObserver } =
await import('./pr-refresh-coordinator')
const observer = vi.fn()
setPRRefreshOutcomeObserver(observer)
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
const manual = refreshPRNow(candidate)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
expect(provider).toHaveBeenCalledTimes(1)
finish(found('merged'))
await manual
await vi.advanceTimersByTimeAsync(900_000)
expect(observer).toHaveBeenCalledTimes(1)
expect(provider).toHaveBeenCalledTimes(1)
})
it('does not start an older queued read when admission finishes after a newer direct read', async () => {
let finishAdmission: () => void = () => {}
getOriginGitHubApiRepositoryMock.mockImplementationOnce(
() =>
new Promise<null>((resolve) => {
finishAdmission = () => resolve(null)
})
)
getPRForBranchOutcomeMock.mockImplementation(async () => found('merged'))
const { reportVisiblePRRefreshCandidates, refreshPRNow } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishAdmission()
await vi.advanceTimersByTimeAsync(900_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks))
vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks))
vi.mock('./github-api-repository', () =>
moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks))
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
import { PRRefreshQueue } from './pr-refresh-queue'
const { getPRForBranchOutcomeMock } = coordinatorMocks
describe('failed main refresh pacing', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'network',
message: 'offline',
fetchedAt: Date.now()
}))
})
afterEach(() => vi.useRealTimers())
it.each([
{ cachedPRState: 'open', cachedHasPR: true, isSelected: true, interval: 60_000 },
{ cachedPRState: 'open', cachedHasPR: true, isSelected: false, interval: 120_000 },
{ cachedPRState: 'closed', cachedHasPR: true, isSelected: true, interval: 900_000 },
{ cachedPRState: null, cachedHasPR: false, isSelected: false, interval: 900_000 },
{ cachedPRState: null, cachedHasPR: false, isSelected: true, interval: 60_000 },
{ cachedPRState: 'merged', cachedHasPR: true, isSelected: false, interval: 60_000 }
] as const)(
'keeps $cachedPRState selected=$isSelected retries behind $interval ms',
async ({ interval, ...state }) => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates(
[makeCandidate({ ...state, cachedChecksStatus: 'success' })],
1,
1
)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(interval - 1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(Math.max(interval, 120_000) - 1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
}
)
it('panel foreground exposure cannot bypass a failed lookup deadline', async () => {
const { reportVisiblePRRefreshCandidates, enqueuePRRefresh } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh(candidate, 'visible', 80, 1)
await vi.advanceTimersByTimeAsync(59_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('keeps request ownership bounded and rejects evicted completions', () => {
const queue = new PRRefreshQueue(() => {})
for (let sequence = 1; sequence <= 1_001; sequence++) {
queue.noteRequestStarted(`branch-${sequence}`, sequence)
}
expect(queue.ownsRequest('branch-1', 1)).toBe(false)
expect(queue.ownsRequest('branch-1001', 1_001)).toBe(true)
queue.protectBackgroundUntil('branch-1001', Date.now() + 300_000)
expect(queue.ownsRequest('branch-1001', 1_001)).toBe(true)
queue.noteRequestStarted('branch-1001', 1_002)
expect(queue.ownsRequest('branch-1001', 1_001)).toBe(false)
expect(queue.ownsRequest('branch-1001', 1_002)).toBe(true)
})
})
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks))
vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks))
vi.mock('./github-api-repository', () =>
moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
describe('foreground refresh pacing', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
}))
})
afterEach(() => {
vi.useRealTimers()
})
it('limits five selected admissions to three starts until 30 seconds, including deselected queued rows', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
const cards = Array.from({ length: 5 }, (_, index) =>
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`,
cachedPRState: 'open',
cachedChecksStatus: 'success',
cachedHasPR: true,
cachedFetchedAt: Date.now()
})
)
for (let index = 0; index < cards.length; index += 1) {
const candidates = cards.map((card, cardIndex) => ({
...card,
cachedFetchedAt: cardIndex === index ? null : card.cachedFetchedAt,
isSelected: cardIndex === index
}))
reportVisiblePRRefreshCandidates(candidates, index + 1, 1)
enqueuePRRefresh(candidates[index], 'visible', 80, 1)
await vi.advanceTimersByTimeAsync(0)
}
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(29_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual([
'feature/0',
'feature/1',
'feature/2',
'feature/4',
'feature/3'
])
})
it('uses the list budget for ordinary priority-80 periodic follow-ups', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
coordinatorMocks.getAllWebContentsMock.mockReturnValue(
Array.from({ length: 5 }, (_, index) => ({ id: index + 1, isDestroyed: () => false }))
)
const candidates = Array.from({ length: 5 }, (_, index) =>
makeCandidate({
cacheKey: `/repo::feature/${index}`,
branch: `feature/${index}`,
worktreeId: `wt-${index}`,
isSelected: true
})
)
// Independent windows can expose different selected rows in the same runtime.
for (const [index, candidate] of candidates.entries()) {
reportVisiblePRRefreshCandidates([candidate], 1, index + 1)
enqueuePRRefresh(candidate, 'visible', 80, index + 1)
}
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(9_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(7)
})
it('keeps the explicit manual mergeability follow-up outside the foreground burst budget', async () => {
const { enqueuePRRefresh, refreshPRNow, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
for (let index = 0; index < 3; index += 1) {
enqueuePRRefresh(
makeCandidate({ branch: `active/${index}`, cacheKey: `/repo::active/${index}` }),
'active',
80,
1
)
}
await vi.advanceTimersByTimeAsync(0)
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'found',
pr: makePR({ mergeable: 'UNKNOWN' }),
fetchedAt: Date.now()
})
await refreshPRNow(candidate)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(2_500)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
})
})
@@ -0,0 +1,346 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks))
vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks))
vi.mock('./github-api-repository', () =>
moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { getAllWebContentsMock, getPRForBranchOutcomeMock, sendMock } = coordinatorMocks
function twoWindows(): void {
getAllWebContentsMock.mockReturnValue([1, 2].map((id) => ({ id, isDestroyed: () => false })))
}
function found(state: 'open' | 'merged' = 'open'): PRRefreshOutcome {
return { kind: 'found', pr: makePR({ state, checksStatus: 'success' }), fetchedAt: Date.now() }
}
describe('visibility-aware coordinator scheduling', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => found())
})
afterEach(() => {
vi.useRealTimers()
})
it.each([
[true, 60_000],
[false, 120_000]
] as const)('refreshes selected=%s at %s ms', async (isSelected, interval) => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate({ isSelected })], 1, 1)
await vi.advanceTimersByTimeAsync(interval - 1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('uses the strongest tier across windows and slows down when its owner hides', async () => {
twoWindows()
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(20_000)
reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 2)
await vi.advanceTimersByTimeAsync(40_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
reportVisiblePRRefreshCandidates([], 2, 2)
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
reportVisiblePRRefreshCandidates([], 2, 1)
await vi.advanceTimersByTimeAsync(600_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
})
it('re-times a fresh cached follow-up when selected changes', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const candidate = makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'open'
})
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(20_000)
reportVisiblePRRefreshCandidates([{ ...candidate, isSelected: true }], 2, 1)
await vi.advanceTimersByTimeAsync(39_999)
expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
it('preserves a selection change during an in-flight lookup', async () => {
twoWindows()
let resolve: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock.mockImplementationOnce(
() =>
new Promise<PRRefreshOutcome>((done) => {
resolve = done
})
)
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(0)
reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 2)
resolve(found())
await vi.advanceTimersByTimeAsync(59_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('does not resurrect periodic work after every window hides during a lookup', async () => {
let resolve: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock.mockImplementationOnce(
() =>
new Promise<PRRefreshOutcome>((done) => {
resolve = done
})
)
const { reportVisiblePRRefreshCandidates, _getPRRefreshQueueSizeForTests } =
await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(0)
reportVisiblePRRefreshCandidates([], 2, 1)
resolve(found())
await vi.advanceTimersByTimeAsync(600_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
expect(_getPRRefreshQueueSizeForTests()).toBe(0)
})
it('allows one settled-merged lookup on re-exposure after cooldown', async () => {
getPRForBranchOutcomeMock.mockImplementation(async () => found('merged'))
const { reportVisiblePRRefreshCandidates, _getPRRefreshQueueSizeForTests } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
reportVisiblePRRefreshCandidates([], 2, 1)
const merged = makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'merged',
cachedChecksStatus: 'neutral'
})
await vi.advanceTimersByTimeAsync(9_999)
reportVisiblePRRefreshCandidates([merged], 3, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
reportVisiblePRRefreshCandidates([], 4, 1)
await vi.advanceTimersByTimeAsync(1)
reportVisiblePRRefreshCandidates([merged], 5, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
reportVisiblePRRefreshCandidates([merged], 6, 1)
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
expect(_getPRRefreshQueueSizeForTests()).toBe(0)
})
it.each(['merged', 'closed'] as const)(
'discovers new work when a cached %s head changes',
async (cachedPRState) => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState,
cachedChecksStatus: 'success',
cachedHeadOid: 'old',
currentHeadOid: 'new'
})
],
1,
1
)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
expect(getPRForBranchOutcomeMock.mock.calls[0][5]?.currentHeadOid).toBe('new')
await vi.advanceTimersByTimeAsync(120_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
}
)
it('retains stale PR state and error backoff across reports and hiding', async () => {
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'network',
message: 'offline',
fetchedAt: Date.now()
}))
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const candidate = makeCandidate({
cachedHasPR: true,
cachedPRState: 'open',
cachedHeadOid: 'old',
currentHeadOid: 'new'
})
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
reportVisiblePRRefreshCandidates([], 2, 1)
reportVisiblePRRefreshCandidates([candidate], 3, 1)
reportVisiblePRRefreshCandidates([{ ...candidate, isSelected: true }], 4, 1)
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
reportVisiblePRRefreshCandidates([candidate], 5, 1)
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
expect(sendMock.mock.calls.map(([, event]) => event.outcome?.kind).filter(Boolean)).toEqual([
'upstream-error',
'upstream-error',
'upstream-error'
])
})
it('pulls a closed follow-up forward when HEAD changes while it is queued', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const candidate = makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'closed',
cachedHeadOid: 'old',
currentHeadOid: 'old'
})
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(20_000)
reportVisiblePRRefreshCandidates([{ ...candidate, currentHeadOid: 'new' }], 2, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
expect(getPRForBranchOutcomeMock.mock.calls[0][5]?.currentHeadOid).toBe('new')
})
it('keeps an in-flight head change behind the lookup failure backoff', async () => {
let resolve: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock.mockImplementationOnce(
() =>
new Promise<PRRefreshOutcome>((done) => {
resolve = done
})
)
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ currentHeadOid: 'old' })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
reportVisiblePRRefreshCandidates([{ ...candidate, currentHeadOid: 'new' }], 2, 1)
resolve({
kind: 'upstream-error',
errorType: 'network',
message: 'offline',
fetchedAt: Date.now()
})
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
expect(getPRForBranchOutcomeMock.mock.calls[1][5]?.currentHeadOid).toBe('new')
})
it.each([true, false])(
'preserves a rate-limit gate across selected reports (hidden=%s)',
async (hidden) => {
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'wait',
fetchedAt: Date.now(),
retryDisabledUntil: 301_000
}))
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
if (hidden) {
reportVisiblePRRefreshCandidates([], 2, 1)
}
await vi.advanceTimersByTimeAsync(10_000)
reportVisiblePRRefreshCandidates([{ ...candidate, isSelected: true }], 3, 1)
await vi.advanceTimersByTimeAsync(289_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
}
)
it('discovers a new PR on settled-merged re-exposure and resumes selected polling', async () => {
getPRForBranchOutcomeMock
.mockImplementationOnce(async () => found('merged'))
.mockImplementation(async () => ({
kind: 'found',
pr: makePR({ number: 13 }),
fetchedAt: Date.now()
}))
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate({ isSelected: true })], 1, 1)
await vi.advanceTimersByTimeAsync(0)
const merged = makeCandidate({
isSelected: true,
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'merged',
cachedChecksStatus: 'success'
})
reportVisiblePRRefreshCandidates([], 2, 1)
await vi.advanceTimersByTimeAsync(120_000)
reportVisiblePRRefreshCandidates([merged], 3, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
expect(
sendMock.mock.calls.map(([, event]) => event.outcome?.pr?.number).filter(Boolean)
).toEqual([12, 13])
await vi.advanceTimersByTimeAsync(59_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
})
it.each([
[true, 60_000],
[false, 900_000]
] as const)('discovers missing PRs at selected=%s cadence', async (isSelected, interval) => {
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'no-pr',
fetchedAt: Date.now()
}))
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
reportVisiblePRRefreshCandidates([makeCandidate({ isSelected })], 1, 1)
await vi.advanceTimersByTimeAsync(interval - 1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
it('foreground exposure bypasses list spacing once while preserving cooldown and periodic budget', async () => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => found())
const { reportVisiblePRRefreshCandidates, enqueuePRRefresh } =
await import('./pr-refresh-coordinator')
const other = makeCandidate({ branch: 'other', cacheKey: 'other', worktreeId: 'other' })
const selected = makeCandidate({ branch: 'selected', cacheKey: 'selected', isSelected: true })
reportVisiblePRRefreshCandidates([other, selected], 1, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
enqueuePRRefresh(selected, 'visible', 80, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
enqueuePRRefresh(selected, 'visible', 80, 1)
await vi.advanceTimersByTimeAsync(9_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
@@ -81,7 +81,7 @@ describe('pr-refresh-coordinator', () => {
expect(_getPRRefreshErrorBackoffCountForTests()).toBe(0)
})
it('retries visible PRs with unknown mergeability before the success-check interval', async () => {
it('uses the regular cadence for automatic unknown mergeability refreshes', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
@@ -97,7 +97,7 @@ describe('pr-refresh-coordinator', () => {
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(9_999)
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
@@ -149,4 +149,114 @@ describe('pr-refresh-coordinator', () => {
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
})
it('stops polling settled merged PRs, including repeated visibility reports', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found',
pr: makePR({ checksStatus: 'success', state: 'merged' }),
fetchedAt: Date.now()
}))
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(0)
const merged = makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'merged',
cachedChecksStatus: 'success'
})
await vi.advanceTimersByTimeAsync(31 * 60_000)
reportVisiblePRRefreshCandidates([merged], 2, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000 - 31 * 60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
it.each([
['closed', 'success', 15 * 60_000],
['merged', 'pending', 60_000]
] as const)('keeps watching %s PRs with %s checks', async (state, checksStatus, interval) => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found',
pr: makePR({ checksStatus, state }),
fetchedAt: Date.now()
}))
reportVisiblePRRefreshCandidates([makeCandidate()], 1, 1)
await vi.advanceTimersByTimeAsync(interval)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('checks merged reviews with missing cached checks again after 60 seconds', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'found',
pr: makePR({ state: 'merged', checksStatus: 'success' }),
fetchedAt: Date.now()
})
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'merged',
cachedChecksStatus: null
})
],
1,
1
)
await vi.advanceTimersByTimeAsync(59_999)
expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
it('resumes visible polling when a manual refresh discovers a live PR', async () => {
const { refreshPRNow, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockImplementationOnce(async () => ({
kind: 'found',
pr: makePR({ state: 'merged', checksStatus: 'success' }),
fetchedAt: Date.now()
}))
.mockImplementation(async () => ({
kind: 'found',
pr: makePR({ number: 13, checksStatus: 'pending' }),
fetchedAt: Date.now()
}))
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(120_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(3)
})
it.each(['active', 'manual', 'post-push'] as const)(
'refreshes merged PRs on %s',
async (reason) => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'found',
pr: makePR(),
fetchedAt: Date.now()
})
enqueuePRRefresh(
makeCandidate({
cachedFetchedAt: Date.now(),
cachedPRState: 'merged',
cachedChecksStatus: 'success'
}),
reason,
80,
1
)
await vi.advanceTimersByTimeAsync(2_500)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
}
)
})
+29 -2
View File
@@ -51,6 +51,8 @@ export function clearVisiblePRRefreshWindow(windowId: number): void {
pacing.clearActiveBurstWindow(windowId)
if (hadVisibleRefreshes) {
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
drainer.schedule()
}
}
@@ -75,6 +77,11 @@ export function enqueuePRRefresh(
}
const enqueued = queue.enqueue(candidate, reason, priority, windowId)
const pending = queue.get(key)
if (pending && reason === 'visible' && candidate.isSelected && priority >= 80) {
// Foreground exposure keeps retry gates while bypassing the list budget once.
pending.bypassBackgroundBudget = true
}
events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason)
if (shouldBroadcastQueued(reason, enqueued.dueAt)) {
events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' })
@@ -87,13 +94,27 @@ export function reportVisiblePRRefreshCandidates(
generation: number,
windowId: number
): void {
const newlyExposed = new Set(candidates.map(refreshKey).filter((key) => !visibility.has(key)))
if (!visibility.report(candidates, generation, windowId)) {
return
}
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
for (const candidate of candidates) {
enqueuePRRefresh(candidate, 'visible', 40, windowId)
const key = refreshKey(candidate)
if (validateCandidate(candidate)) {
enqueuePRRefresh(candidate, 'visible', 40, windowId)
continue
}
queue.enqueue(
visibility.candidate(key, candidate),
'visible',
40,
windowId,
newlyExposed.has(key)
)
}
drainer.schedule()
}
export function _getVisiblePRRefreshWindowCountForTests(): number {
@@ -138,6 +159,7 @@ export async function refreshPRNow(
const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false)
const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key))
if (gateUntil > Date.now()) {
queue.protectBackgroundUntil(key, gateUntil)
queue.set(key, {
key,
candidate,
@@ -168,6 +190,7 @@ export async function refreshPRNow(
queue.delete(key)
const requestSequence = events.nextSequence()
const requestStartedAt = Date.now()
queue.noteRequestStarted(key, requestSequence)
events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence)
const outcome = await getPRForBranchOutcome(
candidate.repoPath,
@@ -177,10 +200,14 @@ export async function refreshPRNow(
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate, reason)
)
if (!queue.ownsRequest(key, requestSequence)) {
events.broadcast({ aliases, reason, outcome, requestStartedAt }, requestSequence)
return outcome
}
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
if (outcome.kind === 'upstream-error' && visibility.has(key)) {
plannedRetryAt = retry.nextVisibleErrorRetryAt(key)
plannedRetryAt = retry.nextVisibleErrorRetryAt(key, visibility.candidate(key, candidate))
broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt)
}
events.observe(candidate, outcome)
+11 -3
View File
@@ -6,6 +6,14 @@ const BACKGROUND_BUDGET_MAX = 20
const ACTIVE_BURST_WINDOW_MS = 30_000
const ACTIVE_BURST_MAX = 3
export function usesActiveRefreshPacing(entry: PRRefreshQueueEntry): boolean {
// Selected admission survives deselection while queued; periodic follow-ups clear the flag.
return (
entry.reason === 'active' ||
(entry.reason === 'visible' && entry.priority >= 80 && entry.bypassBackgroundBudget === true)
)
}
export class PRRefreshPacing {
private readonly backgroundStarts: number[] = []
private readonly activeStartsByScope = new Map<string, number[]>()
@@ -42,7 +50,7 @@ export class PRRefreshPacing {
}
activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number {
if (a.reason !== 'active' || b.reason !== 'active') {
if (!usesActiveRefreshPacing(a) || !usesActiveRefreshPacing(b)) {
return 0
}
if (this.activeBurstScope(a) !== this.activeBurstScope(b)) {
@@ -52,7 +60,7 @@ export class PRRefreshPacing {
}
entryDelay(entry: PRRefreshQueueEntry): number {
const activeDelay = entry.reason === 'active' ? this.nextActiveBurstDelay(entry) : 0
const activeDelay = usesActiveRefreshPacing(entry) ? this.nextActiveBurstDelay(entry) : 0
if (activeDelay > 0) {
return activeDelay
}
@@ -63,7 +71,7 @@ export class PRRefreshPacing {
}
isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean {
return entry.reason === 'active' && this.nextActiveBurstDelay(entry) > 0
return usesActiveRefreshPacing(entry) && this.nextActiveBurstDelay(entry) > 0
}
noteActiveStart(entry: PRRefreshQueueEntry): void {
+49 -10
View File
@@ -5,15 +5,17 @@ import type {
} from '../../shared/github/pull-request-refresh-types'
import { getPRForBranchOutcome } from './client'
import {
aliasFromCandidate,
freshRetryAt,
hostedReviewOptionArgs,
isBackground,
isMergeabilityPendingOutcome,
sameAliasRequestIdentity,
validateCandidate,
visibleCandidateAfterOutcome
} from './pr-refresh-candidate-policy'
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
import type { PRRefreshPacing } from './pr-refresh-pacing'
import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
@@ -50,12 +52,16 @@ export class PRRefreshQueueDrainer {
windowId?: number,
options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number }
): void {
if (!this.visibility.has(key)) {
this.retry.reset(key)
return
}
if (outcome.kind === 'upstream-error') {
const retryAt = options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key)
const retryAt = Math.max(
options?.plannedRetryAt ??
this.retry.nextVisibleErrorRetryAt(key, this.visibility.candidate(key, candidate)),
this.retry.manualGateUntil(key)
)
this.queue.protectBackgroundUntil(key, retryAt)
if (!this.visibility.has(key)) {
return
}
this.queue.setVisibleFollowUp({
key,
candidate,
@@ -70,13 +76,31 @@ export class PRRefreshQueueDrainer {
return
}
this.retry.reset(key)
const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome)
const refreshed = visibleCandidateAfterOutcome(candidate, outcome)
this.visibility.update(refreshed)
if (!this.visibility.has(key)) {
return
}
const followUpCandidate = this.visibility.candidate(key, refreshed)
const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now()
const pendingDueAt =
options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome)
? outcome.fetchedAt + options.pendingMergeabilityDelayMs
: null
const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt)
if (!Number.isFinite(dueAt)) {
const pending = this.queue.get(key)
if (
pending?.reason === 'visible' &&
sameAliasRequestIdentity(
aliasFromCandidate(pending.candidate),
aliasFromCandidate(followUpCandidate)
)
) {
this.queue.delete(key)
}
return
}
this.queue.setVisibleFollowUp({
key,
candidate: followUpCandidate,
@@ -86,6 +110,7 @@ export class PRRefreshQueueDrainer {
dueAt,
queuedAt: this.queue.nextOrder(),
bypassBackgroundBudget: pendingDueAt !== null,
followUp: true,
windowId
})
this.schedule(Math.max(0, dueAt - Date.now()))
@@ -146,7 +171,6 @@ export class PRRefreshQueueDrainer {
continue
}
if (next.reason === 'visible' && !this.visibility.has(next.key)) {
this.retry.reset(next.key)
this.events.broadcast({
aliases,
reason: next.reason,
@@ -157,6 +181,7 @@ export class PRRefreshQueueDrainer {
}
const requestSequence = this.events.nextSequence()
const requestStartedAt = Date.now()
this.queue.noteRequestStarted(next.key, requestSequence)
this.events.broadcast(
{ aliases, reason: next.reason, status: 'in-flight', requestStartedAt },
requestSequence
@@ -164,7 +189,11 @@ export class PRRefreshQueueDrainer {
if (isBackground(next.reason)) {
const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true)
if (!this.queue.ownsRequest(next.key, requestSequence)) {
continue
}
if (pausedUntil !== null) {
this.queue.protectBackgroundUntil(next.key, pausedUntil)
this.queue.set(next.key, { ...next, dueAt: pausedUntil })
this.events.broadcast({
aliases,
@@ -182,7 +211,7 @@ export class PRRefreshQueueDrainer {
) {
this.pacing.noteBackgroundStart()
}
if (next.reason === 'active') {
if (usesActiveRefreshPacing(next)) {
this.pacing.noteActiveStart(next)
}
}
@@ -195,10 +224,20 @@ export class PRRefreshQueueDrainer {
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(next.candidate, next.reason)
)
if (!this.queue.ownsRequest(next.key, requestSequence)) {
this.events.broadcast(
{ aliases, reason: next.reason, outcome, requestStartedAt },
requestSequence
)
continue
}
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) {
plannedRetryAt = this.retry.nextVisibleErrorRetryAt(next.key)
plannedRetryAt = this.retry.nextVisibleErrorRetryAt(
next.key,
this.visibility.candidate(next.key, next.candidate)
)
broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt)
}
this.events.observe(next.candidate, outcome)
@@ -192,10 +192,10 @@ describe('pr-refresh queue growth bounds', () => {
})
reportVisiblePRRefreshCandidates([first], 1, 1)
await vi.advanceTimersByTimeAsync(100_000)
await vi.advanceTimersByTimeAsync(119_999)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual(['churn/1'])
await vi.advanceTimersByTimeAsync(500_001)
await vi.advanceTimersByTimeAsync(1)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual([
'churn/1',
'churn/2'
+81 -52
View File
@@ -1,3 +1,4 @@
import { REVIEW_REFRESH_COOLDOWN_MS } from '../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
@@ -9,6 +10,8 @@ import {
freshRetryAt,
POST_PUSH_DELAY_MS,
refreshKey,
sameAliasRequestIdentity,
refreshIntervalForCandidate,
shouldSkipFresh
} from './pr-refresh-candidate-policy'
@@ -22,6 +25,7 @@ export type PRRefreshQueueEntry = {
queuedAt: number
bypassBackgroundBudget?: boolean
activeDelayNotified?: boolean
followUp?: boolean
windowId?: number
}
@@ -32,6 +36,8 @@ export type PRRefreshEnqueue = {
coalesced: boolean
}
type PRRefreshRequestState = { until: number; requestSequence?: number }
/** A worktree has one live branch at a time, so a second cacheKey for it is a
* branch it moved off. Drop those: a linked-PR key survives every branch
* switch, so while its entry is parked (rate-limit pause, error backoff, or
@@ -70,25 +76,6 @@ function mergeFollowUpAlias(
return undefined
}
function sameAliasRequestIdentity(
left: GitHubPRRefreshAlias,
right: GitHubPRRefreshAlias
): boolean {
return (
left.cacheKey === right.cacheKey &&
left.repoId === right.repoId &&
left.repoPath === right.repoPath &&
left.branch === right.branch &&
left.worktreeId === right.worktreeId &&
left.connectionId === right.connectionId &&
left.executionHostId === right.executionHostId &&
left.linkedPRNumber === right.linkedPRNumber &&
left.fallbackPRNumber === right.fallbackPRNumber &&
left.fallbackPRSource === right.fallbackPRSource &&
left.currentHeadOid === right.currentHeadOid
)
}
/** A manual refresh merges its alias into its own copy of the map and writes it
* back, so re-entry through `set` has to re-apply the same bound; later
* insertions are the newer branch and win. */
@@ -109,6 +96,8 @@ function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias
export class PRRefreshQueue {
private readonly entries = new Map<string, PRRefreshQueueEntry>()
private order = 0
// Completion ownership shares the cooldown map's bound.
private readonly backgroundNotBefore = new Map<string, PRRefreshRequestState>()
constructor(private readonly resetRetryState: (key: string) => void) {}
@@ -142,17 +131,73 @@ export class PRRefreshQueue {
return this.entries.get(key)?.aliases.size ?? 0
}
protectBackgroundUntil(key: string, until: number, requestSequence?: number): void {
const owner = requestSequence ?? this.backgroundNotBefore.get(key)?.requestSequence
this.backgroundNotBefore.delete(key)
this.backgroundNotBefore.set(key, { until, requestSequence: owner })
const pending = this.entries.get(key)
if (pending && !bypassesFreshnessDelay(pending.reason)) {
pending.dueAt = Math.max(pending.dueAt, until)
}
const oldest = this.backgroundNotBefore.keys().next().value
if (this.backgroundNotBefore.size > 1_000 && oldest !== undefined) {
this.backgroundNotBefore.delete(oldest)
this.resetRetryState(oldest)
}
}
noteRequestStarted(key: string, requestSequence: number): void {
this.protectBackgroundUntil(key, Date.now() + REVIEW_REFRESH_COOLDOWN_MS, requestSequence)
}
ownsRequest(key: string, requestSequence: number): boolean {
return this.backgroundNotBefore.get(key)?.requestSequence === requestSequence
}
retimeVisible(
candidateFor: (key: string, candidate: GitHubPRRefreshCandidate) => GitHubPRRefreshCandidate
): void {
for (const entry of this.entries.values()) {
if (entry.reason !== 'visible' || !entry.followUp || entry.bypassBackgroundBudget) {
continue
}
entry.candidate = candidateFor(entry.key, entry.candidate)
entry.dueAt = Math.max(
freshRetryAt(entry.candidate) ?? Date.now(),
this.backgroundNotBefore.get(entry.key)?.until ?? 0
)
if (!Number.isFinite(entry.dueAt)) {
this.entries.delete(entry.key)
}
}
}
enqueue(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason,
priority: number,
windowId?: number
windowId?: number,
reexposed = false
): PRRefreshEnqueue {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = this.entries.get(key)
const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null
const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0)
const stopped = !Number.isFinite(refreshIntervalForCandidate(candidate))
const exposureDueAt =
reexposed &&
stopped &&
candidate.cachedFetchedAt != null &&
Date.now() - candidate.cachedFetchedAt >= REVIEW_REFRESH_COOLDOWN_MS
? Date.now()
: null
const dueAt = Math.max(
exposureDueAt ?? freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0),
bypassesFreshnessDelay(reason) ? 0 : (this.backgroundNotBefore.get(key)?.until ?? 0)
)
if (!Number.isFinite(dueAt) && !existing) {
return { alias, key, dueAt, coalesced: false }
}
if (!existing) {
this.entries.set(key, {
key,
@@ -162,12 +207,21 @@ export class PRRefreshQueue {
priority,
dueAt,
queuedAt: this.nextOrder(),
followUp: reason === 'visible' && freshDueAt !== null && exposureDueAt === null,
windowId
})
return { alias, key, dueAt, coalesced: false }
}
setLiveAlias(existing.aliases, alias)
if (
reason === 'visible' &&
!shouldSkipFresh(candidate, reason) &&
existing.reason === 'visible'
) {
existing.dueAt = Math.min(existing.dueAt, dueAt)
existing.followUp = false
}
const shouldPromote =
priority > existing.priority ||
reason === 'manual' ||
@@ -186,7 +240,8 @@ export class PRRefreshQueue {
...existing.candidate,
cacheKey: candidate.cacheKey,
branch: candidate.branch,
currentHeadOid: candidate.currentHeadOid ?? null
currentHeadOid: candidate.currentHeadOid ?? null,
isSelected: candidate.isSelected
}
}
return { alias, key, dueAt, coalesced: true }
@@ -207,9 +262,7 @@ export class PRRefreshQueue {
if (existing.candidate.cacheKey === alias.cacheKey) {
existing.candidate = {
...existing.candidate,
cacheKey: replacement.cacheKey,
branch: replacement.branch,
worktreeId: replacement.worktreeId,
...replacement,
currentHeadOid: replacement.currentHeadOid ?? null,
isArchived: false,
isBare: false
@@ -219,31 +272,9 @@ export class PRRefreshQueue {
pruneWorktreeAliases(worktreeId: string): void {
for (const [key, entry] of this.entries) {
let removed = false
for (const [cacheKey, alias] of entry.aliases) {
for (const alias of entry.aliases.values()) {
if (alias.worktreeId === worktreeId) {
entry.aliases.delete(cacheKey)
removed = true
}
}
if (!removed) {
continue
}
if (entry.aliases.size === 0) {
this.entries.delete(key)
this.resetRetryState(key)
continue
}
if (entry.candidate.worktreeId === worktreeId) {
const replacement = entry.aliases.values().next().value
if (replacement) {
entry.candidate = {
...entry.candidate,
cacheKey: replacement.cacheKey,
branch: replacement.branch,
worktreeId: replacement.worktreeId,
currentHeadOid: replacement.currentHeadOid ?? null
}
this.removeInvalidAlias(key, alias)
}
}
}
@@ -256,7 +287,6 @@ export class PRRefreshQueue {
continue
}
this.entries.delete(key)
this.resetRetryState(key)
removed.push(entry)
}
return removed
@@ -282,8 +312,7 @@ export class PRRefreshQueue {
if (
candidateSuperseded ||
bypassesFreshnessDelay(existing.reason) ||
existing.priority > entry.priority ||
existing.dueAt <= entry.dueAt
existing.priority > entry.priority
) {
return
}
+10 -3
View File
@@ -1,5 +1,9 @@
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
import type {
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing'
import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy'
export class PRRefreshRetryState {
private readonly errorBackoff = new Map<string, { failures: number; retryAt: number }>()
@@ -26,9 +30,12 @@ export class PRRefreshRetryState {
return this.manualRetryGates.get(key) ?? 0
}
nextVisibleErrorRetryAt(key: string): number {
nextVisibleErrorRetryAt(key: string, candidate: GitHubPRRefreshCandidate): number {
const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1
const retryAt = Date.now() + lookupBackoffDelayMs(failures)
const interval = refreshIntervalForCandidate(candidate)
const retryAt =
Date.now() +
Math.max(lookupBackoffDelayMs(failures), Number.isFinite(interval) ? interval : 0)
this.errorBackoff.set(key, { failures, retryAt })
return retryAt
}
+64 -9
View File
@@ -3,7 +3,10 @@ import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-
import { refreshKey } from './pr-refresh-candidate-policy'
export class PRRefreshVisibility {
private readonly visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
private readonly visibleByWindow = new Map<
number,
{ generation: number; candidates: Map<string, GitHubPRRefreshCandidate> }
>()
get windowCount(): number {
return this.visibleByWindow.size
@@ -14,31 +17,83 @@ export class PRRefreshVisibility {
}
report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean {
this.pruneDestroyedWindows()
const existing = this.visibleByWindow.get(windowId)
if (existing && generation < existing.generation) {
return false
}
this.visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) })
const reported = new Map<string, GitHubPRRefreshCandidate>()
for (const candidate of candidates) {
const key = refreshKey(candidate)
const previous = this.candidate(key, candidate)
reported.set(key, {
...previous,
...candidate,
...(previous.cachedFetchedAt != null &&
previous.cachedFetchedAt > (candidate.cachedFetchedAt ?? 0)
? this.cachedFields(previous)
: {}),
isSelected: candidate.isSelected === true || reported.get(key)?.isSelected === true
})
}
this.visibleByWindow.set(windowId, { generation, candidates: reported })
return true
}
has(key: string): boolean {
this.pruneDestroyedWindows()
return Array.from(this.visibleByWindow.values()).some((window) => window.candidates.has(key))
}
candidate(key: string, fallback: GitHubPRRefreshCandidate): GitHubPRRefreshCandidate {
let latest = fallback
let selected = false
for (const window of this.visibleByWindow.values()) {
const candidate = window.candidates.get(key)
if (!candidate) {
continue
}
selected ||= candidate.isSelected === true
if ((candidate.cachedFetchedAt ?? 0) > (latest.cachedFetchedAt ?? 0)) {
latest = { ...fallback, ...this.cachedFields(candidate) }
}
}
return { ...latest, isSelected: selected }
}
update(candidate: GitHubPRRefreshCandidate): void {
const key = refreshKey(candidate)
for (const window of this.visibleByWindow.values()) {
const current = window.candidates.get(key)
if (current && (current.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) {
window.candidates.set(key, { ...current, ...this.cachedFields(candidate) })
}
}
}
private cachedFields(candidate: GitHubPRRefreshCandidate): Partial<GitHubPRRefreshCandidate> {
return {
cachedFetchedAt: candidate.cachedFetchedAt,
cachedHeadOid: candidate.cachedHeadOid,
cachedHasPR: candidate.cachedHasPR,
cachedPRState: candidate.cachedPRState,
cachedChecksStatus: candidate.cachedChecksStatus,
cachedMergeable: candidate.cachedMergeable,
cachedMergeStateStatus: candidate.cachedMergeStateStatus
}
}
private pruneDestroyedWindows(): void {
const liveWindowIds = new Set(
webContents
.getAllWebContents()
.filter((contents) => !contents.isDestroyed())
.map((contents) => contents.id)
)
for (const windowId of Array.from(this.visibleByWindow.keys())) {
for (const windowId of this.visibleByWindow.keys()) {
if (!liveWindowIds.has(windowId)) {
this.visibleByWindow.delete(windowId)
}
}
for (const visible of this.visibleByWindow.values()) {
if (visible.keys.has(key)) {
return true
}
}
return false
}
}
+4 -3
View File
@@ -306,7 +306,7 @@ describe('registerHostedReviewHandlers', () => {
expect(getHostedReviewForBranchMock.mock.calls[0][0]).not.toHaveProperty('active')
})
it('carries a selected-worktree claim through to the branch lookup', async () => {
it('carries a selected-worktree claim and explicit refresh through to the branch lookup', async () => {
getHostedReviewForBranchMock.mockResolvedValueOnce(null)
registerHostedReviewHandlers(store as never, stats as never)
@@ -314,13 +314,14 @@ describe('registerHostedReviewHandlers', () => {
repoPath,
repoId: repo.id,
branch: 'feature/selected',
active: true
active: true,
force: true
})
// Why: the right sidebar renders only the selected worktree, so its lookup
// earns the per-minute tier instead of the card-list interval (#11532).
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({ branch: 'feature/selected', active: true })
expect.objectContaining({ branch: 'feature/selected', active: true, force: true })
)
})
+1
View File
@@ -125,6 +125,7 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
linkedGiteaPR: args.linkedGiteaPR ?? null,
currentHeadOid: args.currentHeadOid ?? null,
...(args.active === true ? { active: true } : {}),
...(args.force === true ? { force: true } : {}),
localGitExecOptions: localGitOptions
})
if (review?.provider === 'github' && !stats.hasCountedPR(review.url)) {
@@ -50,7 +50,7 @@ describe('hosted review RPC methods', () => {
})
})
it('carries a selected-worktree claim through to the runtime', async () => {
it('carries a selected-worktree claim and explicit refresh through to the runtime', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getHostedReviewForBranch: vi.fn().mockResolvedValue(null)
@@ -61,14 +61,15 @@ describe('hosted review RPC methods', () => {
makeRequest('hostedReview.forBranch', {
repo: '/repo',
branch: 'feature/selected',
active: true
active: true,
force: true
})
)
// Why: without this the mobile PR sidebar would sit on the card-list pacing
// and take a no-review interval to notice a PR opened elsewhere (#11532).
expect(runtime.getHostedReviewForBranch).toHaveBeenCalledWith(
expect.objectContaining({ active: true })
expect.objectContaining({ active: true, force: true })
)
})
@@ -20,6 +20,7 @@ export const HOSTED_REVIEW_METHODS = [
...(params.admissionTier ? { admissionTier: params.admissionTier } : {}),
currentHeadOid: params.currentHeadOid ?? null,
...(params.active === true ? { active: true } : {}),
...(params.force === true ? { force: true } : {}),
linkedGitHubPR: params.linkedGitHubPR ?? null,
...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}),
linkedGitLabMR: params.linkedGitLabMR ?? null,
@@ -106,6 +106,7 @@ export class RuntimeHostedReviewCommands {
admissionTier?: GitAdmissionTier
currentHeadOid?: string | null
active?: boolean
force?: boolean
linkedGitHubPR?: number | null
fallbackGitHubPR?: number | null
linkedGitLabMR?: number | null
@@ -121,6 +122,7 @@ export class RuntimeHostedReviewCommands {
branch: args.branch,
currentHeadOid: args.currentHeadOid ?? null,
...(args.active === true ? { active: true } : {}),
...(args.force === true ? { force: true } : {}),
linkedGitHubPR: args.linkedGitHubPR ?? null,
fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null,
linkedGitLabMR: args.linkedGitLabMR ?? null,
@@ -123,6 +123,37 @@ describe('hosted review branch cache (#11532)', () => {
expect(lookup).toHaveBeenCalledTimes(2)
})
it('keeps merged reviews fresh for only 60 seconds for older clients', async () => {
const lookup = vi.fn(async () => mergedReview)
await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup)
vi.setSystemTime(START + 59_999)
await withHostedReviewBranchCache(identity, { headOid: 'aaa', active: true }, lookup)
expect(lookup).toHaveBeenCalledTimes(1)
vi.setSystemTime(START + 60_000)
await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
await withHostedReviewBranchCache(identity, { headOid: 'bbb' }, lookup)
expect(lookup).toHaveBeenCalledTimes(3)
})
it('continues watching merged reviews whose checks are pending', async () => {
const lookup = vi.fn(async () => ({ ...mergedReview, status: 'pending' as const }))
await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup)
vi.setSystemTime(START + 60_000)
await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup)
expect(lookup).toHaveBeenCalledTimes(2)
})
it('bypasses a merged result for an explicit refresh', async () => {
const lookup = vi.fn(async () => mergedReview)
await withHostedReviewBranchCache(identity, { headOid: 'aaa' }, lookup)
lookup.mockResolvedValue(openReview)
await expect(
withHostedReviewBranchCache(identity, { headOid: 'aaa', force: true }, lookup)
).resolves.toEqual(openReview)
expect(lookup).toHaveBeenCalledTimes(2)
})
it('drops a merged review once the inspected head moves off it', async () => {
const lookup = vi.fn(async () => mergedReview)
@@ -25,6 +25,7 @@ import {
import {
__resetHostedReviewScopeGenerationsForTests,
bumpScopeGeneration,
hostedReviewRepoScope,
scopeGeneration
} from './hosted-review-scope-generations'
import {
@@ -91,18 +92,12 @@ export type HostedReviewBranchCacheOptions = {
headOid: string | null
/** Set by surfaces that only ever render the selected worktree. */
active?: boolean
}
/** Repo-scoped prefix so a single repo's entries can be dropped without a full flush.
* Keyed on the resolved host, not a raw connection id: two rows at one path on different hosts
* are different repositories, and collapsing them serves one host's answer for the other. */
function repoScope(repoPath: string, executionHostId: ExecutionHostId): string {
return `${executionHostId}${KEY_SEPARATOR}${repoPath}`
force?: boolean
}
export function hostedReviewBranchCacheKey(identity: HostedReviewBranchCacheIdentity): string {
return [
repoScope(identity.repoPath, identity.executionHostId),
hostedReviewRepoScope(identity.repoPath, identity.executionHostId),
identity.branch,
// Each linked id selects a different lookup, so it belongs in the identity.
identity.linkedGitHubPR ?? '',
@@ -159,7 +154,7 @@ export function invalidateHostedReviewBranchCache(
repoPath: string,
executionHostId: ExecutionHostId
): void {
const scope = repoScope(repoPath, executionHostId)
const scope = hostedReviewRepoScope(repoPath, executionHostId)
bumpScopeGeneration(scope)
const prefix = `${scope}${KEY_SEPARATOR}`
for (const key of entries.keys()) {
@@ -360,7 +355,7 @@ export async function withHostedReviewBranchCache(
const active = isActiveBranch(key)
const cached = entries.get(key)
if (cached && isFresh(cached, headOid, active)) {
if (!options.force && cached && isFresh(cached, headOid, active)) {
return cached.review
}
@@ -379,5 +374,10 @@ export async function withHostedReviewBranchCache(
throw new Error(unavailable)
}
return startLookup(key, repoScope(identity.repoPath, identity.executionHostId), headOid, lookup)
return startLookup(
key,
hostedReviewRepoScope(identity.repoPath, identity.executionHostId),
headOid,
lookup
)
}
@@ -1,3 +1,4 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import { MAX_BRANCH_MAP_ENTRIES } from './hosted-review-refresh-pacing'
/**
@@ -18,6 +19,11 @@ const scopeGenerations = new Map<string, number>()
*/
let evictedGeneration = 0
// Cache invalidation and provider reads must use the same host-scoped identity.
export function hostedReviewRepoScope(repoPath: string, executionHostId: ExecutionHostId): string {
return `${executionHostId}\0${repoPath}`
}
export function scopeGeneration(scope: string): number {
return scopeGenerations.get(scope) ?? evictedGeneration
}
+6 -1
View File
@@ -47,6 +47,7 @@ export async function getHostedReviewForBranch(
* one branch cheap enough to re-check per minute (#11532).
*/
active?: boolean
force?: boolean
} & HostedReviewExecutionOptions
): Promise<HostedReviewInfo | null> {
const branchName = input.branch.replace(/^refs\/heads\//, '')
@@ -69,7 +70,11 @@ export async function getHostedReviewForBranch(
// host's per-user API quota, so the cache has to sit above the provider call.
return withHostedReviewBranchCache(
{ ...input, branch: branchName },
{ headOid, ...(input.active === true ? { active: true } : {}) },
{
headOid,
...(input.active === true ? { active: true } : {}),
...(input.force === true ? { force: true } : {})
},
async () => {
const provider = await getForgeProviderForRepository({
repoPath: input.repoPath,
@@ -22,6 +22,8 @@ import { useTerminalViewerColorPublication } from './use-terminal-viewer-color-p
import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice'
import { useCodexTerminalServerIsolationNotice } from '../components/terminal-pane/codex-terminal-server-isolation-notice'
import { useCodexSharedSettingsNotice } from '../components/terminal-pane/codex-shared-settings-notice'
import { useVisibleReviewRefreshReporting } from './use-visible-review-refresh-reporting'
import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh'
/**
* App-level subscriptions that must outlive any individual surface. Each one is here because
@@ -42,6 +44,8 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }):
// Subscribe to IPC push events
useIpcEvents()
useRemoteRuntimeRecoveryTriggers()
useVisibleReviewRefreshReporting()
useVisibleHostedReviewRefresh({ enabled: workspaceSessionReady })
useTerminalViewerColorPublication()
useAutomationDispatchEvents()
// Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint.
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refresh'
const mocks = vi.hoisted(() => ({
refresh: vi.fn(async () => true),
getState: vi.fn(() => ({})),
subscribe: vi.fn(() => vi.fn()),
web: vi.fn(() => false)
}))
vi.mock('@/store', () => ({
useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe }
}))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: mocks.web }))
vi.mock('@/store/github/visible-hosted-review-refresh-targets', () => ({
visibleHostedReviewRefreshInputsChanged: () => true,
getVisibleHostedReviewRefreshTargets: () => [
{
key: 'review',
revision: 'head',
selected: true,
intervalMs: 60_000,
fetchedAt: null,
refresh: mocks.refresh
}
]
}))
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('waits for workspace readiness and stops timers/subscription when disabled', async () => {
vi.useFakeTimers()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
const hook = renderHook(({ enabled }) => useVisibleHostedReviewRefresh({ enabled }), {
initialProps: { enabled: false }
})
await vi.advanceTimersByTimeAsync(600_000)
expect(mocks.refresh).not.toHaveBeenCalled()
expect(mocks.subscribe).not.toHaveBeenCalled()
hook.rerender({ enabled: true })
await vi.advanceTimersByTimeAsync(0)
expect(mocks.refresh).toHaveBeenCalledOnce()
const unsubscribe = mocks.subscribe.mock.results[0].value
hook.rerender({ enabled: false })
expect(unsubscribe).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(600_000)
expect(mocks.refresh).toHaveBeenCalledOnce()
})
@@ -0,0 +1,38 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import { isWebClientLocation } from '@/lib/web-client-location'
import { createVisibleHostedReviewRefreshScheduler } from '@/store/github/visible-hosted-review-refresh-scheduler'
import {
getVisibleHostedReviewRefreshTargets,
visibleHostedReviewRefreshInputsChanged
} from '@/store/github/visible-hosted-review-refresh-targets'
export function useVisibleHostedReviewRefresh({ enabled }: { enabled: boolean }): void {
useEffect(() => {
if (!enabled) {
return
}
const scheduler = createVisibleHostedReviewRefreshScheduler()
const update = (): void =>
scheduler.update(
getVisibleHostedReviewRefreshTargets(useAppStore.getState(), useAppStore.getState, {
selectedOnly: isWebClientLocation()
})
)
const visibilityChanged = (): void => scheduler.setVisible(isWindowVisible())
const unsubscribe = useAppStore.subscribe((state, previous) => {
if (visibleHostedReviewRefreshInputsChanged(state, previous)) {
update()
}
})
update()
visibilityChanged()
document.addEventListener('visibilitychange', visibilityChanged)
return () => {
unsubscribe()
document.removeEventListener('visibilitychange', visibilityChanged)
scheduler.dispose()
}
}, [enabled])
}
@@ -0,0 +1,208 @@
import { useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '../store'
import type { AppState } from '../store/types'
import { rightSidebarShowsPullRequestData } from '../lib/right-sidebar-visibility'
import { findWorktreeById, buildPRRefreshCandidate } from '../store/github/worktree-refresh'
import { getHostedReviewCacheKey } from '../store/slices/hosted-review-cache-identity'
import { getIndexedRepoMap } from '../store/worktree-repo-index'
import { shouldCoordinateVisibleGitHubReview } from '../store/github/visible-hosted-review-refresh-ownership'
import { getPRRefreshRuntimeRepoTarget } from '../store/github/repository-routing'
import {
reviewRefreshIntervalMs,
REVIEW_REFRESH_COOLDOWN_MS
} from '../../../shared/review-refresh-policy'
export function visibleReviewWorktreeIdsForState(state: AppState): string[] {
const ids = new Set(state.visibleReviewCardWorktreeIds ?? [])
const repos = getIndexedRepoMap(state.repos)
if (state.activeWorktreeId && rightSidebarShowsPullRequestData(state)) {
ids.add(state.activeWorktreeId)
}
return Array.from(ids).filter((id) => {
const worktree = findWorktreeById(state, id)
const repo = worktree && repos.get(worktree.repoId)
return (
worktree &&
repo &&
(repo.kind ?? 'git') === 'git' &&
!worktree.isBare &&
!worktree.isArchived &&
Boolean(worktree.branch)
)
})
}
function reportIdentity(state: AppState): string {
return JSON.stringify([
state.activeWorktreeId,
rightSidebarShowsPullRequestData(state),
state.sshConnectedGeneration,
state.prVisibleRefreshGeneration,
visibleReviewWorktreeIdsForState(state).map((id) => {
const worktree = findWorktreeById(state, id)
if (!worktree) {
return id
}
const candidate = buildPRRefreshCandidate(state, worktree)
const key =
candidate &&
getHostedReviewCacheKey(
candidate.repoPath,
candidate.branch,
state.settings,
candidate.repoId,
candidate.connectionId,
candidate.executionHostId,
true
)
return [
id,
worktree.branch,
worktree.head,
worktree.linkedPR,
worktree.linkedGitLabMR,
worktree.linkedBitbucketPR,
worktree.linkedAzureDevOpsPR,
worktree.linkedGiteaPR,
candidate?.connectionState,
candidate?.executionHostId,
candidate?.cacheKey,
getIndexedRepoMap(state.repos).get(worktree.repoId)?.gitRemoteIdentity?.canonicalKey,
Boolean(candidate && state.prCache[candidate.cacheKey]?.data),
key ? state.hostedReviewCache[key]?.data?.provider : null
]
})
])
}
const REPORT_INPUT_KEYS = [
'activeView',
'activeWorktreeId',
'rightSidebarOpen',
'rightSidebarTab',
'visibleReviewCardWorktreeIds',
'repos',
'worktreesByRepo',
'settings',
'sshConnectionStates',
'sshConnectedGeneration',
'prVisibleRefreshGeneration',
'prCache',
'hostedReviewCache'
] as const
type ReviewReportInputs = Pick<AppState, (typeof REPORT_INPUT_KEYS)[number]>
export function createVisibleReviewReportIdentitySelector(): (state: AppState) => string {
let previous: ReviewReportInputs | null = null
let identity = ''
return (state) => {
const cachedInputs = previous
if (cachedInputs && REPORT_INPUT_KEYS.every((key) => cachedInputs[key] === state[key])) {
return identity
}
// Keep only review inputs so terminal output and agent snapshots can be released.
previous = {
activeView: state.activeView,
activeWorktreeId: state.activeWorktreeId,
rightSidebarOpen: state.rightSidebarOpen,
rightSidebarTab: state.rightSidebarTab,
visibleReviewCardWorktreeIds: state.visibleReviewCardWorktreeIds,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
settings: state.settings,
sshConnectionStates: state.sshConnectionStates,
sshConnectedGeneration: state.sshConnectedGeneration,
prVisibleRefreshGeneration: state.prVisibleRefreshGeneration,
prCache: state.prCache,
hostedReviewCache: state.hostedReviewCache
}
identity = reportIdentity(state)
return identity
}
}
export function refreshForegroundVisibleReview(state: AppState): void {
const id = state.activeWorktreeId
if (!id || !rightSidebarShowsPullRequestData(state)) {
return
}
const worktree = findWorktreeById(state, id)
const candidate = worktree && buildPRRefreshCandidate(state, worktree)
if (
!worktree ||
!candidate ||
!shouldCoordinateVisibleGitHubReview(state, worktree, candidate) ||
getPRRefreshRuntimeRepoTarget(state, candidate)
) {
return
}
const interval =
reviewRefreshIntervalMs({
state: candidate.cachedPRState,
checksStatus: candidate.cachedChecksStatus,
hasReview: candidate.cachedHasPR,
selected: true
}) ?? REVIEW_REFRESH_COOLDOWN_MS
if (
candidate.cachedFetchedAt == null ||
Date.now() - candidate.cachedFetchedAt >= interval ||
(candidate.currentHeadOid != null &&
candidate.cachedHeadOid != null &&
candidate.currentHeadOid !== candidate.cachedHeadOid)
) {
state.enqueueGitHubPRRefresh(id, 'visible', 80)
}
}
export function useVisibleReviewRefreshReporting(): void {
const selectIdentity = useMemo(() => createVisibleReviewReportIdentitySelector(), [])
const foregroundRef = useRef<string | null>(null)
const identity = useAppStore(selectIdentity)
const report = useAppStore((s) => s.reportVisibleGitHubPRRefreshCandidates)
useEffect(() => {
let mounted = true
const update = (): void => {
const state = useAppStore.getState()
const foreground =
document.visibilityState === 'visible' && rightSidebarShowsPullRequestData(state)
? state.activeWorktreeId
: null
const newlyForeground = foreground !== null && foregroundRef.current !== foreground
if (foreground === null) {
foregroundRef.current = null
}
void report(
document.visibilityState === 'visible'
? visibleReviewWorktreeIdsForState(useAppStore.getState())
: [],
Date.now()
).then(() => {
const current = useAppStore.getState()
if (
mounted &&
newlyForeground &&
foregroundRef.current !== foreground &&
rightSidebarShowsPullRequestData(current) &&
document.visibilityState === 'visible' &&
current.activeWorktreeId === foreground
) {
foregroundRef.current = foreground
refreshForegroundVisibleReview(current)
}
})
}
update()
document.addEventListener('visibilitychange', update)
return () => {
mounted = false
document.removeEventListener('visibilitychange', update)
}
}, [identity, report])
useEffect(
() => () => {
void report([], Date.now())
},
[report]
)
}
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import {
createTestStore,
makePRRefreshWorktree,
makePR
} from '../store/slices/github-slice-test-harness'
import type { AppState } from '../store/types'
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
import {
createVisibleReviewReportIdentitySelector,
refreshForegroundVisibleReview,
visibleReviewWorktreeIdsForState
} from './use-visible-review-refresh-reporting'
function state() {
const store = createTestStore()
store.setState({
repos: [
{ id: 'repo-1', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 1, kind: 'git' }
],
worktreesByRepo: {
'repo-1': [makePRRefreshWorktree({ id: 'selected' }), makePRRefreshWorktree({ id: 'card' })]
},
activeWorktreeId: 'selected',
activeView: 'terminal',
rightSidebarOpen: true,
rightSidebarTab: 'checks',
visibleReviewCardWorktreeIds: []
})
return store
}
describe('review surface visibility union', () => {
it('keeps the selected panel when the left sidebar has no visible cards', () => {
expect(visibleReviewWorktreeIdsForState(state().getState())).toEqual(['selected'])
})
it('unions cards and the panel without duplicate requests', () => {
const store = state()
store.setState({ visibleReviewCardWorktreeIds: ['card', 'selected'] })
expect(visibleReviewWorktreeIdsForState(store.getState())).toEqual(['card', 'selected'])
})
it('drops the selected panel when closed or suppressed by the active view', () => {
const store = state()
store.setState({ visibleReviewCardWorktreeIds: ['card'], activeView: 'settings' })
expect(visibleReviewWorktreeIdsForState(store.getState())).toEqual(['card'])
store.setState({ activeView: 'terminal', rightSidebarOpen: false })
expect(visibleReviewWorktreeIdsForState(store.getState())).toEqual(['card'])
})
it('excludes archived and bare workspace cards', () => {
const store = state()
store.setState({
rightSidebarOpen: false,
visibleReviewCardWorktreeIds: ['bare', 'archived'],
worktreesByRepo: {
'repo-1': [
makePRRefreshWorktree({ id: 'bare', isBare: true }),
makePRRefreshWorktree({ id: 'archived', isArchived: true })
]
}
})
expect(visibleReviewWorktreeIdsForState(store.getState())).toEqual([])
})
})
describe('review report selector work', () => {
it('does not rebuild candidates or serialize on unrelated agent updates', () => {
const current = state().getState()
const select = createVisibleReviewReportIdentitySelector()
const stringify = vi.spyOn(JSON, 'stringify')
try {
const initial = select(current)
const initialSerializations = stringify.mock.calls.length
expect(initialSerializations).toBeGreaterThan(0)
for (let tick = 0; tick < 1_000; tick++) {
expect(select({ ...current, agentStatusByPaneKey: {}, agentStatusEpoch: tick })).toBe(
initial
)
}
expect(stringify).toHaveBeenCalledTimes(initialSerializations)
} finally {
stringify.mockRestore()
}
})
it('recomputes for every relevant input without retaining the whole state', () => {
const current = state().getState()
const updates: Partial<AppState>[] = [
{ activeView: 'settings' },
{ activeWorktreeId: 'card' },
{ rightSidebarOpen: false },
{ rightSidebarTab: 'source-control' },
{ visibleReviewCardWorktreeIds: ['card'] },
{ repos: [...current.repos] },
{ worktreesByRepo: { ...current.worktreesByRepo } },
{ settings: createGlobalSettingsFixture(current.settings ?? {}) },
{ sshConnectionStates: new Map(current.sshConnectionStates) },
{ sshConnectedGeneration: current.sshConnectedGeneration + 1 },
{ prVisibleRefreshGeneration: current.prVisibleRefreshGeneration + 1 },
{ prCache: { ...current.prCache } },
{ hostedReviewCache: { ...current.hostedReviewCache } }
]
const stringify = vi.spyOn(JSON, 'stringify')
try {
for (const update of updates) {
const select = createVisibleReviewReportIdentitySelector()
select(current)
const before = stringify.mock.calls.length
select({ ...current, ...update })
expect(stringify.mock.calls.length).toBeGreaterThan(before)
}
} finally {
stringify.mockRestore()
}
})
it('changes reports for a visible HEAD change and panel closure', () => {
const store = state()
const select = createVisibleReviewReportIdentitySelector()
const initial = select(store.getState())
store.setState({
worktreesByRepo: {
'repo-1': [makePRRefreshWorktree({ id: 'selected', head: 'new-head' })]
}
})
const changedHead = select(store.getState())
expect(changedHead).not.toBe(initial)
store.setState({ rightSidebarOpen: false })
expect(select(store.getState())).not.toBe(changedHead)
})
})
describe('selected foreground refresh admission', () => {
it('fast-tracks a stale local GitHub panel using visible gates and skips fresh or other-provider answers', () => {
const store = state()
const enqueue = vi.fn()
store.setState({
enqueueGitHubPRRefresh: enqueue,
prCache: { 'repo-1::feature/test': { data: makePR(), fetchedAt: 0 } }
})
const candidate = store.getState().worktreesByRepo['repo-1'][0]
const key = `repo-1::${candidate.branch}`
store.setState({ prCache: { [key]: { data: makePR(), fetchedAt: 0 } } })
refreshForegroundVisibleReview(store.getState())
expect(enqueue).toHaveBeenCalledExactlyOnceWith('selected', 'visible', 80)
enqueue.mockClear()
store.setState({ prCache: { [key]: { data: makePR(), fetchedAt: Date.now() } } })
refreshForegroundVisibleReview(store.getState())
expect(enqueue).not.toHaveBeenCalled()
store.setState({ worktreesByRepo: { 'repo-1': [{ ...candidate, linkedGitLabMR: 8 }] } })
refreshForegroundVisibleReview(store.getState())
expect(enqueue).not.toHaveBeenCalled()
})
it('reports panel exposure even when the selected card was already visible', () => {
const store = state()
store.setState({ visibleReviewCardWorktreeIds: ['selected'], rightSidebarOpen: false })
const select = createVisibleReviewReportIdentitySelector()
const initial = select(store.getState())
store.setState({ rightSidebarOpen: true })
expect(select(store.getState())).not.toBe(initial)
})
})
@@ -1,144 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getChecksPanelForegroundReviewEvidenceKey,
resolveChecksPanelPRRefreshRequest,
resolveChecksPanelReviewEvidenceProvider
} from './checks-panel-pr-refresh-request'
describe('resolveChecksPanelReviewEvidenceProvider', () => {
const noLinkedReviews = {
linkedGitHubPR: null,
linkedGitLabMR: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null
}
it.each([
['linkedGitHubPR', 'github'],
['linkedGitLabMR', 'gitlab'],
['linkedBitbucketPR', 'bitbucket'],
['linkedAzureDevOpsPR', 'azure-devops'],
['linkedGiteaPR', 'gitea']
] as const)('lets an explicit %s link outrank stale cached metadata', (linkedField, provider) => {
expect(
resolveChecksPanelReviewEvidenceProvider({
...noLinkedReviews,
[linkedField]: 42,
cachedProvider: 'unsupported'
})
).toBe(provider)
})
it('uses eligibility before cached provider metadata when no review is linked', () => {
expect(
resolveChecksPanelReviewEvidenceProvider({
...noLinkedReviews,
eligibilityProvider: 'bitbucket',
cachedProvider: 'gitlab'
})
).toBe('bitbucket')
})
})
describe('getChecksPanelForegroundReviewEvidenceKey', () => {
const input = {
refreshContextKey: 'worktree::cache::branch',
reviewEvidenceIdentity: 42,
hasUnrenderedReviewEvidence: true,
isGitHubReviewContext: true
} as const
it('keeps optimistic and confirmed GitHub evidence on one request key', () => {
const optimisticKey = getChecksPanelForegroundReviewEvidenceKey(input)
const confirmedKey = getChecksPanelForegroundReviewEvidenceKey({
...input,
reviewEvidenceProvider: 'github'
})
expect(optimisticKey).toBe('worktree::cache::branch::github::42')
expect(confirmedKey).toBe(optimisticKey)
})
it('clears the request key when evidence switches to another provider', () => {
expect(
getChecksPanelForegroundReviewEvidenceKey({
...input,
reviewEvidenceProvider: 'gitlab'
})
).toBeNull()
})
})
describe('resolveChecksPanelPRRefreshRequest', () => {
it('uses an active refresh for a cached miss from before the checks panel became visible', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: false,
cachedFetchedAt: 100,
panelVisibleSince: 200
})
).toEqual({ reason: 'active', priority: 80 })
})
it('keeps fresh empty lookups on the background path', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: false,
cachedFetchedAt: 200,
panelVisibleSince: 100
})
).toEqual({ reason: 'swr', priority: 30 })
})
it('foreground-fetches a known-but-unrendered review so the panel resolves off the transient card', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: null,
cachedFetchedAt: null,
panelVisibleSince: 200,
hasUnrenderedReviewEvidence: true
})
).toEqual({ reason: 'active', priority: 80 })
})
it('does not repeatedly force provider work for the same unrendered review evidence', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: false,
cachedFetchedAt: 100,
panelVisibleSince: 200,
hasUnrenderedReviewEvidence: true,
hasRequestedForegroundRefresh: true
})
).toEqual({ reason: 'swr', priority: 30 })
})
it('does not force provider work when review details are already cached', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: true,
cachedFetchedAt: 100,
panelVisibleSince: 200,
hasUnrenderedReviewEvidence: true
})
).toEqual({ reason: 'swr', priority: 30 })
})
it('keeps populated or unknown cache entries on the background path', () => {
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: true,
cachedFetchedAt: 100,
panelVisibleSince: 200
})
).toEqual({ reason: 'swr', priority: 30 })
expect(
resolveChecksPanelPRRefreshRequest({
cachedHasPR: null,
cachedFetchedAt: null,
panelVisibleSince: 200
})
).toEqual({ reason: 'swr', priority: 30 })
})
})
@@ -1,90 +0,0 @@
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
import type { GitHubPRRefreshReason } from '../../../../shared/github/pull-request-refresh-types'
type ChecksPanelPRRefreshRequestInput = {
cachedHasPR: boolean | null
cachedFetchedAt: number | null
panelVisibleSince: number | null
// A known-but-unrendered review needs one foreground lookup to resolve its transient state.
hasUnrenderedReviewEvidence?: boolean
hasRequestedForegroundRefresh?: boolean
}
type ChecksPanelPRRefreshRequest = {
reason: GitHubPRRefreshReason
priority: number
}
type ChecksPanelReviewEvidenceProviderInput = {
linkedGitHubPR: number | null
linkedGitLabMR: number | null
linkedBitbucketPR: number | null
linkedAzureDevOpsPR: number | null
linkedGiteaPR: number | null
eligibilityProvider?: HostedReviewProvider | undefined
cachedProvider?: HostedReviewProvider | undefined
}
type ChecksPanelForegroundReviewEvidenceKeyInput = {
refreshContextKey: string
reviewEvidenceIdentity: number | string
reviewEvidenceProvider?: HostedReviewProvider | undefined
hasUnrenderedReviewEvidence: boolean
isGitHubReviewContext: boolean
}
export function resolveChecksPanelReviewEvidenceProvider(
input: ChecksPanelReviewEvidenceProviderInput
): HostedReviewProvider | undefined {
if (input.linkedGitHubPR !== null) {
return 'github'
}
if (input.linkedGitLabMR !== null) {
return 'gitlab'
}
if (input.linkedBitbucketPR !== null) {
return 'bitbucket'
}
if (input.linkedAzureDevOpsPR !== null) {
return 'azure-devops'
}
if (input.linkedGiteaPR !== null) {
return 'gitea'
}
return input.eligibilityProvider ?? input.cachedProvider
}
export function getChecksPanelForegroundReviewEvidenceKey(
input: ChecksPanelForegroundReviewEvidenceKeyInput
): string | null {
if (
!input.hasUnrenderedReviewEvidence ||
!input.isGitHubReviewContext ||
(input.reviewEvidenceProvider !== undefined && input.reviewEvidenceProvider !== 'github')
) {
return null
}
return `${input.refreshContextKey}::github::${input.reviewEvidenceIdentity}`
}
export function resolveChecksPanelPRRefreshRequest(
input: ChecksPanelPRRefreshRequestInput
): ChecksPanelPRRefreshRequest {
const cachedMissPredatesVisiblePanel =
input.cachedHasPR === false &&
input.cachedFetchedAt !== null &&
input.panelVisibleSince !== null &&
input.cachedFetchedAt < input.panelVisibleSince
const unresolvedEvidenceNeedsForeground =
input.hasUnrenderedReviewEvidence && input.cachedHasPR !== true
if (
!input.hasRequestedForegroundRefresh &&
(cachedMissPredatesVisiblePanel || unresolvedEvidenceNeedsForeground)
) {
// A stale miss or new positive evidence needs one foreground lookup to recover.
return { reason: 'active', priority: 80 }
}
return { reason: 'swr', priority: 30 }
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { ChecksDetailPollingPolicy, detailedChecksStatus } from './checks-detail-polling-policy'
describe('detailed checks refresh policy', () => {
it.each([
{ status: 'queued', conclusion: null },
{ status: 'in_progress', conclusion: null },
{ status: 'completed', conclusion: 'pending' },
{ status: 'completed', conclusion: 'action_required' },
{ status: 'completed', conclusion: null }
] satisfies Pick<PRCheckDetail, 'status' | 'conclusion'>[])(
'continues merged checks for $status / $conclusion',
(check) => {
const policy = new ChecksDetailPollingPolicy()
policy.accept([{ name: 'Build', url: null, ...check }])
expect(policy.delayMs('merged', 'success', 60_000)).toBe(60_000)
}
)
it.each(['success', 'failure', 'neutral', 'cancelled', 'timed_out', 'skipped'] as const)(
'stops merged details after %s even if the aggregate remains pending',
(conclusion) => {
const policy = new ChecksDetailPollingPolicy()
policy.accept([{ name: 'Build', url: null, status: 'completed', conclusion }])
expect(policy.delayMs('merged', 'pending', 60_000)).toBeNull()
}
)
it.each(['success', 'failure', 'neutral'] as const)(
'stops successful empty details with a settled %s aggregate',
(aggregate) => {
const policy = new ChecksDetailPollingPolicy()
policy.accept([])
expect(policy.delayMs('merged', aggregate, 120_000)).toBeNull()
}
)
it('retries empty details when the aggregate is pending or unknown and backs off errors', () => {
const policy = new ChecksDetailPollingPolicy()
expect(detailedChecksStatus([])).toBeUndefined()
policy.accept([])
expect(policy.delayMs('merged', 'pending', 120_000)).toBe(120_000)
expect(policy.delayMs('merged', undefined, 120_000)).toBe(120_000)
policy.fail()
expect(policy.delayMs('merged', 'success', 240_000)).toBe(240_000)
})
})
@@ -0,0 +1,73 @@
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { CheckStatus } from '../../../../../shared/github/pull-request-types'
import type { HostedReviewState } from '../../../../../shared/hosted-review'
import { reviewRefreshIntervalMs } from '../../../../../shared/review-refresh-policy'
export function detailedChecksStatus(checks: readonly PRCheckDetail[]): CheckStatus | undefined {
if (checks.length === 0) {
return undefined
}
if (
checks.some(
(check) =>
check.status !== 'completed' ||
check.conclusion === 'pending' ||
check.conclusion === 'action_required' ||
check.conclusion === null
)
) {
return 'pending'
}
if (
checks.some((check) => ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? ''))
) {
return 'failure'
}
return checks.some((check) => check.conclusion === 'success') ? 'success' : 'neutral'
}
export class ChecksDetailPollingPolicy {
private status: CheckStatus | undefined
private hasDetails = false
private failed = false
reset(): void {
this.status = undefined
this.hasDetails = false
this.failed = false
}
accept(checks: readonly PRCheckDetail[]): string {
this.status = detailedChecksStatus(checks)
this.hasDetails = true
this.failed = false
return JSON.stringify(
checks.map((check) => `${check.name}:${check.status}:${check.conclusion}`)
)
}
fail(): void {
this.failed = true
}
delayMs(
state: HostedReviewState | undefined,
aggregateStatus: CheckStatus | undefined,
backoffMs: number
): number | null {
if (this.failed) {
return Math.max(state === 'closed' ? 900_000 : 60_000, backoffMs)
}
const status = this.hasDetails && this.status !== undefined ? this.status : aggregateStatus
const interval = reviewRefreshIntervalMs({
state,
checksStatus: status,
hasReview: true,
selected: true
})
if (interval === null) {
return null
}
return state === 'closed' ? interval : Math.max(interval, backoffMs)
}
}
@@ -0,0 +1,48 @@
import { vi } from 'vitest'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import type { useChecksPanelPolling } from './use-checks-panel-polling'
type PollingInput = Parameters<typeof useChecksPanelPolling>[0]
export function createModel(overrides: Partial<PollingInput> = {}): PollingInput {
const fetchPRChecks = vi.fn<() => Promise<PRCheckDetail[]>>().mockResolvedValue([])
return {
activeGitLabReview: null,
activeWorktree: null,
asyncResultKeyRef: { current: 'cache::main::42' },
branch: 'main',
fetchPRChecks,
hostedReviewCacheKey: 'hosted-review',
isCurrentAsyncResult: () => true,
isPanelVisible: true,
pollIntervalRef: { current: 30_000 },
pr: {
number: 42,
headSha: 'head-1',
prRepo: { owner: 'orca', repo: 'app', host: 'github.com' },
title: 'Review',
state: 'open',
url: '',
checksStatus: 'pending',
updatedAt: '',
mergeable: 'UNKNOWN'
},
prCacheKey: 'cache',
prNumber: 42,
prevChecksRef: { current: '' },
repo: {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'Repo',
badgeColor: '',
addedAt: 1
},
settings: null,
setChecks: vi.fn(),
setChecksLoading: vi.fn(),
setComments: vi.fn(),
setCommentsLoading: vi.fn(),
gitLabProjectRefRef: { current: null },
...overrides
}
}
@@ -18,7 +18,6 @@ export type ChecksPanelReviewStateInput = Pick<
| 'prRefreshStateNow'
| 'prCachedHasPR'
| 'prNumber'
| 'refreshContextKey'
> &
Pick<
ChecksPanelControllerState,
@@ -0,0 +1,149 @@
import { useEffect, useRef, type RefObject } from 'react'
import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../../shared/review-refresh-policy'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
import type { ChecksDetailPollingPolicy } from './checks-detail-polling-policy'
import type { ChecksPanelPollingInput, ChecksPanelPollingState } from './use-checks-panel-polling'
export function useChecksDetailTimer({
model,
modelRef,
policyRef,
requestIdentity,
fetchChecks,
fetchGitLabDetails
}: {
model: ChecksPanelPollingInput
modelRef: RefObject<ChecksPanelPollingInput>
policyRef: RefObject<ChecksDetailPollingPolicy>
requestIdentity: string
fetchChecks: ChecksPanelPollingState['fetchChecks']
fetchGitLabDetails: ChecksPanelPollingState['fetchGitLabDetails']
}): void {
const {
activeGitLabReview,
pr,
prNumber,
isPanelVisible,
setChecks,
setComments,
pollIntervalRef,
prevChecksRef
} = model
const refreshRef = useRef<(() => void) | null>(null)
const forceNextFetchRef = useRef(false)
const isGitLabReview = activeGitLabReview !== null
const lastIdentityRef = useRef<string | null>(null)
const lastAttemptAtRef = useRef(-Infinity)
const nextAttemptAtRef = useRef(-Infinity)
const inFlightRef = useRef<{ identity: string; token: symbol } | null>(null)
useEffect(() => {
if (lastIdentityRef.current !== requestIdentity) {
lastIdentityRef.current = requestIdentity
lastAttemptAtRef.current = -Infinity
nextAttemptAtRef.current = -Infinity
forceNextFetchRef.current = false
policyRef.current.reset()
pollIntervalRef.current = 60_000
prevChecksRef.current = ''
setChecks([])
setComments([])
}
if (!isPanelVisible || (!isGitLabReview && !prNumber)) {
return
}
const policyDelay = (): number | null => {
const current = modelRef.current
return policyRef.current.delayMs(
current.activeGitLabReview?.state ?? current.pr?.state,
current.activeGitLabReview?.status ?? current.pr?.checksStatus,
current.pollIntervalRef.current
)
}
const cleanup = installWindowVisibilityTimeoutPoller({
run: async () => {
if (inFlightRef.current?.identity === requestIdentity) {
return
}
const now = Date.now()
const nextAttemptAt = nextAttemptAtRef.current
if (
now - lastAttemptAtRef.current < REVIEW_REFRESH_COOLDOWN_MS ||
(Number.isFinite(nextAttemptAt)
? now < nextAttemptAt
: nextAttemptAt === Infinity &&
!forceNextFetchRef.current &&
now - lastAttemptAtRef.current < 60_000)
) {
return
}
const token = Symbol()
inFlightRef.current = { identity: requestIdentity, token }
lastAttemptAtRef.current = now
nextAttemptAtRef.current = Infinity
const force = forceNextFetchRef.current
forceNextFetchRef.current = false
try {
await (isGitLabReview ? fetchGitLabDetails() : fetchChecks({ force }))
} finally {
if (inFlightRef.current?.token === token) {
inFlightRef.current = null
if (lastIdentityRef.current === requestIdentity) {
const delay = policyDelay()
nextAttemptAtRef.current = delay === null ? Infinity : Date.now() + delay
refreshRef.current?.()
}
}
}
},
getDelayMs: () => {
const delay = policyDelay()
if (inFlightRef.current?.identity === requestIdentity) {
return delay
}
if (delay === null) {
return forceNextFetchRef.current
? Math.max(0, lastAttemptAtRef.current + REVIEW_REFRESH_COOLDOWN_MS - Date.now())
: null
}
return Number.isFinite(nextAttemptAtRef.current)
? Math.max(0, nextAttemptAtRef.current - Date.now())
: delay
}
})
refreshRef.current = cleanup.refresh
return () => {
refreshRef.current = null
cleanup()
}
}, [
requestIdentity,
modelRef,
policyRef,
fetchChecks,
fetchGitLabDetails,
isPanelVisible,
isGitLabReview,
prNumber,
pollIntervalRef,
prevChecksRef,
setChecks,
setComments
])
const aggregatePending = (activeGitLabReview?.status ?? pr?.checksStatus) === 'pending'
const reviewState = activeGitLabReview?.state ?? pr?.state
const previousPendingRef = useRef(aggregatePending)
useEffect(() => {
if (previousPendingRef.current !== aggregatePending) {
previousPendingRef.current = aggregatePending
forceNextFetchRef.current = true
if (aggregatePending) {
policyRef.current.reset()
}
if (nextAttemptAtRef.current === Infinity) {
nextAttemptAtRef.current = lastAttemptAtRef.current + REVIEW_REFRESH_COOLDOWN_MS
}
refreshRef.current?.()
}
}, [aggregatePending, reviewState, policyRef])
}
@@ -27,10 +27,8 @@ type ChecksPanelContextStateInput = Pick<
| 'commentResolutionLaunchAcceptedRef'
| 'conflictSummaryRefreshKeyRef'
| 'createPrInFlightRef'
| 'isPanelVisible'
| 'panelContextKey'
| 'panelContextKeyRef'
| 'panelVisibleSinceRef'
| 'pendingCommentResolutionRef'
| 'pollIntervalRef'
| 'prevChecksRef'
@@ -72,10 +70,8 @@ export function useChecksPanelContextState(model: ChecksPanelContextStateInput)
commentResolutionLaunchAcceptedRef,
conflictSummaryRefreshKeyRef,
createPrInFlightRef,
isPanelVisible,
panelContextKey,
panelContextKeyRef,
panelVisibleSinceRef,
pendingCommentResolutionRef,
pollIntervalRef,
prevChecksRef,
@@ -292,14 +288,6 @@ export function useChecksPanelContextState(model: ChecksPanelContextStateInput)
repo?.id
])
useEffect(() => {
if (!isPanelVisible) {
panelVisibleSinceRef.current = null
return
}
panelVisibleSinceRef.current = Date.now()
}, [isPanelVisible, panelContextKey, panelVisibleSinceRef])
// Why: drop unaccepted launch payloads when the panel switches context (refs stay pure in render).
useEffect(() => {
if (commentResolutionLaunchAcceptedRef.current) {
@@ -161,8 +161,6 @@ export function useChecksPanelControllerState() {
// fetched against the MR's own project rather than this repo's default remote.
const gitLabProjectRefRef = useRef<GitLabProjectRef | null>(null)
const conflictSummaryRefreshKeyRef = useRef<string | null>(null)
const panelVisibleSinceRef = useRef<number | null>(null)
const foregroundedUnrenderedReviewKeyRef = useRef<string | null>(null)
commentsRef.current = comments
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
@@ -362,8 +360,6 @@ export function useChecksPanelControllerState() {
prevChecksRef,
gitLabProjectRefRef,
conflictSummaryRefreshKeyRef,
panelVisibleSinceRef,
foregroundedUnrenderedReviewKeyRef,
prGenerationRecords,
allocatePullRequestGenerationRequestId,
setPullRequestGenerationRecord,
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { useChecksPanelForegroundEffects } from './use-checks-panel-foreground-effects'
function model() {
return {
activeWorktree: makeWorktree({ id: 'worktree', repoId: 'repo', branch: 'main', head: 'head' }),
activeWorktreeId: 'worktree',
branch: 'main',
enqueueGitHubPRRefresh: vi.fn(),
fetchHostedReviewForBranch: vi.fn().mockResolvedValue(null),
foregroundedUnrenderedReviewKeyRef: { current: null },
isPanelVisible: true,
panelVisibleSinceRef: { current: 1 },
repo: {
id: 'repo',
path: '/repo',
displayName: 'Repo',
badgeColor: '',
addedAt: 1
},
repoConnectionId: null,
runtimeEnvironmentId: null,
setGitStatusRefreshNonce: vi.fn(),
fallbackGitHubPRNumber: null,
isFolder: false,
linkedAzureDevOpsPR: null,
linkedBitbucketPR: null,
linkedGiteaPR: null,
linkedGitLabMR: null,
linkedPR: null,
prCachedHasPR: true,
foregroundReviewEvidenceKey: null,
isGitHubReviewContext: true,
prFetchedAt: 1
}
}
beforeEach(() => vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'))
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('checks foreground discovery', () => {
it('leaves initial and HEAD metadata discovery to the central owners', () => {
const input = model()
const hook = renderHook(({ input }) => useChecksPanelForegroundEffects(input), {
initialProps: { input }
})
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
hook.rerender({
input: {
...input,
repo: { ...input.repo }
}
})
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
expect(input.enqueueGitHubPRRefresh).not.toHaveBeenCalled()
hook.rerender({
input: {
...input,
activeWorktree: { ...input.activeWorktree, head: 'new-head' }
}
})
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
expect(input.enqueueGitHubPRRefresh).not.toHaveBeenCalled()
})
it('does no metadata discovery from hidden windows or folder panels', () => {
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const input = model()
const hook = renderHook(({ input }) => useChecksPanelForegroundEffects(input), {
initialProps: { input }
})
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
expect(input.enqueueGitHubPRRefresh).not.toHaveBeenCalled()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
hook.rerender({ input: { ...input, isFolder: true } })
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
})
it('retains runtime SSH status polling while stopping hidden work', async () => {
vi.useFakeTimers()
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
const input = { ...model(), runtimeEnvironmentId: 'owner', repoConnectionId: 'ssh' }
try {
renderHook(() => useChecksPanelForegroundEffects(input))
await act(async () => vi.advanceTimersByTimeAsync(3_000))
expect(input.setGitStatusRefreshNonce).toHaveBeenCalledOnce()
expect(input.fetchHostedReviewForBranch).not.toHaveBeenCalled()
expect(input.enqueueGitHubPRRefresh).not.toHaveBeenCalled()
visibility.mockReturnValue('hidden')
act(() => document.dispatchEvent(new Event('visibilitychange')))
await act(async () => vi.advanceTimersByTimeAsync(30_000))
expect(input.setGitStatusRefreshNonce).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
@@ -1,129 +1,21 @@
import { useEffect } from 'react'
import { resolveChecksPanelPRRefreshRequest } from '../checks-panel-pr-refresh-request'
import { shouldPollChecksPanelRuntimeSshStatus } from '../checks-panel-git-status-snapshot'
import type { ChecksPanelControllerState } from './use-checks-panel-controller-state'
import type { ChecksPanelContextState } from './use-checks-panel-context-state'
import type { ChecksPanelReviewState } from './use-checks-panel-review-state'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
type ChecksPanelForegroundEffectsInput = Pick<
ChecksPanelControllerState,
| 'activeWorktree'
| 'activeWorktreeId'
| 'branch'
| 'enqueueGitHubPRRefresh'
| 'fetchHostedReviewForBranch'
| 'foregroundedUnrenderedReviewKeyRef'
| 'isPanelVisible'
| 'panelVisibleSinceRef'
| 'repo'
| 'repoConnectionId'
| 'runtimeEnvironmentId'
| 'setGitStatusRefreshNonce'
> &
Pick<
ChecksPanelContextState,
| 'fallbackGitHubPRNumber'
| 'isFolder'
| 'linkedAzureDevOpsPR'
| 'linkedBitbucketPR'
| 'linkedGiteaPR'
| 'linkedGitLabMR'
| 'linkedPR'
| 'prCachedHasPR'
> &
Pick<
ChecksPanelReviewState,
'foregroundReviewEvidenceKey' | 'isGitHubReviewContext' | 'prFetchedAt'
>
'isPanelVisible' | 'repoConnectionId' | 'runtimeEnvironmentId' | 'setGitStatusRefreshNonce'
>
const RUNTIME_SSH_STATUS_REFRESH_MS = 3000
export function useChecksPanelForegroundEffects(model: ChecksPanelForegroundEffectsInput) {
const {
activeWorktree,
activeWorktreeId,
branch,
enqueueGitHubPRRefresh,
fallbackGitHubPRNumber,
fetchHostedReviewForBranch,
foregroundReviewEvidenceKey,
foregroundedUnrenderedReviewKeyRef,
isFolder,
isGitHubReviewContext,
isPanelVisible,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
panelVisibleSinceRef,
prCachedHasPR,
prFetchedAt,
repo,
repoConnectionId,
runtimeEnvironmentId,
setGitStatusRefreshNonce
} = model
useEffect(() => {
if (foregroundReviewEvidenceKey === null || !isPanelVisible) {
foregroundedUnrenderedReviewKeyRef.current = null
}
if (isPanelVisible && repo && !isFolder && branch) {
void fetchHostedReviewForBranch(repo.path, branch, {
repoId: repo.id,
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
currentHeadOid: activeWorktree?.head ?? null,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
staleWhileRevalidate: true,
// Why: this panel only ever renders the selected worktree, so it earns
// the host's fast re-check tier (#11532).
active: true
})
// Why: the gh-based refresh coordinator is GitHub-only; running it elsewhere gave a spurious gh_unavailable error hiding a valid composer.
if (activeWorktreeId && isGitHubReviewContext) {
const refreshRequest = resolveChecksPanelPRRefreshRequest({
cachedHasPR: prCachedHasPR,
cachedFetchedAt: prFetchedAt ?? null,
panelVisibleSince: panelVisibleSinceRef.current,
hasUnrenderedReviewEvidence: foregroundReviewEvidenceKey !== null,
hasRequestedForegroundRefresh:
foregroundReviewEvidenceKey !== null &&
foregroundedUnrenderedReviewKeyRef.current === foregroundReviewEvidenceKey
})
if (refreshRequest.reason === 'active' && foregroundReviewEvidenceKey !== null) {
foregroundedUnrenderedReviewKeyRef.current = foregroundReviewEvidenceKey
}
enqueueGitHubPRRefresh(activeWorktreeId, refreshRequest.reason, refreshRequest.priority)
}
}
}, [
activeWorktreeId,
branch,
enqueueGitHubPRRefresh,
fallbackGitHubPRNumber,
fetchHostedReviewForBranch,
foregroundReviewEvidenceKey,
isFolder,
isGitHubReviewContext,
isPanelVisible,
activeWorktree?.head,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
prCachedHasPR,
prFetchedAt,
repo,
panelVisibleSinceRef,
foregroundedUnrenderedReviewKeyRef
])
export function useChecksPanelForegroundEffects({
isPanelVisible,
repoConnectionId,
runtimeEnvironmentId,
setGitStatusRefreshNonce
}: ChecksPanelForegroundEffectsInput) {
useEffect(() => {
if (
!shouldPollChecksPanelRuntimeSshStatus({
@@ -0,0 +1,333 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { createModel } from './checks-panel-polling-test-model'
import { useChecksPanelPolling } from './use-checks-panel-polling'
const gitlab = vi.hoisted(() => ({ fetchDetails: vi.fn() }))
vi.mock('./gitlab-review-client', () => ({
fetchGitLabMRDetailsForChecks: gitlab.fetchDetails,
gitLabMRCommentsToPRComments: () => []
}))
const pending: PRCheckDetail[] = [{ name: 'Build', status: 'queued', conclusion: null, url: null }]
const settled: PRCheckDetail[] = [
{ name: 'Build', status: 'completed', conclusion: 'success', url: null }
]
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
gitlab.fetchDetails.mockReset().mockResolvedValue({ item: {}, pipelineJobs: [], comments: [] })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
async function advance(ms: number): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
describe('checks detail polling ownership', () => {
it('keeps pending details polling despite a settled aggregate and stops on settled details', async () => {
const model = createModel()
if (model.pr) {
model.pr = { ...model.pr, state: 'merged', checksStatus: 'success' }
}
const fetch = vi.fn().mockResolvedValueOnce(pending).mockResolvedValue(settled)
model.fetchPRChecks = fetch
renderHook(() => useChecksPanelPolling(model))
await advance(0)
expect(fetch).toHaveBeenCalledOnce()
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(900_000)
expect(fetch).toHaveBeenCalledTimes(2)
})
it('rechecks a settled review when pending returns, with the resume cooldown', async () => {
const model = createModel()
if (model.pr) {
model.pr = { ...model.pr, state: 'merged', checksStatus: 'success' }
}
const fetch = vi
.fn()
.mockResolvedValueOnce(settled)
.mockResolvedValueOnce(pending)
.mockResolvedValue(settled)
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(2000)
hook.rerender({
input: { ...model, pr: model.pr ? { ...model.pr, checksStatus: 'pending' } : null }
})
await advance(7999)
expect(fetch).toHaveBeenCalledOnce()
await advance(1)
expect(fetch).toHaveBeenCalledTimes(2)
expect(fetch).toHaveBeenLastCalledWith(
'/workspace/repo',
42,
'main',
'head-1',
model.pr?.prRepo,
expect.objectContaining({ force: true, throwOnError: true })
)
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(3)
await advance(900_000)
expect(fetch).toHaveBeenCalledTimes(3)
})
it('preserves the timer and backoff when repository cache objects are replaced', async () => {
const model = createModel()
const fetch = vi.fn().mockResolvedValue(pending)
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(30_000)
hook.rerender({
input: {
...model,
repo: model.repo ? { ...model.repo } : null,
pr: model.pr
? { ...model.pr, prRepo: model.pr.prRepo ? { ...model.pr.prRepo } : undefined }
: null
}
})
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(30_000)
expect(fetch).toHaveBeenCalledTimes(3)
})
it('preserves a GitLab details timer when its cache object is replaced', async () => {
const model = createModel({
activeGitLabReview: {
provider: 'gitlab',
number: 17,
headSha: 'head',
title: 'MR',
state: 'open',
url: '',
status: 'pending',
updatedAt: '',
mergeable: 'UNKNOWN'
}
})
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(0)
hook.rerender({
input: {
...model,
activeGitLabReview: model.activeGitLabReview ? { ...model.activeGitLabReview } : null
}
})
await advance(59_999)
expect(gitlab.fetchDetails).toHaveBeenCalledOnce()
await advance(1)
expect(gitlab.fetchDetails).toHaveBeenCalledTimes(2)
})
it('keeps good checks on errors and retries merged unknown details with backoff', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const model = createModel()
if (model.pr) {
model.pr = { ...model.pr, state: 'merged', checksStatus: 'success' }
}
const fetch = vi
.fn()
.mockResolvedValueOnce(pending)
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(settled)
model.fetchPRChecks = fetch
renderHook(() => useChecksPanelPolling(model))
await advance(60_000)
expect(model.setChecks).toHaveBeenCalledTimes(2)
expect(model.setChecks).toHaveBeenLastCalledWith(pending)
await advance(119_999)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(1)
expect(fetch).toHaveBeenCalledTimes(3)
await advance(900_000)
expect(fetch).toHaveBeenCalledTimes(3)
})
it('does no hidden work and coalesces visibility and focus return bursts', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const model = createModel()
const fetch = vi.fn().mockResolvedValue(pending)
model.fetchPRChecks = fetch
renderHook(() => useChecksPanelPolling(model))
await advance(900_000)
expect(fetch).not.toHaveBeenCalled()
visibility.mockReturnValue('visible')
act(() => document.dispatchEvent(new Event('visibilitychange')))
await advance(0)
act(() => window.dispatchEvent(new Event('focus')))
await advance(0)
expect(fetch).toHaveBeenCalledOnce()
})
it('retains failed detail backoff and good rows across panel hiding and reopening', async () => {
const model = createModel()
const fetch = vi.fn().mockResolvedValueOnce(pending).mockRejectedValue(new Error('offline'))
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(2)
const writes = vi.mocked(model.setChecks).mock.calls.length
await advance(1_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(0)
expect(fetch).toHaveBeenCalledTimes(2)
expect(model.setChecks).toHaveBeenCalledTimes(writes)
await advance(118_999)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(1)
expect(fetch).toHaveBeenCalledTimes(3)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(239_999)
expect(fetch).toHaveBeenCalledTimes(3)
await advance(1)
expect(fetch).toHaveBeenCalledTimes(4)
})
it('does not start a second detail request on reopening while the first is pending', async () => {
const model = createModel()
let finish: (checks: PRCheckDetail[]) => void = () => {}
const fetch = vi.fn(() => new Promise<PRCheckDetail[]>((resolve) => (finish = resolve)))
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(1_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(0)
expect(fetch).toHaveBeenCalledOnce()
await act(async () => finish(pending))
await advance(60_000)
expect(fetch).toHaveBeenCalledTimes(2)
})
it('keeps settled details stopped across a quick reopening and discovers stale exposure once', async () => {
const model = createModel()
if (model.pr) {
model.pr = { ...model.pr, state: 'merged', checksStatus: 'success' }
}
const fetch = vi.fn().mockResolvedValue(settled)
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(1_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(0)
expect(fetch).toHaveBeenCalledOnce()
await advance(59_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(0)
expect(fetch).toHaveBeenCalledTimes(2)
await advance(900_000)
expect(fetch).toHaveBeenCalledTimes(2)
})
it('clears details and starts discovery when the review identity changes after failure', async () => {
const model = createModel()
const fetch = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(settled)
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(1_000)
hook.rerender({
input: { ...model, pr: model.pr ? { ...model.pr, headSha: 'new-head' } : null }
})
await advance(0)
expect(fetch).toHaveBeenCalledTimes(2)
expect(fetch.mock.calls[1]?.[3]).toBe('new-head')
expect(model.setChecks).toHaveBeenLastCalledWith(settled)
})
it('retains GitLab failure backoff when the same panel is reopened', async () => {
gitlab.fetchDetails.mockRejectedValue(new Error('offline'))
const model = createModel({
activeGitLabReview: {
provider: 'gitlab',
number: 17,
headSha: 'head',
title: 'MR',
state: 'merged',
url: '',
status: 'success',
updatedAt: '',
mergeable: 'UNKNOWN'
}
})
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(1_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
hook.rerender({ input: { ...model, isPanelVisible: true } })
await advance(0)
expect(gitlab.fetchDetails).toHaveBeenCalledOnce()
await advance(118_999)
expect(gitlab.fetchDetails).toHaveBeenCalledOnce()
await advance(1)
expect(gitlab.fetchDetails).toHaveBeenCalledTimes(2)
})
it('retains a pending transition that arrives during a settled detail request', async () => {
const model = createModel()
if (model.pr) {
model.pr = { ...model.pr, state: 'merged', checksStatus: 'success' }
}
let finish: (checks: PRCheckDetail[]) => void = () => {}
const fetch = vi
.fn()
.mockImplementationOnce(() => new Promise<PRCheckDetail[]>((resolve) => (finish = resolve)))
.mockResolvedValue(settled)
model.fetchPRChecks = fetch
const hook = renderHook(({ input }) => useChecksPanelPolling(input), {
initialProps: { input: model }
})
await advance(1_000)
hook.rerender({
input: { ...model, pr: model.pr ? { ...model.pr, checksStatus: 'pending' } : null }
})
await act(async () => finish(settled))
await advance(8_999)
expect(fetch).toHaveBeenCalledOnce()
await advance(1)
expect(fetch).toHaveBeenCalledTimes(2)
expect(fetch).toHaveBeenLastCalledWith(
'/workspace/repo',
42,
'main',
'head-1',
model.pr?.prRepo,
expect.objectContaining({ force: true })
)
})
})
@@ -1,26 +1,32 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import type { PRCheckDetail } from '../../../../../shared/github/check-types'
import { getDefaultSettings } from '../../../../../shared/constants'
import type * as GitLabReviewClient from './gitlab-review-client'
const poller = vi.hoisted(() => ({
const poller = vi.hoisted<{
install: Mock<() => void>
run: null | (() => Promise<void> | void)
getDelayMs: null | (() => number | null)
cleanup: Mock<() => void>
}>(() => ({
install: vi.fn(),
run: null as null | (() => Promise<void> | void),
getDelayMs: null as null | (() => number),
run: null,
getDelayMs: null,
cleanup: vi.fn()
}))
const gitlab = vi.hoisted(() => ({ fetchDetails: vi.fn() }))
vi.mock('@/lib/window-visibility-timeout-poller', () => ({
installWindowVisibilityTimeoutPoller: vi.fn(
(config: { run: () => Promise<void> | void; getDelayMs: () => number }) => {
(config: { run: () => Promise<void> | void; getDelayMs: () => number | null }) => {
poller.run = config.run
poller.getDelayMs = config.getDelayMs
poller.install()
return poller.cleanup
return Object.assign(poller.cleanup, { refresh: vi.fn() })
}
)
}))
@@ -31,40 +37,11 @@ vi.mock('./gitlab-review-client', async (importOriginal) => {
import { useChecksPanelPolling } from './use-checks-panel-polling'
type PollingInput = Parameters<typeof useChecksPanelPolling>[0]
function createModel(overrides: Partial<PollingInput> = {}): PollingInput {
const fetchPRChecks = vi.fn<() => Promise<PRCheckDetail[]>>().mockResolvedValue([])
return {
activeGitLabReview: null,
activeWorktree: null,
asyncResultKeyRef: { current: 'cache::main::42' },
branch: 'main',
fetchPRChecks,
hostedReviewCacheKey: 'hosted-review',
isCurrentAsyncResult: () => true,
isPanelVisible: true,
pollIntervalRef: { current: 30_000 },
pr: {
number: 42,
headSha: 'head-1',
prRepo: { owner: 'orca', repo: 'app', host: 'github.com' }
} as NonNullable<PollingInput['pr']>,
prCacheKey: 'cache',
prNumber: 42,
prevChecksRef: { current: '' },
repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable<PollingInput['repo']>,
settings: null,
setChecks: vi.fn(),
setChecksLoading: vi.fn(),
setComments: vi.fn(),
setCommentsLoading: vi.fn(),
gitLabProjectRefRef: { current: null },
...overrides
}
}
import { createModel } from './checks-panel-polling-test-model'
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
poller.install.mockReset()
poller.cleanup.mockReset()
poller.run = null
@@ -78,6 +55,7 @@ beforeEach(() => {
afterEach(() => {
cleanup()
vi.useRealTimers()
})
describe('useChecksPanelPolling live behavior', () => {
@@ -91,22 +69,39 @@ describe('useChecksPanelPolling live behavior', () => {
hook.rerender({ input: { ...model, isPanelVisible: true } })
expect(poller.install).toHaveBeenCalledOnce()
expect(poller.getDelayMs?.()).toBe(30_000)
expect(poller.getDelayMs?.()).toBe(60_000)
hook.rerender({ input: { ...model, isPanelVisible: false } })
expect(poller.cleanup).toHaveBeenCalledOnce()
})
it('preserves live repeated-empty backoff at 30, 60, then 120 seconds', async () => {
it.each([
['merged', 'success', null],
['merged', 'pending', 60_000],
['closed', 'success', 15 * 60_000]
] as const)('paces %s checks with %s status', (state, checksStatus, interval) => {
const model = createModel()
renderHook(() =>
useChecksPanelPolling({
...model,
pr: model.pr ? { ...model.pr, state, checksStatus } : null
})
)
expect(poller.getDelayMs?.()).toBe(interval)
})
it('preserves live repeated-empty backoff at 60, then 120 seconds', async () => {
const model = createModel()
renderHook(() => useChecksPanelPolling(model))
await act(async () => poller.run?.())
expect(model.pollIntervalRef.current).toBe(30_000)
expect(poller.getDelayMs?.()).toBe(30_000)
await act(async () => poller.run?.())
expect(model.pollIntervalRef.current).toBe(60_000)
expect(poller.getDelayMs?.()).toBe(60_000)
vi.setSystemTime(Date.now() + 60_000)
await act(async () => poller.run?.())
expect(model.pollIntervalRef.current).toBe(120_000)
expect(poller.getDelayMs?.()).toBe(120_000)
vi.setSystemTime(Date.now() + 120_000)
await act(async () => poller.run?.())
expect(model.pollIntervalRef.current).toBe(120_000)
expect(poller.getDelayMs?.()).toBe(120_000)
@@ -117,8 +112,14 @@ describe('useChecksPanelPolling live behavior', () => {
activeGitLabReview: {
provider: 'gitlab',
number: 17,
headSha: 'gitlab-head'
} as NonNullable<PollingInput['activeGitLabReview']>
headSha: 'gitlab-head',
title: 'MR',
state: 'open',
url: '',
status: 'pending',
updatedAt: '',
mergeable: 'UNKNOWN'
}
})
renderHook(() => useChecksPanelPolling(model))
@@ -130,20 +131,27 @@ describe('useChecksPanelPolling live behavior', () => {
it('uses an explicit owner and missing head override for a replacement MR', async () => {
const ownerSettings = {
...getDefaultSettings('/tmp'),
activeRuntimeEnvironmentId: 'owner-runtime'
} as PollingInput['settings']
}
const model = createModel({
activeGitLabReview: {
provider: 'gitlab',
number: 17,
headSha: 'old-head'
} as NonNullable<PollingInput['activeGitLabReview']>,
headSha: 'old-head',
title: 'MR',
state: 'open',
url: '',
status: 'pending',
updatedAt: '',
mergeable: 'UNKNOWN'
},
activeWorktree: makeWorktree({
id: 'worktree-1',
repoId: 'repo-1',
hostId: 'runtime:owner-runtime'
}),
settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as PollingInput['settings']
settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'focused-runtime' }
})
const { result } = renderHook(() => useChecksPanelPolling(model))
@@ -196,8 +204,8 @@ describe('useChecksPanelPolling live behavior', () => {
})
await act(async () => request)
expect(model.setChecks).not.toHaveBeenCalled()
expect(model.setComments).not.toHaveBeenCalled()
expect(model.setChecks).toHaveBeenCalledExactlyOnceWith([])
expect(model.setComments).toHaveBeenCalledExactlyOnceWith([])
expect(model.setChecksLoading).toHaveBeenLastCalledWith(false)
expect(model.setCommentsLoading).toHaveBeenLastCalledWith(false)
})
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller'
import { useChecksDetailTimer } from './use-checks-detail-timer'
import { ChecksDetailPollingPolicy } from './checks-detail-polling-policy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { gitLabPipelineJobsToPRChecks } from '../../../../../shared/gitlab-pipeline-checks'
import {
checksPanelAsyncResultKey,
@@ -10,7 +11,7 @@ import type { ChecksPanelControllerState } from './use-checks-panel-controller-s
import type { ChecksPanelComposerState } from './use-checks-panel-composer-state'
import { fetchGitLabMRDetailsForChecks, gitLabMRCommentsToPRComments } from './gitlab-review-client'
type ChecksPanelPollingInput = Pick<
export type ChecksPanelPollingInput = Pick<
ChecksPanelContextState,
'activeGitLabReview' | 'hostedReviewCacheKey' | 'pr' | 'prCacheKey' | 'prNumber'
> &
@@ -34,28 +35,28 @@ type ChecksPanelPollingInput = Pick<
Pick<ChecksPanelComposerState, 'isCurrentAsyncResult'>
export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
const {
activeWorktree,
activeGitLabReview,
asyncResultKeyRef,
const modelRef = useRef(model)
const [policy] = useState(() => new ChecksDetailPollingPolicy())
const policyRef = useRef(policy)
useEffect(() => {
modelRef.current = model
})
const { activeGitLabReview, pr, prNumber, prCacheKey, branch, repo, hostedReviewCacheKey } = model
const requestIdentity = JSON.stringify([
repo?.id,
repo?.path,
repo?.executionHostId,
repo?.connectionId,
model.activeWorktree?.hostId,
branch,
fetchPRChecks,
hostedReviewCacheKey,
isCurrentAsyncResult,
isPanelVisible,
pollIntervalRef,
pr,
prCacheKey,
prNumber,
prevChecksRef,
repo,
settings,
setChecks,
setChecksLoading,
setComments,
setCommentsLoading,
gitLabProjectRefRef
} = model
activeGitLabReview ? 'gitlab' : 'github',
activeGitLabReview?.number ?? prNumber,
activeGitLabReview?.headSha ?? pr?.headSha,
pr?.prRepo?.owner,
pr?.prRepo?.repo,
pr?.prRepo?.host,
activeGitLabReview ? hostedReviewCacheKey : prCacheKey
])
const gitLabDetailsLoadingGenerationRef = useRef(0)
// Fetch checks via cached store method
const fetchChecks = useCallback(
@@ -63,6 +64,19 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
force = false,
prNumberOverride
}: { force?: boolean; prNumberOverride?: number | null } = {}) => {
const {
repo,
prNumber,
branch,
pr,
prCacheKey,
fetchPRChecks,
isCurrentAsyncResult,
setChecksLoading,
setChecks,
pollIntervalRef,
prevChecksRef
} = modelRef.current
const targetPRNumber = prNumberOverride ?? prNumber
if (!repo || !targetPRNumber) {
return
@@ -84,6 +98,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
pr?.prRepo,
{
force,
throwOnError: true,
repoId: repo.id
}
)
@@ -92,12 +107,12 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
}
setChecks(result)
// Exponential backoff: unchanged checks double the interval (cap 120s), changes reset to 30s.
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
// Unchanged details back off; a changed result restores the selected cadence.
const signature = policyRef.current.accept(result)
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
: 60_000
prevChecksRef.current = signature
} catch (err) {
if (
@@ -108,7 +123,8 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
return
}
console.warn('Failed to fetch PR checks:', err)
setChecks([])
policyRef.current.fail()
pollIntervalRef.current = Math.min(Math.max(60_000, pollIntervalRef.current * 2), 900_000)
} finally {
if (
isCurrentAsyncResult(
@@ -119,20 +135,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
}
}
},
[
repo,
prNumber,
branch,
pr?.headSha,
pr?.prRepo,
prCacheKey,
fetchPRChecks,
isCurrentAsyncResult,
prevChecksRef,
setChecksLoading,
pollIntervalRef,
setChecks
]
[]
)
const fetchGitLabDetails = useCallback(
@@ -149,6 +152,23 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
settingsOverride?: ChecksPanelControllerState['settings']
isRequestCurrent?: () => boolean
} = {}) => {
const {
activeGitLabReview,
repo,
branch,
hostedReviewCacheKey,
asyncResultKeyRef,
settings,
activeWorktree,
isCurrentAsyncResult,
gitLabProjectRefRef,
setChecks,
setChecksLoading,
setComments,
setCommentsLoading,
pollIntervalRef,
prevChecksRef
} = modelRef.current
const targetMRNumber = mrNumberOverride ?? activeGitLabReview?.number ?? null
const targetHeadSha =
headShaOverride === undefined ? (activeGitLabReview?.headSha ?? null) : headShaOverride
@@ -183,23 +203,26 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) {
return
}
gitLabProjectRefRef.current = details?.item.projectRef ?? null
if (details === null) {
throw new Error('GitLab MR details are unavailable')
}
gitLabProjectRefRef.current = details.item.projectRef ?? null
const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? [])
setChecks(result)
setComments(gitLabMRCommentsToPRComments(details?.comments))
const signature = JSON.stringify(result.map((c) => `${c.name}:${c.status}:${c.conclusion}`))
const signature = policyRef.current.accept(result)
pollIntervalRef.current =
signature === prevChecksRef.current
? Math.min(pollIntervalRef.current * 2, 120_000)
: 30_000
: 60_000
prevChecksRef.current = signature
} catch (err) {
if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) {
return
}
console.warn('Failed to fetch GitLab MR checks:', err)
setChecks([])
setComments([])
policyRef.current.fail()
pollIntervalRef.current = Math.min(Math.max(60_000, pollIntervalRef.current * 2), 900_000)
} finally {
if (
gitLabDetailsLoadingGenerationRef.current === loadingGeneration &&
@@ -210,66 +233,18 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) {
}
}
},
[
activeGitLabReview?.headSha,
activeGitLabReview?.number,
activeWorktree?.hostId,
branch,
hostedReviewCacheKey,
isCurrentAsyncResult,
repo,
settings,
asyncResultKeyRef,
setChecksLoading,
prevChecksRef,
pollIntervalRef,
setCommentsLoading,
setChecks,
setComments,
gitLabProjectRefRef
]
[]
)
// Fetch checks on mount + poll with exponential backoff
useEffect(() => {
if (activeGitLabReview) {
return
}
if (!prNumber || !isPanelVisible) {
setChecks([])
return
}
// Reset backoff state on PR change
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
// Why: check status is user-visible; keep visible unfocused windows fresh but stop timers/API work while hidden.
return installWindowVisibilityTimeoutPoller({
run: () => fetchChecks(),
getDelayMs: () => pollIntervalRef.current
})
}, [
activeGitLabReview,
useChecksDetailTimer({
model,
modelRef,
policyRef,
requestIdentity,
fetchChecks,
isPanelVisible,
prNumber,
pollIntervalRef,
prevChecksRef,
setChecks
])
fetchGitLabDetails
})
useEffect(() => {
if (!activeGitLabReview || !isPanelVisible) {
return
}
pollIntervalRef.current = 30_000
prevChecksRef.current = ''
return installWindowVisibilityTimeoutPoller({
run: () => fetchGitLabDetails(),
getDelayMs: () => pollIntervalRef.current
})
}, [activeGitLabReview, fetchGitLabDetails, isPanelVisible, pollIntervalRef, prevChecksRef])
return { fetchChecks, fetchGitLabDetails }
}
@@ -10,10 +10,6 @@ import {
import { resolveHostedReviewCreationProvider } from '../../../../../shared/hosted-review-creation-providers'
import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy'
import { resolveChecksPanelReviewLookup } from '../checks-panel-review-lookup-authority'
import {
getChecksPanelForegroundReviewEvidenceKey,
resolveChecksPanelReviewEvidenceProvider
} from '../checks-panel-pr-refresh-request'
import {
computeChecksPanelConfirmedReadiness,
isChecksPanelHardErrorCleared,
@@ -64,7 +60,6 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) {
prCachedHasPR,
prGenerationRecords,
prNumber,
refreshContextKey,
remoteStatusInvalidation,
repo,
repoConnectionId,
@@ -208,32 +203,6 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) {
eligibilityReview: hostedReviewCreation?.review ?? null
})
const checksPanelReviewLookup = checksPanelReviewLookupResult.state
const hasUnrenderedReviewEvidence =
checksPanelReviewLookup === 'positive_unresolved' ||
(checksPanelReviewLookup !== 'found' &&
hostedReviewCreation?.blockedReason === 'existing_review')
const unrenderedReviewEvidenceIdentity =
linkedReviewNumber ??
hostedReview?.number ??
hostedReviewCreation?.review?.number ??
checksPanelReviewLookupResult.openReviewUrl ??
'unknown'
const unrenderedReviewEvidenceProvider = resolveChecksPanelReviewEvidenceProvider({
linkedGitHubPR: linkedPR,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
eligibilityProvider: hostedReviewCreation?.provider,
cachedProvider: hostedReview?.provider
})
const foregroundReviewEvidenceKey = getChecksPanelForegroundReviewEvidenceKey({
refreshContextKey,
reviewEvidenceIdentity: unrenderedReviewEvidenceIdentity,
reviewEvidenceProvider: unrenderedReviewEvidenceProvider,
hasUnrenderedReviewEvidence,
isGitHubReviewContext
})
// Confirmed readiness from the last eligibility snapshot, not live canCreate (which would be circular and flap during transient failures).
const hardErrorObservedAt =
isGitHubReviewContext && hardRefreshError && hardRefreshError.contextKey === panelContextKey
@@ -357,10 +326,6 @@ export function useChecksPanelReviewState(model: ChecksPanelReviewStateInput) {
prCachedHasPRForContext,
checksPanelReviewLookupResult,
checksPanelReviewLookup,
hasUnrenderedReviewEvidence,
unrenderedReviewEvidenceIdentity,
unrenderedReviewEvidenceProvider,
foregroundReviewEvidenceKey,
hardErrorObservedAt,
confirmedReadinessInput,
confirmedReadiness,
@@ -1,105 +0,0 @@
import { useEffect } from 'react'
import type { SourceControlStoreActions } from '../listing/use-store-actions'
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
import type { SourceControlLinkedReviews } from './use-linked-reviews'
/**
* Keeps the hosted review for the visible branch fresh: resolves the review's push target when the
* worktree has none, then refetches the review (and the paced GitHub cache) on branch changes.
*/
export function useSourceControlHostedReviewPolling({
activeRepo,
activeWorktree,
activeWorktreeId,
branchName,
enqueueGitHubPRRefresh,
ensureHostedReviewPushTarget,
fallbackGitHubPRNumber,
fetchHostedReviewForBranch,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGitHubPR,
linkedGitLabMR,
linkedGiteaPR
}: {
activeRepo: SourceControlWorktreeContext['activeRepo']
activeWorktree: SourceControlWorktreeContext['activeWorktree']
activeWorktreeId: string | null
branchName: string
enqueueGitHubPRRefresh: SourceControlStoreActions['enqueueGitHubPRRefresh']
ensureHostedReviewPushTarget: SourceControlStoreActions['ensureHostedReviewPushTarget']
fallbackGitHubPRNumber: SourceControlLinkedReviews['fallbackGitHubPRNumber']
fetchHostedReviewForBranch: SourceControlStoreActions['fetchHostedReviewForBranch']
hasResolvableReviewPushTargetLink: boolean
isBranchVisible: boolean
isFolder: boolean
linkedAzureDevOpsPR: SourceControlLinkedReviews['linkedAzureDevOpsPR']
linkedBitbucketPR: SourceControlLinkedReviews['linkedBitbucketPR']
linkedGitHubPR: SourceControlLinkedReviews['linkedGitHubPR']
linkedGitLabMR: SourceControlLinkedReviews['linkedGitLabMR']
linkedGiteaPR: SourceControlLinkedReviews['linkedGiteaPR']
}): void {
useEffect(() => {
// Why: resolving review heads can hit provider/SSH APIs; gate on the visible branch view like the adjacent PR polling.
if (!isBranchVisible || isFolder || !activeWorktreeId || activeWorktree?.pushTarget) {
return
}
if (!hasResolvableReviewPushTargetLink) {
return
}
void ensureHostedReviewPushTarget(activeWorktreeId)
}, [
activeWorktree?.pushTarget,
activeWorktreeId,
ensureHostedReviewPushTarget,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder
])
useEffect(() => {
if (
!isBranchVisible ||
!activeRepo ||
isFolder ||
!branchName ||
branchName === 'HEAD' ||
!activeWorktreeId
) {
return
}
// Why: fetch review immediately on branch change; carry a known PR number because branch lookup is lossy for fork/deleted-head PRs.
void fetchHostedReviewForBranch(activeRepo.path, branchName, {
repoId: activeRepo.id,
linkedGitHubPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
staleWhileRevalidate: true,
// Why: scoped to the active worktree, so it earns the host's fast
// re-check tier instead of the O(N) card pacing (#11532).
active: true
})
// Why: keep the GitHub cache refresh behind the coordinator so Source Control doesn't bypass pacing.
enqueueGitHubPRRefresh(activeWorktreeId, 'swr', 30)
}, [
activeRepo,
activeWorktreeId,
branchName,
enqueueGitHubPRRefresh,
fetchHostedReviewForBranch,
isBranchVisible,
isFolder,
linkedGitHubPR,
fallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
])
}
@@ -2,7 +2,7 @@ import type { SourceControlPanelState } from '../panel/use-panel-state'
import { useSourceControlBaseRefs } from '../sync/use-base-refs'
import { useSourceControlBranchCompare } from '../sync/use-branch-compare'
import { useSourceControlCreatePrIntentTarget } from './use-create-pr-intent-target'
import { useSourceControlHostedReviewPolling } from './use-hosted-review-polling'
import { useSourceControlReviewPushTarget } from './use-review-push-target'
import { useSourceControlHostedReviewProviderHint } from './use-hosted-review-provider-hint'
import { useSourceControlHostedReviewState } from './use-hosted-review-state'
import { useSourceControlLinkedReviews } from './use-linked-reviews'
@@ -26,9 +26,7 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat
activeWorktreeId,
branchName,
createPrIntentCurrentTargetRef,
enqueueGitHubPRRefresh,
ensureHostedReviewPushTarget,
fetchHostedReviewForBranch,
hostedReviewCacheKey,
hostedReviewEntry,
hostedReviewEntryData,
@@ -129,23 +127,13 @@ export function useSourceControlReviewContext(panelState: SourceControlPanelStat
linkedGitLabMR,
linkedGiteaPR
})
useSourceControlHostedReviewPolling({
activeRepo,
useSourceControlReviewPushTarget({
activeWorktree,
activeWorktreeId,
branchName,
enqueueGitHubPRRefresh,
ensureHostedReviewPushTarget,
fallbackGitHubPRNumber,
fetchHostedReviewForBranch,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder,
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGitHubPR,
linkedGitLabMR,
linkedGiteaPR
isFolder
})
const suppressedGitHubPRState = resolveSourceControlSuppressedGitHubPRState({
worktree: activeWorktree ?? null,
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { useSourceControlReviewPushTarget } from './use-review-push-target'
beforeEach(() => vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'))
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resolves a linked review push target only for a visible branch needing it', () => {
const ensure = vi.fn()
const input = {
activeWorktree: makeWorktree({ id: 'worktree', repoId: 'repo' }),
activeWorktreeId: 'worktree',
ensureHostedReviewPushTarget: ensure,
hasResolvableReviewPushTargetLink: true,
isBranchVisible: false,
isFolder: false
}
const hook = renderHook(({ input }) => useSourceControlReviewPushTarget(input), {
initialProps: { input }
})
expect(ensure).not.toHaveBeenCalled()
hook.rerender({ input: { ...input, isBranchVisible: true } })
expect(ensure).toHaveBeenCalledExactlyOnceWith('worktree')
hook.rerender({
input: { ...input, isBranchVisible: true, activeWorktree: { ...input.activeWorktree } }
})
expect(ensure).toHaveBeenCalledOnce()
})
it('does not resolve a linked target while the window is hidden', () => {
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const ensure = vi.fn()
renderHook(() =>
useSourceControlReviewPushTarget({
activeWorktree: makeWorktree({ id: 'worktree', repoId: 'repo' }),
activeWorktreeId: 'worktree',
ensureHostedReviewPushTarget: ensure,
hasResolvableReviewPushTargetLink: true,
isBranchVisible: true,
isFolder: false
})
)
expect(ensure).not.toHaveBeenCalled()
})
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import type { SourceControlStoreActions } from '../listing/use-store-actions'
import type { SourceControlWorktreeContext } from '../listing/use-worktree-context'
export function useSourceControlReviewPushTarget({
activeWorktree,
activeWorktreeId,
ensureHostedReviewPushTarget,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder
}: {
activeWorktree: SourceControlWorktreeContext['activeWorktree']
activeWorktreeId: string | null
ensureHostedReviewPushTarget: SourceControlStoreActions['ensureHostedReviewPushTarget']
hasResolvableReviewPushTargetLink: boolean
isBranchVisible: boolean
isFolder: boolean
}): void {
useEffect(() => {
if (
!isWindowVisible() ||
!isBranchVisible ||
isFolder ||
!activeWorktreeId ||
activeWorktree?.pushTarget ||
!hasResolvableReviewPushTargetLink
) {
return
}
void ensureHostedReviewPushTarget(activeWorktreeId)
}, [
activeWorktree?.pushTarget,
activeWorktreeId,
ensureHostedReviewPushTarget,
hasResolvableReviewPushTargetLink,
isBranchVisible,
isFolder
])
}
@@ -132,30 +132,15 @@ describe('WorktreeCard hosted review refresh', () => {
vi.useRealTimers()
})
it('polls visible hosted review cards after a cached branch miss', async () => {
it('leaves repeating metadata reads to the central visible-review scheduler', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
act(() => {
root?.render(<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />)
})
expect(fetchHostedReviewForBranch).toHaveBeenCalledTimes(1)
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(fetchHostedReviewForBranch).toHaveBeenCalledTimes(2)
expect(fetchHostedReviewForBranch).toHaveBeenLastCalledWith('/repo', 'feature/branch', {
repoId: 'repo-1',
linkedGitHubPR: null,
currentHeadOid: 'abc123',
linkedGitLabMR: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
staleWhileRevalidate: true
await act(async () => {
await vi.advanceTimersByTimeAsync(15 * 60_000)
})
expect(fetchHostedReviewForBranch).not.toHaveBeenCalled()
})
it('does not poll hosted reviews when status and PR surfaces are hidden', async () => {
@@ -16,6 +16,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(),
activateWorktreeFromSidebar: vi.fn(),
openModal: vi.fn()
}))
@@ -218,6 +219,7 @@ function setFlatWorktreeState(): void {
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds,
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
@@ -35,6 +35,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(),
activateWorktreeFromSidebar: vi.fn(),
openModal: vi.fn()
}))
@@ -331,6 +332,7 @@ function setAgentLineageState(options: {
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds,
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
@@ -24,6 +24,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(),
activateWorktreeFromSidebar: vi.fn(),
openModal: vi.fn(),
updateWorktreeMeta: vi.fn(),
@@ -308,6 +309,7 @@ function setLineageState(
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds,
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
@@ -23,6 +23,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
setVisibleReviewCardWorktreeIds: vi.fn<(ids: readonly string[]) => void>(),
activateWorktreeFromSidebar: vi.fn(),
openModal: vi.fn(),
updateWorktreeMeta: vi.fn(),
@@ -279,6 +280,7 @@ function setStatusLaneState(): void {
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds,
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
@@ -2,11 +2,7 @@ import { useEffect } from 'react'
import { isMacAppDataPath } from '@/lib/passive-macos-app-data-access'
import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval'
import {
HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS,
isWebClient,
type WorktreeCardProps
} from './worktree-card-model'
import { isWebClient, type WorktreeCardProps } from './worktree-card-model'
import type { useWorktreeCardFoundation } from './use-worktree-card-foundation'
import type { useWorktreeCardReviewDetails } from './use-worktree-card-review-details'
@@ -55,65 +51,11 @@ export function useWorktreeCardLifecycleEffects({
showIssue: boolean
showLinearIssue: boolean
}): void {
// Why: card surfaces are presentational, so skip hosted-review fetches when hidden to save rate-limit budget.
useEffect(() => {
// Why: paired web must not fan out per-card decoration RPCs during startup; host session/tab parity is critical.
if (isWebClient()) {
return
}
if (
!repo ||
isFolder ||
worktree.isBare ||
!hostedReviewCacheKey ||
!shouldRefreshHostedReview ||
isMacAppDataPath(repo.path)
) {
return
}
const refreshHostedReview = (): void => {
// Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR number from explicit metadata when we have one.
void fetchHostedReviewForBranch(repo.path, branch, {
repoId: repo.id,
linkedGitHubPR: worktree.linkedPR ?? null,
...(cachedBranchFallbackGitHubPRNumber !== null
? { fallbackGitHubPR: cachedBranchFallbackGitHubPRNumber }
: {}),
currentHeadOid: worktree.head ?? null,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
staleWhileRevalidate: true
})
}
// Why: PRs created outside Orca (e.g. `gh pr create`) emit no renderer event; poll visible cards to discover them.
return installWindowVisibilityInterval({
run: refreshHostedReview,
jitterOnVisible: true,
intervalMs: HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS
})
}, [
repo,
isFolder,
worktree.isBare,
worktree.linkedPR,
worktree.head,
cachedBranchFallbackGitHubPRNumber,
linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR,
fetchHostedReviewForBranch,
branch,
hostedReviewCacheKey,
shouldRefreshHostedReview
])
useEffect(() => {
if (
!newCardStyle ||
!hoverDetailsOpen ||
!isWindowVisible() ||
shouldRefreshHostedReview ||
isWebClient() ||
!repo ||
@@ -1,23 +1,70 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect } from 'react'
import type React from 'react'
import type { VirtualItem } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { WorktreeGroupBy } from '../grouping/row-types'
import type { RenderRow } from '../listing/render-row'
import type { WorktreeItemRow } from '../listing/renderable-rows'
import { getMountedWorktreeOptions } from '../rows/option-dom'
export function installWorktreeVisibleRefreshVisibilityListener(onChange: () => void): () => void {
document.addEventListener('visibilitychange', onChange)
return () => document.removeEventListener('visibilitychange', onChange)
}
const DOCUMENT_HIDDEN_KEY = '__document_hidden__'
const NOTHING_TO_TRACK_KEY = '__hidden__'
export function installVisibleReviewCardScrollListener(
scroll: Pick<HTMLElement, 'addEventListener' | 'removeEventListener'>,
update: () => void
): () => void {
let frame: number | null = null
const onScroll = (): void => {
if (frame !== null) {
return
}
frame = requestAnimationFrame(() => {
frame = null
update()
})
}
scroll.addEventListener('scroll', onScroll, { passive: true })
return () => {
scroll.removeEventListener('scroll', onScroll)
if (frame !== null) {
cancelAnimationFrame(frame)
}
}
}
export function visibleReviewCardIds(args: {
enabled: boolean
renderRows: RenderRow[]
virtualItems: readonly VirtualItem[]
viewportTop: number
viewportHeight: number
isOnScreen?: (id: string) => boolean
}): string[] {
if (!args.enabled) {
return []
}
const bottom = args.viewportTop + args.viewportHeight
return args.virtualItems
.filter((item) => item.start < bottom && item.end > args.viewportTop)
.map((item) => args.renderRows[item.index])
.flatMap((row): WorktreeItemRow[] =>
row?.type === 'lineage-group' ? row.rows : row?.type === 'item' ? [row] : []
)
.filter(
(row) =>
(row.repo?.kind ?? 'git') === 'git' &&
!row.worktree.isBare &&
!row.worktree.isArchived &&
Boolean(row.worktree.branch)
)
.map((row) => row.worktree.id)
.filter((id) => args.isOnScreen?.(id) ?? true)
}
// Reports which sidebar rows are on screen so the GitHub PR/CI coordinator can refresh
// exactly those, and no more.
export function useVisiblePrRefreshReporting(args: {
currentWorktreeId: string | null
worktreeMap: Map<string, Worktree>
@@ -27,108 +74,50 @@ export function useVisiblePrRefreshReporting(args: {
virtualItems: readonly VirtualItem[]
scrollRef: React.RefObject<HTMLDivElement | null>
}): void {
const {
currentWorktreeId,
worktreeMap,
groupBy,
newCardStyle,
renderRows,
virtualItems,
scrollRef
} = args
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
const lastVisibleRefreshKeyRef = useRef('')
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
(s) => s.reportVisibleGitHubPRRefreshCandidates
)
const publish = useAppStore((s) => s.setVisibleReviewCardWorktreeIds)
const cardProps = useAppStore((s) => s.worktreeCardProperties)
const rightSidebarShowsPR = useAppStore((s) => rightSidebarShowsPullRequestData(s))
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
useEffect(
() =>
installWorktreeVisibleRefreshVisibilityListener(() => {
if (document.visibilityState !== 'visible') {
// Why: row identity may be unchanged after a hidden window; reset the key so PR/CI rows refresh.
lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY
return
}
setDocumentVisibilityRevision((revision) => revision + 1)
}),
[]
)
const enabled =
args.groupBy === 'pr-status' ||
(args.newCardStyle
? cardProps.includes('status')
: cardProps.includes('pr') || cardProps.includes('ci'))
useEffect(() => {
if (document.visibilityState !== 'visible') {
lastVisibleRefreshKeyRef.current = DOCUMENT_HIDDEN_KEY
return
const update = (): void => {
const scroll = args.scrollRef.current
const viewport = scroll?.getBoundingClientRect()
publish(
scroll && document.visibilityState === 'visible'
? visibleReviewCardIds({
enabled,
renderRows: args.renderRows,
virtualItems: args.virtualItems,
viewportTop: scroll.scrollTop,
viewportHeight: scroll.clientHeight,
isOnScreen: (id) =>
getMountedWorktreeOptions(id, scroll).some((option) => {
const surface = option.querySelector('[data-worktree-card-surface]')
const bounds = (surface?.firstElementChild ?? option).getBoundingClientRect()
return (
viewport !== undefined &&
bounds.height > 0 &&
bounds.top < viewport.bottom &&
bounds.bottom > viewport.top
)
})
})
: []
)
}
const currentWorktree = currentWorktreeId ? (worktreeMap.get(currentWorktreeId) ?? null) : null
// Why: this reporter feeds the GitHub coordinator; GitLab-only MR panels refresh via hosted-review paths.
const sidebarWorktreeHasGitHubReview =
currentWorktree !== null &&
((currentWorktree.linkedGitLabMR ?? null) === null ||
(currentWorktree.linkedPR ?? null) !== null)
const shouldTrackSidebarWorktree = rightSidebarShowsPR && sidebarWorktreeHasGitHubReview
const shouldTrackVisibleRows =
groupBy === 'pr-status' ||
(newCardStyle
? cardProps.includes('status')
: cardProps.includes('pr') || cardProps.includes('ci'))
if (!shouldTrackVisibleRows && !shouldTrackSidebarWorktree) {
if (lastVisibleRefreshKeyRef.current !== NOTHING_TO_TRACK_KEY) {
lastVisibleRefreshKeyRef.current = NOTHING_TO_TRACK_KEY
reportVisibleGitHubPRRefreshCandidates([], Date.now())
}
return
update()
const stopVisibility = installWorktreeVisibleRefreshVisibilityListener(update)
const scroll = args.scrollRef.current
const stopScroll = scroll ? installVisibleReviewCardScrollListener(scroll, update) : () => {}
return () => {
stopVisibility()
stopScroll()
}
const scrollEl = scrollRef.current
if (!scrollEl) {
return
}
const viewportTop = scrollEl.scrollTop
const viewportBottom = viewportTop + scrollEl.clientHeight
const visibleRows = virtualItems
.filter((item) => item.start < viewportBottom && item.end > viewportTop)
.map((item) => renderRows[item.index])
.filter((row): row is WorktreeItemRow => row?.type === 'item')
.filter((row) => row.repo?.kind === 'git' && !row.worktree.isBare && row.worktree.branch)
const visibleWorktreeIds = new Set(visibleRows.map((row) => row.worktree.id))
if (
shouldTrackSidebarWorktree &&
currentWorktree &&
!currentWorktree.isBare &&
currentWorktree.branch
) {
visibleWorktreeIds.add(currentWorktree.id)
}
const visibleIdentity = visibleRows
.map((row) => `${row.worktree.id}:${row.worktree.branch}:${row.worktree.linkedPR ?? ''}`)
.join('|')
const sidebarIdentity =
shouldTrackSidebarWorktree && currentWorktree
? `${currentWorktree.id}:${currentWorktree.branch}:${currentWorktree.linkedPR ?? ''}`
: ''
const key = `${visibleIdentity}:${sidebarIdentity}:${sshConnectedGeneration}:${prVisibleRefreshGeneration}:${cardProps.join(',')}`
if (!key || key === lastVisibleRefreshKeyRef.current) {
return
}
lastVisibleRefreshKeyRef.current = key
reportVisibleGitHubPRRefreshCandidates(Array.from(visibleWorktreeIds), Date.now())
}, [
cardProps,
currentWorktreeId,
documentVisibilityRevision,
groupBy,
renderRows,
reportVisibleGitHubPRRefreshCandidates,
prVisibleRefreshGeneration,
rightSidebarShowsPR,
scrollRef,
sshConnectedGeneration,
newCardStyle,
virtualItems,
worktreeMap
])
}, [enabled, args.renderRows, args.virtualItems, args.scrollRef, publish])
useEffect(() => () => publish([]), [publish])
}
@@ -1,5 +1,12 @@
import type { VirtualItem } from '@tanstack/react-virtual'
import type { WorktreeItemRow } from '../listing/renderable-rows'
import { makePRRefreshWorktree } from '@/store/slices/github-slice-test-harness'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installWorktreeVisibleRefreshVisibilityListener } from './use-visible-review-refresh'
import {
installWorktreeVisibleRefreshVisibilityListener,
visibleReviewCardIds,
installVisibleReviewCardScrollListener
} from './use-visible-review-refresh'
describe('installWorktreeVisibleRefreshVisibilityListener', () => {
beforeEach(() => {
@@ -30,3 +37,103 @@ describe('installWorktreeVisibleRefreshVisibilityListener', () => {
expect(removeEventListener).toHaveBeenCalledWith('visibilitychange', onChange)
})
})
function row(id: string): WorktreeItemRow {
return {
type: 'item',
rowKey: id,
sectionKey: 'all',
worktree: makePRRefreshWorktree({ id }),
repo: {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 1,
kind: 'git'
},
depth: 0,
groupDepth: 0,
lineageTrail: [],
isLastLineageChild: true,
lineageChildCount: 0
}
}
function virtualItem(index: number): VirtualItem {
return { key: index, index, start: index * 100, end: (index + 1) * 100, size: 100, lane: 0 }
}
describe('actual visible review cards', () => {
it('excludes overscan rows and exact viewport boundaries', () => {
expect(
visibleReviewCardIds({
enabled: true,
renderRows: [row('above'), row('visible'), row('below')],
virtualItems: [0, 1, 2].map(virtualItem),
viewportTop: 100,
viewportHeight: 100
})
).toEqual(['visible'])
})
it('includes visible lineage children but excludes offscreen members', () => {
expect(
visibleReviewCardIds({
enabled: true,
renderRows: [{ type: 'lineage-group', key: 'family', rows: [row('parent'), row('child')] }],
virtualItems: [virtualItem(0)],
viewportTop: 0,
viewportHeight: 100,
isOnScreen: (id) => id === 'child'
})
).toEqual(['child'])
})
it('excludes rows when their review decoration is disabled', () => {
expect(
visibleReviewCardIds({
enabled: false,
renderRows: [row('visible')],
virtualItems: [virtualItem(0)],
viewportTop: 0,
viewportHeight: 100
})
).toEqual([])
})
})
describe('scrolling within one virtual lineage row', () => {
it('updates child visibility on scroll without changing virtual indexes and cancels pending frames', () => {
const listeners = new Map<string, EventListenerOrEventListenerObject>()
let frame: FrameRequestCallback = () => {}
const update = vi.fn()
const scroll = {
addEventListener: vi.fn((type: string, callback: EventListenerOrEventListenerObject) => {
listeners.set(type, callback)
}),
removeEventListener: vi.fn()
}
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
frame = callback
return 1
})
)
const cancel = vi.fn()
vi.stubGlobal('cancelAnimationFrame', cancel)
const cleanup = installVisibleReviewCardScrollListener(scroll, update)
const listener = listeners.get('scroll')
if (typeof listener === 'function') {
listener(new Event('scroll'))
}
expect(update).not.toHaveBeenCalled()
frame(0)
expect(update).toHaveBeenCalledOnce()
if (typeof listener === 'function') {
listener(new Event('scroll'))
}
cleanup()
expect(cancel).toHaveBeenCalledWith(1)
expect(scroll.removeEventListener).toHaveBeenCalledWith('scroll', listener)
vi.unstubAllGlobals()
})
})
@@ -1,122 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installWindowVisibilityTimeoutPoller } from './window-visibility-timeout-poller'
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
async function flush(): Promise<void> {
await vi.advanceTimersByTimeAsync(0)
}
describe('installWindowVisibilityTimeoutPoller', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('runs immediately while visible and schedules the next poll after completion', async () => {
it('runs immediately and schedules after completion', async () => {
const run = vi.fn().mockResolvedValue(undefined)
const setTimeoutMock = vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>)
const clearTimeoutMock = vi.fn()
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const cleanup = installWindowVisibilityTimeoutPoller({
run,
getDelayMs: () => 3000,
setTimeoutFn: setTimeoutMock,
clearTimeoutFn: clearTimeoutMock
})
expect(run).toHaveBeenCalledTimes(1)
await Promise.resolve()
expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 3000)
const cleanup = installWindowVisibilityTimeoutPoller({ run, getDelayMs: () => 3000 })
expect(run).toHaveBeenCalledOnce()
await flush()
await vi.advanceTimersByTimeAsync(2999)
expect(run).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(run).toHaveBeenCalledTimes(2)
cleanup()
expect(clearTimeoutMock).toHaveBeenCalledWith(1)
expect(vi.getTimerCount()).toBe(0)
})
it('pauses while hidden and refreshes immediately when visible again', async () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, () => void>()
it('pauses hidden work and refreshes once when visible again', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
const run = vi.fn().mockResolvedValue(undefined)
const setTimeoutMock = vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>)
const clearTimeoutMock = vi.fn()
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
get visibilityState() {
return visibilityState
},
addEventListener: vi.fn((event: string, listener: () => void) => {
documentListeners.set(event, listener)
}),
removeEventListener: vi.fn()
})
const cleanup = installWindowVisibilityTimeoutPoller({
run,
getDelayMs: () => 3000,
setTimeoutFn: setTimeoutMock,
clearTimeoutFn: clearTimeoutMock
})
const cleanup = installWindowVisibilityTimeoutPoller({ run, getDelayMs: () => 3000 })
await vi.advanceTimersByTimeAsync(9000)
expect(run).not.toHaveBeenCalled()
expect(setTimeoutMock).not.toHaveBeenCalled()
visibilityState = 'visible'
documentListeners.get('visibilitychange')?.()
expect(run).toHaveBeenCalledTimes(1)
await Promise.resolve()
expect(setTimeoutMock).toHaveBeenCalledTimes(1)
visibilityState = 'hidden'
documentListeners.get('visibilitychange')?.()
expect(clearTimeoutMock).toHaveBeenCalledWith(1)
visibility.mockReturnValue('visible')
document.dispatchEvent(new Event('visibilitychange'))
await flush()
expect(run).toHaveBeenCalledOnce()
visibility.mockReturnValue('hidden')
document.dispatchEvent(new Event('visibilitychange'))
expect(vi.getTimerCount()).toBe(0)
cleanup()
})
it('does not overlap focus refreshes while a poll is in flight', async () => {
const windowListeners = new Map<string, () => void>()
let resolveRun!: () => void
it('does not overlap in-flight focus reads', async () => {
let resolveRun: (() => void) | undefined
const run = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveRun = resolve
})
)
const setTimeoutMock = vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>)
vi.stubGlobal('window', {
addEventListener: vi.fn((event: string, listener: () => void) => {
windowListeners.set(event, listener)
}),
removeEventListener: vi.fn()
})
vi.stubGlobal('document', {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const cleanup = installWindowVisibilityTimeoutPoller({ run, getDelayMs: () => 3000 })
window.dispatchEvent(new Event('focus'))
expect(run).toHaveBeenCalledOnce()
resolveRun?.()
await flush()
expect(vi.getTimerCount()).toBe(1)
cleanup()
})
it('stops after a null delay and responds to visibility return without focus bursts', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get')
const run = vi.fn().mockResolvedValue(undefined)
const cleanup = installWindowVisibilityTimeoutPoller({
run,
getDelayMs: () => 3000,
setTimeoutFn: setTimeoutMock
getDelayMs: () => null,
cooldownMs: 10_000
})
await flush()
expect(vi.getTimerCount()).toBe(0)
window.dispatchEvent(new Event('focus'))
await flush()
expect(run).toHaveBeenCalledOnce()
visibility.mockReturnValue('hidden')
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(10_000)
visibility.mockReturnValue('visible')
document.dispatchEvent(new Event('visibilitychange'))
await flush()
window.dispatchEvent(new Event('focus'))
await flush()
expect(run).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
cleanup()
})
expect(run).toHaveBeenCalledTimes(1)
windowListeners.get('focus')?.()
expect(run).toHaveBeenCalledTimes(1)
it('waits for stale data when a fresh window returns before its next poll', async () => {
const visibility = vi.spyOn(document, 'visibilityState', 'get')
const run = vi.fn().mockResolvedValue(undefined)
const cleanup = installWindowVisibilityTimeoutPoller({
run,
getDelayMs: () => 60_000,
cooldownMs: 10_000
})
await flush()
visibility.mockReturnValue('hidden')
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(30_000)
visibility.mockReturnValue('visible')
document.dispatchEvent(new Event('visibilitychange'))
await flush()
expect(run).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(30_000)
expect(run).toHaveBeenCalledTimes(2)
cleanup()
})
resolveRun()
await Promise.resolve()
expect(setTimeoutMock).toHaveBeenCalledTimes(1)
it('picks up a settled null delay after pending finishes', async () => {
let delay: number | null = 60_000
const run = vi.fn().mockResolvedValue(undefined)
const cleanup = installWindowVisibilityTimeoutPoller({ run, getDelayMs: () => delay })
await flush()
delay = null
await vi.advanceTimersByTimeAsync(60_000)
expect(run).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
cleanup()
})
it('keeps retrying rejected and synchronously throwing reads', async () => {
const run = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockImplementationOnce(() => {
throw new Error('offline')
})
.mockResolvedValue(undefined)
const cleanup = installWindowVisibilityTimeoutPoller({ run, getDelayMs: () => 3000 })
await vi.advanceTimersByTimeAsync(6000)
expect(run).toHaveBeenCalledTimes(3)
cleanup()
})
})
@@ -4,10 +4,11 @@ export type WindowVisibilityTimeoutPollerTimer = ReturnType<typeof setTimeout>
export function installWindowVisibilityTimeoutPoller(args: {
run: () => Promise<void> | void
getDelayMs: () => number
getDelayMs: () => number | null
cooldownMs?: number
setTimeoutFn?: (callback: () => void, delayMs: number) => WindowVisibilityTimeoutPollerTimer
clearTimeoutFn?: (handle: WindowVisibilityTimeoutPollerTimer) => void
}): () => void {
}): (() => void) & { refresh: () => void } {
const setTimeoutFn =
args.setTimeoutFn ??
((callback: () => void, delayMs: number): WindowVisibilityTimeoutPollerTimer =>
@@ -18,62 +19,99 @@ export function installWindowVisibilityTimeoutPoller(args: {
let timeoutId: WindowVisibilityTimeoutPollerTimer | null = null
let disposed = false
let inFlight = false
let lastRunAt = -Infinity
const clearScheduledPoll = (): void => {
if (!timeoutId) {
if (timeoutId === null) {
return
}
clearTimeoutFn(timeoutId)
timeoutId = null
}
const schedulePoll = (): void => {
const schedulePoll = (remainingMs?: number): void => {
clearScheduledPoll()
if (disposed || !isWindowVisible()) {
return
}
const delayMs = args.getDelayMs()
if (delayMs === null) {
return
}
timeoutId = setTimeoutFn(() => {
timeoutId = null
runAndSchedule()
}, args.getDelayMs())
}, remainingMs ?? delayMs)
}
function runAndSchedule(): void {
clearScheduledPoll()
function runAndSchedule(explicitRefresh = false): void {
if (disposed || !isWindowVisible() || inFlight) {
return
}
const elapsedMs = Date.now() - lastRunAt
const cooldownRemainingMs = (args.cooldownMs ?? 0) - elapsedMs
if (cooldownRemainingMs > 0) {
if (explicitRefresh) {
clearScheduledPoll()
timeoutId = setTimeoutFn(() => {
timeoutId = null
runAndSchedule(true)
}, cooldownRemainingMs)
}
return
}
clearScheduledPoll()
lastRunAt = Date.now()
inFlight = true
void Promise.resolve(args.run()).finally(() => {
const finish = (): void => {
inFlight = false
schedulePoll()
})
}
try {
void Promise.resolve(args.run()).then(finish, finish)
} catch {
finish()
}
}
const reconcileVisibility = (): void => {
if (isWindowVisible()) {
const delayMs = args.getDelayMs()
const elapsedMs = Date.now() - lastRunAt
if (args.cooldownMs && delayMs !== null && elapsedMs < delayMs) {
if (timeoutId === null && !inFlight) {
schedulePoll(delayMs - elapsedMs)
}
return
}
runAndSchedule()
} else {
clearScheduledPoll()
}
}
const reconcileFocus = (): void => {
if (args.getDelayMs() !== null) {
reconcileVisibility()
}
}
runAndSchedule()
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('focus', reconcileVisibility)
window.addEventListener('focus', reconcileFocus)
}
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
document.addEventListener('visibilitychange', reconcileVisibility)
}
return () => {
const cleanup = (): void => {
disposed = true
clearScheduledPoll()
if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
window.removeEventListener('focus', reconcileVisibility)
window.removeEventListener('focus', reconcileFocus)
}
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
document.removeEventListener('visibilitychange', reconcileVisibility)
}
}
return Object.assign(cleanup, { refresh: () => runAndSchedule(true) })
}
@@ -18,6 +18,7 @@ export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo }
export type CacheEntry<T> = {
data: T | null
fetchedAt: number
fetchedHeadOid?: string | null
headSha?: string
sources?: WorkItemsCacheSources
error?: WorkItemsCacheError
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRCheckDetail } from '../../../../shared/github/check-types'
import {
createTestStore,
mockApi,
resetRemoteRuntimeMocks
} from '../slices/github-slice-test-harness'
const pending: PRCheckDetail[] = [{ name: 'Build', status: 'queued', conclusion: null, url: null }]
beforeEach(() => {
vi.clearAllMocks()
resetRemoteRuntimeMocks()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => vi.restoreAllMocks())
describe('checks provider error policy', () => {
it.each([true, false])(
'shares one failed read with strict caller first=%s',
async (strictFirst) => {
const store = createTestStore()
const upstream = Promise.withResolvers<PRCheckDetail[]>()
const error = new Error('offline')
mockApi.gh.prChecks.mockReturnValueOnce(upstream.promise)
const fetch = (throwOnError: boolean): Promise<PRCheckDetail[]> =>
store.getState().fetchPRChecks('/repo', 1, 'main', 'sha', undefined, { throwOnError })
const first = fetch(strictFirst)
const second = fetch(!strictFirst)
const settled = Promise.allSettled([first, second])
upstream.reject(error)
const results = await settled
expect(results[strictFirst ? 0 : 1]).toEqual({ status: 'rejected', reason: error })
expect(results[strictFirst ? 1 : 0]).toEqual({ status: 'fulfilled', value: [] })
expect(mockApi.gh.prChecks).toHaveBeenCalledOnce()
expect(store.getState().checksCache).toEqual({})
}
)
it('preserves the good cache for default callers while strict callers observe errors and recover', async () => {
const store = createTestStore()
mockApi.gh.prChecks.mockResolvedValueOnce(pending)
await store.getState().fetchPRChecks('/repo', 1, 'main', 'sha')
const error = new Error('offline')
mockApi.gh.prChecks.mockRejectedValue(error)
await expect(
store.getState().fetchPRChecks('/repo', 1, 'main', 'sha', undefined, {
force: true,
throwOnError: true
})
).rejects.toThrow(error)
await expect(
store.getState().fetchPRChecks('/repo', 1, 'main', 'sha', undefined, {
force: true
})
).resolves.toEqual(pending)
expect(Object.values(store.getState().checksCache).map((entry) => entry.data)).toEqual([
pending
])
mockApi.gh.prChecks.mockResolvedValueOnce([])
await expect(
store.getState().fetchPRChecks('/repo', 1, 'main', 'sha', undefined, {
force: true,
throwOnError: true
})
).resolves.toEqual([])
})
})
+73 -71
View File
@@ -91,6 +91,17 @@ export const createCheckActions = (
return cachedChecks
}
const recoverChecks = (err: unknown): PRCheckDetail[] => {
if (options?.throwOnError) {
throw err
}
console.error('Failed to fetch PR checks:', err)
const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
return latestCached?.data && (!headSha || latestCached.headSha === headSha)
? latestCached.data
: []
}
let waitedForUpgrade = false
for (;;) {
const inflightRequest = inflightChecksRequests.get(inflightKey)
@@ -100,7 +111,7 @@ export const createCheckActions = (
const weakerThanRequested =
(options?.force && !inflightRequest.force) || (options?.noCache && !inflightRequest.noCache)
if (!weakerThanRequested) {
return inflightRequest.promise
return inflightRequest.promise.catch(recoverChecks)
}
// Why: wait out one weaker request so peers can share the upgrade, but never twice — a steady stream of weaker callers would otherwise starve this one forever.
if (waitedForUpgrade) {
@@ -112,81 +123,72 @@ export const createCheckActions = (
const requestId = nextProviderRequestId()
const request = (async () => {
try {
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
const checks =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<PRCheckDetail[]>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.prChecks',
{
repo: requestContext.target.runtimeRepoId,
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: Boolean(options?.force || options?.noCache)
},
{ timeoutMs: 30_000 }
)
: ((await window.api.gh.prChecks({
repoPath,
repoId,
const requestContext = getGitHubWorkItemRequestContext(
get(),
requestSettings,
repoId ?? repoPath,
repoPath,
options?.sourceContext
)
const checks =
requestContext.target.kind === 'environment'
? await callRuntimeRpc<PRCheckDetail[]>(
{ kind: 'environment', environmentId: requestContext.target.environmentId },
'github.prChecks',
{
repo: requestContext.target.runtimeRepoId,
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: Boolean(options?.force || options?.noCache),
sourceContext: options?.sourceContext
})) as PRCheckDetail[])
// Why: the bounded upgrade wait can leave us running beside a stronger request for this
// key. Both bypass gh's cache here, but the later-started one holds the newer run state —
// let only the key's current owner write checksCache and the PR status it derives.
if (!ownsInflightRequest(inflightChecksRequests, inflightKey, requestId)) {
return checks
}
set((s) => {
const nextState: Partial<AppState> = {
checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, {
data: checks,
fetchedAt: Date.now(),
headSha
noCache: Boolean(options?.force || options?.noCache)
},
{ timeoutMs: 30_000 }
)
: await window.api.gh.prChecks({
repoPath,
repoId,
prNumber,
headSha,
prRepo: prRepo ?? null,
noCache: Boolean(options?.force || options?.noCache),
sourceContext: options?.sourceContext
})
}
const prStatusUpdate = syncPRChecksStatus(
s,
repoPath,
repoId,
branch,
checks,
headSha,
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId,
repo !== undefined
)
if (prStatusUpdate?.prCache) {
nextState.prCache = prStatusUpdate.prCache
}
return nextState
})
debouncedSaveCache(get())
// Why: the bounded upgrade wait can leave us running beside a stronger request for this
// key. Both bypass gh's cache here, but the later-started one holds the newer run state —
// let only the key's current owner write checksCache and the PR status it derives.
if (!ownsInflightRequest(inflightChecksRequests, inflightKey, requestId)) {
return checks
} catch (err) {
console.error('Failed to fetch PR checks:', err)
const latestCached = get().checksCache[cacheKey] ?? get().checksCache[legacyCacheKey]
if (latestCached?.data && (!headSha || latestCached.headSha === headSha)) {
return latestCached.data
}
return []
}
set((s) => {
const nextState: Partial<AppState> = {
checksCache: withBoundedCacheEntry(s.checksCache, cacheKey, {
data: checks,
fetchedAt: Date.now(),
headSha
})
}
const prStatusUpdate = syncPRChecksStatus(
s,
repoPath,
repoId,
branch,
checks,
headSha,
prRepo,
requestSettings,
repo?.connectionId,
repo?.executionHostId,
repo !== undefined
)
if (prStatusUpdate?.prCache) {
nextState.prCache = prStatusUpdate.prCache
}
return nextState
})
debouncedSaveCache(get())
return checks
})().finally(() => {
if (ownsInflightRequest(inflightChecksRequests, inflightKey, requestId)) {
inflightChecksRequests.delete(inflightKey)
@@ -199,7 +201,7 @@ export const createCheckActions = (
force: Boolean(options?.force),
noCache: Boolean(options?.force || options?.noCache)
})
return request
return request.catch(recoverChecks)
},
fetchPRCheckDetails: async (repoPath, args, options): Promise<PRCheckRunDetails | null> => {
@@ -18,13 +18,18 @@ export function applyPRCacheResult(
pr: PRInfo | null,
fetchedAt: number,
accepted: boolean,
preserveExisting: boolean
preserveExisting: boolean,
fetchedHeadOid?: string | null
): AppState['prCache'] {
if (preserveExisting) {
return cache
}
if (accepted) {
return withBoundedCacheEntry(cache, cacheKey, { data: pr, fetchedAt })
return withBoundedCacheEntry(cache, cacheKey, {
data: pr,
fetchedAt,
...(fetchedHeadOid ? { fetchedHeadOid } : {})
})
}
if (!cache[cacheKey]) {
return cache
@@ -81,6 +86,7 @@ export function setGitHubPRResultCaches(
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
requestStartedAt?: number
fetchedHeadOid?: string | null
requestStartedEntry?: AppState['hostedReviewCache'][string]
}
): Partial<AppState> {
@@ -132,7 +138,8 @@ export function setGitHubPRResultCaches(
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber
}),
preserveExistingPRForFallbackMiss
preserveExistingPRForFallbackMiss,
args.fetchedHeadOid
)
return {
...(nextPRCache === state.prCache ? {} : { prCache: nextPRCache }),
@@ -161,6 +168,7 @@ export function applyGitHubPRResultToCaches(args: {
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
requestStartedAt?: number
fetchedHeadOid?: string | null
requestStartedEntry?: AppState['hostedReviewCache'][string]
}): {
prCache: AppState['prCache']
@@ -215,7 +223,8 @@ export function applyGitHubPRResultToCaches(args: {
linkedPRNumber: args.linkedPRNumber,
fallbackPRNumber: args.fallbackPRNumber
}),
preserveExistingPRForFallbackMiss
preserveExistingPRForFallbackMiss,
args.fetchedHeadOid
),
hostedReviewCache: hostedReviewSync.cache
}
@@ -102,6 +102,8 @@ export function startPullRequestLookup(args: {
connectionId: repo?.connectionId ?? null,
executionHostId: repo?.executionHostId ?? null,
cachedFetchedAt: cached?.fetchedAt ?? null,
cachedHeadOid: cached?.fetchedHeadOid ?? cached?.data?.headSha ?? null,
isSelected: options?.worktreeId === get().activeWorktreeId,
cachedHasPR: cached?.data ? true : cached ? false : null,
cachedPRState: cached?.data?.state ?? null,
cachedChecksStatus: cached?.data?.checksStatus ?? null,
@@ -166,6 +168,7 @@ export function startPullRequestLookup(args: {
hasRepoOwner: repo !== undefined,
pr,
fetchedAt: outcome.fetchedAt,
fetchedHeadOid: requestHeadOid,
worktreeId: options?.worktreeId,
linkedPRNumber,
fallbackPRNumber,
@@ -177,6 +177,7 @@ export const createRefreshEventActions = (
hasRepoOwner: true,
pr: data,
fetchedAt: event.outcome.fetchedAt,
fetchedHeadOid: alias.currentHeadOid,
state: s,
worktreeId: alias.worktreeId,
linkedPRNumber: alias.linkedPRNumber,
@@ -9,6 +9,10 @@ import {
shouldEnqueueLocalPRRefresh
} from './repository-routing'
import { buildPRRefreshCandidate, findWorktreeById } from './worktree-refresh'
import {
hasNonGitHubReview,
shouldCoordinateVisibleGitHubReview
} from './visible-hosted-review-refresh-ownership'
export const createRefreshRoutingActions = (
set: Parameters<StateCreator<AppState>>[0],
@@ -18,7 +22,16 @@ export const createRefreshRoutingActions = (
| 'enqueueGitHubPRRefresh'
| 'reportVisibleGitHubPRRefreshCandidates'
| 'bumpGitHubPRVisibleRefreshGeneration'
| 'setVisibleReviewCardWorktreeIds'
> => ({
setVisibleReviewCardWorktreeIds: (ids) => {
set((s) =>
s.visibleReviewCardWorktreeIds?.length === ids.length &&
ids.every((id, index) => s.visibleReviewCardWorktreeIds[index] === id)
? s
: { visibleReviewCardWorktreeIds: ids }
)
},
enqueueGitHubPRRefresh: (worktreeId, reason, priority = 0) => {
const state = get()
const worktree = findWorktreeById(state, worktreeId)
@@ -26,6 +39,9 @@ export const createRefreshRoutingActions = (
if (!candidate) {
return
}
if (worktree && hasNonGitHubReview(state, worktree, candidate)) {
return
}
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: bypassesGitHubPRRefreshFreshness(reason),
@@ -54,25 +70,28 @@ export const createRefreshRoutingActions = (
})
},
reportVisibleGitHubPRRefreshCandidates: (worktreeIds, generation) => {
reportVisibleGitHubPRRefreshCandidates: async (worktreeIds, generation) => {
set((s) =>
s.visibleReviewWorktreeIds?.length === worktreeIds.length &&
worktreeIds.every((id, index) => s.visibleReviewWorktreeIds[index] === id)
? s
: { visibleReviewWorktreeIds: worktreeIds }
)
const state = get()
const candidates = worktreeIds
.map((id) => {
const worktree = findWorktreeById(state, id)
return worktree ? buildPRRefreshCandidate(state, worktree) : null
const candidate = worktree ? buildPRRefreshCandidate(state, worktree) : null
return worktree &&
candidate &&
shouldCoordinateVisibleGitHubReview(state, worktree, candidate)
? candidate
: null
})
.filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null)
const localCandidates: GitHubPRRefreshCandidate[] = []
for (const candidate of candidates) {
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'visible'
})
continue
}
if (shouldEnqueueLocalPRRefresh(candidate)) {
@@ -81,7 +100,7 @@ export const createRefreshRoutingActions = (
}
const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates
if (reportVisible) {
void reportVisible({ candidates: localCandidates, generation }).catch((err) => {
await reportVisible({ candidates: localCandidates, generation }).catch((err) => {
console.warn('Failed to report visible PR refresh candidates:', err)
})
}
@@ -1,22 +1,12 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GitHubSlice } from './slice-types'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index'
import { issueCacheKey, prCacheKey } from './cache-identity'
import { CACHE_TTL, evictStaleEntries } from './cache-policy'
import { pruneExpiredPRRefreshStates } from './pr-refresh-state'
import {
enqueueLocalGitHubPRRefresh,
getPRRefreshRuntimeRepoTarget,
getRuntimeRepoTarget,
shouldEnqueueLocalPRRefresh
} from './repository-routing'
import { settingsForGitHubRepoOwner } from './work-item-routing'
import { buildPRRefreshCandidate } from './worktree-refresh'
export const createRefreshSweepActions = (
set: Parameters<StateCreator<AppState>>[0],
@@ -49,25 +39,12 @@ export const createRefreshSweepActions = (
// Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale.
// Only re-fetch PR/issue entries that are already stale — skip fresh ones
const state = get()
const cardProps = state.worktreeCardProperties ?? []
const rawCardProps = cardProps as readonly string[]
const shouldRefreshIssues = (state.worktreeCardProperties ?? []).includes('issue')
const isPRStatusGrouping = state.groupBy === 'pr-status'
const rightSidebarShowsPR = rightSidebarShowsPullRequestData(state)
const shouldRefreshPRs =
isPRStatusGrouping ||
rightSidebarShowsPR ||
(state.settings?.experimentalNewWorktreeCardStyle === true
? cardProps.includes('status')
: cardProps.includes('pr') || rawCardProps.includes('ci'))
if (!shouldRefreshPRs && !shouldRefreshIssues) {
if (!(state.worktreeCardProperties ?? []).includes('issue')) {
return
}
const now = Date.now()
const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = []
const repoLookup = getGitHubRepoLookupIndex(state.repos)
for (const worktrees of Object.values(state.worktreesByRepo)) {
@@ -77,31 +54,7 @@ export const createRefreshSweepActions = (
continue
}
const branch = wt.branch.replace(/^refs\/heads\//, '')
if (shouldRefreshPRs && !wt.isBare && branch) {
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const prKey = prCacheKey(
repo.path,
repo.id,
branch,
ownerSettings,
repo.connectionId,
repo.executionHostId
)
const prEntry = state.prCache[prKey]
if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) {
const candidate = buildPRRefreshCandidate(state, wt, undefined, repo)
if (candidate) {
stalePRCandidates.push({
candidate,
score:
(state.activeWorktreeId === wt.id ? Number.MAX_SAFE_INTEGER : 0) +
wt.lastActivityAt
})
}
}
}
if (shouldRefreshIssues && wt.linkedIssue) {
if (wt.linkedIssue) {
const ownerSettings = settingsForGitHubRepoOwner(state.settings, repo)
const issueKey = issueCacheKey(
repo.path,
@@ -119,27 +72,6 @@ export const createRefreshSweepActions = (
}
}
}
const candidatesToRefresh = stalePRCandidates
.sort((a, b) => b.score - a.score)
.slice(0, isPRStatusGrouping ? stalePRCandidates.length : 5)
for (const { candidate } of candidatesToRefresh) {
const candidateSettings = settingsForGitHubRepoOwner(
state.settings,
candidate as Pick<Repo, 'connectionId' | 'executionHostId'>
)
if (getRuntimeRepoTarget(state, candidate.repoPath, candidateSettings)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'swr'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 })
}
}
},
refreshGitHubForWorktree: (worktreeId) => {
@@ -199,22 +131,7 @@ export const createRefreshSweepActions = (
// Re-fetch (skip when branch is empty — detached HEAD during rebase)
if (!worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(get(), worktree)
if (candidate) {
if (getPRRefreshRuntimeRepoTarget(get(), candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'post-push'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 })
}
}
get().enqueueGitHubPRRefresh(worktreeId, 'post-push', 100)
}
if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) {
void get().fetchIssue(repo.path, worktree.linkedIssue, { repoId: repo.id })
+8 -2
View File
@@ -48,6 +48,9 @@ export type GitHubSlice = {
prRefreshSequences: Record<string, number>
prRefreshStates: Record<string, PRRefreshState>
prVisibleRefreshGeneration: number
visibleReviewWorktreeIds: readonly string[]
visibleReviewCardWorktreeIds: readonly string[]
setVisibleReviewCardWorktreeIds: (ids: readonly string[]) => void
// Why: keyed by repoId + limit + query so same-path repos on different SSH targets don't share results.
workItemsCache: Record<string, CacheEntry<readonly GitHubWorkItem[]>>
fetchPRForBranch: (
@@ -72,7 +75,7 @@ export type GitHubSlice = {
branch?: string,
headSha?: string,
prRepo?: GitHubOwnerRepo | null,
options?: RepoScopedFetchOptions
options?: RepoScopedFetchOptions & { throwOnError?: boolean }
) => Promise<PRCheckDetail[]>
fetchPRCheckDetails: (
repoPath: string,
@@ -132,7 +135,10 @@ export type GitHubSlice = {
reason: GitHubPRRefreshReason,
priority?: number
) => void
reportVisibleGitHubPRRefreshCandidates: (worktreeIds: string[], generation: number) => void
reportVisibleGitHubPRRefreshCandidates: (
worktreeIds: string[],
generation: number
) => Promise<void>
bumpGitHubPRVisibleRefreshGeneration: () => void
applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void
getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined
@@ -5,13 +5,7 @@ import type { Worktree } from '../../../../shared/worktree/types'
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
import { issueCacheKey } from './cache-identity'
import { CACHE_TTL } from './cache-policy'
import {
enqueueLocalGitHubPRRefresh,
getPRRefreshRuntimeRepoTarget,
shouldEnqueueLocalPRRefresh
} from './repository-routing'
import { settingsForGitHubRepoOwner } from './work-item-routing'
import { buildPRRefreshCandidate } from './worktree-refresh'
export const createStaleWorktreeRefreshActions = (
get: Parameters<StateCreator<AppState>>[1]
@@ -38,31 +32,15 @@ export const createStaleWorktreeRefreshActions = (
const now = Date.now()
const branch = worktree.branch.replace(/^refs\/heads\//, '')
const cardProps = state.worktreeCardProperties ?? []
const rawCardProps = cardProps as readonly string[]
const shouldRefreshPR =
state.groupBy === 'pr-status' ||
(state.settings?.experimentalNewWorktreeCardStyle === true
? cardProps.includes('status')
: cardProps.includes('pr') || rawCardProps.includes('ci')) ||
: cardProps.includes('pr') || cardProps.some((property: string) => property === 'ci')) ||
rightSidebarShowsPullRequestData(state)
if (shouldRefreshPR && !worktree.isBare && branch) {
const candidate = buildPRRefreshCandidate(state, worktree)
if (candidate) {
if (getPRRefreshRuntimeRepoTarget(state, candidate)) {
void get().fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
repoId: candidate.repoId,
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'active'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 })
}
}
get().enqueueGitHubPRRefresh(worktreeId, 'active', 80)
}
if ((state.worktreeCardProperties ?? []).includes('issue') && worktree.linkedIssue) {
@@ -0,0 +1,59 @@
import type { AppState } from '../types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-request-refresh-types'
import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity'
import { getGitHubRepoLookupIndex } from '../slices/github-repo-lookup-index'
export function shouldCoordinateVisibleGitHubReview(
state: AppState,
worktree: Worktree,
candidate: GitHubPRRefreshCandidate
): boolean {
if (hasNonGitHubReview(state, worktree, candidate)) {
return false
}
const key = getHostedReviewCacheKey(
candidate.repoPath,
candidate.branch,
state.settings,
candidate.repoId,
candidate.connectionId,
candidate.executionHostId,
true
)
const hosted = state.hostedReviewCache[key]
const remote = getGitHubRepoLookupIndex(state.repos).findById(candidate.repoId)?.gitRemoteIdentity
return (
state.prCache[candidate.cacheKey]?.data != null ||
hosted?.data?.provider === 'github' ||
worktree.linkedPR != null ||
(hosted?.linkedReviewHintKey?.split('|').some((hint) => hint.startsWith('github:')) ?? false) ||
remote?.canonicalKey.split('/', 1)[0].toLowerCase() === 'github.com'
)
}
export function hasNonGitHubReview(
state: AppState,
worktree: Worktree,
candidate: GitHubPRRefreshCandidate
): boolean {
if (
worktree.linkedGitLabMR != null ||
worktree.linkedBitbucketPR != null ||
worktree.linkedAzureDevOpsPR != null ||
worktree.linkedGiteaPR != null
) {
return true
}
const key = getHostedReviewCacheKey(
candidate.repoPath,
candidate.branch,
state.settings,
candidate.repoId,
candidate.connectionId,
candidate.executionHostId,
true
)
const hosted = state.hostedReviewCache[key]
return hosted?.data != null && hosted.data.provider !== 'github'
}
@@ -0,0 +1,329 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createVisibleHostedReviewRefreshScheduler,
type VisibleHostedReviewRefreshTarget
} from './visible-hosted-review-refresh-scheduler'
const schedulers: ReturnType<typeof createVisibleHostedReviewRefreshScheduler>[] = []
function setup(targets: VisibleHostedReviewRefreshTarget[]) {
const scheduler = createVisibleHostedReviewRefreshScheduler()
schedulers.push(scheduler)
scheduler.update(targets)
scheduler.setVisible(true)
return scheduler
}
function target(
overrides: Partial<VisibleHostedReviewRefreshTarget> = {}
): VisibleHostedReviewRefreshTarget {
return {
key: 'local::repo::branch',
revision: 'head|',
fetchedAt: Date.now(),
intervalMs: 120_000,
selected: false,
refresh: vi.fn(async () => true),
...overrides
}
}
describe('visible hosted review scheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
})
afterEach(() => {
for (const scheduler of schedulers.splice(0)) {
scheduler.dispose()
}
vi.useRealTimers()
})
it('paces selected and other branches independently with one timer', async () => {
const selected = target({ key: 'selected', intervalMs: 60_000, selected: true })
const other = target()
setup([selected, other])
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(selected.refresh).toHaveBeenCalledTimes(1)
expect(other.refresh).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
expect(selected.refresh).toHaveBeenCalledTimes(2)
expect(other.refresh).toHaveBeenCalledTimes(1)
expect(selected.refresh).toHaveBeenCalledWith(false)
expect(other.refresh).toHaveBeenCalledWith(false)
})
it('starts no hidden or offscreen requests and removes timers on disposal', async () => {
const row = target({ fetchedAt: null })
const scheduler = createVisibleHostedReviewRefreshScheduler()
schedulers.push(scheduler)
scheduler.update([row])
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).not.toHaveBeenCalled()
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(row.refresh).toHaveBeenCalledTimes(1)
expect(row.refresh).toHaveBeenCalledWith(true)
scheduler.setVisible(false)
expect(vi.getTimerCount()).toBe(0)
scheduler.update([])
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).toHaveBeenCalledTimes(1)
scheduler.dispose()
expect(vi.getTimerCount()).toBe(0)
})
it('stops settled merged reviews and refreshes a stale revisible review once', async () => {
let row = target({ intervalMs: null })
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(600_000)
expect(row.refresh).not.toHaveBeenCalled()
scheduler.update([])
row = {
...row,
refresh: vi.fn(async () => {
row = { ...row, fetchedAt: Date.now() }
scheduler.update([row])
return true
})
}
scheduler.update([row])
await vi.advanceTimersByTimeAsync(0)
expect(row.refresh).toHaveBeenCalledTimes(1)
for (let i = 0; i < 10; i++) {
scheduler.update([{ ...row }])
}
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('keeps cooldown across removal and retimes HEAD/link discovery and selected tier', async () => {
let row = target({ fetchedAt: null })
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(0)
expect(row.refresh).toHaveBeenCalledTimes(1)
scheduler.update([])
row = { ...row, revision: 'new-head|github:8' }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(9_999)
expect(row.refresh).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(row.refresh).toHaveBeenCalledTimes(2)
row = { ...row, fetchedAt: Date.now(), intervalMs: 60_000, selected: true }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(60_000)
expect(row.refresh).toHaveBeenCalledTimes(3)
})
it('does not let repeated reports or cache writes trigger immediate refreshes', async () => {
let row = target()
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(30_000)
for (let i = 0; i < 20; i++) {
scheduler.update([{ ...row }])
}
row = { ...row, fetchedAt: Date.now() }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(119_999)
expect(row.refresh).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(row.refresh).toHaveBeenCalledTimes(1)
})
it('backs off preserved-cache failures from one minute to fifteen minutes', async () => {
const row = target({
fetchedAt: null,
intervalMs: 60_000,
selected: true,
refresh: vi.fn(async () => false)
})
setup([row])
await vi.advanceTimersByTimeAsync(0)
for (const delay of [60_000, 120_000, 240_000, 480_000, 900_000, 900_000]) {
const calls = vi.mocked(row.refresh).mock.calls.length
await vi.advanceTimersByTimeAsync(delay - 1)
expect(row.refresh).toHaveBeenCalledTimes(calls)
await vi.advanceTimersByTimeAsync(1)
expect(row.refresh).toHaveBeenCalledTimes(calls + 1)
expect(row.refresh).toHaveBeenLastCalledWith(true)
}
})
it('retimes settled cache transitions and resets backoff after fresh evidence', async () => {
let row = target({ fetchedAt: null, refresh: vi.fn(async () => false) })
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(0)
row = { ...row, fetchedAt: Date.now(), intervalMs: null }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('caps concurrency at three and removes abandoned queued work', async () => {
let release: (() => void) | undefined
const pending = new Promise<boolean>((resolve) => {
release = () => resolve(true)
})
const rows = Array.from({ length: 10 }, (_, i) =>
target({ key: String(i), fetchedAt: null, refresh: vi.fn(() => pending) })
)
const scheduler = setup(rows)
expect(rows.slice(0, 3).every((row) => vi.mocked(row.refresh).mock.calls.length === 1)).toBe(
true
)
expect(rows.slice(3).every((row) => vi.mocked(row.refresh).mock.calls.length === 0)).toBe(true)
scheduler.update([rows[9]])
release?.()
await vi.advanceTimersByTimeAsync(0)
expect(rows[9].refresh).toHaveBeenCalledTimes(1)
expect(rows.slice(3, 9).every((row) => vi.mocked(row.refresh).mock.calls.length === 0)).toBe(
true
)
scheduler.update([])
expect(vi.getTimerCount()).toBe(0)
})
it('does not leave timers when in-flight requests finish after cleanup', async () => {
let release: (() => void) | undefined
const row = target({
fetchedAt: null,
refresh: vi.fn(
() =>
new Promise<boolean>((resolve) => {
release = () => resolve(true)
})
)
})
const scheduler = setup([row])
scheduler.dispose()
release?.()
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('retries a failed settled-review reprobe and stops after success', async () => {
const refresh = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
const row = target({ intervalMs: null, fetchedAt: Date.now() - 60_000, refresh })
setup([row])
await vi.advanceTimersByTimeAsync(0)
expect(refresh).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(60_000)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(900_000)
expect(refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
it('keeps new-HEAD discovery when an older in-flight request writes the cache', async () => {
let release: (() => void) | undefined
const refresh = vi
.fn<() => Promise<boolean>>()
.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
release = () => resolve(true)
})
)
.mockResolvedValue(true)
let row = target({ fetchedAt: null, intervalMs: null, refresh })
const scheduler = setup([row])
row = { ...row, revision: 'new-head|' }
scheduler.update([row])
row = { ...row, fetchedAt: Date.now() }
scheduler.update([row])
release?.()
await vi.advanceTimersByTimeAsync(9_999)
expect(refresh).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
it('keeps offscreen failure gates across HEAD changes and clears them with fresh evidence', async () => {
let row = target({ fetchedAt: null, refresh: vi.fn(async () => false) })
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(0)
scheduler.update([])
row = { ...row, revision: 'new-head|' }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(119_999)
expect(row.refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(row.refresh).toHaveBeenCalledTimes(2)
scheduler.update([])
row = { ...row, fetchedAt: Date.now(), intervalMs: null }
scheduler.update([row])
await vi.advanceTimersByTimeAsync(900_000)
expect(row.refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
})
describe('failed discovery retains branch admission', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
})
afterEach(() => {
for (const scheduler of schedulers.splice(0)) {
scheduler.dispose()
}
vi.useRealTimers()
})
it('preserves error backoff when an unchanged distinct-head alias hides or returns', async () => {
const refresh = vi.fn(async () => false)
const both = target({
fetchedAt: null,
intervalMs: 60_000,
refresh,
revision: 'head-a;head-b',
aliasRevisions: new Map([
['a', 'head-a'],
['b', 'head-b']
])
})
const scheduler = setup([both])
await vi.advanceTimersByTimeAsync(0)
const one = { ...both, revision: 'head-a', aliasRevisions: new Map([['a', 'head-a']]) }
scheduler.update([one])
await vi.advanceTimersByTimeAsync(10_000)
expect(refresh).toHaveBeenCalledOnce()
scheduler.update([both])
await vi.advanceTimersByTimeAsync(49_999)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(2)
})
it('retains retry deadlines across real HEAD discovery changes', async () => {
const refresh = vi.fn(async () => false)
const row = target({ fetchedAt: null, intervalMs: 60_000, refresh })
const scheduler = setup([row])
await vi.advanceTimersByTimeAsync(0)
scheduler.update([{ ...row, revision: 'new-head' }])
await vi.advanceTimersByTimeAsync(59_999)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(2)
})
it('does not retry a closed or unselected empty branch faster than fifteen minutes', async () => {
const refresh = vi.fn(async () => false)
setup([target({ fetchedAt: null, intervalMs: 900_000, refresh })])
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(899_999)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,228 @@
import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../shared/review-refresh-policy'
export type VisibleHostedReviewRefreshTarget = {
key: string
revision: string
aliasRevisions?: ReadonlyMap<string, string>
fetchedAt: number | null
intervalMs: number | null
selected: boolean
refresh: (force?: boolean) => Promise<boolean>
}
type Entry = {
target: VisibleHostedReviewRefreshTarget
lastAttemptAt: number
failures: number
retryAt: number | null
discovery: boolean
}
function discoveryIdentityChanged(
previous: VisibleHostedReviewRefreshTarget,
next: VisibleHostedReviewRefreshTarget
): boolean {
if (!previous.aliasRevisions || !next.aliasRevisions) {
return previous.revision !== next.revision
}
const previousValues = new Set(previous.aliasRevisions.values())
for (const [id, revision] of next.aliasRevisions) {
const before = previous.aliasRevisions.get(id)
if (before !== undefined ? before !== revision : !previousValues.has(revision)) {
return true
}
}
return false
}
// One timer and one admission queue serve every visible branch.
export function createVisibleHostedReviewRefreshScheduler() {
const entries = new Map<string, Entry>()
const recent = new Map<string, Entry>()
const inFlight = new Map<string, VisibleHostedReviewRefreshTarget>()
let timer: ReturnType<typeof setTimeout> | null = null
let visible = false
let disposed = false
const clearTimer = (): void => {
if (timer !== null) {
clearTimeout(timer)
}
timer = null
}
const dueAt = (entry: Entry): number => {
const cooldown = entry.lastAttemptAt + REVIEW_REFRESH_COOLDOWN_MS
if (entry.retryAt !== null) {
return Math.max(cooldown, entry.retryAt)
}
if (entry.discovery) {
return cooldown
}
if (entry.target.intervalMs === null) {
return Infinity
}
const anchor = Math.max(entry.target.fetchedAt ?? -Infinity, entry.lastAttemptAt)
return Math.max(cooldown, anchor + entry.target.intervalMs)
}
const remember = (key: string, entry: Entry): void => {
recent.delete(key)
recent.set(key, entry)
while (recent.size > 512) {
const oldest = recent.keys().next().value
if (oldest !== undefined) {
recent.delete(oldest)
}
}
}
const finish = (
key: string,
startedAt: number,
target: VisibleHostedReviewRefreshTarget,
succeeded: boolean
): void => {
inFlight.delete(key)
if (disposed) {
return
}
const entry = entries.get(key) ?? recent.get(key)
if (entry && entry.lastAttemptAt === startedAt) {
if (!succeeded || !discoveryIdentityChanged(target, entry.target)) {
entry.discovery = false
entry.failures = succeeded ? 0 : entry.failures + 1
entry.retryAt = succeeded
? null
: Date.now() +
Math.max(
entry.target.intervalMs ?? 0,
Math.min(900_000, 60_000 * 2 ** Math.min(entry.failures - 1, 4))
)
}
}
reconcile()
}
function reconcile(): void {
clearTimer()
if (disposed || !visible) {
return
}
const now = Date.now()
const candidates = [...entries.entries()]
.filter(([key]) => !inFlight.has(key))
.sort(
([, a], [, b]) =>
Number(b.target.selected) - Number(a.target.selected) || dueAt(a) - dueAt(b)
)
for (const [key, entry] of candidates) {
if (inFlight.size >= 3) {
break
}
if (inFlight.has(key)) {
continue
}
if (dueAt(entry) > now) {
continue
}
const force = entry.discovery || entry.failures > 0 || entry.target.fetchedAt === null
entry.lastAttemptAt = now
entry.discovery = false
const startedTarget = entry.target
inFlight.set(key, startedTarget)
try {
void entry.target.refresh(force).then(
(succeeded) => finish(key, now, startedTarget, succeeded),
() => finish(key, now, startedTarget, false)
)
} catch {
finish(key, now, startedTarget, false)
}
}
if (inFlight.size >= 3) {
return
}
let next = Infinity
for (const [key, entry] of entries) {
if (!inFlight.has(key)) {
next = Math.min(next, dueAt(entry))
}
}
clearTimer()
if (Number.isFinite(next)) {
timer = setTimeout(reconcile, Math.max(0, next - Date.now()))
}
}
return {
update(targets: readonly VisibleHostedReviewRefreshTarget[]): void {
if (disposed) {
return
}
const keep = new Set(targets.map((target) => target.key))
for (const [key, entry] of entries) {
if (!keep.has(key)) {
entries.delete(key)
remember(key, entry)
}
}
for (const target of targets) {
let entry = entries.get(target.key)
if (!entry) {
const previous = recent.get(target.key)
recent.delete(target.key)
const preserveFailure =
previous !== undefined &&
(target.fetchedAt ?? -Infinity) <= (previous.target.fetchedAt ?? -Infinity)
entry = {
target,
lastAttemptAt: previous?.lastAttemptAt ?? -Infinity,
failures: preserveFailure ? previous.failures : 0,
retryAt: preserveFailure ? previous.retryAt : null,
discovery:
(previous !== undefined && discoveryIdentityChanged(previous.target, target)) ||
target.fetchedAt === null ||
Date.now() - target.fetchedAt >= (target.intervalMs ?? 60_000)
}
entries.set(target.key, entry)
} else if (discoveryIdentityChanged(entry.target, target)) {
entry.discovery = true
} else if (
(target.fetchedAt ?? -Infinity) > (entry.target.fetchedAt ?? -Infinity) &&
(!inFlight.has(target.key) ||
!discoveryIdentityChanged(inFlight.get(target.key) ?? target, target))
) {
entry.failures = 0
entry.retryAt = null
entry.discovery = false
}
entry.target = target
}
reconcile()
},
setVisible(next: boolean): void {
if (next === visible || disposed) {
return
}
visible = next
if (visible) {
for (const entry of entries.values()) {
if (
entry.target.intervalMs === null &&
Date.now() - (entry.target.fetchedAt ?? -Infinity) >= 60_000
) {
entry.discovery = true
}
}
}
reconcile()
},
dispose(): void {
disposed = true
clearTimer()
entries.clear()
recent.clear()
}
}
}
@@ -0,0 +1,404 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { getHostedReviewCacheKey } from '../slices/hosted-review-cache-identity'
import {
createTestStore,
makePR,
makePRRefreshWorktree,
mockApi,
resetRemoteRuntimeMocks,
runtimeEnvironmentCall
} from '../slices/github-slice-test-harness'
import {
getVisibleHostedReviewRefreshTargets,
visibleHostedReviewRefreshInputsChanged
} from './visible-hosted-review-refresh-targets'
import { createVisibleHostedReviewRefreshScheduler } from './visible-hosted-review-refresh-scheduler'
const repo: Repo = { id: 'repo-1', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }
const worktree = makePRRefreshWorktree()
const review: HostedReviewInfo = {
provider: 'gitlab',
number: 8,
title: 'Review',
state: 'open',
status: 'success',
url: 'https://example.com/review/8',
updatedAt: '',
mergeable: 'MERGEABLE'
}
const hostedKey = getHostedReviewCacheKey(
repo.path,
worktree.branch,
null,
repo.id,
null,
null,
true
)
const prKey = `${repo.id}::${worktree.branch}`
function setup(owner: Repo = repo) {
const store = createTestStore()
store.setState({
repos: [owner],
settings: null,
worktreesByRepo: { [repo.id]: [worktree] },
activeWorktreeId: null,
visibleReviewWorktreeIds: [worktree.id],
sshConnectionStates: new Map()
})
return store
}
function targets(store: ReturnType<typeof setup>) {
return getVisibleHostedReviewRefreshTargets(store.getState(), store.getState)
}
describe('visible hosted review refresh targets', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
vi.clearAllMocks()
resetRemoteRuntimeMocks()
mockApi.hostedReview.forBranch.mockResolvedValue(review)
})
afterEach(() => vi.useRealTimers())
it('discovers an unknown provider once and hands known local GitHub to main', async () => {
const store = setup()
mockApi.hostedReview.forBranch.mockResolvedValue({ ...review, provider: 'github' })
expect(targets(store)).toHaveLength(1)
expect(await targets(store)[0].refresh()).toBe(true)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(1)
expect(targets(store)).toEqual([])
expect(mockApi.gh.prForBranch).not.toHaveBeenCalled()
store.setState({
hostedReviewCache: {},
prCache: { [prKey]: { data: null, fetchedAt: Date.now() } }
})
expect(targets(store)).toHaveLength(1)
})
it('preserves explicit non-GitHub linked hints despite a GitHub PR cache', async () => {
const store = setup()
store.setState({
worktreesByRepo: { [repo.id]: [{ ...worktree, linkedGitLabMR: 8 }] },
prCache: { [prKey]: { data: makePR(), fetchedAt: Date.now() } }
})
const rows = targets(store)
expect(rows).toHaveLength(1)
expect(await rows[0].refresh()).toBe(true)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith(
expect.objectContaining({
repoOwnerExecutionHostId: 'local',
linkedGitLabMR: 8,
force: true,
currentHeadOid: worktree.head,
admissionTier: 'background'
})
)
})
it('uses the repo runtime owner rather than the focused server and fills both caches', async () => {
const owner: Repo = { ...repo, executionHostId: 'runtime:owner-server' }
const store = setup(owner)
store.setState({
settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'other-server' }),
worktreesByRepo: { [repo.id]: [{ ...worktree, linkedPR: 12 }] },
activeWorktreeId: worktree.id
})
runtimeEnvironmentCall.mockResolvedValue({ id: 'review', ok: true, result: makePR() })
expect(await targets(store)[0].refresh()).toBe(true)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'owner-server',
method: 'github.prForBranch',
params: expect.objectContaining({
repo: repo.id,
branch: worktree.branch,
reason: 'visible',
linkedPRNumber: 12
})
})
)
expect(mockApi.hostedReview.forBranch).not.toHaveBeenCalled()
expect(store.getState().prCache[`runtime:owner-server::${prKey}`]?.data?.number).toBe(12)
expect(
store.getState().hostedReviewCache[`runtime:owner-server::${repo.id}::${worktree.branch}`]
?.data?.provider
).toBe('github')
})
it('routes non-GitHub runtime reviews and local reviews independently of server focus', async () => {
const owner: Repo = { ...repo, executionHostId: 'runtime:owner-server' }
const remote = setup(owner)
runtimeEnvironmentCall.mockResolvedValue({ id: 'review', ok: true, result: review })
expect(await targets(remote)[0].refresh()).toBe(true)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'owner-server',
method: 'hostedReview.forBranch'
})
)
const local = setup()
local.setState({
settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'other-server' })
})
expect(await targets(local)[0].refresh()).toBe(true)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/repo', repoOwnerExecutionHostId: 'local' })
)
})
it('excludes folders, bare, archived, detached, missing and disconnected SSH workspaces', () => {
const store = setup()
store.setState({ repos: [{ ...repo, kind: 'folder' }] })
expect(targets(store)).toEqual([])
store.setState({ repos: [repo] })
for (const patch of [
{ isBare: true },
{ isArchived: true },
{ branch: '' },
{ branch: 'HEAD' }
]) {
store.setState({ worktreesByRepo: { [repo.id]: [{ ...worktree, ...patch }] } })
expect(targets(store)).toEqual([])
}
store.setState({
worktreesByRepo: { [repo.id]: [worktree] },
visibleReviewWorktreeIds: ['missing']
})
expect(targets(store)).toEqual([])
store.setState({
repos: [{ ...repo, connectionId: 'ssh-1' }],
visibleReviewWorktreeIds: [worktree.id]
})
expect(targets(store)).toEqual([])
})
it('shares host/repo/branch aliases and promotes the selected alias', async () => {
const store = setup()
const alias = { ...worktree, id: 'alias' }
store.setState({
worktreesByRepo: { [repo.id]: [worktree, alias] },
visibleReviewWorktreeIds: [worktree.id, alias.id],
activeWorktreeId: alias.id,
hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: Date.now() } }
})
const rows = targets(store)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ intervalMs: 60_000, selected: true })
await rows[0].refresh()
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith(
expect.objectContaining({ active: true })
)
const revision = rows[0].revision
store.setState({ visibleReviewWorktreeIds: [alias.id, worktree.id] })
expect(targets(store)[0].revision).toBe(revision)
store.setState({ worktreesByRepo: { [repo.id]: [{ ...worktree, head: 'new-head' }, alias] } })
expect(targets(store)[0].revision).not.toBe(revision)
})
it('sets provider-neutral cadence for open, no review, closed and merged states', () => {
const store = setup()
for (const provider of ['gitlab', 'bitbucket', 'azure-devops', 'gitea'] as const) {
for (const [state, status, intervalMs] of [
['open', 'success', 120_000],
['draft', 'pending', 120_000],
['closed', 'failure', 900_000],
['merged', 'pending', 60_000],
['merged', 'success', null],
['merged', 'failure', null],
['merged', 'neutral', null]
] as const) {
store.setState({
hostedReviewCache: {
[hostedKey]: { data: { ...review, provider, state, status }, fetchedAt: Date.now() }
}
})
expect(targets(store)[0].intervalMs).toBe(intervalMs)
}
}
store.setState({ hostedReviewCache: { [hostedKey]: { data: null, fetchedAt: Date.now() } } })
expect(targets(store)[0].intervalMs).toBe(900_000)
store.setState({ activeWorktreeId: worktree.id })
expect(targets(store)[0].intervalMs).toBe(60_000)
})
it('detects preserved hosted cache failures and backs off without blanking the review', async () => {
const store = setup()
store.setState({
hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: Date.now() - 120_000 } }
})
mockApi.hostedReview.forBranch.mockRejectedValue(new Error('temporary failure'))
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
const scheduler = createVisibleHostedReviewRefreshScheduler()
const unsubscribe = store.subscribe((state, previous) => {
if (visibleHostedReviewRefreshInputsChanged(state, previous)) {
scheduler.update(targets(store))
}
})
scheduler.update(targets(store))
scheduler.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(1)
expect(store.getState().hostedReviewCache[hostedKey].data).toEqual(review)
await vi.advanceTimersByTimeAsync(119_999)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledTimes(2)
scheduler.dispose()
unsubscribe()
log.mockRestore()
})
it('ignores unrelated store writes and includes every projection input', () => {
const store = setup()
const state = store.getState()
expect(
visibleHostedReviewRefreshInputsChanged({ ...state, prVisibleRefreshGeneration: 5 }, state)
).toBe(false)
expect(
visibleHostedReviewRefreshInputsChanged({ ...state, activeWorktreeId: worktree.id }, state)
).toBe(true)
expect(
visibleHostedReviewRefreshInputsChanged({ ...state, hostedReviewCache: {} }, state)
).toBe(true)
})
it('keeps positive GitHub ownership after a miss and trusts known GitHub remote identity', () => {
const store = setup()
store.setState({
hostedReviewCache: {
[hostedKey]: { data: null, fetchedAt: Date.now(), linkedReviewHintKey: 'github:12' }
}
})
expect(targets(store)).toEqual([])
store.setState({
hostedReviewCache: {},
repos: [
{
...repo,
gitRemoteIdentity: {
canonicalKey: 'github.com/team/repo',
remoteName: 'origin',
remoteUrl: 'git@github.com:team/repo.git'
}
}
]
})
expect(targets(store)).toEqual([])
store.setState({ hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: Date.now() } } })
expect(targets(store)).toHaveLength(1)
})
it('requires discovery for a changed runtime HEAD even with fresh settled merged metadata', () => {
const store = setup({ ...repo, executionHostId: 'runtime:owner-server' })
store.setState({
prCache: {
[`runtime:owner-server::${prKey}`]: {
data: makePR({ state: 'merged', checksStatus: 'success' }),
fetchedAt: Date.now(),
fetchedHeadOid: 'older-head'
}
}
})
expect(targets(store)[0]).toMatchObject({ fetchedAt: null, intervalMs: null })
})
it('discovers a changed runtime HEAD from persisted metadata without a recorded request HEAD', () => {
const store = setup({ ...repo, executionHostId: 'runtime:owner-server' })
store.setState({
prCache: {
[`runtime:owner-server::${prKey}`]: {
data: makePR({ state: 'merged', checksStatus: 'success', headSha: 'older-head' }),
fetchedAt: Date.now()
}
}
})
expect(targets(store)[0]).toMatchObject({ fetchedAt: null, intervalMs: null })
store.setState({
prCache: {
[`runtime:owner-server::${prKey}`]: {
data: makePR({ state: 'merged', checksStatus: 'success', headSha: 'older-head' }),
fetchedAt: Date.now(),
fetchedHeadOid: worktree.head
}
}
})
expect(targets(store)[0]).toMatchObject({ fetchedAt: Date.now(), intervalMs: null })
})
it.each([
{ head: '', headSha: 'older-head' },
{ head: worktree.head, headSha: '' }
])('preserves fresh settled metadata with an unknown HEAD: %j', ({ head, headSha }) => {
const store = setup({ ...repo, executionHostId: 'runtime:owner-server' })
store.setState({
worktreesByRepo: { [repo.id]: [{ ...worktree, head }] },
prCache: {
[`runtime:owner-server::${prKey}`]: {
data: makePR({ state: 'merged', checksStatus: 'success', headSha }),
fetchedAt: Date.now()
}
}
})
expect(targets(store)[0]).toMatchObject({ fetchedAt: Date.now(), intervalMs: null })
})
it('includes only connected SSH branches and preserves their host scope', async () => {
const store = setup({ ...repo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' })
store.setState({
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
}
]
])
})
const rows = targets(store)
expect(rows[0].key).toBe(`ssh:ssh-1::${repo.id}::${worktree.branch}`)
await rows[0].refresh()
expect(mockApi.hostedReview.forBranch).toHaveBeenCalledWith(
expect.objectContaining({ repoOwnerExecutionHostId: 'ssh:ssh-1' })
)
})
it('limits paired-web card metadata to the selected workspace while preserving discovery', async () => {
const store = setup()
store.setState({
activeWorktreeId: worktree.id,
visibleReviewWorktreeIds: [worktree.id, 'other'],
worktreesByRepo: { [repo.id]: [worktree, { ...worktree, id: 'other', branch: 'other' }] }
})
const selected = getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, {
selectedOnly: true
})
expect(selected).toHaveLength(1)
expect(selected[0].selected).toBe(true)
store.setState({ activeWorktreeId: null })
expect(
getVisibleHostedReviewRefreshTargets(store.getState(), store.getState, { selectedOnly: true })
).toEqual([])
})
it('lets normal hosted-review refreshes share the host cache but forces discovery', async () => {
const store = setup()
await targets(store)[0].refresh(false)
expect(mockApi.hostedReview.forBranch).toHaveBeenLastCalledWith(
expect.not.objectContaining({ force: true })
)
await targets(store)[0].refresh(true)
expect(mockApi.hostedReview.forBranch).toHaveBeenLastCalledWith(
expect.objectContaining({ force: true })
)
})
})
@@ -0,0 +1,157 @@
import type { AppState } from '../types'
import {
getHostedReviewCacheKey,
linkedReviewHintKey
} from '../slices/hosted-review-cache-identity'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy'
import { buildPRRefreshCandidate, findWorktreeById } from './worktree-refresh'
import { getPRRefreshRuntimeRepoTarget } from './repository-routing'
import type { VisibleHostedReviewRefreshTarget } from './visible-hosted-review-refresh-scheduler'
import { shouldCoordinateVisibleGitHubReview } from './visible-hosted-review-refresh-ownership'
export function getVisibleHostedReviewRefreshTargets(
state: AppState,
getState: () => AppState,
options?: { selectedOnly?: boolean }
): VisibleHostedReviewRefreshTarget[] {
const targets = new Map<string, VisibleHostedReviewRefreshTarget>()
const revisions = new Map<string, Map<string, string>>()
for (const id of state.visibleReviewWorktreeIds) {
if (options?.selectedOnly && id !== state.activeWorktreeId) {
continue
}
const worktree = findWorktreeById(state, id)
if (!worktree || worktree.isArchived || worktree.isBare || !worktree.branch) {
continue
}
const candidate = buildPRRefreshCandidate(state, worktree)
if (
!candidate ||
candidate.repoKind !== 'git' ||
!candidate.branch ||
candidate.branch === 'HEAD'
) {
continue
}
if (candidate.connectionId && candidate.connectionState !== 'connected') {
continue
}
const key = getHostedReviewCacheKey(
candidate.repoPath,
candidate.branch,
state.settings,
candidate.repoId,
candidate.connectionId,
candidate.executionHostId,
true
)
const hostedEntry = state.hostedReviewCache[key]
const prEntry = state.prCache[candidate.cacheKey]
const hints = {
linkedGitHubPR: worktree.linkedPR,
linkedGitLabMR: worktree.linkedGitLabMR,
linkedBitbucketPR: worktree.linkedBitbucketPR,
linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR,
linkedGiteaPR: worktree.linkedGiteaPR
}
const nonGitHub =
hints.linkedGitLabMR != null ||
hints.linkedBitbucketPR != null ||
hints.linkedAzureDevOpsPR != null ||
hints.linkedGiteaPR != null ||
(hostedEntry?.data != null && hostedEntry.data.provider !== 'github')
const knownGitHub = shouldCoordinateVisibleGitHubReview(state, worktree, candidate)
const runtime = knownGitHub ? getPRRefreshRuntimeRepoTarget(state, candidate) : null
if (knownGitHub && !runtime) {
continue
}
const selected = id === state.activeWorktreeId
const usePR =
knownGitHub &&
prEntry !== undefined &&
prEntry.fetchedAt >= (hostedEntry?.fetchedAt ?? -Infinity)
const review = usePR ? prEntry.data : hostedEntry?.data
const fetchedAt = usePR ? prEntry.fetchedAt : (hostedEntry?.fetchedAt ?? null)
const target: VisibleHostedReviewRefreshTarget = {
key,
revision: `${candidate.currentHeadOid ?? ''}|${linkedReviewHintKey(hints)}`,
fetchedAt:
usePR &&
candidate.cachedHeadOid &&
candidate.currentHeadOid &&
candidate.cachedHeadOid !== candidate.currentHeadOid
? null
: fetchedAt,
selected,
intervalMs: reviewRefreshIntervalMs({
state: review?.state,
checksStatus: usePR ? prEntry.data?.checksStatus : hostedEntry?.data?.status,
hasReview: review ? true : fetchedAt !== null ? false : null,
selected
}),
refresh: async (force = true) => {
const before = getState()
const beforeFetchedAt = knownGitHub
? before.prCache[candidate.cacheKey]?.fetchedAt
: before.hostedReviewCache[key]?.fetchedAt
await (runtime
? before.fetchPRForBranch(candidate.repoPath, candidate.branch, {
force: true,
reason: 'visible',
repoId: candidate.repoId,
worktreeId: id,
linkedPRNumber: candidate.linkedPRNumber,
fallbackPRNumber: candidate.fallbackPRNumber,
fallbackPRSource: candidate.fallbackPRSource
})
: before.fetchHostedReviewForBranch(candidate.repoPath, candidate.branch, {
...hints,
force,
repoId: candidate.repoId,
repoOwnerExecutionHostId: getRepoExecutionHostId(candidate),
fallbackGitHubPR: nonGitHub ? null : candidate.fallbackPRNumber,
currentHeadOid: candidate.currentHeadOid,
active: selected,
admissionTier: selected ? 'interactive' : 'background'
}))
const after = getState()
const afterFetchedAt = knownGitHub
? after.prCache[candidate.cacheKey]?.fetchedAt
: after.hostedReviewCache[key]?.fetchedAt
return (
afterFetchedAt !== undefined &&
(beforeFetchedAt === undefined || afterFetchedAt > beforeFetchedAt)
)
}
}
const branchRevisions = revisions.get(key) ?? new Map<string, string>()
branchRevisions.set(id, target.revision)
revisions.set(key, branchRevisions)
const previous = targets.get(key)
if (!previous || (selected && !previous.selected)) {
targets.set(key, target)
}
}
for (const [key, target] of targets) {
target.aliasRevisions = revisions.get(key)
target.revision = [...(target.aliasRevisions?.values() ?? [])].sort().join(';')
}
return [...targets.values()]
}
export function visibleHostedReviewRefreshInputsChanged(
state: AppState,
previous: AppState
): boolean {
return (
state.visibleReviewWorktreeIds !== previous.visibleReviewWorktreeIds ||
state.activeWorktreeId !== previous.activeWorktreeId ||
state.worktreesByRepo !== previous.worktreesByRepo ||
state.repos !== previous.repos ||
state.settings !== previous.settings ||
state.sshConnectionStates !== previous.sshConnectionStates ||
state.prCache !== previous.prCache ||
state.hostedReviewCache !== previous.hostedReviewCache
)
}
@@ -257,6 +257,8 @@ export function buildPRRefreshCandidate(
cacheKey,
worktreeId: worktree.id,
currentHeadOid: worktree.head ?? null,
cachedHeadOid: state.prCache[cacheKey]?.fetchedHeadOid ?? cachedPR?.headSha ?? null,
isSelected: state.activeWorktreeId === worktree.id,
// Why: persisted linked PR metadata is exact; PR cache numbers are only fallback hints after branch-lookup misses.
linkedPRNumber: worktree.linkedPR ?? null,
fallbackPRNumber,
@@ -360,9 +360,13 @@ describe('createGitHubSlice.fetchPRForBranch', () => {
linkedPRNumber: testCase.linkedPRNumber
})
).resolves.toBeNull()
const fetchedHeadOid = Object.values(testCase.worktreesByRepo)
.flat()
.find((worktree) => worktree.id === testCase.worktreeId)?.head
expect(store.getState().prCache[`${repoId}::${branch}`]).toEqual({
data: null,
fetchedAt: 2
fetchedAt: 2,
...(fetchedHeadOid ? { fetchedHeadOid } : {})
})
})
@@ -75,6 +75,11 @@ function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
badgeColor: 'blue',
addedAt: 1,
kind: 'git',
gitRemoteIdentity: {
canonicalKey: 'github.com/owner/repo',
remoteName: 'origin',
remoteUrl: 'https://github.com/owner/repo.git'
},
...overrides
}
}
@@ -278,7 +283,8 @@ describe('GitHub PR refresh owner-host routing', () => {
store.getState().reportVisibleGitHubPRRefreshCandidates(['wt-local', 'wt-runtime'], 123)
await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1))
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(store.getState().visibleReviewWorktreeIds).toEqual(['wt-local', 'wt-runtime'])
expect(reportVisiblePRRefreshCandidates).toHaveBeenCalledWith({
candidates: [
expect.objectContaining({
@@ -289,17 +295,57 @@ describe('GitHub PR refresh owner-host routing', () => {
],
generation: 123
})
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: {
repo: 'repo-runtime',
branch: 'feature/runtime',
linkedPRNumber: null,
currentHeadOid: 'head-oid',
reason: 'visible'
},
timeoutMs: 30_000
})
})
})
describe('known non-GitHub review event routing', () => {
beforeEach(() => {
vi.clearAllMocks()
resetRuntimeMocks()
})
it.each(['local', 'runtime:env-1'] as const)(
'does not start GitHub lookups from activation, push or manual enqueue on %s',
(executionHostId) => {
const store = createTestStore()
const worktree = { ...makeWorktree('repo', 'feature/review', 'worktree'), linkedGitLabMR: 8 }
seed(store, {
repos: [makeRepo({ id: 'repo', path: '/repo', executionHostId })],
worktreesByRepo: { repo: [worktree] },
activeWorktreeId: worktree.id
})
store.getState().enqueueGitHubPRRefresh(worktree.id, 'active', 80)
store.getState().refreshGitHubForWorktreeIfStale(worktree.id)
store.getState().refreshGitHubForWorktree(worktree.id)
expect(enqueuePRRefresh).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled()
}
)
})
it('acknowledges visibility admission before foreground callers can enqueue work', async () => {
vi.clearAllMocks()
let release: () => void = () => {}
reportVisiblePRRefreshCandidates.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve
})
)
const store = createTestStore()
seed(store, {
repos: [makeRepo({ id: 'repo', path: '/repo' })],
worktreesByRepo: { repo: [makeWorktree('repo', 'branch', 'worktree')] }
})
const acknowledged = vi.fn()
const completion = store
.getState()
.reportVisibleGitHubPRRefreshCandidates(['worktree'], 1)
.then(acknowledged)
await Promise.resolve()
expect(acknowledged).not.toHaveBeenCalled()
release()
await completion
expect(acknowledged).toHaveBeenCalledOnce()
})
@@ -18,12 +18,21 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
it('publishes no store update when the cache sweep changes nothing', () => {
const store = createTestStore()
store.setState({
repos: [{ id: 'repo-1', path: '/repo', name: 'repo', kind: 'git' }],
repos: [
{
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 1,
kind: 'git'
}
],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false,
worktreesByRepo: { 'repo-1': [makePRRefreshWorktree()] }
} as unknown as Partial<AppState>)
})
let publications = 0
const unsubscribe = store.subscribe(() => {
publications += 1
@@ -42,7 +51,7 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
groupBy: 'repo',
worktreeCardProperties: ['comment'],
rightSidebarOpen: false
} as unknown as Partial<AppState>)
})
let publications = 0
const unsubscribe = store.subscribe(() => {
publications += 1
@@ -103,206 +112,45 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
expect(mockApi.gh.issue).not.toHaveBeenCalled()
})
it('bounds stale PR repo identity reads to a constant amount per repo and worktree', () => {
const store = createTestStore()
const repoCount = 128
let repoIdentityReads = 0
const repoIds = Array.from({ length: repoCount }, (_, index) => `repo-${index}`)
const repos = repoIds.map((repoId) => {
const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const }
return Object.defineProperty(repo, 'id', {
configurable: true,
enumerable: true,
get: () => {
repoIdentityReads += 1
return repoId
}
})
})
const worktreesByRepo = Object.fromEntries(
repoIds.map((repoId, index) => [
repoId,
[
makePRRefreshWorktree({
id: `wt-${index}`,
repoId,
path: `/${repoId}/worktrees/feature`,
branch: `feature/${index}`,
lastActivityAt: index
})
]
])
)
store.setState({
repos,
groupBy: 'repo',
worktreeCardProperties: ['pr'],
rightSidebarOpen: false,
worktreesByRepo
} as unknown as Partial<AppState>)
repoIdentityReads = 0
store.getState().refreshAllGitHub()
expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 5)
expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(5)
})
it('stops repo indexing after a sparse enabled match', () => {
const store = createTestStore()
const repoCount = 128
let repoIdentityReads = 0
const repos = Array.from({ length: repoCount }, (_, index) => {
const repoId = `repo-${index}`
const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const }
return Object.defineProperty(repo, 'id', {
configurable: true,
enumerable: true,
get: () => {
repoIdentityReads += 1
return repoId
}
})
})
store.setState({
repos,
groupBy: 'repo',
worktreeCardProperties: ['pr'],
rightSidebarOpen: false,
worktreesByRepo: {
'repo-0': [
makePRRefreshWorktree({
id: 'wt-sparse',
repoId: 'repo-0',
path: '/repo-0/worktrees/feature',
branch: 'feature/sparse'
})
]
}
} as unknown as Partial<AppState>)
repoIdentityReads = 0
store.getState().refreshAllGitHub()
expect(repoIdentityReads).toBeLessThanOrEqual(5)
expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledOnce()
})
it('does not restart repo indexing for repeated missing IDs', () => {
const store = createTestStore()
const repoCount = 128
let repoIdentityReads = 0
const repos = Array.from({ length: repoCount }, (_, index) => {
const repoId = `repo-${index}`
const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const }
return Object.defineProperty(repo, 'id', {
configurable: true,
enumerable: true,
get: () => {
repoIdentityReads += 1
return repoId
}
})
})
const missingWorktrees = Array.from({ length: repoCount }, (_, index) =>
makePRRefreshWorktree({
id: `wt-missing-${index}`,
repoId: `missing-${index}`,
branch: `feature/missing-${index}`
})
)
store.setState({
repos,
groupBy: 'repo',
worktreeCardProperties: ['pr'],
rightSidebarOpen: false,
worktreesByRepo: { missing: missingWorktrees }
} as unknown as Partial<AppState>)
repoIdentityReads = 0
store.getState().refreshAllGitHub()
expect(repoIdentityReads).toBeLessThanOrEqual(repoCount)
expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
})
it('keeps runtime PR dispatch identity reads linear in PR-status grouping', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-linear-pr',
ok: true,
result: makePR({ number: 12 }),
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repoCount = 128
let repoIdentityReads = 0
let worktreeIdentityReads = 0
const repos = Array.from({ length: repoCount }, (_, index) => {
const repoId = `runtime-repo-${index}`
const repoPath = `/runtime/repo-${index}`
const repo = {
id: repoId,
path: repoPath,
name: repoId,
kind: 'git' as const,
executionHostId: 'runtime:env-1'
}
Object.defineProperty(repo, 'id', {
configurable: true,
enumerable: true,
get: () => {
repoIdentityReads += 1
return repoId
}
})
return Object.defineProperty(repo, 'path', {
configurable: true,
enumerable: true,
get: () => {
repoIdentityReads += 1
return repoPath
}
})
})
const worktreesByRepo = Object.fromEntries(
repos.map((repo, index) => {
const worktreeId = `runtime-wt-${index}`
const worktree = makePRRefreshWorktree({
id: worktreeId,
repoId: repo.id,
path: `${repo.path}/worktrees/feature`,
branch: `feature/${index}`,
linkedPR: 12,
lastActivityAt: index
})
Object.defineProperty(worktree, 'id', {
configurable: true,
enumerable: true,
get: () => {
worktreeIdentityReads += 1
return worktreeId
it.each(['repo', 'pr-status'] as const)(
'does not fan out PR lookups on resume with %s grouping',
(groupBy) => {
const store = createTestStore()
store.setState({
repos: [
{
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: '',
addedAt: 1,
kind: 'git'
}
})
return [repo.id, [worktree]]
],
groupBy,
worktreeCardProperties: ['pr', 'status'],
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
activeWorktreeId: 'visible',
visibleReviewWorktreeIds: ['visible'],
worktreesByRepo: {
'repo-1': [
makePRRefreshWorktree({ id: 'visible', branch: 'visible' }),
...Array.from({ length: 100 }, (_, index) =>
makePRRefreshWorktree({
id: `offscreen-${index}`,
branch: `offscreen-${index}`
})
)
]
}
})
)
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos,
groupBy: 'pr-status',
worktreeCardProperties: ['comment'],
worktreesByRepo
} as unknown as Partial<AppState>)
repoIdentityReads = 0
worktreeIdentityReads = 0
store.getState().refreshAllGitHub()
await vi.waitFor(() => expect(Object.keys(store.getState().prCache)).toHaveLength(repoCount))
expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount)
expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 20)
expect(worktreeIdentityReads).toBeLessThanOrEqual(repoCount * 5)
})
store.getState().refreshAllGitHub()
expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
expect(mockApi.gh.prForBranch).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
}
)
it('keeps runtime issue dispatch repo identity reads linear', async () => {
runtimeEnvironmentCall.mockResolvedValue({
@@ -370,234 +218,6 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 15)
})
it('keeps the first repo owner when duplicate IDs span hosts', () => {
const store = createTestStore()
const repoId = 'duplicate-repo'
const firstRepo = {
id: repoId,
path: '/first',
name: 'first',
kind: 'git' as const,
connectionId: 'first',
executionHostId: 'ssh:first'
}
const secondRepo = {
id: repoId,
path: '/second',
name: 'second',
kind: 'git' as const,
connectionId: 'second',
executionHostId: 'ssh:second'
}
const laterRepo = {
id: 'later-repo',
path: '/later',
name: 'later',
kind: 'git' as const
}
store.setState({
repos: [firstRepo, secondRepo, laterRepo],
groupBy: 'repo',
worktreeCardProperties: ['pr'],
rightSidebarOpen: false,
sshConnectionStates: new Map([
['first', { status: 'connected' }],
['second', { status: 'connected' }]
]),
worktreesByRepo: {
first: [
makePRRefreshWorktree({
id: 'wt-duplicate-first',
repoId,
path: '/first/worktrees/feature',
branch: 'feature/duplicate-first'
})
],
middle: [
makePRRefreshWorktree({
id: 'wt-later',
repoId: laterRepo.id,
path: '/later/worktrees/feature',
branch: 'feature/later'
})
],
last: [
makePRRefreshWorktree({
id: 'wt-duplicate-last',
repoId,
path: '/first/worktrees/last',
branch: 'feature/duplicate-last'
})
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
const duplicateCandidates = mockApi.gh.enqueuePRRefresh.mock.calls
.map(([call]) => call.candidate)
.filter((candidate) => candidate.repoId === repoId)
expect(duplicateCandidates).toHaveLength(2)
expect(duplicateCandidates).toEqual(
expect.arrayContaining([
expect.objectContaining({
repoPath: '/first',
connectionId: 'first',
executionHostId: 'ssh:first'
}),
expect.objectContaining({
repoPath: '/first',
connectionId: 'first',
executionHostId: 'ssh:first'
})
])
)
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('refreshes stale PR data when source control is the visible PR surface', () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
activeWorktreeId: 'wt-1',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledWith({
candidate: expect.objectContaining({ repoPath, branch }),
reason: 'swr',
priority: 10
})
})
it('bounds rejected stale PR refresh IPCs', async () => {
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/test'
const error = new Error('Access denied: unknown repository path')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockApi.gh.enqueuePRRefresh.mockRejectedValueOnce(error)
store.setState({
repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
activeWorktreeId: 'wt-1',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/test',
branch,
displayName: 'test',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1
}
]
}
} as unknown as Partial<AppState>)
try {
store.getState().refreshAllGitHub()
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith('Failed to enqueue PR refresh:', error)
)
} finally {
warn.mockRestore()
}
})
it('refreshes runtime PR data directly instead of enqueueing local coordinator work', async () => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-1',
ok: true,
result: makePR({ number: 12 }),
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repoPath = '/repo'
const branch = 'feature/runtime'
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'],
repos: [
{
id: 'repo-1',
path: repoPath,
name: 'repo',
kind: 'git',
executionHostId: 'runtime:env-1'
}
],
groupBy: 'repo',
worktreeCardProperties: ['comment'],
activeWorktreeId: 'wt-1',
rightSidebarOpen: true,
rightSidebarTab: 'source-control',
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/worktrees/runtime',
branch,
displayName: 'runtime',
isMainWorktree: false,
isBare: false,
isArchived: false,
lastActivityAt: 1
}
]
}
} as unknown as Partial<AppState>)
store.getState().refreshAllGitHub()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: {
repo: 'repo-1',
branch,
linkedPRNumber: null,
currentHeadOid: null,
reason: 'swr'
},
timeoutMs: 30_000
})
})
it('does not refresh stale linked issues when the issue card section is hidden', async () => {
const store = createTestStore()
const repoPath = '/repo'
+2
View File
@@ -33,6 +33,8 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
prRefreshSequences: {},
prRefreshStates: {},
prVisibleRefreshGeneration: 0,
visibleReviewWorktreeIds: [],
visibleReviewCardWorktreeIds: [],
workItemsCache: {},
workItemsInvalidationNonce: 0,
projectViewCache: {},
@@ -1,3 +1,4 @@
import type { LinkedReviewHints } from './hosted-review-cache-identity'
import type {
CreateHostedReviewInput,
CreateStackedHostedReviewInput,
@@ -238,3 +239,25 @@ export function settingsForHostedReviewActionOwner(
}
return settingsForHostedReviewRepoOwner(settings, repo)
}
export function hostedReviewBranchLookupArgs(
branch: string,
options?: HostedReviewFetchOptions & LinkedReviewHints
) {
const fallbackGitHubPR =
options?.linkedGitHubPR == null ? (options?.fallbackGitHubPR ?? null) : null
return {
branch,
...(options?.force === true ? { force: true } : {}),
...(options?.admissionTier ? { admissionTier: options.admissionTier } : {}),
...(options?.repoId !== undefined ? { repoId: options.repoId } : {}),
currentHeadOid: options?.currentHeadOid ?? null,
...(options?.active === true ? { active: true } : {}),
linkedGitHubPR: options?.linkedGitHubPR ?? null,
...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}),
linkedGitLabMR: options?.linkedGitLabMR ?? null,
linkedBitbucketPR: options?.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: options?.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: options?.linkedGiteaPR ?? null
}
}
+2 -15
View File
@@ -19,6 +19,7 @@ import {
findHostedReviewRepoForFetch,
hasNewerHostedReviewCacheEntry,
hostedReviewOwnerIpcArgs,
hostedReviewBranchLookupArgs,
isFreshHostedReview,
isStaleMergedGitHubReviewForHead,
settingsForHostedReviewActionOwner,
@@ -193,21 +194,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
requestGenerations.set(cacheKey, generation)
const request = (async () => {
try {
const fallbackGitHubPR =
options?.linkedGitHubPR == null ? (options?.fallbackGitHubPR ?? null) : null
const args = {
branch,
...(options?.admissionTier ? { admissionTier: options.admissionTier } : {}),
...(options?.repoId !== undefined ? { repoId: options.repoId } : {}),
currentHeadOid: options?.currentHeadOid ?? null,
...(options?.active === true ? { active: true } : {}),
linkedGitHubPR: options?.linkedGitHubPR ?? null,
...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}),
linkedGitLabMR: options?.linkedGitLabMR ?? null,
linkedBitbucketPR: options?.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: options?.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: options?.linkedGiteaPR ?? null
}
const args = hostedReviewBranchLookupArgs(branch, options)
const review =
target.kind === 'environment'
? await callRuntimeRpc<HostedReviewInfo | null>(
@@ -68,6 +68,8 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & {
connectionId?: string | null
executionHostId?: string | null
connectionState?: 'connected' | 'disconnected' | 'unknown'
isSelected?: boolean
cachedHeadOid?: string | null
cachedFetchedAt?: number | null
cachedHasPR?: boolean | null
cachedPRState?: PRState | null
+1
View File
@@ -53,6 +53,7 @@ export type HostedReviewInfo = {
}
export type HostedReviewForBranchArgs = {
force?: boolean
repoPath: string
repoId?: string
admissionTier?: 'interactive' | 'status' | 'background'
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { REVIEW_REFRESH_COOLDOWN_MS, reviewRefreshIntervalMs } from './review-refresh-policy'
describe('review refresh intervals', () => {
it.each([
[{ state: 'open', selected: true }, 60_000],
[{ state: 'open', selected: false }, 120_000],
[{ state: 'draft', selected: true }, 60_000],
[{ state: 'draft' }, 120_000],
[{ state: 'merged', checksStatus: 'pending' }, 60_000],
[{ state: 'merged', checksStatus: null }, 60_000],
[{ state: 'merged' }, 60_000],
[{ state: 'merged', checksStatus: 'success' }, null],
[{ state: 'merged', checksStatus: 'failure' }, null],
[{ state: 'merged', checksStatus: 'neutral' }, null],
[{ state: 'closed', selected: true }, 900_000],
[{ state: 'closed' }, 900_000],
[{ hasReview: false, selected: true }, 60_000],
[{ hasReview: false }, 900_000],
[{ state: 'open', checksStatus: 'success' }, 120_000],
[{ state: 'open', checksStatus: 'failure', selected: true }, 60_000]
] as const)('uses %j → %s', (input, expected) => {
expect(reviewRefreshIntervalMs(input)).toBe(expected)
})
it('keeps exposure cooldown separate from periodic refresh cadence', () => {
expect(REVIEW_REFRESH_COOLDOWN_MS).toBe(10_000)
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { HostedReviewState } from './hosted-review'
import type { CheckStatus } from './github/pull-request-types'
export const REVIEW_REFRESH_COOLDOWN_MS = 10_000
export const CLOSED_REVIEW_REFRESH_INTERVAL_MS = 15 * 60_000
export function reviewRefreshIntervalMs(input: {
state?: HostedReviewState | null
checksStatus?: CheckStatus | null
hasReview?: boolean | null
selected?: boolean
}): number | null {
if (input.hasReview === false) {
return input.selected ? 60_000 : 15 * 60_000
}
if (input.state === 'merged') {
// Neutral means completed checks or no checks in the hosted-review contract.
return input.checksStatus == null || input.checksStatus === 'pending' ? 60_000 : null
}
if (input.state === 'closed') {
return CLOSED_REVIEW_REFRESH_INTERVAL_MS
}
return input.selected ? 60_000 : 120_000
}
export function finishedReviewRefreshIntervalMs(
state: HostedReviewState | null | undefined,
status: CheckStatus | null | undefined
): number | null {
return state === 'merged' || state === 'closed'
? reviewRefreshIntervalMs({ state, checksStatus: status })
: null
}
@@ -5,6 +5,7 @@ import { OptionalGitAdmissionTier } from './git-admission-tier-params'
export const HostedReviewForBranch = z.object({
repo: requiredString('Missing repo selector'),
branch: requiredString('Missing branch'),
force: z.boolean().optional(),
admissionTier: OptionalGitAdmissionTier,
currentHeadOid: z.string().nullable().optional(),
// Only the caller's selected worktree; the host caps how many earn the fast tier.