fix(github): verify cached integration PRs after tracking is removed

Use the existing REST PR mapping to read the head repository's default
branch when no tracked remote or origin can identify it. Require matching
repository and head metadata, and preserve the PR if the lookup fails.

This clears both closed and merged integration PRs cached by a fork
checkout after its upstream tracking is removed.

Co-authored-by: petersindex <287557875+petersindex@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-10 17:08:10 -07:00
committed by Neil
co-authored by petersindex
parent 82c5484f66
commit 884575a797
3 changed files with 116 additions and 4 deletions
@@ -117,9 +117,20 @@ function primeGh(
)
return { stdout: JSON.stringify(list) }
}
const exactRestMatch = args[1]?.match(/^repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/)
if (args[0] === 'api' && exactRestMatch) {
const pr = prsByRepositoryAndNumber.get(`${host}/${exactRestMatch[1]}#${exactRestMatch[2]}`)
if (pr) {
return { stdout: JSON.stringify(pr) }
}
throw new Error('HTTP 404 Not Found')
}
const repoIndex = args.indexOf('--repo')
const exact = prsByRepositoryAndNumber.get(`${host}/${args[repoIndex + 1]}#${args[2]}`)
if (args[0] === 'pr' && args[1] === 'view' && exact) {
if (args[0] === 'pr' && args[1] === 'view') {
if (!exact) {
throw new Error('HTTP 404 Not Found')
}
return {
stdout: JSON.stringify({
number: exact.number,
@@ -264,6 +275,92 @@ describe('issue #26948: a branch tracking the default branch', () => {
).toBe('no-pr')
})
it.each(['closed', 'merged'] as const)(
'clears a cached %s integration PR in a fork after upstream tracking is removed',
async (state) => {
primeForkWithUpstream()
primeGit('', { origin: 'main' })
const integrationPR = restPR('develop', UPSTREAM, UPSTREAM)
primeGh(
{},
{
...integrationPR,
merged_at: state === 'merged' ? '2025-01-02T00:00:00Z' : null,
head: {
...integrationPR.head,
sha: 'feature-head-oid',
repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'develop' }
}
}
)
expect(
await getPRForBranchOutcome('/repo-root', 'feature/my-change', null, null, 7, {
currentHeadOid: 'feature-head-oid',
acceptMergedFallbackPR: true
})
).toMatchObject({
kind: 'no-pr',
rejectedPRUrls: ['https://github.com/stablyai/widgets/pull/7']
})
}
)
it('preserves a cached renamed feature PR using its own repository default metadata', async () => {
primeForkWithUpstream()
primeGit('', { origin: 'develop' })
const featurePR = restPR('develop', UPSTREAM, UPSTREAM)
primeGh(
{},
{
...featurePR,
state: 'open',
head: {
...featurePR.head,
repo: { name: 'widgets', owner: { login: 'stablyai' }, default_branch: 'main' }
}
}
)
expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({
number: 7,
headRefName: 'develop'
})
})
it.each(['unavailable', 'changed-head'] as const)(
'preserves an untracked cached PR when REST metadata is %s',
async (metadataState) => {
primeForkWithUpstream()
primeGit('', { origin: 'develop' })
primeGh({}, { ...restPR('develop', UPSTREAM, UPSTREAM), state: 'open' })
const ghImplementation = ghExecFileAsyncMock.getMockImplementation()
ghExecFileAsyncMock.mockImplementation(async (args: string[], options) => {
if (args[0] === 'api' && args[1] === 'repos/stablyai/widgets/pulls/7') {
if (metadataState === 'unavailable') {
throw new Error('network unavailable')
}
const pr = restPR('develop', ACME, UPSTREAM)
return {
stdout: JSON.stringify({
...pr,
head: {
...pr.head,
repo: { name: 'widgets', owner: { login: 'acme' }, default_branch: 'develop' }
}
})
}
}
return ghImplementation?.(args, options)
})
expect(await getPRForBranch('/repo-root', 'feature/my-change', null, null, 7)).toMatchObject({
number: 7,
headRepo: UPSTREAM
})
}
)
it('tries the cached feature PR after rejecting an unrelated upstream result', async () => {
primeGit('refs/remotes/origin/develop')
primeGh(
@@ -87,7 +87,8 @@ export async function resolvePRForBranchOutcome(input: {
linkedPRNumber,
repoPath,
connectionId,
localGitOptions
localGitOptions,
ghOptions
}
const implicitPRPolicy = createImplicitDefaultBranchPRPolicy(defaultBranchPRContext)
@@ -4,13 +4,14 @@ import {
getRemoteHeadBranchName,
getRepoDefaultBranchName
} from '../../../source-control/repo-default-branch'
import type { HostedReviewLocalGitOptions } from './../github-exec-scope'
import type { GhExecOptions, HostedReviewLocalGitOptions } from './../github-exec-scope'
import type { TrackedUpstreamBranch } from './tracked-upstream-cache'
import {
pullRequestHeadRepository,
type PullRequestLookupData,
type PullRequestLookupPolicy
} from './pull-request-lookup-data'
import { getRestPRByNumber } from './pr-number-lookup'
export type ImplicitDefaultBranchPRContext = {
originHeadRepo: OwnerRepo | null
@@ -20,6 +21,7 @@ export type ImplicitDefaultBranchPRContext = {
repoPath: string
connectionId?: string | null
localGitOptions: HostedReviewLocalGitOptions
ghOptions: GhExecOptions
}
export function createImplicitDefaultBranchPRPolicy(
@@ -73,7 +75,19 @@ async function shouldHideImplicitDefaultBranchPR(
? 'origin'
: null
if (!remoteName) {
return false
try {
// Removed tracking can leave a cached integration PR without a matching remote.
const metadata = await getRestPRByNumber(prRepo, data.number, input.ghOptions)
const metadataHeadRepo = pullRequestHeadRepository(metadata, prRepo)
return (
metadataHeadRepo !== null &&
githubRepoIdentityKey(metadataHeadRepo) === headRepoKey &&
metadata.headRefName === data.headRefName &&
metadata.headDefaultBranchName === data.headRefName
)
} catch {
return false
}
}
// An unknown upstream HEAD cannot borrow a different repository's origin default.
const defaultBranchName =