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