mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
refactor(github): organize concrete feature owners (#41601)
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
+82
@@ -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)
|
||||
})
|
||||
})
|
||||
+153
@@ -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 })
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user