Add PR progress comment and increase Cursor review timeout.

Post a temporary in-progress comment while reviewing, remove it when done, default timeout to 10 minutes, and skip retries on timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Daan Schouteden
2026-06-03 11:28:24 +02:00
parent 28488d0be9
commit 881719f743
6 changed files with 178 additions and 87 deletions
+1 -1
View File
@@ -7,6 +7,6 @@ PORT=8787
DEFAULT_BASE_BRANCH=main DEFAULT_BASE_BRANCH=main
MAX_INLINE_COMMENTS=5 MAX_INLINE_COMMENTS=5
REVIEW_TIMEOUT_MS=120000 REVIEW_TIMEOUT_MS=600000
DEDUPE_TTL_SECONDS=1800 DEDUPE_TTL_SECONDS=1800
LOG_LEVEL=info LOG_LEVEL=info
+1 -1
View File
@@ -154,7 +154,7 @@ Optional:
- `DEFAULT_BASE_BRANCH` (default: `main`) - `DEFAULT_BASE_BRANCH` (default: `main`)
- `MAX_INLINE_COMMENTS` (default: `5`) - `MAX_INLINE_COMMENTS` (default: `5`)
- `REVIEW_TIMEOUT_MS` (default: `120000`) - `REVIEW_TIMEOUT_MS` (default: `600000`, 10 minutes)
- `DEDUPE_TTL_SECONDS` (default: `1800`) - `DEDUPE_TTL_SECONDS` (default: `1800`)
- `LOG_LEVEL` (default: `info`; options: `debug`, `info`, `warn`, `error`) - `LOG_LEVEL` (default: `info`; options: `debug`, `info`, `warn`, `error`)
+1 -1
View File
@@ -9,7 +9,7 @@ const envSchema = z.object({
PORT: z.coerce.number().int().positive().default(8787), PORT: z.coerce.number().int().positive().default(8787),
DEFAULT_BASE_BRANCH: z.string().default("main"), DEFAULT_BASE_BRANCH: z.string().default("main"),
MAX_INLINE_COMMENTS: z.coerce.number().int().positive().default(5), MAX_INLINE_COMMENTS: z.coerce.number().int().positive().default(5),
REVIEW_TIMEOUT_MS: z.coerce.number().int().positive().default(120000), REVIEW_TIMEOUT_MS: z.coerce.number().int().positive().default(600000),
DEDUPE_TTL_SECONDS: z.coerce.number().int().positive().default(1800) DEDUPE_TTL_SECONDS: z.coerce.number().int().positive().default(1800)
}); });
+18
View File
@@ -59,6 +59,24 @@ export class GiteaClient {
}); });
} }
async createIssueComment(
owner: string,
repo: string,
issueIndex: number,
body: string
): Promise<{ id: number }> {
return this.requestJson(`/repos/${owner}/${repo}/issues/${issueIndex}/comments`, {
method: "POST",
body: { body }
});
}
async deleteIssueComment(owner: string, repo: string, commentId: number): Promise<void> {
await this.requestJson(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: "DELETE"
});
}
async getFileIfExists(owner: string, repo: string, path: string, ref: string): Promise<string | null> { async getFileIfExists(owner: string, repo: string, path: string, ref: string): Promise<string | null> {
const encodedPath = path const encodedPath = path
.split("/") .split("/")
+31
View File
@@ -0,0 +1,31 @@
import { GiteaClient } from "./client.js";
export const PROGRESS_COMMENT_MARKER = "<!-- gitea-pr-review-bot-progress -->";
export const PROGRESS_COMMENT_BODY =
`${PROGRESS_COMMENT_MARKER}\n\n` +
"🤖 **PR review in progress…** This comment will be removed when the review is ready.";
export async function postProgressComment(input: {
gitea: GiteaClient;
owner: string;
repo: string;
prNumber: number;
}): Promise<number> {
const comment = await input.gitea.createIssueComment(
input.owner,
input.repo,
input.prNumber,
PROGRESS_COMMENT_BODY
);
return comment.id;
}
export async function deleteProgressComment(input: {
gitea: GiteaClient;
owner: string;
repo: string;
commentId: number;
}): Promise<void> {
await input.gitea.deleteIssueComment(input.owner, input.repo, input.commentId);
}
+126 -84
View File
@@ -4,6 +4,7 @@ import { runCursorReview } from "../cursor/review-agent.js";
import { DedupeStore } from "../domain/dedupe-store.js"; import { DedupeStore } from "../domain/dedupe-store.js";
import { shouldProcessEvent } from "../domain/should-process-event.js"; import { shouldProcessEvent } from "../domain/should-process-event.js";
import { GiteaClient } from "../gitea/client.js"; import { GiteaClient } from "../gitea/client.js";
import { deleteProgressComment, postProgressComment } from "../gitea/comments-api.js";
import { deletePriorBotReviews, postReview } from "../gitea/review-api.js"; import { deletePriorBotReviews, postReview } from "../gitea/review-api.js";
import { removeBotFromReviewers } from "../gitea/reviewer-api.js"; import { removeBotFromReviewers } from "../gitea/reviewer-api.js";
import { buildReviewPrompt } from "../prompt/build-review-prompt.js"; import { buildReviewPrompt } from "../prompt/build-review-prompt.js";
@@ -113,93 +114,134 @@ async function executeReview(params: {
return "skipped"; return "skipped";
} }
const files = await retry({ let progressCommentId: number | undefined;
fn: () => gitea.getPullFiles(owner, repo, prNumber), try {
retries: 2, try {
initialDelayMs: 300, progressCommentId = await postProgressComment({ gitea, owner, repo, prNumber });
operationName: "getPullFiles", log("info", "Posted progress comment on PR", {
correlationId: input.correlationId correlation_id: input.correlationId,
}); pr_number: prNumber,
const maxInlineComments = repoConfig.max_inline_comments ?? input.env.MAX_INLINE_COMMENTS; comment_id: progressCommentId
const prompt = buildReviewPrompt({ });
owner, } catch (error) {
repo, log("warn", "Failed to post progress comment on PR", {
pull, correlation_id: input.correlationId,
files, pr_number: prNumber,
maxInlineComments, error: error instanceof Error ? error.message : String(error)
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: 2,
initialDelayMs: 500,
operationName: "runCursorReview",
correlationId: input.correlationId
});
await retry({ const files = await retry({
fn: () => fn: () => gitea.getPullFiles(owner, repo, prNumber),
deletePriorBotReviews({ retries: 2,
gitea, initialDelayMs: 300,
owner, operationName: "getPullFiles",
repo, correlationId: input.correlationId
prNumber, });
botLogin: input.env.GITEA_BOT_LOGIN const maxInlineComments = repoConfig.max_inline_comments ?? input.env.MAX_INLINE_COMMENTS;
}), const prompt = buildReviewPrompt({
retries: 2, owner,
initialDelayMs: 300, repo,
operationName: "deletePriorBotReviews", pull,
correlationId: input.correlationId 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({ await retry({
fn: () => fn: () =>
postReview({ deletePriorBotReviews({
gitea, gitea,
owner, owner,
repo, repo,
prNumber, prNumber,
files, botLogin: input.env.GITEA_BOT_LOGIN
review, }),
maxInlineComments retries: 2,
}), initialDelayMs: 300,
retries: 2, operationName: "deletePriorBotReviews",
initialDelayMs: 300, correlationId: input.correlationId
operationName: "postReview", });
correlationId: input.correlationId
});
await retry({ await retry({
fn: () => fn: () =>
removeBotFromReviewers({ postReview({
gitea, gitea,
owner, owner,
repo, repo,
prNumber, prNumber,
botLogin: input.env.GITEA_BOT_LOGIN files,
}), review,
retries: 2, maxInlineComments
initialDelayMs: 300, }),
operationName: "removeBotFromReviewers", retries: 2,
correlationId: input.correlationId initialDelayMs: 300,
}); operationName: "postReview",
correlationId: input.correlationId
});
input.dedupe.complete(dedupeKey); await retry({
fn: () =>
removeBotFromReviewers({
gitea,
owner,
repo,
prNumber,
botLogin: input.env.GITEA_BOT_LOGIN
}),
retries: 2,
initialDelayMs: 300,
operationName: "removeBotFromReviewers",
correlationId: input.correlationId
});
log("info", "Review completed", { input.dedupe.complete(dedupeKey);
correlation_id: input.correlationId,
owner, log("info", "Review completed", {
repo, correlation_id: input.correlationId,
pr_number: prNumber, owner,
head_sha: headSha, repo,
outcome: "success" pr_number: prNumber,
}); head_sha: headSha,
return "success"; outcome: "success"
});
return "success";
} 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");
} }