CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-review-trio.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.
| 422a2d4 | 1 | /** |
| 2 | * Three-Claude parallel PR review — security / correctness / style. | |
| 3 | * | |
| 4 | * Where `ai-review.ts` runs a single Claude pass with a generalist | |
| 5 | * prompt, this module fans out three concurrent calls — each with a | |
| 6 | * narrow persona and a stricter remit. The three verdicts are inserted | |
| 7 | * as separate PR comments (one per reviewer) plus a top-level summary | |
| 8 | * comment that highlights disagreements. | |
| 9 | * | |
| 10 | * When the personas disagree on the same file/line (one says fail, one | |
| 11 | * says pass), that's a SIGNAL for a human reviewer — surfaced both in | |
| 12 | * the summary comment and as a yellow callout strip in `pulls.tsx`. | |
| 13 | * | |
| 14 | * Hard rules (mirrors `ai-review.ts`): | |
| 15 | * - Never throws at the boundary. Anthropic, JSON parse, and DB | |
| 16 | * failures all fail-closed: the reviewer in question lands a | |
| 17 | * verdict of `fail` with an empty findings list so a human still | |
| 18 | * sees the attempt. | |
| 19 | * - All DB writes are best-effort with breadcrumb logging. | |
| 20 | * - Uses the shared Anthropic client (`getAnthropic` from `ai-client`) | |
| 21 | * and the shared `audit()` helper from `notify`. | |
| 22 | * | |
| 23 | * Wiring: `ai-review.ts`'s `triggerAiReview()` consults | |
| 24 | * `isTrioReviewEnabled()` (env `AI_TRIO_REVIEW_ENABLED=1`). When on, | |
| 25 | * it delegates the whole AI review to `runTrioReview()` instead of the | |
| 26 | * single-Claude path. | |
| 27 | */ | |
| 28 | ||
| 29 | import { eq, and, like } from "drizzle-orm"; | |
| 30 | import { db } from "../db"; | |
| 31 | import { pullRequests, prComments } from "../db/schema"; | |
| 32 | import { getAnthropic, MODEL_SONNET, parseJsonResponse } from "./ai-client"; | |
| 33 | import { audit } from "./notify"; | |
| 34 | import { recordAiCost, extractUsage } from "./ai-cost-tracker"; | |
| 0b49751 | 35 | import { assertAiQuota, AiQuotaExceededError } from "./billing"; |
| a7460bf | 36 | import { getBotUserIdOrFallback } from "./bot-user"; |
| 422a2d4 | 37 | |
| 38 | // --------------------------------------------------------------------------- | |
| 39 | // Public types | |
| 40 | // --------------------------------------------------------------------------- | |
| 41 | ||
| 42 | export type TrioPersona = "security" | "correctness" | "style"; | |
| 43 | ||
| 44 | export type Verdict = "pass" | "fail"; | |
| 45 | ||
| 46 | export interface TrioFinding { | |
| 47 | severity: "low" | "medium" | "high" | "critical" | string; | |
| 48 | file: string | null; | |
| 49 | line: number | null; | |
| 50 | issue: string; | |
| 51 | fix: string; | |
| 52 | } | |
| 53 | ||
| 54 | export interface TrioVerdict { | |
| 55 | persona: TrioPersona; | |
| 56 | verdict: Verdict; | |
| 57 | findings: TrioFinding[]; | |
| 58 | /** Raw text from Claude — kept for the comment body + debugging. */ | |
| 59 | rawText: string; | |
| 60 | /** Anthropic latency in ms, observational only. */ | |
| 61 | latencyMs: number; | |
| 62 | /** | |
| 63 | * True when the call/parse failed and we synthesised a fail-closed | |
| 64 | * verdict. The summary comment marks these so humans aren't misled. | |
| 65 | */ | |
| 66 | failed: boolean; | |
| 67 | } | |
| 68 | ||
| 69 | export interface TrioDisagreement { | |
| 70 | file: string; | |
| 71 | line: number | null; | |
| 72 | /** Personas that returned `fail` for this file/line. */ | |
| 73 | failingPersonas: TrioPersona[]; | |
| 74 | /** Personas that returned `pass` (i.e. had no finding here). */ | |
| 75 | passingPersonas: TrioPersona[]; | |
| 76 | } | |
| 77 | ||
| 78 | export interface TrioReviewResult { | |
| 79 | securityVerdict: TrioVerdict; | |
| 80 | correctnessVerdict: TrioVerdict; | |
| 81 | styleVerdict: TrioVerdict; | |
| 82 | disagreements: TrioDisagreement[]; | |
| 83 | } | |
| 84 | ||
| 85 | export interface RunTrioReviewOpts { | |
| 86 | pullRequestId: string; | |
| 87 | /** Resolved SHA of the PR head — recorded in the audit metadata. */ | |
| 88 | headSha: string; | |
| 89 | /** Unified diff text. Will be truncated to `DIFF_BYTE_CAP`. */ | |
| 90 | diff: string; | |
| 91 | /** Optional repository id for audit attribution. */ | |
| 92 | repositoryId?: string | null; | |
| 93 | /** Optional override of the model id (tests may want haiku). */ | |
| 94 | model?: string; | |
| 95 | } | |
| 96 | ||
| 97 | // --------------------------------------------------------------------------- | |
| 98 | // Marker constants | |
| 99 | // --------------------------------------------------------------------------- | |
| 100 | ||
| 101 | /** | |
| 102 | * Per-reviewer marker embedded in each persona's PR comment body. | |
| 103 | * Used by `pulls.tsx` to render the three cards as a single grid. | |
| 104 | */ | |
| 105 | export const TRIO_COMMENT_MARKER: Record<TrioPersona, string> = { | |
| 106 | security: "<!-- ai-trio:security -->", | |
| 107 | correctness: "<!-- ai-trio:correctness -->", | |
| 108 | style: "<!-- ai-trio:style -->", | |
| 109 | }; | |
| 110 | ||
| 111 | /** Marker embedded in the trio summary comment. */ | |
| 112 | export const TRIO_SUMMARY_MARKER = "<!-- ai-trio:summary -->"; | |
| 113 | ||
| 114 | /** Cap on diff size we feed each reviewer — matches `ai-review.ts`. */ | |
| 115 | const DIFF_BYTE_CAP = 100_000; | |
| 116 | ||
| 117 | /** Per-call max tokens. Findings are short JSON — 3k is plenty. */ | |
| 118 | const MAX_TOKENS = 3072; | |
| 119 | ||
| 120 | // --------------------------------------------------------------------------- | |
| 121 | // Persona prompts — kept terse so each Claude stays in lane. | |
| 122 | // --------------------------------------------------------------------------- | |
| 123 | ||
| 124 | const SYSTEM_PROMPT_BASE = `You are a focused code reviewer on a pull request. Respond with ONLY valid JSON matching this exact shape: | |
| 125 | ||
| 126 | { | |
| 127 | "verdict": "pass" | "fail", | |
| 128 | "findings": [ | |
| 129 | { | |
| 130 | "severity": "low" | "medium" | "high" | "critical", | |
| 131 | "file": "path/to/file.ts", | |
| 132 | "line": 42, | |
| 133 | "issue": "short description of the problem", | |
| 134 | "fix": "concrete suggested fix" | |
| 135 | } | |
| 136 | ] | |
| 137 | } | |
| 138 | ||
| 139 | Rules: | |
| 140 | - Return "verdict": "fail" if you found ANY finding worth flagging at your remit. Otherwise "pass" with an empty findings array. | |
| 141 | - "line" is the line number in the NEW file (right side of the diff), or null when you can't pin it. | |
| 142 | - Stay strictly inside your remit — do not flag issues that belong to another reviewer. | |
| 143 | - No prose outside the JSON. No code fences.`; | |
| 144 | ||
| 145 | const PERSONA_PROMPT: Record<TrioPersona, string> = { | |
| 146 | security: `You are the SECURITY reviewer. Be paranoid. Find security issues: | |
| 147 | - SQL/NoSQL injection, command injection, path traversal | |
| 148 | - XSS (reflected, stored, DOM-based) and HTML-escaping gaps | |
| 149 | - Broken auth: missing session/token checks, IDOR, privilege escalation | |
| 150 | - Secret leaks (API keys, tokens, passwords committed to source) | |
| 151 | - Unsafe deserialization (eval, Function, untrusted JSON.parse into prototypes) | |
| 152 | - Crypto misuse (weak hashes, missing HMAC verification, IV reuse) | |
| 153 | - CSRF / SSRF / open redirects | |
| 154 | ||
| 155 | Do NOT flag style, naming, or non-security bugs. | |
| 156 | ||
| 157 | ${SYSTEM_PROMPT_BASE}`, | |
| 158 | ||
| 159 | correctness: `You are the CORRECTNESS reviewer. Find logic bugs: | |
| 160 | - Null/undefined dereference risks where input may not be guaranteed | |
| 161 | - Race conditions, missing await, unhandled promise rejection | |
| 162 | - Off-by-one errors in loops, slices, range checks | |
| 163 | - Missing error handling at system boundaries (fs, network, DB) | |
| 164 | - Wrong operator (== vs ===, & vs &&), inverted conditions | |
| 165 | - Resource leaks (unclosed handles, missing cleanup on error) | |
| 166 | - Type coercion bugs, NaN propagation, integer overflow | |
| 167 | ||
| 168 | Do NOT flag style, naming, security, or readability. | |
| 169 | ||
| 170 | ${SYSTEM_PROMPT_BASE}`, | |
| 171 | ||
| 172 | style: `You are the STYLE reviewer. Find readability + maintainability issues: | |
| 173 | - Inconsistent or unclear naming (vars, functions, types) | |
| 174 | - Missing JSDoc / docstring on newly-added public APIs | |
| 175 | - Functions over ~80 lines or cyclomatic complexity hotspots | |
| 176 | - Magic numbers without named constants | |
| 177 | - Duplicated code blocks that should be extracted | |
| 178 | - Deeply nested conditionals that hurt readability | |
| 179 | ||
| 180 | Do NOT flag security, correctness bugs, or trivial formatting (let the linter do that). | |
| 181 | ||
| 182 | ${SYSTEM_PROMPT_BASE}`, | |
| 183 | }; | |
| 184 | ||
| 185 | // --------------------------------------------------------------------------- | |
| 186 | // Test seam — let tests inject canned persona outputs instead of calling | |
| 187 | // the real Anthropic API. Pass `null` to reset. | |
| 188 | // --------------------------------------------------------------------------- | |
| 189 | ||
| 190 | export type PersonaRunner = (args: { | |
| 191 | persona: TrioPersona; | |
| 192 | diff: string; | |
| 193 | model: string; | |
| 194 | }) => Promise<{ text: string; inputTokens: number; outputTokens: number }>; | |
| 195 | ||
| 196 | let _runnerOverride: PersonaRunner | null = null; | |
| 197 | ||
| 198 | export function __setPersonaRunnerForTests(fn: PersonaRunner | null): void { | |
| 199 | _runnerOverride = fn; | |
| 200 | } | |
| 201 | ||
| 202 | // --------------------------------------------------------------------------- | |
| 203 | // Enablement | |
| 204 | // --------------------------------------------------------------------------- | |
| 205 | ||
| 206 | /** | |
| 207 | * Whether the trio review path is on. Off by default; flip with | |
| 208 | * `AI_TRIO_REVIEW_ENABLED=1`. Independent from `ANTHROPIC_API_KEY` — | |
| 209 | * callers still need a key for the actual API calls, but tests can | |
| 210 | * toggle the flag without a real key via the runner override. | |
| 211 | */ | |
| 212 | export function isTrioReviewEnabled(): boolean { | |
| 213 | return process.env.AI_TRIO_REVIEW_ENABLED === "1"; | |
| 214 | } | |
| 215 | ||
| 216 | /** | |
| 217 | * Has trio already run on this PR? Detected by a prior summary marker. | |
| 218 | */ | |
| 219 | export async function alreadyTrioReviewed(prId: string): Promise<boolean> { | |
| 220 | try { | |
| 221 | const [row] = await db | |
| 222 | .select({ id: prComments.id }) | |
| 223 | .from(prComments) | |
| 224 | .where( | |
| 225 | and( | |
| 226 | eq(prComments.pullRequestId, prId), | |
| 227 | eq(prComments.isAiReview, true), | |
| 228 | like(prComments.body, `%${TRIO_SUMMARY_MARKER}%`) | |
| 229 | ) | |
| 230 | ) | |
| 231 | .limit(1); | |
| 232 | return !!row; | |
| 233 | } catch { | |
| 234 | return false; | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | // --------------------------------------------------------------------------- | |
| 239 | // Core runner | |
| 240 | // --------------------------------------------------------------------------- | |
| 241 | ||
| 242 | /** | |
| 243 | * Run all three personas in parallel against the same diff, compute | |
| 244 | * disagreements, persist four prComments (3 verdicts + 1 summary), and | |
| 245 | * audit the outcome. | |
| 246 | * | |
| 247 | * Returns the structured trio result. Never throws. | |
| 248 | */ | |
| 249 | export async function runTrioReview( | |
| 250 | opts: RunTrioReviewOpts | |
| 251 | ): Promise<TrioReviewResult> { | |
| 252 | const model = opts.model || MODEL_SONNET; | |
| 253 | const diff = | |
| 254 | opts.diff.length > DIFF_BYTE_CAP | |
| 255 | ? opts.diff.slice(0, DIFF_BYTE_CAP) | |
| 256 | : opts.diff; | |
| 257 | ||
| 0b49751 | 258 | // 0. Hard quota gate — bail before any API calls if the PR author is over |
| 259 | // budget. Resolve the author from the DB (needed for the comment insert | |
| 260 | // in persistTrioComments anyway). | |
| 261 | let prAuthorId: string | null = null; | |
| 262 | try { | |
| 263 | const [pr] = await db | |
| 264 | .select({ authorId: pullRequests.authorId }) | |
| 265 | .from(pullRequests) | |
| 266 | .where(eq(pullRequests.id, opts.pullRequestId)) | |
| 267 | .limit(1); | |
| 268 | if (pr) prAuthorId = pr.authorId; | |
| 269 | } catch { | |
| 270 | /* tolerate — quota check will fail open */ | |
| 271 | } | |
| 272 | if (prAuthorId) { | |
| 273 | try { | |
| 274 | await assertAiQuota(prAuthorId); | |
| 275 | } catch (err) { | |
| 276 | if (err instanceof AiQuotaExceededError) { | |
| 277 | // Post a single summary comment so the PR author sees the skip reason. | |
| 278 | try { | |
| 279 | await db.insert(prComments).values({ | |
| 280 | pullRequestId: opts.pullRequestId, | |
| 281 | authorId: prAuthorId, | |
| 282 | isAiReview: true, | |
| 283 | body: [ | |
| 284 | TRIO_SUMMARY_MARKER, | |
| 285 | "## AI Trio Review skipped", | |
| 286 | "", | |
| 287 | "Your monthly AI token budget has been reached. Upgrade at [/settings/billing](/settings/billing) to re-enable AI code review.", | |
| 288 | ].join("\n"), | |
| 289 | }); | |
| 290 | } catch { | |
| 291 | /* best-effort */ | |
| 292 | } | |
| 293 | // Return a neutral fail-closed result so the caller doesn't crash. | |
| 294 | const skippedVerdict = (persona: TrioPersona): TrioVerdict => ({ | |
| 295 | persona, | |
| 296 | verdict: "fail", | |
| 297 | findings: [], | |
| 298 | rawText: "", | |
| 299 | latencyMs: 0, | |
| 300 | failed: true, | |
| 301 | }); | |
| 302 | return { | |
| 303 | securityVerdict: skippedVerdict("security"), | |
| 304 | correctnessVerdict: skippedVerdict("correctness"), | |
| 305 | styleVerdict: skippedVerdict("style"), | |
| 306 | disagreements: [], | |
| 307 | }; | |
| 308 | } | |
| 309 | // Unexpected error — log and proceed (fail open). | |
| 310 | console.warn("[ai-review-trio] assertAiQuota failed unexpectedly:", err); | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 422a2d4 | 314 | // 1. Fan out the three persona calls. |
| 315 | const personas: TrioPersona[] = ["security", "correctness", "style"]; | |
| 316 | const [securityVerdict, correctnessVerdict, styleVerdict] = await Promise.all( | |
| 317 | personas.map((p) => runOnePersona({ persona: p, diff, model })) | |
| 318 | ); | |
| 319 | ||
| 320 | // 2. Compute disagreements. | |
| 321 | const disagreements = computeDisagreements({ | |
| 322 | securityVerdict, | |
| 323 | correctnessVerdict, | |
| 324 | styleVerdict, | |
| 325 | }); | |
| 326 | ||
| 327 | const result: TrioReviewResult = { | |
| 328 | securityVerdict, | |
| 329 | correctnessVerdict, | |
| 330 | styleVerdict, | |
| 331 | disagreements, | |
| 332 | }; | |
| 333 | ||
| 334 | // 3. Persist comments (best-effort). | |
| 335 | await persistTrioComments({ | |
| 336 | pullRequestId: opts.pullRequestId, | |
| 337 | result, | |
| 338 | }); | |
| 339 | ||
| 340 | // 4. Audit. | |
| 341 | try { | |
| 342 | await audit({ | |
| 343 | action: "ai.review.trio", | |
| 344 | targetType: "pull_request", | |
| 345 | targetId: opts.pullRequestId, | |
| 346 | repositoryId: opts.repositoryId ?? null, | |
| 347 | metadata: { | |
| 348 | headSha: opts.headSha, | |
| 349 | security: securityVerdict.verdict, | |
| 350 | correctness: correctnessVerdict.verdict, | |
| 351 | style: styleVerdict.verdict, | |
| 352 | disagreements: disagreements.length, | |
| 353 | failed: [ | |
| 354 | securityVerdict.failed ? "security" : null, | |
| 355 | correctnessVerdict.failed ? "correctness" : null, | |
| 356 | styleVerdict.failed ? "style" : null, | |
| 357 | ].filter(Boolean), | |
| 358 | }, | |
| 359 | }); | |
| 360 | } catch { | |
| 361 | /* audit is observational only */ | |
| 362 | } | |
| 363 | ||
| 364 | return result; | |
| 365 | } | |
| 366 | ||
| 367 | // --------------------------------------------------------------------------- | |
| 368 | // One persona — single Anthropic call + fail-closed JSON parse. | |
| 369 | // --------------------------------------------------------------------------- | |
| 370 | ||
| 371 | async function runOnePersona(args: { | |
| 372 | persona: TrioPersona; | |
| 373 | diff: string; | |
| 374 | model: string; | |
| 375 | }): Promise<TrioVerdict> { | |
| 376 | const t0 = Date.now(); | |
| 377 | let text = ""; | |
| 378 | let inputTokens = 0; | |
| 379 | let outputTokens = 0; | |
| 380 | let failed = false; | |
| 381 | ||
| 382 | try { | |
| 383 | if (_runnerOverride) { | |
| 384 | const out = await _runnerOverride({ | |
| 385 | persona: args.persona, | |
| 386 | diff: args.diff, | |
| 387 | model: args.model, | |
| 388 | }); | |
| 389 | text = out.text || ""; | |
| 390 | inputTokens = out.inputTokens || 0; | |
| 391 | outputTokens = out.outputTokens || 0; | |
| 392 | } else { | |
| 393 | const client = getAnthropic(); | |
| 394 | const message = await client.messages.create({ | |
| 395 | model: args.model, | |
| 396 | max_tokens: MAX_TOKENS, | |
| 397 | system: PERSONA_PROMPT[args.persona], | |
| 398 | messages: [ | |
| 399 | { | |
| 400 | role: "user", | |
| 401 | content: `Review this diff at your remit (${args.persona}). Return JSON only.\n\n\`\`\`diff\n${args.diff}\n\`\`\``, | |
| 402 | }, | |
| 403 | ], | |
| 404 | }); | |
| 405 | text = | |
| 406 | message.content[0]?.type === "text" ? message.content[0].text : ""; | |
| 407 | const usage = extractUsage(message); | |
| 408 | inputTokens = usage.input; | |
| 409 | outputTokens = usage.output; | |
| 410 | } | |
| 411 | } catch (err) { | |
| 412 | failed = true; | |
| 413 | text = `__error__:${err instanceof Error ? err.message : String(err)}`; | |
| 414 | } | |
| 415 | ||
| 416 | // Best-effort cost capture (skipped when call failed and we have no usage). | |
| 417 | if (!failed && (inputTokens || outputTokens)) { | |
| 418 | try { | |
| 419 | await recordAiCost({ | |
| 420 | model: args.model, | |
| 421 | inputTokens, | |
| 422 | outputTokens, | |
| 423 | category: "ai_review", | |
| 424 | sourceKind: "pull_request", | |
| 425 | }); | |
| 426 | } catch { | |
| 427 | /* observational */ | |
| 428 | } | |
| 429 | } | |
| 430 | ||
| 431 | const parsed = parseJsonResponse<{ | |
| 432 | verdict?: unknown; | |
| 433 | findings?: unknown; | |
| 434 | }>(text); | |
| 435 | ||
| 436 | let verdict: Verdict = "fail"; // fail-closed default | |
| 437 | let findings: TrioFinding[] = []; | |
| 438 | ||
| 439 | if (parsed && typeof parsed === "object") { | |
| 440 | if (parsed.verdict === "pass") verdict = "pass"; | |
| 441 | else if (parsed.verdict === "fail") verdict = "fail"; | |
| 442 | if (Array.isArray(parsed.findings)) { | |
| 443 | findings = parsed.findings | |
| 444 | .map((f) => normaliseFinding(f)) | |
| 445 | .filter((f): f is TrioFinding => !!f); | |
| 446 | } | |
| 447 | } else if (!failed) { | |
| 448 | // Call succeeded but JSON parse failed → fail-closed. | |
| 449 | failed = true; | |
| 450 | } | |
| 451 | ||
| 452 | return { | |
| 453 | persona: args.persona, | |
| 454 | verdict, | |
| 455 | findings, | |
| 456 | rawText: text, | |
| 457 | latencyMs: Date.now() - t0, | |
| 458 | failed, | |
| 459 | }; | |
| 460 | } | |
| 461 | ||
| 462 | function normaliseFinding(raw: unknown): TrioFinding | null { | |
| 463 | if (!raw || typeof raw !== "object") return null; | |
| 464 | const r = raw as Record<string, unknown>; | |
| 465 | const issue = | |
| 466 | typeof r.issue === "string" | |
| 467 | ? r.issue | |
| 468 | : typeof r.description === "string" | |
| 469 | ? r.description | |
| 470 | : ""; | |
| 471 | if (!issue) return null; | |
| 472 | return { | |
| 473 | severity: | |
| 474 | typeof r.severity === "string" && r.severity.length > 0 | |
| 475 | ? r.severity | |
| 476 | : "medium", | |
| 477 | file: typeof r.file === "string" && r.file.length > 0 ? r.file : null, | |
| 478 | line: | |
| 479 | typeof r.line === "number" && Number.isInteger(r.line) && r.line > 0 | |
| 480 | ? r.line | |
| 481 | : null, | |
| 482 | issue, | |
| 483 | fix: typeof r.fix === "string" ? r.fix : "", | |
| 484 | }; | |
| 485 | } | |
| 486 | ||
| 487 | // --------------------------------------------------------------------------- | |
| 488 | // Disagreement detection — file/line pairs where one persona flags `fail` | |
| 489 | // and another would have said `pass` (i.e. no finding at that location). | |
| 490 | // --------------------------------------------------------------------------- | |
| 491 | ||
| 492 | export function computeDisagreements(args: { | |
| 493 | securityVerdict: TrioVerdict; | |
| 494 | correctnessVerdict: TrioVerdict; | |
| 495 | styleVerdict: TrioVerdict; | |
| 496 | }): TrioDisagreement[] { | |
| 497 | const verdicts: TrioVerdict[] = [ | |
| 498 | args.securityVerdict, | |
| 499 | args.correctnessVerdict, | |
| 500 | args.styleVerdict, | |
| 501 | ]; | |
| 502 | ||
| 503 | // Group findings by file (+ optional line) and record which personas | |
| 504 | // hit each location. | |
| 505 | const byKey = new Map< | |
| 506 | string, | |
| 507 | { | |
| 508 | file: string; | |
| 509 | line: number | null; | |
| 510 | failingPersonas: Set<TrioPersona>; | |
| 511 | } | |
| 512 | >(); | |
| 513 | ||
| 514 | for (const v of verdicts) { | |
| 515 | for (const f of v.findings) { | |
| 516 | const file = f.file; | |
| 517 | if (!file) continue; // can't disagree about an unattributable finding | |
| 518 | const key = `${file}::${f.line ?? ""}`; | |
| 519 | let bucket = byKey.get(key); | |
| 520 | if (!bucket) { | |
| 521 | bucket = { file, line: f.line, failingPersonas: new Set() }; | |
| 522 | byKey.set(key, bucket); | |
| 523 | } | |
| 524 | bucket.failingPersonas.add(v.persona); | |
| 525 | } | |
| 526 | } | |
| 527 | ||
| 528 | const allPersonas: TrioPersona[] = ["security", "correctness", "style"]; | |
| 529 | const disagreements: TrioDisagreement[] = []; | |
| 530 | ||
| 531 | for (const bucket of byKey.values()) { | |
| 532 | // Disagreement = at least one persona flagged AND at least one | |
| 533 | // persona did not. (All three flagging the same location is | |
| 534 | // unanimous agreement, not a disagreement.) | |
| 535 | if ( | |
| 536 | bucket.failingPersonas.size === 0 || | |
| 537 | bucket.failingPersonas.size === allPersonas.length | |
| 538 | ) { | |
| 539 | continue; | |
| 540 | } | |
| 541 | disagreements.push({ | |
| 542 | file: bucket.file, | |
| 543 | line: bucket.line, | |
| 544 | failingPersonas: Array.from(bucket.failingPersonas).sort() as TrioPersona[], | |
| 545 | passingPersonas: allPersonas.filter( | |
| 546 | (p) => !bucket.failingPersonas.has(p) | |
| 547 | ), | |
| 548 | }); | |
| 549 | } | |
| 550 | ||
| 551 | // Stable sort: file then line then severity. | |
| 552 | disagreements.sort((a, b) => { | |
| 553 | if (a.file !== b.file) return a.file < b.file ? -1 : 1; | |
| 554 | return (a.line ?? 0) - (b.line ?? 0); | |
| 555 | }); | |
| 556 | ||
| 557 | return disagreements; | |
| 558 | } | |
| 559 | ||
| 560 | // --------------------------------------------------------------------------- | |
| 561 | // Comment persistence — 3 per-reviewer + 1 summary. | |
| 562 | // --------------------------------------------------------------------------- | |
| 563 | ||
| 564 | async function persistTrioComments(args: { | |
| 565 | pullRequestId: string; | |
| 566 | result: TrioReviewResult; | |
| 567 | }): Promise<void> { | |
| a7460bf | 568 | // Need a user id to satisfy `prComments.authorId NOT NULL`. |
| 569 | // Prefer the bot user; fall back to the PR author for pre-migration envs. | |
| 570 | let prAuthorId: string | null = null; | |
| 422a2d4 | 571 | try { |
| 572 | const [pr] = await db | |
| 573 | .select({ authorId: pullRequests.authorId }) | |
| 574 | .from(pullRequests) | |
| 575 | .where(eq(pullRequests.id, args.pullRequestId)) | |
| 576 | .limit(1); | |
| a7460bf | 577 | if (pr) prAuthorId = pr.authorId; |
| 422a2d4 | 578 | } catch { |
| 579 | /* tolerate */ | |
| 580 | } | |
| a7460bf | 581 | if (!prAuthorId) return; // can't post comments without an author id |
| 582 | ||
| 583 | const commentAuthorId = await getBotUserIdOrFallback(prAuthorId); | |
| 422a2d4 | 584 | |
| 585 | const verdicts: TrioVerdict[] = [ | |
| 586 | args.result.securityVerdict, | |
| 587 | args.result.correctnessVerdict, | |
| 588 | args.result.styleVerdict, | |
| 589 | ]; | |
| 590 | ||
| 591 | for (const v of verdicts) { | |
| 592 | const body = renderPersonaCommentBody(v); | |
| 593 | try { | |
| 594 | await db.insert(prComments).values({ | |
| 595 | pullRequestId: args.pullRequestId, | |
| a7460bf | 596 | authorId: commentAuthorId, |
| 422a2d4 | 597 | isAiReview: true, |
| 598 | body, | |
| 599 | }); | |
| 600 | } catch (err) { | |
| 601 | console.error( | |
| 602 | `[ai-review-trio] persona ${v.persona} comment insert failed for PR ${args.pullRequestId}:`, | |
| 603 | err instanceof Error ? err.message : err | |
| 604 | ); | |
| 605 | } | |
| 606 | } | |
| 607 | ||
| 608 | // Top-level summary. | |
| 609 | try { | |
| 610 | await db.insert(prComments).values({ | |
| 611 | pullRequestId: args.pullRequestId, | |
| a7460bf | 612 | authorId: commentAuthorId, |
| 422a2d4 | 613 | isAiReview: true, |
| 614 | body: renderSummaryCommentBody(args.result), | |
| 615 | }); | |
| 616 | } catch (err) { | |
| 617 | console.error( | |
| 618 | `[ai-review-trio] summary insert failed for PR ${args.pullRequestId}:`, | |
| 619 | err instanceof Error ? err.message : err | |
| 620 | ); | |
| 621 | } | |
| 622 | } | |
| 623 | ||
| 624 | // --------------------------------------------------------------------------- | |
| 625 | // Comment body rendering — markdown that's also re-parseable. | |
| 626 | // --------------------------------------------------------------------------- | |
| 627 | ||
| 628 | function renderPersonaCommentBody(v: TrioVerdict): string { | |
| 629 | const marker = TRIO_COMMENT_MARKER[v.persona]; | |
| 630 | const heading = `${marker}\n## AI ${v.persona[0].toUpperCase() + v.persona.slice(1)} Review — ${v.verdict === "pass" ? "Pass" : "Fail"}`; | |
| 631 | if (v.failed) { | |
| 632 | return `${heading}\n\n_AI review call failed; treating as fail-closed. A human reviewer should look at this PR._`; | |
| 633 | } | |
| 634 | if (v.findings.length === 0) { | |
| 635 | return `${heading}\n\nNo ${v.persona} issues detected.`; | |
| 636 | } | |
| 637 | const lines = v.findings.map((f) => { | |
| 638 | const loc = f.file | |
| 639 | ? `\`${f.file}${f.line ? `:${f.line}` : ""}\`` | |
| 640 | : "_(unattributed)_"; | |
| 641 | return `- **${f.severity}** ${loc} — ${f.issue}${f.fix ? ` _Fix: ${f.fix}_` : ""}`; | |
| 642 | }); | |
| 643 | return `${heading}\n\n${lines.join("\n")}`; | |
| 644 | } | |
| 645 | ||
| 646 | function renderSummaryCommentBody(r: TrioReviewResult): string { | |
| 647 | const verdictLine = (v: TrioVerdict): string => | |
| 648 | `- **${v.persona}**: ${v.verdict === "pass" ? "✓ pass" : "✗ fail"}${v.failed ? " _(call failed)_" : ""} — ${v.findings.length} finding(s)`; | |
| 649 | ||
| 650 | const disagreementLines = | |
| 651 | r.disagreements.length === 0 | |
| 652 | ? "_All three reviewers agree on every flagged location._" | |
| 653 | : r.disagreements | |
| 654 | .map((d) => { | |
| 655 | const loc = `\`${d.file}${d.line ? `:${d.line}` : ""}\``; | |
| 656 | return `- ${loc} — ${d.failingPersonas.join(", ")} say ✗, ${d.passingPersonas.join(", ")} say ✓`; | |
| 657 | }) | |
| 658 | .join("\n"); | |
| 659 | ||
| 660 | return [ | |
| 661 | TRIO_SUMMARY_MARKER, | |
| 662 | "## AI Trio Review", | |
| 663 | "", | |
| 664 | "Three independent reviewers ran in parallel — security, correctness, style.", | |
| 665 | "", | |
| 666 | "### Verdicts", | |
| 667 | verdictLine(r.securityVerdict), | |
| 668 | verdictLine(r.correctnessVerdict), | |
| 669 | verdictLine(r.styleVerdict), | |
| 670 | "", | |
| 671 | "### Disagreements", | |
| 672 | disagreementLines, | |
| 673 | ].join("\n"); | |
| 674 | } | |
| 675 | ||
| 676 | // --------------------------------------------------------------------------- | |
| 677 | // Test-only exports. | |
| 678 | // --------------------------------------------------------------------------- | |
| 679 | ||
| 680 | export const __test = { | |
| 681 | PERSONA_PROMPT, | |
| 682 | normaliseFinding, | |
| 683 | renderPersonaCommentBody, | |
| 684 | renderSummaryCommentBody, | |
| 685 | DIFF_BYTE_CAP, | |
| 686 | }; |