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.
+
+
+
+
+ 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',