Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

review-response-agent.tsBlame578 lines · 1 contributor
ddb25a6Claude1/**
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
20import { eq } from "drizzle-orm";
21import { db } from "../../db";
22import {
23 prComments,
24 pullRequests,
25 repositories,
26 users,
27} from "../../db/schema";
28import {
29 MODEL_HAIKU,
30 MODEL_SONNET,
31 extractText,
32 getAnthropic,
33 isAiAvailable,
34 parseJsonResponse,
35} from "../ai-client";
36import {
37 executeAgentRun,
38 startAgentRun,
39 type AgentExecutorResult,
40} from "../agent-runtime";
41import { getDefaultBranch, getDiff } from "../../git/repository";
42
43// ---------------------------------------------------------------------------
44// Public types
45// ---------------------------------------------------------------------------
46
47export 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
58export 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 */
73export const AI_REPLY_MARKER =
74 "— drafted by review-response agent; human must apply or reject";
75
76/** Single-emoji / reaction detector. */
77const EMOJI_RANGE =
78 /^[\s\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u200d\uFE0F]+$/u;
79const 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 */
106export interface ShouldSkipInput {
107 commentBody: string;
108 commenterUsername?: string | null;
109 prState?: string | null;
110 parentBody?: string | null;
111}
112
113export interface ShouldSkipResult {
114 skip: boolean;
115 reason?: string;
116}
117
118/** Pure skip-logic. Exported for unit tests. */
119export 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
162export 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
170const FALLBACK_CLASSIFICATION: CommentClassification = {
171 actionable: false,
172 intent: "other",
173 touches_files: [],
174 suggested_action: "",
175 confidence: 0,
176};
177
178const 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. */
187export const CONFIDENCE_THRESHOLD = 0.6;
188
189async 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
206Comment${filePath ? ` on ${filePath}${lineNumber ? `:${lineNumber}` : ""}` : ""}:
207"""
208${commentBody.slice(0, 4000)}
209"""
210
211Rules:
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
252async 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}.
270A human reviewer left this comment${filePath ? ` on ${filePath}${lineNumber ? `:${lineNumber}` : ""}` : ""}:
271
272"""
273${commentBody.slice(0, 4000)}
274"""
275
276Classifier said: intent=${classification.intent}, suggested_action="${classification.suggested_action}".
277
278Relevant diff excerpt (may be empty):
279\`\`\`
280${diffContext.slice(0, 6000)}
281\`\`\`
282
283Write a short Markdown reply that:
2841. Acknowledges the comment.
2852. Describes the change you WOULD make in prose (no actual patch — you cannot push commits in v1).
2863. If a diff excerpt in \`\`\`suggestion\`\`\` blocks helps, include at most one, kept minimal.
2874. Ends with exactly this sentence on its own line:
288${AI_REPLY_MARKER}
289
290Keep 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
309function 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
325async 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
402async 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 */
436export 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.
575export const __internal = {
576 FALLBACK_CLASSIFICATION,
577 acknowledgementBody,
578};