mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +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.
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { _resetAzureDevOpsRepoRefCache } from '../azure-devops/repository-ref'
|
|
import { getHostedReviewForBranch } from './hosted-review'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const OLD_ENV = process.env
|
|
|
|
type SeenRequest = {
|
|
pathname: string
|
|
search: string
|
|
authorization: string | undefined
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, body: unknown): void {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify(body))
|
|
}
|
|
|
|
describe('Azure DevOps hosted review integration', () => {
|
|
beforeEach(() => {
|
|
process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'local-pat' }
|
|
delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL
|
|
_resetAzureDevOpsRepoRefCache()
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = OLD_ENV
|
|
_resetAzureDevOpsRepoRefCache()
|
|
})
|
|
|
|
it('resolves an Azure Repos PR through real git remote parsing and HTTP API calls', async () => {
|
|
const seen: SeenRequest[] = []
|
|
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`)
|
|
seen.push({
|
|
pathname: url.pathname,
|
|
search: url.search,
|
|
authorization: req.headers.authorization
|
|
})
|
|
|
|
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
|
|
sendJson(res, {
|
|
id: 'repo-guid',
|
|
webUrl: 'https://dev.azure.com/acme/Project/_git/repo'
|
|
})
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
|
|
expect(url.searchParams.get('searchCriteria.sourceRefName')).toBe(
|
|
'refs/heads/feature/azure'
|
|
)
|
|
sendJson(res, {
|
|
value: [
|
|
{
|
|
pullRequestId: 31,
|
|
title: 'Azure branch',
|
|
status: 'active',
|
|
creationDate: '2026-05-16T00:00:00Z',
|
|
mergeStatus: 'succeeded',
|
|
lastMergeSourceCommit: { commitId: 'abc123' }
|
|
}
|
|
]
|
|
})
|
|
return
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/31/statuses'
|
|
) {
|
|
sendJson(res, { value: [{ state: 'succeeded' }] })
|
|
return
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ message: 'not found' }))
|
|
})
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
|
|
const repoPath = await mkdtemp(join(tmpdir(), 'orca-azure-review-'))
|
|
try {
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('expected TCP server address')
|
|
}
|
|
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = `http://127.0.0.1:${address.port}/acme/Project`
|
|
|
|
await execFileAsync('git', ['init'], { cwd: repoPath })
|
|
await execFileAsync(
|
|
'git',
|
|
['remote', 'add', 'origin', 'https://dev.azure.com/acme/Project/_git/repo'],
|
|
{ cwd: repoPath }
|
|
)
|
|
|
|
await expect(
|
|
getHostedReviewForBranch({
|
|
executionHostId: 'local',
|
|
repoPath,
|
|
branch: 'refs/heads/feature/azure'
|
|
})
|
|
).resolves.toEqual({
|
|
provider: 'azure-devops',
|
|
number: 31,
|
|
title: 'Azure branch',
|
|
state: 'open',
|
|
url: 'https://dev.azure.com/acme/Project/_git/repo/pullrequest/31',
|
|
status: 'success',
|
|
updatedAt: '2026-05-16T00:00:00Z',
|
|
mergeable: 'MERGEABLE',
|
|
headSha: 'abc123'
|
|
})
|
|
|
|
expect(seen.map((request) => request.pathname)).toEqual([
|
|
'/acme/Project/_apis/git/repositories/repo',
|
|
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests',
|
|
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests/31/statuses'
|
|
])
|
|
expect(seen.every((request) => request.authorization === 'Basic OmxvY2FsLXBhdA==')).toBe(true)
|
|
} finally {
|
|
await rm(repoPath, { recursive: true, force: true })
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
})
|
|
}
|
|
})
|
|
|
|
it('prefers an active Azure Repos PR over a newer abandoned PR for the same branch', async () => {
|
|
const seen: SeenRequest[] = []
|
|
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`)
|
|
seen.push({
|
|
pathname: url.pathname,
|
|
search: url.search,
|
|
authorization: req.headers.authorization
|
|
})
|
|
|
|
if (url.pathname === '/acme/Project/_apis/git/repositories/repo') {
|
|
sendJson(res, {
|
|
id: 'repo-guid',
|
|
webUrl: 'https://dev.azure.com/acme/Project/_git/repo'
|
|
})
|
|
return
|
|
}
|
|
|
|
if (url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests') {
|
|
sendJson(res, {
|
|
value: [
|
|
{
|
|
pullRequestId: 40,
|
|
title: 'Abandoned branch',
|
|
status: 'abandoned',
|
|
creationDate: '2026-05-10T00:00:00Z',
|
|
closedDate: '2026-05-20T00:00:00Z',
|
|
mergeStatus: 'conflicts',
|
|
lastMergeSourceCommit: { commitId: 'old123' }
|
|
},
|
|
{
|
|
pullRequestId: 41,
|
|
title: 'Active branch',
|
|
status: 'active',
|
|
creationDate: '2026-05-01T00:00:00Z',
|
|
mergeStatus: 'succeeded',
|
|
lastMergeSourceCommit: { commitId: 'active123' }
|
|
}
|
|
]
|
|
})
|
|
return
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/40/statuses'
|
|
) {
|
|
sendJson(res, { value: [{ state: 'failed' }] })
|
|
return
|
|
}
|
|
|
|
if (
|
|
url.pathname === '/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses'
|
|
) {
|
|
sendJson(res, { value: [{ state: 'succeeded' }] })
|
|
return
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ message: 'not found' }))
|
|
})
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
|
|
const repoPath = await mkdtemp(join(tmpdir(), 'orca-azure-review-active-'))
|
|
try {
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('expected TCP server address')
|
|
}
|
|
process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = `http://127.0.0.1:${address.port}/acme/Project`
|
|
|
|
await execFileAsync('git', ['init'], { cwd: repoPath })
|
|
await execFileAsync(
|
|
'git',
|
|
['remote', 'add', 'origin', 'https://dev.azure.com/acme/Project/_git/repo'],
|
|
{ cwd: repoPath }
|
|
)
|
|
|
|
await expect(
|
|
getHostedReviewForBranch({
|
|
executionHostId: 'local',
|
|
repoPath,
|
|
branch: 'refs/heads/feature/azure'
|
|
})
|
|
).resolves.toMatchObject({
|
|
provider: 'azure-devops',
|
|
number: 41,
|
|
title: 'Active branch',
|
|
state: 'open',
|
|
status: 'success',
|
|
mergeable: 'MERGEABLE',
|
|
headSha: 'active123'
|
|
})
|
|
|
|
expect(seen.map((request) => request.pathname)).toContain(
|
|
'/acme/Project/_apis/git/repositories/repo-guid/pullRequests/41/statuses'
|
|
)
|
|
} finally {
|
|
await rm(repoPath, { recursive: true, force: true })
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()))
|
|
})
|
|
}
|
|
})
|
|
})
|