mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +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.
271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
import type { ExecutionHostId } from '../../shared/execution-host'
|
|
import { hostedReviewSshConnectionId } from '../source-control/hosted-review-execution-host'
|
|
import { Buffer } from 'node:buffer'
|
|
import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review'
|
|
import {
|
|
normalizeHostedReviewBaseRef,
|
|
normalizeHostedReviewHeadRef
|
|
} from '../../shared/hosted-review-refs'
|
|
import {
|
|
HostedReviewApiRequestError,
|
|
requestHostedReviewJson
|
|
} from '../source-control/hosted-review-api-request'
|
|
import { readHostedPullRequestTemplate } from '../source-control/pull-request-template'
|
|
import {
|
|
azureDevOpsApiVersionForOrigin,
|
|
isAzureDevOpsPreviewVersionRejection,
|
|
markAzureDevOpsPreviewApiVersionOrigin,
|
|
resolveAzureDevOpsGitApiBaseUrl
|
|
} from './azure-devops-api-request'
|
|
import { getAzureDevOpsPullRequestForBranch } from './client'
|
|
import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers'
|
|
import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref'
|
|
|
|
const CREATE_REQUEST_TIMEOUT_MS = 60_000
|
|
|
|
type AzureDevOpsCreateAuthConfig = {
|
|
pat: string | null
|
|
accessToken: string | null
|
|
username: string | null
|
|
}
|
|
|
|
function envValue(name: string): string | null {
|
|
const value = process.env[name]?.trim() ?? ''
|
|
return value.length > 0 ? value : null
|
|
}
|
|
|
|
function getAuthConfig(): AzureDevOpsCreateAuthConfig {
|
|
return {
|
|
pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'),
|
|
accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'),
|
|
username: envValue('ORCA_AZURE_DEVOPS_USERNAME')
|
|
}
|
|
}
|
|
|
|
export function isAzureDevOpsReviewCreationAuthenticated(): boolean {
|
|
const config = getAuthConfig()
|
|
return Boolean(config.pat || config.accessToken)
|
|
}
|
|
|
|
function authHeaders(config: AzureDevOpsCreateAuthConfig): Record<string, string> {
|
|
if (config.accessToken) {
|
|
return { Authorization: `Bearer ${config.accessToken}` }
|
|
}
|
|
if (config.pat) {
|
|
const encoded = Buffer.from(`${config.username ?? ''}:${config.pat}`).toString('base64')
|
|
return { Authorization: `Basic ${encoded}` }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function apiUrl(repo: AzureDevOpsRepoRef, path: string): URL {
|
|
const baseUrl = resolveAzureDevOpsGitApiBaseUrl(repo)
|
|
const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`)
|
|
url.searchParams.set('api-version', azureDevOpsApiVersionForOrigin(url.origin))
|
|
return url
|
|
}
|
|
|
|
// Why (STA-3494): Azure DevOps Server 400s versioned requests without -preview;
|
|
// the rejection happens before the PR is created, so one retry is safe.
|
|
async function requestCreatePullRequest(
|
|
repo: AzureDevOpsRepoRef,
|
|
path: string,
|
|
init: Omit<RequestInit, 'signal'>
|
|
): Promise<RawAzureDevOpsPullRequest> {
|
|
const url = apiUrl(repo, path)
|
|
try {
|
|
return await requestHostedReviewJson<RawAzureDevOpsPullRequest>(
|
|
url,
|
|
init,
|
|
CREATE_REQUEST_TIMEOUT_MS
|
|
)
|
|
} catch (error) {
|
|
if (
|
|
url.searchParams.get('api-version')?.endsWith('-preview') ||
|
|
!(error instanceof HostedReviewApiRequestError) ||
|
|
!isAzureDevOpsPreviewVersionRejection(error.status, error.message)
|
|
) {
|
|
throw error
|
|
}
|
|
markAzureDevOpsPreviewApiVersionOrigin(url.origin)
|
|
return requestHostedReviewJson<RawAzureDevOpsPullRequest>(
|
|
apiUrl(repo, path),
|
|
init,
|
|
CREATE_REQUEST_TIMEOUT_MS
|
|
)
|
|
}
|
|
}
|
|
|
|
function encodePathSegment(value: string): string {
|
|
return encodeURIComponent(value)
|
|
}
|
|
|
|
function azureBranchRef(branch: string): string {
|
|
return `refs/heads/${branch.replace(/^refs\/heads\//, '')}`
|
|
}
|
|
|
|
function apiErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
function classifyCreateError(error: unknown): CreateHostedReviewResult {
|
|
const message = apiErrorMessage(error)
|
|
if (message) {
|
|
console.warn('createAzureDevOpsPullRequest failed:', message)
|
|
}
|
|
const lower = message.toLowerCase()
|
|
const status = error instanceof HostedReviewApiRequestError ? error.status : null
|
|
if (
|
|
status === 401 ||
|
|
status === 403 ||
|
|
lower.includes('unauthorized') ||
|
|
lower.includes('forbidden') ||
|
|
lower.includes('authentication')
|
|
) {
|
|
return {
|
|
ok: false,
|
|
code: 'auth_required',
|
|
error:
|
|
'Create PR failed: Azure DevOps is not authenticated. Next step: set ORCA_AZURE_DEVOPS_TOKEN in this environment.'
|
|
}
|
|
}
|
|
if (status === 409 || lower.includes('already exists') || lower.includes('active pull request')) {
|
|
return {
|
|
ok: false,
|
|
code: 'already_exists',
|
|
error: 'A pull request already exists for this branch.'
|
|
}
|
|
}
|
|
if (error instanceof HostedReviewApiRequestError && error.timedOut) {
|
|
return {
|
|
ok: false,
|
|
code: 'unknown_completion',
|
|
error: 'PR creation may have completed. Refreshing branch review state...'
|
|
}
|
|
}
|
|
if (status === 400 || status === 422 || lower.includes('validation')) {
|
|
return {
|
|
ok: false,
|
|
code: 'validation',
|
|
error:
|
|
'Create PR failed: Azure DevOps rejected the pull request. Check the base branch and branch state, then try again.'
|
|
}
|
|
}
|
|
return {
|
|
ok: false,
|
|
code: 'unknown',
|
|
error:
|
|
'Create PR failed: Azure DevOps could not create the pull request. Try again in a moment.'
|
|
}
|
|
}
|
|
|
|
async function findExistingPullRequest(
|
|
repoPath: string,
|
|
head: string,
|
|
connectionId?: string | null
|
|
): Promise<{ number: number; url: string } | null> {
|
|
const existing = await getAzureDevOpsPullRequestForBranch(repoPath, head, null, connectionId)
|
|
return existing ? { number: existing.number, url: existing.url } : null
|
|
}
|
|
|
|
export async function createAzureDevOpsPullRequest(
|
|
repoPath: string,
|
|
input: CreateHostedReviewInput,
|
|
executionHostId: ExecutionHostId
|
|
): Promise<CreateHostedReviewResult> {
|
|
if (input.provider !== 'azure-devops') {
|
|
return {
|
|
ok: false,
|
|
code: 'unsupported_provider',
|
|
error: 'Creating reviews for this provider is not supported yet.'
|
|
}
|
|
}
|
|
|
|
// The Azure DevOps REST calls run on this client; only the git reads under them are routed.
|
|
const connectionId = hostedReviewSshConnectionId(executionHostId)
|
|
|
|
const repo = await getAzureDevOpsRepoRef(repoPath, connectionId)
|
|
if (!repo) {
|
|
return {
|
|
ok: false,
|
|
code: 'unsupported_provider',
|
|
error: 'Creating pull requests requires an Azure DevOps remote.'
|
|
}
|
|
}
|
|
|
|
const base = normalizeHostedReviewBaseRef(input.base)
|
|
const head = input.head ? normalizeHostedReviewHeadRef(input.head) : ''
|
|
const title = input.title.trim()
|
|
if (!base || !head || !title) {
|
|
return {
|
|
ok: false,
|
|
code: 'validation',
|
|
error: 'Create PR failed: base branch, head branch, and title are required.'
|
|
}
|
|
}
|
|
if (head.toLowerCase() === base.toLowerCase()) {
|
|
return {
|
|
ok: false,
|
|
code: 'validation',
|
|
error: 'Create PR failed: choose a different base branch before creating a pull request.'
|
|
}
|
|
}
|
|
|
|
const body =
|
|
input.useTemplate && !input.body?.trim()
|
|
? await readHostedPullRequestTemplate(repoPath, connectionId)
|
|
: (input.body ?? '')
|
|
const requestBody = {
|
|
sourceRefName: azureBranchRef(head),
|
|
targetRefName: azureBranchRef(base),
|
|
title,
|
|
description: body,
|
|
...(input.draft ? { isDraft: true } : {})
|
|
}
|
|
|
|
try {
|
|
const raw = await requestCreatePullRequest(
|
|
repo,
|
|
`/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
...authHeaders(getAuthConfig())
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
}
|
|
)
|
|
const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl)
|
|
if (created) {
|
|
return { ok: true, number: created.number, url: created.url }
|
|
}
|
|
const found = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
|
return found
|
|
? { ok: true, ...found }
|
|
: {
|
|
ok: false,
|
|
code: 'unknown_completion',
|
|
error: 'PR creation may have completed. Refreshing branch review state...'
|
|
}
|
|
} catch (error) {
|
|
const classified = classifyCreateError(error)
|
|
if (
|
|
!classified.ok &&
|
|
(classified.code === 'already_exists' || classified.code === 'unknown_completion')
|
|
) {
|
|
const existing = await findExistingPullRequest(repoPath, head, connectionId).catch(() => null)
|
|
if (existing) {
|
|
return {
|
|
ok: false,
|
|
code: 'already_exists',
|
|
error: 'A pull request already exists for this branch.',
|
|
existingReview: existing
|
|
}
|
|
}
|
|
}
|
|
return classified
|
|
}
|
|
}
|