CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix(gates): an AI review that never ran no longer shows "approved · Passed" #5448

Merged⚡ AI-generatedXSccantynz wants to mergefix/honest-ai-review-gatemainopened 24d ago
4 changed files+50−16
Modifiedsrc/lib/ai-review.ts+30−3View fileUnifiedSplit
1010import { pullRequests, prComments } from "../db/schema";
1111import { getRepoPath, refRange } from "../git/repository";
1212import { config } from "./config";
13import { getAnthropic, modelForTask } from "./ai-client";
13import { getAnthropic, humanizeAiError, modelForTask } from "./ai-client";
1414import { recordAiCost, extractUsage } from "./ai-cost-tracker";
1515import {
1616 isTrioReviewEnabled,
394394 pullRequestId: prId,
395395 authorId: commentAuthorId,
396396 isAiReview: true,
397 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\nThe AI review attempt failed: ${reason}. The PR is otherwise unchanged.`,
397 body: `${AI_REVIEW_MARKER}\n## AI review unavailable\n\n${humanizeAiError(reason)} The PR is otherwise unchanged.`,
398398 })
399399 .catch((err) => {
400400 // Was a silent .catch(() => {}) — DB blips here meant the user
500500 * "should this gate even run".
501501 */
502502export async function isAiReviewApproved(prId: string): Promise<boolean> {
503 return (await aiReviewGateState(prId)) !== false;
504}
505
506/**
507 * Richer gate state: `true` = review ran and approved, `false` = review ran
508 * and found blocking issues, `"unavailable"` = the latest attempt never ran
509 * (provider error / exhausted balance / explicit skip). Fail-open POLICY is
510 * unchanged — "unavailable" does not block a merge — but the gate UI showed
511 * "AI review approved · Passed" directly under a comment saying the review
512 * FAILED, and a green checkmark earned by an outage is a lie. Callers that
513 * feed the gate panel should use this; the boolean wrapper above keeps
514 * existing callers working.
515 */
516export async function aiReviewGateState(
517 prId: string
518): Promise<boolean | "unavailable"> {
503519 const aiComments = await db
504520 .select({ body: prComments.body, createdAt: prComments.createdAt })
505521 .from(prComments)
506522 .where(
507523 and(eq(prComments.pullRequestId, prId), eq(prComments.isAiReview, true))
508524 );
509 return computeAiReviewApproval(aiComments);
525 const summaryComments = aiComments
526 .filter((c) => c.body.includes(AI_REVIEW_MARKER))
527 .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
528 const latest = summaryComments[0];
529 if (!latest) return true;
530 if (
531 latest.body.includes("AI review skipped") ||
532 latest.body.includes("AI review unavailable")
533 ) {
534 return "unavailable";
535 }
536 return latest.body.includes("no blocking issues found");
510537}
511538
512539/**
Modifiedsrc/lib/gate.ts+11−6View fileUnifiedSplit
399399 baseBranch: string,
400400 headBranch: string,
401401 headSha: string,
402 aiReviewApproved: boolean,
402 // `"unavailable"` = the review never ran (provider outage / balance).
403 // Fail-open like `true` for pass/fail, but the check row reports itself
404 // as SKIPPED with an honest reason instead of "AI review approved".
405 aiReviewApproved: boolean | "unavailable",
403406 opts: {
404407 pullRequestId?: string;
405408 enableAutoRepair?: boolean;
440443 checks.push(mergeResult);
441444 checks.push({
442445 name: "AI Review",
443 passed: !runAiReview || aiReviewApproved,
444 skipped: !runAiReview,
446 passed: !runAiReview || aiReviewApproved !== false,
447 skipped: !runAiReview || aiReviewApproved === "unavailable",
445448 details: !runAiReview
446449 ? "Disabled in settings"
447 : aiReviewApproved
448 ? "AI review approved"
449 : "AI review found blocking issues — resolve before merging",
450 : aiReviewApproved === "unavailable"
451 ? "AI review could not run (AI service unavailable) — not blocking the merge"
452 : aiReviewApproved
453 ? "AI review approved"
454 : "AI review found blocking issues — resolve before merging",
450455 });
451456
452457 // ---- Auto-repair on failures ----
Modifiedsrc/lib/pr-merge-gated.ts+4−3View fileUnifiedSplit
4646 passingCheckNames,
4747} from "./branch-protection";
4848import { mergeWithAutoResolve } from "./merge-resolver";
49import { isAiReviewApproved, isAiReviewEnabled } from "./ai-review";
49import { aiReviewGateState, isAiReviewEnabled } from "./ai-review";
5050import { requiredOwnersApproved } from "./codeowners";
5151import {
5252 computePrRiskForPullRequest,
162162 return { merged: false, reason: "Head branch not found" };
163163 }
164164
165 const aiApproved = await isAiReviewApproved(pr.id);
165 const aiApproved = await aiReviewGateState(pr.id);
166166
167167 const gateResult = await runAllGateChecks(
168168 owner,
193193 const decision = evaluateProtection(
194194 protectionRule,
195195 {
196 aiApproved,
196 // Protection semantics unchanged: an unavailable review fails open.
197 aiApproved: aiApproved !== false,
197198 humanApprovalCount: humanApprovals,
198199 gateResultGreen: hardFailures.length === 0,
199200 hasFailedGates: hardFailures.length > 0,
Modifiedsrc/routes/pulls.tsx+5−4View fileUnifiedSplit
5656 notifyOwnerOfPendingComment,
5757 countPendingForRepo,
5858} from "../lib/comment-moderation";
59import { isAiReviewEnabled, triggerAiReview, isAiReviewApproved } from "../lib/ai-review";
59import { isAiReviewEnabled, triggerAiReview, aiReviewGateState } from "../lib/ai-review";
6060import {
6161 TRIO_COMMENT_MARKER,
6262 TRIO_SUMMARY_MARKER,
40824082 try {
40834083 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
40844084 if (headSha) {
4085 const aiApproved = await isAiReviewApproved(pr.id);
4085 const aiApproved = await aiReviewGateState(pr.id);
40864086 const [gateResult, fetchedCiStatuses] = await Promise.all([
40874087 runAllGateChecks(
40884088 ownerName, repoName, pr.baseBranch, pr.headBranch, headSha, aiApproved
62696269 // "**AI review:** flagged N item(s)..." → not approved
62706270 // "severity: blocking" → explicit blocking (future)
62716271 // If no AI comments exist yet, treat as approved (gate hasn't run).
6272 const aiApproved = await isAiReviewApproved(pr.id);
6272 const aiApproved = await aiReviewGateState(pr.id);
62736273
62746274 // Run all green gate checks (GateTest + mergeability + AI review)
62756275 const gateResult = await runAllGateChecks(
63126312 const decision = evaluateProtection(
63136313 protectionRule,
63146314 {
6315 aiApproved,
6315 // Protection semantics unchanged: an unavailable review fails open.
6316 aiApproved: aiApproved !== false,
63166317 humanApprovalCount: humanApprovals,
63176318 gateResultGreen: hardFailures.length === 0,
63186319 hasFailedGates: hardFailures.length > 0,
63196320
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts