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

This commit is contained in:
Neil
2026-11-12 09:17:26 -08:00
committed by GitHub
parent ff9fd6634b
commit fb8d6f950b
1003 changed files with 4460 additions and 8422 deletions
@@ -0,0 +1,123 @@
import type { PRConflictSummary } from '../../../../shared/github/pull-requests/pull-request-types'
import { dedupeInFlightRun } from '../../../runtime/concurrency/in-flight-run-dedupe'
// Throttle even manual refreshes to one base fetch per minute; GitHub recomputes mergeability asynchronously.
export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000
// TTL alone cannot bound retained repositories and PRs in a long-lived process.
const BASE_OID_CACHE_MAX = 64
const SUMMARY_CACHE_MAX = 128
export type FreshBaseTipResolution =
| { kind: 'resolved'; oid: string }
| { kind: 'fallback-unresolved' }
type CachedBaseTipResolution = {
oid: string | null
resolvedAt: number
}
type CachedSummary = {
value: PRConflictSummary | undefined
// Success depends on immutable OIDs; failure can recover when later fetches supply missing objects.
staleAt: number | null
}
const baseOidCache = new Map<string, CachedBaseTipResolution>()
const summaryCache = new Map<string, CachedSummary>()
const inFlightBaseOidResolves = new Map<string, Promise<FreshBaseTipResolution>>()
const inFlightSummaryDerivations = new Map<string, Promise<PRConflictSummary | undefined>>()
// Git binaries, filesystems and remote access differ across native/WSL runtimes.
export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string {
return wslDistro ? `wsl:${wslDistro}` : 'local:host'
}
// Paths and refs may contain printable delimiters; structural encoding prevents key collisions.
export function buildConflictSummaryCacheKey(...parts: string[]): string {
return JSON.stringify(parts)
}
export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null {
const entry = baseOidCache.get(baseKey)
if (!entry) {
return null
}
if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) {
baseOidCache.delete(baseKey)
return null
}
return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' }
}
export function storeResolvedBaseTip(baseKey: string, oid: string): void {
setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX)
}
export function rememberUnresolvedBaseTip(baseKey: string): void {
setBoundedMapEntry(
baseOidCache,
baseKey,
{ oid: null, resolvedAt: Date.now() },
BASE_OID_CACHE_MAX
)
}
export function readCachedSummary(summaryKey: string): CachedSummary | null {
const entry = summaryCache.get(summaryKey)
if (!entry) {
return null
}
if (entry.staleAt !== null && Date.now() >= entry.staleAt) {
summaryCache.delete(summaryKey)
return null
}
return entry
}
export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void {
setBoundedMapEntry(
summaryCache,
summaryKey,
{
value,
staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null
},
SUMMARY_CACHE_MAX
)
}
export function dedupeBaseOidResolve(
key: string,
factory: () => Promise<FreshBaseTipResolution>
): Promise<FreshBaseTipResolution> {
return dedupeInFlightRun(inFlightBaseOidResolves, key, factory)
}
export function dedupeSummaryDerivation(
key: string,
factory: () => Promise<PRConflictSummary | undefined>
): Promise<PRConflictSummary | undefined> {
return dedupeInFlightRun(inFlightSummaryDerivations, key, factory)
}
function setBoundedMapEntry<K, V>(map: Map<K, V>, key: K, value: V, maxEntries: number): void {
if (map.has(key)) {
map.delete(key)
}
map.set(key, value)
while (map.size > maxEntries) {
const oldest = map.keys().next()
if (oldest.done) {
return
}
map.delete(oldest.value)
}
}
export function __resetPRConflictSummaryDerivationCachesForTests(): void {
baseOidCache.clear()
summaryCache.clear()
inFlightBaseOidResolves.clear()
inFlightSummaryDerivations.clear()
}
@@ -0,0 +1,152 @@
import { mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Count subprocesses: unchanged OIDs reuse summaries and base fetches share a throttle window.
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
const scratchFolderPrefixes = vi.hoisted((): string[] => [])
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
// Why: off Windows a WSL objects path is relative, so a real scratch dir would land in the cwd.
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
return {
...actual,
mkdtemp: async (prefix: string) => {
scratchFolderPrefixes.push(prefix)
if (!isAbsolute(prefix)) {
throw new Error('no object store in this test')
}
return actual.mkdtemp(prefix)
}
}
})
import { findGitSubcommandIndex } from '../../../../shared/git/commands/git-command-classification'
import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary'
type GitResult = { stdout: string }
type GitHandler = (argv: string[]) => Promise<GitResult>
const COMMON_DIR_COMMAND = 'rev-parse --git-common-dir'
const defaultHandlers: Record<string, GitHandler> = {
fetch: async () => ({ stdout: '' }),
'rev-parse': async () => ({ stdout: 'base-tip-1\n' }),
'merge-base': async () => ({ stdout: 'merge-base-1\n' }),
'rev-list': async () => ({ stdout: '3\n' }),
'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' }),
[COMMON_DIR_COMMAND]: async () => ({ stdout: '.git\n' })
}
function commandKey(argv: string[]): string {
const subcommand = argv[findGitSubcommandIndex(argv)]
return subcommand === 'rev-parse' && argv.includes('--git-common-dir')
? COMMON_DIR_COMMAND
: subcommand
}
function mockGitDispatch(overrides: Record<string, GitHandler> = {}): void {
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
const key = commandKey(argv)
const handler = overrides[key] ?? defaultHandlers[key]
if (!handler) {
return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`))
}
return handler(argv)
})
}
function spawnCount(command?: string): number {
const calls = gitExecFileAsyncMock.mock.calls
if (!command) {
return calls.length
}
return calls.filter(([argv]) => Array.isArray(argv) && commandKey(argv) === command).length
}
const expectedSummary = {
baseRef: 'main',
baseCommit: 'base-ti',
commitsBehind: 3,
files: ['src/conflict.ts']
}
function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) {
return getPRConflictSummary(
'/repo-root',
'main',
'github-base-oid',
headRefOid,
wslDistro ? { wslDistro } : {}
)
}
describe('getPRConflictSummary caching', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_750_000_000_000)
gitExecFileAsyncMock.mockReset()
scratchFolderPrefixes.length = 0
__resetPRConflictSummaryCachesForTests()
})
afterEach(() => {
vi.useRealTimers()
})
it('stops waiting on a hung base fetch after 10 s and uses the local tracking ref', async () => {
mockGitDispatch({ fetch: () => new Promise<GitResult>(() => {}) })
const summary = deriveSummary()
await vi.waitFor(() => expect(spawnCount('fetch')).toBe(1))
await vi.advanceTimersByTimeAsync(10_000)
await expect(summary).resolves.toEqual(expectedSummary)
})
it('runs the legacy merge-tree fallback against the scratch object store too', async () => {
const commonDir = join(
realpathSync(mkdtempSync(join(tmpdir(), 'orca-conflict-summary-'))),
'.git'
)
mkdirSync(join(commonDir, 'objects'), { recursive: true })
try {
mockGitDispatch({
[COMMON_DIR_COMMAND]: async () => ({ stdout: `${commonDir}\n` }),
'merge-tree': async (argv) => {
if (argv.includes('--merge-base')) {
throw Object.assign(new Error("error: unknown option `merge-base'"), { stderr: '' })
}
return { stdout: 'tree-oid\u0000src/conflict.ts\u0000' }
}
})
await expect(deriveSummary('head-oid-1')).resolves.toEqual(expectedSummary)
await expect(deriveSummary('head-oid-2')).resolves.toEqual(expectedSummary)
const legacy = gitExecFileAsyncMock.mock.calls.filter(
([argv]) => argv[0] === 'merge-tree' && !argv.includes('--merge-base')
)
expect(legacy).toHaveLength(2)
expect(legacy[0]?.[1]?.env).toBeUndefined()
expect(legacy[1]?.[1]?.env).toMatchObject({
GIT_OBJECT_DIRECTORY: expect.stringContaining(
join(commonDir, 'objects', 'tmp_objdir-orca-merge-tree-')
),
GIT_ALTERNATE_OBJECT_DIRECTORIES: join(commonDir, 'objects')
})
expect(spawnCount('merge-tree')).toBe(3)
expect(readdirSync(join(commonDir, 'objects'))).toEqual([])
} finally {
rmSync(join(commonDir, '..'), { recursive: true, force: true })
}
})
})
@@ -0,0 +1,325 @@
import type { PRConflictSummary } from '../../../../shared/github/pull-requests/pull-request-types'
import {
isUnsupportedMergeTreeMergeBaseError,
isUnsupportedMergeTreeWriteTreeError
} from '../../../../shared/git/branches/git-merge-tree-capability'
import { gitExecFileAsync } from '../../../git/command-runner/git-exec-file'
import {
gitOptionsForWorktree,
type GitRuntimeOptions
} from '../../../git/command-runner/git-runtime-options'
import { createLocalGitObjectQuarantine } from '../../../git/objects/local-git-object-quarantine'
import { _resetCanonicalRepoKeyCacheForTests } from '../../../git/repositories/canonical-repo-key'
import { RuntimeRemoteFetchController } from '../../../runtime/repositories/runtime-remote-fetch-controller'
import {
clearGitCapabilityStateForTests,
withLocalGitCapabilityCacheForExecution
} from '../../../git/command-runner/host-routing/git-capability-state'
import {
__resetPRConflictSummaryDerivationCachesForTests,
buildConflictSummaryCacheKey,
dedupeBaseOidResolve,
dedupeSummaryDerivation,
getConflictSummaryGitRuntimeKey,
readCachedSummary,
readFreshBaseTipResolution,
rememberUnresolvedBaseTip,
storeResolvedBaseTip,
storeCachedSummary
} from './conflict-summary-cache'
type LocalGitExecOptions = Pick<GitRuntimeOptions, 'wslDistro' | 'admissionTier'>
type BaseRefreshController = Pick<
RuntimeRemoteFetchController,
'refreshRemoteTrackingBaseForPreview'
>
let remoteFetches: BaseRefreshController | null = null
export function setPRConflictSummaryRemoteFetches(controller: BaseRefreshController): void {
remoteFetches = controller
}
export function __resetPRConflictSummaryCachesForTests(): void {
clearGitCapabilityStateForTests()
__resetPRConflictSummaryDerivationCachesForTests()
_resetCanonicalRepoKeyCacheForTests()
remoteFetches = null
}
export async function getPRConflictSummary(
repoPath: string,
baseRefName: string,
baseRefOid: string,
headRefOid: string,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRConflictSummary | undefined> {
// Use GitHub's head OID when no local branch exists, and refresh the live base to match current mergeability.
const latestBaseOid = await resolveLatestBaseOidThrottled(
repoPath,
baseRefName,
baseRefOid,
localGitOptions
)
// Immutable commit pairs let cache hits skip the entire Git derivation.
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const summaryKey = buildConflictSummaryCacheKey(
runtimeKey,
repoPath,
baseRefName,
headRefOid,
latestBaseOid
)
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
// Distinct pinned base snapshots can resolve to one live tip; dedupe by the actual commit pair.
return dedupeSummaryDerivation(summaryKey, () =>
derivePRConflictSummary(
repoPath,
baseRefName,
headRefOid,
latestBaseOid,
summaryKey,
localGitOptions
)
)
}
async function derivePRConflictSummary(
repoPath: string,
baseRefName: string,
headRefOid: string,
latestBaseOid: string,
summaryKey: string,
localGitOptions: LocalGitExecOptions
): Promise<PRConflictSummary | undefined> {
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
try {
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions)
const [commitsBehind, files] = await Promise.all([
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions),
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions)
])
const summary = {
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files,
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
}
storeCachedSummary(summaryKey, summary)
return summary
} catch {
storeCachedSummary(summaryKey, undefined)
return undefined
}
}
async function resolveLatestBaseOidThrottled(
repoPath: string,
baseRefName: string,
fallbackBaseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string> {
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName)
const cachedResolution = readFreshBaseTipResolution(baseKey)
if (cachedResolution) {
return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid
}
return dedupeBaseOidResolve(baseKey, async () => {
// Another caller can resolve the base between the first cache read and this factory.
const freshResolution = readFreshBaseTipResolution(baseKey)
if (freshResolution) {
return freshResolution
}
const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions)
if (oid) {
storeResolvedBaseTip(baseKey, oid)
return { kind: 'resolved', oid }
}
// Cache unresolved branch-wide probes, not each PR's distinct fallback base OID.
rememberUnresolvedBaseTip(baseKey)
return { kind: 'fallback-unresolved' }
}).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid))
}
async function resolveLatestBaseOid(
repoPath: string,
baseRefName: string,
localGitOptions: LocalGitExecOptions
): Promise<string | null> {
const remoteName = 'origin'
const trackingRef = `refs/remotes/${remoteName}/${baseRefName}`
remoteFetches ??= new RuntimeRemoteFetchController()
// Keep cards available offline: failed refreshes fall back to local refs, then GitHub's base OID.
await remoteFetches.refreshRemoteTrackingBaseForPreview(
repoPath,
{
remote: remoteName,
branch: baseRefName,
ref: trackingRef,
base: `${remoteName}/${baseRefName}`
},
localGitOptions
)
for (const ref of [trackingRef, `${remoteName}/${baseRefName}`]) {
try {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
...gitOptionsForWorktree(repoPath, localGitOptions)
})
const oid = stdout.trim()
if (oid) {
return oid
}
} catch {
// Continue through ref forms before falling back to GitHub's base OID.
}
}
return null
}
async function resolveMergeBase(
repoPath: string,
headOid: string,
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string> {
const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], {
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return stdout.trim()
}
async function countCommits(
repoPath: string,
range: string,
localGitOptions: LocalGitExecOptions
): Promise<number> {
const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], {
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return Number.parseInt(stdout.trim(), 10) || 0
}
async function loadConflictingFiles(
repoPath: string,
mergeBase: string,
headOid: string,
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
const modernArgs = [
'merge-tree',
'--write-tree',
'--name-only',
'-z',
'--no-messages',
'--merge-base',
mergeBase,
headOid,
baseOid
]
const legacyArgs = [
'merge-tree',
'--write-tree',
'--name-only',
'-z',
'--no-messages',
headOid,
baseOid
]
// Quarantine the unused merged tree so preview reads cannot accumulate loose objects.
const quarantine = createLocalGitObjectQuarantine(repoPath, localGitOptions)
return withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: localGitOptions.wslDistro },
(capabilities) => {
const runMergeTree = (args: string[]): Promise<{ stdout: string }> => {
const run = (env: NodeJS.ProcessEnv | undefined) =>
gitExecFileAsync(args, {
...gitOptionsForWorktree(repoPath, localGitOptions),
...(env ? { env } : {})
})
// Probe support before creating a quarantine that older Git cannot prune.
return capabilities.isKnownSupported('merge-tree-write-tree')
? quarantine.run(run)
: run(undefined)
}
return capabilities.runWithFallback(
'merge-tree-write-tree',
() =>
capabilities.runWithFallback(
'merge-tree-merge-base',
async () => {
try {
const result = await runMergeTree(modernArgs)
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (error) {
if (isUnsupportedMergeTreeWriteTreeError(error)) {
throw error
}
// Conflicts exit 1 but still provide the file list; option rejection alone triggers fallback.
const stdoutFromError = getGitErrorOutput(error, 'stdout')
if (stdoutFromError) {
return parseMergeTreeNameOnlyOutput(stdoutFromError)
}
throw error
}
},
() => loadConflictingFilesWithLegacyMergeTree(runMergeTree, legacyArgs),
isUnsupportedMergeTreeMergeBaseError
),
async () => {
// Older Git cannot derive a reliable merge-conflict list; cache the unsupported capability.
throw new Error('Git merge-tree --write-tree is unavailable on this execution host.')
},
isUnsupportedMergeTreeWriteTreeError
)
}
)
}
async function loadConflictingFilesWithLegacyMergeTree(
runMergeTree: (args: string[]) => Promise<{ stdout: string }>,
legacyArgs: string[]
): Promise<string[]> {
try {
const result = await runMergeTree(legacyArgs)
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (fallbackError) {
const fallbackStdout = getGitErrorOutput(fallbackError, 'stdout')
if (fallbackStdout) {
return parseMergeTreeNameOnlyOutput(fallbackStdout)
}
throw fallbackError
}
}
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {
return []
}
const [, ...files] = entries
return files
}
function getGitErrorOutput(error: unknown, key: 'stdout' | 'stderr'): string {
if (typeof error !== 'object' || error === null) {
return ''
}
const output = (error as Partial<Record<'stdout' | 'stderr', unknown>>)[key]
return typeof output === 'string' ? output : ''
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines'
describe('getPRReviewCommentLineNumbersFromPatch', () => {
it('returns modified-side context and added lines from GitHub patch hunks', () => {
const patch = [
'@@ -10,4 +20,5 @@ function example() {',
' const kept = true',
'-const oldValue = 1',
'+const newValue = 1',
'+const added = true',
' return kept',
'@@ -40,2 +51,2 @@ function other() {',
'-removeMe()',
'+addMe()',
' done()'
].join('\n')
expect(getPRReviewCommentLineNumbersFromPatch(patch)).toEqual([20, 21, 22, 23, 51, 52])
})
it('counts added lines whose content begins with ++', () => {
// Inside a hunk +++count is added ++count content, not a file header.
const patch = ['@@ -1,1 +1,2 @@', ' const a = 1', '+++count'].join('\n')
expect(getPRReviewCommentLineNumbersFromPatch(patch)).toEqual([1, 2])
})
it('does not treat removed lines whose content begins with -- as commentable', () => {
// Removed --count content must not advance the new-side line counter.
const patch = ['@@ -1,2 +1,1 @@', ' const a = 1', '---count'].join('\n')
expect(getPRReviewCommentLineNumbersFromPatch(patch)).toEqual([1])
})
})
@@ -0,0 +1,43 @@
const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
export function getPRReviewCommentLineNumbersFromPatch(patch: string | undefined): number[] {
if (!patch) {
return []
}
const lineNumbers: number[] = []
let nextModifiedLine: number | null = null
for (const line of patch.split('\n')) {
const hunk = HUNK_HEADER_RE.exec(line)
if (hunk) {
const start = Number(hunk[1])
const count = hunk[2] === undefined ? 1 : Number(hunk[2])
nextModifiedLine = Number.isInteger(start) && count > 0 ? start : null
continue
}
if (nextModifiedLine === null || line.startsWith('\\')) {
continue
}
// Per-file headers precede the hunk; +++ inside a hunk is added content.
if (line.startsWith('+')) {
lineNumbers.push(nextModifiedLine)
nextModifiedLine++
continue
}
if (line.startsWith(' ')) {
lineNumbers.push(nextModifiedLine)
nextModifiedLine++
continue
}
if (!line.startsWith('-')) {
nextModifiedLine++
}
}
return lineNumbers
}
@@ -0,0 +1,138 @@
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LocalGitExecOptions } from '../../repositories/github-repository-identity'
const { exec, guard, spend } = vi.hoisted(() => ({ exec: vi.fn(), guard: vi.fn(), spend: vi.fn() }))
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({
ghExecFileAsync: exec
}))
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
githubRepoContext: (
repoPath: string,
connectionId: string | null,
options: LocalGitExecOptions
) => ({ repoPath, connectionId, ...options }),
ghRepoExecOptions: ({ repoPath, ...options }: { repoPath: string }) => ({
cwd: repoPath,
...options
})
}))
vi.mock('../../api/rate-limit', () => ({
repositoryRateLimitGuard: guard,
noteRepositoryRateLimitSpend: spend
}))
import { getPRDetailContext } from './pull-request-detail-context'
const repository = { owner: 'acme', repo: 'widgets', host: 'github.com' }
const participant = { login: 'reviewer', name: null, avatarUrl: 'https://avatars/reviewer' }
const files = {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [{ path: 'src/changed.ts', viewerViewedState: 'VIEWED' }]
}
function response(
pullRequest: Record<string, unknown> | null = { participants: { nodes: [participant] } },
viewedPullRequest: Record<string, unknown> | null = { id: 'PR_42', files },
errors?: Record<string, unknown>[]
) {
return {
stdout: JSON.stringify({ data: { repository: { pullRequest, viewedPullRequest } }, errors })
}
}
function mockReads(reads: ({ stdout: string } | Error | { code: number; stdout: string })[]) {
exec.mockImplementation(async (args: string[]) => {
if (args.includes('graphql')) {
const next = reads.shift()
if (!next) {
throw new Error('Unexpected GraphQL read')
}
if (next instanceof Error || 'code' in next) {
throw next
}
return next
}
throw new Error('Unexpected non-GraphQL read')
})
}
function graphqlCalls(): string[][] {
return exec.mock.calls.flatMap(([args]) => (args.includes('graphql') ? [args] : []))
}
beforeEach(() => {
vi.resetAllMocks()
guard.mockReturnValue({ blocked: false })
})
describe('PR detail context requests', () => {
it('paginates only files after the first page and retains nullable viewer states', async () => {
mockReads([
response(undefined, {
id: 'PR_42',
files: { ...files, pageInfo: { hasNextPage: true, endCursor: 'cursor-1' } }
}),
response(null, {
id: 'PR_42',
files: { ...files, nodes: [null, { path: 'src/unknown.ts', viewerViewedState: null }] }
})
])
const result = await getPRDetailContext('/repo', 42, repository)
expect(result.viewedStates?.viewedStates).toEqual(new Map([['src/changed.ts', 'VIEWED']]))
expect(result.participants).toEqual([participant])
expect(graphqlCalls()).toHaveLength(2)
expect(graphqlCalls()[1]).toContain('after=cursor-1')
expect(graphqlCalls()[1].join(' ')).not.toContain('participants')
})
it.each([
{ path: ['repository', 'pullRequest', 'participants'] },
{ path: ['repository', 'pullRequest'] }
])('retains viewed files when an error nulls the participant PR at $path', async ({ path }) => {
mockReads([{ code: 1, ...response(null, { id: 'PR_42', files }, [{ path }]) }, response()])
const result = await getPRDetailContext('/repo', 42, repository)
expect(result.participants).toEqual([participant])
expect(result.viewedStates?.pullRequestId).toBe('PR_42')
expect(result.viewedStates?.viewedStates.get('src/changed.ts')).toBe('VIEWED')
expect(graphqlCalls()).toHaveLength(2)
expect(graphqlCalls()[1].join(' ')).not.toContain('viewerViewedState')
})
it('retains participants when the viewed PR alias is nulled', async () => {
mockReads([
{
code: 1,
...response(undefined, null, [{ path: ['repository', 'viewedPullRequest', 'id'] }])
}
])
const result = await getPRDetailContext('/repo', 42, repository)
expect(result.participants).toEqual([participant])
expect(result.viewedStates).toBeNull()
expect(graphqlCalls()).toHaveLength(1)
})
it('does not publish partially paginated viewed files after a later page fails', async () => {
mockReads([
response(undefined, {
id: 'PR_42',
files: { ...files, pageInfo: { hasNextPage: true, endCursor: 'next' } }
}),
new Error('file page unavailable')
])
const result = await getPRDetailContext('/repo', 42, repository)
expect(result.participants).toEqual([participant])
expect(result.viewedStates).toBeNull()
expect(graphqlCalls()).toHaveLength(2)
})
it('recovers participants after a failed query without retrying unavailable viewed state', async () => {
mockReads([new Error('Query failed'), response()])
const result = await getPRDetailContext('/repo', 42, repository)
expect(result.participants).toEqual([participant])
expect(result.viewedStates).toBeNull()
expect(graphqlCalls()).toHaveLength(2)
})
})
@@ -0,0 +1,216 @@
import { z } from 'zod'
import type {
GitHubAssignableUser,
GitHubPRFileViewedState
} from '../../../../shared/github/pull-requests/pull-request-types'
import { extractExecError } from '../../../git/command-runner/process-capture/exec-error'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import {
ghRepoExecOptions,
githubRepoContext,
type LocalGitExecOptions
} from '../../repositories/github-repository-identity'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
import { getWorkItemParticipants } from '../../work-items/work-item-participants'
function detailContextQuery(includeContext: boolean): string {
const context = includeContext
? `pullRequest(number: $number) {
participants(first: 100) {
nodes { login avatarUrl(size: 48) ... on User { name } }
}
}`
: ''
return `query($owner: String!, $repo: String!, $number: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
${context}
viewedPullRequest: pullRequest(number: $number) {
id
files(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes { path viewerViewedState }
}
}
}
}`
}
const envelopeSchema = z.object({
data: z
.object({
repository: z
.object({
pullRequest: z.record(z.string(), z.unknown()).nullish(),
viewedPullRequest: z.record(z.string(), z.unknown()).nullish()
})
.nullish()
})
.nullish(),
errors: z
.array(z.object({ path: z.array(z.union([z.string(), z.number()])).optional() }))
.optional()
})
const participantsSchema = z.object({
nodes: z.array(
z.object({ login: z.string(), avatarUrl: z.string(), name: z.string().nullish() }).nullable()
)
})
const filesSchema = z.object({
id: z.string(),
files: z.object({
pageInfo: z.object({ hasNextPage: z.boolean(), endCursor: z.string().nullish() }),
nodes: z.array(
z
.object({
path: z.string(),
viewerViewedState: z.enum(['VIEWED', 'UNVIEWED', 'DISMISSED']).nullable()
})
.nullable()
)
})
})
export type PRFileViewedStatesResult = {
pullRequestId: string
viewedStates: Map<string, GitHubPRFileViewedState>
}
type PRDetailContext = {
participants: GitHubAssignableUser[]
viewedStates: PRFileViewedStatesResult | null
}
function sourceFailed(
envelope: z.infer<typeof envelopeSchema>,
source: string,
alias = 'pullRequest'
): boolean {
return Boolean(
envelope.errors?.some(
({ path }) =>
!path?.length ||
path[0] !== 'repository' ||
(path[1] !== 'pullRequest' && path[1] !== 'viewedPullRequest') ||
(path[1] === alias && (path.length < 3 || path[2] === source))
)
)
}
async function readPRDetailContext(
repoPath: string,
prNumber: number,
repository: GitHubApiRepository | null,
connectionId: string | null | undefined,
localGitOptions: LocalGitExecOptions
): Promise<Partial<PRDetailContext>> {
if (!repository) {
return {}
}
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(repository)
}
const result: Partial<PRDetailContext> = {}
const viewedStates = new Map<string, GitHubPRFileViewedState>()
let after: string | null = null
try {
for (let fetched = 0; fetched < 300; fetched += 100) {
if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) {
break
}
const firstPage = fetched === 0
const args = [
'api',
'graphql',
'-f',
`query=${detailContextQuery(firstPage)}`,
'-f',
`owner=${repository.owner}`,
'-f',
`repo=${repository.repo}`,
'-F',
`number=${prNumber}`
]
if (after) {
args.push('-f', `after=${after}`)
}
noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions)
let stdout: string
try {
stdout = (await ghExecFileAsync(args, ghOptions)).stdout
} catch (error) {
const partial = extractExecError(error).stdout
if (!partial) {
throw error
}
stdout = partial
}
const envelope = envelopeSchema.parse(JSON.parse(stdout))
const pr = envelope.data?.repository?.pullRequest
if (firstPage) {
const participants = participantsSchema.safeParse(pr?.participants)
if (participants.success && !sourceFailed(envelope, 'participants')) {
result.participants = participants.data.nodes.flatMap((user) =>
user?.login ? [{ ...user, name: user.name ?? null }] : []
)
}
}
const files = filesSchema.safeParse(envelope.data?.repository?.viewedPullRequest)
if (
!files.success ||
sourceFailed(envelope, 'files', 'viewedPullRequest') ||
sourceFailed(envelope, 'id', 'viewedPullRequest')
) {
break
}
for (const file of files.data.files.nodes) {
if (file?.path && file.viewerViewedState) {
viewedStates.set(file.path, file.viewerViewedState)
}
}
const page = files.data.files.pageInfo
if (!page.hasNextPage || fetched === 200) {
result.viewedStates = { pullRequestId: files.data.id, viewedStates }
break
}
if (!page.endCursor || page.endCursor === after) {
break
}
after = page.endCursor
}
} catch {
// A failed file page must not discard participants from the first page.
}
return result
}
export async function getPRDetailContext(
repoPath: string,
prNumber: number,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRDetailContext> {
const context = await readPRDetailContext(
repoPath,
prNumber,
repository,
connectionId,
localGitOptions
)
const participants =
context.participants ??
(await getWorkItemParticipants(
repoPath,
{ number: prNumber, type: 'pr' },
repository,
connectionId,
localGitOptions
))
return {
participants,
viewedStates: context.viewedStates ?? null
}
}
@@ -0,0 +1,146 @@
import {
withGitHubRepoExecution,
ghRepoExecOptions,
githubRepoContext,
type LocalGitExecOptions
} from '../../repositories/github-repository-identity'
import type {
GitHubPRFile,
GitHubPRFileContents
} from '../../../../shared/github/pull-requests/pull-request-types'
import { acquire, release } from '../../execution/github-operation-slots'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import { resolveGitHubRepoExecution } from '../../repositories/github-api-repository'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import { isMaxBufferOverflowError } from '../../../git/command-runner/process-capture/max-buffer-overflow'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024
function encodeGitHubContentPath(path: string): string {
return path.split('/').map(encodeURIComponent).join('/')
}
async function fetchContentAtRef(args: {
repoPath: string
connectionId?: string | null
localGitOptions?: LocalGitExecOptions
ownerRepo: GitHubApiRepository
path: string
ref: string
}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)),
...githubHostExecOptions(args.ownerRepo),
maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES
}
if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return { content: '', isBinary: false }
}
try {
noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'300s',
'-H',
'Accept: application/vnd.github.raw',
`repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}`
],
ghOptions
)
const sample = stdout.slice(0, 2048)
if (sample.includes('\u0000')) {
return { content: '', isBinary: true }
}
return { content: stdout, isBinary: false }
} catch (error) {
if (isMaxBufferOverflowError(error)) {
return { content: '', isBinary: false, tooLarge: true }
}
return { content: '', isBinary: false }
}
}
export async function getPRFileContents(args: {
repoPath: string
connectionId?: string | null
localGitOptions?: LocalGitExecOptions
prRepo?: GitHubApiRepository | null
prNumber: number
path: string
oldPath?: string
status: GitHubPRFile['status']
headSha: string
baseSha: string
}): Promise<GitHubPRFileContents> {
return withGitHubRepoExecution(
args.repoPath,
args.connectionId,
args.localGitOptions ?? {},
async () => {
const { ownerRepo } = await resolveGitHubRepoExecution(
args.repoPath,
args.prRepo,
args.connectionId,
args.localGitOptions
)
if (!ownerRepo) {
return {
original: '',
modified: '',
originalIsBinary: false,
modifiedIsBinary: false
}
}
await acquire()
try {
const needsOriginal = args.status !== 'added'
const needsModified = args.status !== 'removed'
const originalPath = args.oldPath ?? args.path
const [original, modified] = await Promise.all([
needsOriginal
? fetchContentAtRef({
repoPath: args.repoPath,
connectionId: args.connectionId,
localGitOptions: args.localGitOptions,
ownerRepo,
path: originalPath,
ref: args.baseSha
})
: Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({
content: '',
isBinary: false
}),
needsModified
? fetchContentAtRef({
repoPath: args.repoPath,
connectionId: args.connectionId,
localGitOptions: args.localGitOptions,
ownerRepo,
path: args.path,
ref: args.headSha
})
: Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({
content: '',
isBinary: false
})
])
return {
original: original.content,
modified: modified.content,
originalIsBinary: original.isBinary,
modifiedIsBinary: modified.isBinary,
originalTooLarge: original.tooLarge,
modifiedTooLarge: modified.tooLarge
}
} finally {
release()
}
}
)
}
@@ -0,0 +1,167 @@
import { GITHUB_BODY_MEDIA_TYPE, githubBodyImageUrls } from '../../attachments/body-image-urls'
import type { GitHubPRFile } from '../../../../shared/github/pull-requests/pull-request-types'
import type { PRFileViewedStatesResult } from './pull-request-detail-context'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import {
ghRepoExecOptions,
githubRepoContext,
type LocalGitExecOptions
} from '../../repositories/github-repository-identity'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
const MAX_PR_FILES = 300
type RESTPRFile = {
filename: string
previous_filename?: string
status: string
additions: number
deletions: number
changes: number
patch?: string
}
function mapFileStatus(raw: string): GitHubPRFile['status'] {
switch (raw) {
case 'added':
return 'added'
case 'removed':
return 'removed'
case 'modified':
return 'modified'
case 'renamed':
return 'renamed'
case 'copied':
return 'copied'
case 'changed':
return 'changed'
case 'unchanged':
return 'unchanged'
default:
return 'modified'
}
}
function isBinaryHint(file: RESTPRFile): boolean {
return file.patch === undefined && file.changes > 0
}
export async function getPRMetadata(
repoPath: string,
prNumber: number,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<{
body: string
bodyImageUrls?: Record<string, string>
headSha?: string
baseSha?: string
}> {
if (!repository) {
return { body: '' }
}
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(repository)
}
if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) {
return { body: '' }
}
try {
noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'-H',
GITHUB_BODY_MEDIA_TYPE,
`repos/${repository.owner}/${repository.repo}/pulls/${prNumber}`
],
ghOptions
)
const data: {
body?: string | null
body_html?: unknown
head?: { sha?: string }
base?: { sha?: string }
} = JSON.parse(stdout)
const metadata = {
body: data.body ?? '',
bodyImageUrls: githubBodyImageUrls(data.body, data.body_html),
...(data.head?.sha ? { headSha: data.head.sha } : {}),
...(data.base?.sha ? { baseSha: data.base.sha } : {})
}
return metadata
} catch {
return { body: '' }
}
}
// null means unavailable; [] means a successfully fetched empty PR.
export async function getPRFiles(
repoPath: string,
prNumber: number,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubPRFile[] | null> {
if (!repository) {
return null
}
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(repository)
}
try {
const data: RESTPRFile[] = []
for (let page = 1; data.length < MAX_PR_FILES; page += 1) {
if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) {
return null
}
const pageSuffix = page === 1 ? '' : `&page=${page}`
noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${repository.owner}/${repository.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}`
],
ghOptions
)
const pageData = JSON.parse(stdout) as RESTPRFile[]
data.push(...pageData.slice(0, MAX_PR_FILES - data.length))
if (pageData.length < 100) {
break
}
}
return data.map((file) => ({
path: file.filename,
oldPath: file.previous_filename,
status: mapFileStatus(file.status),
additions: file.additions,
deletions: file.deletions,
isBinary: isBinaryHint(file),
reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch)
}))
} catch {
return null
}
}
export function mergePRFileViewedStates(
files: GitHubPRFile[],
viewedStates: PRFileViewedStatesResult | null
): GitHubPRFile[] {
if (!viewedStates) {
return files
}
return files.map((file) => ({
...file,
viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED'
}))
}
@@ -0,0 +1,84 @@
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { codeHostReadExecutionScope as githubReadExecutionScope } from '../../../source-control/code-host-read-execution-scope'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import { githubRepoIdentityKey } from '../../../../shared/github/repositories/repository-identity-key'
import type { GitHubRepoExecOptions } from '../../repositories/github-api-repository'
import {
type MergedReviewCommitMembership as MergedPRCommitMembership,
MergedReviewMembershipMemo
} from '../../../source-control/merged-review-membership-memo'
import type { GitHubRepositoryIdentity as OwnerRepo } from '../../../../shared/github/repositories/repository-identity'
const COMMIT_PULLS_PAGE_SIZE = 100
const COMMIT_PULLS_MAX_PAGES = 5
const membershipMemo = new MergedReviewMembershipMemo()
export function resetMergedPRCommitMembershipCacheForTest(): void {
membershipMemo.clear()
}
/** Check PR-history membership rather than branch-name reuse; failures return unknown. */
export async function isCommitPartOfMergedPR(args: {
ownerRepo: OwnerRepo
prNumber: number
commitOid: string
ghOptions: GitHubRepoExecOptions
}): Promise<MergedPRCommitMembership> {
const oid = args.commitOid.trim().toLowerCase()
if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) {
return 'unknown'
}
const owner = args.ownerRepo.owner
const repo = args.ownerRepo.repo
const ghOptions = { ...args.ghOptions, ...githubHostExecOptions(args.ownerRepo) }
const cacheKey = `${githubReadExecutionScope(ghOptions)}\0${githubRepoIdentityKey(args.ownerRepo)}#${args.prNumber}@${oid}`
const cached = membershipMemo.peek(cacheKey)
if (cached !== undefined) {
return cached
}
// An unrun probe cannot justify clearing a durable linked PR.
if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return 'unknown'
}
return membershipMemo.read(cacheKey, async () => {
try {
// Paginate until found or a short page proves absence; a full final page leaves membership unknown.
for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) {
if (page > 1 && repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) {
return 'unknown'
}
noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
`repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}`
],
ghOptions
)
const parsed = JSON.parse(stdout) as unknown
// Non-array success is unverified, not evidence that a durable link should be cleared.
if (!Array.isArray(parsed)) {
return 'unknown'
}
const entries = parsed
const contained = entries.some(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as { number?: unknown }).number === args.prNumber
)
if (contained) {
return 'contained'
}
if (entries.length < COMMIT_PULLS_PAGE_SIZE) {
return 'not-contained'
}
}
return 'unknown'
} catch {
// A local commit can become queryable after push; preserve links until verification succeeds.
return 'unknown'
}
})
}
@@ -0,0 +1,10 @@
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { invalidateHostedReviewBranchCache } from '../../../source-control/hosted-review-branch-cache'
// A post-mutation refresh must not join an older lookup or reuse its cached answer.
export function invalidateReviewLookupsAfterPRMutation(
repoPath: string,
connectionId: string | null | undefined
): void {
invalidateHostedReviewBranchCache(repoPath, getRepoExecutionHostId({ connectionId }))
}
@@ -0,0 +1,219 @@
import { shouldAcceptMergedFallbackPR } from '../../../../shared/github/pull-requests/refresh/pr-branch-fallback-rule'
import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
GitHubPRRefreshSkippedReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { GitHubPRBranchLookupOptions } from '../../client/lookup/pull-request-lookup-data'
import { PR_CHECK_PAGE_LIMIT } from '../../client/check/pr-check-rollup'
import { parsePRKey } from '../../pr-watch/pr-watch-types'
export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500
export const POST_PUSH_DELAY_MS = 2_500
type PRBranchLookupCandidate = Pick<
GitHubPRRefreshCandidate,
'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid'
>
export function hostedReviewOptionArgs(
candidate: PRBranchLookupCandidate,
reason: GitHubPRRefreshReason = 'visible',
requestOptions: { userRefresh?: boolean; watchPRKey?: string } = {}
): [] | [GitHubPRBranchLookupOptions] {
const options: GitHubPRBranchLookupOptions =
requestOptions.userRefresh === true ? { userRefresh: true } : {}
options.localGitExecOptions = {
...(candidate.localGitOptions?.wslDistro
? { wslDistro: candidate.localGitOptions.wslDistro }
: {}),
admissionTier: admissionTierForRefreshReason(reason)
}
if (shouldAcceptMergedFallbackPR(candidate)) {
options.acceptMergedFallbackPR = true
}
if (reason === 'watch') {
options.includeDerivedData = false
options.checkPageLimit = PR_CHECK_PAGE_LIMIT
// A watch knows its PR's repository; probing another remote by number could read another PR.
const pr = requestOptions.watchPRKey ? parsePRKey(requestOptions.watchPRKey) : null
if (pr) {
options.exactRepository = {
owner: pr.owner,
repo: pr.repo,
...(pr.host === 'github.com' ? {} : { host: pr.host })
}
}
}
if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) {
options.currentHeadOid = candidate.currentHeadOid.trim()
}
return Object.keys(options).length > 0 ? [options] : []
}
export function admissionTierForRefreshReason(
reason: GitHubPRRefreshReason
): 'interactive' | 'background' {
return reason === 'manual' ? 'interactive' : 'background'
}
export function refreshKey(candidate: GitHubPRRefreshCandidate): string {
const connectionScope = candidate.connectionId ?? 'local'
const runtimeScope = candidate.connectionId
? 'remote'
: `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}`
if (typeof candidate.linkedPRNumber === 'number') {
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}`
}
return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}`
}
export function validateCandidate(
candidate: GitHubPRRefreshCandidate
): GitHubPRRefreshSkippedReason | null {
if (candidate.repoKind !== 'git') {
return 'not-git'
}
if (candidate.isBare) {
return 'bare'
}
if (candidate.isArchived) {
return 'archived'
}
if (candidate.connectionId && candidate.connectionState === 'disconnected') {
return 'disconnected'
}
if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') {
return 'fresh'
}
return null
}
export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean {
return reason === 'manual' || reason === 'active' || reason === 'post-push'
}
export function isBackground(reason: GitHubPRRefreshReason): boolean {
return reason !== 'manual'
}
export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean {
return reason === 'visible' || reason === 'swr' || reason === 'watch'
}
export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean {
if (isBudgetedBackground(reason)) {
return false
}
const delay = dueAt - Date.now()
return delay > 0 && delay <= 5_000
}
export function shouldSkipFresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason
): boolean {
if (
bypassesFreshnessDelay(reason) ||
candidate.cachedFetchedAt == null ||
hasStaleHead(candidate)
) {
return false
}
return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate)
}
export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null {
return candidate.cachedFetchedAt == null || hasStaleHead(candidate)
? null
: candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate)
}
export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias {
return {
cacheKey: candidate.cacheKey,
repoId: candidate.repoId,
repoPath: candidate.repoPath,
branch: candidate.branch,
worktreeId: candidate.worktreeId,
connectionId: candidate.connectionId ?? null,
currentHeadOid: candidate.currentHeadOid ?? null,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber:
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null
}
}
export function visibleCandidateAfterOutcome(
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome
): GitHubPRRefreshCandidate {
if (outcome.kind === 'upstream-error') {
return candidate
}
return {
...candidate,
cachedFetchedAt: outcome.fetchedAt,
cachedHeadOid: candidate.currentHeadOid ?? null,
cachedHasPR: outcome.kind === 'found',
cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null,
cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null,
cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null,
cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null
}
}
export function hasStaleHead(candidate: GitHubPRRefreshCandidate): boolean {
return (
candidate.currentHeadOid != null &&
candidate.cachedHeadOid != null &&
candidate.currentHeadOid !== candidate.cachedHeadOid
)
}
export function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number {
return (
reviewRefreshIntervalMs({
state: candidate.cachedPRState,
checksStatus: candidate.cachedChecksStatus,
hasReview: candidate.cachedHasPR,
selected: candidate.isSelected
}) ?? Number.POSITIVE_INFINITY
)
}
function hasResolvedMergeStateStatus(status: string | null | undefined): boolean {
return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED'
}
export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean {
return (
outcome.kind === 'found' &&
outcome.pr.state === 'open' &&
outcome.pr.mergeable === 'UNKNOWN' &&
!hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus)
)
}
export function sameAliasRequestIdentity(
left: GitHubPRRefreshAlias,
right: GitHubPRRefreshAlias
): boolean {
return (
left.cacheKey === right.cacheKey &&
left.repoId === right.repoId &&
left.repoPath === right.repoPath &&
left.branch === right.branch &&
left.worktreeId === right.worktreeId &&
left.connectionId === right.connectionId &&
left.executionHostId === right.executionHostId &&
left.linkedPRNumber === right.linkedPRNumber &&
left.fallbackPRNumber === right.fallbackPRNumber &&
left.fallbackPRSource === right.fallbackPRSource &&
left.currentHeadOid === right.currentHeadOid
)
}
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('pr-refresh-coordinator', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
})
afterEach(() => {
vi.useRealTimers()
})
it('preserves an active refresh queued while a visible refresh is in flight', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
const visibleOutcome = deferred<{
kind: 'found'
pr: PRInfo
fetchedAt: number
}>()
getPRForBranchOutcomeMock.mockReturnValueOnce(visibleOutcome.promise).mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success', state: 'merged' }),
fetchedAt: Date.now()
})
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh({ ...candidate, cachedFetchedAt: Date.now() }, 'active', 80, 1)
visibleOutcome.resolve({
kind: 'found',
pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }),
fetchedAt: Date.now()
})
await vi.advanceTimersByTimeAsync(0)
const inFlightEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'in-flight')
expect(inFlightEvents.map((event) => event.reason)).toEqual(['visible', 'active'])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { sendMock, getPRForBranchOutcomeMock } = coordinatorMocks
describe('pr-refresh-coordinator', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
})
afterEach(() => {
vi.useRealTimers()
})
it('cancels queued work when a later enqueue marks the candidate invalid', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
const candidate = makeCandidate()
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh(
{ ...candidate, isArchived: true, cachedFetchedAt: Date.now() },
'active',
80,
1
)
await vi.advanceTimersByTimeAsync(10 * 60_000)
const skippedEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'skipped')
expect(skippedEvents.at(-1)?.skippedReason).toBe('archived')
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
})
it('does not cancel other aliases when one coalesced PR alias becomes invalid', async () => {
const { enqueuePRRefresh, reportVisiblePRRefreshCandidates } =
await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success', state: 'merged' }),
fetchedAt: Date.now()
})
const first = makeCandidate({
cacheKey: '/repo::feature/a',
branch: 'feature/a',
linkedPRNumber: 12,
worktreeId: 'wt-a'
})
const second = makeCandidate({
cacheKey: '/repo::feature/b',
branch: 'feature/b',
linkedPRNumber: 12,
worktreeId: 'wt-b'
})
reportVisiblePRRefreshCandidates([first, second], 1, 1)
await vi.advanceTimersByTimeAsync(0)
enqueuePRRefresh({ ...first, isArchived: true, cachedFetchedAt: Date.now() }, 'active', 80, 1)
enqueuePRRefresh({ ...second, cachedFetchedAt: Date.now() }, 'active', 80, 1)
await vi.advanceTimersByTimeAsync(0)
const outcomeEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.outcome)
expect(outcomeEvents.at(-1)?.aliases.map((alias) => alias.cacheKey)).toEqual([
'/repo::feature/b'
])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
it('preserves coalesced aliases across visible follow-up refreshes', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'pending', mergeable: 'MERGEABLE' }),
fetchedAt: Date.now()
})
.mockResolvedValueOnce({
kind: 'found',
pr: makePR({ checksStatus: 'success' }),
fetchedAt: Date.now()
})
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: '/repo::feature/a',
branch: 'feature/a',
linkedPRNumber: 12,
worktreeId: 'wt-a'
}),
makeCandidate({
cacheKey: '/repo::feature/b',
branch: 'feature/b',
linkedPRNumber: 12,
worktreeId: 'wt-b'
})
],
1,
1
)
await vi.runOnlyPendingTimersAsync()
await vi.advanceTimersByTimeAsync(120_000)
const outcomeEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.outcome)
expect(outcomeEvents).toHaveLength(2)
expect(outcomeEvents[1].aliases.map((alias) => alias.cacheKey).sort()).toEqual([
'/repo::feature/a',
'/repo::feature/b'
])
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
const { getPRForBranchOutcomeMock } = coordinatorMocks
function found(state: 'open' | 'closed' | 'merged'): PRRefreshOutcome {
return { kind: 'found', pr: makePR({ state, checksStatus: 'success' }), fetchedAt: Date.now() }
}
describe('main refresh completion ordering', () => {
beforeEach(() => moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks))
afterEach(() => vi.useRealTimers())
it('preserves a newer failure gate when an older successful lookup completes', async () => {
let finishOlder: (outcome: PRRefreshOutcome) => void = () => {}
getPRForBranchOutcomeMock
.mockImplementationOnce(
() => new Promise<PRRefreshOutcome>((resolve) => (finishOlder = resolve))
)
.mockImplementation(async () => ({
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'wait',
fetchedAt: Date.now(),
retryDisabledUntil: Date.now() + 300_000
}))
const { reportVisiblePRRefreshCandidates, refreshPRNow } =
await import('./pr-refresh-coordinator')
const candidate = makeCandidate({ isSelected: true })
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
await refreshPRNow(candidate)
finishOlder(found('open'))
await vi.advanceTimersByTimeAsync(60_000)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
await expect(refreshPRNow(candidate)).resolves.toMatchObject({ errorType: 'rate_limited' })
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,31 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
export function makeCandidate(
overrides: Partial<GitHubPRRefreshCandidate> = {}
): GitHubPRRefreshCandidate {
return {
cacheKey: '/repo::feature/test',
repoPath: '/repo',
branch: 'feature/test',
repoKind: 'git',
repoId: 'repo-1',
worktreeId: 'wt-1',
cachedFetchedAt: null,
...overrides
}
}
export function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 12,
title: 'Test PR',
state: 'open',
url: 'https://github.com/acme/repo/pull/12',
checksStatus: 'pending',
updatedAt: '2026-05-12T00:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'head-sha',
...overrides
}
}
@@ -0,0 +1,141 @@
import type { PRRefreshRendererPort } from './pr-refresh-renderer-port'
import { vi, type Mock } from 'vitest'
import { isWslUncPath } from '../../../../shared/wsl-paths'
type PRRefreshWebContentsMock = {
id: number
isDestroyed: () => boolean
send?: Mock
}
export type PRRefreshCoordinatorMocks = {
sendMock: Mock
sendToTrustedUIRendererMock: Mock
getAllWebContentsMock: Mock<() => PRRefreshWebContentsMock[]>
webContentsFromIdMock: Mock<(id: number) => PRRefreshWebContentsMock | undefined>
getPRForBranchOutcomeMock: Mock
getOriginGitHubApiRepositoryMock: Mock
getRateLimitMock: Mock
noteRepositoryRateLimitSpendMock: Mock
repositoryRateLimitGuardMock: Mock
spendsSharedGitHubComQuotaMock: Mock
}
// Vitest hoists per file; suites register these shared factories locally.
export function createPRRefreshCoordinatorMocks(): PRRefreshCoordinatorMocks {
return {
sendMock: vi.fn(),
sendToTrustedUIRendererMock: vi.fn(),
getAllWebContentsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
getPRForBranchOutcomeMock: vi.fn(),
getOriginGitHubApiRepositoryMock: vi.fn(),
getRateLimitMock: vi.fn(),
noteRepositoryRateLimitSpendMock: vi.fn(),
repositoryRateLimitGuardMock: vi.fn(),
spendsSharedGitHubComQuotaMock: vi.fn()
}
}
export type RendererPortModuleMock = { prRefreshRenderers: () => PRRefreshRendererPort }
/** Renderers are the mocked web contents; refresh events reach `sendToTrustedUIRendererMock`. */
export function rendererPortModuleMock(mocks: PRRefreshCoordinatorMocks): RendererPortModuleMock {
const port: PRRefreshRendererPort = {
isRendererOpen: (id) => {
const contents = mocks.webContentsFromIdMock(id)
return contents !== undefined && !contents.isDestroyed()
},
sendToTrustedUI: (channel, payload) => mocks.sendToTrustedUIRendererMock(channel, payload)
}
return { prRefreshRenderers: () => port }
}
export type ClientModuleMock = { getPRForBranchOutcome: Mock }
export function clientModuleMock(mocks: PRRefreshCoordinatorMocks): ClientModuleMock {
return {
getPRForBranchOutcome: mocks.getPRForBranchOutcomeMock
}
}
export type GithubApiRepositoryModuleMock = { getOriginGitHubApiRepository: Mock }
export function githubApiRepositoryModuleMock(
mocks: PRRefreshCoordinatorMocks
): GithubApiRepositoryModuleMock {
return {
getOriginGitHubApiRepository: mocks.getOriginGitHubApiRepositoryMock
}
}
export type RateLimitModuleMock = {
getRateLimit: Mock
noteRepositoryRateLimitSpend: Mock
repositoryRateLimitGuard: Mock
spendsSharedGitHubComQuota: Mock
}
export function rateLimitModuleMock(mocks: PRRefreshCoordinatorMocks): RateLimitModuleMock {
return {
getRateLimit: mocks.getRateLimitMock,
noteRepositoryRateLimitSpend: mocks.noteRepositoryRateLimitSpendMock,
repositoryRateLimitGuard: mocks.repositoryRateLimitGuardMock,
spendsSharedGitHubComQuota: mocks.spendsSharedGitHubComQuotaMock
}
}
// Module queues and timers must not leak pending work between tests.
export function resetPRRefreshCoordinatorMocks(mocks: PRRefreshCoordinatorMocks): void {
const {
sendMock,
sendToTrustedUIRendererMock,
getAllWebContentsMock,
webContentsFromIdMock,
getPRForBranchOutcomeMock,
getOriginGitHubApiRepositoryMock,
getRateLimitMock,
noteRepositoryRateLimitSpendMock,
repositoryRateLimitGuardMock,
spendsSharedGitHubComQuotaMock
} = mocks
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(1_000)
sendMock.mockReset()
sendToTrustedUIRendererMock.mockReset()
sendToTrustedUIRendererMock.mockImplementation((channel, payload) => {
sendMock(channel, payload)
})
getAllWebContentsMock.mockReset()
webContentsFromIdMock.mockReset()
webContentsFromIdMock.mockImplementation((id) =>
getAllWebContentsMock().find((contents) => contents.id === id)
)
getPRForBranchOutcomeMock.mockReset()
getOriginGitHubApiRepositoryMock.mockReset()
getOriginGitHubApiRepositoryMock.mockResolvedValue({
owner: 'acme',
repo: 'widgets',
host: 'github.com'
})
getRateLimitMock.mockReset()
noteRepositoryRateLimitSpendMock.mockReset()
repositoryRateLimitGuardMock.mockReset()
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
spendsSharedGitHubComQuotaMock.mockReset()
spendsSharedGitHubComQuotaMock.mockImplementation(
(repository: { host?: string } | null, options?: { cwd?: string; wslDistro?: string }) =>
(!repository?.host || repository.host.toLowerCase() === 'github.com') &&
!options?.wslDistro &&
!(options?.cwd && isWslUncPath(options.cwd))
)
getAllWebContentsMock.mockReturnValue([
{
id: 1,
isDestroyed: () => false,
send: sendMock
}
])
getRateLimitMock.mockResolvedValue({ ok: true })
}
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WslUncAccessGateModule from '../../../wsl/filesystem/wsl-unc-access-gate'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
const gate = vi.hoisted(() => ({
stopped: true,
blockedWslDistroForPath: vi.fn(),
wslDistroForPath: vi.fn()
}))
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../../../wsl/filesystem/wsl-unc-access-gate', async (importOriginal) => ({
...(await importOriginal<typeof WslUncAccessGateModule>()),
blockedWslDistroForPath: gate.blockedWslDistroForPath,
wslDistroForPath: gate.wslDistroForPath
}))
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
const WSL_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
describe('PR refresh for a stopped WSL distro (E9)', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 })
gate.blockedWslDistroForPath.mockReset()
gate.wslDistroForPath.mockReset()
gate.wslDistroForPath.mockImplementation((path: string) =>
path.startsWith('\\\\wsl.localhost\\') ? 'Ubuntu' : null
)
gate.blockedWslDistroForPath.mockImplementation(async () =>
gate.stopped ? { distro: 'Ubuntu', state: 'stopped' } : null
)
})
afterEach(() => {
gate.stopped = true
vi.useRealTimers()
})
it("drains outside a user act that scheduled it, so a stopped distro's queue is not admitted", async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const { isWslUserActFor, runAsWslUserAct } =
await import('../../../wsl/discovery/wsl-user-act-scope')
const drainedInsideAct: boolean[] = []
gate.blockedWslDistroForPath.mockImplementation(async () => {
drainedInsideAct.push(isWslUserActFor('Ubuntu'))
return { distro: 'Ubuntu', state: 'stopped' }
})
const candidate = makeCandidate({
repoPath: WSL_REPO,
cacheKey: `${WSL_REPO}::feature/act`,
isSelected: true
})
// The drain timer is armed and fires while the act still holds.
await runAsWslUserAct('Ubuntu', async () => {
reportVisiblePRRefreshCandidates([candidate], 1, 1)
await vi.advanceTimersByTimeAsync(0)
})
expect(drainedInsideAct).toEqual([false])
expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,287 @@
import type {
GitHubPRRefreshCandidate,
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome'
import {
aliasFromCandidate,
hostedReviewOptionArgs,
isBackground,
MANUAL_MERGEABILITY_PENDING_REFRESH_MS,
refreshKey,
shouldBroadcastQueued,
validateCandidate
} from './pr-refresh-candidate-policy'
import {
PRRefreshEventPublisher,
type PRRefreshOutcomeObserver
} from './pr-refresh-event-publisher'
import { PRRefreshPacing } from './pr-refresh-pacing'
import { PRRefreshQueue } from './pr-refresh-queue'
import { PRRefreshQueueDrainer, type PRRefreshRegistrationCheck } from './pr-refresh-queue-drainer'
import {
prWatchQueueKey,
schedulePRWatchEntry,
type PRWatchJob,
type PRWatchJobHooks
} from './pr-refresh-watch-jobs'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import { PRRefreshRetryState } from './pr-refresh-retry-state'
import { PRRefreshVisibility } from './pr-refresh-visibility'
import {
blockedWslDistroForPath,
wslDistroForPath
} from '../../../wsl/filesystem/wsl-unc-access-gate'
const retry = new PRRefreshRetryState()
const queue = new PRRefreshQueue((key) => retry.reset(key))
const pacing = new PRRefreshPacing()
const visibility = new PRRefreshVisibility()
const events = new PRRefreshEventPublisher()
const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events)
export function addPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver): () => void {
return events.addOutcomeObserver(observer)
}
export function setPRRefreshRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
drainer.setRegistrationCheck(check)
}
export function setPRWatchJobHooks(hooks: PRWatchJobHooks | null): void {
drainer.watchJobs.setHooks(hooks)
}
/** Queue a watch read; `defer` only moves a queued one later, as another surface just read it. */
export function schedulePRWatchRead(
job: PRWatchJob,
candidate: GitHubPRRefreshCandidate,
dueAt: number,
options: { defer?: boolean } = {}
): void {
schedulePRWatchEntry(queue, job, candidate, dueAt, options.defer === true)
drainer.schedule()
}
export function cancelPRWatchRead(job: PRWatchJob): void {
queue.delete(prWatchQueueKey(job))
}
/** A read a paired server made for a client reaches every observer. It takes no queue ownership,
* so a desktop read in flight for the same checkout keeps its follow-up. */
export async function runHostedPRRead(
candidate: GitHubPRRefreshCandidate,
read: () => Promise<PRRefreshOutcome>
): Promise<PRRefreshOutcome> {
const outcome = await read()
events.observe(candidate, outcome)
return outcome
}
function removeInvisibleVisibleRefreshes(): void {
const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key))
for (const entry of removed) {
events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: 'visible',
status: 'skipped',
skippedReason: 'fresh'
})
}
}
export function clearVisiblePRRefreshWindow(windowId: number): void {
const hadVisibleRefreshes = visibility.clearWindow(windowId)
pacing.clearActiveBurstWindow(windowId)
if (hadVisibleRefreshes) {
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
drainer.schedule()
}
}
export function pruneWorktreePRRefreshAliases(worktreeId: string): void {
queue.pruneWorktreeAliases(worktreeId)
}
export function enqueuePRRefresh(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason,
priority = 0,
windowId?: number
): void {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
events.record('skipped', reason, skippedReason)
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return
}
const enqueued = queue.enqueue(candidate, reason, priority, windowId)
const pending = queue.get(key)
if (pending && reason === 'visible' && candidate.isSelected && priority >= 80) {
// Foreground exposure keeps retry gates while bypassing the list budget once.
pending.bypassBackgroundBudget = true
}
events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason)
if (shouldBroadcastQueued(reason, enqueued.dueAt)) {
events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' })
}
drainer.schedule()
}
export function reportVisiblePRRefreshCandidates(
candidates: GitHubPRRefreshCandidate[],
generation: number,
windowId: number
): void {
const newlyExposed = new Set(candidates.map(refreshKey).filter((key) => !visibility.has(key)))
if (!visibility.report(candidates, generation, windowId)) {
return
}
removeInvisibleVisibleRefreshes()
queue.retimeVisible((key, candidate) => visibility.candidate(key, candidate))
for (const candidate of candidates) {
const key = refreshKey(candidate)
if (validateCandidate(candidate)) {
enqueuePRRefresh(candidate, 'visible', 40, windowId)
continue
}
queue.enqueue(
visibility.candidate(key, candidate),
'visible',
40,
windowId,
newlyExposed.has(key)
)
}
drainer.schedule()
}
export function _getVisiblePRRefreshWindowCountForTests(): number {
return visibility.windowCount
}
export function _getPRRefreshErrorBackoffCountForTests(): number {
return retry.errorBackoffCount
}
export function _getPRRefreshQueueSizeForTests(): number {
return queue.size
}
export function _getPRRefreshAliasCountForTests(key: string): number {
return queue.aliasCount(key)
}
export async function refreshPRNow(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason = 'manual',
options: { userRefresh?: boolean } = {}
): Promise<PRRefreshOutcome> {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = queue.get(key)
const aliasMap = new Map(existing ? existing.aliases : [])
aliasMap.set(alias.cacheKey, alias)
const aliases = Array.from(aliasMap.values())
const skippedReason = validateCandidate(candidate)
if (skippedReason) {
queue.removeInvalidAlias(key, alias)
const outcome: PRRefreshOutcome = {
kind: 'upstream-error',
errorType: 'unknown',
message: `Cannot refresh PR for this worktree: ${skippedReason}`,
fetchedAt: Date.now()
}
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason })
return outcome
}
// E9: renderer fallbacks reach here with automatic reasons; only a manual refresh may start a stopped distro.
if (
isBackground(reason) &&
wslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro) &&
(await blockedWslDistroForPath(candidate.repoPath, candidate.localGitOptions?.wslDistro))
) {
events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason: 'fresh' })
// Local callers keep their cached PR on an upstream error; nothing is recorded or retried here.
return {
kind: 'upstream-error',
errorType: 'unknown',
message: 'WSL distro is not running',
fetchedAt: Date.now()
}
}
const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false)
const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key))
if (gateUntil > Date.now()) {
queue.protectBackgroundUntil(key, gateUntil)
queue.set(key, {
key,
candidate,
aliases: aliasMap,
reason,
priority: 40,
dueAt: gateUntil,
queuedAt: queue.nextOrder()
})
events.broadcast({
aliases,
reason,
status: 'paused',
pausedUntil: gateUntil,
skippedReason: 'rate-limit'
})
drainer.schedule(Math.max(1_000, gateUntil - Date.now()))
return {
kind: 'upstream-error',
errorType: 'rate_limited',
message: 'GitHub is temporarily limiting requests. Try again after the limit resets.',
fetchedAt: Date.now(),
nextAutoRetryAt: gateUntil,
retryDisabledUntil: gateUntil
}
}
queue.delete(key)
const requestSequence = events.nextSequence()
const requestStartedAt = Date.now()
queue.noteRequestStarted(key, requestSequence)
events.broadcast({ aliases, reason, status: 'in-flight', requestStartedAt }, requestSequence)
const outcome = await getPRForBranchOutcome(
candidate.repoPath,
candidate.branch,
candidate.linkedPRNumber ?? null,
candidate.connectionId ?? null,
candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(candidate, reason, options)
)
if (!queue.ownsRequest(key, requestSequence)) {
events.broadcast({ aliases, reason, outcome, requestStartedAt }, requestSequence)
return outcome
}
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
if (outcome.kind === 'upstream-error' && visibility.has(key)) {
plannedRetryAt = retry.nextVisibleErrorRetryAt(key, visibility.candidate(key, candidate))
broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt)
}
events.observe(candidate, outcome, aliases)
retry.noteManualGate(key, broadcastOutcome)
events.broadcast(
{ aliases, reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, {
plannedRetryAt,
...(reason === 'manual'
? { pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }
: {})
})
return broadcastOutcome
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { classifyPRRefreshError } from './pr-refresh-error-classification'
describe('classifyPRRefreshError', () => {
it('classifies from the real runner error shape where the signal is on .stderr', () => {
// The runner stores diagnostics on stderr even when message is only a generic exit error.
const permission = Object.assign(new Error('gh exited with 1.'), {
stderr: 'HTTP 403: Resource not accessible by integration'
})
expect(classifyPRRefreshError(permission)).toBe('permission')
const repo = Object.assign(new Error('gh exited with 1.'), {
stderr: Buffer.from('HTTP 404: Not Found')
})
expect(classifyPRRefreshError(repo)).toBe('repo_unavailable')
const secondary = Object.assign(new Error('gh exited with 1.'), {
stderr: 'You have exceeded a secondary rate limit'
})
expect(classifyPRRefreshError(secondary)).toBe('rate_limited')
const auth = Object.assign(new Error('gh exited with 1.'), {
stdout: '',
stderr: 'gh auth login required: bad credentials'
})
expect(classifyPRRefreshError(auth)).toBe('auth')
})
})
@@ -0,0 +1,115 @@
import type { PRRefreshErrorType } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { classifyGitHubUnavailable } from '../../../../shared/github/api/api-availability'
import { extractExecError } from '../../../git/command-runner/process-capture/exec-error'
/** Classify runner failures before UI rendering; rate-limit 403s precede permissions, and repo 404s precede network heuristics. */
export function classifyPRRefreshError(err: unknown): PRRefreshErrorType {
// Runner diagnostics may live only on stdout/stderr; retain structured spawn codes too.
const { stderr, stdout } = extractExecError(err)
const message = err instanceof Error ? err.message : String(err)
const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase()
const code =
err && typeof err === 'object' && 'code' in err
? String((err as { code?: unknown }).code ?? '').toLowerCase()
: ''
// Secondary limits may mention abuse or Retry-After without the phrase rate limit.
const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests')
const isHttp403 = lower.includes('http 403')
const hasRetryAfter = lower.includes('retry-after')
if (
isHttp429 ||
lower.includes('secondary rate limit') ||
lower.includes('abuse detection') ||
lower.includes('abuse-rate-limits') ||
lower.includes('you have triggered an abuse') ||
((isHttp403 || isHttp429) && hasRetryAfter) ||
lower.includes('api rate limit exceeded') ||
lower.includes('rate limit')
) {
return 'rate_limited'
}
// Match repository failures before incidental connectivity wording.
if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) {
return 'repo_unavailable'
}
const unavailable = classifyGitHubUnavailable(lower)
if (unavailable) {
return unavailable
}
// Require structured codes or full phrases; a repository named network-* is not a network failure.
const networkCodes = new Set([
'etimedout',
'econnreset',
'econnrefused',
'enotfound',
'eai_again',
'enetunreach',
'enetdown'
])
if (
networkCodes.has(code) ||
lower.includes('etimedout') ||
lower.includes('econnreset') ||
lower.includes('econnrefused') ||
lower.includes('enotfound') ||
lower.includes('eai_again') ||
lower.includes('timed out') ||
lower.includes('timeout') ||
lower.includes('could not resolve host') ||
lower.includes('no such host') ||
lower.includes('network is unreachable') ||
lower.includes('network is down') ||
lower.includes('network error') ||
lower.includes('connection refused') ||
lower.includes('connection reset')
) {
return 'network'
}
if (isHttp403 || lower.includes('resource not accessible')) {
return 'permission'
}
// Prefer structured launch codes so paths containing gh cannot imply a missing executable.
if (
code === 'enoent' ||
lower.includes('spawn gh enoent') ||
lower.includes('gh: command not found') ||
lower.includes("'gh' is not recognized")
) {
return 'gh_unavailable'
}
// Full auth phrases avoid matching author/authored.
if (
lower.includes('http 401') ||
lower.includes('unauthorized') ||
lower.includes('authorization') ||
lower.includes('authentication') ||
lower.includes('bad credentials') ||
lower.includes('gh auth') ||
/\b(login|credentials?)\b/i.test(lower)
) {
return 'auth'
}
return 'unknown'
}
export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string {
switch (errorType) {
case 'rate_limited':
return 'GitHub rate limit is low. Try again after the limit resets.'
case 'auth':
return 'GitHub authentication is unavailable. Check your gh login.'
case 'network':
return 'GitHub is unreachable right now. Check your network and try again.'
case 'server_error':
return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue."
case 'permission':
return 'GitHub did not allow access to this pull request.'
case 'repo_unavailable':
return 'The GitHub repository is unavailable or cannot be resolved.'
case 'gh_unavailable':
return 'GitHub CLI is unavailable.'
case 'unknown':
return 'GitHub pull request refresh failed.'
}
}
@@ -0,0 +1,86 @@
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshEvent,
GitHubPRRefreshReason,
GitHubPRRefreshSkippedReason,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { recordCoalescedCrashBreadcrumb } from '../../../crash-reporting/breadcrumbs/crash-breadcrumb-store'
import { prRefreshRenderers } from './pr-refresh-renderer-port'
// `aliases`: every checkout the read stood for (one read can serve several linked to the same PR).
export type PRRefreshOutcomeObserver = (
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
aliases: readonly GitHubPRRefreshAlias[]
) => void
const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000
export class PRRefreshEventPublisher {
private sequence = 0
private readonly observers = new Set<PRRefreshOutcomeObserver>()
private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 }
addOutcomeObserver(observer: PRRefreshOutcomeObserver): () => void {
const registered: PRRefreshOutcomeObserver = (...args) => observer(...args)
this.observers.add(registered)
return () => this.observers.delete(registered)
}
/** Every read of a PR reaches every observer once, whatever surface caused it. */
observe(
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
aliases: readonly GitHubPRRefreshAlias[] = []
): void {
for (const observer of this.observers) {
try {
observer(candidate, outcome, aliases)
} catch (error) {
// One observer's failure must not hide the read from the others or fail the refresh.
console.warn('[pr-refresh] outcome observer failed', error)
}
}
}
nextSequence(): number {
this.sequence += 1
return this.sequence
}
broadcast(event: Omit<GitHubPRRefreshEvent, 'sequence'>, sequenceOverride?: number): void {
const payload = {
...event,
sequence: sequenceOverride ?? this.nextSequence()
} as GitHubPRRefreshEvent
prRefreshRenderers().sendToTrustedUI('gh:prRefreshEvent', payload)
}
record(
event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause',
reason: GitHubPRRefreshReason,
skippedReason?: GitHubPRRefreshSkippedReason
): void {
if (event === 'background-pause') {
this.counters.backgroundPauses += 1
} else {
this.counters[event] += 1
}
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_queue',
coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`,
minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS,
data: {
event,
reason,
...(skippedReason ? { skippedReason } : {}),
enqueued: this.counters.enqueued,
coalesced: this.counters.coalesced,
skipped: this.counters.skipped,
backgroundPauses: this.counters.backgroundPauses
}
})
}
}
@@ -0,0 +1,122 @@
import { isBudgetedBackground } from './pr-refresh-candidate-policy'
import type { PRRefreshQueueEntry } from './pr-refresh-queue'
const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000
const MIN_BACKGROUND_SPACING_MS = 10_000
const BACKGROUND_BUDGET_MAX = 20
const ACTIVE_BURST_WINDOW_MS = 30_000
const ACTIVE_BURST_MAX = 3
export function usesActiveRefreshPacing(entry: PRRefreshQueueEntry): boolean {
// Selected admission survives deselection while queued; periodic follow-ups clear the flag.
return (
entry.reason === 'active' ||
(entry.reason === 'visible' && entry.priority >= 80 && entry.bypassBackgroundBudget === true)
)
}
export class PRRefreshPacing {
private readonly backgroundStarts: number[] = []
private readonly activeStartsByScope = new Map<string, number[]>()
private lastBackgroundStartAt = 0
clearActiveBurstWindow(windowId: number): void {
const windowPrefix = `${windowId}::`
for (const scope of Array.from(this.activeStartsByScope.keys())) {
if (scope.startsWith(windowPrefix)) {
this.activeStartsByScope.delete(scope)
}
}
}
noteBackgroundStart(): void {
const now = Date.now()
this.lastBackgroundStartAt = now
this.backgroundStarts.push(now)
this.pruneBackgroundStarts(now)
}
nextBudgetDelay(): number {
const now = Date.now()
this.pruneBackgroundStarts(now)
const spacingDelay =
this.lastBackgroundStartAt > 0
? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt))
: 0
const windowDelay =
this.backgroundStarts.length < BACKGROUND_BUDGET_MAX
? 0
: Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0]))
return Math.max(spacingDelay, windowDelay)
}
activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number {
if (!usesActiveRefreshPacing(a) || !usesActiveRefreshPacing(b)) {
return 0
}
if (this.activeBurstScope(a) !== this.activeBurstScope(b)) {
return 0
}
return b.queuedAt - a.queuedAt
}
entryDelay(entry: PRRefreshQueueEntry): number {
const activeDelay = usesActiveRefreshPacing(entry) ? this.nextActiveBurstDelay(entry) : 0
if (activeDelay > 0) {
return activeDelay
}
return entry.bypassBackgroundBudget !== true && isBudgetedBackground(entry.reason)
? this.nextBudgetDelay()
: 0
}
isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean {
return usesActiveRefreshPacing(entry) && this.nextActiveBurstDelay(entry) > 0
}
noteActiveStart(entry: PRRefreshQueueEntry): void {
const now = Date.now()
const scope = this.activeBurstScope(entry)
const activeStarts = this.pruneActiveStarts(scope, now)
activeStarts.push(now)
this.activeStartsByScope.set(scope, activeStarts)
}
private pruneBackgroundStarts(now: number): void {
while (
this.backgroundStarts.length > 0 &&
now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS
) {
this.backgroundStarts.shift()
}
}
private activeBurstScope(entry: PRRefreshQueueEntry): string {
const runtimeScope = entry.candidate.connectionId
? `ssh:${entry.candidate.connectionId}`
: `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}`
return `${entry.windowId ?? 'global'}::${runtimeScope}`
}
private pruneActiveStarts(scope: string, now: number): number[] {
const activeStarts = this.activeStartsByScope.get(scope) ?? []
while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) {
activeStarts.shift()
}
if (activeStarts.length === 0) {
this.activeStartsByScope.delete(scope)
} else {
this.activeStartsByScope.set(scope, activeStarts)
}
return activeStarts
}
private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number {
const now = Date.now()
const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now)
if (activeStarts.length < ACTIVE_BURST_MAX) {
return 0
}
return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0]))
}
}
@@ -0,0 +1,317 @@
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getPRForBranchOutcome } from '../../client/lookup/pr-for-branch-outcome'
import {
aliasFromCandidate,
freshRetryAt,
hostedReviewOptionArgs,
isBackground,
isBudgetedBackground,
isMergeabilityPendingOutcome,
sameAliasRequestIdentity,
validateCandidate,
visibleCandidateAfterOutcome
} from './pr-refresh-candidate-policy'
import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher'
import { type PRRefreshPacing, usesActiveRefreshPacing } from './pr-refresh-pacing'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import { PRWatchJobRunner } from './pr-refresh-watch-jobs'
import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate'
import {
blockedWslDistroForPath,
wslDistroForPath
} from '../../../wsl/filesystem/wsl-unc-access-gate'
import { outsideWslUserAct } from '../../../wsl/discovery/wsl-user-act-scope'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
import type { PRRefreshVisibility } from './pr-refresh-visibility'
export type PRRefreshRegistrationCheck = (candidate: GitHubPRRefreshCandidate) => boolean
export class PRRefreshQueueDrainer {
private draining = false
private timer: ReturnType<typeof setTimeout> | null = null
// Why: queued work can outlive its project; re-check on every removal path before Git runs.
private registrationCheck: PRRefreshRegistrationCheck | null = null
readonly watchJobs: PRWatchJobRunner
constructor(
private readonly queue: PRRefreshQueue,
private readonly pacing: PRRefreshPacing,
private readonly visibility: PRRefreshVisibility,
private readonly retry: PRRefreshRetryState,
private readonly events: PRRefreshEventPublisher
) {
this.watchJobs = new PRWatchJobRunner(queue, retry, this)
}
setRegistrationCheck(check: PRRefreshRegistrationCheck | null): void {
this.registrationCheck = check
}
schedule(delay = 0): void {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
this.timer = null
// The drain serves every queued repo; a scheduling call inside a WSL user act must not lend it that act.
void outsideWslUserAct(() => this.drain())
}, delay)
}
scheduleVisibleFollowUp(
key: string,
candidate: GitHubPRRefreshCandidate,
outcome: PRRefreshOutcome,
priority: number,
aliases: GitHubPRRefreshAlias[],
windowId?: number,
options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number }
): void {
if (outcome.kind === 'upstream-error') {
const retryAt = Math.max(
options?.plannedRetryAt ??
this.retry.nextVisibleErrorRetryAt(key, this.visibility.candidate(key, candidate)),
this.retry.manualGateUntil(key)
)
this.queue.protectBackgroundUntil(key, retryAt)
if (!this.visibility.has(key)) {
return
}
this.queue.setVisibleFollowUp({
key,
candidate,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt: retryAt,
queuedAt: this.queue.nextOrder(),
windowId
})
this.schedule(retryAt - Date.now())
return
}
this.retry.reset(key)
const refreshed = visibleCandidateAfterOutcome(candidate, outcome)
this.visibility.update(refreshed)
if (!this.visibility.has(key)) {
return
}
const followUpCandidate = this.visibility.candidate(key, refreshed)
const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now()
const pendingDueAt =
options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome)
? outcome.fetchedAt + options.pendingMergeabilityDelayMs
: null
const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt)
if (!Number.isFinite(dueAt)) {
const pending = this.queue.get(key)
if (
pending?.reason === 'visible' &&
sameAliasRequestIdentity(
aliasFromCandidate(pending.candidate),
aliasFromCandidate(followUpCandidate)
)
) {
this.queue.delete(key)
}
return
}
this.queue.setVisibleFollowUp({
key,
candidate: followUpCandidate,
aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])),
reason: 'visible',
priority,
dueAt,
queuedAt: this.queue.nextOrder(),
bypassBackgroundBudget: pendingDueAt !== null,
followUp: true,
windowId
})
this.schedule(Math.max(0, dueAt - Date.now()))
}
private ordered(): PRRefreshQueueEntry[] {
this.watchJobs.age(this.queue.values(), Date.now())
return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b))
}
private nextQueuedWakeDelay(excludedKey: string): number | null {
const now = Date.now()
let nextDelay = Number.POSITIVE_INFINITY
for (const entry of this.queue.values()) {
if (entry.key === excludedKey) {
continue
}
const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry)
nextDelay = Math.min(nextDelay, delay)
}
return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null
}
private async drain(): Promise<void> {
if (this.draining) {
return
}
this.draining = true
try {
while (this.queue.size > 0) {
let next = this.ordered()[0]
const waitMs = next.dueAt - Date.now()
if (waitMs > 0) {
this.schedule(waitMs)
return
}
let delay = this.pacing.entryDelay(next)
if (delay > 0) {
const runnable = this.ordered().find(
(entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0
)
if (runnable && runnable.key !== next.key) {
next = runnable
delay = 0
} else {
this.notePacingDelay(next)
this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay))
return
}
}
this.queue.delete(next.key)
const aliases = Array.from(next.aliases.values())
const skippedReason = validateCandidate(next.candidate)
if (skippedReason) {
this.events.record('skipped', next.reason, skippedReason)
this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason })
continue
}
if (
!(this.registrationCheck?.(next.candidate) ?? true) ||
(next.reason === 'visible' && !this.visibility.has(next.key)) ||
// E9: gh/Git in a stopped distro's repo boots it; a queued refresh keeps the current status.
(isBackground(next.reason) &&
wslDistroForPath(next.candidate.repoPath, next.candidate.localGitOptions?.wslDistro) &&
(await blockedWslDistroForPath(
next.candidate.repoPath,
next.candidate.localGitOptions?.wslDistro
)))
) {
this.events.broadcast({
aliases,
reason: next.reason,
status: 'skipped',
skippedReason: 'fresh'
})
this.watchJobs.skipped(next)
continue
}
const requestSequence = this.events.nextSequence()
const requestStartedAt = Date.now()
this.queue.noteRequestStarted(next.key, requestSequence)
this.events.broadcast(
{ aliases, reason: next.reason, status: 'in-flight', requestStartedAt },
requestSequence
)
if (isBackground(next.reason)) {
const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true)
if (!this.queue.ownsRequest(next.key, requestSequence)) {
continue
}
if (pausedUntil !== null) {
this.queue.protectBackgroundUntil(next.key, pausedUntil)
this.queue.set(next.key, { ...next, dueAt: pausedUntil })
this.events.broadcast({
aliases,
reason: next.reason,
status: 'paused',
pausedUntil,
skippedReason: 'rate-limit'
})
this.schedule(Math.max(1_000, pausedUntil - Date.now()))
continue
}
if (next.bypassBackgroundBudget !== true && isBudgetedBackground(next.reason)) {
this.pacing.noteBackgroundStart()
this.watchJobs.noteBudgetedStart(next)
}
if (usesActiveRefreshPacing(next)) {
this.pacing.noteActiveStart(next)
}
}
if (await this.watchJobs.readHead(next)) {
continue
}
const outcome = await getPRForBranchOutcome(
next.candidate.repoPath,
next.candidate.branch,
next.candidate.linkedPRNumber ?? null,
next.candidate.connectionId ?? null,
next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null,
...hostedReviewOptionArgs(next.candidate, next.reason, { watchPRKey: next.watch?.prKey })
)
if (!this.queue.ownsRequest(next.key, requestSequence)) {
this.events.broadcast(
{ aliases, reason: next.reason, outcome, requestStartedAt },
requestSequence
)
continue
}
let plannedRetryAt: number | undefined
let broadcastOutcome = outcome
if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) {
plannedRetryAt = this.retry.nextVisibleErrorRetryAt(
next.key,
this.visibility.candidate(next.key, next.candidate)
)
broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt)
}
this.events.observe(next.candidate, outcome, aliases)
this.retry.noteManualGate(next.key, broadcastOutcome)
this.events.broadcast(
{ aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt },
requestSequence
)
if (next.watch) {
this.watchJobs.afterOutcome(next, outcome)
continue
}
this.scheduleVisibleFollowUp(
next.key,
next.candidate,
outcome,
next.priority,
aliases,
next.windowId,
{ plannedRetryAt }
)
}
} finally {
this.draining = false
}
}
private notePacingDelay(entry: PRRefreshQueueEntry): void {
if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) {
entry.activeDelayNotified = true
this.events.broadcast({
aliases: Array.from(entry.aliases.values()),
reason: entry.reason,
status: 'queued'
})
}
if (
entry.bypassBackgroundBudget !== true &&
isBudgetedBackground(entry.reason) &&
this.pacing.nextBudgetDelay() > 0
) {
this.events.record('background-pause', entry.reason)
}
}
}
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
vi.mock('../../client/lookup/pr-for-branch-outcome', () => {
return {
getPRForBranchOutcome: coordinatorMocks.getPRForBranchOutcomeMock
}
})
vi.mock('../../repositories/github-api-repository-remote-probe', () => {
return {
getOriginGitHubApiRepository: coordinatorMocks.getOriginGitHubApiRepositoryMock
}
})
vi.mock('../../api/rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42'
describe('pr-refresh queue growth bounds', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}))
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps the newer alias when a branch switch lands mid-request', async () => {
const { reportVisiblePRRefreshCandidates, _getPRRefreshAliasCountForTests } =
await import('./pr-refresh-coordinator')
const churnCandidate = (index: number): GitHubPRRefreshCandidate =>
makeCandidate({
cacheKey: `/repo::churn/${index}`,
branch: `churn/${index}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
let switched = false
getPRForBranchOutcomeMock.mockImplementation(async () => {
if (!switched) {
switched = true
// Re-enqueue after dequeue so the older in-flight snapshot merges into a newer queued entry.
reportVisiblePRRefreshCandidates([churnCandidate(2)], 2, 1)
}
return {
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}
})
reportVisiblePRRefreshCandidates([churnCandidate(1)], 1, 1)
await vi.advanceTimersByTimeAsync(120_000)
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
const broadcasts = coordinatorMocks.sendMock.mock.calls.filter(
(call) => call[0] === 'gh:prRefreshEvent'
) as [string, { aliases: { cacheKey: string }[] }][]
const lastBroadcast = broadcasts.at(-1)
expect(lastBroadcast?.[1].aliases.map((alias) => alias.cacheKey)).toEqual(['/repo::churn/2'])
})
})
@@ -0,0 +1,331 @@
import { REVIEW_REFRESH_COOLDOWN_MS } from '../../../../shared/review-refresh-policy'
import type {
GitHubPRRefreshAlias,
GitHubPRRefreshCandidate,
GitHubPRRefreshReason
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import {
aliasFromCandidate,
bypassesFreshnessDelay,
freshRetryAt,
POST_PUSH_DELAY_MS,
refreshKey,
sameAliasRequestIdentity,
refreshIntervalForCandidate,
shouldSkipFresh
} from './pr-refresh-candidate-policy'
export type PRRefreshQueueEntry = {
key: string
candidate: GitHubPRRefreshCandidate
aliases: Map<string, GitHubPRRefreshAlias>
reason: GitHubPRRefreshReason
priority: number
dueAt: number
queuedAt: number
bypassBackgroundBudget?: boolean
activeDelayNotified?: boolean
followUp?: boolean
windowId?: number
/** A watch job: the PR's current head, or with `headSha` one earlier head's checks. */
watch?: { prKey: string; headSha?: string }
}
export type PRRefreshEnqueue = {
alias: GitHubPRRefreshAlias
key: string
dueAt: number
coalesced: boolean
}
type PRRefreshRequestState = { until: number; requestSequence?: number }
/** Retire old-branch aliases when a linked-PR key survives worktree branch changes. */
function setLiveAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): void {
if (alias.worktreeId) {
for (const [cacheKey, existing] of aliases) {
if (cacheKey !== alias.cacheKey && existing.worktreeId === alias.worktreeId) {
aliases.delete(cacheKey)
}
}
}
aliases.set(alias.cacheKey, alias)
}
/** Queued aliases are newer than request-time follow-ups; preserve them with their matching candidate. */
function mergeFollowUpAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): GitHubPRRefreshAlias | undefined {
if (alias.worktreeId) {
for (const existing of aliases.values()) {
if (existing.worktreeId === alias.worktreeId) {
return existing
}
}
}
setLiveAlias(aliases, alias)
return undefined
}
/** Manual refresh writes copied maps back; keep the latest branch per worktree on re-entry too. */
function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias>): void {
const liveCacheKeyByWorktree = new Map<string, string>()
for (const [cacheKey, alias] of aliases) {
if (!alias.worktreeId) {
continue
}
const superseded = liveCacheKeyByWorktree.get(alias.worktreeId)
if (superseded !== undefined) {
aliases.delete(superseded)
}
liveCacheKeyByWorktree.set(alias.worktreeId, cacheKey)
}
}
export class PRRefreshQueue {
private readonly entries = new Map<string, PRRefreshQueueEntry>()
private order = 0
// Completion ownership shares the cooldown map's bound.
private readonly backgroundNotBefore = new Map<string, PRRefreshRequestState>()
constructor(private readonly resetRetryState: (key: string) => void) {}
get size(): number {
return this.entries.size
}
get(key: string): PRRefreshQueueEntry | undefined {
return this.entries.get(key)
}
set(key: string, entry: PRRefreshQueueEntry): void {
dropSupersededWorktreeAliases(entry.aliases)
this.entries.set(key, entry)
}
delete(key: string): void {
this.entries.delete(key)
}
values(): IterableIterator<PRRefreshQueueEntry> {
return this.entries.values()
}
nextOrder(): number {
this.order += 1
return this.order
}
aliasCount(key: string): number {
return this.entries.get(key)?.aliases.size ?? 0
}
protectBackgroundUntil(key: string, until: number, requestSequence?: number): void {
const owner = requestSequence ?? this.backgroundNotBefore.get(key)?.requestSequence
this.backgroundNotBefore.delete(key)
this.backgroundNotBefore.set(key, { until, requestSequence: owner })
const pending = this.entries.get(key)
if (pending && !bypassesFreshnessDelay(pending.reason)) {
pending.dueAt = Math.max(pending.dueAt, until)
}
const oldest = this.backgroundNotBefore.keys().next().value
if (this.backgroundNotBefore.size > 1_000 && oldest !== undefined) {
this.backgroundNotBefore.delete(oldest)
this.resetRetryState(oldest)
}
}
noteRequestStarted(key: string, requestSequence: number): void {
this.protectBackgroundUntil(key, Date.now() + REVIEW_REFRESH_COOLDOWN_MS, requestSequence)
}
ownsRequest(key: string, requestSequence: number): boolean {
return this.backgroundNotBefore.get(key)?.requestSequence === requestSequence
}
retimeVisible(
candidateFor: (key: string, candidate: GitHubPRRefreshCandidate) => GitHubPRRefreshCandidate
): void {
for (const entry of this.entries.values()) {
if (entry.reason !== 'visible' || !entry.followUp || entry.bypassBackgroundBudget) {
continue
}
entry.candidate = candidateFor(entry.key, entry.candidate)
entry.dueAt = Math.max(
freshRetryAt(entry.candidate) ?? Date.now(),
this.backgroundNotBefore.get(entry.key)?.until ?? 0
)
if (!Number.isFinite(entry.dueAt)) {
this.entries.delete(entry.key)
}
}
}
enqueue(
candidate: GitHubPRRefreshCandidate,
reason: GitHubPRRefreshReason,
priority: number,
windowId?: number,
reexposed = false
): PRRefreshEnqueue {
const alias = aliasFromCandidate(candidate)
const key = refreshKey(candidate)
const existing = this.entries.get(key)
const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null
const stopped = !Number.isFinite(refreshIntervalForCandidate(candidate))
const exposureDueAt =
reexposed &&
stopped &&
candidate.cachedFetchedAt != null &&
Date.now() - candidate.cachedFetchedAt >= REVIEW_REFRESH_COOLDOWN_MS
? Date.now()
: null
const dueAt = Math.max(
exposureDueAt ?? freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0),
bypassesFreshnessDelay(reason) ? 0 : (this.backgroundNotBefore.get(key)?.until ?? 0)
)
if (!Number.isFinite(dueAt) && !existing) {
return { alias, key, dueAt, coalesced: false }
}
if (!existing) {
this.entries.set(key, {
key,
candidate,
aliases: new Map([[alias.cacheKey, alias]]),
reason,
priority,
dueAt,
queuedAt: this.nextOrder(),
followUp: reason === 'visible' && freshDueAt !== null && exposureDueAt === null,
windowId
})
return { alias, key, dueAt, coalesced: false }
}
setLiveAlias(existing.aliases, alias)
if (
reason === 'visible' &&
!shouldSkipFresh(candidate, reason) &&
existing.reason === 'visible'
) {
existing.dueAt = Math.min(existing.dueAt, dueAt)
existing.followUp = false
}
const shouldPromote =
priority > existing.priority ||
reason === 'manual' ||
(reason === 'active' && existing.reason === 'active') ||
(priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason))
if (shouldPromote) {
existing.priority = priority
existing.reason = reason
existing.dueAt = Math.min(existing.dueAt, dueAt)
existing.queuedAt = this.nextOrder()
existing.activeDelayNotified = false
existing.candidate = candidate
existing.windowId = windowId ?? existing.windowId
} else if (existing.candidate.worktreeId === candidate.worktreeId) {
existing.candidate = {
...existing.candidate,
cacheKey: candidate.cacheKey,
branch: candidate.branch,
currentHeadOid: candidate.currentHeadOid ?? null,
isSelected: candidate.isSelected
}
}
return { alias, key, dueAt, coalesced: true }
}
removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void {
const existing = this.entries.get(key)
if (!existing) {
return
}
existing.aliases.delete(alias.cacheKey)
const replacement = existing.aliases.values().next().value
if (!replacement) {
this.entries.delete(key)
this.resetRetryState(key)
return
}
if (existing.candidate.cacheKey === alias.cacheKey) {
existing.candidate = {
...existing.candidate,
...replacement,
currentHeadOid: replacement.currentHeadOid ?? null,
isArchived: false,
isBare: false
}
}
}
pruneWorktreeAliases(worktreeId: string): void {
for (const [key, entry] of this.entries) {
for (const alias of entry.aliases.values()) {
if (alias.worktreeId === worktreeId) {
this.removeInvalidAlias(key, alias)
}
}
}
}
removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] {
const removed: PRRefreshQueueEntry[] = []
for (const [key, entry] of this.entries) {
if (entry.reason !== 'visible' || isVisible(key)) {
continue
}
this.entries.delete(key)
removed.push(entry)
}
return removed
}
setVisibleFollowUp(entry: PRRefreshQueueEntry): void {
const existing = this.entries.get(entry.key)
if (!existing) {
this.set(entry.key, entry)
return
}
let candidateSuperseded = false
for (const alias of entry.aliases.values()) {
const preserved = mergeFollowUpAlias(existing.aliases, alias)
if (
preserved &&
alias.worktreeId === entry.candidate.worktreeId &&
!sameAliasRequestIdentity(preserved, alias)
) {
candidateSuperseded = true
}
}
if (
candidateSuperseded ||
bypassesFreshnessDelay(existing.reason) ||
existing.priority > entry.priority
) {
return
}
this.set(entry.key, { ...entry, aliases: existing.aliases })
}
ordered(
activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number
): PRRefreshQueueEntry[] {
const now = Date.now()
return Array.from(this.entries.values()).sort((a, b) => {
const aReady = a.dueAt <= now
const bReady = b.dueAt <= now
if (aReady && bReady) {
return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt
}
if (aReady !== bReady) {
return aReady ? -1 : 1
}
return a.dueAt - b.dueAt || b.priority - a.priority
})
}
}
@@ -0,0 +1,31 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { getOriginGitHubApiRepository } from '../../repositories/github-api-repository-remote-probe'
import { ghRepoExecOptions, githubRepoContext } from '../../repositories/github-repository-identity'
import {
getRateLimit,
repositoryRateLimitGuard,
spendsSharedGitHubComQuota
} from '../../api/rate-limit'
const BUCKETS = ['core', 'graphql'] as const
export async function prRefreshRateLimitPausedUntil(
candidate: GitHubPRRefreshCandidate,
warmSharedSnapshot: boolean
): Promise<number | null> {
const executionOptions = ghRepoExecOptions(
githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions)
)
const repository = await getOriginGitHubApiRepository(
candidate.repoPath,
candidate.connectionId,
executionOptions
)
if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) {
await getRateLimit()
}
const blockedGuard = BUCKETS.map((bucket) =>
repositoryRateLimitGuard(repository, bucket, executionOptions)
).find((guard) => guard.blocked)
return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null
}
@@ -0,0 +1,22 @@
/** The desktop renderers PR refresh serves. A Node host (orcad) has none: no window reports
* visible PRs there, and refresh events go nowhere. Electron installs its own at startup. */
export type PRRefreshRendererPort = {
/** Whether the renderer that reported visible PRs is still open. */
isRendererOpen(webContentsId: number): boolean
sendToTrustedUI(channel: string, payload: unknown): void
}
const headless: PRRefreshRendererPort = {
isRendererOpen: () => false,
sendToTrustedUI: () => {}
}
let current = headless
export function setPRRefreshRendererPort(port: PRRefreshRendererPort | null): void {
current = port ?? headless
}
export function prRefreshRenderers(): PRRefreshRendererPort {
return current
}
@@ -0,0 +1,53 @@
import type {
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { lookupBackoffDelayMs } from '../../../source-control/hosted-review-refresh-pacing'
import { refreshIntervalForCandidate } from './pr-refresh-candidate-policy'
export class PRRefreshRetryState {
private readonly errorBackoff = new Map<string, { failures: number; retryAt: number }>()
private readonly manualRetryGates = new Map<string, number>()
get errorBackoffCount(): number {
return this.errorBackoff.size
}
reset(key: string): void {
this.errorBackoff.delete(key)
this.manualRetryGates.delete(key)
}
noteManualGate(key: string, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) {
this.manualRetryGates.set(key, outcome.retryDisabledUntil)
} else {
this.manualRetryGates.delete(key)
}
}
manualGateUntil(key: string): number {
return this.manualRetryGates.get(key) ?? 0
}
nextVisibleErrorRetryAt(key: string, candidate: GitHubPRRefreshCandidate): number {
const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1
const interval = refreshIntervalForCandidate(candidate)
const retryAt =
Date.now() +
Math.max(lookupBackoffDelayMs(failures), Number.isFinite(interval) ? interval : 0)
this.errorBackoff.set(key, { failures, retryAt })
return retryAt
}
withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome {
if (outcome.kind !== 'upstream-error') {
return outcome
}
const cooldownUntil = outcome.retryDisabledUntil
return {
...outcome,
nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt
}
}
}
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({
recordCoalescedCrashBreadcrumbMock: vi.fn()
}))
vi.mock('../../../crash-reporting/breadcrumbs/crash-breadcrumb-store', () => ({
recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock
}))
import {
clearPRRefreshValidationBackoffForTests,
notePRRefreshValidationDenial
} from './pr-refresh-validation-backoff'
describe('PR refresh validation backoff', () => {
beforeEach(() => {
clearPRRefreshValidationBackoffForTests()
recordCoalescedCrashBreadcrumbMock.mockReset()
})
it('records path-safe diagnostic breadcrumbs', () => {
notePRRefreshValidationDenial(
{
repoId: 'repo-1',
repoPath: '/Users/alice/private/project',
reason: 'repo-path-mismatch'
},
0
)
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'pr_refresh_validation_skip',
data: expect.objectContaining({
reason: 'repo-path-mismatch',
result: 'recorded',
token: expect.any(String)
})
})
)
const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0]
expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project')
})
})
@@ -0,0 +1,107 @@
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { recordCoalescedCrashBreadcrumb } from '../../../crash-reporting/breadcrumbs/crash-breadcrumb-store'
const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000
const MAX_VALIDATION_BACKOFF_ENTRIES = 256
const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000
export type PRRefreshValidationDenialReason =
| 'unknown-repo'
| 'repo-path-mismatch'
| 'host-mismatch'
type ValidationBackoffIdentity = {
repoId?: string | null
repoPath: string
reason: PRRefreshValidationDenialReason
}
type ValidationBackoffEntry = {
expiresAt: number
}
type ValidationBackoffCounters = {
recorded: number
skipped: number
expired: number
}
const validationBackoff = new Map<string, ValidationBackoffEntry>()
const counters: ValidationBackoffCounters = {
recorded: 0,
skipped: 0,
expired: 0
}
function validationIdentityKey(identity: ValidationBackoffIdentity): string {
return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0')
}
function validationIdentityToken(key: string): string {
return createHash('sha256').update(key).digest('hex').slice(0, 12)
}
function evictOldestValidationBackoffEntries(): void {
while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) {
const oldest = validationBackoff.keys().next()
if (oldest.done) {
break
}
validationBackoff.delete(oldest.value)
}
}
function recordValidationBreadcrumb(
reason: PRRefreshValidationDenialReason,
result: 'recorded' | 'backoff',
token: string
): void {
recordCoalescedCrashBreadcrumb({
name: 'pr_refresh_validation_skip',
coalesceKey: `pr-refresh-validation:${reason}:${token}`,
minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS,
data: {
reason,
result,
token,
recorded: counters.recorded,
skipped: counters.skipped,
expired: counters.expired
}
})
}
export function notePRRefreshValidationDenial(
identity: ValidationBackoffIdentity,
nowMs = Date.now()
): 'validation-denied' | 'validation-backoff' {
const key = validationIdentityKey(identity)
const existing = validationBackoff.get(key)
const token = validationIdentityToken(key)
if (existing && existing.expiresAt > nowMs) {
counters.skipped += 1
recordValidationBreadcrumb(identity.reason, 'backoff', token)
return 'validation-backoff'
}
if (existing) {
counters.expired += 1
validationBackoff.delete(key)
}
counters.recorded += 1
validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS })
evictOldestValidationBackoffEntries()
recordValidationBreadcrumb(identity.reason, 'recorded', token)
return 'validation-denied'
}
export function clearPRRefreshValidationBackoffForTests(): void {
validationBackoff.clear()
counters.recorded = 0
counters.skipped = 0
counters.expired = 0
}
export function getPRRefreshValidationBackoffCountForTests(): number {
return validationBackoff.size
}
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('./pr-refresh-renderer-port', () => moduleMocks.rendererPortModuleMock(coordinatorMocks))
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
import { refreshKey } from './pr-refresh-candidate-policy'
import { PRRefreshVisibility } from './pr-refresh-visibility'
const { getAllWebContentsMock, webContentsFromIdMock } = coordinatorMocks
describe('PR refresh owner liveness', () => {
beforeEach(() => moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks))
afterEach(() => vi.useRealTimers())
it('prunes missing and destroyed owners while checking surviving owners afresh', () => {
const destroyed = vi.fn(() => false)
const registry = new Map([
[1, { id: 1, isDestroyed: () => false }],
[2, { id: 2, isDestroyed: destroyed }],
[3, { id: 3, isDestroyed: () => false }]
])
getAllWebContentsMock.mockImplementation(() => Array.from(registry.values()))
webContentsFromIdMock.mockImplementation((id) => registry.get(id))
const visibility = new PRRefreshVisibility()
const candidate = makeCandidate()
for (const id of registry.keys()) {
visibility.report([candidate], 1, id)
}
registry.delete(1)
destroyed.mockReturnValue(true)
webContentsFromIdMock.mockClear()
expect(visibility.has(refreshKey(candidate))).toBe(true)
expect(visibility.windowCount).toBe(1)
expect(webContentsFromIdMock.mock.calls).toEqual([[1], [2], [3]])
registry.delete(3)
webContentsFromIdMock.mockClear()
expect(visibility.has(refreshKey(candidate))).toBe(false)
expect(webContentsFromIdMock.mock.calls).toEqual([[3]])
expect(visibility.windowCount).toBe(0)
})
it('retains generation, cached fields and surviving selection ownership', () => {
const registry = new Map([
[1, { id: 1, isDestroyed: () => false }],
[2, { id: 2, isDestroyed: () => false }]
])
getAllWebContentsMock.mockImplementation(() => Array.from(registry.values()))
webContentsFromIdMock.mockImplementation((id) => registry.get(id))
const visibility = new PRRefreshVisibility()
const candidate = makeCandidate({ cachedFetchedAt: 1, cachedHeadOid: 'old-head' })
const key = refreshKey(candidate)
visibility.report([{ ...candidate, isSelected: true }], 2, 1)
visibility.report([candidate], 1, 2)
visibility.update({ ...candidate, cachedFetchedAt: 3, cachedHeadOid: 'new-head' })
expect(visibility.report([], 1, 1)).toBe(false)
expect(visibility.candidate(key, candidate)).toMatchObject({
isSelected: true,
cachedFetchedAt: 3,
cachedHeadOid: 'new-head'
})
expect(visibility.report([candidate], 3, 1)).toBe(true)
expect(visibility.candidate(key, candidate)).toMatchObject({
isSelected: false,
cachedFetchedAt: 3,
cachedHeadOid: 'new-head'
})
visibility.report([{ ...candidate, isSelected: true }], 2, 2)
expect(visibility.clearWindow(1)).toBe(true)
expect(visibility.has(key)).toBe(true)
expect(visibility.candidate(key, candidate)).toMatchObject({ isSelected: true })
registry.delete(2)
expect(visibility.has(key)).toBe(false)
})
})
@@ -0,0 +1,93 @@
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import { refreshKey } from './pr-refresh-candidate-policy'
import { prRefreshRenderers } from './pr-refresh-renderer-port'
export class PRRefreshVisibility {
private readonly visibleByWindow = new Map<
number,
{ generation: number; candidates: Map<string, GitHubPRRefreshCandidate> }
>()
get windowCount(): number {
return this.visibleByWindow.size
}
clearWindow(windowId: number): boolean {
return this.visibleByWindow.delete(windowId)
}
report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean {
this.pruneDestroyedWindows()
const existing = this.visibleByWindow.get(windowId)
if (existing && generation < existing.generation) {
return false
}
const reported = new Map<string, GitHubPRRefreshCandidate>()
for (const candidate of candidates) {
const key = refreshKey(candidate)
const previous = this.candidate(key, candidate)
reported.set(key, {
...previous,
...candidate,
...(previous.cachedFetchedAt != null &&
previous.cachedFetchedAt > (candidate.cachedFetchedAt ?? 0)
? this.cachedFields(previous)
: {}),
isSelected: candidate.isSelected === true || reported.get(key)?.isSelected === true
})
}
this.visibleByWindow.set(windowId, { generation, candidates: reported })
return true
}
has(key: string): boolean {
this.pruneDestroyedWindows()
return Array.from(this.visibleByWindow.values()).some((window) => window.candidates.has(key))
}
candidate(key: string, fallback: GitHubPRRefreshCandidate): GitHubPRRefreshCandidate {
let latest = fallback
let selected = false
for (const window of this.visibleByWindow.values()) {
const candidate = window.candidates.get(key)
if (!candidate) {
continue
}
selected ||= candidate.isSelected === true
if ((candidate.cachedFetchedAt ?? 0) > (latest.cachedFetchedAt ?? 0)) {
latest = { ...fallback, ...this.cachedFields(candidate) }
}
}
return { ...latest, isSelected: selected }
}
update(candidate: GitHubPRRefreshCandidate): void {
const key = refreshKey(candidate)
for (const window of this.visibleByWindow.values()) {
const current = window.candidates.get(key)
if (current && (current.cachedFetchedAt ?? 0) <= (candidate.cachedFetchedAt ?? 0)) {
window.candidates.set(key, { ...current, ...this.cachedFields(candidate) })
}
}
}
private cachedFields(candidate: GitHubPRRefreshCandidate): Partial<GitHubPRRefreshCandidate> {
return {
cachedFetchedAt: candidate.cachedFetchedAt,
cachedHeadOid: candidate.cachedHeadOid,
cachedHasPR: candidate.cachedHasPR,
cachedPRState: candidate.cachedPRState,
cachedChecksStatus: candidate.cachedChecksStatus,
cachedMergeable: candidate.cachedMergeable,
cachedMergeStateStatus: candidate.cachedMergeStateStatus
}
}
private pruneDestroyedWindows(): void {
for (const windowId of this.visibleByWindow.keys()) {
if (!prRefreshRenderers().isRendererOpen(windowId)) {
this.visibleByWindow.delete(windowId)
}
}
}
}
@@ -0,0 +1,128 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PullRequestLookupData } from '../../client/lookup/pull-request-lookup-data'
import { makeCandidate } from './pr-refresh-coordinator-test-harness'
const fixture = vi.hoisted(() => ({
lookup: vi.fn(),
conflict: vi.fn(),
stack: { number: 2, position: 1, size: 2, baseRefName: 'main', baseSha: 'base-sha' }
}))
vi.mock('../../repositories/github-api-repository', () => ({
resolveGitHubApiRepositoryCandidates: async () => ({
candidates: [{ owner: 'acme', repo: 'widgets' }],
headRepo: { owner: 'acme', repo: 'widgets' }
})
}))
vi.mock('../../repositories/github-api-repository-remote-probe', () => ({
getOriginGitHubApiRepository: async () => ({ owner: 'acme', repo: 'widgets' })
}))
vi.mock('../../api/rate-limit', () => ({
repositoryRateLimitGuard: () => ({ blocked: false }),
spendsSharedGitHubComQuota: () => false
}))
vi.mock('../../client/lookup/pr-number-lookup', () => ({ lookupPRByNumber: fixture.lookup }))
vi.mock('../../client/lookup/pr-stack-summary-cache', () => ({
getCachedGitHubPRStackSummary: async () => fixture.stack
}))
vi.mock('../stacks/github-pr-stack', () => ({
hydrateGitHubPRStack: async () => fixture.stack
}))
vi.mock('../../client/detect/repository-merge-metadata', () => ({
detectRepositoryMergeMetadata: async () => ({ mergeQueueRequired: true })
}))
vi.mock('../conflicts/conflict-summary', () => ({
getPRConflictSummary: fixture.conflict
}))
const data: PullRequestLookupData = {
number: 12,
title: 'Conflicting change',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/12',
updatedAt: '2026-11-11T00:00:00Z',
mergeable: 'CONFLICTING',
baseRefName: 'main',
baseRefOid: 'base-sha',
headRefName: 'topic',
headRefOid: 'head-sha',
checksUnavailable: false,
statusCheckRollup: [{ __typename: 'CheckRun', name: 'build', status: 'IN_PROGRESS' }]
}
const summary = { baseRef: 'main', baseCommit: 'base-sh', commitsBehind: 1, files: ['a.ts'] }
afterEach(() => vi.useRealTimers())
it('watches preserve provider checks while full and phone reads retain derived details', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
fixture.lookup.mockImplementation(async () => ({
data: { ...data },
dataRepo: { owner: 'acme', repo: 'widgets' }
}))
fixture.conflict.mockResolvedValue(summary)
const { addPRRefreshOutcomeObserver, schedulePRWatchRead } =
await import('./pr-refresh-coordinator')
const { getPRForBranchOutcome, readRecentPhonePROutcome } =
await import('../../client/lookup/pr-for-branch-outcome')
const candidate = makeCandidate({ branch: 'topic', linkedPRNumber: 12 })
const watched = Promise.withResolvers<PRRefreshOutcome>()
const unsubscribe = addPRRefreshOutcomeObserver((_, outcome) => watched.resolve(outcome))
schedulePRWatchRead({ prKey: 'github.com/acme/widgets#12' }, candidate, Date.now())
await vi.advanceTimersByTimeAsync(0)
const watch = await watched.promise
unsubscribe()
expect(watch).toMatchObject({
kind: 'found',
pr: { number: 12, headSha: 'head-sha', state: 'open', checksStatus: 'pending' }
})
if (watch.kind !== 'found') {
throw new Error('watch did not find the PR')
}
expect(watch.pr.conflictSummary).toBeUndefined()
expect(watch.pr.stack).toBeUndefined()
expect(watch.pr.mergeQueueRequired).toBeUndefined()
// An uncapped partial read must also stay separate from a concurrent full read.
const provider = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const conflict = Promise.withResolvers<typeof summary>()
fixture.lookup.mockImplementation(async () => {
entered.resolve()
await provider.promise
return { data: { ...data }, dataRepo: { owner: 'acme', repo: 'widgets' } }
})
fixture.conflict.mockReturnValue(conflict.promise)
const partial = getPRForBranchOutcome('/repo', 'topic', 12, null, null, {
includeDerivedData: false
})
await entered.promise
const full = getPRForBranchOutcome('/repo', 'topic', 12, null, null)
provider.resolve()
try {
expect(await partial).toMatchObject({ kind: 'found', pr: { conflictSummary: undefined } })
expect(
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, {
currentHeadOid: 'head-sha'
})
).toBeNull()
} finally {
conflict.resolve(summary)
}
const complete = await full
expect(complete).toMatchObject({
kind: 'found',
pr: {
number: 12,
headSha: 'head-sha',
checksStatus: 'pending',
conflictSummary: { files: ['a.ts'], commitsBehind: 1 },
stack: { number: 2, baseRefName: 'main' },
mergeQueueRequired: true
}
})
expect(
await readRecentPhonePROutcome('/repo', 'topic', 12, null, null, { currentHeadOid: 'head-sha' })
).toEqual(complete)
})
@@ -0,0 +1,153 @@
import type {
GitHubPRRefreshCandidate,
PRRefreshOutcome
} from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
import { reviewRefreshIntervalMs } from '../../../../shared/review-refresh-policy'
import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue'
import type { PRRefreshRetryState } from './pr-refresh-retry-state'
export type PRWatchJob = NonNullable<PRRefreshQueueEntry['watch']>
export type PRWatchJobHooks = {
/** False once the watch ends, so its job is not read again. */
isWatched: (job: PRWatchJob) => boolean
/** Reads one earlier head's checks; resolves the next due time, or null when that head is done. */
readHeadChecks: (
job: Required<PRWatchJob>,
candidate: GitHubPRRefreshCandidate
) => Promise<number | null>
}
/** A watch overdue by one background interval outranks visible reads, so none starves. */
export const PR_WATCH_AGING_MS = 120_000
const PR_WATCH_PRIORITY = 20
// An aged watch takes the next budgeted start above visible reads (40), then yields one back
// (30), so under saturation watches and visible rows alternate and neither starves.
const AGED_PR_WATCH_PRIORITY = 50
const YIELDING_PR_WATCH_PRIORITY = 30
// A watch skipped while its repo's WSL distro is stopped looks again later, without starting it.
const PR_WATCH_SKIPPED_RETRY_MS = 15 * 60_000
export function prWatchQueueKey(job: PRWatchJob): string {
return job.headSha ? `sha-checks::${job.prKey}::${job.headSha}` : `watch::${job.prKey}`
}
/** A complete read with nothing left running, or a complete read with no checks at all. A failed
* check beside a running one is not done, whatever the sidebar's failure-first summary says. */
export function prChecksDone(pr: PRInfo): boolean {
return (
pr.checksFinal === true || (pr.checksReadComplete === true && pr.checksSummary?.total === 0)
)
}
/** A watch's next read: the background cadence, or none once the PR has settled. */
export function prWatchNextDueAt(outcome: PRRefreshOutcome): number | null {
if (outcome.kind === 'upstream-error') {
return null
}
const pr = outcome.kind === 'found' ? outcome.pr : null
const interval = reviewRefreshIntervalMs({
state: pr?.state ?? null,
checksStatus: pr && !prChecksDone(pr) ? 'pending' : (pr?.checksStatus ?? null),
hasReview: pr !== null,
selected: false
})
return interval === null ? null : outcome.fetchedAt + interval
}
/** A new watch keeps the earliest due time; a read from elsewhere only defers a queued one. */
export function schedulePRWatchEntry(
queue: PRRefreshQueue,
job: PRWatchJob,
candidate: GitHubPRRefreshCandidate,
dueAt: number,
defer: boolean
): void {
const key = prWatchQueueKey(job)
const existing = queue.get(key)
if (existing) {
existing.candidate = candidate
existing.dueAt = defer ? Math.max(existing.dueAt, dueAt) : Math.min(existing.dueAt, dueAt)
return
}
if (defer) {
return
}
queue.set(key, {
key,
candidate,
// Watch reads update no card, so they name no alias the renderer would show as refreshing.
aliases: new Map(),
reason: 'watch',
priority: PR_WATCH_PRIORITY,
dueAt,
queuedAt: queue.nextOrder(),
watch: job
})
}
/** Watch jobs inside the drain: they re-enqueue themselves whether or not any window shows the PR. */
export class PRWatchJobRunner {
private hooks: PRWatchJobHooks | null = null
private lastBudgetedStartWasWatch = false
constructor(
private readonly queue: PRRefreshQueue,
private readonly retry: PRRefreshRetryState,
private readonly drainer: { schedule: (delay: number) => void }
) {}
setHooks(hooks: PRWatchJobHooks | null): void {
this.hooks = hooks
}
noteBudgetedStart(entry: PRRefreshQueueEntry): void {
this.lastBudgetedStartWasWatch = entry.watch !== undefined
}
/** Re-ranks watch entries before each pick, so aging needs no change to the queue's order. */
age(entries: Iterable<PRRefreshQueueEntry>, now: number): void {
const aged = this.lastBudgetedStartWasWatch
? YIELDING_PR_WATCH_PRIORITY
: AGED_PR_WATCH_PRIORITY
for (const entry of entries) {
if (entry.watch) {
entry.priority = now - entry.dueAt >= PR_WATCH_AGING_MS ? aged : PR_WATCH_PRIORITY
}
}
}
skipped(entry: PRRefreshQueueEntry): void {
this.followUp(entry, Date.now() + PR_WATCH_SKIPPED_RETRY_MS)
}
/** Runs an earlier head's job; false for any other entry, which the drain reads as usual. */
async readHead(entry: PRRefreshQueueEntry): Promise<boolean> {
const headSha = entry.watch?.headSha
if (!entry.watch || !headSha) {
return false
}
const job = { prKey: entry.watch.prKey, headSha }
this.followUp(entry, (await this.hooks?.readHeadChecks(job, entry.candidate)) ?? null)
return true
}
afterOutcome(entry: PRRefreshQueueEntry, outcome: PRRefreshOutcome): void {
if (outcome.kind === 'upstream-error') {
this.followUp(entry, this.retry.nextVisibleErrorRetryAt(entry.key, entry.candidate))
return
}
this.retry.reset(entry.key)
this.followUp(entry, prWatchNextDueAt(outcome))
}
private followUp(entry: PRRefreshQueueEntry, dueAt: number | null): void {
const job = entry.watch
if (!job || dueAt === null || !(this.hooks?.isWatched(job) ?? false)) {
return
}
schedulePRWatchEntry(this.queue, job, entry.candidate, dueAt, false)
this.drainer.schedule(Math.max(0, dueAt - Date.now()))
}
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type { GitHubPRRefreshCandidate } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { PRInfo } from '../../../../shared/github/pull-requests/pull-request-types'
import { pullRequestChangeObserver, type PullRequestChange } from './pull-request-changed-events'
const candidate: GitHubPRRefreshCandidate = {
cacheKey: '/repo::feature',
repoKind: 'git',
repoId: 'repo-1',
repoPath: '/repo',
branch: 'feature',
worktreeId: 'repo-1::/repo'
}
const pr: PRInfo = {
number: 42,
title: 'Add feature',
state: 'open',
url: 'https://github.com/acme/app/pull/42',
checksStatus: 'pending',
checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1, neutral: 0 },
updatedAt: '2026-11-09T10:00:00Z',
mergeable: 'MERGEABLE',
headSha: 'abc123'
}
describe('pullRequestChangeObserver', () => {
it('publishes a found PR only when it differs from the last one published', () => {
const published: PullRequestChange[] = []
const observe = pullRequestChangeObserver((change) => published.push(change))
observe(candidate, { kind: 'found', pr, fetchedAt: 1 }, [])
// Same PR read again, with a field outside the change set moved.
observe(candidate, { kind: 'found', pr: { ...pr, title: 'Renamed' }, fetchedAt: 2 }, [])
observe(
candidate,
{
kind: 'upstream-error',
errorType: 'network',
message: 'offline',
fetchedAt: 3
},
[]
)
// A read that couldn't load the checks reports neutral with no summary.
const { checksSummary: _unloaded, ...withoutChecks } = pr
observe(
candidate,
{ kind: 'found', pr: { ...withoutChecks, checksStatus: 'neutral' }, fetchedAt: 4 },
[]
)
observe(
candidate,
{
kind: 'found',
pr: {
...pr,
checksStatus: 'success',
checksSummary: {
state: 'success',
total: 1,
passed: 1,
failed: 0,
pending: 0,
neutral: 0
}
},
fetchedAt: 5
},
[]
)
expect(published).toEqual([
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' },
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' }
])
})
// Checkouts linked to the same PR share one coordinator read.
it('publishes for every checkout one read stood for', () => {
const published: PullRequestChange[] = []
const observe = pullRequestChangeObserver((change) => published.push(change))
const sibling = {
cacheKey: '/repo-b::other',
repoPath: '/repo-b',
branch: 'other',
worktreeId: 'repo-1::/repo-b'
}
observe(candidate, { kind: 'found', pr, fetchedAt: 1 }, [candidate, sibling])
expect(published).toEqual([
{ worktreeId: 'repo-1::/repo', repoId: 'repo-1', branch: 'feature' },
{ worktreeId: 'repo-1::/repo-b', repoId: 'repo-1', branch: 'other' }
])
})
})
@@ -0,0 +1,64 @@
import type { PRRefreshOutcome } from '../../../../shared/github/pull-requests/refresh/pull-request-refresh-types'
import type { CheckStatus } from '../../../../shared/github/pull-requests/pull-request-types'
import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events'
import type { PRRefreshOutcomeObserver } from './pr-refresh-event-publisher'
export type PullRequestChange = Omit<
Extract<RuntimeClientEvent, { type: 'pullRequestChanged' }>,
'type'
>
type PublishedPR = { summary: string; checks: CheckStatus | null }
// An evicted checkout costs one repeat event, so the bound only caps memory.
const MAX_TRACKED_CHECKOUTS = 256
// Errors say nothing about the PR, so they never publish.
function observedPR(outcome: PRRefreshOutcome, last: PublishedPR | undefined): PublishedPR | null {
if (outcome.kind === 'no-pr') {
return { summary: 'no-pr', checks: null }
}
if (outcome.kind !== 'found') {
return null
}
const { number, state, headSha, updatedAt, checksStatus, checksSummary } = outcome.pr
return {
summary: JSON.stringify([number, state, headSha ?? null, updatedAt ?? null]),
// A read that couldn't load the checks (an error, or a watch read's page cap) reports 'neutral' without a summary.
checks: checksSummary ? checksStatus : (last?.checks ?? null)
}
}
/**
* Reports a checkout's PR only when a read finds it different from what was last reported: the
* coordinator observes every refresh, including unchanged follow-ups, and each event costs a phone read.
*/
export function pullRequestChangeObserver(
publish: (change: PullRequestChange) => void
): PRRefreshOutcomeObserver {
const published = new Map<string, PublishedPR>()
const publishIfChanged = (change: PullRequestChange, outcome: PRRefreshOutcome): void => {
const key = `${change.worktreeId}\0${change.branch}`
const last = published.get(key)
const next = observedPR(outcome, last)
if (!next || (next.summary === last?.summary && next.checks === last.checks)) {
return
}
published.delete(key)
published.set(key, next)
const oldest = published.keys().next().value
if (published.size > MAX_TRACKED_CHECKOUTS && oldest !== undefined) {
published.delete(oldest)
}
publish(change)
}
return (candidate, outcome, aliases) => {
// One read can stand for several checkouts linked to the same PR. A client's own read names
// no checkout, and that client already holds the answer.
for (const { worktreeId, repoId, branch } of [candidate, ...aliases]) {
if (worktreeId) {
publishIfChanged({ worktreeId, repoId: repoId ?? candidate.repoId, branch }, outcome)
}
}
}
}
@@ -0,0 +1,144 @@
import type { GitHubPRMergeMethod } from '../../../../shared/github/pull-requests/pull-request-types'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { acquire, release } from '../../execution/github-operation-slots'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import type { GitHubRepoExecOptions } from '../../repositories/github-api-repository'
import { codeHostFailure } from '../../../../shared/code-host-failure-copy'
const POLL_INTERVAL_MS = 1_000
const MAX_POLLS = 180
type AsyncMergeResponse = {
status?: unknown
details?: {
message?: unknown
uuid?: unknown
}
}
type AsyncMergeResult =
| { kind: 'pending'; uuid: string }
| { kind: 'success' }
| { kind: 'failure'; message: string }
export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue'
function parseResponse(value: string): AsyncMergeResponse | null {
try {
return JSON.parse(value) as AsyncMergeResponse
} catch {
return null
}
}
function errorResponseBody(error: unknown): AsyncMergeResponse | null {
if (!error || typeof error !== 'object' || !('stdout' in error)) {
return null
}
const stdout = (error as { stdout?: unknown }).stdout
return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout))
}
function classifyResult(response: AsyncMergeResponse): AsyncMergeResult {
if (response.status === 'merged' || response.status === 'enqueued') {
return { kind: 'success' }
}
if (response.status === 'pending' && typeof response.details?.uuid === 'string') {
return { kind: 'pending', uuid: response.details.uuid }
}
const message =
typeof response.details?.message === 'string'
? response.details.message
: 'GitHub could not merge this stack.'
return { kind: 'failure', message }
}
function waitForNextPoll(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
}
async function runStackMergeCommand(
command: string[],
options: NonNullable<Parameters<typeof ghExecFileAsync>[1]>
) {
await acquire()
try {
return await ghExecFileAsync(command, options)
} finally {
release()
}
}
export async function mergeGitHubPRStack(args: {
repository: GitHubApiRepository
prNumber: number
method: GitHubPRMergeMethod
mergeAction: GitHubPRStackMergeAction
headSha?: string
ghOptions: GitHubRepoExecOptions
}): Promise<{ ok: true } | { ok: false; error: string }> {
const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async`
const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`]
if (args.mergeAction === 'direct_merge') {
command.push('-f', `merge_method=${args.method}`)
}
if (args.headSha) {
command.push('-f', `sha=${args.headSha}`)
}
let submitted: AsyncMergeResponse | null
try {
const { stdout } = await runStackMergeCommand(command, {
...args.ghOptions,
...githubHostExecOptions(args.repository),
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
submitted = parseResponse(stdout)
} catch (error) {
submitted = errorResponseBody(error)
if (!submitted) {
throw error
}
}
if (!submitted) {
return codeHostFailure('github.stackMergeInvalidResponse')
}
let result = classifyResult(submitted)
if (result.kind === 'success') {
return { ok: true }
}
if (result.kind === 'failure') {
return { ok: false, error: result.message }
}
const uuid = encodeURIComponent(result.uuid)
for (let poll = 0; poll < MAX_POLLS; poll++) {
await waitForNextPoll()
let response: AsyncMergeResponse | null
try {
const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], {
...args.ghOptions,
...githubHostExecOptions(args.repository)
})
response = parseResponse(stdout)
} catch (error) {
// Why: the merge was submitted; a transient poll failure cannot make it fail.
response = errorResponseBody(error)
if (!response) {
continue
}
}
if (!response) {
return codeHostFailure('github.stackMergeInvalidResponse')
}
result = classifyResult(response)
if (result.kind === 'success') {
return { ok: true }
}
if (result.kind === 'failure') {
return { ok: false, error: result.message }
}
}
return codeHostFailure('github.stackMergePending')
}
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
noteRateLimitSpendMock: vi.fn()
}))
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ghExecFileAsync: ghExecFileAsyncMock
}))
vi.mock('../../api/rate-limit', () => ({
repositoryRateLimitGuard: rateLimitGuardMock,
noteRepositoryRateLimitSpend: noteRateLimitSpendMock
}))
import { _resetGitHubPRStackCacheForTests } from './github-pr-stack'
import { mergeGitHubPRStack } from './github-pr-stack-async-merge'
const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
beforeEach(() => {
vi.useRealTimers()
ghExecFileAsyncMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
_resetGitHubPRStackCacheForTests()
})
describe('mergeGitHubPRStack', () => {
it('reports an in-progress merge after polling is exhausted', async () => {
vi.useFakeTimers()
ghExecFileAsyncMock.mockResolvedValue({
stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } })
})
const result = mergeGitHubPRStack({
repository,
prNumber: 202,
method: 'squash',
mergeAction: 'direct_merge',
ghOptions: { cwd: '/repo' }
})
await vi.advanceTimersByTimeAsync(180_000)
await expect(result).resolves.toMatchObject({
ok: false,
error: 'GitHub is still merging this stack. Refresh to check its status.'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181)
})
})
@@ -0,0 +1,281 @@
import type {
CheckStatus,
GitHubPRStack,
GitHubPRStackEntry,
PRMergeableState,
PRReviewDecision,
PRState
} from '../../../../shared/github/pull-requests/pull-request-types'
import { githubRepoIdentityKey } from '../../../../shared/github/repositories/repository-identity-key'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { codeHostReadExecutionScope as githubReadExecutionScope } from '../../../source-control/code-host-read-execution-scope'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import type { GitHubRepoExecOptions } from '../../repositories/github-api-repository'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../api/rate-limit'
const STACK_CACHE_TTL_MS = 30_000
const STACK_CACHE_MAX_ENTRIES = 256
type CachedStackDetails = {
value: Omit<GitHubPRStack, 'position'> | null
expiresAt: number
}
type GraphQLStackEntry = {
position?: unknown
pullRequest?: {
number?: unknown
title?: unknown
url?: unknown
updatedAt?: unknown
state?: unknown
isDraft?: unknown
headRefName?: unknown
headRefOid?: unknown
mergeable?: unknown
reviewDecision?: unknown
mergeStateStatus?: unknown
statusCheckRollup?: { state?: unknown } | null
} | null
}
type GraphQLStackResponse = {
data?: {
repository?: {
pullRequest?: {
stack?: {
number?: unknown
size?: unknown
baseRefName?: unknown
entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null
} | null
} | null
} | null
}
}
const stackDetailsCache = new Map<string, CachedStackDetails>()
const stackDetailsInFlight = new Map<string, Promise<Omit<GitHubPRStack, 'position'> | null>>()
export function _resetGitHubPRStackCacheForTests(): void {
stackDetailsCache.clear()
stackDetailsInFlight.clear()
}
function stackCacheKey(
repository: GitHubApiRepository,
stackNumber: number,
executionScope: string,
execution: GitHubRepoExecOptions
): string {
return `${executionScope}\0${githubReadExecutionScope(execution)}\0${githubRepoIdentityKey(repository)}:${stackNumber}`
}
function pruneStackCache(now = Date.now()): void {
for (const [key, cached] of stackDetailsCache) {
if (cached.expiresAt <= now) {
stackDetailsCache.delete(key)
}
}
while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) {
const oldestKey = stackDetailsCache.keys().next().value
if (oldestKey === undefined) {
return
}
stackDetailsCache.delete(oldestKey)
}
}
function mapStackPRState(value: unknown, isDraft: unknown): PRState {
if (value === 'MERGED') {
return 'merged'
}
if (value === 'CLOSED') {
return 'closed'
}
return isDraft === true ? 'draft' : 'open'
}
function mapStackCheckStatus(value: unknown): CheckStatus {
if (value === 'SUCCESS') {
return 'success'
}
if (value === 'FAILURE' || value === 'ERROR') {
return 'failure'
}
if (value === 'PENDING' || value === 'EXPECTED') {
return 'pending'
}
return 'neutral'
}
function mapStackMergeable(value: unknown): PRMergeableState {
return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN'
}
function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined {
if (value === null) {
return null
}
if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') {
return value
}
return undefined
}
function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null {
const pr = entry.pullRequest
if (
typeof entry.position !== 'number' ||
typeof pr?.number !== 'number' ||
typeof pr.title !== 'string' ||
typeof pr.url !== 'string'
) {
return null
}
const reviewDecision = mapReviewDecision(pr.reviewDecision)
return {
position: entry.position,
number: pr.number,
title: pr.title,
url: pr.url,
...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}),
state: mapStackPRState(pr.state, pr.isDraft),
checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state),
mergeable: mapStackMergeable(pr.mergeable),
...(reviewDecision !== undefined ? { reviewDecision } : {}),
...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}),
...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}),
...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {})
}
}
function parseStackDetails(
response: GraphQLStackResponse,
expectedStackNumber: number,
fallbackBaseRefName: string,
fallbackSize: number
): Omit<GitHubPRStack, 'position'> | null {
const stack = response.data?.repository?.pullRequest?.stack
if (!stack || stack.number !== expectedStackNumber) {
return null
}
const entries = (stack.entries?.nodes ?? [])
.flatMap((entry) => (entry ? [mapStackEntry(entry)] : []))
.filter((entry): entry is GitHubPRStackEntry => entry !== null)
.sort((a, b) => a.position - b.position)
return {
number: expectedStackNumber,
size: typeof stack.size === 'number' ? stack.size : fallbackSize,
baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName,
...(entries.length > 0 ? { entries } : {})
}
}
const STACK_DETAILS_QUERY = `
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
stack {
number
size
baseRefName
entries(first: 100) {
nodes {
position
pullRequest {
number
title
url
updatedAt
state
isDraft
headRefName
headRefOid
mergeable
reviewDecision
mergeStateStatus
statusCheckRollup { state }
}
}
}
}
}
}
}`
async function fetchStackDetails(
repository: GitHubApiRepository,
prNumber: number,
summary: GitHubPRStack,
ghOptions: GitHubRepoExecOptions
): Promise<Omit<GitHubPRStack, 'position'> | null> {
if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) {
return null
}
noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${STACK_DETAILS_QUERY}`,
'-f',
`owner=${repository.owner}`,
'-f',
`repo=${repository.repo}`,
'-F',
`pr=${prNumber}`
],
ghOptions
)
return parseStackDetails(
JSON.parse(stdout) as GraphQLStackResponse,
summary.number,
summary.baseRefName,
summary.size
)
}
export async function hydrateGitHubPRStack(
repository: GitHubApiRepository,
prNumber: number,
summary: GitHubPRStack,
ghOptions: GitHubRepoExecOptions,
prUpdatedAt?: string,
executionScope = 'local:host'
): Promise<GitHubPRStack> {
const execution = { ...ghOptions, ...githubHostExecOptions(repository) }
const key = stackCacheKey(repository, summary.number, executionScope, execution)
const now = Date.now()
pruneStackCache(now)
const cached = stackDetailsCache.get(key)
const cachedPRUpdatedAt = cached?.value?.entries?.find(
(entry) => entry.number === prNumber
)?.updatedAt
const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt
if (cached && cached.expiresAt > now && cachedMatchesPR) {
return cached.value
? { ...cached.value, position: summary.position, baseSha: summary.baseSha }
: summary
}
const existing = stackDetailsInFlight.get(key)
if (existing) {
const value = await existing
return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary
}
const request = fetchStackDetails(repository, prNumber, summary, execution).catch(() => null)
stackDetailsInFlight.set(key, request)
try {
const value = await request
stackDetailsCache.delete(key)
stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS })
pruneStackCache()
return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary
} finally {
if (stackDetailsInFlight.get(key) === request) {
stackDetailsInFlight.delete(key)
}
}
}
@@ -0,0 +1,52 @@
import type { HostedReviewSummary } from '../../../../shared/source-control/hosted-review/hosted-review-contracts'
export type NumberedHostedReviewSummary = Omit<HostedReviewSummary, 'number'> & { number: number }
export type GitHubStackPullRequest = NumberedHostedReviewSummary & {
headRefName: string
baseRefName: string
}
export type GitHubStack = {
number: number
open: boolean
pull_requests: { number: number }[]
}
export function parseGitHubStackPullRequests(stdout: string): GitHubStackPullRequest[] {
const pullRequests = JSON.parse(stdout) as {
number?: unknown
html_url?: unknown
head?: { ref?: unknown }
base?: { ref?: unknown }
}[]
return pullRequests.flatMap((pullRequest) => {
const number = Number(pullRequest.number)
const url = typeof pullRequest.html_url === 'string' ? pullRequest.html_url : ''
const headRefName = typeof pullRequest.head?.ref === 'string' ? pullRequest.head.ref : ''
const baseRefName = typeof pullRequest.base?.ref === 'string' ? pullRequest.base.ref : ''
return Number.isInteger(number) && number > 0 && url && headRefName && baseRefName
? [{ number, url, headRefName, baseRefName }]
: []
})
}
export function parseGitHubStacks(stdout: string): GitHubStack[] {
const stacks = JSON.parse(stdout) as {
number?: unknown
open?: unknown
pull_requests?: { number?: unknown }[]
}[]
return stacks.flatMap((stack) => {
const number = Number(stack.number)
const pullRequests = (stack.pull_requests ?? []).flatMap((pullRequest) => {
const pullRequestNumber = Number(pullRequest.number)
return Number.isInteger(pullRequestNumber) && pullRequestNumber > 0
? [{ number: pullRequestNumber }]
: []
})
return Number.isInteger(number) && number > 0
? [{ number, open: stack.open === true, pull_requests: pullRequests }]
: []
})
}
@@ -0,0 +1,176 @@
import type * as GithubRepositoryIdentityModule from '../../repositories/github-repository-identity'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, repositoryMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
repositoryMock: vi.fn()
}))
vi.mock('../../execution/github-operation-slots', () => ({
acquire: vi.fn(),
release: vi.fn()
}))
vi.mock('../../../git/command-runner/hosted-cli/gh-exec-file', () => ({
ghExecFileAsync: ghExecFileAsyncMock
}))
vi.mock('../../repositories/github-repository-identity', async (importOriginal) => ({
...(await importOriginal<typeof GithubRepositoryIdentityModule>()),
ghRepoExecOptions: (context: { repoPath: string; connectionId?: string | null }) =>
context.connectionId ? {} : { cwd: context.repoPath },
githubRepoContext: (
repoPath: string,
connectionId?: string | null,
localGitOptions?: Record<string, unknown>
) => ({ repoPath, connectionId, localGitOptions })
}))
vi.mock('../../repositories/github-api-repository-remote-probe', () => ({
getOriginGitHubApiRepository: repositoryMock
}))
vi.mock('../../repositories/github-repository-host', () => ({
githubHostExecOptions: (repository: { host?: string }) => ({ host: repository.host })
}))
import {
prepareGitHubStackedPullRequest,
registerGitHubStackedPullRequest
} from './stacked-pr-creation'
const repository = { owner: 'acme', repo: 'orca', host: 'github.com' }
const parentReview = { number: 41, url: 'https://github.com/acme/orca/pull/41' }
const currentReview = { number: 42, url: 'https://github.com/acme/orca/pull/42' }
function pullRequest(number: number, head: string, base: string) {
return {
number,
html_url: `https://github.com/acme/orca/pull/${number}`,
head: { ref: head },
base: { ref: base }
}
}
function stack(number: number, pullRequests: number[]) {
return {
number,
open: true,
pull_requests: pullRequests.map((pullRequestNumber) => ({ number: pullRequestNumber }))
}
}
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
repositoryMock.mockReset()
repositoryMock.mockResolvedValue(repository)
})
describe('prepareGitHubStackedPullRequest', () => {
it('allows an idempotent retry after the child was already registered', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({
stdout: JSON.stringify([pullRequest(42, 'stack/child', 'stack/parent')])
})
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await prepareGitHubStackedPullRequest(
'/repo',
{
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
},
'local'
)
expect(result).toMatchObject({ ok: true, currentReview: { number: 42 } })
})
it('rejects a parent that is not the top of its stack', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([pullRequest(41, 'stack/parent', 'main')]) })
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 45])]) })
const result = await prepareGitHubStackedPullRequest(
'/repo',
{
provider: 'github',
base: 'stack/parent',
head: 'stack/child',
title: 'Child'
},
'local'
)
expect(result).toMatchObject({ ok: false, code: 'validation' })
if (!result.ok) {
expect(result.error).toContain('top pull request')
}
})
})
describe('registerGitHubStackedPullRequest', () => {
it('treats an already registered parent-child pair as success', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [41, 42])]) })
const result = await registerGitHubStackedPullRequest({
executionHostId: 'local',
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({ ok: true, stackNumber: 50 })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not claim registration when the stack no longer holds the parent', async () => {
// A missing parent gives -1; reading its successor would falsely identify the first entry as its child.
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
.mockResolvedValueOnce({ stdout: JSON.stringify([stack(50, [42])]) })
const result = await registerGitHubStackedPullRequest({
executionHostId: 'local',
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
error: 'The pull request already belongs to a different GitHub stack.',
reason: 'stack_conflict',
createdReview: currentReview
})
})
it('preserves the created PR when registration fails', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({ stdout: '[]' })
.mockRejectedValueOnce(new Error('HTTP 422'))
const result = await registerGitHubStackedPullRequest({
executionHostId: 'local',
repoPath: '/repo',
repository,
parentReview,
currentReview
})
expect(result).toMatchObject({
ok: false,
createdReview: currentReview
})
})
})
@@ -0,0 +1,286 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { hostedReviewSshConnectionId } from '../../../source-control/hosted-review-execution-host'
import type {
CreateStackedHostedReviewInput,
CreateStackedHostedReviewResult
} from '../../../../shared/source-control/hosted-review/hosted-review-contracts'
import { isDefaultGitHubHost } from '../../../../shared/github/repositories/repository-identity-key'
import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../../../shared/source-control/hosted-review/hosted-review-refs'
import { acquire, release } from '../../execution/github-operation-slots'
import { ghExecFileAsync } from '../../../git/command-runner/hosted-cli/gh-exec-file'
import { ghRepoExecOptions, githubRepoContext } from '../../repositories/github-repository-identity'
import { getOriginGitHubApiRepository } from '../../repositories/github-api-repository-remote-probe'
import { githubHostExecOptions } from '../../repositories/github-repository-host'
import type { GitHubRepositoryIdentity as GitHubApiRepository } from '../../../../shared/github/repositories/repository-identity'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../../../source-control/hosted-review-git-options'
import {
parseGitHubStackPullRequests,
parseGitHubStacks,
type GitHubStack,
type GitHubStackPullRequest,
type NumberedHostedReviewSummary
} from './github-stack-api-responses'
import { stackedReviewFailure } from '../../../../shared/source-control/hosted-review/stacked-hosted-review-failure-copy'
type StackedPullRequestPlan =
| {
ok: true
repository: GitHubApiRepository
parentReview: GitHubStackPullRequest
currentReview: GitHubStackPullRequest | null
}
| Extract<CreateStackedHostedReviewResult, { ok: false }>
function isStacksUnavailableError(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
return message.includes('http 404') || message.includes('feature not supported')
}
function ghOptions(
repoPath: string,
repository: GitHubApiRepository,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
) {
return {
...ghRepoExecOptions(
githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
),
...githubHostExecOptions(repository),
timeout: 60_000
}
}
async function findOpenPullRequestsForBranch(
repository: GitHubApiRepository,
branch: string,
execOptions: ReturnType<typeof ghOptions>,
base?: string
): Promise<GitHubStackPullRequest[]> {
const head = encodeURIComponent(`${repository.owner}:${branch}`)
const baseQuery = base ? `&base=${encodeURIComponent(base)}` : ''
const endpoint = `repos/${repository.owner}/${repository.repo}/pulls?head=${head}${baseQuery}&state=open&per_page=2`
const { stdout } = await ghExecFileAsync(['api', endpoint], execOptions)
return parseGitHubStackPullRequests(stdout)
}
async function getStacksForPullRequest(
repository: GitHubApiRepository,
pullRequestNumber: number,
execOptions: ReturnType<typeof ghOptions>
): Promise<GitHubStack[]> {
const endpoint = `repos/${repository.owner}/${repository.repo}/stacks?pull_request=${pullRequestNumber}`
const { stdout } = await ghExecFileAsync(['api', endpoint], execOptions)
return parseGitHubStacks(stdout)
}
function validateParentStack(
parentReview: NumberedHostedReviewSummary,
stacks: GitHubStack[]
): Extract<CreateStackedHostedReviewResult, { ok: false }> | null {
if (stacks.length > 1) {
return stackedReviewFailure('stack_parent_multiple_stacks')
}
const stack = stacks[0]
if (!stack) {
return null
}
if (!stack.open) {
return stackedReviewFailure('stack_parent_closed')
}
if (stack.pull_requests.at(-1)?.number !== parentReview.number) {
return stackedReviewFailure('stack_parent_not_top')
}
return null
}
export async function prepareGitHubStackedPullRequest(
repoPath: string,
input: CreateStackedHostedReviewInput,
executionHostId: ExecutionHostId,
options: HostedReviewExecutionOptions = {}
): Promise<StackedPullRequestPlan> {
if (input.provider !== 'github') {
return stackedReviewFailure('stack_github_only')
}
// `gh` runs on this client whatever the host; only the git reads under it are routed.
const connectionId = hostedReviewSshConnectionId(executionHostId)
const context = githubRepoContext(repoPath, connectionId, getHostedReviewLocalGitOptions(options))
const repository = await getOriginGitHubApiRepository(
repoPath,
connectionId,
getHostedReviewLocalGitOptions(options)
)
if (!repository || !isDefaultGitHubHost(repository.host)) {
return stackedReviewFailure('stack_github_com_only')
}
const execOptions = {
...ghRepoExecOptions(context),
...githubHostExecOptions(repository),
timeout: 60_000
}
const base = normalizeHostedReviewBaseRef(input.base).trim()
const head = input.head ? normalizeHostedReviewHeadRef(input.head).trim() : ''
if (!base || !head || base.toLowerCase() === head.toLowerCase()) {
return stackedReviewFailure('stack_same_parent')
}
await acquire()
try {
const [parentPullRequests, currentPullRequests] = await Promise.all([
findOpenPullRequestsForBranch(repository, base, execOptions),
findOpenPullRequestsForBranch(repository, head, execOptions, base)
])
if (parentPullRequests.length === 0) {
return stackedReviewFailure('stack_parent_without_review', { base })
}
if (parentPullRequests.length !== 1) {
return stackedReviewFailure('stack_parent_ambiguous', { base })
}
if (currentPullRequests.length > 1) {
return stackedReviewFailure('stack_head_ambiguous', { head })
}
const parentReview = parentPullRequests[0]
const currentReview = currentPullRequests[0] ?? null
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(repository, parentReview.number, execOptions),
currentReview
? getStacksForPullRequest(repository, currentReview.number, execOptions)
: Promise.resolve([])
])
if (
currentReview &&
registeredStackNumber(parentReview, currentReview, parentStacks, currentStacks)
) {
return { ok: true, repository, parentReview, currentReview }
}
if (currentStacks.length > 0) {
return stackedReviewFailure('stack_conflict')
}
const parentError = validateParentStack(parentReview, parentStacks)
return (
parentError ?? {
ok: true,
repository,
parentReview,
currentReview
}
)
} catch (error) {
console.warn('GitHub stack creation preflight failed:', error)
return isStacksUnavailableError(error)
? stackedReviewFailure('stacks_unavailable')
: stackedReviewFailure('stack_parent_unverified', {}, 'unknown')
} finally {
release()
}
}
function registeredStackNumber(
parentReview: NumberedHostedReviewSummary,
currentReview: NumberedHostedReviewSummary,
parentStacks: GitHubStack[],
currentStacks: GitHubStack[]
): number | null {
const parentStack = parentStacks[0]
const currentStack = currentStacks[0]
if (!parentStack || !currentStack || parentStack.number !== currentStack.number) {
return null
}
const parentPosition = parentStack.pull_requests.findIndex(
(pullRequest) => pullRequest.number === parentReview.number
)
// A missing parent gives -1; its successor index 0 must not look like an existing pair.
if (parentPosition === -1) {
return null
}
return parentStack.pull_requests[parentPosition + 1]?.number === currentReview.number
? parentStack.number
: null
}
export async function registerGitHubStackedPullRequest(args: {
repoPath: string
repository: GitHubApiRepository
parentReview: NumberedHostedReviewSummary
currentReview: NumberedHostedReviewSummary
executionHostId: ExecutionHostId
options?: HostedReviewExecutionOptions
}): Promise<CreateStackedHostedReviewResult> {
const options = args.options ?? {}
const connectionId = hostedReviewSshConnectionId(args.executionHostId)
const execOptions = ghOptions(args.repoPath, args.repository, connectionId, options)
await acquire()
try {
const [parentStacks, currentStacks] = await Promise.all([
getStacksForPullRequest(args.repository, args.parentReview.number, execOptions),
getStacksForPullRequest(args.repository, args.currentReview.number, execOptions)
])
const existingStackNumber = registeredStackNumber(
args.parentReview,
args.currentReview,
parentStacks,
currentStacks
)
if (existingStackNumber) {
return {
ok: true,
...args.currentReview,
stackNumber: existingStackNumber,
parentReview: args.parentReview
}
}
if (currentStacks.length > 0) {
return {
...stackedReviewFailure('stack_conflict'),
createdReview: args.currentReview
}
}
const parentError = validateParentStack(args.parentReview, parentStacks)
if (parentError) {
return { ...parentError, createdReview: args.currentReview }
}
const parentStack = parentStacks[0]
const endpoint = parentStack
? `repos/${args.repository.owner}/${args.repository.repo}/stacks/${parentStack.number}/add`
: `repos/${args.repository.owner}/${args.repository.repo}/stacks`
const pullRequests = parentStack
? [args.currentReview.number]
: [args.parentReview.number, args.currentReview.number]
const command = ['api', '-X', 'POST', endpoint]
for (const pullRequest of pullRequests) {
command.push('-F', `pull_requests[]=${pullRequest}`)
}
const { stdout } = await ghExecFileAsync(command, {
...execOptions,
idempotent: false
})
const stackNumber = Number((JSON.parse(stdout) as { number?: unknown }).number)
if (!Number.isInteger(stackNumber) || stackNumber <= 0) {
throw new Error('GitHub returned an invalid stack response.')
}
return {
ok: true,
...args.currentReview,
stackNumber,
parentReview: args.parentReview
}
} catch (error) {
console.warn('GitHub stack registration failed:', error)
return {
...(isStacksUnavailableError(error)
? stackedReviewFailure('stack_registration_unavailable')
: stackedReviewFailure('stack_registration_failed', {}, 'unknown')),
createdReview: args.currentReview
}
} finally {
release()
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() }))
vi.mock('../../../git/command-runner/git-exec-file', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
import { fetchGitHubPullRequestHeadRef } from './pr-head-tracking-ref'
describe('fetchPrHeadTrackingRef', () => {
it('rejects invalid PR numbers and option-shaped remotes before running git', async () => {
await expect(
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, 'origin', 4.2)
).rejects.toThrow('Invalid pull request number')
await expect(
fetchGitHubPullRequestHeadRef({ path: '/repo', connectionId: null }, '--upload-pack=x', 42)
).rejects.toThrow('must not start with "-"')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,65 @@
import { gitExecFileAsync } from '../../../git/command-runner/git-exec-file'
import {
githubPullRequestHeadLocalRef,
isSafeReviewHeadFetchRemote,
isValidReviewHeadNumber,
REVIEW_HEAD_FETCH_TIMEOUT_MS
} from '../../../../shared/review-head-tracking-ref'
import { getReviewHeadRemoteComponent } from '../../../git/remotes/review-head-remote-identity'
import {
SSH_GIT_RECONNECT_MESSAGE,
SshHostNotServedError
} from '../../../providers/ssh-host-not-served-error'
import { getRepoRoutingConnectionId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
type LocalGitExecOptions = {
cwd: string
wslDistro?: string
}
export async function fetchPrHeadTrackingRef(
repo: { path: string } & Pick<Repo, 'connectionId' | 'executionHostId'>,
remote: string,
branch: string,
options: { localGitExecOptions?: LocalGitExecOptions } = {}
): Promise<void> {
const sshTargetId = getRepoRoutingConnectionId(repo)
if (sshTargetId) {
throw new SshHostNotServedError(sshTargetId, SSH_GIT_RECONNECT_MESSAGE)
}
await gitExecFileAsync(
['fetch', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`],
{
...(options.localGitExecOptions ?? { cwd: repo.path }),
timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS
}
)
}
export async function fetchGitHubPullRequestHeadRef(
repo: { path: string } & Pick<Repo, 'connectionId' | 'executionHostId'>,
remote: string,
prNumber: number,
options: { localGitExecOptions?: LocalGitExecOptions } = {}
): Promise<string> {
if (!isValidReviewHeadNumber(prNumber)) {
throw new Error(`Invalid pull request number: ${String(prNumber)}`)
}
if (!isSafeReviewHeadFetchRemote(remote)) {
throw new Error('Pull request fetch remote must not start with "-".')
}
const sshTargetId = getRepoRoutingConnectionId(repo)
if (sshTargetId) {
throw new SshHostNotServedError(sshTargetId, SSH_GIT_RECONNECT_MESSAGE)
}
const localGitExecOptions = options.localGitExecOptions ?? { cwd: repo.path }
const remoteComponent = await getReviewHeadRemoteComponent(remote, localGitExecOptions)
// Return the written ref so callers do not recompute its remote identity.
const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber)
await gitExecFileAsync(
['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`],
{ ...localGitExecOptions, timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
)
return localRef
}
@@ -0,0 +1,34 @@
import { wslDistroStoppedMessage } from '../../../../shared/wsl-distro-not-running-refusal'
import type { ReviewStartPointFailure } from '../../../../shared/review-start-point-failure-copy'
import {
guardWslUncPathAccess,
WslDistroStoppedError,
WslDistroUnverifiableError
} from '../../../wsl/filesystem/wsl-unc-access-gate'
/**
* Resolves a PR start point unless its repo's WSL distro is stopped (E9): a background push-target
* ensure gets a start-point failure instead of starting it. `wakeWslDistro` marks the user creating
* or launching from the PR, which may start it.
*/
export async function resolvePrStartPointUnlessWslStopped<T>(
repo: { path: string; wslDistro?: string },
options: { wakeWslDistro?: boolean },
resolve: () => Promise<T>
): Promise<T | ReviewStartPointFailure> {
try {
return await guardWslUncPathAccess(repo.path, resolve, {
wake: options.wakeWslDistro === true,
wslDistro: repo.wslDistro
})
} catch (error) {
if (error instanceof WslDistroStoppedError) {
// Uncoded: the English text is the refusal contract, and the push-target ensure only logs it.
return { error: wslDistroStoppedMessage(error.distro, false) }
}
if (error instanceof WslDistroUnverifiableError) {
return { error: error.message }
}
throw error
}
}
@@ -0,0 +1,209 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getPullRequestPushTargetMock, getWorkItemMock } = vi.hoisted(() => ({
getPullRequestPushTargetMock: vi.fn(),
getWorkItemMock: vi.fn()
}))
vi.mock('../../client/lookup/pull-request-push-target', () => ({
getPullRequestPushTarget: getPullRequestPushTargetMock
}))
vi.mock('../../client/fetch/get-work-item', () => ({
getWorkItem: getWorkItemMock
}))
import { resolveGitHubPrStartPoint } from './pr-start-point'
import { reviewHeadRemoteRefComponent } from '../../../../shared/review-head-tracking-ref'
const ORIGIN_URL = 'git@github.com:acme/orca.git'
const ORIGIN_COMPONENT = reviewHeadRemoteRefComponent('origin', ORIGIN_URL)
const durablePrLocalRef = (prNumber: number): string =>
`refs/orca/pull/${ORIGIN_COMPONENT}/${prNumber}`
const durablePrRev = (prNumber: number): string => `${durablePrLocalRef(prNumber)}^{commit}`
const remoteGetUrl = (args: string[]): { stdout: string; stderr: string } | null =>
args[0] === 'remote' && args[1] === 'get-url' ? { stdout: `${ORIGIN_URL}\n`, stderr: '' } : null
describe('resolveGitHubPrStartPoint', () => {
const fetchPullRequestHeadRefMock = vi.fn()
beforeEach(() => {
getPullRequestPushTargetMock.mockReset()
getWorkItemMock.mockReset()
fetchPullRequestHeadRefMock.mockReset()
fetchPullRequestHeadRefMock.mockImplementation(async (_remote: string, prNumber: number) =>
durablePrLocalRef(prNumber)
)
})
it('captures the fork PR head from a dedicated ref, not the shared FETCH_HEAD', async () => {
getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable'))
const fetchRemoteTrackingRef = vi.fn(async () => {})
// Concurrent fetches can clobber FETCH_HEAD; the durable Orca ref must remain authoritative.
const gitExec = vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse') {
const ref = args.at(-1)
if (ref === 'FETCH_HEAD') {
return { stdout: 'mainbranchtip000\n', stderr: '' }
}
if (ref === durablePrRev(1849)) {
return { stdout: 'prheadsha111\n', stderr: '' }
}
throw new Error(`unexpected rev-parse ref: ${ref}`)
}
return { stdout: '', stderr: '' }
})
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
prNumber: 1849,
headRefName: 'feat/onboarding-model-choice-782',
isCrossRepository: true,
gitExec,
fetchRemoteTrackingRef,
fetchPullRequestHeadRef: fetchPullRequestHeadRefMock,
resolveRemote: async () => 'origin'
})
expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849)
expect(gitExec).not.toHaveBeenCalledWith(['remote', 'get-url', 'origin'])
expect(gitExec).not.toHaveBeenCalledWith(['rev-parse', '--verify', 'FETCH_HEAD'])
expect(result).toEqual({
baseBranch: 'prheadsha111',
headSha: 'prheadsha111',
branchNameOverride: 'feat/onboarding-model-choice-782'
})
})
it('keeps the durable PR head when the head fetch fails but the local ref resolves', async () => {
// A prior durable fetch can survive transient transport failure.
getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable'))
fetchPullRequestHeadRefMock.mockRejectedValue(
new Error('fatal: unable to access repo: Could not resolve host: github.com')
)
const fetchRemoteTrackingRef = vi.fn(async () => {})
const gitExec = vi.fn(async (args: string[]) => {
const url = remoteGetUrl(args)
if (url) {
return url
}
if (args[0] === 'rev-parse' && args[2] === durablePrRev(1849)) {
return { stdout: 'pinnedheadsha\n', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'refs/remotes/origin/main^{commit}') {
return { stdout: 'base-commit-sha\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
prNumber: 1849,
headRefName: 'contributor/fix',
baseRefName: 'main',
isCrossRepository: true,
gitExec,
fetchRemoteTrackingRef,
fetchPullRequestHeadRef: fetchPullRequestHeadRefMock,
resolveRemote: async () => 'origin'
})
expect(result).toEqual({
baseBranch: 'pinnedheadsha',
compareBaseRef: 'refs/remotes/origin/main',
headSha: 'pinnedheadsha',
branchNameOverride: 'contributor/fix'
})
} finally {
warnSpy.mockRestore()
}
})
it.each([
["fatal: couldn't find remote ref refs/pull/1849/head", 'deleted PR / cleaned fork'],
['Authentication failed. Check your remote credentials.', 'auth failure'],
[
'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.',
'stale relay'
]
])('fails hard instead of soft-keeping the durable PR head on: %s', async (message) => {
// Auth/deleted-ref/stale-relay failures must not return a dead or unauthorized cached tip.
getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable'))
fetchPullRequestHeadRefMock.mockRejectedValue(new Error(message))
const fetchRemoteTrackingRef = vi.fn(async () => {})
const gitExec = vi.fn(async (args: string[]) => {
const url = remoteGetUrl(args)
if (url) {
return url
}
if (args[0] === 'rev-parse' && args[2] === durablePrRev(1849)) {
return { stdout: 'pinnedheadsha\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
prNumber: 1849,
headRefName: 'contributor/fix',
baseRefName: 'main',
isCrossRepository: true,
gitExec,
fetchRemoteTrackingRef,
fetchPullRequestHeadRef: fetchPullRequestHeadRefMock,
resolveRemote: async () => 'origin'
})
expect(result).toMatchObject({
error: `Failed to fetch refs/pull/1849/head: ${message}`,
errorCode: 'fetchFailed',
errorParams: { ref: 'refs/pull/1849/head' },
errorDetail: message
})
expect(gitExec).not.toHaveBeenCalledWith(['rev-parse', '--verify', durablePrRev(1849)])
})
it('soft-keeps the durable PR head on an exec-timeout kill', async () => {
getPullRequestPushTargetMock.mockRejectedValue(new Error('head repo is unavailable'))
const timeoutError = Object.assign(new Error('Command failed: git fetch --no-tags origin'), {
killed: true,
signal: 'SIGTERM'
})
fetchPullRequestHeadRefMock.mockRejectedValue(timeoutError)
const fetchRemoteTrackingRef = vi.fn(async () => {})
const gitExec = vi.fn(async (args: string[]) => {
const url = remoteGetUrl(args)
if (url) {
return url
}
if (args[0] === 'rev-parse' && args[2] === durablePrRev(1849)) {
return { stdout: 'pinnedheadsha\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await resolveGitHubPrStartPoint({
repoPath: '/repo-root',
prNumber: 1849,
headRefName: 'contributor/fix',
isCrossRepository: true,
gitExec,
fetchRemoteTrackingRef,
fetchPullRequestHeadRef: fetchPullRequestHeadRefMock,
resolveRemote: async () => 'origin'
})
expect(result).toEqual({
baseBranch: 'pinnedheadsha',
headSha: 'pinnedheadsha',
branchNameOverride: 'contributor/fix'
})
} finally {
warnSpy.mockRestore()
}
})
})
@@ -0,0 +1,248 @@
import { withGitHubRepoExecution } from '../../repositories/github-repository-identity'
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import type { GhAccountBinding } from '../../../../shared/github/accounts/account-binding'
import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types'
import { fetchCompareBaseRefWithLocalFallback } from '../../../git/remotes/compare-base-ref-fetch'
import { isMissingRemoteRefGitError } from '../../../../shared/git/refs/git-missing-remote-ref'
import { isTransientReviewHeadFetchError } from '../../../git/remotes/fetch-error-classification'
import { getPullRequestPushTarget } from '../../client/lookup/pull-request-push-target'
import { getWorkItem } from '../../client/fetch/get-work-item'
import {
githubPullRequestHeadLocalRef,
reviewHeadRemoteRefComponent
} from '../../../../shared/review-head-tracking-ref'
import { reviewStartPointFailure } from '../../../../shared/review-start-point-failure-copy'
type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>
type ResolveGitHubPrStartPointArgs = {
repoPath: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
issueSourcePreference?: IssueSourcePreference
connectionId?: string | null
localGitOptions?: { wslDistro?: string; ghAccount?: GhAccountBinding }
gitExec: GitExec
fetchRemoteTrackingRef: (remote: string, branch: string) => Promise<void>
// Return the written durable ref so success never recomputes its remote identity.
fetchPullRequestHeadRef: (remote: string, prNumber: number) => Promise<string>
resolveRemote: () => Promise<string>
}
type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string }
export async function resolveGitHubPrStartPoint(
args: ResolveGitHubPrStartPointArgs
): Promise<ResolveGitHubPrStartPointResult> {
return withGitHubRepoExecution(
args.repoPath,
args.connectionId,
args.localGitOptions ?? {},
async (): Promise<ResolveGitHubPrStartPointResult> => {
let headRefName = args.headRefName?.trim() ?? ''
let baseRefName = args.baseRefName?.trim() ?? ''
let isCrossRepository = args.isCrossRepository === true
let pushTarget: GitPushTarget | undefined
let maintainerCanModify: boolean | undefined
const resolvePushTarget = async (): Promise<void> => {
if (pushTarget) {
return
}
try {
const resolved = await getPullRequestPushTarget(
args.repoPath,
args.prNumber,
args.connectionId ?? null,
args.localGitOptions ?? {},
args.issueSourcePreference
)
pushTarget = resolved?.pushTarget
maintainerCanModify = resolved?.maintainerCanModify
} catch {
// Missing fork metadata can block push-target discovery while the PR head ref remains fetchable.
pushTarget = undefined
}
}
if (!headRefName) {
const item = await getWorkItem(
args.repoPath,
args.prNumber,
'pr',
args.connectionId ?? null,
args.localGitOptions ?? {},
args.issueSourcePreference
)
if (!item || item.type !== 'pr') {
return reviewStartPointFailure('github.prNotFound', { number: args.prNumber })
}
headRefName = (item.branchName ?? '').trim()
baseRefName = (item.baseRefName ?? '').trim()
if (!headRefName) {
return reviewStartPointFailure('github.prHeadBranchMissing', { number: args.prNumber })
}
if (item.isCrossRepository === true) {
isCrossRepository = true
}
}
if (isCrossRepository) {
await resolvePushTarget()
}
let remote: string
try {
remote = await args.resolveRemote()
} catch (error) {
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
const compareBaseRef = baseRefName ? `refs/remotes/${remote}/${baseRefName}` : undefined
const fetchCompareBaseRef = (): Promise<boolean> =>
fetchCompareBaseRefWithLocalFallback({
compareBaseRef,
fetchCompareBaseRef: () => args.fetchRemoteTrackingRef(remote, baseRefName),
gitExec: args.gitExec,
logLabel: '[github:resolvePrStartPoint]',
logContext: { remote, baseRefName, prNumber: args.prNumber }
})
const fetchPullRequestHeadSha = async (): Promise<
{ baseBranch: string } | { error: string }
> => {
const pullRef = `refs/pull/${args.prNumber}/head`
// Failed fetches return no ref path, so only soft-keep must independently resolve its identity.
let softKeepLocalRefPromise: Promise<string | null> | undefined
const resolveSoftKeepLocalRef = (): Promise<string | null> => {
softKeepLocalRefPromise ??= (async () => {
try {
const { stdout } = await args.gitExec(['remote', 'get-url', remote])
const remoteUrl = stdout.trim()
if (!remoteUrl) {
return null
}
return githubPullRequestHeadLocalRef(
reviewHeadRemoteRefComponent(remote, remoteUrl),
args.prNumber
)
} catch {
return null
}
})()
return softKeepLocalRefPromise
}
const resolveDurableHeadSha = async (localRef: string | null): Promise<string | null> => {
if (!localRef) {
return null
}
try {
const { stdout } = await args.gitExec(['rev-parse', '--verify', `${localRef}^{commit}`])
return stdout.trim() || null
} catch {
return null
}
}
try {
const localRef = await args.fetchPullRequestHeadRef(remote, args.prNumber)
const sha = await resolveDurableHeadSha(localRef)
if (!sha) {
return reviewStartPointFailure('github.forkHeadUnresolved', { number: args.prNumber })
}
return { baseBranch: sha }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Reuse durable heads only for transient transport failures; deleted/auth/stale-relay errors stay actionable.
if (isTransientReviewHeadFetchError(error)) {
const localSha = await resolveDurableHeadSha(await resolveSoftKeepLocalRef())
if (localSha) {
console.warn(
'[github:resolvePrStartPoint] PR head fetch failed; using durable local ref',
{
remote,
prNumber: args.prNumber,
error: message.split('\n')[0]
}
)
return { baseBranch: localSha }
}
}
return reviewStartPointFailure('fetchFailed', {
ref: pullRef,
detail: message.split('\n')[0]
})
}
}
// Upstream exposes fork heads through refs/pull/<N>/head even without a configured fork remote.
if (isCrossRepository) {
const result = await fetchPullRequestHeadSha()
if ('error' in result) {
return result
}
const compareBaseFetched = await fetchCompareBaseRef()
// Keep the contributor's branch name locally; push still targets the fork.
return {
...result,
...(compareBaseFetched && compareBaseRef ? { compareBaseRef } : {}),
headSha: result.baseBranch,
branchNameOverride: headRefName,
...(pushTarget ? { pushTarget } : {}),
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
}
try {
await args.fetchRemoteTrackingRef(remote, headRefName)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// Missing fork metadata can mimic a same-repo branch; only missing-ref errors allow pull-head fallback.
if (isMissingRemoteRefGitError(error)) {
const result = await fetchPullRequestHeadSha()
if (!('error' in result)) {
await resolvePushTarget()
const compareBaseFetched = await fetchCompareBaseRef()
return {
...result,
...(compareBaseFetched && compareBaseRef ? { compareBaseRef } : {}),
headSha: result.baseBranch,
branchNameOverride: headRefName,
...(pushTarget ? { pushTarget } : {}),
...(maintainerCanModify !== undefined ? { maintainerCanModify } : {})
}
}
// Return the actionable pull-head fallback failure instead of the earlier branch miss.
return result
}
return reviewStartPointFailure('fetchFailed', {
ref: `${remote}/${headRefName}`,
detail: message.split('\n')[0]
})
}
const remoteRef = `${remote}/${headRefName}`
let headSha: string
try {
const { stdout } = await args.gitExec(['rev-parse', '--verify', remoteRef])
headSha = stdout.trim()
} catch {
return reviewStartPointFailure('remoteRefMissing', { ref: remoteRef })
}
if (!headSha) {
return reviewStartPointFailure('github.headShaEmpty', { number: args.prNumber })
}
const compareBaseFetched = await fetchCompareBaseRef()
return {
baseBranch: headSha,
...(compareBaseFetched && compareBaseRef ? { compareBaseRef } : {}),
headSha,
branchNameOverride: headRefName,
pushTarget: { remoteName: remote, branchName: headRefName }
}
}
)
}
@@ -0,0 +1,50 @@
import type { IssueSourcePreference } from '../../../../shared/repo-types'
import type { GhAccountBinding } from '../../../../shared/github/accounts/account-binding'
import { pickPreferredGitRemote } from '../../../../shared/preferred-git-remote'
import { getDefaultRemote } from '../../../git/remotes/repo-remote-queries'
import { getGitHubApiRepositoryForRemote } from '../../repositories/github-api-repository-remote-probe'
type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>
// Explicit origin matches issue listing; otherwise honor the upstream project's PR namespace.
export async function resolveGitHubReviewHeadRemote(args: {
repoPath: string
issueSourcePreference?: IssueSourcePreference
connectionId?: string | null
localGitOptions?: { wslDistro?: string; ghAccount?: GhAccountBinding }
gitExec: GitExec
}): Promise<string> {
const { stdout } = await args.gitExec(['remote'])
const remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (args.issueSourcePreference === 'origin') {
if (remotes.includes('origin')) {
return 'origin'
}
throw new Error('Repo has no configured origin remote.')
}
// Avoid per-remote identity/auth probes unless multiple remotes make selection ambiguous.
if (remotes.length > 1) {
for (const remote of ['upstream', 'origin']) {
if (!remotes.includes(remote)) {
continue
}
const repository = await getGitHubApiRepositoryForRemote(
args.repoPath,
remote,
args.connectionId ?? null,
args.localGitOptions ?? {}
)
if (repository) {
return remote
}
}
}
// Without GitHub identity, retain transport-specific remote selection.
if (args.connectionId) {
return pickPreferredGitRemote(remotes)
}
return getDefaultRemote(args.repoPath, args.localGitOptions ?? {}, remotes)
}