diff --git a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx index 78fa840828..86e8e635bd 100644 --- a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { toast } from 'sonner'; import { ReviewConfigForm } from '@/components/code-reviews/ReviewConfigForm'; +import { CodeReviewActionRequiredAlert } from '@/components/code-reviews/CodeReviewActionRequiredAlert'; import { CodeReviewJobsCard } from '@/components/code-reviews/CodeReviewJobsCard'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -56,6 +57,11 @@ export function ReviewAgentPageClient({ trpc.personalReviewAgent.getGitLabStatus.queryOptions() ); + const { data: selectedConfigData } = useQuery( + trpc.personalReviewAgent.getReviewConfig.queryOptions({ platform: selectedPlatform }) + ); + const selectedActionRequired = selectedConfigData?.actionRequired ?? null; + const isGitHubAppInstalled = githubStatusData?.connected && githubStatusData?.integration?.isValid; const isGitLabConnected = gitlabStatusData?.connected && gitlabStatusData?.integration?.isValid; @@ -151,6 +157,10 @@ export function ReviewAgentPageClient({ )} + {selectedPlatform === 'github' && selectedActionRequired && ( + + )} + {/* GitHub Configuration Tabs */} @@ -211,6 +221,10 @@ export function ReviewAgentPageClient({ )} + {selectedPlatform === 'gitlab' && selectedActionRequired && ( + + )} + {/* GitLab Configuration Tabs */} diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx index 99131626a2..42fd1e1072 100644 --- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/ReviewAgentPageClient.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { toast } from 'sonner'; import { ReviewConfigForm } from '@/components/code-reviews/ReviewConfigForm'; +import { CodeReviewActionRequiredAlert } from '@/components/code-reviews/CodeReviewActionRequiredAlert'; import { CodeReviewJobsCard } from '@/components/code-reviews/CodeReviewJobsCard'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -63,6 +64,14 @@ export function ReviewAgentPageClient({ }) ); + const { data: selectedConfigData } = useQuery( + trpc.organizations.reviewAgent.getReviewConfig.queryOptions({ + organizationId, + platform: selectedPlatform, + }) + ); + const selectedActionRequired = selectedConfigData?.actionRequired ?? null; + const isGitHubAppInstalled = githubStatusData?.connected && githubStatusData?.integration?.isValid; const isGitLabConnected = gitlabStatusData?.connected && gitlabStatusData?.integration?.isValid; @@ -158,6 +167,13 @@ export function ReviewAgentPageClient({ )} + {selectedPlatform === 'github' && selectedActionRequired && ( + + )} + {/* GitHub Configuration Tabs */} @@ -218,6 +234,13 @@ export function ReviewAgentPageClient({ )} + {selectedPlatform === 'gitlab' && selectedActionRequired && ( + + )} + {/* GitLab Configuration Tabs */} diff --git a/apps/web/src/app/admin/components/CodeReviewErrorAnalysis.tsx b/apps/web/src/app/admin/components/CodeReviewErrorAnalysis.tsx index bc7ffe3d99..c1a766534f 100644 --- a/apps/web/src/app/admin/components/CodeReviewErrorAnalysis.tsx +++ b/apps/web/src/app/admin/components/CodeReviewErrorAnalysis.tsx @@ -49,6 +49,7 @@ type Props = { }; const CATEGORY_COLORS: Record = { + 'Action Required': 'bg-yellow-500', 'Rate Limited': 'bg-amber-500', Timeout: 'bg-orange-500', 'Context Window Exceeded': 'bg-purple-500', diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts index 5f55fb9f0d..cb41737a08 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts @@ -74,6 +74,8 @@ const mockCaptureMessage = jest.fn(); const mockAppendReviewSummaryFooter = jest.fn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRetryReviewFresh = jest.fn(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockDisableCodeReviewForActionRequiredFailure = jest.fn(); // --- Module mocks --- @@ -142,6 +144,15 @@ jest.mock('@/lib/code-reviews/summary/usage-footer', () => ({ appendReviewSummaryFooter: (...args: unknown[]) => mockAppendReviewSummaryFooter(...args), })); +jest.mock('@/lib/code-reviews/action-required', () => { + const actual = jest.requireActual>('@/lib/code-reviews/action-required'); + return { + ...actual, + disableCodeReviewForActionRequiredFailure: (...args: unknown[]) => + mockDisableCodeReviewForActionRequiredFailure(...args), + }; +}); + jest.mock('@/lib/constants', () => ({ APP_URL: 'https://test.kilo.ai', })); @@ -334,6 +345,7 @@ beforeEach(async () => { mockUpdateCodeReviewUsage.mockResolvedValue(undefined); mockUpdateCodeReviewStatusIfNonTerminal.mockResolvedValue(true); mockAppendReviewSummaryFooter.mockReturnValue('body with footer'); + mockDisableCodeReviewForActionRequiredFailure.mockResolvedValue(undefined); ({ POST } = await import('./route')); }); @@ -479,6 +491,96 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { ); }); + it('infers BYOK invalid-key callbacks as action-required failures', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + const response = await POST( + makeRequest({ + status: 'failed', + errorMessage: + '[BYOK] Your API key is invalid or has been revoked. Please check your API key configuration.', + }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + expect(mockUpdateCodeReviewStatus).toHaveBeenCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ + terminalReason: 'byok_invalid_key', + }) + ); + expect(mockCreateInfraRetryAttemptIfMissing).not.toHaveBeenCalled(); + expect(mockRetryReviewFresh).not.toHaveBeenCalled(); + expect(mockDisableCodeReviewForActionRequiredFailure).toHaveBeenCalledWith( + expect.objectContaining({ + owner: { type: 'user', id: 'user-1', userId: 'user-1' }, + platform: 'github', + reviewId: REVIEW_ID, + reason: 'byok_invalid_key', + }) + ); + expect(mockUpdateCheckRun).toHaveBeenCalledWith( + 'inst-1', + 'owner', + 'repo', + 12345, + expect.objectContaining({ + conclusion: 'action_required', + output: expect.objectContaining({ title: 'BYOK API key needs attention' }), + }), + 'standard' + ); + }); + + it('infers GitHub installation and IP allow-list callback failures', async () => { + mockGetCodeReviewById.mockResolvedValue(makeReview()); + + await POST( + makeRequest({ + status: 'failed', + errorMessage: + 'Dispatch failed: GitHub token or active app installation required for this repository (no_installation_found)', + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewStatus).toHaveBeenLastCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ terminalReason: 'github_installation_required' }) + ); + + jest.clearAllMocks(); + mockGetCodeReviewById.mockResolvedValue(makeReview()); + mockUpdateCodeReviewAttemptForCallback.mockImplementation(async params => + makeAttempt({ + status: params.status, + error_message: params.errorMessage ?? null, + terminal_reason: params.terminalReason ?? null, + }) + ); + mockGetLatestCodeReviewAttempt.mockResolvedValue(makeAttempt()); + mockGetIntegrationById.mockResolvedValue(makeIntegration()); + mockDisableCodeReviewForActionRequiredFailure.mockResolvedValue(undefined); + + await POST( + makeRequest({ + status: 'failed', + errorMessage: + 'Although you appear to have the correct authorization credentials, the `acme` organization has an IP allow list enabled, and 192.0.2.1 is not permitted.', + }), + makeParams(REVIEW_ID) + ); + + expect(mockUpdateCodeReviewStatus).toHaveBeenLastCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ terminalReason: 'github_ip_allow_list' }) + ); + }); + it('keeps interrupted non-billing callbacks as cancelled', async () => { mockGetCodeReviewById.mockResolvedValue(makeReview()); diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts index db510ebace..ede6a3a0e8 100644 --- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts +++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts @@ -67,6 +67,14 @@ import { CODE_REVIEW_TERMINAL_REASONS, type CodeReviewTerminalReason, } from '@kilocode/db/schema-types'; +import { + classifyCodeReviewActionRequiredFailure, + disableCodeReviewForActionRequiredFailure, + getCodeReviewActionRequiredCopy, + isCodeReviewActionRequiredReason, + type CodeReviewActionRequiredReason, +} from '@/lib/code-reviews/action-required'; +import type { Owner } from '@/lib/code-reviews/core'; /** * Payload from the orchestrator DO (legacy format). @@ -97,6 +105,11 @@ type CloudAgentNextCallbackPayload = { type StatusUpdatePayload = OrchestratorPayload | CloudAgentNextCallbackPayload; +type TerminalOwnerResolution = { + owner: Owner; + canDispatch: boolean; +}; + /** * Normalize a payload from either the orchestrator or cloud-agent-next callback * into the common format expected by the update logic. @@ -130,6 +143,20 @@ function normalizePayload(raw: StatusUpdatePayload): { let terminalReason: CodeReviewTerminalReason | undefined = raw.terminalReason && validReasons.has(raw.terminalReason) ? raw.terminalReason : undefined; + if (terminalReason && isCodeReviewActionRequiredReason(terminalReason)) { + if (status === 'cancelled') { + status = 'failed'; + } + } + + const actionRequiredReason = classifyCodeReviewActionRequiredFailure(raw.errorMessage); + if (!terminalReason && actionRequiredReason) { + if (status === 'cancelled') { + status = 'failed'; + } + terminalReason = actionRequiredReason; + } + // Infer billing when no explicit terminalReason was provided. // v1: billing errors arrive as 'interrupted' (→ cancelled) with billing error text // v2: billing errors arrive as 'failed' with billing error text (after wrapper fix) @@ -191,6 +218,17 @@ function isModelNotFoundCodeReviewTerminalReason( return /\bmodel\s+not\s+found\b/i.test(errorMessage ?? ''); } +function getActionRequiredTerminalReason( + terminalReason?: CodeReviewTerminalReason, + errorMessage?: string | null +): CodeReviewActionRequiredReason | null { + if (isCodeReviewActionRequiredReason(terminalReason)) { + return terminalReason; + } + + return classifyCodeReviewActionRequiredFailure(errorMessage); +} + function isRetryableInfraFailure( status: 'running' | 'completed' | 'failed' | 'cancelled', terminalReason?: CodeReviewTerminalReason, @@ -198,6 +236,8 @@ function isRetryableInfraFailure( ): boolean { if (status !== 'failed') return false; if (terminalReason === 'billing') return false; + if (isCodeReviewActionRequiredReason(terminalReason)) return false; + if (classifyCodeReviewActionRequiredFailure(errorMessage)) return false; if (isBillingCodeReviewTerminalReason(terminalReason, errorMessage)) return false; if (isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage)) return false; @@ -226,6 +266,51 @@ function isSupersededReview(review: CloudAgentCodeReview): boolean { return review.terminal_reason === 'superseded'; } +async function resolveTerminalOwner( + review: CloudAgentCodeReview, + reviewId: string +): Promise { + if (review.owned_by_organization_id) { + const botUserId = await getBotUserId(review.owned_by_organization_id, 'code-review'); + if (!botUserId) { + errorExceptInTest('[code-review-status] Bot user not found for organization', { + organizationId: review.owned_by_organization_id, + reviewId, + }); + captureMessage('Bot user missing for organization code review', { + level: 'error', + tags: { source: 'code-review-status' }, + extra: { + organizationId: review.owned_by_organization_id, + reviewId, + }, + }); + } + + return { + owner: { + type: 'org', + id: review.owned_by_organization_id, + userId: botUserId ?? 'system', + }, + canDispatch: !!botUserId, + }; + } + + if (review.owned_by_user_id) { + return { + owner: { + type: 'user', + id: review.owned_by_user_id, + userId: review.owned_by_user_id, + }, + canDispatch: true, + }; + } + + return undefined; +} + const BILLING_NOTICE_MARKER = ''; const MODEL_NOT_FOUND_SUMMARY_URL = 'https://app.kilo.ai/code-reviews'; const MODEL_NOT_FOUND_CHECK_TITLE = 'Selected model is no longer available'; @@ -340,20 +425,31 @@ function mapStatusToCheckRun( const reviewFailed = reviewStatus === 'completed' && gateResult === 'fail'; const billingFailure = reviewStatus === 'failed' && isBillingCodeReviewTerminalReason(terminalReason, errorMessage); + const actionRequiredReason = + reviewStatus === 'failed' + ? getActionRequiredTerminalReason(terminalReason, errorMessage) + : null; const modelNotFoundCancellation = reviewStatus === 'cancelled' && isModelNotFoundCodeReviewTerminalReason(terminalReason, errorMessage); + const actionRequiredCopy = actionRequiredReason + ? getCodeReviewActionRequiredCopy(actionRequiredReason) + : null; const conclusionMap: Record = { completed: reviewFailed ? 'failure' : 'success', - failed: billingFailure ? 'action_required' : 'failure', + failed: billingFailure || actionRequiredReason ? 'action_required' : 'failure', cancelled: 'cancelled', }; const titleMap: Record = { running: 'Kilo Code Review in progress', completed: reviewFailed ? 'Kilo Code Review found issues' : 'Kilo Code Review completed', - failed: billingFailure ? 'Insufficient credits to run review' : 'Kilo Code Review failed', + failed: actionRequiredCopy + ? actionRequiredCopy.checkTitle + : billingFailure + ? 'Insufficient credits to run review' + : 'Kilo Code Review failed', cancelled: modelNotFoundCancellation ? MODEL_NOT_FOUND_CHECK_TITLE : 'Kilo Code Review cancelled', @@ -364,11 +460,13 @@ function mapStatusToCheckRun( completed: reviewFailed ? 'Code review completed with findings that require attention.' : 'Code review completed successfully.', - failed: billingFailure - ? 'Review could not start because the account has insufficient credits.' - : errorMessage - ? `Review failed: ${errorMessage}` - : 'Review failed.', + failed: actionRequiredCopy + ? actionRequiredCopy.checkSummary + : billingFailure + ? 'Review could not start because the account has insufficient credits.' + : errorMessage + ? `Review failed: ${errorMessage}` + : 'Review failed.', cancelled: modelNotFoundCancellation ? MODEL_NOT_FOUND_STATUS_SUMMARY : 'Review was cancelled.', }; @@ -421,6 +519,13 @@ function getGitLabStatusDescription( ) { return 'Insufficient credits to run review'; } + const actionRequiredReason = + reviewStatus === 'failed' + ? getActionRequiredTerminalReason(terminalReason, errorMessage) + : null; + if (actionRequiredReason) { + return getCodeReviewActionRequiredCopy(actionRequiredReason).gitlabDescription; + } if (reviewStatus === 'failed' && errorMessage) { const desc = `Review failed: ${errorMessage}`; return desc.length > 255 ? desc.slice(0, 252) + '...' : desc; @@ -755,6 +860,12 @@ export async function POST( }); } + let terminalOwnerResolution: TerminalOwnerResolution | undefined; + const getTerminalOwnerResolution = async () => { + terminalOwnerResolution ??= await resolveTerminalOwner(review, reviewId); + return terminalOwnerResolution; + }; + if (isRetryableInfraFailure(status, terminalReason, errorMessage)) { const retryableReview = await getCodeReviewById(reviewId); if (!retryableReview || isSupersededReview(retryableReview)) { @@ -900,6 +1011,32 @@ export async function POST( await updateCodeReviewStatus(reviewId, status, parentStatusUpdates); } + const actionRequiredReason = + status === 'failed' ? getActionRequiredTerminalReason(terminalReason, errorMessage) : null; + if (actionRequiredReason) { + const ownerResolution = await getTerminalOwnerResolution(); + if (ownerResolution) { + try { + await disableCodeReviewForActionRequiredFailure({ + owner: ownerResolution.owner, + platform: review.platform === 'gitlab' ? 'gitlab' : 'github', + reviewId, + reason: actionRequiredReason, + errorMessage: errorMessage ?? actionRequiredReason, + }); + } catch (disableError) { + logExceptInTest( + '[code-review-status] Failed to disable Code Reviewer for action-required failure:', + disableError + ); + captureException(disableError, { + tags: { source: 'code-review-status-action-required-disable' }, + extra: { reviewId, reason: actionRequiredReason }, + }); + } + } + } + // Fetch integration once — used for gate check updates and post-completion actions const integration = review.platform_integration_id ? await getIntegrationById(review.platform_integration_id) @@ -966,53 +1103,23 @@ export async function POST( // Only trigger dispatch for terminal states (completed/failed/cancelled) // This frees up a slot for the next pending review if (status === 'completed' || status === 'failed' || status === 'cancelled') { - let owner: Parameters[0] | undefined; - if (review.owned_by_organization_id) { - const botUserId = await getBotUserId(review.owned_by_organization_id, 'code-review'); - if (botUserId) { - owner = { - type: 'org' as const, - id: review.owned_by_organization_id, - userId: botUserId, - }; - } else { - errorExceptInTest('[code-review-status] Bot user not found for organization', { - organizationId: review.owned_by_organization_id, - reviewId, - }); - captureMessage('Bot user missing for organization code review', { - level: 'error', - tags: { source: 'code-review-status' }, - extra: { - organizationId: review.owned_by_organization_id, - reviewId, - }, - }); - } - } else { - owner = { - type: 'user' as const, - id: review.owned_by_user_id || '', - userId: review.owned_by_user_id || '', - }; - } - - if (owner) { + const ownerResolution = await getTerminalOwnerResolution(); + if (ownerResolution?.canDispatch) { // Trigger dispatch in background (don't await - fire and forget) - tryDispatchPendingReviews(owner).catch(dispatchError => { + tryDispatchPendingReviews(ownerResolution.owner).catch(dispatchError => { errorExceptInTest( '[code-review-status] Error dispatching pending reviews:', dispatchError ); captureException(dispatchError, { tags: { source: 'code-review-status-dispatch' }, - extra: { reviewId, owner }, + extra: { reviewId, owner: ownerResolution.owner }, }); }); logExceptInTest('[code-review-status] Triggered dispatch for pending reviews', { reviewId, - owner, + owner: ownerResolution.owner, }); } diff --git a/apps/web/src/components/code-reviews/CodeReviewActionRequiredAlert.tsx b/apps/web/src/components/code-reviews/CodeReviewActionRequiredAlert.tsx new file mode 100644 index 0000000000..7993488320 --- /dev/null +++ b/apps/web/src/components/code-reviews/CodeReviewActionRequiredAlert.tsx @@ -0,0 +1,55 @@ +import Link from 'next/link'; +import { ExternalLink, TriangleAlert } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import type { CodeReviewActionRequiredState } from '@/lib/code-reviews/action-required-shared'; +import { + getCodeReviewActionRequiredCopy, + getCodeReviewActionRequiredRecoveryHref, +} from '@/lib/code-reviews/action-required-shared'; + +type CodeReviewActionRequiredAlertProps = { + actionRequired: CodeReviewActionRequiredState; + organizationId?: string; + compact?: boolean; +}; + +export function CodeReviewActionRequiredAlert({ + actionRequired, + organizationId, + compact = false, +}: CodeReviewActionRequiredAlertProps) { + const copy = getCodeReviewActionRequiredCopy(actionRequired.reason); + const recoveryHref = getCodeReviewActionRequiredRecoveryHref( + actionRequired.reason, + organizationId + ); + const isMailto = recoveryHref.startsWith('mailto:'); + + const cta = ( + + ); + + return ( + + + {copy.title} + +

{copy.description}

+ {cta} +
+
+ ); +} diff --git a/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx b/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx index 7d80f1e9b6..38afe75394 100644 --- a/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx +++ b/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx @@ -25,6 +25,11 @@ import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { CodeReviewStreamView } from './CodeReviewStreamView'; +import { + getCodeReviewActionRequiredCopy, + getCodeReviewActionRequiredRecoveryHref, + isCodeReviewActionRequiredReason, +} from '@/lib/code-reviews/action-required-shared'; type Platform = 'github' | 'gitlab'; @@ -223,6 +228,15 @@ export function CodeReviewJobsCard({ const StatusIcon = statusInfo.icon; const isExpanded = expandedReviewId === review.id; const canShowStream = ['running', 'queued'].includes(review.status); + const actionRequiredReason = isCodeReviewActionRequiredReason(review.terminal_reason) + ? review.terminal_reason + : null; + const actionRequiredCopy = actionRequiredReason + ? getCodeReviewActionRequiredCopy(actionRequiredReason) + : null; + const actionRequiredRecoveryHref = actionRequiredReason + ? getCodeReviewActionRequiredRecoveryHref(actionRequiredReason, organizationId) + : null; return (
@@ -364,25 +378,45 @@ export function CodeReviewJobsCard({ )} {/* Retry Button for failed/cancelled/interrupted reviews */} - {['failed', 'cancelled', 'interrupted'].includes(review.status) && ( - - )} + {['failed', 'cancelled', 'interrupted'].includes(review.status) && + actionRequiredCopy && + actionRequiredRecoveryHref && ( + + )} + {['failed', 'cancelled', 'interrupted'].includes(review.status) && + !actionRequiredReason && ( + + )}
diff --git a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx index 87d35e4ac9..0e5875052d 100644 --- a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx +++ b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx @@ -30,6 +30,7 @@ import { useOrganizationModels } from '@/components/cloud-agent/hooks/useOrganiz import { ModelCombobox } from '@/components/shared/ModelCombobox'; import { cn } from '@/lib/utils'; import { RepositoryMultiSelect, type Repository } from './RepositoryMultiSelect'; +import { CodeReviewActionRequiredAlert } from './CodeReviewActionRequiredAlert'; import { PRIMARY_DEFAULT_MODEL } from '@/lib/ai-gateway/models'; import { getAvailableThinkingEfforts, @@ -522,6 +523,14 @@ export function ReviewConfigForm({
+ {configData?.actionRequired && ( + + )} + {/* Enable/Disable Toggle */}
diff --git a/apps/web/src/emails/AGENTS.md b/apps/web/src/emails/AGENTS.md index bc5093a842..b7eb4352b8 100644 --- a/apps/web/src/emails/AGENTS.md +++ b/apps/web/src/emails/AGENTS.md @@ -71,6 +71,7 @@ Every template must include this branding footer below the content table: | `magicLink.html` | `magic_link_url`, `email`, `expires_in`, `year` | `14` | | `balanceAlert.html` | `minimum_balance`, `organization_url`, `year` | `16` | | `autoTopUpFailed.html` | `reason`, `credits_url`, `year` | `17` | +| `codeReviewDisabled.html` | `reason`, `recovery_url`, `recovery_label`, `year` | — | | `ossInviteNewUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `accept_invite_url`, `integrations_url`, `code_reviews_url`, `year` | `18` | | `ossInviteExistingUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `19` | | `ossExistingOrgProvisioned.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `20` | diff --git a/apps/web/src/emails/codeReviewDisabled.html b/apps/web/src/emails/codeReviewDisabled.html new file mode 100644 index 0000000000..cdb1e4d982 --- /dev/null +++ b/apps/web/src/emails/codeReviewDisabled.html @@ -0,0 +1,154 @@ + + + + + + Code Reviewer Disabled + + + + + + +
+ + + + + + + + + + + + + +
+

+ Code Reviewer Disabled +

+
+

+ Code Reviewer was disabled because it needs configuration attention. + {{ reason }} +

+

+ Existing review history remains available. Fix the configuration issue, then + enable Code Reviewer again to resume automatic reviews. +

+ + + + + +
+ + {{ recovery_label }} + +
+

+ If you have questions, reply to this email or contact hi@kilocode.ai. +

+
+

+ The Kilo Team +

+
+ + + + + +
+

+ © {{ year }} Kilo Code, Inc
455 Market St, Ste 1940 PMB 993504
San + Francisco, CA 94105, USA +

+
+
+ + diff --git a/apps/web/src/lib/agent-config/db/agent-configs.ts b/apps/web/src/lib/agent-config/db/agent-configs.ts index e0fd0eca94..ac063793c6 100644 --- a/apps/web/src/lib/agent-config/db/agent-configs.ts +++ b/apps/web/src/lib/agent-config/db/agent-configs.ts @@ -37,6 +37,14 @@ export async function upsertAgentConfig(data: { isEnabled?: boolean; createdBy: string; }) { + const updateSet: Partial = { + config: data.config, + updated_at: new Date().toISOString(), + }; + if (data.isEnabled !== undefined) { + updateSet.is_enabled = data.isEnabled; + } + await db .insert(agent_configs) .values({ @@ -53,11 +61,7 @@ export async function upsertAgentConfig(data: { agent_configs.agent_type, agent_configs.platform, ], - set: { - config: data.config, - is_enabled: data.isEnabled ?? true, - updated_at: new Date().toISOString(), - }, + set: updateSet, }); // Create bot user for code review agents @@ -156,6 +160,14 @@ export async function upsertAgentConfigForOwner(data: { isEnabled?: boolean; createdBy: string; }) { + const updateSet: Partial = { + config: data.config, + updated_at: new Date().toISOString(), + }; + if (data.isEnabled !== undefined) { + updateSet.is_enabled = data.isEnabled; + } + const values = data.owner.type === 'org' ? { @@ -182,17 +194,10 @@ export async function upsertAgentConfigForOwner(data: { ? [agent_configs.owned_by_organization_id, agent_configs.agent_type, agent_configs.platform] : [agent_configs.owned_by_user_id, agent_configs.agent_type, agent_configs.platform]; - await db - .insert(agent_configs) - .values(values) - .onConflictDoUpdate({ - target: targetColumns, - set: { - config: data.config, - is_enabled: data.isEnabled ?? true, - updated_at: new Date().toISOString(), - }, - }); + await db.insert(agent_configs).values(values).onConflictDoUpdate({ + target: targetColumns, + set: updateSet, + }); // Create bot user for code review agents (only for organizations) if (data.agentType === 'code_review' && data.owner.type === 'org') { diff --git a/apps/web/src/lib/code-reviews/action-required-shared.ts b/apps/web/src/lib/code-reviews/action-required-shared.ts new file mode 100644 index 0000000000..2940d53833 --- /dev/null +++ b/apps/web/src/lib/code-reviews/action-required-shared.ts @@ -0,0 +1,95 @@ +export const CODE_REVIEW_ACTION_REQUIRED_RUNTIME_STATE_KEY = 'code_review_action_required'; + +export const CODE_REVIEW_ACTION_REQUIRED_REASONS = [ + 'github_installation_required', + 'github_ip_allow_list', + 'byok_invalid_key', +] as const; + +export type CodeReviewActionRequiredReason = (typeof CODE_REVIEW_ACTION_REQUIRED_REASONS)[number]; + +export type CodeReviewActionRequiredState = { + reason: CodeReviewActionRequiredReason; + detectedAt: string; + lastSeenAt: string; + triggeringReviewId?: string; + lastErrorMessage: string; + emailSentAt?: string; +}; + +export type CodeReviewActionRequiredCopy = { + title: string; + description: string; + recoveryLabel: string; + emailReason: string; + checkTitle: string; + checkSummary: string; + gitlabDescription: string; +}; + +const COPY_BY_REASON = { + github_installation_required: { + title: 'Code Reviewer needs attention', + description: + 'Code Reviewer was disabled because Kilo cannot access this repository with an active GitHub App installation. Update the GitHub App installation, then enable Code Reviewer again.', + recoveryLabel: 'Update GitHub App', + emailReason: 'Kilo cannot access this repository with an active GitHub App installation.', + checkTitle: 'GitHub App access required', + checkSummary: + 'Code Reviewer was disabled because Kilo cannot access this repository with an active GitHub App installation. Update the GitHub App installation, then enable Code Reviewer again.', + gitlabDescription: 'GitHub App access required for Code Reviewer', + }, + github_ip_allow_list: { + title: 'Code Reviewer needs attention', + description: + 'Code Reviewer was disabled because this GitHub organization uses an IP allow list that blocks Kilo. Contact hi@kilocode.ai to discuss supported access options, then enable Code Reviewer again.', + recoveryLabel: 'Contact support', + emailReason: 'This GitHub organization uses an IP allow list that blocks Kilo.', + checkTitle: 'GitHub IP allow list blocks Kilo', + checkSummary: + 'Code Reviewer was disabled because this GitHub organization uses an IP allow list that blocks Kilo. Contact hi@kilocode.ai, then enable Code Reviewer again.', + gitlabDescription: 'GitHub IP allow list blocks Code Reviewer', + }, + byok_invalid_key: { + title: 'Code Reviewer needs attention', + description: + 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', + recoveryLabel: 'Update BYOK settings', + emailReason: 'The selected BYOK API key is invalid or has been revoked.', + checkTitle: 'BYOK API key needs attention', + checkSummary: + 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', + gitlabDescription: 'BYOK API key needs attention for Code Reviewer', + }, +} satisfies Record; + +const ACTION_REQUIRED_REASON_SET = new Set(CODE_REVIEW_ACTION_REQUIRED_REASONS); + +export function isCodeReviewActionRequiredReason( + reason: string | null | undefined +): reason is CodeReviewActionRequiredReason { + return reason !== null && reason !== undefined && ACTION_REQUIRED_REASON_SET.has(reason); +} + +export function getCodeReviewActionRequiredCopy( + reason: CodeReviewActionRequiredReason +): CodeReviewActionRequiredCopy { + return COPY_BY_REASON[reason]; +} + +export function getCodeReviewActionRequiredRecoveryHref( + reason: CodeReviewActionRequiredReason, + organizationId?: string +): string { + if (reason === 'github_installation_required') { + return organizationId + ? `/organizations/${organizationId}/integrations/github` + : '/integrations/github'; + } + + if (reason === 'github_ip_allow_list') { + return 'mailto:hi@kilocode.ai?subject=GitHub%20IP%20allow%20list%20for%20Code%20Reviewer'; + } + + return organizationId ? `/organizations/${organizationId}/byok` : '/byok'; +} diff --git a/apps/web/src/lib/code-reviews/action-required.test.ts b/apps/web/src/lib/code-reviews/action-required.test.ts new file mode 100644 index 0000000000..09a678e8d1 --- /dev/null +++ b/apps/web/src/lib/code-reviews/action-required.test.ts @@ -0,0 +1,213 @@ +const mockSendCodeReviewDisabledEmail = jest.fn(); + +jest.mock('@/lib/email', () => ({ + sendCodeReviewDisabledEmail: (...args: unknown[]) => mockSendCodeReviewDisabledEmail(...args), +})); + +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { agent_configs, kilocode_users, type User } from '@kilocode/db/schema'; +import { and, eq } from 'drizzle-orm'; +import { + classifyCodeReviewActionRequiredFailure, + disableCodeReviewForActionRequiredFailure, + getCodeReviewActionRequiredState, +} from './action-required'; + +describe('classifyCodeReviewActionRequiredFailure', () => { + it('classifies GitHub installation, GitHub IP allow-list, and BYOK invalid key failures', () => { + expect( + classifyCodeReviewActionRequiredFailure( + 'GitHub token or active app installation required for this repository (no_installation_found)' + ) + ).toBe('github_installation_required'); + + expect( + classifyCodeReviewActionRequiredFailure( + 'Dispatch failed: GitHub token or active app installation required for this repository (no_installation_found)' + ) + ).toBe('github_installation_required'); + + expect( + classifyCodeReviewActionRequiredFailure( + '[BYOK] Your API key is invalid or has been revoked. Please check your API key configuration.' + ) + ).toBe('byok_invalid_key'); + + expect( + classifyCodeReviewActionRequiredFailure( + 'Although you appear to have the correct authorization credentials, the `acme` organization has an IP allow list enabled, and 192.0.2.1 is not permitted.' + ) + ).toBe('github_ip_allow_list'); + }); + + it('does not classify unrelated auth, rate-limit, or BYOK quota failures', () => { + expect(classifyCodeReviewActionRequiredFailure('GitHub returned 401 Unauthorized')).toBeNull(); + expect(classifyCodeReviewActionRequiredFailure('GitHub returned 403 Forbidden')).toBeNull(); + expect(classifyCodeReviewActionRequiredFailure('Rate limit exceeded: 429')).toBeNull(); + expect( + classifyCodeReviewActionRequiredFailure('[BYOK] Your account quota is exhausted.') + ).toBeNull(); + }); +}); + +describe('disableCodeReviewForActionRequiredFailure', () => { + let testUser: User; + + beforeAll(async () => { + testUser = await insertTestUser(); + }); + + beforeEach(async () => { + mockSendCodeReviewDisabledEmail.mockResolvedValue({ sent: true }); + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: {}, + is_enabled: true, + created_by: testUser.id, + }); + }); + + afterEach(async () => { + await db + .delete(agent_configs) + .where( + and( + eq(agent_configs.owned_by_user_id, testUser.id), + eq(agent_configs.agent_type, 'code_review') + ) + ); + mockSendCodeReviewDisabledEmail.mockReset(); + }); + + afterAll(async () => { + await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id)); + }); + + async function getStoredConfig() { + const [config] = await db + .select() + .from(agent_configs) + .where( + and( + eq(agent_configs.owned_by_user_id, testUser.id), + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.platform, 'github') + ) + ) + .limit(1); + return config; + } + + it('throws when the agent config is missing', async () => { + await db + .delete(agent_configs) + .where( + and( + eq(agent_configs.owned_by_user_id, testUser.id), + eq(agent_configs.agent_type, 'code_review') + ) + ); + + await expect( + disableCodeReviewForActionRequiredFailure({ + owner: { type: 'user', id: testUser.id, userId: testUser.id }, + platform: 'github', + reason: 'github_installation_required', + errorMessage: + 'GitHub token or active app installation required for this repository (no_installation_found)', + }) + ).rejects.toThrow('Code Review agent config not found'); + + expect(mockSendCodeReviewDisabledEmail).not.toHaveBeenCalled(); + }); + + it('stores runtime state without recipient PII and sends one email for a repeated reason', async () => { + const owner = { type: 'user' as const, id: testUser.id, userId: testUser.id }; + + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reviewId: 'review-1', + reason: 'github_installation_required', + errorMessage: + 'GitHub token or active app installation required for this repository (no_installation_found)', + }); + + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reviewId: 'review-2', + reason: 'github_installation_required', + errorMessage: + 'Dispatch failed: GitHub token or active app installation required for this repository (no_installation_found)', + }); + + const config = await getStoredConfig(); + const state = getCodeReviewActionRequiredState(config); + + expect(config?.is_enabled).toBe(false); + expect(state?.reason).toBe('github_installation_required'); + expect(state?.triggeringReviewId).toBe('review-2'); + expect(state?.emailSentAt).toBeTruthy(); + expect(JSON.stringify(config?.runtime_state)).not.toContain(testUser.google_user_email); + expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(1); + }); + + it('retries email when notification delivery fails', async () => { + const owner = { type: 'user' as const, id: testUser.id, userId: testUser.id }; + mockSendCodeReviewDisabledEmail.mockResolvedValueOnce({ sent: false }); + + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reviewId: 'review-1', + reason: 'github_installation_required', + errorMessage: + 'GitHub token or active app installation required for this repository (no_installation_found)', + }); + + let state = getCodeReviewActionRequiredState(await getStoredConfig()); + expect(state?.emailSentAt).toBeUndefined(); + + mockSendCodeReviewDisabledEmail.mockResolvedValueOnce({ sent: true }); + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reviewId: 'review-2', + reason: 'github_installation_required', + errorMessage: + 'Dispatch failed: GitHub token or active app installation required for this repository (no_installation_found)', + }); + + state = getCodeReviewActionRequiredState(await getStoredConfig()); + expect(state?.emailSentAt).toBeTruthy(); + expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(2); + }); + + it('sends a new email when the action-required reason changes', async () => { + const owner = { type: 'user' as const, id: testUser.id, userId: testUser.id }; + + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reason: 'github_installation_required', + errorMessage: + 'GitHub token or active app installation required for this repository (no_installation_found)', + }); + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: 'github', + reason: 'github_ip_allow_list', + errorMessage: + 'Although you appear to have the correct authorization credentials, the `acme` organization has an IP allow list enabled, and 192.0.2.1 is not permitted.', + }); + + const state = getCodeReviewActionRequiredState(await getStoredConfig()); + + expect(state?.reason).toBe('github_ip_allow_list'); + expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/code-reviews/action-required.ts b/apps/web/src/lib/code-reviews/action-required.ts new file mode 100644 index 0000000000..72cf213687 --- /dev/null +++ b/apps/web/src/lib/code-reviews/action-required.ts @@ -0,0 +1,357 @@ +import * as z from 'zod'; +import { captureException } from '@sentry/nextjs'; +import { and, eq, type SQL } from 'drizzle-orm'; +import { agent_configs } from '@kilocode/db/schema'; +import { db, sql, type DrizzleTransaction } from '@/lib/drizzle'; +import { NEXTAUTH_URL } from '@/lib/config.server'; +import { sendCodeReviewDisabledEmail } from '@/lib/email'; +import { getOrganizationMembers } from '@/lib/organizations/organizations'; +import { findUserById } from '@/lib/user'; +import { logExceptInTest } from '@/lib/utils.server'; +import type { Owner } from '@/lib/code-reviews/core'; +import type { CodeReviewPlatform } from '@/lib/code-reviews/core/schemas'; +import { + CODE_REVIEW_ACTION_REQUIRED_REASONS, + CODE_REVIEW_ACTION_REQUIRED_RUNTIME_STATE_KEY, + type CodeReviewActionRequiredReason, + type CodeReviewActionRequiredState, + getCodeReviewActionRequiredCopy, + getCodeReviewActionRequiredRecoveryHref, + isCodeReviewActionRequiredReason, +} from './action-required-shared'; + +export type { CodeReviewActionRequiredReason, CodeReviewActionRequiredState }; +export { + getCodeReviewActionRequiredCopy, + getCodeReviewActionRequiredRecoveryHref, + isCodeReviewActionRequiredReason, +}; + +const CodeReviewActionRequiredStateSchema = z.object({ + reason: z.enum(CODE_REVIEW_ACTION_REQUIRED_REASONS), + detectedAt: z.string(), + lastSeenAt: z.string(), + triggeringReviewId: z.string().optional(), + lastErrorMessage: z.string(), + emailSentAt: z.string().optional(), +}); + +type AgentConfigWithRuntimeState = { + runtime_state?: Record | null; +}; + +type DisableCodeReviewForActionRequiredFailureArgs = { + owner: Owner; + platform: CodeReviewPlatform; + reviewId?: string; + reason: CodeReviewActionRequiredReason; + errorMessage: string; +}; + +type ClearCodeReviewActionRequiredStateArgs = { + owner: Owner; + platform: CodeReviewPlatform; +}; + +type MarkActionRequiredEmailSentArgs = { + owner: Owner; + platform: CodeReviewPlatform; + reason: CodeReviewActionRequiredReason; + sentAt: string; +}; + +function stripKnownErrorPrefixes(errorMessage: string): string { + let message = errorMessage.trim(); + let next = message.replace(/^dispatch failed:\s*/i, '').trim(); + + while (next !== message) { + message = next; + next = message.replace(/^dispatch failed:\s*/i, '').trim(); + } + + return message; +} + +export function classifyCodeReviewActionRequiredFailure( + errorMessage?: string | null +): CodeReviewActionRequiredReason | null { + if (!errorMessage) return null; + + const stripped = stripKnownErrorPrefixes(errorMessage); + const normalized = stripped.toLowerCase(); + + if ( + normalized.includes('github token or active app installation required for this repository') && + normalized.includes('no_installation_found') + ) { + return 'github_installation_required'; + } + + if ( + normalized.includes( + '[byok] your api key is invalid or has been revoked. please check your api key configuration.' + ) + ) { + return 'byok_invalid_key'; + } + + if ( + normalized.includes('although you appear to have the correct authorization credentials') && + normalized.includes('organization has an ip allow list enabled') + ) { + return 'github_ip_allow_list'; + } + + return null; +} + +export function getCodeReviewActionRequiredState( + config: AgentConfigWithRuntimeState | null | undefined +): CodeReviewActionRequiredState | null { + const runtimeState = config?.runtime_state; + if (!runtimeState) return null; + + const parsed = CodeReviewActionRequiredStateSchema.safeParse( + runtimeState[CODE_REVIEW_ACTION_REQUIRED_RUNTIME_STATE_KEY] + ); + + return parsed.success ? parsed.data : null; +} + +function ownerConditions(owner: Pick, platform: CodeReviewPlatform): SQL[] { + return [ + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.platform, platform), + owner.type === 'org' + ? eq(agent_configs.owned_by_organization_id, owner.id) + : eq(agent_configs.owned_by_user_id, owner.id), + ]; +} + +async function updateActionRequiredRuntimeState( + tx: DrizzleTransaction, + conditions: SQL[], + state: CodeReviewActionRequiredState +): Promise { + await tx + .update(agent_configs) + .set({ + is_enabled: false, + runtime_state: sql`jsonb_set(COALESCE(${agent_configs.runtime_state}, '{}'::jsonb), '{${sql.raw(CODE_REVIEW_ACTION_REQUIRED_RUNTIME_STATE_KEY)}}', ${JSON.stringify(state)}::jsonb, true)`, + updated_at: new Date().toISOString(), + }) + .where(and(...conditions)); +} + +async function getRecipientEmails(owner: Owner): Promise { + if (owner.type === 'user') { + const user = await findUserById(owner.id); + return user?.google_user_email ? [user.google_user_email] : []; + } + + const members = await getOrganizationMembers(owner.id); + return [ + ...new Set( + members + .filter(member => member.status === 'active' && member.role === 'owner') + .map(member => member.email) + ), + ]; +} + +function toEmailRecoveryUrl(href: string): string { + if (href.startsWith('mailto:')) return href; + return `${NEXTAUTH_URL}${href}`; +} + +async function sendActionRequiredEmailNotifications( + owner: Owner, + platform: CodeReviewPlatform, + reason: CodeReviewActionRequiredReason +): Promise { + const recipients = await getRecipientEmails(owner); + if (recipients.length === 0) { + logExceptInTest('[code-review-action-required] No notification recipients found', { + ownerType: owner.type, + ownerId: owner.id, + platform, + reason, + }); + return false; + } + + const copy = getCodeReviewActionRequiredCopy(reason); + const recoveryHref = getCodeReviewActionRequiredRecoveryHref( + reason, + owner.type === 'org' ? owner.id : undefined + ); + const recoveryUrl = toEmailRecoveryUrl(recoveryHref); + + const results = await Promise.all( + recipients.map(recipient => + sendCodeReviewDisabledEmail(recipient, { + reason: copy.emailReason, + recoveryUrl, + recoveryLabel: copy.recoveryLabel, + }) + ) + ); + + const failedCount = results.filter(result => !result.sent).length; + if (failedCount > 0) { + const error = new Error('Failed to send Code Reviewer disabled email'); + logExceptInTest('[code-review-action-required] Email notification failed', { + ownerType: owner.type, + ownerId: owner.id, + platform, + reason, + failedCount, + recipientCount: recipients.length, + }); + captureException(error, { + tags: { source: 'code-review-action-required-email' }, + extra: { + ownerType: owner.type, + ownerId: owner.id, + platform, + reason, + failedCount, + recipientCount: recipients.length, + }, + }); + return false; + } + + return true; +} + +async function markActionRequiredEmailSent(args: MarkActionRequiredEmailSentArgs): Promise { + await db.transaction(async tx => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${`code-review-action-required:${args.owner.type}:${args.owner.id}:${args.platform}`}))` + ); + + const conditions = ownerConditions(args.owner, args.platform); + const [config] = await tx + .select() + .from(agent_configs) + .where(and(...conditions)) + .for('update') + .limit(1); + + if (!config) { + throw new Error( + `Code Review agent config not found for owner ${args.owner.type}:${args.owner.id} on ${args.platform}` + ); + } + + const existingState = getCodeReviewActionRequiredState(config); + if (!existingState || existingState.reason !== args.reason || existingState.emailSentAt) return; + + await updateActionRequiredRuntimeState(tx, conditions, { + ...existingState, + emailSentAt: args.sentAt, + }); + }); +} + +export async function disableCodeReviewForActionRequiredFailure( + args: DisableCodeReviewForActionRequiredFailureArgs +): Promise { + const copy = getCodeReviewActionRequiredCopy(args.reason); + + const shouldSendEmail = await db.transaction(async tx => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${`code-review-action-required:${args.owner.type}:${args.owner.id}:${args.platform}`}))` + ); + + const conditions = ownerConditions(args.owner, args.platform); + const [config] = await tx + .select() + .from(agent_configs) + .where(and(...conditions)) + .for('update') + .limit(1); + + if (!config) { + logExceptInTest('[code-review-action-required] Agent config not found', { + ownerType: args.owner.type, + ownerId: args.owner.id, + platform: args.platform, + reason: args.reason, + reviewId: args.reviewId, + }); + throw new Error( + `Code Review agent config not found for owner ${args.owner.type}:${args.owner.id} on ${args.platform}` + ); + } + + const now = new Date().toISOString(); + const existingState = getCodeReviewActionRequiredState(config); + const shouldSendEmail = + !existingState || existingState.reason !== args.reason || !existingState.emailSentAt; + + const nextState: CodeReviewActionRequiredState = { + reason: args.reason, + detectedAt: + existingState?.reason === args.reason && existingState.detectedAt + ? existingState.detectedAt + : now, + lastSeenAt: now, + ...(args.reviewId ? { triggeringReviewId: args.reviewId } : {}), + lastErrorMessage: copy.description, + ...(!shouldSendEmail && existingState?.emailSentAt + ? { emailSentAt: existingState.emailSentAt } + : {}), + }; + + await updateActionRequiredRuntimeState(tx, conditions, nextState); + + return shouldSendEmail; + }); + + if (!shouldSendEmail) return; + + try { + const sent = await sendActionRequiredEmailNotifications(args.owner, args.platform, args.reason); + if (sent) { + await markActionRequiredEmailSent({ + owner: args.owner, + platform: args.platform, + reason: args.reason, + sentAt: new Date().toISOString(), + }); + } + } catch (error) { + logExceptInTest('[code-review-action-required] Failed to send notification email', { + ownerType: args.owner.type, + ownerId: args.owner.id, + platform: args.platform, + reason: args.reason, + reviewId: args.reviewId, + }); + captureException(error, { + tags: { source: 'code-review-action-required-email' }, + extra: { + ownerType: args.owner.type, + ownerId: args.owner.id, + platform: args.platform, + reason: args.reason, + reviewId: args.reviewId, + }, + }); + } +} + +export async function clearCodeReviewActionRequiredState( + args: ClearCodeReviewActionRequiredStateArgs +): Promise { + const conditions = ownerConditions(args.owner, args.platform); + await db + .update(agent_configs) + .set({ + runtime_state: sql`COALESCE(${agent_configs.runtime_state}, '{}'::jsonb) - ${CODE_REVIEW_ACTION_REQUIRED_RUNTIME_STATE_KEY}`, + updated_at: new Date().toISOString(), + }) + .where(and(...conditions)); +} diff --git a/apps/web/src/lib/code-reviews/alerting/detectors.test.ts b/apps/web/src/lib/code-reviews/alerting/detectors.test.ts index 91a4f207d5..8da8e7466e 100644 --- a/apps/web/src/lib/code-reviews/alerting/detectors.test.ts +++ b/apps/web/src/lib/code-reviews/alerting/detectors.test.ts @@ -233,7 +233,10 @@ describe('code review alert detectors', () => { reviewValues({ status: 'cancelled', terminal_reason: 'model_not_found' }), reviewValues({ status: 'cancelled', terminal_reason: 'user_cancelled' }), reviewValues({ status: 'cancelled', terminal_reason: 'superseded' }), - ...Array.from({ length: 16 }, () => reviewValues()), + reviewValues({ status: 'failed', terminal_reason: 'github_installation_required' }), + reviewValues({ status: 'failed', terminal_reason: 'github_ip_allow_list' }), + reviewValues({ status: 'failed', terminal_reason: 'byok_invalid_key' }), + ...Array.from({ length: 13 }, () => reviewValues()), ]); await expect(evaluateErrorSpike(db)).resolves.toEqual({ tripped: false }); diff --git a/apps/web/src/lib/code-reviews/db/code-reviews.ts b/apps/web/src/lib/code-reviews/db/code-reviews.ts index 2d98acbf8d..ad1e4d4728 100644 --- a/apps/web/src/lib/code-reviews/db/code-reviews.ts +++ b/apps/web/src/lib/code-reviews/db/code-reviews.ts @@ -18,6 +18,7 @@ import { captureException } from '@sentry/nextjs'; import type { CreateReviewParams, CodeReviewStatus, ListReviewsParams, Owner } from '../core'; import type { CloudAgentCodeReview, CloudAgentCodeReviewAttempt } from '@kilocode/db/schema'; import type { CodeReviewTerminalReason } from '@kilocode/db/schema-types'; +import { isCodeReviewActionRequiredReason } from '../action-required-shared'; import { activeCodeReviewWorkCondition, reconsiderableCodeReviewWorkCondition, @@ -119,6 +120,7 @@ const RETRYABLE_PARENT_REVIEW_STATUSES = ['queued', 'running']; function canCreateInfraRetryAttempt(review: { status: string; terminal_reason: string | null }) { return ( review.terminal_reason !== 'superseded' && + !isCodeReviewActionRequiredReason(review.terminal_reason) && RETRYABLE_PARENT_REVIEW_STATUSES.includes(review.status) ); } @@ -954,17 +956,24 @@ export async function releaseQueuedReviewClaim( export async function failReservedQueuedReview( reviewId: string, dispatchReservationId: string, - errorMessage: string + errorMessage: string, + terminalReason?: CodeReviewTerminalReason ): Promise { try { + const updateData: Partial = { + status: 'failed', + error_message: errorMessage, + dispatch_reservation_id: null, + completed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + if (terminalReason !== undefined) { + updateData.terminal_reason = terminalReason; + } + const failed = await db .update(cloud_agent_code_reviews) - .set({ - status: 'failed', - error_message: errorMessage, - completed_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) + .set(updateData) .where( and( eq(cloud_agent_code_reviews.id, reviewId), diff --git a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts index 2537e8ebbe..14c3d85da2 100644 --- a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts +++ b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.test.ts @@ -2,6 +2,9 @@ const mockDispatchReview = jest.fn(); const mockGetReviewStatus = jest.fn(); const mockGetAgentConfigForOwner = jest.fn(); const mockPrepareReviewPayload = jest.fn(); +const mockSendCodeReviewDisabledEmail = jest.fn(); +const mockGetIntegrationById = jest.fn(); +const mockUpdateCheckRun = jest.fn(); jest.mock('@/lib/code-reviews/client/code-review-worker-client', () => ({ codeReviewWorkerClient: { @@ -18,6 +21,22 @@ jest.mock('@/lib/code-reviews/triggers/prepare-review-payload', () => ({ prepareReviewPayload: (...args: unknown[]) => mockPrepareReviewPayload(...args), })); +jest.mock('@/lib/email', () => ({ + sendCodeReviewDisabledEmail: (...args: unknown[]) => mockSendCodeReviewDisabledEmail(...args), +})); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationById: (...args: unknown[]) => mockGetIntegrationById(...args), +})); + +jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + updateCheckRun: (...args: unknown[]) => mockUpdateCheckRun(...args), +})); + +jest.mock('@/lib/constants', () => ({ + APP_URL: 'https://test.kilo.ai', +})); + jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), })); @@ -25,6 +44,7 @@ jest.mock('@sentry/nextjs', () => ({ import { db } from '@/lib/drizzle'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { + agent_configs, cloud_agent_code_review_attempts, cloud_agent_code_reviews, kilocode_users, @@ -32,6 +52,7 @@ import { type User, } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; +import { or } from 'drizzle-orm'; import { tryDispatchPendingReviews } from './dispatch-pending-reviews'; import { cronPendingCodeReviewCreatedAtWindowSql } from './dispatch-constants'; import { @@ -78,20 +99,39 @@ describe('tryDispatchPendingReviews', () => { beforeEach(() => { mockDispatchReview.mockResolvedValue(undefined); mockGetReviewStatus.mockResolvedValue(null); - mockGetAgentConfigForOwner.mockResolvedValue({ id: 'test-agent-config', config: {} }); + mockGetAgentConfigForOwner.mockResolvedValue({ + id: 'test-agent-config', + config: {}, + is_enabled: true, + runtime_state: {}, + }); mockPrepareReviewPayload.mockImplementation((params: { reviewId: string }) => ({ reviewId: params.reviewId, })); + mockSendCodeReviewDisabledEmail.mockResolvedValue({ sent: true }); + mockGetIntegrationById.mockResolvedValue(null); + mockUpdateCheckRun.mockResolvedValue(undefined); }); afterEach(async () => { await db .delete(cloud_agent_code_reviews) .where(eq(cloud_agent_code_reviews.repo_full_name, REPO)); + await db + .delete(agent_configs) + .where( + or( + eq(agent_configs.owned_by_user_id, testUser.id), + eq(agent_configs.owned_by_organization_id, testOrganizationId) + ) + ); mockDispatchReview.mockReset(); mockGetReviewStatus.mockReset(); mockGetAgentConfigForOwner.mockReset(); mockPrepareReviewPayload.mockReset(); + mockSendCodeReviewDisabledEmail.mockReset(); + mockGetIntegrationById.mockReset(); + mockUpdateCheckRun.mockReset(); }); afterAll(async () => { @@ -142,6 +182,38 @@ describe('tryDispatchPendingReviews', () => { }; } + async function insertAgentConfigForUser(runtimeState: Record = {}) { + const [config] = await db + .insert(agent_configs) + .values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: {}, + is_enabled: true, + runtime_state: runtimeState, + created_by: testUser.id, + }) + .returning(); + + return config; + } + + async function getStoredReview(reviewId: string) { + const [review] = await db + .select({ + status: cloud_agent_code_reviews.status, + terminalReason: cloud_agent_code_reviews.terminal_reason, + dispatchReservationId: cloud_agent_code_reviews.dispatch_reservation_id, + errorMessage: cloud_agent_code_reviews.error_message, + }) + .from(cloud_agent_code_reviews) + .where(eq(cloud_agent_code_reviews.id, reviewId)) + .limit(1); + + return review; + } + it('keeps organization concurrency at 20 reviews', async () => { const recentTimestamp = minutesAgo(1); const owner = { type: 'org', id: testOrganizationId } satisfies ReviewOwner; @@ -250,6 +322,104 @@ describe('tryDispatchPendingReviews', () => { expect(mockDispatchReview).toHaveBeenCalledTimes(1); }); + it('disables Code Reviewer for pre-worker GitHub installation failures', async () => { + const recentTimestamp = minutesAgo(1); + const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; + const agentConfig = await insertAgentConfigForUser(); + mockGetAgentConfigForOwner.mockResolvedValue(agentConfig); + mockPrepareReviewPayload.mockRejectedValue( + new Error( + 'GitHub token or active app installation required for this repository (no_installation_found)' + ) + ); + + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues({ + owner, + status: 'pending', + createdAt: recentTimestamp, + updatedAt: recentTimestamp, + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + const result = await tryDispatchPendingReviews({ + type: 'user', + id: testUser.id, + userId: testUser.id, + }); + + const storedReview = await getStoredReview(review.id); + const storedConfig = await db.query.agent_configs.findFirst({ + where: eq(agent_configs.id, agentConfig.id), + }); + + expect(result.dispatched).toBe(0); + expect(mockDispatchReview).not.toHaveBeenCalled(); + expect(storedReview).toEqual( + expect.objectContaining({ + status: 'failed', + terminalReason: 'github_installation_required', + dispatchReservationId: null, + }) + ); + expect(storedConfig?.is_enabled).toBe(false); + expect(mockSendCodeReviewDisabledEmail).toHaveBeenCalledTimes(1); + }); + + it('refuses to prepare pending work while action-required state is present', async () => { + const recentTimestamp = minutesAgo(1); + const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; + const actionRequiredState = { + code_review_action_required: { + reason: 'byok_invalid_key', + detectedAt: minutesAgo(10), + lastSeenAt: minutesAgo(9), + lastErrorMessage: + 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', + }, + }; + const agentConfig = await insertAgentConfigForUser(actionRequiredState); + mockGetAgentConfigForOwner.mockResolvedValue(agentConfig); + + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues({ + owner, + status: 'pending', + createdAt: recentTimestamp, + updatedAt: recentTimestamp, + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + await tryDispatchPendingReviews({ + type: 'user', + id: testUser.id, + userId: testUser.id, + }); + + const storedReview = await getStoredReview(review.id); + const storedConfig = await db.query.agent_configs.findFirst({ + where: eq(agent_configs.id, agentConfig.id), + }); + + expect(mockPrepareReviewPayload).not.toHaveBeenCalled(); + expect(mockDispatchReview).not.toHaveBeenCalled(); + expect(mockSendCodeReviewDisabledEmail).not.toHaveBeenCalled(); + expect(storedConfig?.runtime_state).toEqual(actionRequiredState); + expect(storedReview).toEqual( + expect.objectContaining({ + status: 'failed', + terminalReason: 'byok_invalid_key', + dispatchReservationId: null, + }) + ); + }); + it('does not dispatch funded personal reviews when three are already active', async () => { const recentTimestamp = minutesAgo(1); const owner = { type: 'user', id: testUser.id } satisfies ReviewOwner; @@ -496,7 +666,7 @@ describe('tryDispatchPendingReviews', () => { expect(mockPrepareReviewPayload).toHaveBeenCalledWith({ reviewId: oldPendingReview.id, owner: { type: 'user', id: testUser.id, userId: testUser.id }, - agentConfig: { id: 'test-agent-config', config: {} }, + agentConfig: { id: 'test-agent-config', config: {}, is_enabled: true, runtime_state: {} }, platform: 'github', }); expect(mockPrepareReviewPayload).not.toHaveBeenCalledWith( @@ -566,7 +736,7 @@ describe('tryDispatchPendingReviews', () => { expect(mockPrepareReviewPayload).toHaveBeenCalledWith({ reviewId: eligibleReview.id, owner: { type: 'user', id: testUser.id, userId: testUser.id }, - agentConfig: { id: 'test-agent-config', config: {} }, + agentConfig: { id: 'test-agent-config', config: {}, is_enabled: true, runtime_state: {} }, platform: 'github', }); expect(mockPrepareReviewPayload).not.toHaveBeenCalledWith( @@ -617,7 +787,7 @@ describe('tryDispatchPendingReviews', () => { expect(mockPrepareReviewPayload).toHaveBeenCalledWith({ reviewId: review.id, owner: { type: 'user', id: testUser.id, userId: testUser.id }, - agentConfig: { id: 'test-agent-config', config: {} }, + agentConfig: { id: 'test-agent-config', config: {}, is_enabled: true, runtime_state: {} }, platform: 'github', }); }); @@ -933,7 +1103,7 @@ describe('tryDispatchPendingReviews', () => { expect(mockPrepareReviewPayload).toHaveBeenCalledWith({ reviewId: pendingReview.id, owner: { type: 'user', id: testUser.id, userId: testUser.id }, - agentConfig: { id: 'test-agent-config', config: {} }, + agentConfig: { id: 'test-agent-config', config: {}, is_enabled: true, runtime_state: {} }, platform: 'github', }); expect(mockPrepareReviewPayload).not.toHaveBeenCalledWith( diff --git a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts index ec98386242..8c35d6cc6a 100644 --- a/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts +++ b/apps/web/src/lib/code-reviews/dispatch/dispatch-pending-reviews.ts @@ -34,6 +34,21 @@ import { captureException } from '@sentry/nextjs'; import { errorExceptInTest, logExceptInTest } from '@/lib/utils.server'; import { codeReviewWorkerClient } from '../client/code-review-worker-client'; import type { CodeReviewPlatform } from '../core/schemas'; +import { getIntegrationById } from '@/lib/integrations/db/platform-integrations'; +import { updateCheckRun } from '@/lib/integrations/platforms/github/adapter'; +import { APP_URL } from '@/lib/constants'; +import { + CODE_REVIEW_TERMINAL_REASONS, + type CodeReviewTerminalReason, +} from '@kilocode/db/schema-types'; +import { + classifyCodeReviewActionRequiredFailure, + disableCodeReviewForActionRequiredFailure, + getCodeReviewActionRequiredCopy, + getCodeReviewActionRequiredState, + isCodeReviewActionRequiredReason, + type CodeReviewActionRequiredReason, +} from '../action-required'; import { activeCodeReviewWorkCondition, reconsiderableCodeReviewWorkCondition, @@ -71,6 +86,62 @@ type ReviewReservationBatch = { reservations: ReservedReview[]; }; +class CodeReviewActionRequiredDispatchError extends Error { + readonly reason: CodeReviewActionRequiredReason; + + constructor(reason: CodeReviewActionRequiredReason) { + super(getCodeReviewActionRequiredCopy(reason).description); + this.name = 'CodeReviewActionRequiredDispatchError'; + this.reason = reason; + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getActionRequiredReasonFromError(error: unknown): CodeReviewActionRequiredReason | null { + if (error instanceof CodeReviewActionRequiredDispatchError) { + return error.reason; + } + + return classifyCodeReviewActionRequiredFailure(getErrorMessage(error)); +} + +function parseTerminalReason(reason?: string): CodeReviewTerminalReason | undefined { + return CODE_REVIEW_TERMINAL_REASONS.find(candidate => candidate === reason); +} + +async function finalizeActionRequiredGateCheck( + review: CloudAgentCodeReview, + reason: CodeReviewActionRequiredReason +): Promise { + const platform: CodeReviewPlatform = review.platform === 'gitlab' ? 'gitlab' : 'github'; + if (platform !== 'github' || !review.check_run_id || !review.platform_integration_id) return; + + const integration = await getIntegrationById(review.platform_integration_id); + if (!integration?.platform_installation_id) return; + + const [repoOwner, repoName] = review.repo_full_name.split('/'); + const copy = getCodeReviewActionRequiredCopy(reason); + await updateCheckRun( + integration.platform_installation_id, + repoOwner, + repoName, + review.check_run_id, + { + status: 'completed', + conclusion: 'action_required', + detailsUrl: `${APP_URL}/code-reviews/${review.id}`, + output: { + title: copy.checkTitle, + summary: copy.checkSummary, + }, + }, + integration.github_app_type ?? 'standard' + ); +} + async function getMaxConcurrentReviewsForOwner( tx: DrizzleTransaction, owner: Owner @@ -230,6 +301,102 @@ export async function tryDispatchPendingReviews( } else { const reservation = reservations[i]; const error = result.reason; + const errorMessage = getErrorMessage(error); + const actionRequiredReason = getActionRequiredReasonFromError(error); + const actionRequiredStateAlreadyPresent = + error instanceof CodeReviewActionRequiredDispatchError; + + if (actionRequiredReason) { + if (!actionRequiredStateAlreadyPresent) { + logExceptInTest( + '[tryDispatchPendingReviews] Disabling Code Reviewer after action-required failure', + { + reviewId: reservation.review.id, + owner, + reason: actionRequiredReason, + } + ); + + try { + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: reservation.review.platform === 'gitlab' ? 'gitlab' : 'github', + reviewId: reservation.review.id, + reason: actionRequiredReason, + errorMessage, + }); + } catch (disableError) { + errorExceptInTest('[tryDispatchPendingReviews] Failed to disable Code Reviewer', { + reviewId: reservation.review.id, + owner, + reason: actionRequiredReason, + disableError, + }); + captureException(disableError, { + tags: { operation: 'disable-code-review-action-required' }, + extra: { reviewId: reservation.review.id, owner, reason: actionRequiredReason }, + }); + } + } + + try { + await failReservedQueuedReview( + reservation.review.id, + reservation.dispatchReservationId, + `Dispatch failed: ${getCodeReviewActionRequiredCopy(actionRequiredReason).description}`, + actionRequiredReason + ); + } catch (updateError) { + errorExceptInTest( + '[tryDispatchPendingReviews] Failed to mark review as action-required', + { + reviewId: reservation.review.id, + updateError, + } + ); + try { + const released = await releaseQueuedReviewClaim( + reservation.review.id, + reservation.dispatchReservationId + ); + logExceptInTest( + '[tryDispatchPendingReviews] Released action-required review reservation', + { + reviewId: reservation.review.id, + released, + } + ); + } catch (releaseError) { + errorExceptInTest( + '[tryDispatchPendingReviews] Failed to release action-required review reservation', + { + reviewId: reservation.review.id, + releaseError, + } + ); + captureException(releaseError, { + tags: { operation: 'release-action-required-review-reservation' }, + extra: { reviewId: reservation.review.id, owner }, + }); + } + continue; + } + + try { + await finalizeActionRequiredGateCheck(reservation.review, actionRequiredReason); + } catch (updateError) { + errorExceptInTest( + '[tryDispatchPendingReviews] Failed to finalize action-required check run', + { + reviewId: reservation.review.id, + updateError, + } + ); + } + + continue; + } + errorExceptInTest('[tryDispatchPendingReviews] Failed to dispatch review', { reviewId: reservation.review.id, error, @@ -243,7 +410,7 @@ export async function tryDispatchPendingReviews( await failReservedQueuedReview( reservation.review.id, reservation.dispatchReservationId, - `Dispatch failed: ${error instanceof Error ? error.message : String(error)}` + `Dispatch failed: ${errorMessage}` ); } catch (updateError) { errorExceptInTest('[tryDispatchPendingReviews] Failed to mark review as failed', { @@ -300,6 +467,15 @@ async function dispatchReservedReview(reservation: ReservedReview, owner: Owner) ); } + const actionRequiredState = getCodeReviewActionRequiredState(agentConfig); + if (actionRequiredState) { + throw new CodeReviewActionRequiredDispatchError(actionRequiredState.reason); + } + + if (!agentConfig.is_enabled) { + throw new Error(`Code Reviewer is disabled for owner ${owner.type}:${owner.id} on ${platform}`); + } + const payload = await prepareReviewPayload({ reviewId: review.id, owner, @@ -412,6 +588,36 @@ async function handleAmbiguousDispatchFailure( } const completedAt = workerStatus.completedAt ? new Date(workerStatus.completedAt) : undefined; + const workerTerminalReason = parseTerminalReason(workerStatus.terminalReason); + const classifiedReason = classifyCodeReviewActionRequiredFailure(workerStatus.errorMessage); + const terminalReason = workerTerminalReason ?? classifiedReason ?? undefined; + const actionRequiredReason = isCodeReviewActionRequiredReason(workerTerminalReason) + ? workerTerminalReason + : classifiedReason; + + if (actionRequiredReason) { + try { + await disableCodeReviewForActionRequiredFailure({ + owner, + platform: review.platform === 'gitlab' ? 'gitlab' : 'github', + reviewId: review.id, + reason: actionRequiredReason, + errorMessage: workerStatus.errorMessage ?? actionRequiredReason, + }); + await finalizeActionRequiredGateCheck(review, actionRequiredReason); + } catch (disableError) { + errorExceptInTest('[dispatchReview] Failed to disable Code Reviewer', { + reviewId: review.id, + reason: actionRequiredReason, + disableError, + }); + captureException(disableError, { + tags: { operation: 'dispatch-review-action-required-disable' }, + extra: { reviewId: review.id, owner, reason: actionRequiredReason }, + }); + } + } + await updateCodeReviewAttemptForCallback({ codeReviewId: review.id, attemptId, @@ -419,6 +625,7 @@ async function handleAmbiguousDispatchFailure( sessionId: workerStatus.sessionId, cliSessionId: workerStatus.cliSessionId, errorMessage: workerStatus.errorMessage, + terminalReason, completedAt, }); const parentUpdated = await updateCodeReviewStatusIfNonTerminal( @@ -428,6 +635,7 @@ async function handleAmbiguousDispatchFailure( sessionId: workerStatus.sessionId, cliSessionId: workerStatus.cliSessionId, errorMessage: workerStatus.errorMessage, + terminalReason, completedAt, }, dispatchReservationId diff --git a/apps/web/src/lib/email.ts b/apps/web/src/lib/email.ts index 8e4c66e344..d941246d65 100644 --- a/apps/web/src/lib/email.ts +++ b/apps/web/src/lib/email.ts @@ -17,6 +17,7 @@ export const subjects = { magicLink: 'Sign in to Kilo Code', balanceAlert: 'Kilo: Low Balance Alert', autoTopUpFailed: 'Kilo: Auto Top-Up Failed', + codeReviewDisabled: 'Action Required: Code Reviewer Disabled', ossInviteNewUser: 'Kilo: OSS Sponsorship Offer', ossInviteExistingUser: 'Kilo: OSS Sponsorship Offer', ossExistingOrgProvisioned: 'Kilo: OSS Sponsorship Offer', @@ -228,6 +229,21 @@ export async function sendAutoTopUpFailedEmail( }); } +export async function sendCodeReviewDisabledEmail( + to: string, + props: { reason: string; recoveryUrl: string; recoveryLabel: string } +): Promise { + return send({ + to, + templateName: 'codeReviewDisabled', + templateVars: { + reason: props.reason, + recovery_url: props.recoveryUrl, + recovery_label: props.recoveryLabel, + }, + }); +} + type SendDeploymentFailedEmailProps = { to: string; deployment_name: string; diff --git a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts index 06f8e7cbbc..e011306348 100644 --- a/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts +++ b/apps/web/src/lib/integrations/platforms/github/webhook-handlers/pull-request-handler.ts @@ -27,6 +27,7 @@ import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-wo import { updateCheckRunId } from '@/lib/code-reviews/db/code-reviews'; import { resolvePullRequestCheckoutRef } from './pull-request-checkout-ref'; import { APP_URL } from '@/lib/constants'; +import { getCodeReviewActionRequiredState } from '@/lib/code-reviews/action-required'; /** * GitHub Pull Request Event Handler @@ -114,7 +115,7 @@ export async function handlePullRequestCodeReview( // 2. Check if code review agent is enabled for this owner const agentConfig = await getAgentConfigForOwner(owner, 'code_review', 'github'); - if (!agentConfig || !agentConfig.is_enabled) { + if (!agentConfig || !agentConfig.is_enabled || getCodeReviewActionRequiredState(agentConfig)) { logExceptInTest( `Code review agent not enabled for ${owner.type} ${owner.id} (repo: ${repository.full_name})` ); diff --git a/apps/web/src/lib/purchase-emails.test.ts b/apps/web/src/lib/purchase-emails.test.ts index e72f2a2ace..ae2834c694 100644 --- a/apps/web/src/lib/purchase-emails.test.ts +++ b/apps/web/src/lib/purchase-emails.test.ts @@ -130,6 +130,23 @@ describe('subjects map', () => { test('includes transactional purchase templates', () => { expect(subjects.creditsTopUp).toBeTruthy(); expect(subjects.kiloClawSubscriptionStarted).toBeTruthy(); + expect(subjects.codeReviewDisabled).toBe('Action Required: Code Reviewer Disabled'); + }); +}); + +describe('codeReviewDisabled template', () => { + test('renders reason and recovery link', () => { + const html = renderTemplate('codeReviewDisabled', { + reason: 'The selected BYOK API key is invalid or has been revoked.', + recovery_url: 'https://app.kilocode.ai/byok', + recovery_label: 'Update BYOK settings', + year: '2026', + }); + + expect(html).toContain('Code Reviewer Disabled'); + expect(html).toContain('The selected BYOK API key is invalid or has been revoked.'); + expect(html).toContain('https://app.kilocode.ai/byok'); + expect(html).toContain('Update BYOK settings'); }); }); diff --git a/apps/web/src/routers/admin-code-reviews-router.ts b/apps/web/src/routers/admin-code-reviews-router.ts index 1363a0e546..5307fe2822 100644 --- a/apps/web/src/routers/admin-code-reviews-router.ts +++ b/apps/web/src/routers/admin-code-reviews-router.ts @@ -73,6 +73,7 @@ const excludeModelNotFoundAttempt = sql`COALESCE(${cloud_agent_code_review_attem * Pattern matching is ordered from most-specific to least-specific. */ const errorCategoryExpr = sql`CASE + WHEN ${cloud_agent_code_reviews.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key') THEN 'Action Required' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%429%' THEN 'Rate Limited' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_reviews.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_reviews.error_message} LIKE '%timed out%' THEN 'Timeout' WHEN ${cloud_agent_code_reviews.error_message} LIKE '%context window%' OR ${cloud_agent_code_reviews.error_message} LIKE '%token limit%' OR ${cloud_agent_code_reviews.error_message} LIKE '%too large%' OR ${cloud_agent_code_reviews.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' @@ -86,6 +87,7 @@ const errorCategoryExpr = sql`CASE END`; const attemptErrorCategoryExpr = sql`CASE + WHEN ${cloud_agent_code_review_attempts.terminal_reason} IN ('github_installation_required', 'github_ip_allow_list', 'byok_invalid_key') THEN 'Action Required' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Rate limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%429%' THEN 'Rate Limited' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%Timeout%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%ETIMEDOUT%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%timed out%' THEN 'Timeout' WHEN ${cloud_agent_code_review_attempts.error_message} LIKE '%context window%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%token limit%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%too large%' OR ${cloud_agent_code_review_attempts.error_message} LIKE '%maximum context length%' THEN 'Context Window Exceeded' diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts index 880f48aeea..f7b13cbbe4 100644 --- a/apps/web/src/routers/code-reviews-router.test.ts +++ b/apps/web/src/routers/code-reviews-router.test.ts @@ -300,6 +300,7 @@ describe('review agent config REVIEW.md setting', () => { const config = await caller.personalReviewAgent.getReviewConfig({ platform: 'github' }); expect(config.disableReviewMd).toBe(true); + expect(config.actionRequired).toBeNull(); }); it('returns disableReviewMd true for organization default config', async () => { @@ -311,6 +312,97 @@ describe('review agent config REVIEW.md setting', () => { }); expect(config.disableReviewMd).toBe(true); + expect(config.actionRequired).toBeNull(); + }); + + it('returns actionRequired runtime state for personal config', async () => { + const caller = await createCallerForUser(testUser.id); + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { disable_review_md: true }, + is_enabled: false, + created_by: testUser.id, + runtime_state: { + code_review_action_required: { + reason: 'byok_invalid_key', + detectedAt: '2026-05-28T00:00:00.000Z', + lastSeenAt: '2026-05-28T00:00:00.000Z', + lastErrorMessage: + 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', + }, + }, + }); + + const config = await caller.personalReviewAgent.getReviewConfig({ platform: 'github' }); + + expect(config.isEnabled).toBe(false); + expect(config.actionRequired).toEqual(expect.objectContaining({ reason: 'byok_invalid_key' })); + }); + + it('preserves disabled state when saving an existing personal config', async () => { + const caller = await createCallerForUser(testUser.id); + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { disable_review_md: true }, + is_enabled: false, + created_by: testUser.id, + }); + + await caller.personalReviewAgent.saveReviewConfig({ + platform: 'github', + reviewStyle: 'balanced', + focusAreas: [], + modelSlug: 'test-model', + disableReviewMd: true, + }); + + const config = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.platform, 'github'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + + expect(config?.is_enabled).toBe(false); + }); + + it('clears actionRequired state when toggling personal Code Reviewer', async () => { + const caller = await createCallerForUser(testUser.id); + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { disable_review_md: true }, + is_enabled: false, + created_by: testUser.id, + runtime_state: { + code_review_action_required: { + reason: 'github_installation_required', + detectedAt: '2026-05-28T00:00:00.000Z', + lastSeenAt: '2026-05-28T00:00:00.000Z', + lastErrorMessage: + 'Code Reviewer was disabled because Kilo cannot access this repository with an active GitHub App installation. Update the GitHub App installation, then enable Code Reviewer again.', + }, + }, + }); + + await caller.personalReviewAgent.toggleReviewAgent({ platform: 'github', isEnabled: true }); + + const config = await db.query.agent_configs.findFirst({ + where: and( + eq(agent_configs.agent_type, 'code_review'), + eq(agent_configs.platform, 'github'), + eq(agent_configs.owned_by_user_id, testUser.id) + ), + }); + + expect(config?.is_enabled).toBe(true); + expect(JSON.stringify(config?.runtime_state)).not.toContain('code_review_action_required'); }); it('persists personal disableReviewMd true as disable_review_md true', async () => { @@ -428,14 +520,29 @@ describe('codeReviewRouter attempts', () => { await db .delete(cloud_agent_code_reviews) .where(eq(cloud_agent_code_reviews.repo_full_name, REPO)); + await db.delete(agent_configs).where(eq(agent_configs.owned_by_user_id, testUser.id)); mockCancelReview.mockReset(); + mockTryDispatchPendingReviews.mockReset(); }); afterAll(async () => { await db.delete(kilocode_users).where(eq(kilocode_users.id, testUser.id)); }); + async function insertEnabledAgentConfig(runtimeState: Record = {}) { + await db.insert(agent_configs).values({ + owned_by_user_id: testUser.id, + agent_type: 'code_review', + platform: 'github', + config: { disable_review_md: true }, + is_enabled: true, + runtime_state: runtimeState, + created_by: testUser.id, + }); + } + it('returns attempts from get and preserves history during retrigger', async () => { + await insertEnabledAgentConfig(); const [review] = await db .insert(cloud_agent_code_reviews) .values( @@ -473,6 +580,7 @@ describe('codeReviewRouter attempts', () => { }); it('retrigger dispatches using the newly created attempt id', async () => { + await insertEnabledAgentConfig(); const [review] = await db .insert(cloud_agent_code_reviews) .values( @@ -498,6 +606,47 @@ describe('codeReviewRouter attempts', () => { expect(mockTryDispatchPendingReviews).toHaveBeenCalled(); }); + it('blocks retrigger while Code Reviewer has action-required state', async () => { + await insertEnabledAgentConfig({ + code_review_action_required: { + reason: 'byok_invalid_key', + detectedAt: '2026-05-28T00:00:00.000Z', + lastSeenAt: '2026-05-28T00:00:00.000Z', + lastErrorMessage: + 'Code Reviewer was disabled because the selected BYOK API key is invalid or has been revoked. Update the key or choose another model, then enable Code Reviewer again.', + }, + }); + const [review] = await db + .insert(cloud_agent_code_reviews) + .values( + reviewValues(testUser.id, 'failed', { + session_id: 'agent-first', + cli_session_id: 'ses_first', + error_message: 'Container shutdown: SIGTERM', + terminal_reason: 'sandbox_error', + }) + ) + .returning({ id: cloud_agent_code_reviews.id }); + + const caller = await createCallerForUser(testUser.id); + + await expect(caller.codeReviews.retrigger({ reviewId: review.id })).rejects.toThrow( + 'Code Reviewer is disabled because configuration needs attention' + ); + + const attempts = await db + .select() + .from(cloud_agent_code_review_attempts) + .where(eq(cloud_agent_code_review_attempts.code_review_id, review.id)); + const storedReview = await db.query.cloud_agent_code_reviews.findFirst({ + where: eq(cloud_agent_code_reviews.id, review.id), + }); + + expect(attempts).toHaveLength(0); + expect(storedReview?.status).toBe('failed'); + expect(mockTryDispatchPendingReviews).not.toHaveBeenCalled(); + }); + it('rejects stream info attempts from another review', async () => { const [review] = await db .insert(cloud_agent_code_reviews) diff --git a/apps/web/src/routers/code-reviews-router.ts b/apps/web/src/routers/code-reviews-router.ts index 7cff45bce3..4b8fcb028f 100644 --- a/apps/web/src/routers/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews-router.ts @@ -24,6 +24,10 @@ import { } from '@/lib/integrations/platforms/gitlab/webhook-sync'; import { getValidGitLabToken } from '@/lib/integrations/gitlab-service'; import { logExceptInTest } from '@/lib/utils.server'; +import { + clearCodeReviewActionRequiredState, + getCodeReviewActionRequiredState, +} from '@/lib/code-reviews/action-required'; const PlatformSchema = z.enum(['github', 'gitlab']).default('github'); @@ -164,6 +168,7 @@ export const personalReviewAgentRouter = createTRPCRouter({ selectedRepositoryIds: [], manuallyAddedRepositories: [], disableReviewMd: true, + actionRequired: null, }; } @@ -180,6 +185,7 @@ export const personalReviewAgentRouter = createTRPCRouter({ selectedRepositoryIds: cfg.selected_repository_ids || [], manuallyAddedRepositories: cfg.manually_added_repositories || [], disableReviewMd: cfg.disable_review_md ?? true, + actionRequired: getCodeReviewActionRequiredState(config), }; }), @@ -315,6 +321,7 @@ export const personalReviewAgentRouter = createTRPCRouter({ const platform = input.platform ?? 'github'; await setAgentEnabledForOwner(owner, 'code_review', platform, input.isEnabled); + await clearCodeReviewActionRequiredState({ owner, platform }); return { success: true, isEnabled: input.isEnabled }; } catch (error) { diff --git a/apps/web/src/routers/code-reviews/code-reviews-router.ts b/apps/web/src/routers/code-reviews/code-reviews-router.ts index eb6be425dc..fccfa5448b 100644 --- a/apps/web/src/routers/code-reviews/code-reviews-router.ts +++ b/apps/web/src/routers/code-reviews/code-reviews-router.ts @@ -49,6 +49,8 @@ import { DEFAULT_LIST_LIMIT } from '@/lib/code-reviews/core/constants'; import { codeReviewWorkerClient } from '@/lib/code-reviews/client/code-review-worker-client'; import { tryDispatchPendingReviews } from '@/lib/code-reviews/dispatch/dispatch-pending-reviews'; import { getBotUserId } from '@/lib/bot-users/bot-user-service'; +import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; +import { getCodeReviewActionRequiredState } from '@/lib/code-reviews/action-required'; import type { CloudAgentCodeReview } from '@kilocode/db/schema'; import { cliSessions, cli_sessions_v2 } from '@kilocode/db/schema'; import { isNewSession } from '@/lib/cloud-agent/session-type'; @@ -492,17 +494,6 @@ export const codeReviewRouter = createTRPCRouter({ }); } - const currentAttempt = await ensureCurrentCodeReviewAttemptFromReview(review); - - // Reset the review for retry - await resetCodeReviewForRetry(input.reviewId); - await createCodeReviewAttempt({ - codeReviewId: input.reviewId, - retryOfAttemptId: currentAttempt.id, - retryReason: 'manual_retrigger', - status: 'pending', - }); - // Build owner object for dispatch. // For org reviews, use the bot user ID so retrigger dispatch matches webhook-created reviews. let owner: Owner; @@ -517,6 +508,35 @@ export const codeReviewRouter = createTRPCRouter({ owner = { type: 'user', id: review.owned_by_user_id as string, userId: ctx.user.id }; } + const platform = review.platform === 'gitlab' ? 'gitlab' : 'github'; + const agentConfig = await getAgentConfigForOwner(owner, 'code_review', platform); + const actionRequiredState = getCodeReviewActionRequiredState(agentConfig); + if (actionRequiredState) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'Code Reviewer is disabled because configuration needs attention. Fix settings, enable Code Reviewer again, then retry this review.', + }); + } + + if (!agentConfig?.is_enabled) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Enable Code Reviewer before retrying this review.', + }); + } + + const currentAttempt = await ensureCurrentCodeReviewAttemptFromReview(review); + + // Reset the review for retry + await resetCodeReviewForRetry(input.reviewId); + await createCodeReviewAttempt({ + codeReviewId: input.reviewId, + retryOfAttemptId: currentAttempt.id, + retryReason: 'manual_retrigger', + status: 'pending', + }); + // Re-create PR gate check so status callbacks can update it. try { await recreatePRGateCheck(review); diff --git a/apps/web/src/routers/organizations/organization-code-reviews-router.ts b/apps/web/src/routers/organizations/organization-code-reviews-router.ts index 9986aee8d0..1874804379 100644 --- a/apps/web/src/routers/organizations/organization-code-reviews-router.ts +++ b/apps/web/src/routers/organizations/organization-code-reviews-router.ts @@ -31,6 +31,10 @@ import { } from '@/lib/integrations/platforms/gitlab/webhook-sync'; import { getValidGitLabToken } from '@/lib/integrations/gitlab-service'; import { logExceptInTest } from '@/lib/utils.server'; +import { + clearCodeReviewActionRequiredState, + getCodeReviewActionRequiredState, +} from '@/lib/code-reviews/action-required'; const PlatformSchema = z.enum(['github', 'gitlab']).default('github'); @@ -181,6 +185,7 @@ export const organizationReviewAgentRouter = createTRPCRouter({ selectedRepositoryIds: [], manuallyAddedRepositories: [], disableReviewMd: true, + actionRequired: null, }; } @@ -197,6 +202,7 @@ export const organizationReviewAgentRouter = createTRPCRouter({ selectedRepositoryIds: cfg.selected_repository_ids || [], manuallyAddedRepositories: cfg.manually_added_repositories || [], disableReviewMd: cfg.disable_review_md ?? true, + actionRequired: getCodeReviewActionRequiredState(config), }; }), @@ -346,8 +352,14 @@ export const organizationReviewAgentRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { try { const platform = input.platform ?? 'github'; + const owner = { + type: 'org' as const, + id: input.organizationId, + userId: ctx.user.id, + }; await setAgentEnabled(input.organizationId, 'code_review', platform, input.isEnabled); + await clearCodeReviewActionRequiredState({ owner, platform }); // Audit log await createAuditLog({ diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index d4e9c100d3..40312ece8c 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -1214,6 +1214,9 @@ export type StripeSubscriptionStatus = export const CODE_REVIEW_TERMINAL_REASONS = [ 'billing', 'model_not_found', + 'github_installation_required', + 'github_ip_allow_list', + 'byok_invalid_key', 'user_cancelled', 'superseded', 'interrupted', @@ -1237,6 +1240,9 @@ export type CodeReviewTerminalReason = (typeof CODE_REVIEW_TERMINAL_REASONS)[num export const CODE_REVIEW_BENIGN_TERMINAL_REASONS = [ 'billing', 'model_not_found', + 'github_installation_required', + 'github_ip_allow_list', + 'byok_invalid_key', 'user_cancelled', 'superseded', ] as const satisfies readonly CodeReviewTerminalReason[]; diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index 2c0717bf4b..12da76ec54 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -119,6 +119,9 @@ export type CloudAgentInterruptOutput = { export type CloudAgentTerminalReason = | 'billing' | 'model_not_found' + | 'github_installation_required' + | 'github_ip_allow_list' + | 'byok_invalid_key' | 'user_cancelled' | 'superseded' | 'interrupted' diff --git a/services/code-review-infra/src/types.ts b/services/code-review-infra/src/types.ts index 568c0ef6c8..78a07180da 100644 --- a/services/code-review-infra/src/types.ts +++ b/services/code-review-infra/src/types.ts @@ -104,6 +104,9 @@ export const InternalStatusResponseSchema = z.object({ .enum([ 'billing', 'model_not_found', + 'github_installation_required', + 'github_ip_allow_list', + 'byok_invalid_key', 'user_cancelled', 'superseded', 'interrupted',