mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
`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.
250 lines
7.5 KiB
TypeScript
250 lines
7.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
createAzureDevOpsPullRequest,
|
|
isAzureDevOpsReviewCreationAuthenticated
|
|
} from './pull-request-creation'
|
|
import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request'
|
|
import { _resetAzureDevOpsRepoRefCache } from './repository-ref'
|
|
import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe'
|
|
|
|
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
|
|
|
|
describe('Azure DevOps pull request creation', () => {
|
|
beforeEach(() => {
|
|
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' }
|
|
gitExecFileAsyncMock.mockReset()
|
|
getSshGitProviderMock.mockReset()
|
|
gitExecFileAsyncMock.mockResolvedValue({
|
|
stdout: 'https://dev.azure.com/acme/Project/_git/repo\n',
|
|
stderr: ''
|
|
})
|
|
_resetAzureDevOpsRepoRefCache()
|
|
_resetAzureDevOpsPreviewApiVersionCache()
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = OLD_ENV
|
|
globalThis.fetch = OLD_FETCH
|
|
_resetAzureDevOpsRepoRefCache()
|
|
})
|
|
|
|
it('treats token-only auth as sufficient for repo-scoped creation', () => {
|
|
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
|
|
expect(isAzureDevOpsReviewCreationAuthenticated()).toBe(true)
|
|
})
|
|
|
|
it('posts a pull request create body to the repository REST endpoint', async () => {
|
|
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = new URL(String(input))
|
|
expect(url.pathname).toBe('/acme/Project/_apis/git/repositories/repo/pullRequests')
|
|
expect(url.searchParams.get('api-version')).toBe('7.1')
|
|
expect(init).toBeDefined()
|
|
const requestInit = init!
|
|
expect(requestInit.method).toBe('POST')
|
|
expect((requestInit.headers as Record<string, string>).Authorization).toMatch(/^Basic /)
|
|
expect(JSON.parse(String(requestInit.body))).toEqual({
|
|
sourceRefName: 'refs/heads/feature/azure',
|
|
targetRefName: 'refs/heads/main',
|
|
title: 'Add Azure create',
|
|
description: 'Body',
|
|
isDraft: true
|
|
})
|
|
return Response.json({
|
|
pullRequestId: 37,
|
|
title: 'Add Azure create',
|
|
status: 'active',
|
|
isDraft: true,
|
|
creationDate: '2026-06-01T00:00:00Z',
|
|
_links: {
|
|
web: {
|
|
href: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
|
|
}
|
|
}
|
|
})
|
|
})
|
|
globalThis.fetch = fetchMock as never
|
|
|
|
await expect(
|
|
createAzureDevOpsPullRequest(
|
|
'/repo',
|
|
{
|
|
provider: 'azure-devops',
|
|
base: 'origin/main',
|
|
head: 'refs/heads/feature/azure',
|
|
title: 'Add Azure create',
|
|
body: 'Body',
|
|
draft: true
|
|
},
|
|
'local'
|
|
)
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
number: 37,
|
|
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/37'
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('retries PR creation with -preview when the Server rejects the api-version (STA-3494)', async () => {
|
|
gitExecFileAsyncMock.mockResolvedValue({
|
|
stdout: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo\n',
|
|
stderr: ''
|
|
})
|
|
const versions: (string | null)[] = []
|
|
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
|
const url = new URL(String(input))
|
|
expect(url.pathname).toBe(
|
|
'/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo/pullRequests'
|
|
)
|
|
versions.push(url.searchParams.get('api-version'))
|
|
if (!url.searchParams.get('api-version')?.endsWith('-preview')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
message: 'The requested version "7.1" of the resource is under preview.',
|
|
typeKey: 'VssInvalidPreviewVersionException'
|
|
}),
|
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
}
|
|
return Response.json({
|
|
pullRequestId: 51,
|
|
title: 'Server create',
|
|
status: 'active',
|
|
creationDate: '2026-06-01T00:00:00Z',
|
|
_links: {
|
|
web: {
|
|
href: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51'
|
|
}
|
|
}
|
|
})
|
|
})
|
|
globalThis.fetch = fetchMock as never
|
|
|
|
await expect(
|
|
createAzureDevOpsPullRequest(
|
|
'/repo',
|
|
{
|
|
provider: 'azure-devops',
|
|
base: 'main',
|
|
head: 'feature/server',
|
|
title: 'Server create',
|
|
body: 'Body'
|
|
},
|
|
'local'
|
|
)
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
number: 51,
|
|
url: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51'
|
|
})
|
|
expect(versions).toEqual(['7.1', '7.1-preview'])
|
|
})
|
|
|
|
it('does not retry PR creation when only the error message names the preview exception', async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
Response.json(
|
|
{ message: 'Validation failed near VssInvalidPreviewVersionException' },
|
|
{ status: 400 }
|
|
)
|
|
)
|
|
globalThis.fetch = fetchMock as never
|
|
|
|
await expect(
|
|
createAzureDevOpsPullRequest(
|
|
'/repo',
|
|
{
|
|
provider: 'azure-devops',
|
|
base: 'main',
|
|
head: 'feature/azure',
|
|
title: 'Do not retry'
|
|
},
|
|
'local'
|
|
)
|
|
).resolves.toMatchObject({ ok: false, code: 'validation' })
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('resolves Azure DevOps remotes through the SSH git provider', async () => {
|
|
const remoteGit = {
|
|
exec: vi.fn(async () => ({
|
|
stdout: 'git@ssh.dev.azure.com:v3/acme/Project/repo.git\n',
|
|
stderr: ''
|
|
}))
|
|
}
|
|
getSshGitProviderMock.mockReturnValue(remoteGit)
|
|
globalThis.fetch = vi.fn(async () =>
|
|
Response.json({
|
|
pullRequestId: 38,
|
|
title: 'Remote Azure create',
|
|
status: 'active',
|
|
creationDate: '2026-06-01T00:00:00Z'
|
|
})
|
|
) as never
|
|
|
|
await expect(
|
|
createAzureDevOpsPullRequest(
|
|
'/remote/repo',
|
|
{
|
|
provider: 'azure-devops',
|
|
base: 'main',
|
|
head: 'feature/azure',
|
|
title: 'Remote Azure create'
|
|
},
|
|
'ssh:ssh-1'
|
|
)
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
number: 38
|
|
})
|
|
expect(remoteGit.exec).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/remote/repo', {
|
|
signal: expect.any(AbortSignal)
|
|
})
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('classifies auth failures without retrying shell commands', async () => {
|
|
globalThis.fetch = vi.fn(async () =>
|
|
Response.json({ message: 'Unauthorized' }, { status: 401 })
|
|
) as never
|
|
|
|
await expect(
|
|
createAzureDevOpsPullRequest(
|
|
'/repo',
|
|
{
|
|
provider: 'azure-devops',
|
|
base: 'main',
|
|
head: 'feature/azure',
|
|
title: 'Add Azure create'
|
|
},
|
|
'local'
|
|
)
|
|
).resolves.toMatchObject({
|
|
ok: false,
|
|
code: 'auth_required'
|
|
})
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
|
|
cwd: '/repo',
|
|
timeout: REMOTE_URL_PROBE_TIMEOUT_MS
|
|
})
|
|
})
|
|
})
|