mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
* 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>
390 lines
14 KiB
TypeScript
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')
|
|
})
|
|
})
|