Blame · Line-by-line history
review-response-agent.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| ddb25a6 | 1 | /** |
| 2 | * Block K6 — Autonomous review-response agent. | |
| 3 | * | |
| 4 | * Fires on `pr.review_comment`. Classifies the human reviewer's comment and | |
| 5 | * either drafts a prose reply explaining the change it would make, or posts | |
| 6 | * a short acknowledgement. v1 is strictly read-only with respect to the PR | |
| 7 | * branch — K5 (fix agent, wave 3) owns commit authorship. Every reply is | |
| 8 | * tagged with `isAiReview=true` and a marker string so future invocations | |
| 9 | * can skip replying to our own comments. | |
| 10 | * | |
| 11 | * Contract: | |
| 12 | * - Never throws — all errors are caught and either recorded on the run | |
| 13 | * or silently swallowed via `shouldSkip`. | |
| 14 | * - If the skip rules fire we do NOT create an `agent_runs` row — agent | |
| 15 | * runs should reflect work, not no-ops. | |
| 16 | * - If the AI backend is unavailable we still create the run and write | |
| 17 | * `summary = "AI backend unavailable; skipped reply"`, but post nothing. | |
| 18 | */ | |
| 19 | ||
| 20 | import { eq } from "drizzle-orm"; | |
| 21 | import { db } from "../../db"; | |
| 22 | import { | |
| 23 | prComments, | |
| 24 | pullRequests, | |
| 25 | repositories, | |
| 26 | users, | |
| 27 | } from "../../db/schema"; | |
| 28 | import { | |
| 29 | MODEL_HAIKU, | |
| 30 | MODEL_SONNET, | |
| 31 | extractText, | |
| 32 | getAnthropic, | |
| 33 | isAiAvailable, | |
| 34 | parseJsonResponse, | |
| 35 | } from "../ai-client"; | |
| 36 | import { | |
| 37 | executeAgentRun, | |
| 38 | startAgentRun, | |
| 39 | type AgentExecutorResult, | |
| 40 | } from "../agent-runtime"; | |
| 41 | import { getDefaultBranch, getDiff } from "../../git/repository"; | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // Public types | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | export interface RunReviewResponseAgentArgs { | |
| 48 | repositoryId: string; | |
| 49 | prId: string; | |
| 50 | prNumber: number; | |
| 51 | commentId: string; | |
| 52 | commentBody: string; | |
| 53 | commenterId?: string; | |
| 54 | filePath?: string; | |
| 55 | lineNumber?: number; | |
| 56 | } | |
| 57 | ||
| 58 | export interface RunReviewResponseAgentResult { | |
| 59 | skipped: boolean; | |
| 60 | skipReason?: string; | |
| 61 | runId?: string; | |
| 62 | } | |
| 63 | ||
| 64 | // --------------------------------------------------------------------------- | |
| 65 | // Pure helpers (unit-testable without DB / network) | |
| 66 | // --------------------------------------------------------------------------- | |
| 67 | ||
| 68 | /** | |
| 69 | * Marker we embed at the tail of every AI-authored reply. Used as a | |
| 70 | * best-effort loop-breaker: if we see our own marker at the end of the | |
| 71 | * parent-comment body we refuse to reply again. | |
| 72 | */ | |
| 73 | export const AI_REPLY_MARKER = | |
| 74 | "— drafted by review-response agent; human must apply or reject"; | |
| 75 | ||
| 76 | /** Single-emoji / reaction detector. */ | |
| 77 | const EMOJI_RANGE = | |
| 78 | /^[\s\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u200d\uFE0F]+$/u; | |
| 79 | const REACTION_WORDS: ReadonlySet<string> = new Set([ | |
| 80 | "lgtm", | |
| 81 | "+1", | |
| 82 | "-1", | |
| 83 | "👍", | |
| 84 | "👎", | |
| 85 | "ok", | |
| 86 | "okay", | |
| 87 | "ack", | |
| 88 | "ty", | |
| 89 | "thx", | |
| 90 | "thanks", | |
| 91 | "nice", | |
| 92 | "cool", | |
| 93 | "great", | |
| 94 | "yep", | |
| 95 | "yes", | |
| 96 | "no", | |
| 97 | "nope", | |
| 98 | "same", | |
| 99 | ]); | |
| 100 | ||
| 101 | /** | |
| 102 | * Args accepted by `shouldSkip`. Slightly wider than the runtime args so | |
| 103 | * tests can inject an explicit commenter username + PR state without | |
| 104 | * hitting the DB. | |
| 105 | */ | |
| 106 | export interface ShouldSkipInput { | |
| 107 | commentBody: string; | |
| 108 | commenterUsername?: string | null; | |
| 109 | prState?: string | null; | |
| 110 | parentBody?: string | null; | |
| 111 | } | |
| 112 | ||
| 113 | export interface ShouldSkipResult { | |
| 114 | skip: boolean; | |
| 115 | reason?: string; | |
| 116 | } | |
| 117 | ||
| 118 | /** Pure skip-logic. Exported for unit tests. */ | |
| 119 | export function shouldSkip(input: ShouldSkipInput): ShouldSkipResult { | |
| 120 | const body = (input.commentBody || "").trim(); | |
| 121 | if (!body) return { skip: true, reason: "empty body" }; | |
| 122 | if (body.length < 10) return { skip: true, reason: "body too short" }; | |
| 123 | ||
| 124 | const username = input.commenterUsername || ""; | |
| 125 | if (username.endsWith("[bot]")) { | |
| 126 | return { skip: true, reason: "bot author" }; | |
| 127 | } | |
| 128 | ||
| 129 | // Single emoji (possibly with ZWJ / variation selectors). | |
| 130 | if (EMOJI_RANGE.test(body)) { | |
| 131 | return { skip: true, reason: "single emoji" }; | |
| 132 | } | |
| 133 | ||
| 134 | // Single reaction word (case-insensitive, strip trailing punctuation). | |
| 135 | const stripped = body.toLowerCase().replace(/[!.?\s]+$/g, ""); | |
| 136 | if (REACTION_WORDS.has(stripped)) { | |
| 137 | return { skip: true, reason: "reaction word" }; | |
| 138 | } | |
| 139 | ||
| 140 | // PR already closed/merged — don't spam a dead thread. | |
| 141 | const prState = (input.prState || "").toLowerCase(); | |
| 142 | if (prState === "closed" || prState === "merged") { | |
| 143 | return { skip: true, reason: "pr not open" }; | |
| 144 | } | |
| 145 | ||
| 146 | // Reply to one of our own AI comments — best-effort loop-breaker. | |
| 147 | if (input.parentBody && input.parentBody.includes(AI_REPLY_MARKER)) { | |
| 148 | return { skip: true, reason: "reply to ai comment" }; | |
| 149 | } | |
| 150 | if (body.includes(AI_REPLY_MARKER)) { | |
| 151 | // Someone quoted our marker verbatim — still treat as AI echo. | |
| 152 | return { skip: true, reason: "body contains ai marker" }; | |
| 153 | } | |
| 154 | ||
| 155 | return { skip: false }; | |
| 156 | } | |
| 157 | ||
| 158 | // --------------------------------------------------------------------------- | |
| 159 | // AI classification + drafting | |
| 160 | // --------------------------------------------------------------------------- | |
| 161 | ||
| 162 | export interface CommentClassification { | |
| 163 | actionable: boolean; | |
| 164 | intent: "question" | "change_request" | "nit" | "praise" | "other"; | |
| 165 | touches_files: string[]; | |
| 166 | suggested_action: string; | |
| 167 | confidence: number; | |
| 168 | } | |
| 169 | ||
| 170 | const FALLBACK_CLASSIFICATION: CommentClassification = { | |
| 171 | actionable: false, | |
| 172 | intent: "other", | |
| 173 | touches_files: [], | |
| 174 | suggested_action: "", | |
| 175 | confidence: 0, | |
| 176 | }; | |
| 177 | ||
| 178 | const ALLOWED_INTENTS: ReadonlyArray<CommentClassification["intent"]> = [ | |
| 179 | "question", | |
| 180 | "change_request", | |
| 181 | "nit", | |
| 182 | "praise", | |
| 183 | "other", | |
| 184 | ]; | |
| 185 | ||
| 186 | /** Minimum classifier confidence to draft a Sonnet reply. */ | |
| 187 | export const CONFIDENCE_THRESHOLD = 0.6; | |
| 188 | ||
| 189 | async function classifyComment( | |
| 190 | commentBody: string, | |
| 191 | filePath: string | undefined, | |
| 192 | lineNumber: number | undefined | |
| 193 | ): Promise<{ parsed: CommentClassification; inputTokens: number; outputTokens: number }> { | |
| 194 | try { | |
| 195 | const client = getAnthropic(); | |
| 196 | const message = await client.messages.create({ | |
| 197 | model: MODEL_HAIKU, | |
| 198 | max_tokens: 512, | |
| 199 | messages: [ | |
| 200 | { | |
| 201 | role: "user", | |
| 202 | content: `Classify this PR review comment. Respond ONLY with JSON: | |
| 203 | ||
| 204 | {"actionable": true|false, "intent": "question|change_request|nit|praise|other", "touches_files": ["path1"], "suggested_action": "one sentence", "confidence": 0.0-1.0} | |
| 205 | ||
| 206 | Comment${filePath ? ` on ${filePath}${lineNumber ? `:${lineNumber}` : ""}` : ""}: | |
| 207 | """ | |
| 208 | ${commentBody.slice(0, 4000)} | |
| 209 | """ | |
| 210 | ||
| 211 | Rules: | |
| 212 | - actionable=true only when the comment asks for or implies a concrete code change. | |
| 213 | - intent: question = asks for info; change_request = wants code modified; nit = trivial style; praise = compliment only; other = anything else. | |
| 214 | - confidence reflects certainty in the classification, not confidence a fix is possible. | |
| 215 | - touches_files should only include paths the comment explicitly mentions or strongly implies. Omit if unclear.`, | |
| 216 | }, | |
| 217 | ], | |
| 218 | }); | |
| 219 | const parsed = parseJsonResponse<CommentClassification>(extractText(message)); | |
| 220 | if (!parsed) return { parsed: FALLBACK_CLASSIFICATION, inputTokens: message.usage?.input_tokens ?? 0, outputTokens: message.usage?.output_tokens ?? 0 }; | |
| 221 | const intent = ALLOWED_INTENTS.includes(parsed.intent as never) | |
| 222 | ? parsed.intent | |
| 223 | : "other"; | |
| 224 | const normalised: CommentClassification = { | |
| 225 | actionable: !!parsed.actionable, | |
| 226 | intent, | |
| 227 | touches_files: Array.isArray(parsed.touches_files) | |
| 228 | ? parsed.touches_files.filter((s) => typeof s === "string").slice(0, 16) | |
| 229 | : [], | |
| 230 | suggested_action: | |
| 231 | typeof parsed.suggested_action === "string" | |
| 232 | ? parsed.suggested_action.slice(0, 500) | |
| 233 | : "", | |
| 234 | confidence: | |
| 235 | typeof parsed.confidence === "number" && | |
| 236 | parsed.confidence >= 0 && | |
| 237 | parsed.confidence <= 1 | |
| 238 | ? parsed.confidence | |
| 239 | : 0, | |
| 240 | }; | |
| 241 | return { | |
| 242 | parsed: normalised, | |
| 243 | inputTokens: message.usage?.input_tokens ?? 0, | |
| 244 | outputTokens: message.usage?.output_tokens ?? 0, | |
| 245 | }; | |
| 246 | } catch (err) { | |
| 247 | console.error("[review-response-agent] classify failed:", err); | |
| 248 | return { parsed: FALLBACK_CLASSIFICATION, inputTokens: 0, outputTokens: 0 }; | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | async function draftReply( | |
| 253 | repoFullName: string, | |
| 254 | prNumber: number, | |
| 255 | commentBody: string, | |
| 256 | classification: CommentClassification, | |
| 257 | diffContext: string, | |
| 258 | filePath: string | undefined, | |
| 259 | lineNumber: number | undefined | |
| 260 | ): Promise<{ body: string; inputTokens: number; outputTokens: number }> { | |
| 261 | try { | |
| 262 | const client = getAnthropic(); | |
| 263 | const message = await client.messages.create({ | |
| 264 | model: MODEL_SONNET, | |
| 265 | max_tokens: 1024, | |
| 266 | messages: [ | |
| 267 | { | |
| 268 | role: "user", | |
| 269 | content: `You are the GlueCron review-response agent on PR #${prNumber} of ${repoFullName}. | |
| 270 | A human reviewer left this comment${filePath ? ` on ${filePath}${lineNumber ? `:${lineNumber}` : ""}` : ""}: | |
| 271 | ||
| 272 | """ | |
| 273 | ${commentBody.slice(0, 4000)} | |
| 274 | """ | |
| 275 | ||
| 276 | Classifier said: intent=${classification.intent}, suggested_action="${classification.suggested_action}". | |
| 277 | ||
| 278 | Relevant diff excerpt (may be empty): | |
| 279 | \`\`\` | |
| 280 | ${diffContext.slice(0, 6000)} | |
| 281 | \`\`\` | |
| 282 | ||
| 283 | Write a short Markdown reply that: | |
| 284 | 1. Acknowledges the comment. | |
| 285 | 2. Describes the change you WOULD make in prose (no actual patch — you cannot push commits in v1). | |
| 286 | 3. If a diff excerpt in \`\`\`suggestion\`\`\` blocks helps, include at most one, kept minimal. | |
| 287 | 4. Ends with exactly this sentence on its own line: | |
| 288 | ${AI_REPLY_MARKER} | |
| 289 | ||
| 290 | Keep it under 200 words. Be specific; reference file paths where possible. Do not apologise unless the reviewer found a real bug.`, | |
| 291 | }, | |
| 292 | ], | |
| 293 | }); | |
| 294 | let text = extractText(message).trim(); | |
| 295 | if (!text.includes(AI_REPLY_MARKER)) { | |
| 296 | text = `${text}\n\n${AI_REPLY_MARKER}`; | |
| 297 | } | |
| 298 | return { | |
| 299 | body: text, | |
| 300 | inputTokens: message.usage?.input_tokens ?? 0, | |
| 301 | outputTokens: message.usage?.output_tokens ?? 0, | |
| 302 | }; | |
| 303 | } catch (err) { | |
| 304 | console.error("[review-response-agent] draft failed:", err); | |
| 305 | return { body: "", inputTokens: 0, outputTokens: 0 }; | |
| 306 | } | |
| 307 | } | |
| 308 | ||
| 309 | function acknowledgementBody(intent: CommentClassification["intent"]): string { | |
| 310 | const lead = | |
| 311 | intent === "praise" | |
| 312 | ? "Thanks for the feedback!" | |
| 313 | : intent === "nit" | |
| 314 | ? "Noted — stylistic call-out." | |
| 315 | : intent === "question" | |
| 316 | ? "Noted — this looks informational." | |
| 317 | : "Noted — no code change required."; | |
| 318 | return `${lead} No change required from my side.\n\n${AI_REPLY_MARKER}`; | |
| 319 | } | |
| 320 | ||
| 321 | // --------------------------------------------------------------------------- | |
| 322 | // DB lookups | |
| 323 | // --------------------------------------------------------------------------- | |
| 324 | ||
| 325 | async function lookupContext(args: RunReviewResponseAgentArgs): Promise<{ | |
| 326 | commenterUsername: string | null; | |
| 327 | prState: string | null; | |
| 328 | prHeadBranch: string | null; | |
| 329 | repoOwnerUsername: string | null; | |
| 330 | repoName: string | null; | |
| 331 | parentBody: string | null; | |
| 332 | botAuthorId: string | null; | |
| 333 | } | null> { | |
| 334 | try { | |
| 335 | const [pr] = await db | |
| 336 | .select() | |
| 337 | .from(pullRequests) | |
| 338 | .where(eq(pullRequests.id, args.prId)) | |
| 339 | .limit(1); | |
| 340 | if (!pr) return null; | |
| 341 | ||
| 342 | const [repo] = await db | |
| 343 | .select() | |
| 344 | .from(repositories) | |
| 345 | .where(eq(repositories.id, args.repositoryId)) | |
| 346 | .limit(1); | |
| 347 | let ownerUsername: string | null = null; | |
| 348 | let repoName: string | null = null; | |
| 349 | if (repo) { | |
| 350 | repoName = repo.name; | |
| 351 | const [owner] = await db | |
| 352 | .select() | |
| 353 | .from(users) | |
| 354 | .where(eq(users.id, repo.ownerId)) | |
| 355 | .limit(1); | |
| 356 | ownerUsername = owner?.username ?? null; | |
| 357 | } | |
| 358 | ||
| 359 | let commenterUsername: string | null = null; | |
| 360 | if (args.commenterId) { | |
| 361 | const [commenter] = await db | |
| 362 | .select() | |
| 363 | .from(users) | |
| 364 | .where(eq(users.id, args.commenterId)) | |
| 365 | .limit(1); | |
| 366 | commenterUsername = commenter?.username ?? null; | |
| 367 | } | |
| 368 | ||
| 369 | // Pick up the body of the comment we're responding to (for AI-marker check). | |
| 370 | let parentBody: string | null = null; | |
| 371 | try { | |
| 372 | const [parent] = await db | |
| 373 | .select() | |
| 374 | .from(prComments) | |
| 375 | .where(eq(prComments.id, args.commentId)) | |
| 376 | .limit(1); | |
| 377 | parentBody = parent?.body ?? null; | |
| 378 | } catch { | |
| 379 | parentBody = null; | |
| 380 | } | |
| 381 | ||
| 382 | // Find a usable author id for the inserted row. Prefer the repo owner | |
| 383 | // (same pattern ai-incident uses to attribute auto-issues). | |
| 384 | let botAuthorId: string | null = null; | |
| 385 | if (repo) botAuthorId = repo.ownerId; | |
| 386 | ||
| 387 | return { | |
| 388 | commenterUsername, | |
| 389 | prState: pr.state, | |
| 390 | prHeadBranch: pr.headBranch, | |
| 391 | repoOwnerUsername: ownerUsername, | |
| 392 | repoName, | |
| 393 | parentBody, | |
| 394 | botAuthorId, | |
| 395 | }; | |
| 396 | } catch (err) { | |
| 397 | console.error("[review-response-agent] lookupContext:", err); | |
| 398 | return null; | |
| 399 | } | |
| 400 | } | |
| 401 | ||
| 402 | async function fetchDiffContext( | |
| 403 | ownerUsername: string | null, | |
| 404 | repoName: string | null, | |
| 405 | headBranch: string | null | |
| 406 | ): Promise<string> { | |
| 407 | if (!ownerUsername || !repoName || !headBranch) return ""; | |
| 408 | try { | |
| 409 | const defaultBranch = | |
| 410 | (await getDefaultBranch(ownerUsername, repoName).catch(() => null)) || | |
| 411 | "main"; | |
| 412 | const headSha = headBranch; // getDiff will resolve ref names too via git internally for common cases | |
| 413 | // Avoid enormous diffs — we just need enough context for a prose reply. | |
| 414 | const { raw } = await getDiff(ownerUsername, repoName, headSha).catch( | |
| 415 | () => ({ raw: "", files: [] as unknown[] }) | |
| 416 | ); | |
| 417 | if (raw && raw.length > 0) return raw; | |
| 418 | // Fallback: compare head to default if above failed | |
| 419 | const compare = await getDiff(ownerUsername, repoName, defaultBranch).catch( | |
| 420 | () => ({ raw: "" }) | |
| 421 | ); | |
| 422 | return compare.raw || ""; | |
| 423 | } catch { | |
| 424 | return ""; | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | // --------------------------------------------------------------------------- | |
| 429 | // Entry point | |
| 430 | // --------------------------------------------------------------------------- | |
| 431 | ||
| 432 | /** | |
| 433 | * Fire-and-forget entry point invoked by the pulls.tsx review-comment POST | |
| 434 | * handler. Never throws. Returns metadata about whether a run was started. | |
| 435 | */ | |
| 436 | export async function runReviewResponseAgent( | |
| 437 | args: RunReviewResponseAgentArgs | |
| 438 | ): Promise<RunReviewResponseAgentResult> { | |
| 439 | try { | |
| 440 | // Fast skip without DB / AI: things we can decide from the body alone. | |
| 441 | const preSkip = shouldSkip({ | |
| 442 | commentBody: args.commentBody, | |
| 443 | commenterUsername: null, | |
| 444 | prState: null, | |
| 445 | parentBody: null, | |
| 446 | }); | |
| 447 | if (preSkip.skip) { | |
| 448 | return { skipped: true, skipReason: preSkip.reason }; | |
| 449 | } | |
| 450 | ||
| 451 | const ctx = await lookupContext(args); | |
| 452 | if (!ctx) { | |
| 453 | return { skipped: true, skipReason: "context lookup failed" }; | |
| 454 | } | |
| 455 | ||
| 456 | const fullSkip = shouldSkip({ | |
| 457 | commentBody: args.commentBody, | |
| 458 | commenterUsername: ctx.commenterUsername, | |
| 459 | prState: ctx.prState, | |
| 460 | parentBody: ctx.parentBody, | |
| 461 | }); | |
| 462 | if (fullSkip.skip) { | |
| 463 | return { skipped: true, skipReason: fullSkip.reason }; | |
| 464 | } | |
| 465 | ||
| 466 | const run = await startAgentRun({ | |
| 467 | repositoryId: args.repositoryId, | |
| 468 | kind: "review_response", | |
| 469 | trigger: "pr.review_comment", | |
| 470 | triggerRef: `${args.prNumber}:${args.commentId}`, | |
| 471 | }); | |
| 472 | if (!run) { | |
| 473 | return { skipped: true, skipReason: "could not start run" }; | |
| 474 | } | |
| 475 | ||
| 476 | await executeAgentRun(run.id, async (execCtx): Promise<AgentExecutorResult> => { | |
| 477 | await execCtx.appendLog( | |
| 478 | `classifying comment ${args.commentId} on PR #${args.prNumber}` | |
| 479 | ); | |
| 480 | ||
| 481 | if (!isAiAvailable()) { | |
| 482 | await execCtx.appendLog("AI backend unavailable; skipping reply"); | |
| 483 | return { | |
| 484 | ok: true, | |
| 485 | summary: "AI backend unavailable; skipped reply", | |
| 486 | }; | |
| 487 | } | |
| 488 | ||
| 489 | const { parsed: classification, inputTokens: cIn, outputTokens: cOut } = | |
| 490 | await classifyComment( | |
| 491 | args.commentBody, | |
| 492 | args.filePath, | |
| 493 | args.lineNumber | |
| 494 | ); | |
| 495 | await execCtx.recordCost(cIn, cOut, 0); | |
| 496 | await execCtx.appendLog( | |
| 497 | `classified: actionable=${classification.actionable} intent=${classification.intent} confidence=${classification.confidence}` | |
| 498 | ); | |
| 499 | ||
| 500 | let replyBody: string; | |
| 501 | if ( | |
| 502 | classification.actionable && | |
| 503 | classification.confidence >= CONFIDENCE_THRESHOLD | |
| 504 | ) { | |
| 505 | const repoFullName = | |
| 506 | ctx.repoOwnerUsername && ctx.repoName | |
| 507 | ? `${ctx.repoOwnerUsername}/${ctx.repoName}` | |
| 508 | : "repository"; | |
| 509 | const diffContext = await fetchDiffContext( | |
| 510 | ctx.repoOwnerUsername, | |
| 511 | ctx.repoName, | |
| 512 | ctx.prHeadBranch | |
| 513 | ); | |
| 514 | const draft = await draftReply( | |
| 515 | repoFullName, | |
| 516 | args.prNumber, | |
| 517 | args.commentBody, | |
| 518 | classification, | |
| 519 | diffContext, | |
| 520 | args.filePath, | |
| 521 | args.lineNumber | |
| 522 | ); | |
| 523 | await execCtx.recordCost(draft.inputTokens, draft.outputTokens, 0); | |
| 524 | if (!draft.body) { | |
| 525 | replyBody = acknowledgementBody(classification.intent); | |
| 526 | await execCtx.appendLog("draft failed; falling back to ack"); | |
| 527 | } else { | |
| 528 | replyBody = draft.body; | |
| 529 | } | |
| 530 | } else { | |
| 531 | replyBody = acknowledgementBody(classification.intent); | |
| 532 | } | |
| 533 | ||
| 534 | if (!ctx.botAuthorId) { | |
| 535 | await execCtx.appendLog("no bot author resolvable; refusing to post"); | |
| 536 | return { | |
| 537 | ok: false, | |
| 538 | summary: "no author id available for reply", | |
| 539 | }; | |
| 540 | } | |
| 541 | ||
| 542 | try { | |
| 543 | await db.insert(prComments).values({ | |
| 544 | pullRequestId: args.prId, | |
| 545 | authorId: ctx.botAuthorId, | |
| 546 | body: replyBody, | |
| 547 | isAiReview: true, | |
| 548 | filePath: args.filePath ?? null, | |
| 549 | lineNumber: args.lineNumber ?? null, | |
| 550 | }); | |
| 551 | } catch (err) { | |
| 552 | await execCtx.appendLog( | |
| 553 | `insert failed: ${(err as Error).message || "unknown"}` | |
| 554 | ); | |
| 555 | return { | |
| 556 | ok: false, | |
| 557 | summary: "reply insert failed", | |
| 558 | }; | |
| 559 | } | |
| 560 | ||
| 561 | return { | |
| 562 | ok: true, | |
| 563 | summary: `replied (${classification.intent})`, | |
| 564 | }; | |
| 565 | }); | |
| 566 | ||
| 567 | return { skipped: false, runId: run.id }; | |
| 568 | } catch (err) { | |
| 569 | console.error("[review-response-agent] unexpected:", err); | |
| 570 | return { skipped: true, skipReason: "unexpected error" }; | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | // Re-exported for tests. | |
| 575 | export const __internal = { | |
| 576 | FALLBACK_CLASSIFICATION, | |
| 577 | acknowledgementBody, | |
| 578 | }; |