Files
orca/src/main/gitea/pull-request-creation.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

221 lines
7.1 KiB
TypeScript

import type { ExecutionHostId } from '../../shared/execution-host'
import { hostedReviewSshConnectionId } from '../source-control/hosted-review-execution-host'
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 { getGiteaPullRequestForBranch, invalidateGiteaPullRequestScanForRepo } from './client'
import { mapGiteaPullRequest, type RawGiteaPullRequest } from './pull-request-mappers'
import { getGiteaRepoRef, type GiteaRepoRef } from './repository-ref'
const CREATE_REQUEST_TIMEOUT_MS = 60_000
function envValue(name: string): string | null {
const value = process.env[name]?.trim() ?? ''
return value.length > 0 ? value : null
}
function normalizeApiBaseUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, '')
return /\/api\/v1$/i.test(trimmed) ? trimmed : `${trimmed}/api/v1`
}
function configuredApiBaseUrl(repo: GiteaRepoRef): string {
const configured = envValue('ORCA_GITEA_API_BASE_URL')
return configured ? normalizeApiBaseUrl(configured) : repo.apiBaseUrl
}
export function isGiteaReviewCreationAuthenticated(): boolean {
return envValue('ORCA_GITEA_TOKEN') !== null
}
function authHeaders(): Record<string, string> {
const token = envValue('ORCA_GITEA_TOKEN')
return token ? { Authorization: `token ${token}` } : {}
}
function apiUrl(repo: GiteaRepoRef, path: string): URL {
return new URL(`${configuredApiBaseUrl(repo).replace(/\/+$/, '')}${path}`)
}
function encodedRepoPath(repo: GiteaRepoRef): string {
return `${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}`
}
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('createGiteaPullRequest 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: Gitea is not authenticated. Next step: set ORCA_GITEA_TOKEN in this environment.'
}
}
if (status === 409 || lower.includes('already exists') || lower.includes('already open')) {
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: Gitea rejected the pull request. Check the base branch and branch state, then try again.'
}
}
return {
ok: false,
code: 'unknown',
error: 'Create PR failed: Gitea 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> {
// Why: only called after a create attempt, which may have just mutated the
// remote — a cached /pulls scan from before the POST would miss the new PR.
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (repo) {
invalidateGiteaPullRequestScanForRepo(repo)
}
const existing = await getGiteaPullRequestForBranch(repoPath, head, null, connectionId)
return existing ? { number: existing.number, url: existing.url } : null
}
export async function createGiteaPullRequest(
repoPath: string,
input: CreateHostedReviewInput,
executionHostId: ExecutionHostId
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'gitea') {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating reviews for this provider is not supported yet.'
}
}
// The Gitea REST calls run on this client; only the git reads under them are routed.
const connectionId = hostedReviewSshConnectionId(executionHostId)
const repo = await getGiteaRepoRef(repoPath, connectionId)
if (!repo) {
return {
ok: false,
code: 'unsupported_provider',
error: 'Creating pull requests requires a Gitea 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 = {
base,
head,
title,
body,
...(input.draft ? { draft: true } : {})
}
try {
const raw = await requestHostedReviewJson<RawGiteaPullRequest>(
apiUrl(repo, `/repos/${encodedRepoPath(repo)}/pulls`),
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...authHeaders()
},
body: JSON.stringify(requestBody)
},
CREATE_REQUEST_TIMEOUT_MS
)
const created = mapGiteaPullRequest(raw, 'neutral')
if (created) {
invalidateGiteaPullRequestScanForRepo(repo)
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
}
}