diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index e99076ee73a..e56627b4509 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -100,6 +100,7 @@ import { setPRAutoMerge, updatePRState, updatePRTitle, + _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests } from './client' @@ -1686,6 +1687,41 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toHaveLength(1) }) + it('bounds merge metadata cache entries across many base branches', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + let prViewCount = 0 + ghExecFileAsyncMock.mockImplementation(async (args) => { + if (args.includes('graphql')) { + return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) } + } + prViewCount += 1 + return { + stdout: JSON.stringify({ + number: prViewCount, + title: 'PR', + state: 'OPEN', + url: `https://github.com/stablyai/orca/pull/${prViewCount}`, + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: `base-${prViewCount}`, + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + } + }) + + for (let i = 0; i < 260; i++) { + await getPRForBranch('/repo-root', `feature/${i}`, i + 1) + } + + expect(_getMergeQueueCacheSizeForTests()).toBe(256) + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index dd1288475c2..488aaa59a67 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -90,6 +90,7 @@ type GhExecOptions = ReturnType const ORCA_REPO = 'stablyai/orca' const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 +const MERGE_QUEUE_CACHE_MAX_ENTRIES = 256 type GitHubRepositoryMergeMetadata = { mergeQueueRequired: boolean | null mergeMethodSettings?: GitHubPRMergeMethodSettings @@ -103,6 +104,25 @@ export function _resetMergeQueueCacheForTests(): void { repositoryMergeMetadataCache.clear() } +export function _getMergeQueueCacheSizeForTests(): number { + return repositoryMergeMetadataCache.size +} + +function pruneRepositoryMergeMetadataCache(now = Date.now()): void { + for (const [cacheKey, cached] of repositoryMergeMetadataCache) { + if (cached.expiresAt <= now) { + repositoryMergeMetadataCache.delete(cacheKey) + } + } + while (repositoryMergeMetadataCache.size > MERGE_QUEUE_CACHE_MAX_ENTRIES) { + const oldestKey = repositoryMergeMetadataCache.keys().next().value + if (oldestKey === undefined) { + break + } + repositoryMergeMetadataCache.delete(oldestKey) + } +} + async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { await getRateLimit() const guard = rateLimitGuard(bucket) @@ -1803,10 +1823,16 @@ function cacheRepositoryMergeMetadata( value: GitHubRepositoryMergeMetadata, ttlMs: number ): void { + const now = Date.now() + pruneRepositoryMergeMetadataCache(now) + // Why: merge metadata is keyed by user-controlled branch names. Keep the + // per-session cache bounded even if many short-lived branches are inspected. + repositoryMergeMetadataCache.delete(cacheKey) repositoryMergeMetadataCache.set(cacheKey, { value, - expiresAt: Date.now() + ttlMs + expiresAt: now + ttlMs }) + pruneRepositoryMergeMetadataCache(now) } async function detectRepositoryMergeMetadata( @@ -1817,8 +1843,9 @@ async function detectRepositoryMergeMetadata( const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${ branchName ?? '__repo__' }` + pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { + if (cached) { return cached.value } const guard = rateLimitGuard('graphql')