fix(github): align PR source and review head origin (#10677)
* fix(github): align PR source and review head origin * fix(github): pin number-based work item open to the repo source preference Open-by-number and details still ran the upstream-first multi-candidate PR probe, so a fork and its upstream sharing a PR number opened different PRs than the list and start-point paths did once #10677 pinned those to origin. Thread repo.issueSourcePreference through dispatchWorkItem, getWorkItemDetails, getRepoWorkItem, and getRepoWorkItemDetails. getWorkItemByOwnerRepo is left alone: explicit owner/repo already pins identity. auto/upstream/undefined keep the multi-candidate probe. Co-authored-by: Orca <help@stably.ai> * test(github): enforce origin preference in review head origin resolution The explicit origin preference must short-circuit before any identity probe, so no remote queries should occur. Add validation to reject unexpected remotes and tighten the test assertion to verify no remote get-url calls happen at all. * fix(github): enforce origin preference in issue open-by-number lookup listWorkItems and getWorkItem must share preference so origin/upstream toggles cannot disagree. Explicit origin preference now fail-closes when origin identity is unresolved (no bare-lookup fallback), matching the PR candidate resolution rule. --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
8cb45318d7
commit
33bd676644
|
|
@ -0,0 +1,181 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
getIssueOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
resolvePRRepositoryCandidatesMock,
|
||||
resolveIssueSourceMock,
|
||||
rateLimitGuardMock,
|
||||
noteRateLimitSpendMock,
|
||||
acquireMock,
|
||||
releaseMock
|
||||
} = vi.hoisted(() => ({
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getIssueOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
resolvePRRepositoryCandidatesMock: vi.fn(),
|
||||
resolveIssueSourceMock: vi.fn(),
|
||||
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
|
||||
noteRateLimitSpendMock: vi.fn(),
|
||||
acquireMock: vi.fn(),
|
||||
releaseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', async () => {
|
||||
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
|
||||
return {
|
||||
...actual,
|
||||
execFileAsync: vi.fn(),
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
getIssueOwnerRepo: getIssueOwnerRepoMock,
|
||||
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
||||
resolveIssueSource: resolveIssueSourceMock,
|
||||
acquire: acquireMock,
|
||||
release: releaseMock,
|
||||
_resetOwnerRepoCache: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./rate-limit', () => ({
|
||||
rateLimitGuard: rateLimitGuardMock,
|
||||
noteRateLimitSpend: noteRateLimitSpendMock,
|
||||
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
|
||||
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
||||
noteRepositoryRateLimitSpend: vi.fn(),
|
||||
spendsSharedGitHubComQuota: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./github-api-repository', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveIssueGitHubApiRepositorySource: (
|
||||
repoPath: string,
|
||||
preference: unknown,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
||||
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
||||
getIssueOwnerRepoMock(repoPath, connectionId),
|
||||
getOriginGitHubApiRepository: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote: (
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) =>
|
||||
remoteName === 'origin'
|
||||
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
||||
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions),
|
||||
resolveGitHubApiRepositoryCandidates: (
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: unknown
|
||||
) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions)
|
||||
}
|
||||
})
|
||||
|
||||
import { getWorkItem, _resetOwnerRepoCache } from './client'
|
||||
|
||||
describe('GitHub issue open-by-number origin preference', () => {
|
||||
beforeEach(() => {
|
||||
ghExecFileAsyncMock.mockReset()
|
||||
getOwnerRepoMock.mockReset()
|
||||
getIssueOwnerRepoMock.mockReset()
|
||||
getOwnerRepoForRemoteMock.mockReset()
|
||||
resolvePRRepositoryCandidatesMock.mockReset()
|
||||
resolveIssueSourceMock.mockReset()
|
||||
rateLimitGuardMock.mockReset()
|
||||
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
||||
noteRateLimitSpendMock.mockReset()
|
||||
acquireMock.mockReset()
|
||||
releaseMock.mockReset()
|
||||
acquireMock.mockResolvedValue(undefined)
|
||||
getOwnerRepoForRemoteMock.mockImplementation(
|
||||
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
|
||||
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
|
||||
)
|
||||
resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => {
|
||||
const origin = await getOwnerRepoMock(repoPath, connectionId)
|
||||
const repository = origin ? { host: 'github.com', ...origin } : null
|
||||
return { candidates: repository ? [repository] : [], headRepo: repository }
|
||||
})
|
||||
_resetOwnerRepoCache()
|
||||
})
|
||||
|
||||
it('pins typed issue metadata to explicit origin preference', async () => {
|
||||
const source = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({ source, fellBack: false })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7,
|
||||
title: 'Origin issue',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
url: 'https://github.com/fork/orca/issues/7',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
author: { login: 'octocat' }
|
||||
})
|
||||
})
|
||||
|
||||
const item = await getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin')
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['api', 'repos/fork/orca/issues/7'],
|
||||
expect.objectContaining({ cwd: '/repo-root', host: 'github.com' })
|
||||
)
|
||||
expect(item).toMatchObject({ number: 7, title: 'Origin issue', type: 'issue' })
|
||||
})
|
||||
|
||||
it('does not run a bare issue lookup when explicit origin identity is unresolved', async () => {
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
|
||||
|
||||
await expect(getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin')).resolves.toBeNull()
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the issue probe on untyped open when origin identity is unresolved', async () => {
|
||||
const origin = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
||||
resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false })
|
||||
getOwnerRepoMock.mockResolvedValue(origin)
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [origin], headRepo: origin })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 7,
|
||||
title: 'Origin PR',
|
||||
state: 'open',
|
||||
labels: [],
|
||||
isDraft: false,
|
||||
url: 'https://github.com/fork/orca/pull/7',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'origin/fix',
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
author: { login: 'octocat' }
|
||||
})
|
||||
})
|
||||
|
||||
const item = await getWorkItem('/repo-root', 7, undefined, null, {}, 'origin')
|
||||
|
||||
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {})
|
||||
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca'])
|
||||
)
|
||||
expect(item).toMatchObject({ number: 7, type: 'pr' })
|
||||
})
|
||||
})
|
||||
|
|
@ -513,6 +513,54 @@ describe('GitHub issue source split', () => {
|
|||
expect(item?.prRepo).toEqual(upstream)
|
||||
})
|
||||
|
||||
it('pins typed PR metadata to explicit origin when upstream has the same number', async () => {
|
||||
const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
||||
const origin = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
||||
getOwnerRepoMock.mockResolvedValue(origin)
|
||||
mockUpstreamCandidate(upstream)
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [upstream, origin],
|
||||
headRepo: origin
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 42,
|
||||
title: 'Origin PR',
|
||||
state: 'open',
|
||||
url: 'https://github.com/fork/orca/pull/42',
|
||||
labels: [],
|
||||
updatedAt: '2026-04-02T00:00:00Z',
|
||||
author: { login: 'octocat' },
|
||||
isDraft: false,
|
||||
headRefName: 'origin/fix',
|
||||
baseRefName: 'main'
|
||||
})
|
||||
})
|
||||
|
||||
const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')
|
||||
|
||||
expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca'])
|
||||
)
|
||||
expect(
|
||||
ghExecFileAsyncMock.mock.calls.some((call) =>
|
||||
(call[0] as string[]).some((arg) => arg.includes('upstream/orca'))
|
||||
)
|
||||
).toBe(false)
|
||||
expect(item?.prRepo).toEqual(origin)
|
||||
})
|
||||
|
||||
it('does not run a bare PR lookup when explicit origin identity is unresolved', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue(null)
|
||||
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
||||
|
||||
await expect(getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull()
|
||||
|
||||
expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run a bare gh lookup for an SSH repo without candidates', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null })
|
||||
|
||||
|
|
|
|||
|
|
@ -3530,6 +3530,44 @@ describe('getPRForBranch', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('pins explicit origin push-target lookup when upstream has the same PR number', async () => {
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' })
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
||||
candidates: [
|
||||
{ owner: 'upstream', repo: 'orca' },
|
||||
{ owner: 'fork', repo: 'orca' }
|
||||
],
|
||||
headRepo: { owner: 'fork', repo: 'orca' }
|
||||
})
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
head: {
|
||||
ref: 'contributor/fix',
|
||||
repo: {
|
||||
full_name: 'contributor/orca',
|
||||
name: 'orca',
|
||||
clone_url: 'https://github.com/contributor/orca.git',
|
||||
ssh_url: 'git@github.com:contributor/orca.git',
|
||||
owner: { login: 'contributor' }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:fork/orca.git')
|
||||
|
||||
await getPullRequestPushTarget('/repo-root', 1738, null, {}, 'origin')
|
||||
|
||||
expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/1738'], {
|
||||
cwd: '/repo-root',
|
||||
host: 'github.com'
|
||||
})
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
['api', 'repos/upstream/orca/pulls/1738'],
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces maintainer_can_modify=false alongside a fork PR push target', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-d
|
|||
import { readLocalGitConfigSignature } from './local-git-config-signature'
|
||||
import {
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getIssueGitHubApiRepository,
|
||||
getOriginGitHubApiRepository,
|
||||
githubHostExecOptions,
|
||||
githubRepositorySlugArg,
|
||||
|
|
@ -283,16 +282,35 @@ export type PullRequestPushTarget = {
|
|||
maintainerCanModify?: boolean
|
||||
}
|
||||
|
||||
// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/
|
||||
// undefined keep the multi-candidate probe ordered upstream-first, matching
|
||||
// resolvePrWorkItemSource list semantics.
|
||||
async function resolvePullRequestLookupCandidates(
|
||||
repoPath: string,
|
||||
preference: IssueSourcePreference | undefined,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepository[]> {
|
||||
if (preference === 'origin') {
|
||||
const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions)
|
||||
return origin ? [origin] : []
|
||||
}
|
||||
return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions))
|
||||
.candidates
|
||||
}
|
||||
|
||||
export async function getPullRequestPushTarget(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<PullRequestPushTarget | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
const ghOptions = ghRepoExecOptions(context)
|
||||
const { candidates } = await resolveGitHubApiRepositoryCandidates(
|
||||
const candidates = await resolvePullRequestLookupCandidates(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
|
|
@ -954,14 +972,19 @@ async function fetchPullRequestWorkItemFromCandidates(
|
|||
repoPath: string,
|
||||
number: number,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
const { candidates } = await resolveGitHubApiRepositoryCandidates(
|
||||
const candidates = await resolvePullRequestLookupCandidates(
|
||||
repoPath,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
if (preference === 'origin') {
|
||||
return null
|
||||
}
|
||||
return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions)
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
|
|
@ -1953,38 +1976,55 @@ export async function getWorkItem(
|
|||
number: number,
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<MainWorkItem | null> {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number
|
||||
// must share that preference so origin/upstream toggles cannot disagree.
|
||||
if (type === 'issue') {
|
||||
return await fetchIssueWorkItem(
|
||||
const { source } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
// Why: explicit origin with no origin identity must not bare-lookup ambient gh
|
||||
// (same fail-closed rule as origin-pinned PR candidate resolution).
|
||||
if (!source && preference === 'origin') {
|
||||
return null
|
||||
}
|
||||
return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions)
|
||||
}
|
||||
if (type === 'pr') {
|
||||
return await fetchPullRequestWorkItemFromCandidates(
|
||||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await fetchIssueWorkItem(
|
||||
const { source } = await resolveIssueGitHubApiRepositorySource(
|
||||
repoPath,
|
||||
await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions),
|
||||
number,
|
||||
preference,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
if (source || preference !== 'origin') {
|
||||
const issue = await fetchIssueWorkItem(
|
||||
repoPath,
|
||||
source,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR.
|
||||
|
|
@ -1997,7 +2037,8 @@ export async function getWorkItem(
|
|||
repoPath,
|
||||
number,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -111,7 +111,13 @@ describe('resolveGitHubPrStartPoint', () => {
|
|||
resolveRemote: async () => 'origin'
|
||||
})
|
||||
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null)
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
1849,
|
||||
null,
|
||||
{},
|
||||
undefined
|
||||
)
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'def456',
|
||||
headSha: 'def456',
|
||||
|
|
@ -144,7 +150,13 @@ describe('resolveGitHubPrStartPoint', () => {
|
|||
resolveRemote: async () => 'origin'
|
||||
})
|
||||
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null)
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
1849,
|
||||
null,
|
||||
{},
|
||||
undefined
|
||||
)
|
||||
expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849)
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'abc123',
|
||||
|
|
@ -383,13 +395,21 @@ describe('resolveGitHubPrStartPoint', () => {
|
|||
const result = await resolveGitHubPrStartPoint({
|
||||
repoPath: '/repo-root',
|
||||
prNumber: 1738,
|
||||
issueSourcePreference: 'origin',
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
fetchPullRequestHeadRef: fetchPullRequestHeadRefMock,
|
||||
resolveRemote: async () => 'origin'
|
||||
})
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null)
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null, {}, 'origin')
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
1738,
|
||||
null,
|
||||
{},
|
||||
'origin'
|
||||
)
|
||||
expect(result).toEqual({
|
||||
baseBranch: 'abc123',
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/types'
|
||||
import type { GitHubPrStartPoint, GitPushTarget, IssueSourcePreference } from '../../shared/types'
|
||||
import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch'
|
||||
import {
|
||||
isMissingRemoteRefGitError,
|
||||
|
|
@ -18,6 +18,7 @@ type ResolveGitHubPrStartPointArgs = {
|
|||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
issueSourcePreference?: IssueSourcePreference
|
||||
connectionId?: string | null
|
||||
localGitOptions?: { wslDistro?: string }
|
||||
gitExec: GitExec
|
||||
|
|
@ -30,12 +31,6 @@ type ResolveGitHubPrStartPointArgs = {
|
|||
|
||||
type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string }
|
||||
|
||||
function localGitOptionArgs(
|
||||
options: { wslDistro?: string } | undefined
|
||||
): [] | [{ wslDistro?: string }] {
|
||||
return options && Object.keys(options).length > 0 ? [options] : []
|
||||
}
|
||||
|
||||
export async function resolveGitHubPrStartPoint(
|
||||
args: ResolveGitHubPrStartPointArgs
|
||||
): Promise<ResolveGitHubPrStartPointResult> {
|
||||
|
|
@ -54,7 +49,8 @@ export async function resolveGitHubPrStartPoint(
|
|||
args.repoPath,
|
||||
args.prNumber,
|
||||
args.connectionId ?? null,
|
||||
...localGitOptionArgs(args.localGitOptions)
|
||||
args.localGitOptions ?? {},
|
||||
args.issueSourcePreference
|
||||
)
|
||||
pushTarget = resolved?.pushTarget
|
||||
maintainerCanModify = resolved?.maintainerCanModify
|
||||
|
|
@ -71,7 +67,8 @@ export async function resolveGitHubPrStartPoint(
|
|||
args.prNumber,
|
||||
'pr',
|
||||
args.connectionId ?? null,
|
||||
...localGitOptionArgs(args.localGitOptions)
|
||||
args.localGitOptions ?? {},
|
||||
args.issueSourcePreference
|
||||
)
|
||||
if (!item || item.type !== 'pr') {
|
||||
return { error: `PR #${args.prNumber} not found.` }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ describe('resolveGitHubReviewHeadRemote', () => {
|
|||
|
||||
const remote = await resolveGitHubReviewHeadRemote({
|
||||
repoPath: '/repo',
|
||||
issueSourcePreference: 'auto',
|
||||
gitExec: gitExecWithRemotes(['origin', 'upstream'])
|
||||
})
|
||||
|
||||
|
|
@ -52,12 +53,40 @@ describe('resolveGitHubReviewHeadRemote', () => {
|
|||
|
||||
const remote = await resolveGitHubReviewHeadRemote({
|
||||
repoPath: '/repo',
|
||||
issueSourcePreference: 'upstream',
|
||||
gitExec: gitExecWithRemotes(['origin', 'upstream'])
|
||||
})
|
||||
|
||||
expect(remote).toBe('origin')
|
||||
})
|
||||
|
||||
it('uses explicit origin without probing hosting identity on a dual-remote clone', async () => {
|
||||
getGitHubApiRepositoryForRemoteMock.mockResolvedValue({ owner: 'org', repo: 'project' })
|
||||
|
||||
const remote = await resolveGitHubReviewHeadRemote({
|
||||
repoPath: '/repo',
|
||||
issueSourcePreference: 'origin',
|
||||
gitExec: gitExecWithRemotes(['origin', 'upstream'])
|
||||
})
|
||||
|
||||
expect(remote).toBe('origin')
|
||||
expect(getGitHubApiRepositoryForRemoteMock).not.toHaveBeenCalled()
|
||||
expect(getDefaultRemoteMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects explicit origin when that remote is not configured', async () => {
|
||||
await expect(
|
||||
resolveGitHubReviewHeadRemote({
|
||||
repoPath: '/repo',
|
||||
issueSourcePreference: 'origin',
|
||||
gitExec: gitExecWithRemotes(['upstream'])
|
||||
})
|
||||
).rejects.toThrow('Repo has no configured origin remote.')
|
||||
|
||||
expect(getGitHubApiRepositoryForRemoteMock).not.toHaveBeenCalled()
|
||||
expect(getDefaultRemoteMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips identity probes for a single-remote clone and uses the local default', async () => {
|
||||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import type { IssueSourcePreference } from '../../shared/types'
|
||||
import { pickPreferredGitRemote } from '../../shared/preferred-git-remote'
|
||||
import { getDefaultRemote } from '../git/repo'
|
||||
import { getGitHubApiRepositoryForRemote } from './github-api-repository'
|
||||
|
||||
type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
// Why: PR work-item/API resolution probes upstream before origin
|
||||
// (resolveGitHubApiRepositoryCandidates), so review-head fetches must target
|
||||
// the same hosting project — a contributor clone's fork `origin` has no
|
||||
// refs/pull/<N>/head for an upstream PR. Local and SSH share this resolver so
|
||||
// the two surfaces cannot pick different remotes.
|
||||
// Why: explicit origin must match issue listing; otherwise hosting identity
|
||||
// keeps contributor clones on the upstream project's PR namespace.
|
||||
export async function resolveGitHubReviewHeadRemote(args: {
|
||||
repoPath: string
|
||||
issueSourcePreference?: IssueSourcePreference
|
||||
connectionId?: string | null
|
||||
localGitOptions?: { wslDistro?: string }
|
||||
gitExec: GitExec
|
||||
|
|
@ -20,6 +19,12 @@ export async function resolveGitHubReviewHeadRemote(args: {
|
|||
.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.')
|
||||
}
|
||||
// Why: identity probes cost a `remote get-url` (plus a possible gh auth
|
||||
// lookup) each; only multi-remote clones are ambiguous enough to need them.
|
||||
if (remotes.length > 1) {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ describe('getWorkItemDetails Enterprise host routing', () => {
|
|||
const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')
|
||||
|
||||
expect(details?.body).toBe('Enterprise issue body')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined)
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1)
|
||||
expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', {
|
||||
|
|
@ -156,7 +156,7 @@ describe('getWorkItemDetails Enterprise host routing', () => {
|
|||
|
||||
await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined)
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -247,7 +247,7 @@ describe('getWorkItemDetails Enterprise host routing', () => {
|
|||
viewerViewedState: 'VIEWED'
|
||||
}
|
||||
])
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined)
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(getPRCommentsMock).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
|
|
@ -280,7 +280,7 @@ describe('getWorkItemDetails Enterprise host routing', () => {
|
|||
|
||||
await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined)
|
||||
expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -206,7 +206,14 @@ describe('getWorkItemDetails', () => {
|
|||
|
||||
const details = await getWorkItemDetails('/repo-root', 923, 'issue')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined)
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
923,
|
||||
'issue',
|
||||
undefined,
|
||||
{},
|
||||
undefined
|
||||
)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api')
|
||||
expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql')
|
||||
|
|
@ -579,7 +586,14 @@ describe('getWorkItemDetails', () => {
|
|||
|
||||
const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith(
|
||||
'/home/tester/widgets',
|
||||
923,
|
||||
'issue',
|
||||
'ssh-test-1',
|
||||
{},
|
||||
undefined
|
||||
)
|
||||
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
|
||||
'/home/tester/widgets',
|
||||
'upstream',
|
||||
|
|
@ -649,7 +663,14 @@ describe('getWorkItemDetails', () => {
|
|||
const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions)
|
||||
|
||||
expect(details?.body).toBe('PR body')
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions)
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
42,
|
||||
'pr',
|
||||
null,
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
|
||||
'/repo-root',
|
||||
'origin',
|
||||
|
|
@ -677,6 +698,16 @@ describe('getWorkItemDetails', () => {
|
|||
)
|
||||
})
|
||||
|
||||
// Why: details open by number, so it must pin the same source as the list;
|
||||
// otherwise a fork and its upstream sharing PR #42 render different PRs.
|
||||
it('forwards the explicit origin source preference to the work item lookup', async () => {
|
||||
getWorkItemMock.mockResolvedValueOnce(null)
|
||||
|
||||
await expect(getWorkItemDetails('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull()
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin')
|
||||
})
|
||||
|
||||
// Why: a rate-limited/auth-failed file fetch must not render as an empty PR;
|
||||
// the Files tab keys its retry state off details.filesUnavailable.
|
||||
it('flags filesUnavailable when the PR file fetch fails but leaves the PR empty otherwise intact', async () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
GitHubIssueTimelineTarget,
|
||||
GitHubWorkItem,
|
||||
GitHubWorkItemDetails,
|
||||
IssueSourcePreference,
|
||||
PRCheckDetail,
|
||||
PRComment
|
||||
} from '../../shared/types'
|
||||
|
|
@ -1047,14 +1048,16 @@ export async function getWorkItemDetails(
|
|||
number: number,
|
||||
type?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
preference?: IssueSourcePreference
|
||||
): Promise<GitHubWorkItemDetails | null> {
|
||||
const item: Omit<GitHubWorkItem, 'repoId'> | null = await getWorkItem(
|
||||
repoPath,
|
||||
number,
|
||||
type,
|
||||
connectionId,
|
||||
...localGitOptionArgs(localGitOptions)
|
||||
localGitOptions,
|
||||
preference
|
||||
)
|
||||
if (!item) {
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -33,19 +33,19 @@ describe('dispatchWorkItem', () => {
|
|||
type: 'bogus' as unknown as 'issue' | 'pr'
|
||||
}
|
||||
await dispatchWorkItem(bogus, repo, fn)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null, undefined)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null, undefined, undefined)
|
||||
})
|
||||
|
||||
it('passes valid issue type through', async () => {
|
||||
const fn = vi.fn().mockResolvedValue(null)
|
||||
await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'issue' }, repo, fn)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null, undefined)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null, undefined, undefined)
|
||||
})
|
||||
|
||||
it('passes valid pr type through', async () => {
|
||||
const fn = vi.fn().mockResolvedValue(null)
|
||||
await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'pr' }, repo, fn)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, undefined)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, undefined, undefined)
|
||||
})
|
||||
|
||||
it('passes SSH connection context through', async () => {
|
||||
|
|
@ -55,6 +55,33 @@ describe('dispatchWorkItem', () => {
|
|||
{ path: '/remote/repo', connectionId: 'ssh-1' },
|
||||
fn
|
||||
)
|
||||
expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1', undefined)
|
||||
expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1', undefined, undefined)
|
||||
})
|
||||
|
||||
it('pins the repo issue source preference for open-by-number', async () => {
|
||||
const fn = vi.fn().mockResolvedValue(null)
|
||||
await dispatchWorkItem(
|
||||
{ repoPath: '/r', number: 42, type: 'pr' },
|
||||
{ path: '/r', connectionId: null, issueSourcePreference: 'origin' },
|
||||
fn,
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, { wslDistro: 'Ubuntu' }, 'origin')
|
||||
})
|
||||
|
||||
it('leaves upstream and auto preferences on the multi-candidate probe', async () => {
|
||||
const fn = vi.fn().mockResolvedValue(null)
|
||||
await dispatchWorkItem(
|
||||
{ repoPath: '/r', number: 7, type: 'pr' },
|
||||
{ path: '/r', connectionId: null, issueSourcePreference: 'upstream' },
|
||||
fn
|
||||
)
|
||||
await dispatchWorkItem(
|
||||
{ repoPath: '/r', number: 7, type: 'pr' },
|
||||
{ path: '/r', connectionId: null, issueSourcePreference: 'auto' },
|
||||
fn
|
||||
)
|
||||
expect(fn).toHaveBeenNthCalledWith(1, '/r', 7, 'pr', null, undefined, 'upstream')
|
||||
expect(fn).toHaveBeenNthCalledWith(2, '/r', 7, 'pr', null, undefined, 'auto')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TaskSourceContext } from '../../shared/task-source-context'
|
||||
import type { IssueSourcePreference } from '../../shared/types'
|
||||
|
||||
export type WorkItemArgs = {
|
||||
repoPath: string
|
||||
|
|
@ -11,6 +12,7 @@ export type WorkItemArgs = {
|
|||
type RegisteredRepoContext = {
|
||||
path: string
|
||||
connectionId?: string | null
|
||||
issueSourcePreference?: IssueSourcePreference
|
||||
}
|
||||
|
||||
type LocalGitExecOptions = {
|
||||
|
|
@ -29,7 +31,8 @@ export function dispatchWorkItem<T>(
|
|||
n: number,
|
||||
t?: 'issue' | 'pr',
|
||||
connectionId?: string | null,
|
||||
localGitOptions?: LocalGitExecOptions
|
||||
localGitOptions?: LocalGitExecOptions,
|
||||
preference?: IssueSourcePreference
|
||||
) => Promise<T | null>,
|
||||
localGitOptions?: LocalGitExecOptions
|
||||
): Promise<T | null> | null {
|
||||
|
|
@ -38,5 +41,14 @@ export function dispatchWorkItem<T>(
|
|||
return null
|
||||
}
|
||||
const safeType = type === 'issue' || type === 'pr' ? type : undefined
|
||||
return fn(repo.path, number, safeType, repo.connectionId ?? null, localGitOptions)
|
||||
// Why: open-by-number must pin the same source the list and start-point use,
|
||||
// else a fork and its upstream sharing a PR number resolve to different PRs.
|
||||
return fn(
|
||||
repo.path,
|
||||
number,
|
||||
safeType,
|
||||
repo.connectionId ?? null,
|
||||
localGitOptions,
|
||||
repo.issueSourcePreference
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -843,6 +843,48 @@ describe('registerGitHubHandlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
// Why: open-by-number must pin the same source the list uses, else a fork and
|
||||
// its upstream sharing PR #42 open different PRs from the same click.
|
||||
it('pins the repo origin source preference on work item and details IPC', async () => {
|
||||
repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
issueSourcePreference: 'origin'
|
||||
}
|
||||
]
|
||||
getWorkItemMock.mockResolvedValue(null)
|
||||
getWorkItemDetailsMock.mockResolvedValue(null)
|
||||
registerGitHubHandlers(store as never, stats as never)
|
||||
|
||||
await handlers['gh:workItem'](null, { repoPath: '/workspace/repo', number: 42, type: 'pr' })
|
||||
await handlers['gh:workItemDetails'](null, {
|
||||
repoPath: '/workspace/repo',
|
||||
number: 42,
|
||||
type: 'pr'
|
||||
})
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
42,
|
||||
'pr',
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
42,
|
||||
'pr',
|
||||
null,
|
||||
undefined,
|
||||
'origin'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes local WSL project GitHub PR detail and action IPC through project git options', async () => {
|
||||
setPlatform('win32')
|
||||
projects = [
|
||||
|
|
@ -1027,7 +1069,14 @@ describe('registerGitHubHandlers', () => {
|
|||
}
|
||||
)
|
||||
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith('/workspace/repo', 42, 'pr', null, localGitOptions)
|
||||
expect(getWorkItemMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
42,
|
||||
'pr',
|
||||
null,
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
prRepo,
|
||||
|
|
@ -1041,7 +1090,8 @@ describe('registerGitHubHandlers', () => {
|
|||
42,
|
||||
'pr',
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(getPRFileContentsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo })
|
||||
|
|
|
|||
|
|
@ -2037,7 +2037,16 @@ describe('registerWorktreeHandlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('returns the PR head push target when resolving a fork PR base', async () => {
|
||||
it('threads explicit origin preference into dual-remote PR head resolution', async () => {
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
issueSourcePreference: 'origin',
|
||||
worktreeBaseRef: null
|
||||
})
|
||||
getPullRequestPushTargetMock.mockResolvedValue({
|
||||
pushTarget: {
|
||||
remoteName: 'pr-prateek-orca',
|
||||
|
|
@ -2047,7 +2056,12 @@ describe('registerWorktreeHandlers', () => {
|
|||
})
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' }
|
||||
const url =
|
||||
args[2] === 'origin' ? ORIGIN_REMOTE_URL : 'git@github.com:org/upstream-repo.git'
|
||||
return { stdout: `${url}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\nupstream\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return { stdout: 'abc123\n', stderr: '' }
|
||||
|
|
@ -2071,6 +2085,17 @@ describe('registerWorktreeHandlers', () => {
|
|||
],
|
||||
{ cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
|
||||
)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
['remote', 'get-url', 'upstream'],
|
||||
expect.anything()
|
||||
)
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
1738,
|
||||
null,
|
||||
{},
|
||||
'origin'
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
baseBranch: 'abc123',
|
||||
headSha: 'abc123',
|
||||
|
|
|
|||
|
|
@ -1341,16 +1341,18 @@ export function registerWorktreeHandlers(
|
|||
headRefName: args.headRefName,
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
|
||||
gitExec,
|
||||
fetchRemoteTrackingRef,
|
||||
fetchPullRequestHeadRef,
|
||||
// Why: one shared resolver for local and SSH so origin-vs-upstream
|
||||
// cannot diverge by surface; it prefers the remote hosting the PR's project.
|
||||
// Why: one resolver keeps source preference and hosting identity aligned
|
||||
// across local, WSL, and SSH worktree creation.
|
||||
resolveRemote: () =>
|
||||
resolveGitHubReviewHeadRemote({
|
||||
repoPath: repo.path,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: getLocalProjectWorktreeGitOptions(store, repo),
|
||||
gitExec
|
||||
|
|
|
|||
|
|
@ -6123,6 +6123,45 @@ describe('OrcaRuntimeService', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('pins explicit origin preference on runtime open-by-number work item lookups', async () => {
|
||||
const originRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: TEST_REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
issueSourcePreference: 'origin' as const
|
||||
}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [originRepo],
|
||||
getRepo: (id: string) => (id === originRepo.id ? originRepo : undefined)
|
||||
} as never)
|
||||
const prRepo = { owner: 'acme', repo: 'orca' }
|
||||
|
||||
await runtime.getRepoWorkItem('id:repo-1', 42, 'pr')
|
||||
await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr')
|
||||
await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr')
|
||||
|
||||
expect(getGitHubWorkItemMock).toHaveBeenCalledWith(TEST_REPO_PATH, 42, 'pr', null, {}, 'origin')
|
||||
expect(getGitHubWorkItemDetailsMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
42,
|
||||
'pr',
|
||||
null,
|
||||
{},
|
||||
'origin'
|
||||
)
|
||||
// Why: explicit owner/repo already pins identity, so it stays preference-free.
|
||||
expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
prRepo,
|
||||
42,
|
||||
'pr',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => {
|
||||
setPlatform('win32')
|
||||
const runtimeStore = {
|
||||
|
|
@ -6220,7 +6259,8 @@ describe('OrcaRuntimeService', () => {
|
|||
42,
|
||||
'pr',
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
|
|
@ -6235,7 +6275,8 @@ describe('OrcaRuntimeService', () => {
|
|||
42,
|
||||
'pr',
|
||||
null,
|
||||
localGitOptions
|
||||
localGitOptions,
|
||||
undefined
|
||||
)
|
||||
expect(getGitHubPRChecksMock).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
|
|
@ -34533,10 +34574,20 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes runtime GitHub PR base git calls through the selected WSL project runtime', async () => {
|
||||
it('threads explicit origin preference through runtime WSL PR base resolution', async () => {
|
||||
setPlatform('win32')
|
||||
const localRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: TEST_REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
issueSourcePreference: 'origin' as const
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [localRepo],
|
||||
getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined),
|
||||
getProjects: () => [
|
||||
{
|
||||
id: 'project-1',
|
||||
|
|
@ -34564,8 +34615,18 @@ describe('OrcaRuntimeService', () => {
|
|||
if (args[0] === 'config') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote' && args[1] === 'get-url') {
|
||||
if (args[2] !== 'origin' && args[2] !== 'upstream') {
|
||||
throw new Error(`unexpected remote: ${String(args[2])}`)
|
||||
}
|
||||
const url =
|
||||
args[2] === 'origin'
|
||||
? 'git@github.com:org/repo.git'
|
||||
: 'git@github.com:org/upstream-repo.git'
|
||||
return { stdout: `${url}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
return { stdout: 'origin\nupstream\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
|
|
@ -34593,11 +34654,6 @@ describe('OrcaRuntimeService', () => {
|
|||
headSha: 'pr-head-sha',
|
||||
branchNameOverride: 'feature/add-feature'
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: TEST_REPO_PATH,
|
||||
timeout: 15_000,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(gitSpy).toHaveBeenCalledWith(
|
||||
[
|
||||
'fetch',
|
||||
|
|
@ -34610,6 +34666,12 @@ describe('OrcaRuntimeService', () => {
|
|||
cwd: TEST_REPO_PATH,
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
// Why: the explicit origin preference must short-circuit before any
|
||||
// identity probe, so no remote — not just upstream — gets a get-url.
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(
|
||||
['remote', 'get-url', expect.anything()],
|
||||
expect.anything()
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
|
|
@ -34622,7 +34684,8 @@ describe('OrcaRuntimeService', () => {
|
|||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
connectionId: 'ssh-1',
|
||||
issueSourcePreference: 'origin' as const
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
|
|
@ -34635,7 +34698,7 @@ describe('OrcaRuntimeService', () => {
|
|||
return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
return { stdout: 'origin\nupstream\n', stderr: '' }
|
||||
}
|
||||
if (
|
||||
args[0] === 'rev-parse' &&
|
||||
|
|
@ -34666,6 +34729,13 @@ describe('OrcaRuntimeService', () => {
|
|||
branchNameOverride: 'contributor/fix'
|
||||
})
|
||||
expect(provider.fetchGitHubPullRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 42)
|
||||
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
42,
|
||||
'ssh-1',
|
||||
{},
|
||||
'origin'
|
||||
)
|
||||
expect(provider.exec).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['fetch']),
|
||||
'/remote/repo'
|
||||
|
|
|
|||
|
|
@ -16139,12 +16139,15 @@ export class OrcaRuntimeService {
|
|||
type?: 'issue' | 'pr'
|
||||
): Promise<Awaited<ReturnType<typeof getWorkItem>>> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
// Why: open-by-number must pin the same source the list and start-point use,
|
||||
// else a fork and its upstream sharing a PR number resolve to different PRs.
|
||||
return getWorkItem(
|
||||
repo.path,
|
||||
number,
|
||||
type,
|
||||
repo.connectionId ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
this.getLocalGitExecutionOptionArgs(repo)[0] ?? {},
|
||||
repo.issueSourcePreference
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -16176,7 +16179,8 @@ export class OrcaRuntimeService {
|
|||
number,
|
||||
type,
|
||||
repo.connectionId ?? null,
|
||||
...this.getLocalGitExecutionOptionArgs(repo)
|
||||
this.getLocalGitExecutionOptionArgs(repo)[0] ?? {},
|
||||
repo.issueSourcePreference
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -20142,11 +20146,12 @@ export class OrcaRuntimeService {
|
|||
const gitExec = sshGitProvider
|
||||
? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path)
|
||||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path })
|
||||
// Why: one shared resolver for local and SSH so origin-vs-upstream cannot
|
||||
// diverge by surface; it prefers the remote hosting the PR's project.
|
||||
// Why: one resolver keeps source preference and hosting identity aligned
|
||||
// across local, WSL, and SSH worktree creation.
|
||||
const resolveRemote = (): Promise<string> =>
|
||||
resolveGitHubReviewHeadRemote({
|
||||
repoPath: repo.path,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: localWorktreeGitOptions,
|
||||
gitExec
|
||||
|
|
@ -20176,6 +20181,7 @@ export class OrcaRuntimeService {
|
|||
headRefName: args.headRefName,
|
||||
baseRefName: args.baseRefName,
|
||||
isCrossRepository: args.isCrossRepository,
|
||||
issueSourcePreference: repo.issueSourcePreference,
|
||||
connectionId: repo.connectionId ?? null,
|
||||
localGitOptions: localWorktreeGitOptions,
|
||||
gitExec,
|
||||
|
|
|
|||
Loading…
Reference in New Issue