import { Env } from "../config/env.js"; import { loadRepoConfig } from "../config/load-repo-config.js"; import { runCursorReview } from "../cursor/review-agent.js"; import { DedupeStore } from "../domain/dedupe-store.js"; import { shouldProcessEvent } from "../domain/should-process-event.js"; import { GiteaClient } from "../gitea/client.js"; import { deleteProgressComment, postProgressComment, postTimeoutFailureComment } from "../gitea/comments-api.js"; import { deletePriorBotReviews, postReview } from "../gitea/review-api.js"; import { removeBotFromReviewers } from "../gitea/reviewer-api.js"; import { buildReviewPrompt } from "../prompt/build-review-prompt.js"; import { RoutedEvent } from "../types/events.js"; import { log } from "../logging/logger.js"; import { retry } from "./retry.js"; export async function runReview(input: { env: Env; event: RoutedEvent; dedupe: DedupeStore; correlationId: string; }): Promise<"skipped" | "success"> { const owner = input.event.payload.repository.owner.login; const repo = input.event.payload.repository.name; const prNumber = input.event.payload.pull_request.number; const headSha = input.event.payload.pull_request.head.sha; const dedupeKey = input.dedupe.createKey({ owner, repo, prNumber, headSha }); const dedupeSlot = input.dedupe.tryAcquire(dedupeKey); if (dedupeSlot !== "acquired") { log("info", "Review skipped: duplicate webhook", { correlation_id: input.correlationId, owner, repo, pr_number: prNumber, head_sha: headSha, reason: dedupeSlot }); return "skipped"; } const gitea = new GiteaClient(input.env.GITEA_BASE_URL, input.env.GITEA_TOKEN, 30000); try { return await executeReview({ input, gitea, owner, repo, prNumber, headSha, dedupeKey }); } catch (error) { input.dedupe.release(dedupeKey); throw error; } } async function executeReview(params: { input: { env: Env; event: RoutedEvent; dedupe: DedupeStore; correlationId: string; }; gitea: GiteaClient; owner: string; repo: string; prNumber: number; headSha: string; dedupeKey: string; }): Promise<"skipped" | "success"> { const { input, gitea, owner, repo, prNumber, headSha, dedupeKey } = params; const pull = await retry({ fn: () => gitea.getPull(owner, repo, prNumber), retries: 2, initialDelayMs: 300, operationName: "getPull", correlationId: input.correlationId }); const { config: repoConfig, ruleFiles } = await retry({ fn: () => loadRepoConfig({ gitea, owner, repo, ref: pull.head.ref }), retries: 2, initialDelayMs: 300, operationName: "loadRepoConfig", correlationId: input.correlationId }); const should = shouldProcessEvent({ event: input.event, repoConfig, defaultBaseBranch: input.env.DEFAULT_BASE_BRANCH, botLogin: input.env.GITEA_BOT_LOGIN }); if (!should.process) { input.dedupe.release(dedupeKey); log("info", "Review skipped by policy", { correlation_id: input.correlationId, owner, repo, pr_number: prNumber, head_sha: headSha, reason: should.reason ?? "unknown" }); return "skipped"; } let progressCommentId: number | undefined; try { try { progressCommentId = await postProgressComment({ gitea, owner, repo, prNumber }); log("info", "Posted progress comment on PR", { correlation_id: input.correlationId, pr_number: prNumber, comment_id: progressCommentId }); } catch (error) { log("warn", "Failed to post progress comment on PR", { correlation_id: input.correlationId, pr_number: prNumber, error: error instanceof Error ? error.message : String(error) }); } const files = await retry({ fn: () => gitea.getPullFiles(owner, repo, prNumber), retries: 2, initialDelayMs: 300, operationName: "getPullFiles", correlationId: input.correlationId }); const maxInlineComments = repoConfig.max_inline_comments ?? input.env.MAX_INLINE_COMMENTS; const prompt = buildReviewPrompt({ owner, repo, pull, files, maxInlineComments, repoRuleFiles: ruleFiles }); const review = await retry({ fn: () => runCursorReview({ apiKey: input.env.CURSOR_API_KEY, prompt, timeoutMs: input.env.REVIEW_TIMEOUT_MS, model: repoConfig.model, correlationId: input.correlationId }), retries: 1, initialDelayMs: 500, operationName: "runCursorReview", correlationId: input.correlationId, shouldRetry: (error) => !isTimeoutError(error) }); await retry({ fn: () => deletePriorBotReviews({ gitea, owner, repo, prNumber, botLogin: input.env.GITEA_BOT_LOGIN }), retries: 2, initialDelayMs: 300, operationName: "deletePriorBotReviews", correlationId: input.correlationId }); await retry({ fn: () => postReview({ gitea, owner, repo, prNumber, files, review, maxInlineComments }), retries: 2, initialDelayMs: 300, operationName: "postReview", correlationId: input.correlationId }); await retry({ fn: () => removeBotFromReviewers({ gitea, owner, repo, prNumber, botLogin: input.env.GITEA_BOT_LOGIN }), retries: 2, initialDelayMs: 300, operationName: "removeBotFromReviewers", correlationId: input.correlationId }); input.dedupe.complete(dedupeKey); log("info", "Review completed", { correlation_id: input.correlationId, owner, repo, pr_number: prNumber, head_sha: headSha, outcome: "success" }); return "success"; } catch (error) { if (isTimeoutError(error)) { await handleTimeoutFailure({ gitea, owner, repo, prNumber, botLogin: input.env.GITEA_BOT_LOGIN, timeoutMs: input.env.REVIEW_TIMEOUT_MS, correlationId: input.correlationId }); } throw error; } finally { if (progressCommentId !== undefined) { try { await deleteProgressComment({ gitea, owner, repo, commentId: progressCommentId }); log("info", "Removed progress comment from PR", { correlation_id: input.correlationId, pr_number: prNumber, comment_id: progressCommentId }); } catch (error) { log("warn", "Failed to remove progress comment from PR", { correlation_id: input.correlationId, pr_number: prNumber, comment_id: progressCommentId, error: error instanceof Error ? error.message : String(error) }); } } } } function isTimeoutError(error: unknown): boolean { return error instanceof Error && error.message.toLowerCase().includes("timed out"); } async function handleTimeoutFailure(input: { gitea: GiteaClient; owner: string; repo: string; prNumber: number; botLogin: string; timeoutMs: number; correlationId: string; }): Promise { try { await postTimeoutFailureComment({ gitea: input.gitea, owner: input.owner, repo: input.repo, prNumber: input.prNumber, timeoutMs: input.timeoutMs }); log("info", "Posted timeout failure comment on PR", { correlation_id: input.correlationId, pr_number: input.prNumber }); } catch (error) { log("warn", "Failed to post timeout failure comment on PR", { correlation_id: input.correlationId, pr_number: input.prNumber, error: error instanceof Error ? error.message : String(error) }); } try { await removeBotFromReviewers({ gitea: input.gitea, owner: input.owner, repo: input.repo, prNumber: input.prNumber, botLogin: input.botLogin }); log("info", "Removed bot from reviewers after timeout", { correlation_id: input.correlationId, pr_number: input.prNumber }); } catch (error) { log("warn", "Failed to remove bot from reviewers after timeout", { correlation_id: input.correlationId, pr_number: input.prNumber, error: error instanceof Error ? error.message : String(error) }); } }