diff --git a/src/main/github/client-pr-checks.test.ts b/src/main/github/client-pr-checks.test.ts index 43be8ba642e..a9732cacf21 100644 --- a/src/main/github/client-pr-checks.test.ts +++ b/src/main/github/client-pr-checks.test.ts @@ -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((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() diff --git a/src/main/github/client-pr-lookup-coalescing.test.ts b/src/main/github/client-pr-lookup-coalescing.test.ts new file mode 100644 index 00000000000..6227f9ece75 --- /dev/null +++ b/src/main/github/client-pr-lookup-coalescing.test.ts @@ -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((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((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((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) + } + }) +}) diff --git a/src/main/github/client/check/get-pr-checks.ts b/src/main/github/client/check/get-pr-checks.ts index 50db1ee9926..0fc997c8793 100644 --- a/src/main/github/client/check/get-pr-checks.ts +++ b/src/main/github/client/check/get-pr-checks.ts @@ -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 = new Map() + +export function getPRChecks( + repoPath: string, + prNumber: number, + headSha?: string, + prRepo?: GitHubApiRepository | null, + options?: { noCache?: boolean }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + 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) + ) +} diff --git a/src/main/github/client/list/work-item-search-batch.ts b/src/main/github/client/list/work-item-search-batch.ts index 7f2154b328a..c7779b95269 100644 --- a/src/main/github/client/list/work-item-search-batch.ts +++ b/src/main/github/client/list/work-item-search-batch.ts @@ -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({ 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(request: SearchRequest): Promise> { const environment = { ...(request.environment ?? request.options.env ?? process.env) } const scope = workItemSearchScope(request.options, environment) diff --git a/src/main/github/client/lookup/pr-for-branch-outcome.ts b/src/main/github/client/lookup/pr-for-branch-outcome.ts index 6bda22315b9..9de76705737 100644 --- a/src/main/github/client/lookup/pr-for-branch-outcome.ts +++ b/src/main/github/client/lookup/pr-for-branch-outcome.ts @@ -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 = 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 + ) } diff --git a/src/main/github/client/merge/merge-pr.ts b/src/main/github/client/merge/merge-pr.ts index 07518d7993c..d54b04a6a73 100644 --- a/src/main/github/client/merge/merge-pr.ts +++ b/src/main/github/client/merge/merge-pr.ts @@ -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 = diff --git a/src/main/github/client/merge/pr-auto-merge.ts b/src/main/github/client/merge/pr-auto-merge.ts index 0cc65c5fb03..45c970662ec 100644 --- a/src/main/github/client/merge/pr-auto-merge.ts +++ b/src/main/github/client/merge/pr-auto-merge.ts @@ -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 = diff --git a/src/main/github/client/update/pr-details.ts b/src/main/github/client/update/pr-details.ts index 2348174e931..503167fc781 100644 --- a/src/main/github/client/update/pr-details.ts +++ b/src/main/github/client/update/pr-details.ts @@ -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 = diff --git a/src/main/github/client/update/pr-ready.ts b/src/main/github/client/update/pr-ready.ts index 40c764d290d..3f76b2fc49d 100644 --- a/src/main/github/client/update/pr-ready.ts +++ b/src/main/github/client/update/pr-ready.ts @@ -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 = diff --git a/src/main/github/client/update/pr-reviewers.ts b/src/main/github/client/update/pr-reviewers.ts index e502158f661..54444def554 100644 --- a/src/main/github/client/update/pr-reviewers.ts +++ b/src/main/github/client/update/pr-reviewers.ts @@ -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 = diff --git a/src/main/github/client/update/pr-state.ts b/src/main/github/client/update/pr-state.ts index d22916c8d54..6b93d850438 100644 --- a/src/main/github/client/update/pr-state.ts +++ b/src/main/github/client/update/pr-state.ts @@ -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 = diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index dac93c146e0..c6605be58c5 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -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( diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index d32dfadb3b9..4dfb8ea7024 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -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 { 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 diff --git a/src/main/github/github-read-execution-scope.ts b/src/main/github/github-read-execution-scope.ts new file mode 100644 index 00000000000..614867c5188 --- /dev/null +++ b/src/main/github/github-read-execution-scope.ts @@ -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') +} diff --git a/src/main/github/pr-mutation-review-invalidation.test.ts b/src/main/github/pr-mutation-review-invalidation.test.ts new file mode 100644 index 00000000000..5dcacd5ff55 --- /dev/null +++ b/src/main/github/pr-mutation-review-invalidation.test.ts @@ -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 Promise>() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => Promise) => + 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): Promise { + return mocks.handlers.get(channel)!( + { sender: { id: 1 } }, + { repoPath: repo.path, repoId: repo.id, ...args } + ) +} + +const mutations: { label: string; run: (repo: Repo) => Promise }[] = [ + { 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 { + mocks.resolve.mockImplementationOnce( + () => + new Promise((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) + }) +}) diff --git a/src/main/github/pr-mutation-review-invalidation.ts b/src/main/github/pr-mutation-review-invalidation.ts new file mode 100644 index 00000000000..d1cde03cc86 --- /dev/null +++ b/src/main/github/pr-mutation-review-invalidation.ts @@ -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 })) +} diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts index f2edf51c676..510573ae672 100644 --- a/src/main/github/pr-refresh-candidate-policy.ts +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -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 + ) +} diff --git a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts index c1f0ccbf76d..8f5a5cf5b32 100644 --- a/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts +++ b/src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts @@ -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) diff --git a/src/main/github/pr-refresh-coordinator-completion-order.test.ts b/src/main/github/pr-refresh-coordinator-completion-order.test.ts new file mode 100644 index 00000000000..b9208d22771 --- /dev/null +++ b/src/main/github/pr-refresh-coordinator-completion-order.test.ts @@ -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 = new Map() + let finishOlder: (outcome: PRRefreshOutcome) => void = () => {} + const provider = vi.fn<() => Promise>() + provider + .mockImplementationOnce( + () => new Promise((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((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((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((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 = new Map() + let finish: (outcome: PRRefreshOutcome) => void = () => {} + const provider = vi.fn(() => new Promise((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((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) + }) +}) diff --git a/src/main/github/pr-refresh-coordinator-error-cadence.test.ts b/src/main/github/pr-refresh-coordinator-error-cadence.test.ts new file mode 100644 index 00000000000..917e7be1bc0 --- /dev/null +++ b/src/main/github/pr-refresh-coordinator-error-cadence.test.ts @@ -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) + }) +}) diff --git a/src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts b/src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts new file mode 100644 index 00000000000..fcf79f5274e --- /dev/null +++ b/src/main/github/pr-refresh-coordinator-foreground-pacing.test.ts @@ -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) + }) +}) diff --git a/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts b/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts new file mode 100644 index 00000000000..8897da22153 --- /dev/null +++ b/src/main/github/pr-refresh-coordinator-visibility-policy.test.ts @@ -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((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((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((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() +}) diff --git a/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts b/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts index 37d8f6f6654..407af19d837 100644 --- a/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts +++ b/src/main/github/pr-refresh-coordinator-visible-follow-up.test.ts @@ -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) + } + ) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index d2976079b2a..c375b706379 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -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) diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts index 7a88bd74a0a..6902afbf874 100644 --- a/src/main/github/pr-refresh-pacing.ts +++ b/src/main/github/pr-refresh-pacing.ts @@ -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() @@ -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 { diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts index 387c4931868..ee765cc80bb 100644 --- a/src/main/github/pr-refresh-queue-drainer.ts +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -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) diff --git a/src/main/github/pr-refresh-queue-growth-bound.test.ts b/src/main/github/pr-refresh-queue-growth-bound.test.ts index 9bd4c6e19bf..759fa4c2fe7 100644 --- a/src/main/github/pr-refresh-queue-growth-bound.test.ts +++ b/src/main/github/pr-refresh-queue-growth-bound.test.ts @@ -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' diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts index ef2a706b589..85b7422ec42 100644 --- a/src/main/github/pr-refresh-queue.ts +++ b/src/main/github/pr-refresh-queue.ts @@ -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() private order = 0 + // Completion ownership shares the cooldown map's bound. + private readonly backgroundNotBefore = new Map() 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 } diff --git a/src/main/github/pr-refresh-retry-state.ts b/src/main/github/pr-refresh-retry-state.ts index fa66bf021a4..b4999b9c04d 100644 --- a/src/main/github/pr-refresh-retry-state.ts +++ b/src/main/github/pr-refresh-retry-state.ts @@ -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() @@ -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 } diff --git a/src/main/github/pr-refresh-visibility.ts b/src/main/github/pr-refresh-visibility.ts index 9673252b6fe..15dab59db1e 100644 --- a/src/main/github/pr-refresh-visibility.ts +++ b/src/main/github/pr-refresh-visibility.ts @@ -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 }>() + private readonly visibleByWindow = new Map< + number, + { generation: number; candidates: Map } + >() 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() + 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 { + 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 } } diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index aa997d37c9a..bffe5c06f85 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -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 }) ) }) diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 2b80ef167fa..f347594e55d 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -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)) { diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 9d4ca356b0d..984394a4b25 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -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 }) ) }) diff --git a/src/main/runtime/rpc/methods/hosted-review.ts b/src/main/runtime/rpc/methods/hosted-review.ts index 49c16a46d09..c2f071d2cbb 100644 --- a/src/main/runtime/rpc/methods/hosted-review.ts +++ b/src/main/runtime/rpc/methods/hosted-review.ts @@ -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, diff --git a/src/main/runtime/runtime-hosted-review-commands.ts b/src/main/runtime/runtime-hosted-review-commands.ts index 00020f9c922..a5fc23a8a44 100644 --- a/src/main/runtime/runtime-hosted-review-commands.ts +++ b/src/main/runtime/runtime-hosted-review-commands.ts @@ -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, diff --git a/src/main/source-control/hosted-review-branch-cache.test.ts b/src/main/source-control/hosted-review-branch-cache.test.ts index ff44ca27d22..b37049c90a8 100644 --- a/src/main/source-control/hosted-review-branch-cache.test.ts +++ b/src/main/source-control/hosted-review-branch-cache.test.ts @@ -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) diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts index 7a9f02e50c8..2438167363c 100644 --- a/src/main/source-control/hosted-review-branch-cache.ts +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -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 + ) } diff --git a/src/main/source-control/hosted-review-scope-generations.ts b/src/main/source-control/hosted-review-scope-generations.ts index f96ed654814..61c4507a2d0 100644 --- a/src/main/source-control/hosted-review-scope-generations.ts +++ b/src/main/source-control/hosted-review-scope-generations.ts @@ -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() */ 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 } diff --git a/src/main/source-control/hosted-review.ts b/src/main/source-control/hosted-review.ts index ebeb518f91e..a340bdfa413 100644 --- a/src/main/source-control/hosted-review.ts +++ b/src/main/source-control/hosted-review.ts @@ -47,6 +47,7 @@ export async function getHostedReviewForBranch( * one branch cheap enough to re-check per minute (#11532). */ active?: boolean + force?: boolean } & HostedReviewExecutionOptions ): Promise { 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, diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 879fcea5984..f36401ff32e 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -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. diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx new file mode 100644 index 00000000000..f1936bfe77a --- /dev/null +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.test.tsx @@ -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() +}) diff --git a/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts new file mode 100644 index 00000000000..033a3657c62 --- /dev/null +++ b/src/renderer/src/app-shell/use-visible-hosted-review-refresh.ts @@ -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]) +} diff --git a/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts new file mode 100644 index 00000000000..07fdaa59d08 --- /dev/null +++ b/src/renderer/src/app-shell/use-visible-review-refresh-reporting.ts @@ -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 + +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(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] + ) +} diff --git a/src/renderer/src/app-shell/visible-review-reporting.test.ts b/src/renderer/src/app-shell/visible-review-reporting.test.ts new file mode 100644 index 00000000000..a7b59a800de --- /dev/null +++ b/src/renderer/src/app-shell/visible-review-reporting.test.ts @@ -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[] = [ + { 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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts deleted file mode 100644 index cc3c9c49b7f..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.test.ts +++ /dev/null @@ -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 }) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts b/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts deleted file mode 100644 index 3558b4bad06..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-pr-refresh-request.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.test.ts b/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.test.ts new file mode 100644 index 00000000000..00a1958f933 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.test.ts @@ -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[])( + '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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.ts b/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.ts new file mode 100644 index 00000000000..ba5b0c2d25e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/checks-detail-polling-policy.ts @@ -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) + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts new file mode 100644 index 00000000000..221e66cacdd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/checks-panel-polling-test-model.ts @@ -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[0] + +export function createModel(overrides: Partial = {}): PollingInput { + const fetchPRChecks = vi.fn<() => Promise>().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 + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts b/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts index 725500fea22..c681e90ab9c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/review-state-dependencies.ts @@ -18,7 +18,6 @@ export type ChecksPanelReviewStateInput = Pick< | 'prRefreshStateNow' | 'prCachedHasPR' | 'prNumber' - | 'refreshContextKey' > & Pick< ChecksPanelControllerState, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-detail-timer.ts b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-detail-timer.ts new file mode 100644 index 00000000000..d82186e10dd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-detail-timer.ts @@ -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 + policyRef: RefObject + 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(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]) +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx index 86d18a5cb2c..dcd7199587a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx @@ -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) { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx index 3ba2dbf7e3e..b8b414b60f2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-controller-state.tsx @@ -161,8 +161,6 @@ export function useChecksPanelControllerState() { // fetched against the MR's own project rather than this repo's default remote. const gitLabProjectRefRef = useRef(null) const conflictSummaryRefreshKeyRef = useRef(null) - const panelVisibleSinceRef = useRef(null) - const foregroundedUnrenderedReviewKeyRef = useRef(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, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.test.tsx new file mode 100644 index 00000000000..280caf996f1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.test.tsx @@ -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() + } + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx index c451dcf4152..0cf561a2482 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-foreground-effects.tsx @@ -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({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling-stability.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling-stability.test.tsx new file mode 100644 index 00000000000..46578bafa78 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling-stability.test.tsx @@ -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 { + 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((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((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 }) + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx index 9d177efa92b..5828cb0473b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx @@ -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) + getDelayMs: null | (() => number | null) + cleanup: Mock<() => void> +}>(() => ({ install: vi.fn(), - run: null as null | (() => Promise | 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; getDelayMs: () => number }) => { + (config: { run: () => Promise | 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[0] - -function createModel(overrides: Partial = {}): PollingInput { - const fetchPRChecks = vi.fn<() => Promise>().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, - prCacheKey: 'cache', - prNumber: 42, - prevChecksRef: { current: '' }, - repo: { id: 'repo-1', path: '/workspace/repo' } as NonNullable, - 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 + 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, + 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) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx index 510a60cc025..0053d7dc17b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx @@ -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 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 } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx index 2a322d2b526..055d6438de8 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx @@ -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, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-polling.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-polling.ts deleted file mode 100644 index 9d543a711d8..00000000000 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-hosted-review-polling.ts +++ /dev/null @@ -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 - ]) -} diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts index 50d7c499066..5629998eb6a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-context.ts @@ -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, diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx b/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx new file mode 100644 index 00000000000..ac5c257e9ea --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.test.tsx @@ -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() +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.ts b/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.ts new file mode 100644 index 00000000000..6b26b20c234 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/review/use-review-push-target.ts @@ -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 + ]) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 75f151163ee..1add6d7a97a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -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() }) - - 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 () => { diff --git a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx index 978ce0e180e..8447ad6fbcf 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx @@ -16,6 +16,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockStore = vi.hoisted(() => ({ state: {} as Record, + 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(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 79f53bf4d8c..32abc34519e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -35,6 +35,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockStore = vi.hoisted(() => ({ state: {} as Record, + 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(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index fde815ab094..bc2eb506866 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -24,6 +24,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockStore = vi.hoisted(() => ({ state: {} as Record, + 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(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 4a8059db50e..0b52fb045b1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -23,6 +23,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true const mockStore = vi.hoisted(() => ({ state: {} as Record, + 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(), diff --git a/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts index 971c7bf0408..0d130ceb815 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-lifecycle-effects.ts @@ -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 || diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts index 727932a0738..17199c4e05f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-visible-review-refresh.ts @@ -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, + 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 @@ -27,108 +74,50 @@ export function useVisiblePrRefreshReporting(args: { virtualItems: readonly VirtualItem[] scrollRef: React.RefObject }): 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]) } diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts index fee590c6cd0..c9d42af4874 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/visible-refresh.test.ts @@ -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() + 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() + }) +}) diff --git a/src/renderer/src/lib/window-visibility-timeout-poller.test.ts b/src/renderer/src/lib/window-visibility-timeout-poller.test.ts index 4cf1ae7d33f..a325bf01b12 100644 --- a/src/renderer/src/lib/window-visibility-timeout-poller.test.ts +++ b/src/renderer/src/lib/window-visibility-timeout-poller.test.ts @@ -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 { + 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) - 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 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) - 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 void>() - let resolveRun!: () => void + it('does not overlap in-flight focus reads', async () => { + let resolveRun: (() => void) | undefined const run = vi.fn( () => new Promise((resolve) => { resolveRun = resolve }) ) - const setTimeoutMock = vi.fn(() => 1 as unknown as ReturnType) - - 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() }) }) diff --git a/src/renderer/src/lib/window-visibility-timeout-poller.ts b/src/renderer/src/lib/window-visibility-timeout-poller.ts index 5f05e948879..2f158ee1bc5 100644 --- a/src/renderer/src/lib/window-visibility-timeout-poller.ts +++ b/src/renderer/src/lib/window-visibility-timeout-poller.ts @@ -4,10 +4,11 @@ export type WindowVisibilityTimeoutPollerTimer = ReturnType export function installWindowVisibilityTimeoutPoller(args: { run: () => Promise | 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) }) } diff --git a/src/renderer/src/store/github/cache-model.ts b/src/renderer/src/store/github/cache-model.ts index d1546ff4768..aece26f50e7 100644 --- a/src/renderer/src/store/github/cache-model.ts +++ b/src/renderer/src/store/github/cache-model.ts @@ -18,6 +18,7 @@ export type WorkItemsCacheError = ClassifiedError & { source: GitHubOwnerRepo } export type CacheEntry = { data: T | null fetchedAt: number + fetchedHeadOid?: string | null headSha?: string sources?: WorkItemsCacheSources error?: WorkItemsCacheError diff --git a/src/renderer/src/store/github/check-actions-errors.test.ts b/src/renderer/src/store/github/check-actions-errors.test.ts new file mode 100644 index 00000000000..b995caa16c6 --- /dev/null +++ b/src/renderer/src/store/github/check-actions-errors.test.ts @@ -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() + const error = new Error('offline') + mockApi.gh.prChecks.mockReturnValueOnce(upstream.promise) + const fetch = (throwOnError: boolean): Promise => + 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([]) + }) +}) diff --git a/src/renderer/src/store/github/check-actions.ts b/src/renderer/src/store/github/check-actions.ts index 51b9a891f1c..a364b061028 100644 --- a/src/renderer/src/store/github/check-actions.ts +++ b/src/renderer/src/store/github/check-actions.ts @@ -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( - { 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( + { 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 = { - 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 = { + 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 => { diff --git a/src/renderer/src/store/github/pr-result-cache.ts b/src/renderer/src/store/github/pr-result-cache.ts index 7603d32332c..d041753fe32 100644 --- a/src/renderer/src/store/github/pr-result-cache.ts +++ b/src/renderer/src/store/github/pr-result-cache.ts @@ -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 { @@ -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 } diff --git a/src/renderer/src/store/github/pull-request-execution.ts b/src/renderer/src/store/github/pull-request-execution.ts index 553ef5c1e13..a1e954081a4 100644 --- a/src/renderer/src/store/github/pull-request-execution.ts +++ b/src/renderer/src/store/github/pull-request-execution.ts @@ -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, diff --git a/src/renderer/src/store/github/refresh-event-actions.ts b/src/renderer/src/store/github/refresh-event-actions.ts index 803d0168c21..8396ff3feca 100644 --- a/src/renderer/src/store/github/refresh-event-actions.ts +++ b/src/renderer/src/store/github/refresh-event-actions.ts @@ -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, diff --git a/src/renderer/src/store/github/refresh-routing-actions.ts b/src/renderer/src/store/github/refresh-routing-actions.ts index a6854f0ce3c..e41b15a0d6f 100644 --- a/src/renderer/src/store/github/refresh-routing-actions.ts +++ b/src/renderer/src/store/github/refresh-routing-actions.ts @@ -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>[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) }) } diff --git a/src/renderer/src/store/github/refresh-sweep-actions.ts b/src/renderer/src/store/github/refresh-sweep-actions.ts index c2c973c0e92..3043f755300 100644 --- a/src/renderer/src/store/github/refresh-sweep-actions.ts +++ b/src/renderer/src/store/github/refresh-sweep-actions.ts @@ -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>[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 - ) - 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 }) diff --git a/src/renderer/src/store/github/slice-types.ts b/src/renderer/src/store/github/slice-types.ts index e1dbd322e44..6a5116804d3 100644 --- a/src/renderer/src/store/github/slice-types.ts +++ b/src/renderer/src/store/github/slice-types.ts @@ -48,6 +48,9 @@ export type GitHubSlice = { prRefreshSequences: Record prRefreshStates: Record 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> fetchPRForBranch: ( @@ -72,7 +75,7 @@ export type GitHubSlice = { branch?: string, headSha?: string, prRepo?: GitHubOwnerRepo | null, - options?: RepoScopedFetchOptions + options?: RepoScopedFetchOptions & { throwOnError?: boolean } ) => Promise 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 bumpGitHubPRVisibleRefreshGeneration: () => void applyGitHubPRRefreshEvent: (event: GitHubPRRefreshEvent) => void getEffectiveGitHubPRRefreshState: (cacheKey: string, now?: number) => PRRefreshState | undefined diff --git a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts index 61361907f6c..12ffa3501ab 100644 --- a/src/renderer/src/store/github/stale-worktree-refresh-actions.ts +++ b/src/renderer/src/store/github/stale-worktree-refresh-actions.ts @@ -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>[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) { diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts new file mode 100644 index 00000000000..8ff82d55da9 --- /dev/null +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-ownership.ts @@ -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' +} diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.test.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.test.ts new file mode 100644 index 00000000000..a020347895d --- /dev/null +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.test.ts @@ -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[] = [] + +function setup(targets: VisibleHostedReviewRefreshTarget[]) { + const scheduler = createVisibleHostedReviewRefreshScheduler() + schedulers.push(scheduler) + scheduler.update(targets) + scheduler.setVisible(true) + return scheduler +} + +function target( + overrides: Partial = {} +): 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((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((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>() + .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>() + .mockImplementationOnce( + () => + new Promise((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) + }) +}) diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.ts new file mode 100644 index 00000000000..c0b8d6c2c76 --- /dev/null +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-scheduler.ts @@ -0,0 +1,228 @@ +import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../shared/review-refresh-policy' + +export type VisibleHostedReviewRefreshTarget = { + key: string + revision: string + aliasRevisions?: ReadonlyMap + fetchedAt: number | null + intervalMs: number | null + selected: boolean + refresh: (force?: boolean) => Promise +} + +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() + const recent = new Map() + const inFlight = new Map() + let timer: ReturnType | 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() + } + } +} diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts new file mode 100644 index 00000000000..fdde17237c6 --- /dev/null +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.test.ts @@ -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) { + 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 }) + ) + }) +}) diff --git a/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts new file mode 100644 index 00000000000..240d3e684c1 --- /dev/null +++ b/src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts @@ -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() + const revisions = new Map>() + 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() + 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 + ) +} diff --git a/src/renderer/src/store/github/worktree-refresh.ts b/src/renderer/src/store/github/worktree-refresh.ts index 3865c4834b1..4168bd50c4a 100644 --- a/src/renderer/src/store/github/worktree-refresh.ts +++ b/src/renderer/src/store/github/worktree-refresh.ts @@ -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, diff --git a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts index d0c97a87222..a4eff8cf5a6 100644 --- a/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts +++ b/src/renderer/src/store/slices/github-pr-branch-fallback-results.test.ts @@ -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 } : {}) }) }) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts index 33b3c87437c..addd8682bb9 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -75,6 +75,11 @@ function makeRepo(overrides: Partial & Pick): 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((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() +}) diff --git a/src/renderer/src/store/slices/github-refresh-sweep.test.ts b/src/renderer/src/store/slices/github-refresh-sweep.test.ts index 35c3e37f3cf..e6ad5ca346d 100644 --- a/src/renderer/src/store/slices/github-refresh-sweep.test.ts +++ b/src/renderer/src/store/slices/github-refresh-sweep.test.ts @@ -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) + }) 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) + }) 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) - 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) - 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) - 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) - 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) - - 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) - - 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) - - 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) - - 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' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 126c75710af..c8efa24ff46 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -33,6 +33,8 @@ export const createGitHubSlice: StateCreator = (s prRefreshSequences: {}, prRefreshStates: {}, prVisibleRefreshGeneration: 0, + visibleReviewWorktreeIds: [], + visibleReviewCardWorktreeIds: [], workItemsCache: {}, workItemsInvalidationNonce: 0, projectViewCache: {}, diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts index 70b2b4d866b..f7a52802a20 100644 --- a/src/renderer/src/store/slices/hosted-review-cache-state.ts +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -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 + } +} diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index af4dff3e862..d5f142736ea 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -19,6 +19,7 @@ import { findHostedReviewRepoForFetch, hasNewerHostedReviewCacheEntry, hostedReviewOwnerIpcArgs, + hostedReviewBranchLookupArgs, isFreshHostedReview, isStaleMergedGitHubReviewForHead, settingsForHostedReviewActionOwner, @@ -193,21 +194,7 @@ export const createHostedReviewSlice: StateCreator { 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( diff --git a/src/shared/github/pull-request-refresh-types.ts b/src/shared/github/pull-request-refresh-types.ts index 2456b59be60..f735a8e0fe2 100644 --- a/src/shared/github/pull-request-refresh-types.ts +++ b/src/shared/github/pull-request-refresh-types.ts @@ -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 diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 719c6d27fa5..72d63bd5830 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -53,6 +53,7 @@ export type HostedReviewInfo = { } export type HostedReviewForBranchArgs = { + force?: boolean repoPath: string repoId?: string admissionTier?: 'interactive' | 'status' | 'background' diff --git a/src/shared/review-refresh-policy.test.ts b/src/shared/review-refresh-policy.test.ts new file mode 100644 index 00000000000..0f5981db25e --- /dev/null +++ b/src/shared/review-refresh-policy.test.ts @@ -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) + }) +}) diff --git a/src/shared/review-refresh-policy.ts b/src/shared/review-refresh-policy.ts new file mode 100644 index 00000000000..db9ed511140 --- /dev/null +++ b/src/shared/review-refresh-policy.ts @@ -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 +} diff --git a/src/shared/rpc-contract/hosted-review-params.ts b/src/shared/rpc-contract/hosted-review-params.ts index 2d5212e65f7..383ec5dff7b 100644 --- a/src/shared/rpc-contract/hosted-review-params.ts +++ b/src/shared/rpc-contract/hosted-review-params.ts @@ -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.