refactor(github): organize concrete feature owners (#41601)

This commit is contained in:
Neil
2026-11-12 09:17:26 -08:00
committed by GitHub
parent ff9fd6634b
commit fb8d6f950b
1003 changed files with 4460 additions and 8422 deletions
@@ -0,0 +1,84 @@
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { codeHostReadExecutionScope as githubReadExecutionScope } from '../../../source-control/code-host-read-execution-scope'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import { githubRepoIdentityKey } from '../../../../shared/github/repositories/repository-identity-key'
import type { GitHubRepoExecOptions } from '../../repositories/github-api-repository'
import {
type MergedReviewCommitMembership as MergedPRCommitMembership,
MergedReviewMembershipMemo
} from '../../../source-control/merged-review-membership-memo'
import type { GitHubRepositoryIdentity as OwnerRepo } from '../../../../shared/github/repositories/repository-identity'
const COMMIT_PULLS_PAGE_SIZE = 100
const COMMIT_PULLS_MAX_PAGES = 5
const membershipMemo = new MergedReviewMembershipMemo()
export function resetMergedPRCommitMembershipCacheForTest(): void {
membershipMemo.clear()
}
/** Check PR-history membership rather than branch-name reuse; failures return unknown. */
export async function isCommitPartOfMergedPR(args: {
ownerRepo: OwnerRepo
prNumber: number
commitOid: string
ghOptions: GitHubRepoExecOptions
}): Promise<MergedPRCommitMembership> {
const oid = args.commitOid.trim().toLowerCase()
if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) {
return 'unknown'
}
const owner = args.ownerRepo.owner
const repo = args.ownerRepo.repo
const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) }
const cacheKey = `${githubReadExecutionScope(ghOptions)}\0${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}`
const cached = membershipMemo.peek(cacheKey)
if (cached !== undefined) {
return cached
}
// An unrun probe cannot justify clearing a durable linked PR.
if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return 'unknown'
}
return membershipMemo.read(cacheKey, async () => {
try {
// Paginate until found or a short page proves absence; a full final page leaves membership unknown.
for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) {
if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return 'unknown'
}
noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
`repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}`
],
ghOptions
)
const parsed = JSON.parse(stdout) as unknown
// Non-array success is unverified, not evidence that a durable link should be cleared.
if (!Array.isArray(parsed)) {
return 'unknown'
}
const entries = parsed
const contained = entries.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as { number?: unknown }).number === args.prNumber
)
if (contained) {
return 'contained'
}
if (entries.length < COMMIT_PULLS_PAGE_SIZE) {
return 'not-contained'
}
}
return 'unknown'
} catch {
// A local commit can become queryable after push; preserve links until verification succeeds.
return 'unknown'
}
})
}
@@ -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 }))
}
@@ -0,0 +1,219 @@
import { shouldAcceptMergedFallbackPR } from '../../../../shared/github/pull-requests/refresh/pr-branch-fallback-rule'
import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
GitHubPRRefreshSkippedReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { GitHubPRBranchLookupOptions } from '../../client/lookup/pull-request-lookup-data'
import { PR_CHECK_PAGE_LIMIT } from '../../client/check/pr-check-rollup'
import { parsePRKey } from '../../pr-watch/pr-watch-types'
export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500
export const POST_PUSH_DELAY_MS = 2_500
type PRBranchLookupCandidate = Pick<
GitHubPRRefreshCandidate,
'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid'
>
export function hostedReviewOptionArgs(
candidate: PRBranchLookupCandidate,
reason: GitHubPRRefreshReason = 'visible',
requestOptions: { userRefresh?: boolean; watchPRKey?: string } = {}
): [] | [GitHubPRBranchLookupOptions] {
const options: GitHubPRBranchLookupOptions =
requestOptions.userRefresh === true ? { userRefresh: true } : {}
options.localGitExecOptions = {
...(candidate.localGitOptions?.wslDistro
? { wslDistro: candidate.localGitOptions.wslDistro }
: {}),
admissionTier: admissionTierForRefreshReason(reason)
}
if (shouldAcceptMergedFallbackPR(candidate)) {
options.acceptMergedFallbackPR = true
}
if (reason === 'watch') {
options.includeDerivedData = false
options.checkPageLimit = PR_CHECK_PAGE_LIMIT
// A watch knows its PR's repository; probing another remote by number could read another PR.
const pr = requestOptions.watchPRKey ? parsePRKey(requestOptions.watchPRKey) : null
if (pr) {
options.exactRepository = {
owner: pr.owner,
repo: pr.repo,
...(pr.host === 'github.com' ? {} : { host: pr.host })
}
}
}
if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) {
options.currentHeadOid = candidate.currentHeadOid.trim()
}
return Object.keys(options).length > 0 ? [options] : []
}
export function admissionTierForRefreshReason(
reason: GitHubPRRefreshReason
): 'interactive' | 'background' {
return reason === 'manual' ? 'interactive' : 'background'
}
export function refreshKey(candidate: GitHubPRRefreshCandidate): string {
const connectionScope = candidate.connectionId ?? 'local'
const runtimeScope = candidate.connectionId
? 'remote'
: `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}`
if (typeof candidate.linkedPRNumber === 'number') {
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}`
}
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}`
}
export function validateCandidate(
candidate: GitHubPRRefreshCandidate
): GitHubPRRefreshSkippedReason | null {
if (candidate.repoKind !== 'git') {
return 'not-git'
}
if (candidate.isBare) {
return 'bare'
}
if (candidate.isArchived) {
return 'archived'
}
if (candidate.connectionId && candidate.connectionState === 'disconnected') {
return 'disconnected'
}
if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') {
return 'fresh'
}
return null
}
export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean {
return reason === 'manual' || reason === 'active' || reason === 'post-push'
}
export function isBackground(reason: GitHubPRRefreshReason): boolean {
return reason !== 'manual'
}
export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean {
return reason === 'visible' || reason === 'swr' || reason === 'watch'
}
export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean {
if (isBudgetedBackground(reason)) {
return false
}
const delay = dueAt - Date.now()
return delay > 0 && delay <= 5_000
}
export function shouldSkipFresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason
): boolean {
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 || hasStaleHead(candidate)
? null
: candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate)
}
export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias {
return {
cacheKey: candidate.cacheKey,
repoId: candidate.repoId,
repoPath: candidate.repoPath,
branch: candidate.branch,
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId ?? null,
currentHeadOid: candidate.currentHeadOid ?? null,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber:
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null
}
}
export function visibleCandidateAfterOutcome(
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome
): GitHubPRRefreshCandidate {
if (outcome.kind === 'upstream-error') {
return candidate
}
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,
cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null,
cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null
}
}
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 {
return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED'
}
export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean {
return (
outcome.kind === 'found' &&
outcome.pr.state === 'open' &&
outcome.pr.mergeable === 'UNKNOWN' &&
!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
)
}
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('pr-refresh-coordinator', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
})
afterEach(() => {
vi.useRealTimers()
})
it('preserves an active refresh queued while a visible refresh is in flight', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
const visibleOutcome = deferred<{
kind: 'found'
pr: PRInfo
fetchedAt: number
}>()
getPRForBranchOutcomeMock.mockReturnValueOnce(visibleOutcome.promise).mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success', state: 'merged' }),
fetchedAt: Date.now()
})
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh({ ...candidate, cachedFetchedAt: Date.now() }, 'active', 80, 1)
visibleOutcome.resolve({
kind: 'found',
pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }),
fetchedAt: Date.now()
})
await vi.advanceTimersByTimeAsync(0)
const inFlightEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'in-flight')
expect(inFlightEvents.map((event) => event.reason)).toEqual(['visible', 'active'])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,153 @@
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('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks
describe('pr-refresh-coordinator', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
})
afterEach(() => {
vi.useRealTimers()
})
it('cancels queued work when a later enqueue marks the candidate invalid', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh(
{ ...candidate, isArchived: true, cachedFetchedAt: Date.now() },
'active',
80,
1
)
await vi.advanceTimersByTimeAsync(10 * 60_000)
const skippedEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'skipped')
expect(skippedEvents.at(-1)?.skippedReason).toBe('archived')
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
it('does not cancel other aliases when one coalesced PR alias becomes invalid', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success', state: 'merged' }),
fetchedAt: Date.now()
})
const first = makeCandidate({
cacheKey: '/repo::feature/a',
branch: 'feature/a',
linkedPRNumber: 12,
worktreeId: 'wt-a'
})
const second = makeCandidate({
cacheKey: '/repo::feature/b',
branch: 'feature/b',
linkedPRNumber: 12,
worktreeId: 'wt-b'
})
reportVisiblePRRefreshCandidates([first, second], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh({ ...first, isArchived: true, cachedFetchedAt: Date.now() }, 'active', 80, 1)
enqueuePRRefresh({ ...second, cachedFetchedAt: Date.now() }, 'active', 80, 1)
await vi.advanceTimersByTimeAsync(0)
const outcomeEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.outcome)
expect(outcomeEvents.at(-1)?.aliases.map((alias) => alias.cacheKey)).toEqual([
'/repo::feature/b'
])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('preserves coalesced aliases across visible follow-up refreshes', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }),
fetchedAt: Date.now()
})
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: '/repo::feature/a',
branch: 'feature/a',
linkedPRNumber: 12,
worktreeId: 'wt-a'
}),
makeCandidate({
cacheKey: '/repo::feature/b',
branch: 'feature/b',
linkedPRNumber: 12,
worktreeId: 'wt-b'
})
],
1,
1
)
await vi.runOnlyPendingTimersAsync()
await vi.advanceTimersByTimeAsync(120_000)
const outcomeEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.outcome)
expect(outcomeEvents).toHaveLength(2)
expect(outcomeEvents[1].aliases.map((alias) => alias.cacheKey).sort()).toEqual([
'/repo::feature/a',
'/repo::feature/b'
])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
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('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
const { getPRForBranchOutcomeMock } = 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('preserves a newer failure gate when an older successful lookup completes', async () => {
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'wait',
fetchedAt: Date.now(),
retryDisabledUntil: Date.now() + 300_000
}))
const { reportVisiblePRRefreshCandidates, refreshPRNow } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishOlder(found('open'))
await vi.advanceTimersByTimeAsync(60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await expect(refreshPRNow(candidate)).resolves.toMatchObject({ errorType: 'rate_limited' })
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,31 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
export function makeCandidate(
overrides: Partial<GitHubPRRefreshCandidate> = {}
): GitHubPRRefreshCandidate {
return {
cacheKey: '/repo::feature/test',
repoPath: '/repo',
branch: 'feature/test',
repoKind: 'git',
repoId: 'repo-1',
worktreeId: 'wt-1',
cachedFetchedAt: null,
...overrides
}
}
export function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 12,
title: 'Test PR',
state: 'open',
url: 'https://github.com/acme/repo/pull/12',
checksStatus: 'pending',
updatedAt: '2026-05-12T00:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'head-sha',
...overrides
}
}
@@ -0,0 +1,141 @@
import type { PRRefreshRendererPort } from './pr-refresh-renderer-port'
import { vi, type Mock } from 'vitest'
import { isWslUncPath } from '../../../../shared/wsl-paths'
type PRRefreshWebContentsMock = {
id: number
isDestroyed: () => boolean
send?: Mock
}
export type PRRefreshCoordinatorMocks = {
sendMock: Mock
sendToTrustedUIRendererMock: Mock
getAllWebContentsMock: Mock<() => PRRefreshWebContentsMock[]>
webContentsFromIdMock: Mock<(id: number) => PRRefreshWebContentsMock | undefined>
getPRForBranchOutcomeMock: Mock
getOriginGitHubApiRepositoryMock: Mock
getRateLimitMock: Mock
noteRepositoryRateLimitSpendMock: Mock
repositoryRateLimitGuardMock: Mock
spendsSharedGitHubComQuotaMock: Mock
}
// Vitest hoists per file; suites register these shared factories locally.
export function createPRRefreshCoordinatorMocks(): PRRefreshCoordinatorMocks {
return {
sendMock: vi.fn(),
sendToTrustedUIRendererMock: vi.fn(),
getAllWebContentsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
getPRForBranchOutcomeMock: vi.fn(),
getOriginGitHubApiRepositoryMock: vi.fn(),
getRateLimitMock: vi.fn(),
noteRepositoryRateLimitSpendMock: vi.fn(),
repositoryRateLimitGuardMock: vi.fn(),
spendsSharedGitHubComQuotaMock: vi.fn()
}
}
export type RendererPortModuleMock = { prRefreshRenderers: () => PRRefreshRendererPort }
/** Renderers are the mocked web contents; refresh events reach `sendToTrustedUIRendererMock`. */
export function rendererPortModuleMock(mocks: PRRefreshCoordinatorMocks): RendererPortModuleMock {
const port: PRRefreshRendererPort = {
isRendererOpen: (id) => {
const contents = mocks.webContentsFromIdMock(id)
return contents !== undefined && !contents.isDestroyed()
},
sendToTrustedUI: (channel, payload) => mocks.sendToTrustedUIRendererMock(channel, payload)
}
return { prRefreshRenderers: () => port }
}
export type ClientModuleMock = { getPRForBranchOutcome: Mock }
export function clientModuleMock(mocks: PRRefreshCoordinatorMocks): ClientModuleMock {
return {
getPRForBranchOutcome: mocks.getPRForBranchOutcomeMock
}
}
export type GithubApiRepositoryModuleMock = { getOriginGitHubApiRepository: Mock }
export function githubApiRepositoryModuleMock(
mocks: PRRefreshCoordinatorMocks
): GithubApiRepositoryModuleMock {
return {
getOriginGitHubApiRepository: mocks.getOriginGitHubApiRepositoryMock
}
}
export type RateLimitModuleMock = {
getRateLimit: Mock
noteRepositoryRateLimitSpend: Mock
repositoryRateLimitGuard: Mock
spendsSharedGitHubComQuota: Mock
}
export function rateLimitModuleMock(mocks: PRRefreshCoordinatorMocks): RateLimitModuleMock {
return {
getRateLimit: mocks.getRateLimitMock,
noteRepositoryRateLimitSpend: mocks.noteRepositoryRateLimitSpendMock,
repositoryRateLimitGuard: mocks.repositoryRateLimitGuardMock,
spendsSharedGitHubComQuota: mocks.spendsSharedGitHubComQuotaMock
}
}
// Module queues and timers must not leak pending work between tests.
export function resetPRRefreshCoordinatorMocks(mocks: PRRefreshCoordinatorMocks): void {
const {
sendMock,
sendToTrustedUIRendererMock,
getAllWebContentsMock,
webContentsFromIdMock,
getPRForBranchOutcomeMock,
getOriginGitHubApiRepositoryMock,
getRateLimitMock,
noteRepositoryRateLimitSpendMock,
repositoryRateLimitGuardMock,
spendsSharedGitHubComQuotaMock
} = mocks
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(1_000)
sendMock.mockReset()
sendToTrustedUIRendererMock.mockReset()
sendToTrustedUIRendererMock.mockImplementation((channel, payload) => {
sendMock(channel, payload)
})
getAllWebContentsMock.mockReset()
webContentsFromIdMock.mockReset()
webContentsFromIdMock.mockImplementation((id) =>
getAllWebContentsMock().find((contents) => contents.id === id)
)
getPRForBranchOutcomeMock.mockReset()
getOriginGitHubApiRepositoryMock.mockReset()
getOriginGitHubApiRepositoryMock.mockResolvedValue({
owner: 'acme',
repo: 'widgets',
host: 'github.com'
})
getRateLimitMock.mockReset()
noteRepositoryRateLimitSpendMock.mockReset()
repositoryRateLimitGuardMock.mockReset()
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
spendsSharedGitHubComQuotaMock.mockReset()
spendsSharedGitHubComQuotaMock.mockImplementation(
(repository: { host?: string } | null, options?: { cwd?: string; wslDistro?: string }) =>
(!repository?.host || repository.host.toLowerCase() === 'github.com') &&
!options?.wslDistro &&
!(options?.cwd && isWslUncPath(options.cwd))
)
getAllWebContentsMock.mockReturnValue([
{
id: 1,
isDestroyed: () => false,
send: sendMock
}
])
getRateLimitMock.mockResolvedValue({ ok: true })
}
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslUncAccessGateModule from '../../../wsl/filesystem/wsl-unc-access-gate'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
const gate = vi.hoisted(() => ({
stopped: true,
blockedWslDistroForPath: vi.fn(),
wslDistroForPath: vi.fn()
}))
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../../../wsl/filesystem/wsl-unc-access-gate', async (importOriginal) => ({
...(await importOriginal<typeof WslUncAccessGateModule>()),
blockedWslDistroForPath: gate.blockedWslDistroForPath,
wslDistroForPath: gate.wslDistroForPath
}))
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
const WSL_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
describe('PR refresh for a stopped WSL distro (E9)', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 })
gate.blockedWslDistroForPath.mockReset()
gate.wslDistroForPath.mockReset()
gate.wslDistroForPath.mockImplementation((path: string) =>
path.startsWith('\\\\wsl.localhost\\') ? 'Ubuntu' : null
)
gate.blockedWslDistroForPath.mockImplementation(async () =>
gate.stopped ? { distro: 'Ubuntu', state: 'stopped' } : null
)
})
afterEach(() => {
gate.stopped = true
vi.useRealTimers()
})
it("drains outside a user act that scheduled it, so a stopped distro's queue is not admitted", async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const { isWslUserActFor, runAsWslUserAct } =
await import('../../../wsl/discovery/wsl-user-act-scope')
const drainedInsideAct: boolean[] = []
gate.blockedWslDistroForPath.mockImplementation(async () => {
drainedInsideAct.push(isWslUserActFor('Ubuntu'))
return { distro: 'Ubuntu', state: 'stopped' }
})
const candidate = makeCandidate({
repoPath: WSL_REPO,
cacheKey: `${WSL_REPO}::feature/act`,
isSelected: true
})
// The drain timer is armed and fires while the act still holds.
await runAsWslUserAct('Ubuntu', async () => {
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
})
expect(drainedInsideAct).toEqual([false])
expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,287 @@
import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome'
import {
aliasFromCandidate,
hostedReviewOptionArgs,
isBackground,
MANUAL_MERGEABILITY_PENDING_REFRESH_MS,
refreshKey,
shouldBroadcastQueued,
validateCandidate
} from './pr-refresh-candidate-policy'
import {
PRRefreshEventPublisher,
type PRRefreshOutcomeObserver
} from './pr-refresh-event-publisher'
import { PRRefreshPacing } from './pr-refresh-pacing'
import { PRRefreshQueue } from './pr-refresh-queue'
import { PRRefreshQueueDrainer, type PRRefreshRegistrationCheck } from './pr-refresh-queue-drainer'
import {
prWatchQueueKey,
schedulePRWatchEntry,
type PRWatchJob,
type PRWatchJobHooks
} from './pr-refresh-watch-jobs'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import { PRRefreshRetryState } from './pr-refresh-retry-state'
import { PRRefreshVisibility } from './pr-refresh-visibility'
import {
blockedWslDistroForPath,
wslDistroForPath
} from '../../../wsl/filesystem/wsl-unc-access-gate'
const retry = new PRRefreshRetryState()
const queue = new PRRefreshQueue((key) => retry.reset(key))
const pacing = new PRRefreshPacing()
const visibility = new PRRefreshVisibility()
const events = new PRRefreshEventPublisher()
const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events)
export function addPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver): () => void {
return events.addOutcomeObserver(observer)
}
export function setPRRefreshRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
drainer.setRegistrationCheck(check)
}
export function setPRWatchJobHooks(hooks: PRWatchJobHooks | null): void {
drainer.watchJobs.setHooks(hooks)
}
/** Queue a watch read; `defer` only moves a queued one later, as another surface just read it. */
export function schedulePRWatchRead(
job: PRWatchJob,
candidate: GitHubPRRefreshCandidate,
dueAt: number,
options: { defer?: boolean } = {}
): void {
schedulePRWatchEntry(queue, job, candidate, dueAt, options.defer === true)
drainer.schedule()
}
export function cancelPRWatchRead(job: PRWatchJob): void {
queue.delete(prWatchQueueKey(job))
}
/** A read a paired server made for a client reaches every observer. It takes no queue ownership,
* so a desktop read in flight for the same checkout keeps its follow-up. */
export async function runHostedPRRead(
candidate: GitHubPRRefreshCandidate,
read: () => Promise<PRRefreshOutcome>
): Promise<PRRefreshOutcome> {
const outcome = await read()
events.observe(candidate, outcome)
return outcome
}
function removeInvisibleVisibleRefreshes(): void {
const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key))
for (const entry of removed) {
events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: 'visible',
status: 'skipped',
skippedReason: 'fresh'
})
}
}
export function clearVisiblePRRefreshWindow(windowId: number): void {
const hadVisibleRefreshes = visibility.clearWindow(windowId)
pacing.clearActiveBurstWindow(windowId)
if (hadVisibleRefreshes) {
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
drainer.schedule()
}
}
export function pruneWorktreePRRefreshAliases(worktreeId: string): void {
queue.pruneWorktreeAliases(worktreeId)
}
export function enqueuePRRefresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason,
priority = 0,
windowId?: number
): void {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
events.record('skipped', reason, skippedReason)
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return
}
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' })
}
drainer.schedule()
}
export function reportVisiblePRRefreshCandidates(
candidates: GitHubPRRefreshCandidate[],
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) {
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 {
return visibility.windowCount
}
export function _getPRRefreshErrorBackoffCountForTests(): number {
return retry.errorBackoffCount
}
export function _getPRRefreshQueueSizeForTests(): number {
return queue.size
}
export function _getPRRefreshAliasCountForTests(key: string): number {
return queue.aliasCount(key)
}
export async function refreshPRNow(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason = 'manual',
options: { userRefresh?: boolean } = {}
): Promise<PRRefreshOutcome> {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = queue.get(key)
const aliasMap = new Map(existing ? existing.aliases : [])
aliasMap.set(alias.cacheKey, alias)
const aliases = Array.from(aliasMap.values())
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
const outcome: PRRefreshOutcome = {
kind: 'upstream-error',
errorType: 'unknown',
message: `Cannot refresh PR for this worktree: ${skippedReason}`,
fetchedAt: Date.now()
}
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return outcome
}
// E9: renderer fallbacks reach here with automatic reasons; only a manual refresh may start a stopped distro.
if (
isBackground(reason) &&
wslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro) &&
(await blockedWslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro))
) {
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason: 'fresh' })
// Local callers keep their cached PR on an upstream error; nothing is recorded or retried here.
return {
kind: 'upstream-error',
errorType: 'unknown',
message: 'WSL distro is not running',
fetchedAt: Date.now()
}
}
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,
aliases: aliasMap,
reason,
priority: 40,
dueAt: gateUntil,
queuedAt: queue.nextOrder()
})
events.broadcast({
aliases,
reason,
status: 'paused',
pausedUntil: gateUntil,
skippedReason: 'rate-limit'
})
drainer.schedule(Math.max(1_000, gateUntil - Date.now()))
return {
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'GitHub is temporarily limiting requests. Try again after the limit resets.',
fetchedAt: Date.now(),
nextAutoRetryAt: gateUntil,
retryDisabledUntil: gateUntil
}
}
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,
candidate.branch,
candidate.linkedPRNumber ?? null,
candidate.connectionId ?? null,
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate, reason, options)
)
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, visibility.candidate(key, candidate))
broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt)
}
events.observe(candidate, outcome, aliases)
retry.noteManualGate(key, broadcastOutcome)
events.broadcast(
{ aliases, reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, {
plannedRetryAt,
...(reason === 'manual'
? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }
: {})
})
return broadcastOutcome
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { classifyPRRefreshError } from './pr-refresh-error-classification'
describe('classifyPRRefreshError', () => {
it('classifies from the real runner error shape where the signal is on .stderr', () => {
// The runner stores diagnostics on stderr even when message is only a generic exit error.
const permission = Object.assign(new Error('gh exited with 1.'), {
stderr: 'HTTP 403: Resource not accessible by integration'
})
expect(classifyPRRefreshError(permission)).toBe('permission')
const repo = Object.assign(new Error('gh exited with 1.'), {
stderr: Buffer.from('HTTP 404: Not Found')
})
expect(classifyPRRefreshError(repo)).toBe('repo_unavailable')
const secondary = Object.assign(new Error('gh exited with 1.'), {
stderr: 'You have exceeded a secondary rate limit'
})
expect(classifyPRRefreshError(secondary)).toBe('rate_limited')
const auth = Object.assign(new Error('gh exited with 1.'), {
stdout: '',
stderr: 'gh auth login required: bad credentials'
})
expect(classifyPRRefreshError(auth)).toBe('auth')
})
})
@@ -0,0 +1,115 @@
import type { PRRefreshErrorType } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { classifyGitHubUnavailable } from '../../../../shared/github/api/api-availability'
import { extractExecError } from '../../../git/command-runner/process-capture/exec-error'
/** Classify runner failures before UI rendering; rate-limit 403s precede permissions, and repo 404s precede network heuristics. */
export function classifyPRRefreshError(err: unknown): PRRefreshErrorType {
// Runner diagnostics may live only on stdout/stderr; retain structured spawn codes too.
const { stderr, stdout } = extractExecError(err)
const message = err instanceof Error ? err.message : String(err)
const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase()
const code =
err && typeof err === 'object' && 'code' in err
? String((err as { code?: unknown }).code ?? '').toLowerCase()
: ''
// Secondary limits may mention abuse or Retry-After without the phrase rate limit.
const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests')
const isHttp403 = lower.includes('http 403')
const hasRetryAfter = lower.includes('retry-after')
if (
isHttp429 ||
lower.includes('secondary rate limit') ||
lower.includes('abuse detection') ||
lower.includes('abuse-rate-limits') ||
lower.includes('you have triggered an abuse') ||
((isHttp403 || isHttp429) && hasRetryAfter) ||
lower.includes('api rate limit exceeded') ||
lower.includes('rate limit')
) {
return 'rate_limited'
}
// Match repository failures before incidental connectivity wording.
if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) {
return 'repo_unavailable'
}
const unavailable = classifyGitHubUnavailable(lower)
if (unavailable) {
return unavailable
}
// Require structured codes or full phrases; a repository named network-* is not a network failure.
const networkCodes = new Set([
'etimedout',
'econnreset',
'econnrefused',
'enotfound',
'eai_again',
'enetunreach',
'enetdown'
])
if (
networkCodes.has(code) ||
lower.includes('etimedout') ||
lower.includes('econnreset') ||
lower.includes('econnrefused') ||
lower.includes('enotfound') ||
lower.includes('eai_again') ||
lower.includes('timed out') ||
lower.includes('timeout') ||
lower.includes('could not resolve host') ||
lower.includes('no such host') ||
lower.includes('network is unreachable') ||
lower.includes('network is down') ||
lower.includes('network error') ||
lower.includes('connection refused') ||
lower.includes('connection reset')
) {
return 'network'
}
if (isHttp403 || lower.includes('resource not accessible')) {
return 'permission'
}
// Prefer structured launch codes so paths containing gh cannot imply a missing executable.
if (
code === 'enoent' ||
lower.includes('spawn gh enoent') ||
lower.includes('gh: command not found') ||
lower.includes("'gh' is not recognized")
) {
return 'gh_unavailable'
}
// Full auth phrases avoid matching author/authored.
if (
lower.includes('http 401') ||
lower.includes('unauthorized') ||
lower.includes('authorization') ||
lower.includes('authentication') ||
lower.includes('bad credentials') ||
lower.includes('gh auth') ||
/\b(login|credentials?)\b/i.test(lower)
) {
return 'auth'
}
return 'unknown'
}
export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string {
switch (errorType) {
case 'rate_limited':
return 'GitHub rate limit is low. Try again after the limit resets.'
case 'auth':
return 'GitHub authentication is unavailable. Check your gh login.'
case 'network':
return 'GitHub is unreachable right now. Check your network and try again.'
case 'server_error':
return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue."
case 'permission':
return 'GitHub did not allow access to this pull request.'
case 'repo_unavailable':
return 'The GitHub repository is unavailable or cannot be resolved.'
case 'gh_unavailable':
return 'GitHub CLI is unavailable.'
case 'unknown':
return 'GitHub pull request refresh failed.'
}
}
@@ -0,0 +1,86 @@
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
GitHubPRRefreshReason,
GitHubPRRefreshSkippedReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { recordCoalescedCrashBreadcrumb } from '../../../crash-reporting/breadcrumbs/crash-breadcrumb-store'
import { prRefreshRenderers } from './pr-refresh-renderer-port'
// `aliases`: every checkout the read stood for (one read can serve several linked to the same PR).
export type PRRefreshOutcomeObserver = (
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
aliases: readonly GitHubPRRefreshAlias[]
) => void
const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000
export class PRRefreshEventPublisher {
private sequence = 0
private readonly observers = new Set<PRRefreshOutcomeObserver>()
private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 }
addOutcomeObserver(observer: PRRefreshOutcomeObserver): () => void {
const registered: PRRefreshOutcomeObserver = (...args) => observer(...args)
this.observers.add(registered)
return () => this.observers.delete(registered)
}
/** Every read of a PR reaches every observer once, whatever surface caused it. */
observe(
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
aliases: readonly GitHubPRRefreshAlias[] = []
): void {
for (const observer of this.observers) {
try {
observer(candidate, outcome, aliases)
} catch (error) {
// One observer's failure must not hide the read from the others or fail the refresh.
console.warn('[pr-refresh] outcome observer failed', error)
}
}
}
nextSequence(): number {
this.sequence += 1
return this.sequence
}
broadcast(event: Omit<GitHubPRRefreshEvent, 'sequence'>, sequenceOverride?: number): void {
const payload = {
...event,
sequence: sequenceOverride ?? this.nextSequence()
} as GitHubPRRefreshEvent
prRefreshRenderers().sendToTrustedUI('gh:prRefreshEvent', payload)
}
record(
event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause',
reason: GitHubPRRefreshReason,
skippedReason?: GitHubPRRefreshSkippedReason
): void {
if (event === 'background-pause') {
this.counters.backgroundPauses += 1
} else {
this.counters[event] += 1
}
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_queue',
coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`,
minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS,
data: {
event,
reason,
...(skippedReason ? { skippedReason } : {}),
enqueued: this.counters.enqueued,
coalesced: this.counters.coalesced,
skipped: this.counters.skipped,
backgroundPauses: this.counters.backgroundPauses
}
})
}
}
@@ -0,0 +1,122 @@
import { isBudgetedBackground } from './pr-refresh-candidate-policy'
import type { PRRefreshQueueEntry } from './pr-refresh-queue'
const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000
const MIN_BACKGROUND_SPACING_MS = 10_000
const BACKGROUND_BUDGET_MAX = 20
const ACTIVE_BURST_WINDOW_MS = 30_000
const ACTIVE_BURST_MAX = 3
export function usesActiveRefreshPacing(entry: PRRefreshQueueEntry): boolean {
// Selected admission survives deselection while queued; periodic follow-ups clear the flag.
return (
entry.reason === 'active' ||
(entry.reason === 'visible' && entry.priority >= 80 && entry.bypassBackgroundBudget === true)
)
}
export class PRRefreshPacing {
private readonly backgroundStarts: number[] = []
private readonly activeStartsByScope = new Map<string, number[]>()
private lastBackgroundStartAt = 0
clearActiveBurstWindow(windowId: number): void {
const windowPrefix = `${windowId}::`
for (const scope of Array.from(this.activeStartsByScope.keys())) {
if (scope.startsWith(windowPrefix)) {
this.activeStartsByScope.delete(scope)
}
}
}
noteBackgroundStart(): void {
const now = Date.now()
this.lastBackgroundStartAt = now
this.backgroundStarts.push(now)
this.pruneBackgroundStarts(now)
}
nextBudgetDelay(): number {
const now = Date.now()
this.pruneBackgroundStarts(now)
const spacingDelay =
this.lastBackgroundStartAt > 0
? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt))
: 0
const windowDelay =
this.backgroundStarts.length < BACKGROUND_BUDGET_MAX
? 0
: Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0]))
return Math.max(spacingDelay, windowDelay)
}
activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number {
if (!usesActiveRefreshPacing(a) || !usesActiveRefreshPacing(b)) {
return 0
}
if (this.activeBurstScope(a) !== this.activeBurstScope(b)) {
return 0
}
return b.queuedAt - a.queuedAt
}
entryDelay(entry: PRRefreshQueueEntry): number {
const activeDelay = usesActiveRefreshPacing(entry) ? this.nextActiveBurstDelay(entry) : 0
if (activeDelay > 0) {
return activeDelay
}
return entry.bypassBackgroundBudget !== true && isBudgetedBackground(entry.reason)
? this.nextBudgetDelay()
: 0
}
isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean {
return usesActiveRefreshPacing(entry) && this.nextActiveBurstDelay(entry) > 0
}
noteActiveStart(entry: PRRefreshQueueEntry): void {
const now = Date.now()
const scope = this.activeBurstScope(entry)
const activeStarts = this.pruneActiveStarts(scope, now)
activeStarts.push(now)
this.activeStartsByScope.set(scope, activeStarts)
}
private pruneBackgroundStarts(now: number): void {
while (
this.backgroundStarts.length > 0 &&
now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS
) {
this.backgroundStarts.shift()
}
}
private activeBurstScope(entry: PRRefreshQueueEntry): string {
const runtimeScope = entry.candidate.connectionId
? `ssh:${entry.candidate.connectionId}`
: `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}`
return `${entry.windowId ?? 'global'}::${runtimeScope}`
}
private pruneActiveStarts(scope: string, now: number): number[] {
const activeStarts = this.activeStartsByScope.get(scope) ?? []
while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) {
activeStarts.shift()
}
if (activeStarts.length === 0) {
this.activeStartsByScope.delete(scope)
} else {
this.activeStartsByScope.set(scope, activeStarts)
}
return activeStarts
}
private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number {
const now = Date.now()
const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now)
if (activeStarts.length < ACTIVE_BURST_MAX) {
return 0
}
return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0]))
}
}
@@ -0,0 +1,317 @@
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome'
import {
aliasFromCandidate,
freshRetryAt,
hostedReviewOptionArgs,
isBackground,
isBudgetedBackground,
isMergeabilityPendingOutcome,
sameAliasRequestIdentity,
validateCandidate,
visibleCandidateAfterOutcome
} from './pr-refresh-candidate-policy'
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import { PRWatchJobRunner } from './pr-refresh-watch-jobs'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import {
blockedWslDistroForPath,
wslDistroForPath
} from '../../../wsl/filesystem/wsl-unc-access-gate'
import { outsideWslUserAct } from '../../../wsl/discovery/wsl-user-act-scope'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
import type { PRRefreshVisibility } from './pr-refresh-visibility'
export type PRRefreshRegistrationCheck = (candidate: GitHubPRRefreshCandidate) => boolean
export class PRRefreshQueueDrainer {
private draining = false
private timer: ReturnType<typeof setTimeout> | null = null
// Why: queued work can outlive its project; re-check on every removal path before Git runs.
private registrationCheck: PRRefreshRegistrationCheck | null = null
readonly watchJobs: PRWatchJobRunner
constructor(
private readonly queue: PRRefreshQueue,
private readonly pacing: PRRefreshPacing,
private readonly visibility: PRRefreshVisibility,
private readonly retry: PRRefreshRetryState,
private readonly events: PRRefreshEventPublisher
) {
this.watchJobs = new PRWatchJobRunner(queue, retry, this)
}
setRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
this.registrationCheck = check
}
schedule(delay = 0): void {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
this.timer = null
// The drain serves every queued repo; a scheduling call inside a WSL user act must not lend it that act.
void outsideWslUserAct(() => this.drain())
}, delay)
}
scheduleVisibleFollowUp(
key: string,
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
priority: number,
aliases: GitHubPRRefreshAlias[],
windowId?: number,
options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number }
): void {
if (outcome.kind === 'upstream-error') {
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,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt: retryAt,
queuedAt: this.queue.nextOrder(),
windowId
})
this.schedule(retryAt - Date.now())
return
}
this.retry.reset(key)
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,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt,
queuedAt: this.queue.nextOrder(),
bypassBackgroundBudget: pendingDueAt !== null,
followUp: true,
windowId
})
this.schedule(Math.max(0, dueAt - Date.now()))
}
private ordered(): PRRefreshQueueEntry[] {
this.watchJobs.age(this.queue.values(), Date.now())
return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b))
}
private nextQueuedWakeDelay(excludedKey: string): number | null {
const now = Date.now()
let nextDelay = Number.POSITIVE_INFINITY
for (const entry of this.queue.values()) {
if (entry.key === excludedKey) {
continue
}
const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry)
nextDelay = Math.min(nextDelay, delay)
}
return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null
}
private async drain(): Promise<void> {
if (this.draining) {
return
}
this.draining = true
try {
while (this.queue.size > 0) {
let next = this.ordered()[0]
const waitMs = next.dueAt - Date.now()
if (waitMs > 0) {
this.schedule(waitMs)
return
}
let delay = this.pacing.entryDelay(next)
if (delay > 0) {
const runnable = this.ordered().find(
(entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0
)
if (runnable && runnable.key !== next.key) {
next = runnable
delay = 0
} else {
this.notePacingDelay(next)
this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay))
return
}
}
this.queue.delete(next.key)
const aliases = Array.from(next.aliases.values())
const skippedReason = validateCandidate(next.candidate)
if (skippedReason) {
this.events.record('skipped', next.reason, skippedReason)
this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason })
continue
}
if (
!(this.registrationCheck?.(next.candidate) ?? true) ||
(next.reason === 'visible' && !this.visibility.has(next.key)) ||
// E9: gh/Git in a stopped distro's repo boots it; a queued refresh keeps the current status.
(isBackground(next.reason) &&
wslDistroForPath(next.candidate.repoPath, next.candidate.localGitOptions?.wslDistro) &&
(await blockedWslDistroForPath(
next.candidate.repoPath,
next.candidate.localGitOptions?.wslDistro
)))
) {
this.events.broadcast({
aliases,
reason: next.reason,
status: 'skipped',
skippedReason: 'fresh'
})
this.watchJobs.skipped(next)
continue
}
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
)
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,
reason: next.reason,
status: 'paused',
pausedUntil,
skippedReason: 'rate-limit'
})
this.schedule(Math.max(1_000, pausedUntil - Date.now()))
continue
}
if (next.bypassBackgroundBudget !== true && isBudgetedBackground(next.reason)) {
this.pacing.noteBackgroundStart()
this.watchJobs.noteBudgetedStart(next)
}
if (usesActiveRefreshPacing(next)) {
this.pacing.noteActiveStart(next)
}
}
if (await this.watchJobs.readHead(next)) {
continue
}
const outcome = await getPRForBranchOutcome(
next.candidate.repoPath,
next.candidate.branch,
next.candidate.linkedPRNumber ?? null,
next.candidate.connectionId ?? null,
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(next.candidate, next.reason, { watchPRKey: next.watch?.prKey })
)
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,
this.visibility.candidate(next.key, next.candidate)
)
broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt)
}
this.events.observe(next.candidate, outcome, aliases)
this.retry.noteManualGate(next.key, broadcastOutcome)
this.events.broadcast(
{ aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
if (next.watch) {
this.watchJobs.afterOutcome(next, outcome)
continue
}
this.scheduleVisibleFollowUp(
next.key,
next.candidate,
outcome,
next.priority,
aliases,
next.windowId,
{ plannedRetryAt }
)
}
} finally {
this.draining = false
}
}
private notePacingDelay(entry: PRRefreshQueueEntry): void {
if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) {
entry.activeDelayNotified = true
this.events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: entry.reason,
status: 'queued'
})
}
if (
entry.bypassBackgroundBudget !== true &&
isBudgetedBackground(entry.reason) &&
this.pacing.nextBudgetDelay() > 0
) {
this.events.record('background-pause', entry.reason)
}
}
}
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/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('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42'
describe('pr-refresh queue growth bounds', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}))
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps the newer alias when a branch switch lands mid-request', async () => {
const { reportVisiblePRRefreshCandidates, _getPRRefreshAliasCountForTests } =
await import('./pr-refresh-coordinator')
const churnCandidate = (index: number): GitHubPRRefreshCandidate =>
makeCandidate({
cacheKey: `/repo::churn/${index}`,
branch: `churn/${index}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
let switched = false
getPRForBranchOutcomeMock.mockImplementation(async () => {
if (!switched) {
switched = true
// Re-enqueue after dequeue so the older in-flight snapshot merges into a newer queued entry.
reportVisiblePRRefreshCandidates([churnCandidate(2)], 2, 1)
}
return {
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}
})
reportVisiblePRRefreshCandidates([churnCandidate(1)], 1, 1)
await vi.advanceTimersByTimeAsync(120_000)
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
const broadcasts = coordinatorMocks.sendMock.mock.calls.filter(
(call) => call[0] === 'gh:prRefreshEvent'
) as [string, { aliases: { cacheKey: string }[] }][]
const lastBroadcast = broadcasts.at(-1)
expect(lastBroadcast?.[1].aliases.map((alias) => alias.cacheKey)).toEqual(['/repo::churn/2'])
})
})
@@ -0,0 +1,331 @@
import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshReason
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import {
aliasFromCandidate,
bypassesFreshnessDelay,
freshRetryAt,
POST_PUSH_DELAY_MS,
refreshKey,
sameAliasRequestIdentity,
refreshIntervalForCandidate,
shouldSkipFresh
} from './pr-refresh-candidate-policy'
export type PRRefreshQueueEntry = {
key: string
candidate: GitHubPRRefreshCandidate
aliases: Map<string, GitHubPRRefreshAlias>
reason: GitHubPRRefreshReason
priority: number
dueAt: number
queuedAt: number
bypassBackgroundBudget?: boolean
activeDelayNotified?: boolean
followUp?: boolean
windowId?: number
/** A watch job: the PR's current head, or with `headSha` one earlier head's checks. */
watch?: { prKey: string; headSha?: string }
}
export type PRRefreshEnqueue = {
alias: GitHubPRRefreshAlias
key: string
dueAt: number
coalesced: boolean
}
type PRRefreshRequestState = { until: number; requestSequence?: number }
/** Retire old-branch aliases when a linked-PR key survives worktree branch changes. */
function setLiveAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): void {
if (alias.worktreeId) {
for (const [cacheKey, existing] of aliases) {
if (cacheKey !== alias.cacheKey && existing.worktreeId === alias.worktreeId) {
aliases.delete(cacheKey)
}
}
}
aliases.set(alias.cacheKey, alias)
}
/** Queued aliases are newer than request-time follow-ups; preserve them with their matching candidate. */
function mergeFollowUpAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): GitHubPRRefreshAlias | undefined {
if (alias.worktreeId) {
for (const existing of aliases.values()) {
if (existing.worktreeId === alias.worktreeId) {
return existing
}
}
}
setLiveAlias(aliases, alias)
return undefined
}
/** Manual refresh writes copied maps back; keep the latest branch per worktree on re-entry too. */
function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias>): void {
const liveCacheKeyByWorktree = new Map<string, string>()
for (const [cacheKey, alias] of aliases) {
if (!alias.worktreeId) {
continue
}
const superseded = liveCacheKeyByWorktree.get(alias.worktreeId)
if (superseded !== undefined) {
aliases.delete(superseded)
}
liveCacheKeyByWorktree.set(alias.worktreeId, cacheKey)
}
}
export class PRRefreshQueue {
private readonly entries = new Map<string, PRRefreshQueueEntry>()
private order = 0
// Completion ownership shares the cooldown map's bound.
private readonly backgroundNotBefore = new Map<string, PRRefreshRequestState>()
constructor(private readonly resetRetryState: (key: string) => void) {}
get size(): number {
return this.entries.size
}
get(key: string): PRRefreshQueueEntry | undefined {
return this.entries.get(key)
}
set(key: string, entry: PRRefreshQueueEntry): void {
dropSupersededWorktreeAliases(entry.aliases)
this.entries.set(key, entry)
}
delete(key: string): void {
this.entries.delete(key)
}
values(): IterableIterator<PRRefreshQueueEntry> {
return this.entries.values()
}
nextOrder(): number {
this.order += 1
return this.order
}
aliasCount(key: string): number {
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,
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 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,
candidate,
aliases: new Map([[alias.cacheKey, alias]]),
reason,
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' ||
(reason === 'active' && existing.reason === 'active') ||
(priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason))
if (shouldPromote) {
existing.priority = priority
existing.reason = reason
existing.dueAt = Math.min(existing.dueAt, dueAt)
existing.queuedAt = this.nextOrder()
existing.activeDelayNotified = false
existing.candidate = candidate
existing.windowId = windowId ?? existing.windowId
} else if (existing.candidate.worktreeId === candidate.worktreeId) {
existing.candidate = {
...existing.candidate,
cacheKey: candidate.cacheKey,
branch: candidate.branch,
currentHeadOid: candidate.currentHeadOid ?? null,
isSelected: candidate.isSelected
}
}
return { alias, key, dueAt, coalesced: true }
}
removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void {
const existing = this.entries.get(key)
if (!existing) {
return
}
existing.aliases.delete(alias.cacheKey)
const replacement = existing.aliases.values().next().value
if (!replacement) {
this.entries.delete(key)
this.resetRetryState(key)
return
}
if (existing.candidate.cacheKey === alias.cacheKey) {
existing.candidate = {
...existing.candidate,
...replacement,
currentHeadOid: replacement.currentHeadOid ?? null,
isArchived: false,
isBare: false
}
}
}
pruneWorktreeAliases(worktreeId: string): void {
for (const [key, entry] of this.entries) {
for (const alias of entry.aliases.values()) {
if (alias.worktreeId === worktreeId) {
this.removeInvalidAlias(key, alias)
}
}
}
}
removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] {
const removed: PRRefreshQueueEntry[] = []
for (const [key, entry] of this.entries) {
if (entry.reason !== 'visible' || isVisible(key)) {
continue
}
this.entries.delete(key)
removed.push(entry)
}
return removed
}
setVisibleFollowUp(entry: PRRefreshQueueEntry): void {
const existing = this.entries.get(entry.key)
if (!existing) {
this.set(entry.key, entry)
return
}
let candidateSuperseded = false
for (const alias of entry.aliases.values()) {
const preserved = mergeFollowUpAlias(existing.aliases, alias)
if (
preserved &&
alias.worktreeId === entry.candidate.worktreeId &&
!sameAliasRequestIdentity(preserved, alias)
) {
candidateSuperseded = true
}
}
if (
candidateSuperseded ||
bypassesFreshnessDelay(existing.reason) ||
existing.priority > entry.priority
) {
return
}
this.set(entry.key, { ...entry, aliases: existing.aliases })
}
ordered(
activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number
): PRRefreshQueueEntry[] {
const now = Date.now()
return Array.from(this.entries.values()).sort((a, b) => {
const aReady = a.dueAt <= now
const bReady = b.dueAt <= now
if (aReady && bReady) {
return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt
}
if (aReady !== bReady) {
return aReady ? -1 : 1
}
return a.dueAt - b.dueAt || b.priority - a.priority
})
}
}
@@ -0,0 +1,31 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getOriginGitHubApiRepository } from '../../repositories/github-api-repository-remote-probe'
import { ghRepoExecOptions, githubRepoContext } from '../../repositories/github-repository-identity'
import {
getRateLimit,
repositoryRateLimitGuard,
spendsSharedGitHubComQuota
} from '../../api/rate-limit'
const BUCKETS = ['core', 'graphql'] as const
export async function prRefreshRateLimitPausedUntil(
candidate: GitHubPRRefreshCandidate,
warmSharedSnapshot: boolean
): Promise<number | null> {
const executionOptions = ghRepoExecOptions(
githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions)
)
const repository = await getOriginGitHubApiRepository(
candidate.repoPath,
candidate.connectionId,
executionOptions
)
if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) {
await getRateLimit()
}
const blockedGuard = BUCKETS.map((bucket) =>
repositoryRateLimitGuard(repository, bucket, executionOptions)
).find((guard) => guard.blocked)
return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null
}
@@ -0,0 +1,22 @@
/** The desktop renderers PR refresh serves. A Node host (orcad) has none: no window reports
* visible PRs there, and refresh events go nowhere. Electron installs its own at startup. */
export type PRRefreshRendererPort = {
/** Whether the renderer that reported visible PRs is still open. */
isRendererOpen(webContentsId: number): boolean
sendToTrustedUI(channel: string, payload: unknown): void
}
const headless: PRRefreshRendererPort = {
isRendererOpen: () => false,
sendToTrustedUI: () => {}
}
let current = headless
export function setPRRefreshRendererPort(port: PRRefreshRendererPort | null): void {
current = port ?? headless
}
export function prRefreshRenderers(): PRRefreshRendererPort {
return current
}
@@ -0,0 +1,53 @@
import type {
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { lookupBackoffDelayMs } from '../../../source-control/hosted-review-refresh-pacing'
import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy'
export class PRRefreshRetryState {
private readonly errorBackoff = new Map<string, { failures: number; retryAt: number }>()
private readonly manualRetryGates = new Map<string, number>()
get errorBackoffCount(): number {
return this.errorBackoff.size
}
reset(key: string): void {
this.errorBackoff.delete(key)
this.manualRetryGates.delete(key)
}
noteManualGate(key: string, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) {
this.manualRetryGates.set(key, outcome.retryDisabledUntil)
} else {
this.manualRetryGates.delete(key)
}
}
manualGateUntil(key: string): number {
return this.manualRetryGates.get(key) ?? 0
}
nextVisibleErrorRetryAt(key: string, candidate: GitHubPRRefreshCandidate): number {
const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1
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
}
withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome {
if (outcome.kind !== 'upstream-error') {
return outcome
}
const cooldownUntil = outcome.retryDisabledUntil
return {
...outcome,
nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt
}
}
}
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({
recordCoalescedCrashBreadcrumbMock: vi.fn()
}))
vi.mock('../../../crash-reporting/breadcrumbs/crash-breadcrumb-store', () => ({
recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock
}))
import {
clearPRRefreshValidationBackoffForTests,
notePRRefreshValidationDenial
} from './pr-refresh-validation-backoff'
describe('PR refresh validation backoff', () => {
beforeEach(() => {
clearPRRefreshValidationBackoffForTests()
recordCoalescedCrashBreadcrumbMock.mockReset()
})
it('records path-safe diagnostic breadcrumbs', () => {
notePRRefreshValidationDenial(
{
repoId: 'repo-1',
repoPath: '/Users/alice/private/project',
reason: 'repo-path-mismatch'
},
0
)
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'pr_refresh_validation_skip',
data: expect.objectContaining({
reason: 'repo-path-mismatch',
result: 'recorded',
token: expect.any(String)
})
})
)
const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0]
expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project')
})
})
@@ -0,0 +1,107 @@
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { recordCoalescedCrashBreadcrumb } from '../../../crash-reporting/breadcrumbs/crash-breadcrumb-store'
const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000
const MAX_VALIDATION_BACKOFF_ENTRIES = 256
const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000
export type PRRefreshValidationDenialReason =
| 'unknown-repo'
| 'repo-path-mismatch'
| 'host-mismatch'
type ValidationBackoffIdentity = {
repoId?: string | null
repoPath: string
reason: PRRefreshValidationDenialReason
}
type ValidationBackoffEntry = {
expiresAt: number
}
type ValidationBackoffCounters = {
recorded: number
skipped: number
expired: number
}
const validationBackoff = new Map<string, ValidationBackoffEntry>()
const counters: ValidationBackoffCounters = {
recorded: 0,
skipped: 0,
expired: 0
}
function validationIdentityKey(identity: ValidationBackoffIdentity): string {
return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0')
}
function validationIdentityToken(key: string): string {
return createHash('sha256').update(key).digest('hex').slice(0, 12)
}
function evictOldestValidationBackoffEntries(): void {
while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) {
const oldest = validationBackoff.keys().next()
if (oldest.done) {
break
}
validationBackoff.delete(oldest.value)
}
}
function recordValidationBreadcrumb(
reason: PRRefreshValidationDenialReason,
result: 'recorded' | 'backoff',
token: string
): void {
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_validation_skip',
coalesceKey: `pr-refresh-validation:${reason}:${token}`,
minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS,
data: {
reason,
result,
token,
recorded: counters.recorded,
skipped: counters.skipped,
expired: counters.expired
}
})
}
export function notePRRefreshValidationDenial(
identity: ValidationBackoffIdentity,
nowMs = Date.now()
): 'validation-denied' | 'validation-backoff' {
const key = validationIdentityKey(identity)
const existing = validationBackoff.get(key)
const token = validationIdentityToken(key)
if (existing && existing.expiresAt > nowMs) {
counters.skipped += 1
recordValidationBreadcrumb(identity.reason, 'backoff', token)
return 'validation-backoff'
}
if (existing) {
counters.expired += 1
validationBackoff.delete(key)
}
counters.recorded += 1
validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS })
evictOldestValidationBackoffEntries()
recordValidationBreadcrumb(identity.reason, 'recorded', token)
return 'validation-denied'
}
export function clearPRRefreshValidationBackoffForTests(): void {
validationBackoff.clear()
counters.recorded = 0
counters.skipped = 0
counters.expired = 0
}
export function getPRRefreshValidationBackoffCountForTests(): number {
return validationBackoff.size
}
@@ -0,0 +1,78 @@
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('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
import { refreshKey } from './pr-refresh-candidate-policy'
import { PRRefreshVisibility } from './pr-refresh-visibility'
const { getAllWebContentsMock, webContentsFromIdMock } = coordinatorMocks
describe('PR refresh owner liveness', () => {
beforeEach(() => moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks))
afterEach(() => vi.useRealTimers())
it('prunes missing and destroyed owners while checking surviving owners afresh', () => {
const destroyed = vi.fn(() => false)
const registry = new Map([
[1, { id: 1, isDestroyed: () => false }],
[2, { id: 2, isDestroyed: destroyed }],
[3, { id: 3, isDestroyed: () => false }]
])
getAllWebContentsMock.mockImplementation(() => Array.from(registry.values()))
webContentsFromIdMock.mockImplementation((id) => registry.get(id))
const visibility = new PRRefreshVisibility()
const candidate = makeCandidate()
for (const id of registry.keys()) {
visibility.report([candidate], 1, id)
}
registry.delete(1)
destroyed.mockReturnValue(true)
webContentsFromIdMock.mockClear()
expect(visibility.has(refreshKey(candidate))).toBe(true)
expect(visibility.windowCount).toBe(1)
expect(webContentsFromIdMock.mock.calls).toEqual([[1], [2], [3]])
registry.delete(3)
webContentsFromIdMock.mockClear()
expect(visibility.has(refreshKey(candidate))).toBe(false)
expect(webContentsFromIdMock.mock.calls).toEqual([[3]])
expect(visibility.windowCount).toBe(0)
})
it('retains generation, cached fields and surviving selection ownership', () => {
const registry = new Map([
[1, { id: 1, isDestroyed: () => false }],
[2, { id: 2, isDestroyed: () => false }]
])
getAllWebContentsMock.mockImplementation(() => Array.from(registry.values()))
webContentsFromIdMock.mockImplementation((id) => registry.get(id))
const visibility = new PRRefreshVisibility()
const candidate = makeCandidate({ cachedFetchedAt: 1, cachedHeadOid: 'old-head' })
const key = refreshKey(candidate)
visibility.report([{ ...candidate, isSelected: true }], 2, 1)
visibility.report([candidate], 1, 2)
visibility.update({ ...candidate, cachedFetchedAt: 3, cachedHeadOid: 'new-head' })
expect(visibility.report([], 1, 1)).toBe(false)
expect(visibility.candidate(key, candidate)).toMatchObject({
isSelected: true,
cachedFetchedAt: 3,
cachedHeadOid: 'new-head'
})
expect(visibility.report([candidate], 3, 1)).toBe(true)
expect(visibility.candidate(key, candidate)).toMatchObject({
isSelected: false,
cachedFetchedAt: 3,
cachedHeadOid: 'new-head'
})
visibility.report([{ ...candidate, isSelected: true }], 2, 2)
expect(visibility.clearWindow(1)).toBe(true)
expect(visibility.has(key)).toBe(true)
expect(visibility.candidate(key, candidate)).toMatchObject({ isSelected: true })
registry.delete(2)
expect(visibility.has(key)).toBe(false)
})
})
@@ -0,0 +1,93 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { refreshKey } from './pr-refresh-candidate-policy'
import { prRefreshRenderers } from './pr-refresh-renderer-port'
export class PRRefreshVisibility {
private readonly visibleByWindow = new Map<
number,
{ generation: number; candidates: Map<string, GitHubPRRefreshCandidate> }
>()
get windowCount(): number {
return this.visibleByWindow.size
}
clearWindow(windowId: number): boolean {
return this.visibleByWindow.delete(windowId)
}
report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean {
this.pruneDestroyedWindows()
const existing = this.visibleByWindow.get(windowId)
if (existing && generation < existing.generation) {
return false
}
const reported = new Map<string, GitHubPRRefreshCandidate>()
for (const candidate of candidates) {
const key = refreshKey(candidate)
const previous = this.candidate(key, candidate)
reported.set(key, {
...previous,
...candidate,
...(previous.cachedFetchedAt != null &&
previous.cachedFetchedAt > (candidate.cachedFetchedAt ?? 0)
? this.cachedFields(previous)
: {}),
isSelected: candidate.isSelected === true || reported.get(key)?.isSelected === true
})
}
this.visibleByWindow.set(windowId, { generation, candidates: reported })
return true
}
has(key: string): boolean {
this.pruneDestroyedWindows()
return Array.from(this.visibleByWindow.values()).some((window) => window.candidates.has(key))
}
candidate(key: string, fallback: GitHubPRRefreshCandidate): GitHubPRRefreshCandidate {
let latest = fallback
let selected = false
for (const window of this.visibleByWindow.values()) {
const candidate = window.candidates.get(key)
if (!candidate) {
continue
}
selected ||= candidate.isSelected === true
if ((candidate.cachedFetchedAt ?? 0) > (latest.cachedFetchedAt ?? 0)) {
latest = { ...fallback, ...this.cachedFields(candidate) }
}
}
return { ...latest, isSelected: selected }
}
update(candidate: GitHubPRRefreshCandidate): void {
const key = refreshKey(candidate)
for (const window of this.visibleByWindow.values()) {
const current = window.candidates.get(key)
if (current && (current.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) {
window.candidates.set(key, { ...current, ...this.cachedFields(candidate) })
}
}
}
private cachedFields(candidate: GitHubPRRefreshCandidate): Partial<GitHubPRRefreshCandidate> {
return {
cachedFetchedAt: candidate.cachedFetchedAt,
cachedHeadOid: candidate.cachedHeadOid,
cachedHasPR: candidate.cachedHasPR,
cachedPRState: candidate.cachedPRState,
cachedChecksStatus: candidate.cachedChecksStatus,
cachedMergeable: candidate.cachedMergeable,
cachedMergeStateStatus: candidate.cachedMergeStateStatus
}
}
private pruneDestroyedWindows(): void {
for (const windowId of this.visibleByWindow.keys()) {
if (!prRefreshRenderers().isRendererOpen(windowId)) {
this.visibleByWindow.delete(windowId)
}
}
}
}
@@ -0,0 +1,128 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PullRequestLookupData } from '../../client/lookup/pull-request-lookup-data'
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
const fixture = vi.hoisted(() => ({
lookup: vi.fn(),
conflict: vi.fn(),
stack: { number: 2, position: 1, size: 2, baseRefName: 'main', baseSha: 'base-sha' }
}))
vi.mock('../../repositories/github-api-repository', () => ({
resolveGitHubApiRepositoryCandidates: async () => ({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
}))
vi.mock('../../repositories/github-api-repository-remote-probe', () => ({
getOriginGitHubApiRepository: async () => ({ owner: 'acme', repo: 'widgets' })
}))
vi.mock('../../api/rate-limit', () => ({
repositoryRateLimitGuard: () => ({ blocked: false }),
spendsSharedGitHubComQuota: () => false
}))
vi.mock('../../client/lookup/pr-number-lookup', () => ({ lookupPRByNumber: fixture.lookup }))
vi.mock('../../client/lookup/pr-stack-summary-cache', () => ({
getCachedGitHubPRStackSummary: async () => fixture.stack
}))
vi.mock('../stacks/github-pr-stack', () => ({
hydrateGitHubPRStack: async () => fixture.stack
}))
vi.mock('../../client/detect/repository-merge-metadata', () => ({
detectRepositoryMergeMetadata: async () => ({ mergeQueueRequired: true })
}))
vi.mock('../conflicts/conflict-summary', () => ({
getPRConflictSummary: fixture.conflict
}))
const data: PullRequestLookupData = {
number: 12,
title: 'Conflicting change',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/12',
updatedAt: '2026-11-11T00:00:00Z',
mergeable: 'CONFLICTING',
baseRefName: 'main',
baseRefOid: 'base-sha',
headRefName: 'topic',
headRefOid: 'head-sha',
checksUnavailable: false,
statusCheckRollup: [{ __typename: 'CheckRun', name: 'build', status: 'IN_PROGRESS' }]
}
const summary = { baseRef: 'main', baseCommit: 'base-sh', commitsBehind: 1, files: ['a.ts'] }
afterEach(() => vi.useRealTimers())
it('watches preserve provider checks while full and phone reads retain derived details', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
fixture.lookup.mockImplementation(async () => ({
data: { ...data },
dataRepo: { owner: 'acme', repo: 'widgets' }
}))
fixture.conflict.mockResolvedValue(summary)
const { addPRRefreshOutcomeObserver, schedulePRWatchRead } =
await import('./pr-refresh-coordinator')
const { getPRForBranchOutcome, readRecentPhonePROutcome } =
await import('../../client/lookup/pr-for-branch-outcome')
const candidate = makeCandidate({ branch: 'topic', linkedPRNumber: 12 })
const watched = Promise.withResolvers<PRRefreshOutcome>()
const unsubscribe = addPRRefreshOutcomeObserver((_, outcome) => watched.resolve(outcome))
schedulePRWatchRead({ prKey: 'github.com/acme/widgets#12' }, candidate, Date.now())
await vi.advanceTimersByTimeAsync(0)
const watch = await watched.promise
unsubscribe()
expect(watch).toMatchObject({
kind: 'found',
pr: { number: 12, headSha: 'head-sha', state: 'open', checksStatus: 'pending' }
})
if (watch.kind !== 'found') {
throw new Error('watch did not find the PR')
}
expect(watch.pr.conflictSummary).toBeUndefined()
expect(watch.pr.stack).toBeUndefined()
expect(watch.pr.mergeQueueRequired).toBeUndefined()
// An uncapped partial read must also stay separate from a concurrent full read.
const provider = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const conflict = Promise.withResolvers<typeof summary>()
fixture.lookup.mockImplementation(async () => {
entered.resolve()
await provider.promise
return { data: { ...data }, dataRepo: { owner: 'acme', repo: 'widgets' } }
})
fixture.conflict.mockReturnValue(conflict.promise)
const partial = getPRForBranchOutcome('/repo', 'topic', 12, null, null, {
includeDerivedData: false
})
await entered.promise
const full = getPRForBranchOutcome('/repo', 'topic', 12, null, null)
provider.resolve()
try {
expect(await partial).toMatchObject({ kind: 'found', pr: { conflictSummary: undefined } })
expect(
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, {
currentHeadOid: 'head-sha'
})
).toBeNull()
} finally {
conflict.resolve(summary)
}
const complete = await full
expect(complete).toMatchObject({
kind: 'found',
pr: {
number: 12,
headSha: 'head-sha',
checksStatus: 'pending',
conflictSummary: { files: ['a.ts'], commitsBehind: 1 },
stack: { number: 2, baseRefName: 'main' },
mergeQueueRequired: true
}
})
expect(
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, { currentHeadOid: 'head-sha' })
).toEqual(complete)
})
@@ -0,0 +1,153 @@
import type {
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
export type PRWatchJob = NonNullable<PRRefreshQueueEntry['watch']>
export type PRWatchJobHooks = {
/** False once the watch ends, so its job is not read again. */
isWatched: (job: PRWatchJob) => boolean
/** Reads one earlier head's checks; resolves the next due time, or null when that head is done. */
readHeadChecks: (
job: Required<PRWatchJob>,
candidate: GitHubPRRefreshCandidate
) => Promise<number | null>
}
/** A watch overdue by one background interval outranks visible reads, so none starves. */
export const PR_WATCH_AGING_MS = 120_000
const PR_WATCH_PRIORITY = 20
// An aged watch takes the next budgeted start above visible reads (40), then yields one back
// (30), so under saturation watches and visible rows alternate and neither starves.
const AGED_PR_WATCH_PRIORITY = 50
const YIELDING_PR_WATCH_PRIORITY = 30
// A watch skipped while its repo's WSL distro is stopped looks again later, without starting it.
const PR_WATCH_SKIPPED_RETRY_MS = 15 * 60_000
export function prWatchQueueKey(job: PRWatchJob): string {
return job.headSha ? `sha-checks::${job.prKey}::${job.headSha}` : `watch::${job.prKey}`
}
/** A complete read with nothing left running, or a complete read with no checks at all. A failed
* check beside a running one is not done, whatever the sidebar's failure-first summary says. */
export function prChecksDone(pr: PRInfo): boolean {
return (
pr.checksFinal === true || (pr.checksReadComplete === true && pr.checksSummary?.total === 0)
)
}
/** A watch's next read: the background cadence, or none once the PR has settled. */
export function prWatchNextDueAt(outcome: PRRefreshOutcome): number | null {
if (outcome.kind === 'upstream-error') {
return null
}
const pr = outcome.kind === 'found' ? outcome.pr : null
const interval = reviewRefreshIntervalMs({
state: pr?.state ?? null,
checksStatus: pr && !prChecksDone(pr) ? 'pending' : (pr?.checksStatus ?? null),
hasReview: pr !== null,
selected: false
})
return interval === null ? null : outcome.fetchedAt + interval
}
/** A new watch keeps the earliest due time; a read from elsewhere only defers a queued one. */
export function schedulePRWatchEntry(
queue: PRRefreshQueue,
job: PRWatchJob,
candidate: GitHubPRRefreshCandidate,
dueAt: number,
defer: boolean
): void {
const key = prWatchQueueKey(job)
const existing = queue.get(key)
if (existing) {
existing.candidate = candidate
existing.dueAt = defer ? Math.max(existing.dueAt, dueAt) : Math.min(existing.dueAt, dueAt)
return
}
if (defer) {
return
}
queue.set(key, {
key,
candidate,
// Watch reads update no card, so they name no alias the renderer would show as refreshing.
aliases: new Map(),
reason: 'watch',
priority: PR_WATCH_PRIORITY,
dueAt,
queuedAt: queue.nextOrder(),
watch: job
})
}
/** Watch jobs inside the drain: they re-enqueue themselves whether or not any window shows the PR. */
export class PRWatchJobRunner {
private hooks: PRWatchJobHooks | null = null
private lastBudgetedStartWasWatch = false
constructor(
private readonly queue: PRRefreshQueue,
private readonly retry: PRRefreshRetryState,
private readonly drainer: { schedule: (delay: number) => void }
) {}
setHooks(hooks: PRWatchJobHooks | null): void {
this.hooks = hooks
}
noteBudgetedStart(entry: PRRefreshQueueEntry): void {
this.lastBudgetedStartWasWatch = entry.watch !== undefined
}
/** Re-ranks watch entries before each pick, so aging needs no change to the queue's order. */
age(entries: Iterable<PRRefreshQueueEntry>, now: number): void {
const aged = this.lastBudgetedStartWasWatch
? YIELDING_PR_WATCH_PRIORITY
: AGED_PR_WATCH_PRIORITY
for (const entry of entries) {
if (entry.watch) {
entry.priority = now - entry.dueAt >= PR_WATCH_AGING_MS ? aged : PR_WATCH_PRIORITY
}
}
}
skipped(entry: PRRefreshQueueEntry): void {
this.followUp(entry, Date.now() + PR_WATCH_SKIPPED_RETRY_MS)
}
/** Runs an earlier head's job; false for any other entry, which the drain reads as usual. */
async readHead(entry: PRRefreshQueueEntry): Promise<boolean> {
const headSha = entry.watch?.headSha
if (!entry.watch || !headSha) {
return false
}
const job = { prKey: entry.watch.prKey, headSha }
this.followUp(entry, (await this.hooks?.readHeadChecks(job, entry.candidate)) ?? null)
return true
}
afterOutcome(entry: PRRefreshQueueEntry, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'upstream-error') {
this.followUp(entry, this.retry.nextVisibleErrorRetryAt(entry.key, entry.candidate))
return
}
this.retry.reset(entry.key)
this.followUp(entry, prWatchNextDueAt(outcome))
}
private followUp(entry: PRRefreshQueueEntry, dueAt: number | null): void {
const job = entry.watch
if (!job || dueAt === null || !(this.hooks?.isWatched(job) ?? false)) {
return
}
schedulePRWatchEntry(this.queue, job, entry.candidate, dueAt, false)
this.drainer.schedule(Math.max(0, dueAt - Date.now()))
}
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
import { pullRequestChangeObserver, type PullRequestChange } from './pull-request-changed-events'
const candidate: GitHubPRRefreshCandidate = {
cacheKey: '/repo::feature',
repoKind: 'git',
repoId: 'repo-1',
repoPath: '/repo',
branch: 'feature',
worktreeId: 'repo-1::/repo'
}
const pr: PRInfo = {
number: 42,
title: 'Add feature',
state: 'open',
url: 'https://github.com/acme/app/pull/42',
checksStatus: 'pending',
checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1, neutral: 0 },
updatedAt: '2026-11-09T10:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'abc123'
}
describe('pullRequestChangeObserver', () => {
it('publishes a found PR only when it differs from the last one published', () => {
const published: PullRequestChange[] = []
const observe = pullRequestChangeObserver((change) => published.push(change))
observe(candidate, { kind: 'found', pr, fetchedAt: 1 }, [])
// Same PR read again, with a field outside the change set moved.
observe(candidate, { kind: 'found', pr: { ...pr, title: 'Renamed' }, fetchedAt: 2 }, [])
observe(
candidate,
{
kind: 'upstream-error',
errorType: 'network',
message: 'offline',
fetchedAt: 3
},
[]
)
// A read that couldn't load the checks reports neutral with no summary.
const { checksSummary: _unloaded, ...withoutChecks } = pr
observe(
candidate,
{ kind: 'found', pr: { ...withoutChecks, checksStatus: 'neutral' }, fetchedAt: 4 },
[]
)
observe(
candidate,
{
kind: 'found',
pr: {
...pr,
checksStatus: 'success',
checksSummary: {
state: 'success',
total: 1,
passed: 1,
failed: 0,
pending: 0,
neutral: 0
}
},
fetchedAt: 5
},
[]
)
expect(published).toEqual([
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' },
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' }
])
})
// Checkouts linked to the same PR share one coordinator read.
it('publishes for every checkout one read stood for', () => {
const published: PullRequestChange[] = []
const observe = pullRequestChangeObserver((change) => published.push(change))
const sibling = {
cacheKey: '/repo-b::other',
repoPath: '/repo-b',
branch: 'other',
worktreeId: 'repo-1::/repo-b'
}
observe(candidate, { kind: 'found', pr, fetchedAt: 1 }, [candidate, sibling])
expect(published).toEqual([
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' },
{ worktreeId: 'repo-1::/repo-b', repoId: 'repo-1', branch: 'other' }
])
})
})
@@ -0,0 +1,64 @@
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { CheckStatus } from '../../../../shared/github/pull-requests/pull-request-types'
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
import type { PRRefreshOutcomeObserver } from './pr-refresh-event-publisher'
export type PullRequestChange = Omit<
Extract<RuntimeClientEvent, { type: 'pullRequestChanged' }>,
'type'
>
type PublishedPR = { summary: string; checks: CheckStatus | null }
// An evicted checkout costs one repeat event, so the bound only caps memory.
const MAX_TRACKED_CHECKOUTS = 256
// Errors say nothing about the PR, so they never publish.
function observedPR(outcome: PRRefreshOutcome, last: PublishedPR | undefined): PublishedPR | null {
if (outcome.kind === 'no-pr') {
return { summary: 'no-pr', checks: null }
}
if (outcome.kind !== 'found') {
return null
}
const { number, state, headSha, updatedAt, checksStatus, checksSummary } = outcome.pr
return {
summary: JSON.stringify([number, state, headSha ?? null, updatedAt ?? null]),
// A read that couldn't load the checks (an error, or a watch read's page cap) reports 'neutral' without a summary.
checks: checksSummary ? checksStatus : (last?.checks ?? null)
}
}
/**
* Reports a checkout's PR only when a read finds it different from what was last reported: the
* coordinator observes every refresh, including unchanged follow-ups, and each event costs a phone read.
*/
export function pullRequestChangeObserver(
publish: (change: PullRequestChange) => void
): PRRefreshOutcomeObserver {
const published = new Map<string, PublishedPR>()
const publishIfChanged = (change: PullRequestChange, outcome: PRRefreshOutcome): void => {
const key = `${change.worktreeId}\0${change.branch}`
const last = published.get(key)
const next = observedPR(outcome, last)
if (!next || (next.summary === last?.summary && next.checks === last.checks)) {
return
}
published.delete(key)
published.set(key, next)
const oldest = published.keys().next().value
if (published.size > MAX_TRACKED_CHECKOUTS && oldest !== undefined) {
published.delete(oldest)
}
publish(change)
}
return (candidate, outcome, aliases) => {
// One read can stand for several checkouts linked to the same PR. A client's own read names
// no checkout, and that client already holds the answer.
for (const { worktreeId, repoId, branch } of [candidate, ...aliases]) {
if (worktreeId) {
publishIfChanged({ worktreeId, repoId: repoId ?? candidate.repoId, branch }, outcome)
}
}
}
}