diff --git a/src/main/github/client-issue-origin-preference.test.ts b/src/main/github/client-issue-origin-preference.test.ts new file mode 100644 index 000000000..951962a3c --- /dev/null +++ b/src/main/github/client-issue-origin-preference.test.ts @@ -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('./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() + 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' }) + }) +}) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index aa16bed0c..17820ddd0 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -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 }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 70c4ea276..4b4ede36e 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3f7cdffff..f1df222b9 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 { + 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 { 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 { - 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 { 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 diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 57eaacb67..9669ef29a 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -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', diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 303bc1ed0..05c012c1a 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -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 { @@ -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.` } diff --git a/src/main/github/review-head-remote.test.ts b/src/main/github/review-head-remote.test.ts index f5ff2fff2..216907a77 100644 --- a/src/main/github/review-head-remote.test.ts +++ b/src/main/github/review-head-remote.test.ts @@ -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') diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index 2df757471..2a0dd53ca 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -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//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) { diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index aca6e4a7c..be2576051 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -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() }) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index bc2bef2a7..11a063b51 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -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 () => { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 5e389be85..2df6d9200 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -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 { const item: Omit | null = await getWorkItem( repoPath, number, type, connectionId, - ...localGitOptionArgs(localGitOptions) + localGitOptions, + preference ) if (!item) { return null diff --git a/src/main/ipc/github-work-item-args.test.ts b/src/main/ipc/github-work-item-args.test.ts index 234906a37..8e253bc95 100644 --- a/src/main/ipc/github-work-item-args.test.ts +++ b/src/main/ipc/github-work-item-args.test.ts @@ -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') }) }) diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index e3dc63b2e..0aff921ee 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -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( n: number, t?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions?: LocalGitExecOptions + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference ) => Promise, localGitOptions?: LocalGitExecOptions ): Promise | null { @@ -38,5 +41,14 @@ export function dispatchWorkItem( 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 + ) } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bb1dd0cc1..bbd26f84d 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -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 }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 67b197e91..e632b03f0 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ccbf77951..22aec8673 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d16233a14..6d8154522 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 49be1d9c1..a37a37c5e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16139,12 +16139,15 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { 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 => 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,