Open Source Team Metrics based on PRs

deploy to test AI

Changed files
+1067 -56
app
api
organizations
[orgId]
ai-settings
webhook
github
dashboard
settings
components
lib
+92
app/api/organizations/[orgId]/ai-settings/route.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { auth } from '@/auth'; 3 + import { 4 + getOrganizationAiSettings, 5 + updateOrganizationAiSettings, 6 + AiSettings, 7 + UpdateAiSettingsPayload 8 + } from '@/lib/repositories'; 9 + import { getOrganizationRole } from '@/lib/repositories/user-repository'; 10 + 11 + // GET current AI settings for an organization 12 + export async function GET( 13 + request: NextRequest, 14 + { params }: { params: Promise<{ orgId: string }> } 15 + ) { 16 + const { orgId } = await params; 17 + const session = await auth(); 18 + 19 + if (!session || !session.user) { 20 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 21 + } 22 + 23 + const numericOrgId = parseInt(orgId); 24 + if (isNaN(numericOrgId)) { 25 + return NextResponse.json({ error: 'Invalid organization ID' }, { status: 400 }); 26 + } 27 + 28 + const role = await getOrganizationRole(session.user.id, numericOrgId); 29 + if (!role || (role !== 'admin' && role !== 'owner')) { 30 + return NextResponse.json({ error: 'Forbidden: User does not have permission to view settings for this organization' }, { status: 403 }); 31 + } 32 + 33 + try { 34 + const aiSettings: AiSettings = await getOrganizationAiSettings(numericOrgId); 35 + return NextResponse.json(aiSettings); 36 + } catch (error) { 37 + console.error(`Error fetching AI settings for org ${orgId}:`, error); 38 + return NextResponse.json({ error: 'Failed to fetch AI settings' }, { status: 500 }); 39 + } 40 + } 41 + 42 + // PUT (update) AI settings for an organization 43 + export async function PUT( 44 + request: NextRequest, 45 + { params }: { params: Promise<{ orgId: string }> } 46 + ) { 47 + const { orgId } = await params; 48 + const session = await auth(); 49 + 50 + if (!session || !session.user) { 51 + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 52 + } 53 + 54 + const numericOrgId = parseInt(orgId); 55 + if (isNaN(numericOrgId)) { 56 + return NextResponse.json({ error: 'Invalid organization ID' }, { status: 400 }); 57 + } 58 + 59 + const role = await getOrganizationRole(session.user.id, numericOrgId); 60 + if (!role || (role !== 'admin' && role !== 'owner')) { 61 + return NextResponse.json({ error: 'Forbidden: User does not have permission to update settings for this organization' }, { status: 403 }); 62 + } 63 + 64 + try { 65 + const body = await request.json() as UpdateAiSettingsPayload; 66 + 67 + // Basic validation for the new payload 68 + if (body.selectedModelId !== undefined && (typeof body.selectedModelId !== 'string' && body.selectedModelId !== null)) { 69 + return NextResponse.json({ error: 'Invalid selectedModelId value' }, { status: 400 }); 70 + } 71 + 72 + // Validate API keys: must be string or null if provided 73 + if (body.openaiApiKey !== undefined && typeof body.openaiApiKey !== 'string' && body.openaiApiKey !== null) { 74 + return NextResponse.json({ error: 'Invalid openaiApiKey value. Must be a string or null.' }, { status: 400 }); 75 + } 76 + if (body.googleApiKey !== undefined && typeof body.googleApiKey !== 'string' && body.googleApiKey !== null) { 77 + return NextResponse.json({ error: 'Invalid googleApiKey value. Must be a string or null.' }, { status: 400 }); 78 + } 79 + if (body.anthropicApiKey !== undefined && typeof body.anthropicApiKey !== 'string' && body.anthropicApiKey !== null) { 80 + return NextResponse.json({ error: 'Invalid anthropicApiKey value. Must be a string or null.' }, { status: 400 }); 81 + } 82 + 83 + await updateOrganizationAiSettings(numericOrgId, body); 84 + return NextResponse.json({ message: 'AI settings updated successfully' }); 85 + } catch (error) { 86 + console.error(`Error updating AI settings for org ${orgId}:`, error); 87 + if (error instanceof SyntaxError) { 88 + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); 89 + } 90 + return NextResponse.json({ error: 'Failed to update AI settings' }, { status: 500 }); 91 + } 92 + }
+167 -21
app/api/webhook/github/route.ts
··· 8 8 updatePullRequest, 9 9 createPullRequestReview, 10 10 findReviewByGitHubId, 11 - updatePullRequestReview 11 + updatePullRequestReview, 12 + updatePullRequestCategory, 13 + getOrganizationCategories, 14 + getOrganizationAiSettings, 15 + getOrganizationApiKey, 16 + findCategoryByNameAndOrg as findCategoryByNameAndOrgFromRepo 12 17 } from '@/lib/repositories'; 13 18 import { GitHubClient } from '@/lib/github'; 14 - import { PRReview } from '@/lib/types'; 19 + import { PRReview, Category } from '@/lib/types'; 20 + import { openai } from '@ai-sdk/openai'; 21 + import { generateText, CoreTool } from 'ai'; 22 + import { createOpenAI } from '@ai-sdk/openai'; 23 + import { createGoogleGenerativeAI } from '@ai-sdk/google'; 24 + import { createAnthropic } from '@ai-sdk/anthropic'; 25 + import { allModels } from '@/lib/ai-models'; // Import shared models 26 + 27 + export const runtime = 'nodejs'; 15 28 16 29 // GitHub webhook payload types 17 30 interface GitHubWebhookPayload { ··· 150 163 // Check if PR already exists in our database 151 164 const existingPR = await findPullRequestByNumber(repoInDb.id, pr.number); 152 165 153 - // For synchronization actions, we need to fetch detailed PR data 154 - let needsFurtherData = ['opened', 'reopened', 'edited', 'ready_for_review', 'converted_to_draft'].includes(action); 155 - 156 166 if (existingPR) { 157 167 // Update existing PR 158 168 await updatePullRequest(existingPR.id, { ··· 170 180 171 181 console.log(`Updated PR #${pr.number} in ${repository.full_name}`); 172 182 173 - // If significant action, fetch additional data 174 - if (needsFurtherData) { 175 - await fetchAdditionalPRData(repository, pr, existingPR.id); 183 + // Conditionally call fetchAdditionalPRData ONLY if action is 'opened' 184 + // (even for existing PRs, though 'opened' usually implies a new PR). 185 + // This handles cases like a PR being created, then a webhook failing, then succeeding on a retry 186 + // where the PR might now exist but the action is still 'opened' from GitHub's perspective. 187 + if (action === 'opened') { 188 + console.log(`PR #${pr.number} action is 'opened'. Preparing to fetch additional data.`); 189 + if (repoInDb.organization_id !== null) { 190 + await fetchAdditionalPRData(repository, pr, existingPR.id, repoInDb.organization_id); 191 + } else { 192 + console.warn(`Organization ID is null for repository ${repoInDb.full_name}. Skipping AI categorization.`); 193 + } 176 194 } 177 195 } else { 178 196 // Create new PR ··· 198 216 199 217 console.log(`Created new PR #${pr.number} in ${repository.full_name}`); 200 218 201 - // Fetch additional data for new PRs 202 - await fetchAdditionalPRData(repository, pr, newPR.id); 219 + // Fetch additional data for new PRs IFF the action was 'opened' 220 + if (action === 'opened') { 221 + console.log(`New PR #${pr.number} action is 'opened'. Preparing to fetch additional data.`); 222 + if (repoInDb.organization_id !== null) { 223 + await fetchAdditionalPRData(repository, pr, newPR.id, repoInDb.organization_id); 224 + } else { 225 + console.warn(`Organization ID is null for new PR in repository ${repoInDb.full_name}. Skipping AI categorization.`); 226 + } 227 + } else { 228 + // This case (new PR but action is not 'opened') should be rare for webhooks 229 + // unless it's a manual trigger or a very delayed/retried event. 230 + console.log(`New PR #${pr.number} created, but action was '${action}', not 'opened'. Skipping fetchAdditionalPRData.`); 231 + } 203 232 } 204 233 } 205 234 ··· 249 278 } 250 279 } 251 280 252 - async function fetchAdditionalPRData(repository: PullRequestPayload['repository'], pr: PullRequestPayload['pull_request'], prDbId: number) { 253 - // We need a GitHub token to fetch additional data 254 - // This is a challenge since the webhook doesn't provide a token 255 - // For now, we'll log this and handle it in a background job 256 - 257 - console.log(`Additional data should be fetched for PR #${pr.number} in ${repository.full_name} (DB ID: ${prDbId})`); 281 + async function fetchAdditionalPRData( 282 + repository: PullRequestPayload['repository'], 283 + pr: PullRequestPayload['pull_request'], 284 + prDbId: number, 285 + organizationId: number 286 + ) { 287 + console.log(`Fetching additional data for PR #${pr.number} in org ${organizationId}`); 288 + 289 + if (!organizationId) { 290 + console.error('Organization ID not provided to fetchAdditionalPRData. Cannot fetch AI settings.'); 291 + return; 292 + } 293 + 294 + // 1. Fetch Organization's AI Settings 295 + const aiSettings = await getOrganizationAiSettings(organizationId); 296 + if (!aiSettings || !aiSettings.selectedModelId) { 297 + console.log(`AI categorization disabled for organization ${organizationId} (no model selected).`); 298 + return; 299 + } 300 + 301 + const selectedModelId = aiSettings.selectedModelId; 302 + console.log(`Organization ${organizationId} selected AI model: ${selectedModelId}`); 303 + 304 + // 2. Determine Provider and API Key 305 + const modelInfo = allModels.find(m => m.id === selectedModelId); // Use imported allModels 306 + if (!modelInfo) { 307 + console.error(`Selected model ID ${selectedModelId} not found in shared allModels for organization ${organizationId}.`); 308 + return; 309 + } 310 + const provider = modelInfo.provider; 311 + console.log(`Determined provider: ${provider} for model ${selectedModelId}`); 312 + 313 + const apiKey = await getOrganizationApiKey(organizationId, provider); 314 + if (!apiKey) { 315 + console.warn(`API key for provider ${provider} not set for organization ${organizationId}. Skipping AI categorization.`); 316 + return; 317 + } 318 + console.log(`API key found for provider ${provider}.`); 319 + 320 + // 3. Instantiate AI Client 321 + let aiClientProvider; 322 + try { 323 + switch (provider) { 324 + case 'openai': 325 + aiClientProvider = createOpenAI({ apiKey: apiKey }); 326 + break; 327 + case 'google': 328 + aiClientProvider = createGoogleGenerativeAI({ apiKey: apiKey }); 329 + break; 330 + case 'anthropic': 331 + aiClientProvider = createAnthropic({ apiKey: apiKey }); 332 + break; 333 + default: 334 + console.error(`Unsupported AI provider: ${provider} for organization ${organizationId}`); 335 + return; 336 + } 337 + } catch (error) { 338 + console.error(`Error instantiating AI client provider for ${provider}:`, error); 339 + return; 340 + } 341 + console.log(`AI client provider instantiated for: ${provider}`); 258 342 259 - // In the future, we can implement a background job that uses a service account token to fetch: 260 - // 1. PR files 261 - // 2. PR commits 262 - // 3. PR reviews 263 - // 4. Any other detailed data needed for AI processing 343 + // Get the specific model instance from the provider 344 + const modelInstance = aiClientProvider(selectedModelId); 345 + if (!modelInstance) { 346 + console.error(`Could not get model instance for ${selectedModelId} from provider ${provider}`); 347 + return; 348 + } 349 + 350 + // Placeholder for GitHub App authentication to fetch PR diff 351 + // This part needs robust auth, e.g., creating an installation token 352 + const githubToken = process.env.GITHUB_APP_INSTALLATION_TOKEN || process.env.GITHUB_SYSTEM_TOKEN; 353 + if (!githubToken) { 354 + console.warn('GitHub token not available, cannot fetch PR diff.'); 355 + // Potentially proceed without diff, or return if diff is critical 356 + return; 357 + } 358 + const githubClient = new GitHubClient(githubToken); 359 + 360 + try { 361 + console.log(`Fetching PR diff for ${repository.full_name}#${pr.number}`); 362 + const diff = await githubClient.getPullRequestDiff(repository.owner.login, repository.name, pr.number); 363 + 364 + if (!diff) { 365 + console.warn(`Could not fetch PR diff for ${repository.full_name}#${pr.number}. Skipping categorization.`); 366 + return; 367 + } 368 + 369 + const orgCategories = await getOrganizationCategories(organizationId); 370 + const categoryNames = orgCategories.map(c => c.name); 371 + 372 + if (categoryNames.length === 0) { 373 + console.warn(`No categories found for organization ${organizationId}. Skipping categorization.`); 374 + return; 375 + } 376 + 377 + const systemPrompt = `You are an expert at categorizing GitHub pull requests. Analyze the pull request title, body, and diff. Respond with the most relevant category from the provided list and a confidence score (0-1). Available categories: ${categoryNames.join(', ')}. Respond in the format: Category: [Selected Category], Confidence: [Score]. Example: Category: Bug Fix, Confidence: 0.9`; 378 + const userPrompt = `Title: ${pr.title}\nBody: ${pr.body || ''}\nDiff:\n${diff}`; 379 + 380 + console.log(`Generating text with model ${selectedModelId} for PR #${pr.number}`); 381 + 382 + const { text } = await generateText({ 383 + model: modelInstance, 384 + system: systemPrompt, 385 + prompt: userPrompt, 386 + }); 387 + 388 + console.log(`AI Response for PR #${pr.number}: ${text}`); 389 + 390 + const categoryMatch = text.match(/Category: (.*?), Confidence: (\d\.?\d*)/i); 391 + if (categoryMatch && categoryMatch[1] && categoryMatch[2]) { 392 + const categoryName = categoryMatch[1].trim(); 393 + const confidence = parseFloat(categoryMatch[2]); 394 + 395 + const category = await findCategoryByNameAndOrgFromRepo(organizationId, categoryName); 396 + if (category) { 397 + await updatePullRequestCategory(prDbId, category.id, confidence); 398 + console.log(`Categorized PR #${pr.number} as '${categoryName}' with confidence ${confidence}`); 399 + } else { 400 + console.warn(`AI suggested category '${categoryName}' not found for organization ${organizationId}.`); 401 + } 402 + } else { 403 + console.warn(`Could not parse category and confidence from AI response for PR #${pr.number}: ${text}`); 404 + } 405 + 406 + } catch (error) { 407 + console.error(`Error during AI categorization for PR #${pr.number}:`, error); 408 + // Decide if this error should halt further processing or just be logged 409 + } 264 410 } 265 411 266 412 // Helper to map GitHub review state to our enum
+2 -13
app/dashboard/settings/page.tsx
··· 19 19 import { IconRefresh } from "@tabler/icons-react" 20 20 import { ReloadOrganizationsButton } from "@/components/ui/reload-organizations-button" 21 21 import { OrganizationSettingsTab } from "@/components/ui/organization-settings-tab" 22 + import { AiSettingsTab } from "@/components/ui/ai-settings-tab" 22 23 23 24 export default function SettingsPage() { 24 25 // GitHub App installation URL (replace with your app's actual URL if needed) ··· 88 89 <OrganizationSettingsTab /> 89 90 </TabsContent> 90 91 <TabsContent value="ai"> 91 - <Card> 92 - <CardHeader> 93 - <CardTitle>AI Settings (Coming Soon)</CardTitle> 94 - <CardDescription> 95 - Configure AI-powered PR categorization and analytics features. 96 - </CardDescription> 97 - </CardHeader> 98 - <CardContent> 99 - <p className="text-muted-foreground text-sm"> 100 - More AI and automation options will be available here in the future. 101 - </p> 102 - </CardContent> 103 - </Card> 92 + <AiSettingsTab /> 104 93 </TabsContent> 105 94 </Tabs> 106 95 </div>
+321
components/ui/ai-settings-tab.tsx
··· 1 + 'use client'; 2 + 3 + import React, { useEffect, useState, useCallback } from 'react'; 4 + import { useSession } from 'next-auth/react'; 5 + import { Button } from '@/components/ui/button'; 6 + import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 7 + import { Label } from '@/components/ui/label'; 8 + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 9 + import { Input } from '@/components/ui/input'; 10 + import { toast } from 'sonner'; 11 + import { AiSettings as FetchedAiSettings, UpdateAiSettingsPayload } from '@/lib/repositories'; 12 + import { Organization } from '@/lib/types'; 13 + import { Avatar, AvatarImage, AvatarFallback } from './avatar'; 14 + import { allModels, ModelDefinition } from '@/lib/ai-models'; // Import shared models and definition 15 + 16 + export function AiSettingsTab() { 17 + const { data: session, status: sessionStatus } = useSession(); 18 + const [organizations, setOrganizations] = useState<Organization[]>([]); 19 + const [selectedOrganization, setSelectedOrganization] = useState<Organization | null>(null); 20 + 21 + const [fetchedSettings, setFetchedSettings] = useState<FetchedAiSettings | null>(null); 22 + 23 + const [selectedModelId, setSelectedModelId] = useState<string | null>(null); 24 + const [openaiApiKeyInput, setOpenaiApiKeyInput] = useState(''); 25 + const [googleApiKeyInput, setGoogleApiKeyInput] = useState(''); 26 + const [anthropicApiKeyInput, setAnthropicApiKeyInput] = useState(''); 27 + 28 + const [isLoadingSettings, setIsLoadingSettings] = useState(false); 29 + const [isSaving, setIsSaving] = useState(false); 30 + 31 + useEffect(() => { 32 + if (session?.organizations && Array.isArray(session.organizations)) { 33 + setOrganizations(session.organizations as Organization[]); 34 + } 35 + }, [session]); 36 + 37 + useEffect(() => { 38 + async function fetchSettings() { 39 + if (!selectedOrganization) return; 40 + setIsLoadingSettings(true); 41 + setFetchedSettings(null); 42 + setSelectedModelId(null); 43 + setOpenaiApiKeyInput(''); 44 + setGoogleApiKeyInput(''); 45 + setAnthropicApiKeyInput(''); 46 + try { 47 + const response = await fetch(`/api/organizations/${selectedOrganization.id}/ai-settings`); 48 + if (!response.ok) { 49 + throw new Error('Failed to fetch AI settings'); 50 + } 51 + const data: FetchedAiSettings = await response.json(); 52 + setFetchedSettings(data); 53 + setSelectedModelId(data.selectedModelId); 54 + } catch (error) { 55 + toast.error(error instanceof Error ? error.message : 'Could not load AI settings.'); 56 + } finally { 57 + setIsLoadingSettings(false); 58 + } 59 + } 60 + if (selectedOrganization?.id) { 61 + fetchSettings(); 62 + } else { 63 + setFetchedSettings(null); 64 + setSelectedModelId(null); 65 + setOpenaiApiKeyInput(''); 66 + setGoogleApiKeyInput(''); 67 + setAnthropicApiKeyInput(''); 68 + setIsLoadingSettings(false); 69 + } 70 + }, [selectedOrganization]); 71 + 72 + const currentSelectedModelDetails = selectedModelId ? allModels.find(m => m.id === selectedModelId) : null; 73 + 74 + const handleSave = async () => { 75 + if (!selectedOrganization) return; 76 + setIsSaving(true); 77 + 78 + const payload: UpdateAiSettingsPayload = { 79 + selectedModelId: selectedModelId, 80 + }; 81 + 82 + const modelDetails = selectedModelId ? allModels.find(m => m.id === selectedModelId) : null; 83 + 84 + if (modelDetails) { 85 + let currentInput = ''; 86 + let apiKeyCurrentlySet = false; 87 + let apiKeyPayloadKey: keyof UpdateAiSettingsPayload | null = null; 88 + 89 + switch (modelDetails.provider) { 90 + case 'openai': 91 + currentInput = openaiApiKeyInput; 92 + apiKeyCurrentlySet = !!fetchedSettings?.isOpenAiKeySet; 93 + apiKeyPayloadKey = 'openaiApiKey'; 94 + break; 95 + case 'google': 96 + currentInput = googleApiKeyInput; 97 + apiKeyCurrentlySet = !!fetchedSettings?.isGoogleKeySet; 98 + apiKeyPayloadKey = 'googleApiKey'; 99 + break; 100 + case 'anthropic': 101 + currentInput = anthropicApiKeyInput; 102 + apiKeyCurrentlySet = !!fetchedSettings?.isAnthropicKeySet; 103 + apiKeyPayloadKey = 'anthropicApiKey'; 104 + break; 105 + } 106 + 107 + if (apiKeyPayloadKey) { 108 + let keyForPayload: string | null | undefined = undefined; 109 + 110 + if (currentInput) { // User typed something 111 + keyForPayload = currentInput; 112 + } else { // Input is blank 113 + if (apiKeyCurrentlySet) { // Key was set, and input is now blank 114 + keyForPayload = null; // Send null to clear 115 + } 116 + // If input is blank AND key was NOT set, keyForPayload remains undefined 117 + } 118 + 119 + // Only add to payload if keyForPayload is a string or null (but not undefined) 120 + if (keyForPayload !== undefined) { 121 + payload[apiKeyPayloadKey] = keyForPayload; 122 + } 123 + } 124 + } 125 + 126 + try { 127 + const response = await fetch(`/api/organizations/${selectedOrganization.id}/ai-settings`, { 128 + method: 'PUT', 129 + headers: { 'Content-Type': 'application/json' }, 130 + body: JSON.stringify(payload), 131 + }); 132 + if (!response.ok) { 133 + const errorData = await response.json(); 134 + throw new Error(errorData.error || 'Failed to save AI settings'); 135 + } 136 + toast.success('AI settings saved successfully!'); 137 + const fetchResponse = await fetch(`/api/organizations/${selectedOrganization.id}/ai-settings`); 138 + const data: FetchedAiSettings = await fetchResponse.json(); 139 + setFetchedSettings(data); 140 + setSelectedModelId(data.selectedModelId); 141 + setOpenaiApiKeyInput(''); 142 + setGoogleApiKeyInput(''); 143 + setAnthropicApiKeyInput(''); 144 + 145 + } catch (error) { 146 + toast.error(error instanceof Error ? error.message : 'Could not save AI settings.'); 147 + } finally { 148 + setIsSaving(false); 149 + } 150 + }; 151 + 152 + const getApiKeyInputProps = (provider: 'openai' | 'google' | 'anthropic') => { 153 + let value = ''; 154 + let onChange: (e: React.ChangeEvent<HTMLInputElement>) => void = () => {}; 155 + let isSet = false; 156 + let placeholder = 'Enter API Key'; 157 + 158 + if (provider === 'openai') { 159 + value = openaiApiKeyInput; 160 + onChange = (e) => setOpenaiApiKeyInput(e.target.value); 161 + isSet = !!fetchedSettings?.isOpenAiKeySet; 162 + } else if (provider === 'google') { 163 + value = googleApiKeyInput; 164 + onChange = (e) => setGoogleApiKeyInput(e.target.value); 165 + isSet = !!fetchedSettings?.isGoogleKeySet; 166 + } else if (provider === 'anthropic') { 167 + value = anthropicApiKeyInput; 168 + onChange = (e) => setAnthropicApiKeyInput(e.target.value); 169 + isSet = !!fetchedSettings?.isAnthropicKeySet; 170 + } 171 + if (isSet && !value) placeholder = 'API Key is set. Enter new key to update or clear.'; 172 + else if (isSet && value) placeholder = 'Update API Key'; 173 + 174 + return { value, onChange, placeholder, isSet }; 175 + }; 176 + 177 + if (sessionStatus === 'loading') { 178 + return <p>Loading session data...</p>; 179 + } 180 + 181 + if (!session || organizations.length === 0) { 182 + return ( 183 + <Card> 184 + <CardHeader> 185 + <CardTitle>AI Categorization Settings</CardTitle> 186 + <CardDescription> 187 + No organizations found. Please sync your organizations from the GitHub tab first. 188 + </CardDescription> 189 + </CardHeader> 190 + </Card> 191 + ); 192 + } 193 + 194 + return ( 195 + <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> 196 + <div className="md:col-span-1"> 197 + <Card> 198 + <CardHeader> 199 + <CardTitle>Your Organizations</CardTitle> 200 + <CardDescription>Select an organization to configure its AI settings.</CardDescription> 201 + </CardHeader> 202 + <CardContent> 203 + {organizations.length === 0 && <p>No organizations linked.</p>} 204 + <ul className="space-y-2"> 205 + {organizations.map((org) => ( 206 + <li key={org.id || org.github_id}> 207 + <Button 208 + variant={selectedOrganization?.id === org.id ? 'secondary' : 'ghost'} 209 + className="w-full justify-start text-left h-auto py-2" 210 + onClick={() => setSelectedOrganization(org)} 211 + > 212 + <div className="flex items-center gap-3"> 213 + <Avatar className="size-8"> 214 + <AvatarImage src={org.avatar_url || undefined} alt={org.name} /> 215 + <AvatarFallback>{org.name[0]}</AvatarFallback> 216 + </Avatar> 217 + <span>{org.name}</span> 218 + </div> 219 + </Button> 220 + </li> 221 + ))} 222 + </ul> 223 + </CardContent> 224 + </Card> 225 + </div> 226 + 227 + <div className="md:col-span-2"> 228 + {!selectedOrganization ? ( 229 + <Card> 230 + <CardHeader> 231 + <CardTitle>AI Categorization Settings</CardTitle> 232 + </CardHeader> 233 + <CardContent> 234 + <p className="text-muted-foreground"> 235 + Please select an organization from the list to configure its AI settings. 236 + </p> 237 + </CardContent> 238 + </Card> 239 + ) : isLoadingSettings ? ( 240 + <p>Loading AI settings for {selectedOrganization.name}...</p> 241 + ) : ( 242 + <Card> 243 + <CardHeader> 244 + <CardTitle>AI Settings for {selectedOrganization.name}</CardTitle> 245 + <CardDescription> 246 + Select an AI model and provide the necessary API key for automatic pull request categorization. API keys are stored securely per organization. 247 + </CardDescription> 248 + </CardHeader> 249 + <CardContent className="space-y-6"> 250 + <div className="space-y-2"> 251 + <Label htmlFor="ai-model-select">AI Model</Label> 252 + <Select 253 + value={selectedModelId === null ? '__none__' : selectedModelId} 254 + onValueChange={(value) => { 255 + const newModelId = value === '__none__' ? null : value; 256 + setSelectedModelId(newModelId); 257 + setOpenaiApiKeyInput(''); 258 + setGoogleApiKeyInput(''); 259 + setAnthropicApiKeyInput(''); 260 + }} 261 + > 262 + <SelectTrigger id="ai-model-select"> 263 + <SelectValue placeholder="Select a model (or None)" /> 264 + </SelectTrigger> 265 + <SelectContent> 266 + <SelectItem value="__none__">None (Disable AI Categorization)</SelectItem> 267 + {allModels.map(model => ( 268 + <SelectItem key={model.id} value={model.id}> 269 + {model.providerName}: {model.name} 270 + </SelectItem> 271 + ))} 272 + </SelectContent> 273 + </Select> 274 + </div> 275 + 276 + {currentSelectedModelDetails && ( 277 + <div className="space-y-2"> 278 + <Label htmlFor={`api-key-${currentSelectedModelDetails.provider}`}> 279 + {currentSelectedModelDetails.providerName} API Key 280 + </Label> 281 + <Input 282 + id={`api-key-${currentSelectedModelDetails.provider}`} 283 + type="password" 284 + value={getApiKeyInputProps(currentSelectedModelDetails.provider).value} 285 + onChange={getApiKeyInputProps(currentSelectedModelDetails.provider).onChange} 286 + placeholder={getApiKeyInputProps(currentSelectedModelDetails.provider).placeholder} 287 + /> 288 + {getApiKeyInputProps(currentSelectedModelDetails.provider).isSet && 289 + !getApiKeyInputProps(currentSelectedModelDetails.provider).value && ( 290 + <p className="text-xs text-muted-foreground"> 291 + An API key is currently set for {currentSelectedModelDetails.providerName}. 292 + To update it, enter the new key. 293 + To clear this key, leave this field blank and click "Save AI Settings". 294 + </p> 295 + )} 296 + {!getApiKeyInputProps(currentSelectedModelDetails.provider).isSet && 297 + currentSelectedModelDetails.provider && ( // Ensure provider exists to show specific message 298 + <p className="text-xs text-muted-foreground"> 299 + Enter your {currentSelectedModelDetails.providerName} API key. 300 + </p> 301 + ) 302 + } 303 + </div> 304 + )} 305 + 306 + {selectedModelId && !currentSelectedModelDetails && ( 307 + <p className="text-destructive text-xs">Error: Selected model details not found. Please re-select.</p> 308 + )} 309 + 310 + </CardContent> 311 + <CardFooter> 312 + <Button onClick={handleSave} disabled={isSaving || isLoadingSettings || !selectedOrganization}> 313 + {isSaving ? 'Saving...' : 'Save AI Settings'} 314 + </Button> 315 + </CardFooter> 316 + </Card> 317 + )} 318 + </div> 319 + </div> 320 + ); 321 + }
+1 -1
components/ui/organization-settings-tab.tsx
··· 58 58 > 59 59 <div className="flex items-center gap-3"> 60 60 <Avatar className="size-8"> 61 - <AvatarImage src={org.avatar_url ?? undefined} alt={org.name} /> 61 + <AvatarImage src={org.avatar_url || undefined} alt={org.name} /> 62 62 <AvatarFallback>{org.name[0]}</AvatarFallback> 63 63 </Avatar> 64 64 <span>{org.name}</span>
+92
lib/ai-models.ts
··· 1 + import type { UpdateAiSettingsPayload, AiSettings as FetchedAiSettings } from "./repositories"; 2 + 3 + // Shared interface for AI Model Definitions 4 + export interface ModelDefinition { 5 + id: string; // e.g., "gpt-4o", "gemini-2.0-flash" 6 + name: string; // User-friendly name, e.g., "GPT-4o (Latest)" 7 + provider: "openai" | "google" | "anthropic"; // Provider key 8 + providerName: string; // User-friendly provider name, e.g., "OpenAI" 9 + apiKeyPayloadKey: keyof UpdateAiSettingsPayload; // Key used in UpdateAiSettingsPayload for this provider's API key 10 + isKeySetSelector: (settings: FetchedAiSettings | null) => boolean; // Function to check if the API key is set for this provider 11 + } 12 + 13 + // Centralized list of all available AI models 14 + export const allModels: ModelDefinition[] = [ 15 + // OpenAI Models 16 + { 17 + id: "gpt-4o", 18 + name: "GPT-4o (Latest)", 19 + provider: "openai", 20 + providerName: "OpenAI", 21 + apiKeyPayloadKey: "openaiApiKey", 22 + isKeySetSelector: (s) => !!s?.isOpenAiKeySet 23 + }, 24 + { 25 + id: "gpt-4-turbo", 26 + name: "GPT-4 Turbo", 27 + provider: "openai", 28 + providerName: "OpenAI", 29 + apiKeyPayloadKey: "openaiApiKey", 30 + isKeySetSelector: (s) => !!s?.isOpenAiKeySet 31 + }, 32 + { 33 + id: "gpt-3.5-turbo", 34 + name: "GPT-3.5 Turbo (Fast, Cost-Effective)", 35 + provider: "openai", 36 + providerName: "OpenAI", 37 + apiKeyPayloadKey: "openaiApiKey", 38 + isKeySetSelector: (s) => !!s?.isOpenAiKeySet 39 + }, 40 + 41 + // Google Gemini Models 42 + { 43 + id: "gemini-2.5-pro-preview-05-06", 44 + name: "Gemini 2.5 Pro (Preview, Max Capability)", 45 + provider: "google", 46 + providerName: "Google", 47 + apiKeyPayloadKey: "googleApiKey", 48 + isKeySetSelector: (s) => !!s?.isGoogleKeySet 49 + }, 50 + { 51 + id: "gemini-2.0-flash", 52 + name: "Gemini 2.0 Flash (Recommended Default)", 53 + provider: "google", 54 + providerName: "Google", 55 + apiKeyPayloadKey: "googleApiKey", 56 + isKeySetSelector: (s) => !!s?.isGoogleKeySet 57 + }, 58 + 59 + // Anthropic Claude Models 60 + { 61 + id: "claude-3.7-sonnet-20250219", // Note: Fictional future date, replace with actual if available 62 + name: "Claude 3.7 Sonnet (Most Intelligent - Preview)", 63 + provider: "anthropic", 64 + providerName: "Anthropic", 65 + apiKeyPayloadKey: "anthropicApiKey", 66 + isKeySetSelector: (s) => !!s?.isAnthropicKeySet 67 + }, 68 + { 69 + id: "claude-3.5-sonnet-20240620", 70 + name: "Claude 3.5 Sonnet (Strong Balance)", 71 + provider: "anthropic", 72 + providerName: "Anthropic", 73 + apiKeyPayloadKey: "anthropicApiKey", 74 + isKeySetSelector: (s) => !!s?.isAnthropicKeySet 75 + }, 76 + { 77 + id: "claude-3-opus-20240229", 78 + name: "Claude 3 Opus (High Capability)", 79 + provider: "anthropic", 80 + providerName: "Anthropic", 81 + apiKeyPayloadKey: "anthropicApiKey", 82 + isKeySetSelector: (s) => !!s?.isAnthropicKeySet 83 + }, 84 + { 85 + id: "claude-3-haiku-20240307", 86 + name: "Claude 3 Haiku (Fastest)", 87 + provider: "anthropic", 88 + providerName: "Anthropic", 89 + apiKeyPayloadKey: "anthropicApiKey", 90 + isKeySetSelector: (s) => !!s?.isAnthropicKeySet 91 + }, 92 + ];
+13
lib/github.ts
··· 140 140 return data as unknown as PullRequestReview[]; 141 141 } 142 142 143 + async getPullRequestDiff(owner: string, repo: string, pullNumber: number): Promise<string> { 144 + const { data } = await this.octokit.pulls.get({ 145 + owner, 146 + repo, 147 + pull_number: pullNumber, 148 + mediaType: { 149 + format: 'diff' 150 + } 151 + }); 152 + // The response for a diff is a string 153 + return data as unknown as string; 154 + } 155 + 143 156 async getRepositoryWebhooks(owner: string, repo: string): Promise<RepositoryWebhook[]> { 144 157 const { data } = await this.octokit.repos.listWebhooks({ 145 158 owner,
+25 -9
lib/repositories/category-repository.ts
··· 10 10 return query<Category>('SELECT * FROM categories WHERE is_default = 1 ORDER BY name'); 11 11 } 12 12 13 - export async function getOrganizationCategories(orgId: number): Promise<Category[]> { 14 - return query<Category>( 15 - `SELECT * FROM categories 16 - WHERE organization_id = ? OR is_default = 1 17 - ORDER BY name`, 18 - [orgId] 19 - ); 13 + export async function getOrganizationCategories(organizationId: number): Promise<Category[]> { 14 + return query<Category>('SELECT * FROM categories WHERE organization_id = ? OR organization_id IS NULL ORDER BY is_default DESC, name ASC', [organizationId]); 20 15 } 21 16 22 - export async function createCategory(category: Omit<Category, 'id' | 'created_at' | 'updated_at'>): Promise<Category> { 17 + export async function createCategory(category: Omit<Category, 'id' | 'created_at' | 'updated_at' | 'is_default'>): Promise<Category> { 23 18 const result = await execute( 24 19 `INSERT INTO categories 25 20 (organization_id, name, description, color, is_default) ··· 29 24 category.name, 30 25 category.description, 31 26 category.color, 32 - category.is_default ? 1 : 0 27 + 0 // is_default is omitted from the type, so we default to 0 33 28 ] 34 29 ); 35 30 ··· 93 88 ORDER BY count DESC, c.name ASC`, 94 89 [orgId] 95 90 ); 91 + } 92 + 93 + export async function findCategoryByNameAndOrg(organizationId: number, name: string): Promise<Category | null> { 94 + // First, try to find an organization-specific category 95 + const orgCategories = await query<Category>( 96 + 'SELECT * FROM categories WHERE organization_id = ? AND name = ? COLLATE NOCASE', 97 + [organizationId, name] 98 + ); 99 + if (orgCategories.length > 0) { 100 + return orgCategories[0]; 101 + } 102 + // If not found, try to find a default category (organization_id IS NULL) 103 + const defaultCategories = await query<Category>( 104 + 'SELECT * FROM categories WHERE organization_id IS NULL AND name = ? COLLATE NOCASE', 105 + [name] 106 + ); 107 + return defaultCategories.length > 0 ? defaultCategories[0] : null; 108 + } 109 + 110 + export async function getCategoriesByOrganization(organizationId: number): Promise<Category[]> { 111 + return query<Category>('SELECT * FROM categories WHERE organization_id = ? OR organization_id IS NULL ORDER BY is_default DESC, name ASC', [organizationId]); 96 112 }
+13 -1
lib/repositories/index.ts
··· 36 36 createPullRequestReview, 37 37 findReviewByGitHubId, 38 38 updatePullRequestReview, 39 + updatePullRequestCategory, 39 40 } from './pr-repository'; 40 41 export { 41 42 getDefaultCategories, ··· 43 44 createCategory, 44 45 updateCategory, 45 46 deleteCategory, 46 - findCategoryById 47 + findCategoryById, 48 + findCategoryByNameAndOrg, 47 49 } from './category-repository'; 50 + 51 + // Export settings repository functions 52 + export { 53 + getOrganizationAiSettings, 54 + updateOrganizationAiSettings, 55 + getOrganizationApiKey, 56 + type AiSettings, 57 + type UpdateAiSettingsPayload, 58 + } from './settings-repository'; 59 + 48 60 // Commented out sections for missing files remain for user to address 49 61 // export { 50 62 // getSettings,
+104
lib/repositories/settings-repository.ts
··· 1 + import { query, execute } from '@/lib/db'; 2 + import { Setting } from '@/lib/types'; 3 + 4 + // Old keys (will be replaced or unused for AI settings) 5 + // const AI_PROVIDER_KEY = 'ai_provider'; 6 + // const AI_MODEL_ID_KEY = 'ai_model_id'; 7 + 8 + // New keys for AI settings 9 + const AI_SELECTED_MODEL_ID_KEY = 'ai_selected_model_id'; 10 + const AI_OPENAI_API_KEY_KEY = 'ai_openai_api_key'; 11 + const AI_GOOGLE_API_KEY_KEY = 'ai_google_api_key'; // Or GEMINI_API_KEY if that naming is preferred 12 + const AI_ANTHROPIC_API_KEY_KEY = 'ai_anthropic_api_key'; 13 + 14 + // Interface for data returned to the client (keys are not sent) 15 + export interface AiSettings { // Renamed from GetAiSettingsResponse for brevity in frontend 16 + selectedModelId: string | null; 17 + isOpenAiKeySet: boolean; 18 + isGoogleKeySet: boolean; 19 + isAnthropicKeySet: boolean; 20 + } 21 + 22 + // Interface for payload when updating settings (actual keys are sent) 23 + export interface UpdateAiSettingsPayload { 24 + selectedModelId?: string | null; // To change the model or disable AI (null) 25 + openaiApiKey?: string | null; // To set or clear/remove a key. Null to clear. 26 + googleApiKey?: string | null; 27 + anthropicApiKey?: string | null; 28 + } 29 + 30 + async function getOrganizationSetting(organizationId: number, key: string): Promise<string | null> { 31 + const settings = await query<Setting>( 32 + 'SELECT value FROM settings WHERE organization_id = ? AND key = ?', 33 + [organizationId, key] 34 + ); 35 + return settings.length > 0 ? settings[0].value : null; 36 + } 37 + 38 + async function updateOrganizationSetting(organizationId: number, key: string, value: string | null): Promise<void> { 39 + // If value is null, we might want to DELETE the setting row or store NULL 40 + // depending on desired behavior for "cleared" keys. 41 + // Storing NULL allows distinguishing between "never set" and "explicitly cleared". 42 + // For simplicity, current behavior is upsert, so null will store NULL. 43 + await execute( 44 + `INSERT INTO settings (organization_id, key, value, created_at, updated_at) 45 + VALUES (?, ?, ?, datetime('now'), datetime('now')) 46 + ON CONFLICT(organization_id, key) DO UPDATE SET 47 + value = excluded.value, 48 + updated_at = datetime('now')`, 49 + [organizationId, key, value] 50 + ); 51 + } 52 + 53 + export async function getOrganizationAiSettings(organizationId: number): Promise<AiSettings> { 54 + const selectedModelId = await getOrganizationSetting(organizationId, AI_SELECTED_MODEL_ID_KEY); 55 + const openAiKey = await getOrganizationSetting(organizationId, AI_OPENAI_API_KEY_KEY); 56 + const googleKey = await getOrganizationSetting(organizationId, AI_GOOGLE_API_KEY_KEY); 57 + const anthropicKey = await getOrganizationSetting(organizationId, AI_ANTHROPIC_API_KEY_KEY); 58 + 59 + return { 60 + selectedModelId, 61 + isOpenAiKeySet: !!openAiKey, // True if key exists and is not empty string (could refine if empty string is valid) 62 + isGoogleKeySet: !!googleKey, 63 + isAnthropicKeySet: !!anthropicKey, 64 + }; 65 + } 66 + 67 + export async function updateOrganizationAiSettings( 68 + organizationId: number, 69 + payload: UpdateAiSettingsPayload 70 + ): Promise<void> { 71 + if (payload.selectedModelId !== undefined) { 72 + await updateOrganizationSetting(organizationId, AI_SELECTED_MODEL_ID_KEY, payload.selectedModelId); 73 + } 74 + if (payload.openaiApiKey !== undefined) { 75 + await updateOrganizationSetting(organizationId, AI_OPENAI_API_KEY_KEY, payload.openaiApiKey); 76 + } 77 + if (payload.googleApiKey !== undefined) { 78 + await updateOrganizationSetting(organizationId, AI_GOOGLE_API_KEY_KEY, payload.googleApiKey); 79 + } 80 + if (payload.anthropicApiKey !== undefined) { 81 + await updateOrganizationSetting(organizationId, AI_ANTHROPIC_API_KEY_KEY, payload.anthropicApiKey); 82 + } 83 + } 84 + 85 + // New function to get a specific API key for an organization and provider 86 + export async function getOrganizationApiKey(organizationId: number, provider: 'openai' | 'google' | 'anthropic'): Promise<string | null> { 87 + let apiKeyDBKey = ''; 88 + switch (provider) { 89 + case 'openai': 90 + apiKeyDBKey = AI_OPENAI_API_KEY_KEY; 91 + break; 92 + case 'google': 93 + apiKeyDBKey = AI_GOOGLE_API_KEY_KEY; 94 + break; 95 + case 'anthropic': 96 + apiKeyDBKey = AI_ANTHROPIC_API_KEY_KEY; 97 + break; 98 + default: 99 + // Should not happen with TypeScript, but good for safety 100 + console.error(`Invalid provider specified for getOrganizationApiKey: ${provider}`); 101 + return null; 102 + } 103 + return getOrganizationSetting(organizationId, apiKeyDBKey); 104 + }
+28 -6
lib/services/github-service.ts
··· 46 46 47 47 console.log(`Linking user ${userId} to organization ${dbOrg.id} (${org.login})`); 48 48 49 - // Link the user to the organization as a member 50 - await addUserToOrganization(userId, dbOrg.id, 'member'); 49 + // Link the user to the organization as an owner 50 + await addUserToOrganization(userId, dbOrg.id, 'owner'); 51 51 52 52 // Fetch and sync repositories for this organization 53 53 await this.syncOrganizationRepositories(org.login); ··· 268 268 } 269 269 270 270 async removeRepositoryTracking(repositoryId: number, appUrl: string): Promise<{ success: boolean; message: string }> { 271 + console.log(`[removeRepositoryTracking] Called with repositoryId: ${repositoryId}, appUrl: ${appUrl}`); 271 272 // Find repository in database 272 273 const repository = await findRepositoryByGitHubId(repositoryId); 273 274 274 275 if (!repository) { 276 + console.error(`[removeRepositoryTracking] Repository not found for GitHub ID: ${repositoryId}`); 275 277 throw new Error('Repository not found'); 276 278 } 277 279 278 - // Extract owner and repo from full_name (format: owner/repo) 279 280 const [owner, repo] = repository.full_name.split('/'); 281 + console.log(`[removeRepositoryTracking] Extracted owner: ${owner}, repo: ${repo}`); 280 282 281 283 if (!owner || !repo) { 284 + console.error(`[removeRepositoryTracking] Invalid repository full_name format: ${repository.full_name}`); 282 285 throw new Error('Invalid repository full_name format'); 283 286 } 284 287 285 288 const webhookUrl = `${appUrl}/api/webhook/github`; 289 + console.log(`[removeRepositoryTracking] Constructed webhookUrl to match: ${webhookUrl}`); 286 290 287 291 // Find and delete existing webhooks 288 292 const existingWebhooks = await this.client.getRepositoryWebhooks(owner, repo); 293 + console.log(`[removeRepositoryTracking] Found ${existingWebhooks.length} existing webhooks on GitHub for ${owner}/${repo}:`, JSON.stringify(existingWebhooks, null, 2)); 294 + 289 295 const targetWebhooks = existingWebhooks.filter(webhook => 290 - webhook.config.url === webhookUrl || webhook.config.url === `${webhookUrl}/` 296 + webhook.config.url === webhookUrl || webhook.config.url === `${webhookUrl}/` // Handle trailing slash 291 297 ); 298 + console.log(`[removeRepositoryTracking] Found ${targetWebhooks.length} target webhooks matching URL ${webhookUrl}:`, JSON.stringify(targetWebhooks, null, 2)); 292 299 300 + let deletedCount = 0; 301 + if (targetWebhooks.length === 0) { 302 + console.warn(`[removeRepositoryTracking] No webhooks found on GitHub matching the URL ${webhookUrl} for repository ${owner}/${repo}. Marking as untracked in DB anyway.`); 303 + } 304 + 293 305 for (const webhook of targetWebhooks) { 294 - await this.client.deleteRepositoryWebhook(owner, repo, webhook.id); 306 + try { 307 + console.log(`[removeRepositoryTracking] Attempting to delete webhook with ID: ${webhook.id} for ${owner}/${repo}`); 308 + await this.client.deleteRepositoryWebhook(owner, repo, webhook.id); 309 + console.log(`[removeRepositoryTracking] Successfully deleted webhook ID: ${webhook.id} from GitHub for ${owner}/${repo}`); 310 + deletedCount++; 311 + } catch (error) { 312 + console.error(`[removeRepositoryTracking] Failed to delete webhook ID: ${webhook.id} for ${owner}/${repo} from GitHub. Error:`, error); 313 + // Decide if you want to throw here or just log and continue to mark as untracked in DB 314 + // For now, we'll log and continue, so it still gets marked as untracked in the DB. 315 + } 295 316 } 296 317 297 318 // Mark repository as not tracked 298 319 await setRepositoryTracking(repository.id, false); 320 + console.log(`[removeRepositoryTracking] Marked repository ${repository.id} (GitHub ID: ${repository.github_id}) as not tracked in DB.`); 299 321 300 322 return { 301 323 success: true, 302 - message: `Removed ${targetWebhooks.length} webhooks and repository is no longer being tracked` 324 + message: `Attempted to remove ${targetWebhooks.length} webhooks (successfully deleted ${deletedCount}) and repository is no longer being tracked` 303 325 }; 304 326 } 305 327 }
+4
package.json
··· 10 10 "generate-mock-data": "node scripts/generate-mock-data.js" 11 11 }, 12 12 "dependencies": { 13 + "@ai-sdk/anthropic": "^1.2.11", 14 + "@ai-sdk/google": "^1.2.17", 15 + "@ai-sdk/openai": "^1.3.22", 13 16 "@dnd-kit/core": "^6.3.1", 14 17 "@dnd-kit/modifiers": "^9.0.0", 15 18 "@dnd-kit/sortable": "^10.0.0", ··· 34 37 "@radix-ui/react-tooltip": "^1.2.4", 35 38 "@tabler/icons-react": "^3.31.0", 36 39 "@tanstack/react-table": "^8.21.3", 40 + "ai": "^4.3.15", 37 41 "class-variance-authority": "^0.7.1", 38 42 "clsx": "^2.1.1", 39 43 "cmdk": "^1.1.1",
+205 -5
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@ai-sdk/anthropic': 12 + specifier: ^1.2.11 13 + version: 1.2.11(zod@3.24.3) 14 + '@ai-sdk/google': 15 + specifier: ^1.2.17 16 + version: 1.2.17(zod@3.24.3) 17 + '@ai-sdk/openai': 18 + specifier: ^1.3.22 19 + version: 1.3.22(zod@3.24.3) 11 20 '@dnd-kit/core': 12 21 specifier: ^6.3.1 13 22 version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 80 89 '@tanstack/react-table': 81 90 specifier: ^8.21.3 82 91 version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 92 + ai: 93 + specifier: ^4.3.15 94 + version: 4.3.15(react@19.1.0)(zod@3.24.3) 83 95 class-variance-authority: 84 96 specifier: ^0.7.1 85 97 version: 0.7.1 ··· 100 112 version: 0.503.0(react@19.1.0) 101 113 next: 102 114 specifier: 15.3.1 103 - version: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 115 + version: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 104 116 next-auth: 105 117 specifier: ^5.0.0-beta.27 106 - version: 5.0.0-beta.27(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) 118 + version: 5.0.0-beta.27(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) 107 119 next-themes: 108 120 specifier: ^0.4.6 109 121 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 165 177 166 178 packages: 167 179 180 + '@ai-sdk/anthropic@1.2.11': 181 + resolution: {integrity: sha512-lZLcEMh8MXY4NVSrN/7DyI2rnid8k7cn/30nMmd3bwJrnIsOuIuuFvY8f0nj+pFcTi6AYK7ujLdqW5dQVz1YQw==} 182 + engines: {node: '>=18'} 183 + peerDependencies: 184 + zod: ^3.0.0 185 + 186 + '@ai-sdk/google@1.2.17': 187 + resolution: {integrity: sha512-mLFLDMCJaDK+j1nvoqeNszazSZIyeSMPi5X+fs5Wh3xWZljGGE0WmFg32RNkFujRB+UnM63EnhPG70WdqOx/MA==} 188 + engines: {node: '>=18'} 189 + peerDependencies: 190 + zod: ^3.0.0 191 + 192 + '@ai-sdk/openai@1.3.22': 193 + resolution: {integrity: sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==} 194 + engines: {node: '>=18'} 195 + peerDependencies: 196 + zod: ^3.0.0 197 + 198 + '@ai-sdk/provider-utils@2.2.8': 199 + resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} 200 + engines: {node: '>=18'} 201 + peerDependencies: 202 + zod: ^3.23.8 203 + 204 + '@ai-sdk/provider@1.1.3': 205 + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} 206 + engines: {node: '>=18'} 207 + 208 + '@ai-sdk/react@1.2.12': 209 + resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} 210 + engines: {node: '>=18'} 211 + peerDependencies: 212 + react: ^18 || ^19 || ^19.0.0-rc 213 + zod: ^3.23.8 214 + peerDependenciesMeta: 215 + zod: 216 + optional: true 217 + 218 + '@ai-sdk/ui-utils@1.2.11': 219 + resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} 220 + engines: {node: '>=18'} 221 + peerDependencies: 222 + zod: ^3.23.8 223 + 168 224 '@alloc/quick-lru@5.2.0': 169 225 resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} 170 226 engines: {node: '>=10'} ··· 591 647 592 648 '@octokit/types@14.0.0': 593 649 resolution: {integrity: sha512-VVmZP0lEhbo2O1pdq63gZFiGCKkm8PPp8AUOijlwPO6hojEVjspA0MWKP7E4hbvGxzFKNqKr6p0IYtOH/Wf/zA==} 650 + 651 + '@opentelemetry/api@1.9.0': 652 + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} 653 + engines: {node: '>=8.0.0'} 594 654 595 655 '@panva/hkdf@1.2.1': 596 656 resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} ··· 1274 1334 '@types/d3-timer@3.0.2': 1275 1335 resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} 1276 1336 1337 + '@types/diff-match-patch@1.0.36': 1338 + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} 1339 + 1277 1340 '@types/estree@1.0.7': 1278 1341 resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} 1279 1342 ··· 1439 1502 engines: {node: '>=0.4.0'} 1440 1503 hasBin: true 1441 1504 1505 + ai@4.3.15: 1506 + resolution: {integrity: sha512-TYKRzbWg6mx/pmTadlAEIhuQtzfHUV0BbLY72+zkovXwq/9xhcH24IlQmkyBpElK6/4ArS0dHdOOtR1jOPVwtg==} 1507 + engines: {node: '>=18'} 1508 + peerDependencies: 1509 + react: ^18 || ^19 || ^19.0.0-rc 1510 + zod: ^3.23.8 1511 + peerDependenciesMeta: 1512 + react: 1513 + optional: true 1514 + 1442 1515 ajv@6.12.6: 1443 1516 resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} 1444 1517 ··· 1551 1624 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 1552 1625 engines: {node: '>=10'} 1553 1626 1627 + chalk@5.4.1: 1628 + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} 1629 + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} 1630 + 1554 1631 class-variance-authority@0.7.1: 1555 1632 resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} 1556 1633 ··· 1688 1765 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} 1689 1766 engines: {node: '>= 0.4'} 1690 1767 1768 + dequal@2.0.3: 1769 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 1770 + engines: {node: '>=6'} 1771 + 1691 1772 detect-libc@2.0.2: 1692 1773 resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} 1693 1774 engines: {node: '>=8'} ··· 1698 1779 1699 1780 detect-node-es@1.1.0: 1700 1781 resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} 1782 + 1783 + diff-match-patch@1.0.5: 1784 + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} 1701 1785 1702 1786 doctrine@2.1.0: 1703 1787 resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} ··· 2189 2273 json-schema-traverse@0.4.1: 2190 2274 resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} 2191 2275 2276 + json-schema@0.4.0: 2277 + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} 2278 + 2192 2279 json-stable-stringify-without-jsonify@1.0.1: 2193 2280 resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} 2194 2281 ··· 2196 2283 resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} 2197 2284 hasBin: true 2198 2285 2286 + jsondiffpatch@0.6.0: 2287 + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} 2288 + engines: {node: ^18.0.0 || >=20.0.0} 2289 + hasBin: true 2290 + 2199 2291 jsx-ast-utils@3.3.5: 2200 2292 resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} 2201 2293 engines: {node: '>=4.0'} ··· 2633 2725 scheduler@0.26.0: 2634 2726 resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} 2635 2727 2728 + secure-json-parse@2.7.0: 2729 + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} 2730 + 2636 2731 semver@6.3.1: 2637 2732 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 2638 2733 hasBin: true ··· 2754 2849 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 2755 2850 engines: {node: '>= 0.4'} 2756 2851 2852 + swr@2.3.3: 2853 + resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} 2854 + peerDependencies: 2855 + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 2856 + 2757 2857 tailwind-merge@3.2.0: 2758 2858 resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} 2759 2859 ··· 2764 2864 resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} 2765 2865 engines: {node: '>=6'} 2766 2866 2867 + throttleit@2.1.0: 2868 + resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} 2869 + engines: {node: '>=18'} 2870 + 2767 2871 tiny-invariant@1.3.3: 2768 2872 resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} 2769 2873 ··· 2910 3014 resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} 2911 3015 engines: {node: '>=10'} 2912 3016 3017 + zod-to-json-schema@3.24.5: 3018 + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} 3019 + peerDependencies: 3020 + zod: ^3.24.1 3021 + 2913 3022 zod@3.24.3: 2914 3023 resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} 2915 3024 2916 3025 snapshots: 2917 3026 3027 + '@ai-sdk/anthropic@1.2.11(zod@3.24.3)': 3028 + dependencies: 3029 + '@ai-sdk/provider': 1.1.3 3030 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 3031 + zod: 3.24.3 3032 + 3033 + '@ai-sdk/google@1.2.17(zod@3.24.3)': 3034 + dependencies: 3035 + '@ai-sdk/provider': 1.1.3 3036 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 3037 + zod: 3.24.3 3038 + 3039 + '@ai-sdk/openai@1.3.22(zod@3.24.3)': 3040 + dependencies: 3041 + '@ai-sdk/provider': 1.1.3 3042 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 3043 + zod: 3.24.3 3044 + 3045 + '@ai-sdk/provider-utils@2.2.8(zod@3.24.3)': 3046 + dependencies: 3047 + '@ai-sdk/provider': 1.1.3 3048 + nanoid: 3.3.11 3049 + secure-json-parse: 2.7.0 3050 + zod: 3.24.3 3051 + 3052 + '@ai-sdk/provider@1.1.3': 3053 + dependencies: 3054 + json-schema: 0.4.0 3055 + 3056 + '@ai-sdk/react@1.2.12(react@19.1.0)(zod@3.24.3)': 3057 + dependencies: 3058 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 3059 + '@ai-sdk/ui-utils': 1.2.11(zod@3.24.3) 3060 + react: 19.1.0 3061 + swr: 2.3.3(react@19.1.0) 3062 + throttleit: 2.1.0 3063 + optionalDependencies: 3064 + zod: 3.24.3 3065 + 3066 + '@ai-sdk/ui-utils@1.2.11(zod@3.24.3)': 3067 + dependencies: 3068 + '@ai-sdk/provider': 1.1.3 3069 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 3070 + zod: 3.24.3 3071 + zod-to-json-schema: 3.24.5(zod@3.24.3) 3072 + 2918 3073 '@alloc/quick-lru@5.2.0': {} 2919 3074 2920 3075 '@auth/core@0.39.0': ··· 3305 3460 '@octokit/types@14.0.0': 3306 3461 dependencies: 3307 3462 '@octokit/openapi-types': 25.0.0 3463 + 3464 + '@opentelemetry/api@1.9.0': {} 3308 3465 3309 3466 '@panva/hkdf@1.2.1': {} 3310 3467 ··· 3961 4118 3962 4119 '@types/d3-timer@3.0.2': {} 3963 4120 4121 + '@types/diff-match-patch@1.0.36': {} 4122 + 3964 4123 '@types/estree@1.0.7': {} 3965 4124 3966 4125 '@types/json-schema@7.0.15': {} ··· 4119 4278 4120 4279 acorn@8.14.1: {} 4121 4280 4281 + ai@4.3.15(react@19.1.0)(zod@3.24.3): 4282 + dependencies: 4283 + '@ai-sdk/provider': 1.1.3 4284 + '@ai-sdk/provider-utils': 2.2.8(zod@3.24.3) 4285 + '@ai-sdk/react': 1.2.12(react@19.1.0)(zod@3.24.3) 4286 + '@ai-sdk/ui-utils': 1.2.11(zod@3.24.3) 4287 + '@opentelemetry/api': 1.9.0 4288 + jsondiffpatch: 0.6.0 4289 + zod: 3.24.3 4290 + optionalDependencies: 4291 + react: 19.1.0 4292 + 4122 4293 ajv@6.12.6: 4123 4294 dependencies: 4124 4295 fast-deep-equal: 3.1.3 ··· 4262 4433 ansi-styles: 4.3.0 4263 4434 supports-color: 7.2.0 4264 4435 4436 + chalk@5.4.1: {} 4437 + 4265 4438 class-variance-authority@0.7.1: 4266 4439 dependencies: 4267 4440 clsx: 2.1.1 ··· 4396 4569 has-property-descriptors: 1.0.2 4397 4570 object-keys: 1.1.1 4398 4571 4572 + dequal@2.0.3: {} 4573 + 4399 4574 detect-libc@2.0.2: {} 4400 4575 4401 4576 detect-libc@2.0.4: {} 4402 4577 4403 4578 detect-node-es@1.1.0: {} 4579 + 4580 + diff-match-patch@1.0.5: {} 4404 4581 4405 4582 doctrine@2.1.0: 4406 4583 dependencies: ··· 5043 5220 5044 5221 json-schema-traverse@0.4.1: {} 5045 5222 5223 + json-schema@0.4.0: {} 5224 + 5046 5225 json-stable-stringify-without-jsonify@1.0.1: {} 5047 5226 5048 5227 json5@1.0.2: 5049 5228 dependencies: 5050 5229 minimist: 1.2.8 5051 5230 5231 + jsondiffpatch@0.6.0: 5232 + dependencies: 5233 + '@types/diff-match-patch': 1.0.36 5234 + chalk: 5.4.1 5235 + diff-match-patch: 1.0.5 5236 + 5052 5237 jsx-ast-utils@3.3.5: 5053 5238 dependencies: 5054 5239 array-includes: 3.1.8 ··· 5178 5363 5179 5364 natural-compare@1.4.0: {} 5180 5365 5181 - next-auth@5.0.0-beta.27(next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): 5366 + next-auth@5.0.0-beta.27(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): 5182 5367 dependencies: 5183 5368 '@auth/core': 0.39.0 5184 - next: 15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 5369 + next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 5185 5370 react: 19.1.0 5186 5371 5187 5372 next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): ··· 5189 5374 react: 19.1.0 5190 5375 react-dom: 19.1.0(react@19.1.0) 5191 5376 5192 - next@15.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): 5377 + next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): 5193 5378 dependencies: 5194 5379 '@next/env': 15.3.1 5195 5380 '@swc/counter': 0.1.3 ··· 5209 5394 '@next/swc-linux-x64-musl': 15.3.1 5210 5395 '@next/swc-win32-arm64-msvc': 15.3.1 5211 5396 '@next/swc-win32-x64-msvc': 15.3.1 5397 + '@opentelemetry/api': 1.9.0 5212 5398 sharp: 0.34.1 5213 5399 transitivePeerDependencies: 5214 5400 - '@babel/core' ··· 5481 5667 5482 5668 scheduler@0.26.0: {} 5483 5669 5670 + secure-json-parse@2.7.0: {} 5671 + 5484 5672 semver@6.3.1: {} 5485 5673 5486 5674 semver@7.7.1: {} ··· 5650 5838 5651 5839 supports-preserve-symlinks-flag@1.0.0: {} 5652 5840 5841 + swr@2.3.3(react@19.1.0): 5842 + dependencies: 5843 + dequal: 2.0.3 5844 + react: 19.1.0 5845 + use-sync-external-store: 1.5.0(react@19.1.0) 5846 + 5653 5847 tailwind-merge@3.2.0: {} 5654 5848 5655 5849 tailwindcss@4.1.4: {} 5656 5850 5657 5851 tapable@2.2.1: {} 5852 + 5853 + throttleit@2.1.0: {} 5658 5854 5659 5855 tiny-invariant@1.3.3: {} 5660 5856 ··· 5855 6051 ws@8.18.2: {} 5856 6052 5857 6053 yocto-queue@0.1.0: {} 6054 + 6055 + zod-to-json-schema@3.24.5(zod@3.24.3): 6056 + dependencies: 6057 + zod: 3.24.3 5858 6058 5859 6059 zod@3.24.3: {}