Files
orca/src/main/bitbucket/pull-request-creation.test.ts
T
Neil d05dd8ef50 fix(source-control): route hosted reviews by resolved execution host (#18382)
`ForgeProvider.createReview(repoPath, input, connectionId, options)` and the
`connectionId` on `ForgeProviderRepositoryContext` carried the same collapse the
five prior migrations closed: `string | null` spells "genuinely local", "runtime
host" and "could not resolve" with one value. Because it was decided two layers
up -- `repo.connectionId ?? null` at the `hostedReview:*` IPC handlers and in
`RuntimeHostedReviewCommands` -- a row naming its owner only as
`executionHostId: ssh:<target>` ran the whole review path against this machine's
copy of a remote path (#11163): `git rev-parse`, `git status`, the base-on-remote
ref probe, the upstream divergence read, and `gh`/`glab` with no host flags.

Replace it with a required `ExecutionHostId` threaded from the decision point
through the contract, routed by #18296's `resolveGitRouteForHost`. The parameter
is removed rather than added beside, so all five implementations -- GitLab,
GitHub, Bitbucket, Azure DevOps, Gitea -- and every caller became a compile
error. None of these families carries `@ts-nocheck`, so unlike #18325 that
guarantee is real here; `orca-runtime-file-commands.ts` does, but it only
constructs `RuntimeHostedReviewCommands` with unchanged deps.

Also fixed at the sites:

- The branch cache scoped entries on `connectionId ?? ''`, so two rows at one
  path on different hosts shared one cached review, one backoff deadline and one
  invalidation. Keyed on the resolved host now, as #18377 did for its probe key.
- `hostedReview:create` resolved shared symlink paths and normalized worktree
  paths off the raw field, so an `executionHostId`-only SSH row read `orca.yaml`
  and `resolve()`d a remote POSIX path on the client. Those ask the file-holder
  question -- `getRepoSshConnectionId` -- not the dialable one.
- An SSH host with no provider now refuses inside the git-state layer instead of
  reaching the local branch, keeping "remote and unreachable" distinct from
  "local" (docs/reference/ssh-execution-boundary.md).

`runtime:` is a routing mistake inside `hostedReviewSshConnectionId` -- that
environment's server runs its own git, and the SSH target on its repo row is
nested in that server's namespace, so dialing it here reaches a same-named box of
ours. But store-backed callers ask `getRepoHostedReviewExecutionHostId` first,
which is "what may this client dial" and answers `local` for a `runtime:` row.
That is deliberate and matches #18377: the runtime registration controller only
adopts a `runtime:` stamp onto a row with no `connectionId`
(`runtimeRepoMatchesExecutionHost` refuses to match an SSH row), so the checkout
really is in this process and refusing would regress a runtime server creating
reviews for its own rows.

No wire change. `connectionId` on `CreateHostedReviewArgs`,
`CreateStackedHostedReviewArgs` and `HostedReviewCreationEligibilityArgs` in
src/shared/hosted-review.ts is untouched -- every host already ignores it in
favor of the repo row, and removing it from the request types would only churn
the schema older clients still populate. The main-side eligibility input `Omit`s
it so nothing on this side can read the ambiguous field again.
2026-09-03 01:32:46 -07:00

183 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createBitbucketPullRequest,
isBitbucketReviewCreationAuthenticated
} from './pull-request-creation'
import { _resetBitbucketRepoRefCache } from './repository-ref'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: () => 0
}))
vi.mock('../source-control/pull-request-template', () => ({
readHostedPullRequestTemplate: vi.fn(async () => 'Template body')
}))
const OLD_ENV = process.env
const OLD_FETCH = globalThis.fetch
const CREATE_INPUT = {
provider: 'bitbucket',
base: 'main',
head: 'feature/login',
title: 'Add login'
} as const
function createdPullRequestResponse(): Response {
return Response.json({
id: 42,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/42' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
}
describe('Bitbucket pull request creation', () => {
beforeEach(() => {
process.env = { ...OLD_ENV, ORCA_BITBUCKET_ACCESS_TOKEN: 'bb-token' }
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://bitbucket.org/team/repo.git\n',
stderr: ''
})
_resetBitbucketRepoRefCache()
})
afterEach(() => {
process.env = OLD_ENV
globalThis.fetch = OLD_FETCH
_resetBitbucketRepoRefCache()
})
it('posts the Bitbucket source/destination create body to the repository endpoint', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input))
expect(url.origin).toBe('https://api.bitbucket.org')
expect(url.pathname).toBe('/2.0/repositories/team/repo/pullrequests')
const requestInit = init!
expect(requestInit.method).toBe('POST')
expect((requestInit.headers as Record<string, string>).Authorization).toBe('Bearer bb-token')
expect(JSON.parse(String(requestInit.body))).toEqual({
title: 'Add login',
description: '',
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
})
return createdPullRequestResponse()
})
globalThis.fetch = fetchMock as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).resolves.toEqual({
ok: true,
number: 42,
url: 'https://bitbucket.org/team/repo/pull-requests/42'
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('uses the stored credential when no environment variable is set', async () => {
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
const fetchMock = vi.fn(async () => createdPullRequestResponse())
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')
// No env var and no stored credential: fail closed rather than POST anonymously.
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('ignores a draft request rather than dead-ending a hidden persisted default', async () => {
const fetchMock = vi.fn(async () => createdPullRequestResponse())
globalThis.fetch = fetchMock as unknown as typeof fetch
// Why: the composer hides the Draft toggle for Bitbucket, so a `true` here
// is an unreachable persisted default the user cannot clear.
await expect(
createBitbucketPullRequest('/repo', { ...CREATE_INPUT, draft: true }, 'local')
).resolves.toMatchObject({ ok: true, number: 42 })
expect(JSON.parse(String((fetchMock.mock.calls[0] as never[])[1]['body']))).not.toHaveProperty(
'draft'
)
})
it('reports authenticated only when a credential resolves', async () => {
expect(isBitbucketReviewCreationAuthenticated()).toBe(true)
delete process.env.ORCA_BITBUCKET_ACCESS_TOKEN
expect(isBitbucketReviewCreationAuthenticated()).toBe(false)
})
it('maps a duplicate-branch rejection to already_exists with the existing review', async () => {
let call = 0
const fetchMock = vi.fn(async () => {
call += 1
if (call === 1) {
return Response.json(
{ error: { message: 'There is already a pull request for this branch.' } },
{ status: 400 }
)
}
return Response.json({
values: [
{
id: 7,
title: 'Add login',
state: 'OPEN',
updated_on: '2026-08-11T00:00:00Z',
links: { html: { href: 'https://bitbucket.org/team/repo/pull-requests/7' } },
source: { branch: { name: 'feature/login' } },
destination: { branch: { name: 'main' } }
}
]
})
})
globalThis.fetch = fetchMock as unknown as typeof fetch
expect(await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).toMatchObject({
ok: false,
code: 'already_exists',
existingReview: { number: 7, url: 'https://bitbucket.org/team/repo/pull-requests/7' }
})
})
it('maps a 401 to auth_required pointing at both credential paths', async () => {
globalThis.fetch = vi.fn(async () =>
Response.json({ error: { message: 'Unauthorized' } }, { status: 401 })
) as unknown as typeof fetch
const result = await createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')
expect(result).toMatchObject({ ok: false, code: 'auth_required' })
expect(!result.ok && result.error).toContain('Settings')
})
it('refuses a non-Bitbucket remote', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: 'https://github.com/team/repo.git\n',
stderr: ''
})
globalThis.fetch = vi.fn() as unknown as typeof fetch
await expect(createBitbucketPullRequest('/repo', CREATE_INPUT, 'local')).resolves.toMatchObject(
{
ok: false,
code: 'unsupported_provider'
}
)
})
})