diff --git a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx index 432aaba5ed..f9d7b63323 100644 --- a/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx +++ b/apps/web/src/app/(app)/code-reviews/ReviewAgentPageClient.tsx @@ -28,12 +28,14 @@ type ReviewAgentPageClientProps = { successMessage?: string; errorMessage?: string; initialPlatform?: Platform; + localCodeReviewDevelopmentEnabled?: boolean; }; export function ReviewAgentPageClient({ successMessage, errorMessage, initialPlatform = 'github', + localCodeReviewDevelopmentEnabled = false, }: ReviewAgentPageClientProps) { const trpc = useTRPC(); const router = useRouter(); @@ -66,6 +68,8 @@ export function ReviewAgentPageClient({ const isGitHubAppInstalled = githubStatusData?.connected && githubStatusData?.integration?.isValid; const isGitLabConnected = gitlabStatusData?.connected && gitlabStatusData?.integration?.isValid; + const canUseGitHubJobs = isGitHubAppInstalled || localCodeReviewDevelopmentEnabled; + const canUseGitLabJobs = isGitLabConnected || localCodeReviewDevelopmentEnabled; // Show toast messages from URL params useEffect(() => { @@ -139,7 +143,7 @@ export function ReviewAgentPageClient({ {/* GitHub Tab Content */} {/* GitHub App Required Alert */} - {!isGitHubAppInstalled && ( + {!isGitHubAppInstalled && !localCodeReviewDevelopmentEnabled && ( GitHub App Required @@ -172,7 +176,7 @@ export function ReviewAgentPageClient({ Jobs @@ -192,8 +196,13 @@ export function ReviewAgentPageClient({ - {isGitHubAppInstalled ? ( - + {canUseGitHubJobs ? ( + ) : ( @@ -225,7 +234,7 @@ export function ReviewAgentPageClient({ {/* GitLab Tab Content */} {/* GitLab Connection Required Alert */} - {!isGitLabConnected && ( + {!isGitLabConnected && !localCodeReviewDevelopmentEnabled && ( GitLab Connection Required @@ -258,7 +267,7 @@ export function ReviewAgentPageClient({ Jobs @@ -286,8 +295,13 @@ export function ReviewAgentPageClient({ - {isGitLabConnected ? ( - + {canUseGitLabJobs ? ( + ) : ( diff --git a/apps/web/src/app/(app)/code-reviews/page.tsx b/apps/web/src/app/(app)/code-reviews/page.tsx index eaf6912382..fcd9f1974d 100644 --- a/apps/web/src/app/(app)/code-reviews/page.tsx +++ b/apps/web/src/app/(app)/code-reviews/page.tsx @@ -1,4 +1,5 @@ import { getUserFromAuthOrRedirect } from '@/lib/user/server'; +import { isLocalCodeReviewDevelopmentEnabled } from '@/lib/config.server'; import { ReviewAgentPageClient } from './ReviewAgentPageClient'; type ReviewAgentPageProps = { @@ -9,6 +10,7 @@ export default async function PersonalReviewAgentPage({ searchParams }: ReviewAg const search = await searchParams; const user = await getUserFromAuthOrRedirect('/users/sign_in?callbackPath=/code-reviews'); const platform = search.platform === 'gitlab' ? 'gitlab' : 'github'; + const localCodeReviewDevelopmentEnabled = isLocalCodeReviewDevelopmentEnabled(); return ( ); } 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 3acabb7a23..51e25f65d7 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 @@ -35,6 +35,7 @@ type ReviewAgentPageClientProps = { successMessage?: string; errorMessage?: string; initialPlatform?: Platform; + localCodeReviewDevelopmentEnabled?: boolean; }; export function ReviewAgentPageClient({ @@ -43,6 +44,7 @@ export function ReviewAgentPageClient({ successMessage, errorMessage, initialPlatform = 'github', + localCodeReviewDevelopmentEnabled = false, }: ReviewAgentPageClientProps) { const trpc = useTRPC(); const router = useRouter(); @@ -84,6 +86,8 @@ export function ReviewAgentPageClient({ const isGitHubAppInstalled = githubStatusData?.connected && githubStatusData?.integration?.isValid; const isGitLabConnected = gitlabStatusData?.connected && gitlabStatusData?.integration?.isValid; + const canUseGitHubJobs = isGitHubAppInstalled || localCodeReviewDevelopmentEnabled; + const canUseGitLabJobs = isGitLabConnected || localCodeReviewDevelopmentEnabled; // Show toast messages from URL params useEffect(() => { @@ -157,7 +161,7 @@ export function ReviewAgentPageClient({ {/* GitHub Tab Content */} {/* GitHub App Required Alert */} - {!isGitHubAppInstalled && ( + {!isGitHubAppInstalled && !localCodeReviewDevelopmentEnabled && ( GitHub App Required @@ -193,7 +197,7 @@ export function ReviewAgentPageClient({ Jobs @@ -217,8 +221,14 @@ export function ReviewAgentPageClient({ - {isGitHubAppInstalled ? ( - + {canUseGitHubJobs ? ( + ) : ( @@ -254,7 +264,7 @@ export function ReviewAgentPageClient({ {/* GitLab Tab Content */} {/* GitLab Connection Required Alert */} - {!isGitLabConnected && ( + {!isGitLabConnected && !localCodeReviewDevelopmentEnabled && ( GitLab Connection Required @@ -290,7 +300,7 @@ export function ReviewAgentPageClient({ Jobs @@ -323,8 +333,14 @@ export function ReviewAgentPageClient({ - {isGitLabConnected ? ( - + {canUseGitLabJobs ? ( + ) : ( diff --git a/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx b/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx index 01b2b82ee4..ffbc58e518 100644 --- a/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/code-reviews/page.tsx @@ -1,4 +1,5 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { isLocalCodeReviewDevelopmentEnabled } from '@/lib/config.server'; import { ReviewAgentPageClient } from './ReviewAgentPageClient'; type ReviewAgentPageProps = { @@ -9,6 +10,7 @@ type ReviewAgentPageProps = { export default async function ReviewAgentPage({ params, searchParams }: ReviewAgentPageProps) { const search = await searchParams; const platform = search.platform === 'gitlab' ? 'gitlab' : 'github'; + const localCodeReviewDevelopmentEnabled = isLocalCodeReviewDevelopmentEnabled(); return ( )} /> 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 42c58e5d1c..85d36dbd3d 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 @@ -249,6 +249,7 @@ function makeReview(overrides: Partial = {}): CloudAgentCo repository_review_instructions_truncated: false, previous_summary_body: null, previous_summary_head_sha: null, + manual_config: null, model: null, total_tokens_in: null, total_tokens_out: null, 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 8c518e443b..efbb199059 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 @@ -92,6 +92,10 @@ import { import type { Owner } from '@/lib/code-reviews/core'; import { parseCodeReviewAnalyticsManifest } from '@/lib/code-reviews/analytics/contracts'; import { finalizeCompletedCodeReviewWithAnalytics } from '@/lib/code-reviews/analytics/db'; +import { + getManualCodeReviewConfig, + shouldPublishCodeReviewToProvider, +} from '@/lib/code-reviews/manual-config'; const CallbackTextTruncationSchema = z .object({ @@ -1012,6 +1016,10 @@ export async function POST( return NextResponse.json({ error: 'Review not found' }, { status: 404 }); } + const manualConfig = getManualCodeReviewConfig(review); + const isManualReview = manualConfig !== null; + const shouldPublishToProvider = shouldPublishCodeReviewToProvider(review); + const callbackCompletedAt = new Date(); let attempt: CloudAgentCodeReviewAttempt; let latestAttempt = await getLatestCodeReviewAttempt(reviewId); @@ -1332,7 +1340,7 @@ export async function POST( let providerTerminalReason = terminalReason; const actionRequiredReason = status === 'failed' ? getActionRequiredTerminalReason(terminalReason, errorMessage) : null; - if (actionRequiredReason) { + if (actionRequiredReason && !isManualReview) { const ownerResolution = await getTerminalOwnerResolution(); if (ownerResolution) { try { @@ -1354,7 +1362,7 @@ export async function POST( }); } } - } else if (status === 'failed') { + } else if (status === 'failed' && !isManualReview) { const ownerResolution = await getTerminalOwnerResolution(); if (ownerResolution) { try { @@ -1381,9 +1389,10 @@ export async function POST( } // Fetch integration once — used for gate check updates and post-completion actions - const integration = review.platform_integration_id - ? await getIntegrationById(review.platform_integration_id) - : null; + const integration = + shouldPublishToProvider && review.platform_integration_id + ? await getIntegrationById(review.platform_integration_id) + : null; // Resolve GitLab token once, shared between gate check and reaction/footer logic const isGitLab = (review.platform || 'github') === PLATFORM.GITLAB; diff --git a/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx b/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx index 38afe75394..e939e8ffd0 100644 --- a/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx +++ b/apps/web/src/components/code-reviews/CodeReviewJobsCard.tsx @@ -1,10 +1,30 @@ 'use client'; -import { useState } from 'react'; +import { type ComponentType, type FormEvent, type ReactNode, useEffect, useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { ExternalLink, GitPullRequest, @@ -19,12 +39,20 @@ import { ChevronRight, RotateCcw, Ban, + Plus, } from 'lucide-react'; import { toast } from 'sonner'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { CodeReviewStreamView } from './CodeReviewStreamView'; +import { useOrganizationModels } from '@/components/cloud-agent/hooks/useOrganizationModels'; +import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; +import { PRIMARY_DEFAULT_MODEL } from '@/lib/ai-gateway/models'; +import { + getAvailableThinkingEfforts, + thinkingEffortLabel, +} from '@/lib/code-reviews/core/model-variants'; import { getCodeReviewActionRequiredCopy, getCodeReviewActionRequiredRecoveryHref, @@ -36,6 +64,9 @@ type Platform = 'github' | 'gitlab'; type CodeReviewJobsCardProps = { organizationId?: string; platform?: Platform; + localCodeReviewDevelopmentEnabled?: boolean; + defaultModelSlug?: string | null; + defaultThinkingEffort?: string | null; }; type CodeReviewStatus = @@ -50,7 +81,7 @@ type CodeReviewStatus = const statusConfig: Record< CodeReviewStatus, { - icon: React.ComponentType<{ className?: string }>; + icon: ComponentType<{ className?: string }>; variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; } @@ -65,20 +96,136 @@ const statusConfig: Record< }; const PAGE_SIZE = 10; +const DEFAULT_THINKING_EFFORT_VALUE = '__default__'; +const MANUAL_INSTRUCTIONS_MAX_LENGTH = 4_000; + +function getManualJobUrlError( + value: string, + platform: Platform, + localCodeReviewDevelopmentEnabled: boolean +): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return platform === 'gitlab' + ? 'Enter a GitLab merge request URL.' + : 'Enter a GitHub pull request URL.'; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed); + } catch { + return 'Enter a valid URL.'; + } + + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + return 'URL must use http or https.'; + } + + if (platform === 'github') { + const isGitHubPullRequest = + parsedUrl.hostname === 'github.com' && + /^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+\/?$/.test(parsedUrl.pathname); + return isGitHubPullRequest + ? null + : 'Enter a GitHub pull request URL like https://github.com/owner/repo/pull/123.'; + } + + const isGitLabMergeRequest = /\/-\/merge_requests\/\d+\/?$/.test(parsedUrl.pathname); + if (!isGitLabMergeRequest) { + return 'Enter a GitLab merge request URL like https://gitlab.com/group/project/-/merge_requests/123.'; + } + + if (localCodeReviewDevelopmentEnabled && parsedUrl.hostname !== 'gitlab.com') { + return 'Local GitLab jobs require a public gitlab.com merge request URL.'; + } + + return null; +} + +function selectInitialManualJobModel(params: { + configuredModelSlug?: string | null; + defaultModel?: string; + modelOptions: ModelOption[]; +}): string { + const configuredModelSlug = params.configuredModelSlug?.trim(); + if (configuredModelSlug && params.modelOptions.some(model => model.id === configuredModelSlug)) { + return configuredModelSlug; + } + if (params.defaultModel && params.modelOptions.some(model => model.id === params.defaultModel)) { + return params.defaultModel; + } + if (params.modelOptions.some(model => model.id === PRIMARY_DEFAULT_MODEL)) { + return PRIMARY_DEFAULT_MODEL; + } + return ( + params.modelOptions[0]?.id ?? + configuredModelSlug ?? + params.defaultModel ?? + PRIMARY_DEFAULT_MODEL + ); +} + +function selectInitialManualJobThinkingEffort( + configuredThinkingEffort: string | null | undefined, + modelSlug: string +): string | null { + if (!configuredThinkingEffort) { + return null; + } + return getAvailableThinkingEfforts(modelSlug).includes(configuredThinkingEffort) + ? configuredThinkingEffort + : null; +} export function CodeReviewJobsCard({ organizationId, platform = 'github', + localCodeReviewDevelopmentEnabled = false, + defaultModelSlug, + defaultThinkingEffort, }: CodeReviewJobsCardProps) { const [expandedReviewId, setExpandedReviewId] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [actionInProgressId, setActionInProgressId] = useState(null); + const [manualJobDialogOpen, setManualJobDialogOpen] = useState(false); + const [manualJobUrl, setManualJobUrl] = useState(''); + const [manualJobUrlTouched, setManualJobUrlTouched] = useState(false); + const [manualJobModelSlug, setManualJobModelSlug] = useState( + defaultModelSlug ?? PRIMARY_DEFAULT_MODEL + ); + const [manualJobThinkingEffort, setManualJobThinkingEffort] = useState(null); + const [manualJobInstructions, setManualJobInstructions] = useState(''); + const [manualJobSubmitted, setManualJobSubmitted] = useState(false); + const [manualJobSubmitError, setManualJobSubmitError] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); + const router = useRouter(); + const { modelOptions, isLoadingModels, defaultModel } = useOrganizationModels(organizationId); const offset = (currentPage - 1) * PAGE_SIZE; const prLabel = platform === 'gitlab' ? 'merge requests' : 'pull requests'; + const changeLabel = platform === 'gitlab' ? 'merge request' : 'pull request'; + const platformLabel = platform === 'gitlab' ? 'GitLab' : 'GitHub'; + const urlPlaceholder = + platform === 'gitlab' + ? 'https://gitlab.com/group/project/-/merge_requests/123' + : 'https://github.com/owner/repo/pull/123'; + const manualJobAvailableThinkingEfforts = getAvailableThinkingEfforts(manualJobModelSlug); + const manualJobUrlError = getManualJobUrlError( + manualJobUrl, + platform, + localCodeReviewDevelopmentEnabled + ); + const showManualJobUrlError = (manualJobUrlTouched || manualJobSubmitted) && manualJobUrlError; + const manualJobModelAllowed = modelOptions.some(model => model.id === manualJobModelSlug); + const manualJobModelError = + manualJobSubmitted && + !isLoadingModels && + (!manualJobModelSlug || modelOptions.length === 0 || !manualJobModelAllowed) + ? 'Select a model available for this account.' + : null; // Fetch code reviews with auto-refresh every 5 seconds if there are active jobs const { data, isLoading, isFetching } = useQuery({ @@ -103,6 +250,317 @@ export function CodeReviewJobsCard({ }, }); + const orgCreateManualReviewJobMutation = useMutation( + trpc.organizations.reviewAgent.createManualReviewJob.mutationOptions({ + onSuccess: data => { + void handleManualJobCreated(data); + }, + onError: error => { + setManualJobSubmitError(error.message); + toast.error('Could not start Code Reviewer job', { + description: error.message, + }); + }, + }) + ); + + const personalCreateManualReviewJobMutation = useMutation( + trpc.personalReviewAgent.createManualReviewJob.mutationOptions({ + onSuccess: data => { + void handleManualJobCreated(data); + }, + onError: error => { + setManualJobSubmitError(error.message); + toast.error('Could not start Code Reviewer job', { + description: error.message, + }); + }, + }) + ); + + const isManualJobSubmitting = organizationId + ? orgCreateManualReviewJobMutation.isPending + : personalCreateManualReviewJobMutation.isPending; + const manualJobSubmitDisabled = + isManualJobSubmitting || isLoadingModels || modelOptions.length === 0 || !manualJobModelAllowed; + + useEffect(() => { + if ( + manualJobThinkingEffort && + !getAvailableThinkingEfforts(manualJobModelSlug).includes(manualJobThinkingEffort) + ) { + setManualJobThinkingEffort(null); + } + }, [manualJobModelSlug, manualJobThinkingEffort]); + + useEffect(() => { + if (!manualJobDialogOpen || modelOptions.length === 0 || manualJobModelAllowed) { + return; + } + + const nextModelSlug = selectInitialManualJobModel({ + configuredModelSlug: defaultModelSlug, + defaultModel, + modelOptions, + }); + setManualJobModelSlug(nextModelSlug); + setManualJobThinkingEffort( + selectInitialManualJobThinkingEffort(defaultThinkingEffort, nextModelSlug) + ); + }, [ + defaultModel, + defaultModelSlug, + defaultThinkingEffort, + manualJobDialogOpen, + manualJobModelAllowed, + modelOptions, + ]); + + function resetManualJobForm() { + const nextModelSlug = selectInitialManualJobModel({ + configuredModelSlug: defaultModelSlug, + defaultModel, + modelOptions, + }); + setManualJobUrl(''); + setManualJobUrlTouched(false); + setManualJobModelSlug(nextModelSlug); + setManualJobThinkingEffort( + selectInitialManualJobThinkingEffort(defaultThinkingEffort, nextModelSlug) + ); + setManualJobInstructions(''); + setManualJobSubmitted(false); + setManualJobSubmitError(null); + } + + function handleManualJobDialogOpenChange(open: boolean) { + setManualJobDialogOpen(open); + if (open || !isManualJobSubmitting) { + resetManualJobForm(); + } + } + + async function invalidateJobsList() { + await queryClient.invalidateQueries({ + queryKey: organizationId + ? trpc.codeReviews.listForOrganization.queryKey({ + organizationId, + limit: PAGE_SIZE, + offset, + platform, + }) + : trpc.codeReviews.listForUser.queryKey({ limit: PAGE_SIZE, offset, platform }), + }); + } + + async function handleManualJobCreated(data: { + reviewId: string; + outputMode: 'provider' | 'kilo'; + }) { + toast.success('Code Reviewer job started', { + description: + data.outputMode === 'kilo' + ? 'Findings will appear in Kilo and will not be posted.' + : `Findings will be posted to ${platformLabel} when the job completes.`, + }); + await invalidateJobsList(); + setManualJobDialogOpen(false); + resetManualJobForm(); + router.push(`/code-reviews/${data.reviewId}`); + } + + function handleManualJobSubmit(event: FormEvent) { + event.preventDefault(); + setManualJobSubmitted(true); + setManualJobUrlTouched(true); + setManualJobSubmitError(null); + + if (manualJobUrlError) { + return; + } + + if (!manualJobModelSlug || modelOptions.length === 0 || !manualJobModelAllowed) { + return; + } + + const input = { + platform, + url: manualJobUrl.trim(), + modelSlug: manualJobModelSlug, + thinkingEffort: manualJobThinkingEffort, + instructions: manualJobInstructions.trim() || undefined, + }; + + if (organizationId) { + orgCreateManualReviewJobMutation.mutate({ organizationId, ...input }); + } else { + personalCreateManualReviewJobMutation.mutate(input); + } + } + + function renderJobsCardHeader(description: ReactNode) { + return ( + +
+ + + Code Review Jobs + + {description} +
+ +
+ ); + } + + const manualJobDialog = ( + + + + Start Code Reviewer job + + Review one {platformLabel} {changeLabel} with the selected model and instructions. + + +
+
+ {localCodeReviewDevelopmentEnabled && ( + + + Local read-only job + + Public github.com and gitlab.com changes are supported. Kilo will not post + comments, statuses, or reactions. + + + )} + + {manualJobSubmitError && ( + + + Can't start Code Reviewer job + {manualJobSubmitError} + + )} + +
+ + setManualJobUrl(event.target.value)} + onBlur={() => setManualJobUrlTouched(true)} + placeholder={urlPlaceholder} + aria-invalid={!!showManualJobUrlError} + aria-describedby={ + showManualJobUrlError + ? 'manual-code-review-url-error' + : 'manual-code-review-url-help' + } + disabled={isManualJobSubmitting} + /> + {showManualJobUrlError ? ( +

+ {manualJobUrlError} +

+ ) : ( +

+ Paste the {changeLabel} URL from the selected {platformLabel} tab. +

+ )} +
+ +
+ + {manualJobModelError && ( +

{manualJobModelError}

+ )} +
+ + {manualJobAvailableThinkingEfforts.length > 0 && ( +
+ + +

+ Configure the model's reasoning intensity for this job. +

+
+ )} + +
+ +