diff --git a/apps/web/.env.development.local.example b/apps/web/.env.development.local.example index cbf3103024..ff4cf41db1 100644 --- a/apps/web/.env.development.local.example +++ b/apps/web/.env.development.local.example @@ -1,6 +1,9 @@ # @url cloud-agent-next CLOUD_AGENT_NEXT_API_URL=http://localhost:8794 +# @from CALLBACK_TOKEN_SECRET +CALLBACK_TOKEN_SECRET= + # @override CLOUD_AGENT_R2_ATTACHMENTS_BUCKET_NAME=cloud-agent-attachments-dev 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 2e20cf501e..9cf1f183a5 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 @@ -36,6 +36,7 @@ type ReviewAgentPageClientProps = { successMessage?: string; errorMessage?: string; initialPlatform?: Platform; + localCodeReviewDevelopmentEnabled?: boolean; returnTo?: string; }; @@ -45,6 +46,7 @@ export function ReviewAgentPageClient({ successMessage, errorMessage, initialPlatform = 'github', + localCodeReviewDevelopmentEnabled = false, returnTo, }: ReviewAgentPageClientProps) { const trpc = useTRPC(); @@ -90,6 +92,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; const returnPath = returnTo ? `${returnTo}${returnTo.includes('?') ? '&' : '?'}code_reviewer_return=true` : null; @@ -179,7 +183,7 @@ export function ReviewAgentPageClient({ {/* GitHub Tab Content */} {/* GitHub App Required Alert */} - {!isGitHubAppInstalled && ( + {!isGitHubAppInstalled && !localCodeReviewDevelopmentEnabled && ( GitHub App Required @@ -215,7 +219,7 @@ export function ReviewAgentPageClient({ Jobs @@ -239,8 +243,14 @@ export function ReviewAgentPageClient({ - {isGitHubAppInstalled ? ( - + {canUseGitHubJobs ? ( + ) : ( @@ -276,7 +286,7 @@ export function ReviewAgentPageClient({ {/* GitLab Tab Content */} {/* GitLab Connection Required Alert */} - {!isGitLabConnected && ( + {!isGitLabConnected && !localCodeReviewDevelopmentEnabled && ( GitLab Connection Required @@ -312,7 +322,7 @@ export function ReviewAgentPageClient({ Jobs @@ -345,8 +355,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 11594f3a30..95541bf4e4 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'; import { validateReturnPath } from '@/lib/integrations/validate-return-path'; @@ -15,6 +16,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(); const returnTo = search.returnTo ? validateReturnPath(search.returnTo) : null; return ( @@ -27,6 +29,7 @@ export default async function ReviewAgentPage({ params, searchParams }: ReviewAg successMessage={search.success} errorMessage={search.error} initialPlatform={platform} + localCodeReviewDevelopmentEnabled={localCodeReviewDevelopmentEnabled} returnTo={returnTo ?? undefined} /> )} 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..5e7a84c846 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, @@ -1367,6 +1368,39 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => { expect(mockTryDispatchPendingReviews).not.toHaveBeenCalled(); }); + it('does not retry assistant authorization failures as infra failures', async () => { + const retryFlow = mockCreatedInfraRetryFlow({ + failedAttemptId: '00000000-0000-0000-0000-000000000207', + retryAttemptId: '00000000-0000-0000-0000-000000000208', + sessionId: 'agent-auth-failed-old', + }); + mockGetCodeReviewById.mockResolvedValue( + makeReview({ status: 'running', session_id: retryFlow.sessionId }) + ); + + const response = await POST( + makeRequest({ + status: 'failed', + cloudAgentSessionId: retryFlow.sessionId, + errorMessage: 'Assistant request was not authorized', + terminalReason: 'upstream_error', + }), + makeParams(REVIEW_ID) + ); + + expect(response.status).toBe(200); + expect(mockCreateInfraRetryAttemptIfMissing).not.toHaveBeenCalled(); + expect(mockRetryReviewFresh).not.toHaveBeenCalled(); + expect(mockUpdateCodeReviewStatus).toHaveBeenCalledWith( + REVIEW_ID, + 'failed', + expect.objectContaining({ + errorMessage: 'Assistant request was not authorized', + terminalReason: 'upstream_error', + }) + ); + }); + it.each([ 'prepareSession failed (400): {"error":{"message":"[\n {\n "origin": "string",\n "code": "invalid_format",\n "format": "regex"\n }\n]","code":-32600,"data":{"code":"BAD_REQUEST","httpStatus":400,"path":"prepareSession"}}}', 'prepareSession failed (500): {"error":{"message":"Unexpected prepareSession server failure","code":-32603,"data":{"code":"INTERNAL_SERVER_ERROR","httpStatus":500,"path":"prepareSession"}}}', 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..379b91a2f8 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({ @@ -407,6 +411,8 @@ function hasKnownUnretryableFailureMessage(errorMessage?: string | null): boolea return ( message.includes('maximum runtime') || + message.includes('assistant request was not authorized') || + /\b(unauthorized|authentication|authorization|forbidden|401|403)\b/i.test(message) || /\b(cancelled|canceled)\b/i.test(message) || message.includes('superseded') || message.includes('user interrupted') || @@ -1012,6 +1018,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 +1342,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 +1364,7 @@ export async function POST( }); } } - } else if (status === 'failed') { + } else if (status === 'failed' && !isManualReview) { const ownerResolution = await getTerminalOwnerResolution(); if (ownerResolution) { try { @@ -1381,9 +1391,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..4b2364bfa7 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,321 @@ 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. +

+
+ )} + +
+ +