Files
orca/src/main/github/default-branch-stale-pr.test.ts
T
OrcaWinandOrcaWin ed7fe7d5dc Combine exact PR fields and stack summary queries (#35295)
* Read sidebar PR and stack metadata in one GraphQL query

* Preserve direct PR error recovery without duplicate GraphQL reads

* Keep combined sidebar queries out of merge preflight

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 17:46:20 -07:00

390 lines
14 KiB
TypeScript

// Implicit non-open PRs must not attach to the default branch; exercise real default-ref resolution over mocked Git.
import { beforeEach, describe, expect, it, vi } from 'vitest'
type RateLimitGuardResult =
| { blocked: false }
| { blocked: true; remaining: number; limit: number; resetAt: number }
const {
execFileAsyncMock,
ghExecFileAsyncMock,
getOwnerRepoMock,
getIssueOwnerRepoMock,
getOwnerRepoForRemoteMock,
resolvePRRepositoryCandidatesMock,
getRemoteUrlForRepoMock,
gitExecFileAsyncMock,
getRateLimitMock,
rateLimitGuardMock,
noteRateLimitSpendMock,
noteRepositoryRateLimitSpendMock,
ghRepoExecOptionsMock,
githubRepoContextMock,
readLocalGitConfigSignatureMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
resolvePRRepositoryCandidatesMock: vi.fn(),
getRemoteUrlForRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getRateLimitMock: vi.fn(),
rateLimitGuardMock: vi.fn<(bucket?: string) => RateLimitGuardResult>(() => ({
blocked: false
})),
noteRateLimitSpendMock: vi.fn(),
noteRepositoryRateLimitSpendMock: vi.fn(),
ghRepoExecOptionsMock: vi.fn((context) =>
context.connectionId
? {}
: {
cwd: context.repoPath,
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
}
),
githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({
repoPath,
connectionId: connectionId ?? null,
...localGitOptions
})),
readLocalGitConfigSignatureMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: getIssueOwnerRepoMock,
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
resolvePRRepositoryCandidates: resolvePRRepositoryCandidatesMock,
getRemoteUrlForRepo: getRemoteUrlForRepoMock,
gitExecFileAsync: gitExecFileAsyncMock,
ghRepoExecOptions: ghRepoExecOptionsMock,
githubRepoContext: githubRepoContextMock,
classifyGhError: (stderr: string) => {
const lower = stderr.toLowerCase()
if (lower.includes('not found') || stderr.includes('HTTP 404')) {
return { type: 'not_found', message: stderr }
}
if (lower.includes('rate limit')) {
return { type: 'rate_limited', message: stderr }
}
return { type: 'unknown', message: stderr }
},
parseGitHubOwnerRepo: (remoteUrl: string) => {
const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
return match ? { owner: match[1], repo: match[2] } : null
},
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('./local-git-config-signature', () => ({
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
}))
vi.mock('./rate-limit', () => ({
getRateLimit: getRateLimitMock,
rateLimitGuard: rateLimitGuardMock,
noteRateLimitSpend: noteRateLimitSpendMock,
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock
}))
import { getPRForBranch } from './client/lookup/get-pr-for-branch'
import { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
import { _resetOwnerRepoCache } from './gh-utils'
import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache'
import { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache'
import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch'
const DEFAULT_BRANCH_REF = 'refs/remotes/origin/master'
function primeGitExecForDefaultBranch({
defaultRef = DEFAULT_BRANCH_REF,
headOid = 'checkout-head-oid'
}: { defaultRef?: string; headOid?: string } = {}): void {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'for-each-ref' && args.includes('--format=%(refname)%00%(symref)')) {
return { stdout: `refs/remotes/origin/HEAD\0${defaultRef}\n`, stderr: '' }
}
if (args[0] === 'rev-parse' && args[1] === 'HEAD') {
return { stdout: `${headOid}\n`, stderr: '' }
}
if (args[0] === 'for-each-ref') {
return { stdout: '', stderr: '' }
}
throw new Error(`unexpected git call: ${args.join(' ')}`)
})
}
type RestPROverrides = {
number?: number
state?: string
merged_at?: string | null
head_ref?: string
head_sha?: string
}
function restPR({
number = 7,
state = 'closed',
merged_at = null,
head_ref = 'master',
head_sha = 'stale-master-oid'
}: RestPROverrides = {}): Record<string, unknown> {
return {
number,
title: 'Historical PR',
state,
merged_at,
html_url: `https://github.com/acme/widgets/pull/${number}`,
updated_at: '2024-01-01T00:00:00Z',
draft: false,
mergeable: null,
base: { ref: 'old-release', sha: 'base-oid' },
head: { ref: head_ref, sha: head_sha }
}
}
// Fixture REST branch lookup only; hydration failures retain its payload.
function primeGhExecWithBranchList(list: Record<string, unknown>[]): void {
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'api' && args[1]?.includes('pulls?head=')) {
return { stdout: `HTTP/2.0 200 OK\n\n${JSON.stringify(list)}` }
}
throw new Error(`gh unavailable: ${args.join(' ')}`)
})
}
describe('issue #9171: default-branch checkout must not attach a stale non-open PR', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getIssueOwnerRepoMock.mockReset()
getOwnerRepoForRemoteMock.mockReset()
getOwnerRepoForRemoteMock.mockImplementation(
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
)
resolvePRRepositoryCandidatesMock.mockReset()
resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => {
const origin = await getOwnerRepoMock(repoPath, connectionId)
return { candidates: origin ? [origin] : [], headRepo: origin }
})
getRemoteUrlForRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
getRateLimitMock.mockReset()
getRateLimitMock.mockResolvedValue({ resources: {} })
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
ghRepoExecOptionsMock.mockClear()
githubRepoContextMock.mockClear()
readLocalGitConfigSignatureMock.mockReset()
readLocalGitConfigSignatureMock.mockResolvedValue(undefined)
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryCachesForTests()
resetMergedPRCommitMembershipCacheForTest()
__resetRepoDefaultBranchCacheForTests()
})
it('hides a stale CLOSED PR when checked out on the default branch (master)', async () => {
primeGitExecForDefaultBranch()
primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })])
const pr = await getPRForBranch('/repo-root', 'master')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', 'repos/acme/widgets/pulls?head=acme%3Amaster&state=all&per_page=1', '--include'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(pr).toBeNull()
})
it('reports kind="no-pr" from getPRForBranchOutcome on the default branch', async () => {
primeGitExecForDefaultBranch()
primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })])
const outcome = await getPRForBranchOutcome('/repo-root', 'master')
expect(outcome.kind).toBe('no-pr')
})
it('keeps a genuinely OPEN PR on the default branch visible', async () => {
primeGitExecForDefaultBranch()
primeGhExecWithBranchList([restPR({ number: 8, state: 'open' })])
const pr = await getPRForBranch('/repo-root', 'master')
expect(pr?.number).toBe(8)
expect(pr?.state).toBe('open')
// Default-ref resolution stays lazy for open PRs; unrelated remote-name enumeration remains allowed.
expect(
gitExecFileAsyncMock.mock.calls
.map(([args]) => args[0])
.filter((command) => command !== 'remote')
).toEqual([])
})
it('keeps a CLOSED PR on a feature branch visible (behavior preserved)', async () => {
primeGitExecForDefaultBranch()
primeGhExecWithBranchList([
restPR({ number: 9, state: 'closed', head_ref: 'feature-x', head_sha: 'feature-oid' })
])
const pr = await getPRForBranch('/repo-root', 'feature-x')
expect(pr?.number).toBe(9)
expect(pr?.state).toBe('closed')
})
it('preserves the merged-at-head carve-out on a feature branch', async () => {
primeGitExecForDefaultBranch({ headOid: 'merged-head-oid' })
primeGhExecWithBranchList([
restPR({
number: 10,
state: 'closed',
merged_at: '2024-02-02T00:00:00Z',
head_ref: 'feature-x',
head_sha: 'merged-head-oid'
})
])
const pr = await getPRForBranch('/repo-root', 'feature-x')
expect(pr?.number).toBe(10)
expect(pr?.state).toBe('merged')
})
it('hides a CLOSED fallback-number PR on the default branch (self-heal for persisted stale PRs)', async () => {
primeGitExecForDefaultBranch()
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'api' && args[1]?.includes('pulls?head=')) {
return { stdout: 'HTTP/2.0 200 OK\n\n[]' }
}
if (args[0] === 'api' && args[1] === 'graphql' && args.includes('number=7')) {
return {
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
number: 7,
title: 'Historical PR',
state: 'CLOSED',
url: 'https://github.com/acme/widgets/pull/7',
reviewDecision: null,
mergeStateStatus: 'UNKNOWN',
autoMergeRequest: null,
headRepositoryOwner: { login: 'acme' },
headRepository: { name: 'widgets' },
commits: {
nodes: [{ commit: { oid: 'stale-master-oid', statusCheckRollup: null } }]
},
stack: null,
stackEntry: null,
updatedAt: '2024-01-01T00:00:00Z',
isDraft: false,
mergeable: 'UNKNOWN',
baseRefName: 'old-release',
headRefName: 'master',
baseRefOid: 'base-oid',
headRefOid: 'stale-master-oid'
}
}
}
})
}
}
throw new Error(`gh unavailable: ${args.join(' ')}`)
})
const outcome = await getPRForBranchOutcome('/repo-root', 'master', null, null, 7)
expect(outcome.kind).toBe('no-pr')
})
it('overrides acceptMergedFallbackPR preservation for a merged fallback PR on the default branch', async () => {
primeGitExecForDefaultBranch()
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'api' && args[1]?.includes('pulls?head=')) {
return { stdout: 'HTTP/2.0 200 OK\n\n[]' }
}
if (args[0] === 'api' && args[1] === 'graphql' && args.includes('number=7')) {
return {
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
number: 7,
title: 'Historical PR',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/7',
reviewDecision: null,
mergeStateStatus: 'UNKNOWN',
autoMergeRequest: null,
headRepositoryOwner: { login: 'acme' },
headRepository: { name: 'widgets' },
commits: {
nodes: [{ commit: { oid: 'stale-master-oid', statusCheckRollup: null } }]
},
stack: null,
stackEntry: null,
updatedAt: '2024-01-01T00:00:00Z',
isDraft: false,
mergeable: 'UNKNOWN',
baseRefName: 'old-release',
headRefName: 'master',
baseRefOid: 'base-oid',
headRefOid: 'stale-master-oid'
}
}
}
})
}
}
throw new Error(`gh unavailable: ${args.join(' ')}`)
})
const outcome = await getPRForBranchOutcome('/repo-root', 'master', null, null, 7, {
acceptMergedFallbackPR: true
})
expect(outcome.kind).toBe('no-pr')
})
it('fails open when the default branch cannot be resolved (stale PR still returned)', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'for-each-ref') {
return { stdout: '', stderr: '' }
}
throw new Error(`fatal: git unavailable: ${args.join(' ')}`)
})
primeGhExecWithBranchList([restPR({ number: 7, state: 'closed' })])
const pr = await getPRForBranch('/repo-root', 'master')
expect(pr?.number).toBe(7)
expect(pr?.state).toBe('closed')
})
})