Files
pr-reviewer/src/run/review-runner.ts
T
Daan Schouteden a847017358 Post failure comment and unassign bot when review times out.
After a Cursor timeout, explain the failure on the PR and remove the bot from requested reviewers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 13:05:00 +02:00

315 lines
8.3 KiB
TypeScript

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<void> {
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)
});
}
}