mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+48
@@ -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)
|
||||
})
|
||||
})
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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])
|
||||
}
|
||||
-12
@@ -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) {
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+102
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+8
-116
@@ -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({
|
||||
|
||||
+333
@@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
+59
-51
@@ -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)
|
||||
})
|
||||
|
||||
+78
-103
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
-35
@@ -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,
|
||||
|
||||
-105
@@ -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
|
||||
])
|
||||
}
|
||||
+3
-15
@@ -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,
|
||||
|
||||
+49
@@ -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()
|
||||
})
|
||||
+41
@@ -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(),
|
||||
|
||||
+2
@@ -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 ||
|
||||
|
||||
+94
-105
@@ -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])
|
||||
}
|
||||
|
||||
+108
-1
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ export type HostedReviewInfo = {
|
||||
}
|
||||
|
||||
export type HostedReviewForBranchArgs = {
|
||||
force?: boolean
|
||||
repoPath: string
|
||||
repoId?: string
|
||||
admissionTier?: 'interactive' | 'status' | 'background'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user