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 a7e4d61eec..acf8a2ba9a 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 @@ -74,6 +74,9 @@ export function ReviewAgentPageClient({ ) ); const councilEntitled = councilEntitlement?.entitled ?? false; + // Same gate as the manual council UI: local dev, or an entitled org behind the rollout flag. + const councilUiEnabled = + localCodeReviewDevelopmentEnabled || (councilEntitled && !!councilFlagEnabled); const handlePlatformChange = (platform: Platform) => { const params = new URLSearchParams(); @@ -297,7 +300,11 @@ export function ReviewAgentPageClient({ - + @@ -397,6 +404,7 @@ export function ReviewAgentPageClient({ { + const owner = review.owned_by_organization_id + ? ({ type: 'org', id: review.owned_by_organization_id } as const) + : review.owned_by_user_id + ? ({ type: 'user', id: review.owned_by_user_id } as const) + : null; + if (!owner) return null; + const agentConfig = await getAgentConfigForOwner(owner, 'code_review', review.platform); + return agentConfig ? (agentConfig.config as CodeReviewAgentConfig) : null; +} + async function resolveTerminalOwner( review: CloudAgentCodeReview, reviewId: string @@ -1060,6 +1081,15 @@ export async function POST( // Code-owned council outcome for a completed council run. Set on whichever completion path // runs below, then used to drive the merge gate (the council LLM never sets `gateResult`). let councilResult: CodeReviewCouncilResult | null = null; + // Council-config source for AUTOMATED (webhook) council reviews, which carry no `manual_config`. + // Fetched once (only for completed council reviews without a manual config) and reused on + // whichever completion path runs. Manual/standard reviews skip the lookup. + const councilOrgAgentConfig: CodeReviewAgentConfig | null = + status === 'completed' && + review.review_type === 'council' && + getManualCodeReviewConfig(review) === null + ? await resolveOrgCodeReviewAgentConfig(review) + : null; if ( status === 'completed' && @@ -1076,6 +1106,7 @@ export async function POST( councilResult = computeCouncilResultForReview({ review, lastAssistantMessageText: rawPayload.lastAssistantMessageText, + orgAgentConfig: councilOrgAgentConfig, }); const completionResult = await finalizeCompletedCodeReviewWithAnalytics({ codeReviewId: reviewId, @@ -1370,6 +1401,7 @@ export async function POST( councilResult = await finalizeCouncilResultForReview({ review, lastAssistantMessageText: rawPayload.lastAssistantMessageText, + orgAgentConfig: councilOrgAgentConfig, }); } diff --git a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx index bb2152dff9..36ea5b2d18 100644 --- a/apps/web/src/components/code-reviews/ReviewConfigForm.tsx +++ b/apps/web/src/components/code-reviews/ReviewConfigForm.tsx @@ -33,6 +33,23 @@ import { import { useRefreshRepositories } from '@/hooks/useRefreshRepositories'; import { useOrganizationModels } from '@/components/cloud-agent/hooks/useOrganizationModels'; +import { CouncilSpecialistPicker } from './CouncilSpecialistPicker'; +import { + buildCouncilSpecialists, + councilSelectionsFromConfig, + countEnabledSelections, + defaultCouncilSelections, + type CouncilSpecialistSelection, +} from '@/lib/code-reviews/core/council-selection'; +import { + COUNCIL_AGGREGATION_STRATEGIES, + DEFAULT_COUNCIL_AGGREGATION_STRATEGY, + type CouncilAggregationStrategy, +} from '@kilocode/db/schema-types'; +import { + COUNCIL_MIN_SPECIALISTS, + formatAggregationStrategy, +} from '@kilocode/worker-utils/code-review-council'; import { ModelCombobox } from '@/components/shared/ModelCombobox'; import { cn } from '@/lib/utils'; import { RepositoryMultiSelect } from './RepositoryMultiSelect'; @@ -70,6 +87,8 @@ export type ReviewConfigFormProps = { organizationId?: string; platform?: Platform; gitlabStatusData?: GitLabStatusData; + /** Same gate as the manual council UI: local dev, or an entitled org behind the rollout flag. */ + councilUiEnabled?: boolean; }; // Labels/descriptions stay web-local; the ids/values themselves are derived @@ -116,6 +135,7 @@ export function ReviewConfigForm({ organizationId, platform = 'github', gitlabStatusData, + councilUiEnabled = false, }: ReviewConfigFormProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -234,8 +254,17 @@ export function ReviewConfigForm({ const [repositoryModelOverrides, setRepositoryModelOverrides] = useState< Map >(new Map()); - // Opt-in toggle for the per-repository model section. - const [overridesEnabled, setOverridesEnabled] = useState(false); + // Org-level council config (shared by every council-enabled repo) + per-repo opt-in set. + // Council is "on" when at least one repo opts in — there is no separate global toggle. + const [councilAggregation, setCouncilAggregation] = useState( + DEFAULT_COUNCIL_AGGREGATION_STRATEGY + ); + const [councilSelections, setCouncilSelections] = useState< + Record + >(() => defaultCouncilSelections()); + const [councilEnabledRepositoryIds, setCouncilEnabledRepositoryIds] = useState>( + new Set() + ); const [useReviewMd, setUseReviewMd] = useState(true); // GitLab-specific: auto-configure webhooks const [autoConfigureWebhooks, setAutoConfigureWebhooks] = useState(true); @@ -365,7 +394,20 @@ export function ReviewConfigForm({ } } setRepositoryModelOverrides(overridesMap); - setOverridesEnabled(overridesMap.size > 0); + const loadedCouncil = configData.council ?? null; + setCouncilAggregation( + loadedCouncil?.aggregation_strategy ?? DEFAULT_COUNCIL_AGGREGATION_STRATEGY + ); + setCouncilSelections( + loadedCouncil ? councilSelectionsFromConfig(loadedCouncil) : defaultCouncilSelections() + ); + setCouncilEnabledRepositoryIds( + new Set( + (configData.councilEnabledRepositoryIds ?? []).filter( + (repositoryId): repositoryId is number => typeof repositoryId === 'number' + ) + ) + ); setUseReviewMd(!(configData.disableReviewMd ?? false)); } }, [configData, isGitLab]); @@ -494,6 +536,41 @@ export function ReviewConfigForm({ } }; + // Master "Simple vs Advanced" switch: Advanced reveals per-repository overrides (repo selection, + // per-repo model/effort, per-repo council). GitLab has no "all" mode, so it is always Advanced. + const perRepoOverridesEnabled = isGitLab || repositorySelectionMode === 'selected'; + const handlePerRepoOverridesToggle = (enabled: boolean) => { + setRepositorySelectionMode(enabled ? 'selected' : 'all'); + }; + + // Council is on when 1+ *currently selected* repos opt in; the shared specialist picker then + // applies to all of them. A council opt-in only counts while its repository is still selected: + // removing a repo from the Repositories list stops rendering its council toggle, so without this + // intersection a stale id would keep the picker open, block save on the minimum-specialist check, + // and persist to council_enabled_repository_ids (silently re-activating council if the repo were + // re-added). Deriving from the live selection instead of mutating the set means re-selecting the + // repo within the same edit restores its prior opt-in. + const councilEnabledSelectedRepositoryIds = selectedRepositoryIds.filter(id => + councilEnabledRepositoryIds.has(id) + ); + const councilEnabled = councilEnabledSelectedRepositoryIds.length > 0; + const councilEnabledCount = countEnabledSelections(councilSelections); + const councilBelowMin = councilEnabled && councilEnabledCount < COUNCIL_MIN_SPECIALISTS; + // Only repositories the user actually selected can be configured per-repo (model + council), + // so the per-repo pickers never offer a repo that isn't being reviewed. + const selectedRepositories = selectableRepositories.filter(repo => + selectedRepositoryIds.includes(repo.id) + ); + + const handleCouncilRepoToggle = (repositoryId: number, enabled: boolean) => { + setCouncilEnabledRepositoryIds(prev => { + const next = new Set(prev); + if (enabled) next.add(repositoryId); + else next.delete(repositoryId); + return next; + }); + }; + const handleSave = () => { // Clear previous webhook sync result setWebhookSyncResult(null); @@ -507,7 +584,7 @@ export function ReviewConfigForm({ // Overrides are independent of the trigger selection mode — they apply whether // reviews run on all or selected repositories. Sent only when the per-repository // section is toggled on; toggling it off clears the overrides on save. - const repositoryModelOverridesPayload = overridesEnabled + const repositoryModelOverridesPayload = perRepoOverridesEnabled ? Array.from(repositoryModelOverrides.entries()).map(([repositoryId, value]) => ({ repositoryId, repoFullName: value.repoFullName, @@ -516,6 +593,33 @@ export function ReviewConfigForm({ })) : []; + // Council lives entirely inside Advanced Settings, so turning Advanced off clears it on save + // (same as per-repo model overrides), matching the "every repository uses the global settings" + // copy. Only guard/persist council when the section is actually shown (Advanced + entitled). + const councilActiveForSave = perRepoOverridesEnabled && councilUiEnabled && councilEnabled; + if (councilActiveForSave && councilBelowMin) { + toast.error('Council needs more specialists', { + description: `Select at least ${COUNCIL_MIN_SPECIALISTS} council specialists.`, + }); + return; + } + const councilPayload = councilActiveForSave + ? { + enabled: true, + aggregation_strategy: councilAggregation, + specialists: buildCouncilSpecialists(councilSelections), + } + : null; + // Gate on `councilActiveForSave` (not just `perRepoOverridesEnabled`) so `council` and its + // per-repo opt-ins are always written or cleared as a unit. Otherwise, if council becomes + // UI-unavailable (entitlement lapses or the flag is turned off) while Advanced stays on, an + // unrelated save would persist `council: null` alongside a non-empty opt-in list, leaving + // orphaned ids that would silently re-activate council if it were later re-enabled. When + // active, still persist only opt-ins for repos that are currently selected. + const councilEnabledRepositoryIdsPayload = councilActiveForSave + ? councilEnabledSelectedRepositoryIds + : []; + if (organizationId) { orgSaveMutation.mutate({ organizationId, @@ -530,6 +634,8 @@ export function ReviewConfigForm({ selectedRepositoryIds, manuallyAddedRepositories, repositoryModelOverrides: repositoryModelOverridesPayload, + council: councilPayload, + councilEnabledRepositoryIds: councilEnabledRepositoryIdsPayload, disableReviewMd: !useReviewMd, // GitLab-specific: auto-configure webhooks autoConfigureWebhooks: isGitLab ? autoConfigureWebhooks : undefined, @@ -621,6 +727,14 @@ export function ReviewConfigForm({ {/* Configuration Fields */}
+ {/* Global Settings — defaults inherited by every repository unless overridden below. */} +
+

Global Settings

+

+ Defaults applied to every repository unless overridden per repository below. +

+
+ {/* Default AI Model Selection */}
- {/* Repository Selection */} + {/* Focus Areas (global) */}
+ +

+ Select specific areas for the agent to pay special attention to +

+
+ {FOCUS_AREAS.map(area => ( +
+ handleFocusAreaToggle(area.id)} + /> +
+ +

{area.description}

+
+
+ ))} +
+
+ + {/* Custom Instructions (global) */} +
+ +