diff --git a/.env.example b/.env.example index 609a8a7..830ef1c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,6 @@ PORT=8787 DEFAULT_BASE_BRANCH=main MAX_INLINE_COMMENTS=5 -REVIEW_TIMEOUT_MS=120000 +REVIEW_TIMEOUT_MS=600000 DEDUPE_TTL_SECONDS=1800 LOG_LEVEL=info diff --git a/README.md b/README.md index 7216cc2..502e0fe 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Optional: - `DEFAULT_BASE_BRANCH` (default: `main`) - `MAX_INLINE_COMMENTS` (default: `5`) -- `REVIEW_TIMEOUT_MS` (default: `120000`) +- `REVIEW_TIMEOUT_MS` (default: `600000`, 10 minutes) - `DEDUPE_TTL_SECONDS` (default: `1800`) - `LOG_LEVEL` (default: `info`; options: `debug`, `info`, `warn`, `error`) diff --git a/src/config/env.ts b/src/config/env.ts index 24e3312..d4d8dec 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -9,7 +9,7 @@ const envSchema = z.object({ PORT: z.coerce.number().int().positive().default(8787), DEFAULT_BASE_BRANCH: z.string().default("main"), 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) }); diff --git a/src/gitea/client.ts b/src/gitea/client.ts index 9ffb72d..1ba7f8f 100644 --- a/src/gitea/client.ts +++ b/src/gitea/client.ts @@ -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 { + await this.requestJson(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { + method: "DELETE" + }); + } + async getFileIfExists(owner: string, repo: string, path: string, ref: string): Promise { const encodedPath = path .split("/") diff --git a/src/gitea/comments-api.ts b/src/gitea/comments-api.ts new file mode 100644 index 0000000..f471a7b --- /dev/null +++ b/src/gitea/comments-api.ts @@ -0,0 +1,31 @@ +import { GiteaClient } from "./client.js"; + +export const PROGRESS_COMMENT_MARKER = ""; + +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 { + 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 { + await input.gitea.deleteIssueComment(input.owner, input.repo, input.commentId); +} diff --git a/src/run/review-runner.ts b/src/run/review-runner.ts index f0ec4fc..d0176f5 100644 --- a/src/run/review-runner.ts +++ b/src/run/review-runner.ts @@ -4,6 +4,7 @@ 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 } 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"; @@ -113,93 +114,134 @@ async function executeReview(params: { return "skipped"; } - 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: 2, - initialDelayMs: 500, - operationName: "runCursorReview", - correlationId: input.correlationId - }); + 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) + }); + } - await retry({ - fn: () => - deletePriorBotReviews({ - gitea, - owner, - repo, - prNumber, - botLogin: input.env.GITEA_BOT_LOGIN - }), - retries: 2, - initialDelayMs: 300, - operationName: "deletePriorBotReviews", - correlationId: input.correlationId - }); + 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: () => - postReview({ - gitea, - owner, - repo, - prNumber, - files, - review, - maxInlineComments - }), - retries: 2, - initialDelayMs: 300, - operationName: "postReview", - correlationId: input.correlationId - }); + 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: () => - removeBotFromReviewers({ - gitea, - owner, - repo, - prNumber, - botLogin: input.env.GITEA_BOT_LOGIN - }), - retries: 2, - initialDelayMs: 300, - operationName: "removeBotFromReviewers", - correlationId: input.correlationId - }); + await retry({ + fn: () => + postReview({ + gitea, + owner, + repo, + prNumber, + files, + review, + maxInlineComments + }), + retries: 2, + 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", { - correlation_id: input.correlationId, - owner, - repo, - pr_number: prNumber, - head_sha: headSha, - outcome: "success" - }); - return "success"; + input.dedupe.complete(dedupeKey); + + log("info", "Review completed", { + correlation_id: input.correlationId, + owner, + repo, + pr_number: prNumber, + head_sha: headSha, + 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"); }