mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
* fix(gitea): share and cache the /pulls scan so card refreshes can't hammer a self-hosted forge Each worktree card resolved its branch by paginating the repo's full /pulls listing (state=all, up to 5 pages) independently, with a 5s timeout. Gitea/Forgejo have no head-branch filter, self-hosted Forgejo takes ~5s per page (it loads reviewer data per PR), and a push event refreshes every card at once — so one repo turned into hundreds of near-simultaneous requests whose responses were aborted right as they completed, and the burst OOM-killed a 512Mi Forgejo pod (#8807). - Share one in-flight /pulls scan per repo across concurrent branch lookups and cache the result for 30s, so a burst costs a single page walk instead of one per worktree. - Raise the list-scan timeout to 15s so slow-but-successful pages are used instead of discarded and retried. - Invalidate the cached scan after Orca itself creates a PR (and before the post-create fallback lookup) so the new PR is seen immediately. Fixes #8807 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gitea): don't let an in-flight scan re-cache a listing from before an invalidation Review feedback: if a PR is created while a card-refresh scan is in flight, the invalidation cleared the cache but the scan then finished and re-cached the pre-create listing, hiding the new PR for a full TTL. Track a per-repo generation: invalidation bumps it (and drops the in-flight promise), and a scan only writes the cache when its generation is still current. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gitea): bound PR scan cache and retry failures Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
216 lines
6.7 KiB
TypeScript
216 lines
6.7 KiB
TypeScript
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,
|
|
connectionId?: string | null
|
|
): Promise<CreateHostedReviewResult> {
|
|
if (input.provider !== 'gitea') {
|
|
return {
|
|
ok: false,
|
|
code: 'unsupported_provider',
|
|
error: 'Creating reviews for this provider is not supported yet.'
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|