+92
app/api/organizations/[orgId]/ai-settings/route.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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: {}