a847017358
After a Cursor timeout, explain the failure on the PR and remove the bot from requested reviewers. Co-authored-by: Cursor <cursoragent@cursor.com>
315 lines
8.3 KiB
TypeScript
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)
|
|
});
|
|
}
|
|
}
|