Open Source Team Metrics based on PRs

onboarding fixes, design

Changed files
+556 -158
.cursor
app
api
debug
categorize-pr
webhook
github
dashboard
analytics
debug
lifecycle
projects
settings
team
components
lib
repositories
+2 -2
.cursor/rules.json
··· 1 1 { 2 - "name": "Pandora Project Rules", 3 - "description": "Rules and guidelines for the Pandora project", 2 + "name": "PR Cat Project Rules", 3 + "description": "Rules and guidelines for the PR Cat project", 4 4 "technologies": { 5 5 "required": [ 6 6 {
+9 -3
app/api/debug/categorize-pr/route.ts
··· 57 57 58 58 console.log(`DEBUG: Starting categorization for PR #${pullRequest.number} (ID: ${prId}) in ${repository.full_name}`); 59 59 60 - // Get AI settings for organization 60 + // Get API settings for organization 61 61 const aiSettings = await getOrganizationAiSettings(organizationId); 62 62 if (!aiSettings.selectedModelId || aiSettings.selectedModelId === '__none__') { 63 63 return NextResponse.json({ ··· 66 66 } 67 67 68 68 // Get provider and model info 69 + const provider = aiSettings.provider; 69 70 const selectedModelId = aiSettings.selectedModelId; 70 - const [provider] = selectedModelId.split('/'); 71 + 72 + if (!provider) { 73 + return NextResponse.json({ 74 + error: 'AI provider not set for organization' 75 + }, { status: 400 }); 76 + } 71 77 72 78 // Get API key for provider 73 - const apiKey = await getOrganizationApiKey(organizationId, provider as 'openai' | 'google' | 'anthropic'); 79 + const apiKey = await getOrganizationApiKey(organizationId, provider); 74 80 75 81 if (!apiKey) { 76 82 return NextResponse.json({
+14 -16
app/api/webhook/github/route.ts
··· 673 673 } 674 674 } 675 675 676 - // 1. Fetch Organization's AI Settings (using organizationId) 676 + // 1. Get API settings (model + API key) 677 + console.log(`WEBHOOK AI SETTINGS: Getting AI settings for organization ${organizationId}`); 677 678 const aiSettings = await getOrganizationAiSettings(organizationId); 678 - console.log(`WEBHOOK AI SETTINGS: Organization ${organizationId} AI settings:`, 679 - aiSettings ? `model=${aiSettings.selectedModelId}` : "No settings found"); 680 - 681 - if (!aiSettings || !aiSettings.selectedModelId) { 679 + const selectedModelId = aiSettings.selectedModelId; 680 + 681 + if (!selectedModelId || selectedModelId === '__none__') { 682 682 console.log(`WEBHOOK INFO: AI categorization disabled for organization ${organizationId} (no model selected).`); 683 683 await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'AI categorization disabled for organization' }); 684 684 return; 685 685 } 686 - 687 - const selectedModelId = aiSettings.selectedModelId; 688 - console.log(`WEBHOOK AI MODEL: Organization ${organizationId} selected AI model: ${selectedModelId}`); 689 - 690 - const modelInfo = allModels.find(m => m.id === selectedModelId); 691 - if (!modelInfo) { 692 - console.error(`WEBHOOK ERROR: Selected model ID ${selectedModelId} not found in allModels for organization ${organizationId}.`); 693 - await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'error', error_message: `Selected AI model ${selectedModelId} not configured` }); 686 + 687 + const provider = aiSettings.provider; 688 + 689 + if (!provider) { 690 + console.log(`WEBHOOK INFO: AI provider not set for organization ${organizationId}.`); 691 + await PullRequestRepository.updatePullRequest(prDbId, { ai_status: 'skipped', error_message: 'AI provider not set for organization' }); 694 692 return; 695 693 } 696 - const provider = modelInfo.provider; 697 - console.log(`WEBHOOK AI PROVIDER: Determined provider: ${provider} for model ${selectedModelId}`); 698 - 694 + 695 + console.log(`WEBHOOK MODEL: Using AI model ${selectedModelId} with provider ${provider}`); 696 + 699 697 const apiKey = await getOrganizationApiKey(organizationId, provider); 700 698 if (!apiKey) { 701 699 console.warn(`WEBHOOK WARNING: API key for provider ${provider} not set for organization ${organizationId}. Skipping AI categorization.`);
+2 -3
app/dashboard/analytics/page.tsx
··· 21 21 > 22 22 <AppSidebar variant="inset" /> 23 23 <SidebarInset> 24 - <SiteHeader /> 24 + <SiteHeader pageTitle="Team Collaboration Insights" /> 25 25 <div className="flex flex-1 flex-col"> 26 26 <div className="@container/main flex flex-1 flex-col gap-2"> 27 27 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 28 - <div className="flex justify-between items-center px-4 lg:px-6"> 29 - <h1 className="text-2xl font-semibold">Team Collaboration Insights</h1> 28 + <div className="flex justify-end items-center px-4 lg:px-6"> 30 29 <Button variant="outline" size="sm"> 31 30 <IconDownload className="mr-2 h-4 w-4" /> 32 31 Export Team Report
+1 -4
app/dashboard/debug/page.tsx
··· 70 70 > 71 71 <AppSidebar variant="inset" /> 72 72 <SidebarInset> 73 - <SiteHeader /> 73 + <SiteHeader pageTitle="Debug Information" /> 74 74 <div className="flex flex-1 flex-col"> 75 75 <div className="@container/main flex flex-1 flex-col gap-2"> 76 76 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 77 - <div className="px-4 lg:px-6"> 78 - <h1 className="text-2xl font-semibold">Debug Information</h1> 79 - </div> 80 77 <div className="grid gap-4 px-4 lg:px-6"> 81 78 <Card> 82 79 <CardHeader>
+1 -2
app/dashboard/lifecycle/page.tsx
··· 21 21 > 22 22 <AppSidebar variant="inset" /> 23 23 <SidebarInset> 24 - <SiteHeader /> 24 + <SiteHeader pageTitle="PR Lifecycle" /> 25 25 <div className="flex flex-1 flex-col"> 26 26 <div className="@container/main flex flex-1 flex-col gap-2"> 27 27 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 28 28 <div className="flex justify-between items-center px-4 lg:px-6"> 29 - <h1 className="text-2xl font-semibold">PR Lifecycle</h1> 30 29 <div className="flex gap-3"> 31 30 <RepositoryFilter /> 32 31 <DateRangePickerWithPresets />
+1 -4
app/dashboard/page.tsx
··· 216 216 > 217 217 <AppSidebar variant="inset" /> 218 218 <SidebarInset> 219 - <SiteHeader /> 219 + <SiteHeader pageTitle="Dashboard Overview" /> 220 220 {setupIncomplete && ( 221 221 <div className="px-4 pt-4 lg:px-6"> 222 222 <SetupStatusAlert /> ··· 225 225 <div className="flex flex-1 flex-col"> 226 226 <div className="@container/main flex flex-1 flex-col gap-2"> 227 227 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 228 - <div className="px-4 lg:px-6"> 229 - <h1 className="text-2xl font-semibold">Dashboard Overview</h1> 230 - </div> 231 228 <SectionCardsEngineering /> 232 229 <div className="px-4 lg:px-6"> 233 230 <ActionableRecommendations />
+1 -4
app/dashboard/projects/page.tsx
··· 18 18 > 19 19 <AppSidebar variant="inset" /> 20 20 <SidebarInset> 21 - <SiteHeader /> 21 + <SiteHeader pageTitle="Projects & Repositories" /> 22 22 <div className="flex flex-1 flex-col"> 23 23 <div className="@container/main flex flex-1 flex-col gap-2"> 24 24 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 25 - <div className="px-4 lg:px-6"> 26 - <h1 className="text-2xl font-semibold">Projects & Repositories</h1> 27 - </div> 28 25 <RepositoryInsights /> 29 26 </div> 30 27 </div>
+1 -5
app/dashboard/settings/page.tsx
··· 38 38 > 39 39 <AppSidebar variant="inset" /> 40 40 <SidebarInset> 41 - <SiteHeader /> 41 + <SiteHeader pageTitle="Settings" /> 42 42 <div className="flex flex-1 flex-col"> 43 43 <div className="@container/main flex flex-1 flex-col gap-2"> 44 44 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 45 - <div className="px-4 lg:px-6"> 46 - <h1 className="text-2xl font-semibold">Settings</h1> 47 - </div> 48 - 49 45 <Tabs defaultValue="github" className="px-4 lg:px-6"> 50 46 <TabsList> 51 47 <TabsTrigger value="github">GitHub</TabsTrigger>
+1 -4
app/dashboard/team/page.tsx
··· 18 18 > 19 19 <AppSidebar variant="inset" /> 20 20 <SidebarInset> 21 - <SiteHeader /> 21 + <SiteHeader pageTitle="Team Performance" /> 22 22 <div className="flex flex-1 flex-col"> 23 23 <div className="@container/main flex flex-1 flex-col gap-2"> 24 24 <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6"> 25 - <div className="px-4 lg:px-6"> 26 - <h1 className="text-2xl font-semibold">Team Performance</h1> 27 - </div> 28 25 <DeveloperPerformance /> 29 26 </div> 30 27 </div>
+378 -25
components/onboarding-wizard.tsx
··· 6 6 import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; 7 7 import { Progress } from '@/components/ui/progress'; 8 8 import { InstallGitHubApp } from '@/components/ui/install-github-app'; 9 - import { CheckCircle2, ChevronRight, Github } from 'lucide-react'; 9 + import { CheckCircle2, ChevronRight, Github, CheckIcon } from 'lucide-react'; 10 10 import { toast } from 'sonner'; 11 11 import { useRouter } from 'next/navigation'; 12 + import { allModels, ModelDefinition } from '@/lib/ai-models'; 13 + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 14 + import { Input } from '@/components/ui/input'; 15 + import { Label } from '@/components/ui/label'; 16 + import { Organization, Repository } from '@/lib/types'; 17 + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 18 + import { Checkbox } from '@/components/ui/checkbox'; 12 19 13 20 // Define the onboarding steps 14 21 const STEPS = { ··· 19 26 COMPLETE: 4 20 27 }; 21 28 29 + // Define minimal organization and repository types 30 + interface GitHubOrganization { 31 + id: number; 32 + login: string; 33 + avatar_url?: string; 34 + } 35 + 36 + interface GitHubRepository { 37 + id: number; 38 + name: string; 39 + full_name: string; 40 + private: boolean; 41 + } 42 + 22 43 export function OnboardingWizard() { 23 44 const { data: session, status } = useSession(); 24 45 const router = useRouter(); 25 46 const [currentStep, setCurrentStep] = useState(STEPS.WELCOME); 26 47 const [githubAppInstalled, setGithubAppInstalled] = useState(false); 27 48 49 + // Add AI settings state 50 + const [selectedProvider, setSelectedProvider] = useState<'openai' | 'google' | 'anthropic' | null>(null); 51 + const [selectedModelId, setSelectedModelId] = useState<string | null>(null); 52 + const [apiKey, setApiKey] = useState<string>(''); 53 + 54 + // Add organization and repository state 55 + const [organizations, setOrganizations] = useState<GitHubOrganization[]>([]); 56 + const [selectedOrg, setSelectedOrg] = useState<GitHubOrganization | null>(null); 57 + const [repositories, setRepositories] = useState<GitHubRepository[]>([]); 58 + const [selectedRepoIds, setSelectedRepoIds] = useState<Set<number>>(new Set()); 59 + const [isLoadingOrgs, setIsLoadingOrgs] = useState(false); 60 + const [isLoadingRepos, setIsLoadingRepos] = useState(false); 61 + 62 + // Filter models based on selected provider 63 + const availableModels = selectedProvider 64 + ? allModels.filter(model => model.provider === selectedProvider) 65 + : []; 66 + 67 + // Load GitHub data when on repository selection step 68 + useEffect(() => { 69 + const fetchGitHubData = async () => { 70 + if (currentStep === STEPS.REPO_SELECT) { 71 + await fetchGitHubOrganizations(); 72 + } 73 + }; 74 + 75 + fetchGitHubData(); 76 + }, [currentStep]); 77 + 78 + // Fetch GitHub organizations directly 79 + const fetchGitHubOrganizations = async () => { 80 + setIsLoadingOrgs(true); 81 + try { 82 + // Use GitHub API directly - bypassing our backend API 83 + const accessToken = session?.accessToken; 84 + 85 + if (!accessToken) { 86 + console.error('No GitHub access token found in session'); 87 + toast.error('GitHub authorization required'); 88 + return; 89 + } 90 + 91 + const response = await fetch('https://api.github.com/user/orgs', { 92 + headers: { 93 + Authorization: `Bearer ${accessToken}`, 94 + Accept: 'application/vnd.github.v3+json' 95 + } 96 + }); 97 + 98 + if (!response.ok) { 99 + throw new Error(`GitHub API error: ${response.status}`); 100 + } 101 + 102 + const data = await response.json(); 103 + setOrganizations(data); 104 + 105 + if (data.length > 0) { 106 + setSelectedOrg(data[0]); 107 + fetchGitHubRepositories(data[0].login); 108 + } 109 + } catch (error) { 110 + console.error('Error fetching GitHub organizations:', error); 111 + toast.error('Failed to load GitHub organizations'); 112 + // Fallback to mocks if needed 113 + setOrganizations([ 114 + { id: 1, login: 'Example-Organization', avatar_url: '' } 115 + ]); 116 + } finally { 117 + setIsLoadingOrgs(false); 118 + } 119 + }; 120 + 121 + // Fetch repositories for the selected organization 122 + const fetchGitHubRepositories = async (orgName: string) => { 123 + setIsLoadingRepos(true); 124 + try { 125 + // Use GitHub API directly - bypassing our backend API 126 + const accessToken = session?.accessToken; 127 + 128 + if (!accessToken) { 129 + console.error('No GitHub access token found in session'); 130 + toast.error('GitHub authorization required'); 131 + return; 132 + } 133 + 134 + const response = await fetch(`https://api.github.com/orgs/${orgName}/repos?per_page=100`, { 135 + headers: { 136 + Authorization: `Bearer ${accessToken}`, 137 + Accept: 'application/vnd.github.v3+json' 138 + } 139 + }); 140 + 141 + if (!response.ok) { 142 + throw new Error(`GitHub API error: ${response.status}`); 143 + } 144 + 145 + const data = await response.json(); 146 + setRepositories(data); 147 + } catch (error) { 148 + console.error(`Error fetching repositories for ${orgName}:`, error); 149 + toast.error('Failed to load repositories'); 150 + // Fallback to mocks if needed 151 + setRepositories([ 152 + { id: 101, name: 'example-repo', full_name: 'Example-Organization/example-repo', private: false } 153 + ]); 154 + } finally { 155 + setIsLoadingRepos(false); 156 + } 157 + }; 158 + 159 + // Handle organization selection 160 + const handleOrgSelect = (org: GitHubOrganization) => { 161 + setSelectedOrg(org); 162 + setRepositories([]); 163 + setSelectedRepoIds(new Set()); 164 + fetchGitHubRepositories(org.login); 165 + }; 166 + 167 + // Toggle repository selection 168 + const toggleRepository = (repoId: number) => { 169 + setSelectedRepoIds(prev => { 170 + const newSet = new Set(prev); 171 + if (newSet.has(repoId)) { 172 + newSet.delete(repoId); 173 + } else { 174 + newSet.add(repoId); 175 + } 176 + return newSet; 177 + }); 178 + }; 179 + 28 180 // Check if GitHub App is installed (simplified version) 29 181 useEffect(() => { 30 182 // In a real implementation, you'd make an API call to check if the GitHub App ··· 56 208 // This is where you'd open the GitHub App installation page 57 209 // For now, we'll use the InstallGitHubApp component's logic 58 210 }; 211 + 212 + // Handle provider selection 213 + const handleProviderSelect = (provider: 'openai' | 'google' | 'anthropic' | null) => { 214 + setSelectedProvider(provider); 215 + setSelectedModelId(null); // Reset model when provider changes 216 + setApiKey(''); 217 + }; 59 218 60 219 // Go to next step 61 - const handleNext = () => { 220 + const handleNext = async () => { 62 221 if (currentStep < STEPS.COMPLETE) { 222 + // Validate AI settings before proceeding 223 + if (currentStep === STEPS.AI_SETUP) { 224 + if (!selectedProvider) { 225 + toast.warning("Please select an AI provider"); 226 + return; 227 + } 228 + if (!selectedModelId) { 229 + toast.warning("Please select an AI model"); 230 + return; 231 + } 232 + 233 + // Save AI settings (in a real app, this would be persisted) 234 + toast.success(`${selectedProvider} provider with ${selectedModelId} model selected`); 235 + // In a real implementation, you would save the API key and other settings to the database 236 + } 237 + 238 + // Handle repository tracking when completing the repo selection step 239 + if (currentStep === STEPS.REPO_SELECT && selectedRepoIds.size > 0) { 240 + try { 241 + // In a real implementation, you would save the selected repositories 242 + // This is a simplified version that just shows a toast 243 + toast.success(`${selectedRepoIds.size} repositories selected for tracking`); 244 + 245 + // Ideally you'd make an API call to update repositories 246 + // const response = await fetch('/api/repositories/track', { 247 + // method: 'POST', 248 + // headers: { 'Content-Type': 'application/json' }, 249 + // body: JSON.stringify({ 250 + // repositoryIds: Array.from(selectedRepoIds), 251 + // tracked: true 252 + // }) 253 + // }); 254 + // if (!response.ok) throw new Error('Failed to update repository tracking'); 255 + } catch (error) { 256 + console.error('Error updating repository tracking:', error); 257 + toast.error('Failed to update repository tracking'); 258 + return; 259 + } 260 + } 261 + 63 262 setCurrentStep(currentStep + 1); 64 263 } else { 65 264 // Onboarding complete, navigate to dashboard ··· 75 274 76 275 // Progress percentage 77 276 const progressPercentage = Math.min(100, (currentStep / (STEPS.COMPLETE)) * 100); 277 + 278 + // Get API key label and link based on provider 279 + const getApiKeyInfo = () => { 280 + if (!selectedProvider) return { label: '', link: '' }; 281 + 282 + switch (selectedProvider) { 283 + case 'openai': 284 + return { 285 + label: 'OpenAI API Key', 286 + link: 'https://platform.openai.com/api-keys' 287 + }; 288 + case 'google': 289 + return { 290 + label: 'Google AI API Key', 291 + link: 'https://ai.google.dev/' 292 + }; 293 + case 'anthropic': 294 + return { 295 + label: 'Anthropic API Key', 296 + link: 'https://console.anthropic.com/' 297 + }; 298 + default: 299 + return { label: '', link: '' }; 300 + } 301 + }; 302 + 303 + const apiKeyInfo = getApiKeyInfo(); 78 304 79 305 return ( 80 306 <div className="flex flex-col items-center justify-center min-h-screen bg-background p-4"> ··· 82 308 <CardHeader> 83 309 <div className="flex justify-between items-center"> 84 310 <div> 85 - <CardTitle className="text-2xl">Welcome to Pandora</CardTitle> 311 + <CardTitle className="text-2xl">Welcome to PR Cat</CardTitle> 86 312 <CardDescription> 87 313 Let's get your workspace set up in just a few steps 88 314 </CardDescription> ··· 93 319 </div> 94 320 </CardHeader> 95 321 96 - <Progress value={progressPercentage} className="mb-4 mx-6" /> 322 + <div className="px-6"> 323 + <Progress value={progressPercentage} className="mb-4" /> 324 + </div> 97 325 98 326 <CardContent> 99 327 {currentStep === STEPS.WELCOME && ( 100 328 <div className="space-y-4"> 101 - <h3 className="text-lg font-medium">Welcome to Pandora PR Categorizer!</h3> 102 329 <p> 103 - Pandora helps you categorize and analyze pull requests using AI. 104 - Let's set up your account in a few simple steps. 330 + PR Cat helps engineering leads who are in the trenches with their teams. Not an enterprise surveillance tool, but a collaborative platform that improves flow and removes barriers together. 105 331 </p> 106 332 <div className="flex flex-col space-y-2"> 107 333 <div className="flex items-center space-x-2 text-sm"> ··· 134 360 <div className="space-y-4"> 135 361 <h3 className="text-lg font-medium">Install GitHub App</h3> 136 362 <p> 137 - To analyze your pull requests, Pandora needs access to your GitHub repositories. 363 + To analyze your pull requests, PR Cat needs access to your GitHub repositories. 138 364 Install our GitHub App to continue. 139 365 </p> 140 366 ··· 147 373 <div className="flex flex-col items-center space-y-4 p-4 bg-muted/50 rounded-md"> 148 374 <Github className="h-10 w-10" /> 149 375 <p className="text-sm text-center"> 150 - Click the button below to install the Pandora GitHub App. 376 + Click the button below to install the PR Cat GitHub App. 151 377 You'll be redirected to GitHub to complete the installation. 152 378 </p> 153 379 <InstallGitHubApp onClick={handleGitHubAppInstall} /> ··· 160 386 <div className="space-y-4"> 161 387 <h3 className="text-lg font-medium">Configure AI Settings</h3> 162 388 <p> 163 - Pandora uses AI to categorize your pull requests. Choose your preferred AI provider and add your API key. 389 + PR Cat uses AI to categorize your pull requests. Choose your preferred AI provider and add your API key. 164 390 </p> 391 + 392 + {/* Provider Selection */} 165 393 <div className="space-y-2"> 166 - <p className="text-sm font-medium">Select AI Provider</p> 394 + <Label htmlFor="provider-select">Select AI Provider</Label> 167 395 <div className="grid grid-cols-3 gap-2"> 168 - <Button variant="outline" className="justify-start h-auto py-2" onClick={() => toast.info("OpenAI selected")}> 396 + <Button 397 + variant={selectedProvider === 'openai' ? 'default' : 'outline'} 398 + className="justify-start h-auto py-2" 399 + onClick={() => handleProviderSelect('openai')} 400 + > 169 401 <div className="flex flex-col items-start"> 170 402 <span>OpenAI</span> 171 403 <span className="text-xs text-muted-foreground">GPT-4o, GPT-3.5</span> 172 404 </div> 173 405 </Button> 174 - <Button variant="outline" className="justify-start h-auto py-2" onClick={() => toast.info("Google selected")}> 406 + <Button 407 + variant={selectedProvider === 'google' ? 'default' : 'outline'} 408 + className="justify-start h-auto py-2" 409 + onClick={() => handleProviderSelect('google')} 410 + > 175 411 <div className="flex flex-col items-start"> 176 412 <span>Google</span> 177 413 <span className="text-xs text-muted-foreground">Gemini</span> 178 414 </div> 179 415 </Button> 180 - <Button variant="outline" className="justify-start h-auto py-2" onClick={() => toast.info("Anthropic selected")}> 416 + <Button 417 + variant={selectedProvider === 'anthropic' ? 'default' : 'outline'} 418 + className="justify-start h-auto py-2" 419 + onClick={() => handleProviderSelect('anthropic')} 420 + > 181 421 <div className="flex flex-col items-start"> 182 422 <span>Anthropic</span> 183 423 <span className="text-xs text-muted-foreground">Claude</span> 184 424 </div> 185 425 </Button> 186 426 </div> 187 - <p className="text-sm text-muted-foreground mt-4"> 188 - You can configure these settings in detail later from the Settings page. 189 - </p> 190 427 </div> 428 + 429 + {/* Model Selection - only shown when provider is selected */} 430 + {selectedProvider && ( 431 + <div className="space-y-2"> 432 + <Label htmlFor="model-select">Select AI Model</Label> 433 + <Select 434 + value={selectedModelId || ''} 435 + onValueChange={(value) => setSelectedModelId(value || null)} 436 + > 437 + <SelectTrigger id="model-select"> 438 + <SelectValue placeholder={`Select ${selectedProvider} model`} /> 439 + </SelectTrigger> 440 + <SelectContent> 441 + {availableModels.map((model) => ( 442 + <SelectItem key={model.id} value={model.id}> 443 + {model.name} 444 + </SelectItem> 445 + ))} 446 + </SelectContent> 447 + </Select> 448 + </div> 449 + )} 450 + 451 + {/* API Key Input - only shown when provider is selected */} 452 + {selectedProvider && ( 453 + <div className="space-y-2"> 454 + <Label htmlFor="api-key-input">{apiKeyInfo.label}</Label> 455 + <Input 456 + id="api-key-input" 457 + type="password" 458 + placeholder="Enter your API key" 459 + value={apiKey} 460 + onChange={(e) => setApiKey(e.target.value)} 461 + /> 462 + <p className="text-xs text-muted-foreground"> 463 + Get your API key from <a href={apiKeyInfo.link} target="_blank" rel="noopener noreferrer" className="underline"> 464 + {selectedProvider === 'openai' ? 'OpenAI dashboard' : 465 + selectedProvider === 'google' ? 'Google AI Studio' : 'Anthropic Console'} 466 + </a>. 467 + </p> 468 + </div> 469 + )} 470 + 471 + <p className="text-sm text-muted-foreground mt-4"> 472 + You can configure these settings in detail later from the Settings page. 473 + </p> 191 474 </div> 192 475 )} 193 476 ··· 195 478 <div className="space-y-4"> 196 479 <h3 className="text-lg font-medium">Select Repositories</h3> 197 480 <p> 198 - Choose which repositories you want Pandora to analyze and categorize pull requests for. 481 + Choose which repositories you want PR Cat to analyze and categorize pull requests for. 199 482 </p> 200 - <div className="p-4 bg-muted/50 rounded-md"> 201 - <p className="text-sm text-center"> 202 - You can select repositories once GitHub organizations have been synced. 203 - This can be done from the dashboard after setup. 204 - </p> 205 - </div> 483 + 484 + {isLoadingOrgs ? ( 485 + <div className="p-4 bg-muted/50 rounded-md text-center"> 486 + <p>Loading your GitHub organizations...</p> 487 + </div> 488 + ) : organizations.length === 0 ? ( 489 + <div className="p-4 bg-muted/50 rounded-md"> 490 + <p className="text-sm text-center"> 491 + No GitHub organizations found. Please make sure you've installed the PR Cat GitHub App and have access to GitHub organizations. 492 + </p> 493 + </div> 494 + ) : ( 495 + <div className="space-y-4"> 496 + {/* Organization List */} 497 + <div className="space-y-2"> 498 + <Label>Select Organization</Label> 499 + <div className="flex flex-wrap gap-2"> 500 + {organizations.map((org) => ( 501 + <Button 502 + key={org.id} 503 + variant={selectedOrg?.id === org.id ? "default" : "outline"} 504 + size="sm" 505 + onClick={() => handleOrgSelect(org)} 506 + className="flex items-center gap-2" 507 + > 508 + <Avatar className="h-5 w-5"> 509 + <AvatarImage src={org.avatar_url || undefined} alt={org.login} /> 510 + <AvatarFallback>{org.login.charAt(0).toUpperCase()}</AvatarFallback> 511 + </Avatar> 512 + <span>{org.login}</span> 513 + </Button> 514 + ))} 515 + </div> 516 + </div> 517 + 518 + {/* Repository List */} 519 + {selectedOrg && ( 520 + <div className="space-y-2"> 521 + <Label>Select Repositories to Track</Label> 522 + {isLoadingRepos ? ( 523 + <p className="text-sm">Loading repositories...</p> 524 + ) : repositories.length === 0 ? ( 525 + <p className="text-sm">No repositories found for this organization.</p> 526 + ) : ( 527 + <div className="max-h-60 overflow-y-auto border rounded-md p-2"> 528 + {repositories.map((repo) => ( 529 + <div 530 + key={repo.id} 531 + className="flex items-center p-2 hover:bg-muted/50 rounded-md cursor-pointer" 532 + onClick={() => toggleRepository(repo.id)} 533 + > 534 + <Checkbox 535 + id={`repo-${repo.id}`} 536 + checked={selectedRepoIds.has(repo.id)} 537 + className="mr-2" 538 + /> 539 + <Label 540 + htmlFor={`repo-${repo.id}`} 541 + className="flex-grow cursor-pointer" 542 + > 543 + {repo.name} 544 + {repo.private && ( 545 + <span className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">Private</span> 546 + )} 547 + </Label> 548 + </div> 549 + ))} 550 + </div> 551 + )} 552 + <p className="text-xs text-muted-foreground"> 553 + Selected {selectedRepoIds.size} repositories for tracking 554 + </p> 555 + </div> 556 + )} 557 + </div> 558 + )} 206 559 </div> 207 560 )} 208 561 ··· 213 566 </div> 214 567 <h3 className="text-lg font-medium">Setup Complete!</h3> 215 568 <p> 216 - You're all set! Pandora is now ready to help you analyze and categorize your pull requests. 569 + You're all set! PR Cat is now ready to help you analyze and categorize your pull requests. 217 570 </p> 218 571 </div> 219 572 )}
+7 -21
components/site-header.tsx
··· 1 1 import { Separator } from "@/components/ui/separator" 2 2 import { SidebarTrigger } from "@/components/ui/sidebar" 3 - import { Button } from "@/components/ui/button" 4 - import { RefreshCcw, Settings, FileBarChart } from "lucide-react" 3 + import { PrcatLogo } from "@/components/ui/prcat-logo" 5 4 6 - export function SiteHeader() { 5 + export function SiteHeader({ pageTitle }: { pageTitle?: string }) { 7 6 return ( 8 7 <header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)"> 9 8 <div className="flex w-full items-center justify-between gap-1 px-4 lg:gap-2 lg:px-6"> ··· 13 12 orientation="vertical" 14 13 className="mx-2 data-[orientation=vertical]:h-4" 15 14 /> 16 - <h1 className="text-base font-medium">Dashboard</h1> 17 - </div> 18 - 19 - <div className="flex items-center gap-2"> 20 - <Button variant="outline" size="sm" className="gap-1.5"> 21 - <RefreshCcw size={16} /> 22 - <span className="hidden md:inline">Sync GitHub PRs</span> 23 - </Button> 24 - 25 - <Button variant="outline" size="sm" className="gap-1.5"> 26 - <FileBarChart size={16} /> 27 - <span className="hidden md:inline">Generate Report</span> 28 - </Button> 29 - 30 - <Button variant="outline" size="sm" className="gap-1.5"> 31 - <Settings size={16} /> 32 - <span className="hidden md:inline">Configure Categories</span> 33 - </Button> 15 + {pageTitle ? ( 16 + <h1 className="text-2xl font-semibold">{pageTitle}</h1> 17 + ) : ( 18 + <PrcatLogo fontSize="text-base" iconSize="h-4 w-4" /> 19 + )} 34 20 </div> 35 21 </div> 36 22 </header>
+108 -48
components/ui/ai-settings-tab.tsx
··· 8 8 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 9 9 import { Input } from '@/components/ui/input'; 10 10 import { toast } from 'sonner'; 11 - import { AiSettings as FetchedAiSettings, UpdateAiSettingsPayload } from '@/lib/repositories'; 11 + import { AiSettings as FetchedAiSettings, UpdateAiSettingsPayload, AIProvider } from '@/lib/repositories'; 12 12 import { Organization } from '@/lib/types'; 13 13 import { Avatar, AvatarImage, AvatarFallback } from './avatar'; 14 - import { allModels, ModelDefinition } from '@/lib/ai-models'; // Import shared models and definition 14 + import { allModels, ModelDefinition } from '@/lib/ai-models'; 15 15 16 16 export function AiSettingsTab() { 17 17 const { data: session, status: sessionStatus } = useSession(); ··· 20 20 21 21 const [fetchedSettings, setFetchedSettings] = useState<FetchedAiSettings | null>(null); 22 22 23 + const [selectedProvider, setSelectedProvider] = useState<AIProvider>(null); 23 24 const [selectedModelId, setSelectedModelId] = useState<string | null>(null); 24 25 const [openaiApiKeyInput, setOpenaiApiKeyInput] = useState(''); 25 26 const [googleApiKeyInput, setGoogleApiKeyInput] = useState(''); ··· 27 28 28 29 const [isLoadingSettings, setIsLoadingSettings] = useState(false); 29 30 const [isSaving, setIsSaving] = useState(false); 31 + 32 + // Filter models by selected provider 33 + const availableModels = selectedProvider 34 + ? allModels.filter(model => model.provider === selectedProvider) 35 + : []; 30 36 31 37 useEffect(() => { 32 38 if (session?.organizations && Array.isArray(session.organizations)) { ··· 39 45 if (!selectedOrganization) return; 40 46 setIsLoadingSettings(true); 41 47 setFetchedSettings(null); 48 + setSelectedProvider(null); 42 49 setSelectedModelId(null); 43 50 setOpenaiApiKeyInput(''); 44 51 setGoogleApiKeyInput(''); ··· 50 57 } 51 58 const data: FetchedAiSettings = await response.json(); 52 59 setFetchedSettings(data); 60 + setSelectedProvider(data.provider); 53 61 setSelectedModelId(data.selectedModelId); 54 62 } catch (error) { 55 63 toast.error(error instanceof Error ? error.message : 'Could not load AI settings.'); ··· 61 69 fetchSettings(); 62 70 } else { 63 71 setFetchedSettings(null); 72 + setSelectedProvider(null); 64 73 setSelectedModelId(null); 65 74 setOpenaiApiKeyInput(''); 66 75 setGoogleApiKeyInput(''); ··· 69 78 } 70 79 }, [selectedOrganization]); 71 80 72 - const currentSelectedModelDetails = selectedModelId ? allModels.find(m => m.id === selectedModelId) : null; 81 + // When provider changes, reset model selection 82 + useEffect(() => { 83 + if (selectedProvider) { 84 + // Check if current selected model is from the new provider 85 + const currentModelMatchesProvider = selectedModelId && 86 + allModels.some(m => m.id === selectedModelId && m.provider === selectedProvider); 87 + 88 + // If not, clear the model selection 89 + if (!currentModelMatchesProvider) { 90 + setSelectedModelId(null); 91 + } 92 + } 93 + }, [selectedProvider, selectedModelId]); 73 94 74 95 const handleSave = async () => { 75 96 if (!selectedOrganization) return; 76 97 setIsSaving(true); 77 98 78 99 const payload: UpdateAiSettingsPayload = { 100 + provider: selectedProvider, 79 101 selectedModelId: selectedModelId, 80 102 }; 81 103 82 - const modelDetails = selectedModelId ? allModels.find(m => m.id === selectedModelId) : null; 83 - 84 - if (modelDetails) { 104 + // Handle API key changes based on provider 105 + if (selectedProvider) { 85 106 let currentInput = ''; 86 107 let apiKeyCurrentlySet = false; 87 108 let apiKeyPayloadKey: keyof UpdateAiSettingsPayload | null = null; 88 109 89 - switch (modelDetails.provider) { 110 + switch (selectedProvider) { 90 111 case 'openai': 91 112 currentInput = openaiApiKeyInput; 92 113 apiKeyCurrentlySet = !!fetchedSettings?.isOpenAiKeySet; ··· 137 158 const fetchResponse = await fetch(`/api/organizations/${selectedOrganization.id}/ai-settings`); 138 159 const data: FetchedAiSettings = await fetchResponse.json(); 139 160 setFetchedSettings(data); 161 + setSelectedProvider(data.provider); 140 162 setSelectedModelId(data.selectedModelId); 141 163 setOpenaiApiKeyInput(''); 142 164 setGoogleApiKeyInput(''); ··· 247 269 </CardDescription> 248 270 </CardHeader> 249 271 <CardContent className="space-y-6"> 272 + {/* Provider Selection */} 250 273 <div className="space-y-2"> 251 - <Label htmlFor="ai-model-select">AI Model</Label> 274 + <Label htmlFor="provider-select">AI Provider</Label> 252 275 <Select 253 - value={selectedModelId === null ? '__none__' : selectedModelId} 276 + value={selectedProvider || ''} 254 277 onValueChange={(value) => { 255 - const newModelId = value === '__none__' ? null : value; 256 - setSelectedModelId(newModelId); 257 - setOpenaiApiKeyInput(''); 258 - setGoogleApiKeyInput(''); 259 - setAnthropicApiKeyInput(''); 278 + if (value === '') { 279 + setSelectedProvider(null); 280 + } else { 281 + setSelectedProvider(value as AIProvider); 282 + } 260 283 }} 261 284 > 262 - <SelectTrigger id="ai-model-select"> 263 - <SelectValue placeholder="Select a model (or None)" /> 285 + <SelectTrigger id="provider-select"> 286 + <SelectValue placeholder="Select an AI provider" /> 264 287 </SelectTrigger> 265 288 <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 - ))} 289 + <SelectItem value="">None (Disable AI Categorization)</SelectItem> 290 + <SelectItem value="openai">OpenAI</SelectItem> 291 + <SelectItem value="google">Google (Gemini)</SelectItem> 292 + <SelectItem value="anthropic">Anthropic (Claude)</SelectItem> 272 293 </SelectContent> 273 294 </Select> 274 295 </div> 296 + 297 + {/* Model Selection - only shown when provider is selected */} 298 + {selectedProvider && ( 299 + <div className="space-y-2"> 300 + <Label htmlFor="model-select">AI Model</Label> 301 + <Select 302 + value={selectedModelId || ''} 303 + onValueChange={(value) => setSelectedModelId(value || null)} 304 + > 305 + <SelectTrigger id="model-select"> 306 + <SelectValue placeholder={`Select ${selectedProvider} model`} /> 307 + </SelectTrigger> 308 + <SelectContent> 309 + {availableModels.map(model => ( 310 + <SelectItem key={model.id} value={model.id}> 311 + {model.name} 312 + </SelectItem> 313 + ))} 314 + </SelectContent> 315 + </Select> 316 + </div> 317 + )} 275 318 276 - {currentSelectedModelDetails && ( 319 + {/* API key input - show based on selected provider */} 320 + {selectedProvider === 'openai' && ( 277 321 <div className="space-y-2"> 278 - <Label htmlFor={`api-key-${currentSelectedModelDetails.provider}`}> 279 - {currentSelectedModelDetails.providerName} API Key 322 + <Label htmlFor="openai-key"> 323 + OpenAI API Key 324 + {fetchedSettings?.isOpenAiKeySet && <span className="ml-2 text-xs text-muted-foreground">(Already set)</span>} 280 325 </Label> 281 326 <Input 282 - id={`api-key-${currentSelectedModelDetails.provider}`} 327 + id="openai-key" 283 328 type="password" 284 - value={getApiKeyInputProps(currentSelectedModelDetails.provider).value} 285 - onChange={getApiKeyInputProps(currentSelectedModelDetails.provider).onChange} 286 - placeholder={getApiKeyInputProps(currentSelectedModelDetails.provider).placeholder} 329 + placeholder={getApiKeyInputProps('openai').placeholder} 330 + value={openaiApiKeyInput} 331 + onChange={getApiKeyInputProps('openai').onChange} 287 332 /> 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 - } 333 + <p className="text-xs text-muted-foreground">Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="underline">OpenAI dashboard</a>.</p> 303 334 </div> 304 335 )} 305 - 306 - {selectedModelId && !currentSelectedModelDetails && ( 307 - <p className="text-destructive text-xs">Error: Selected model details not found. Please re-select.</p> 336 + 337 + {selectedProvider === 'google' && ( 338 + <div className="space-y-2"> 339 + <Label htmlFor="google-key"> 340 + Google AI API Key 341 + {fetchedSettings?.isGoogleKeySet && <span className="ml-2 text-xs text-muted-foreground">(Already set)</span>} 342 + </Label> 343 + <Input 344 + id="google-key" 345 + type="password" 346 + placeholder={getApiKeyInputProps('google').placeholder} 347 + value={googleApiKeyInput} 348 + onChange={getApiKeyInputProps('google').onChange} 349 + /> 350 + <p className="text-xs text-muted-foreground">Get your API key from <a href="https://ai.google.dev/" target="_blank" rel="noopener noreferrer" className="underline">Google AI Studio</a>.</p> 351 + </div> 308 352 )} 309 353 354 + {selectedProvider === 'anthropic' && ( 355 + <div className="space-y-2"> 356 + <Label htmlFor="anthropic-key"> 357 + Anthropic API Key 358 + {fetchedSettings?.isAnthropicKeySet && <span className="ml-2 text-xs text-muted-foreground">(Already set)</span>} 359 + </Label> 360 + <Input 361 + id="anthropic-key" 362 + type="password" 363 + placeholder={getApiKeyInputProps('anthropic').placeholder} 364 + value={anthropicApiKeyInput} 365 + onChange={getApiKeyInputProps('anthropic').onChange} 366 + /> 367 + <p className="text-xs text-muted-foreground">Get your API key from <a href="https://console.anthropic.com/" target="_blank" rel="noopener noreferrer" className="underline">Anthropic Console</a>.</p> 368 + </div> 369 + )} 310 370 </CardContent> 311 371 <CardFooter> 312 - <Button onClick={handleSave} disabled={isSaving || isLoadingSettings || !selectedOrganization}> 372 + <Button onClick={handleSave} disabled={isSaving}> 313 373 {isSaving ? 'Saving...' : 'Save AI Settings'} 314 374 </Button> 315 375 </CardFooter>
+2 -2
components/ui/prcat-logo.tsx
··· 19 19 return ( 20 20 <div className={cn("inline-flex flex-row items-center", className)}> 21 21 <div className="flex flex-row items-center"> 22 - <span className={cn("inline-block", fontSize, dark ? "text-white" : "text-foreground")}>pr</span> 22 + <span className={cn("inline-block font-bold", fontSize, dark ? "text-white" : "text-foreground")}>PR</span> 23 23 <svg 24 24 xmlns="http://www.w3.org/2000/svg" 25 25 fill="none" ··· 37 37 clipRule="evenodd" 38 38 /> 39 39 </svg> 40 - <span className={cn("inline-block", fontSize, dark ? "text-white" : "text-foreground")}>cat</span> 40 + <span className={cn("inline-block font-bold", fontSize, dark ? "text-white" : "text-foreground")}>Cat</span> 41 41 </div> 42 42 </div> 43 43 );
+6 -1
components/ui/setup-status-alert.tsx
··· 5 5 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 6 6 import { Button } from '@/components/ui/button'; 7 7 import { useRouter } from 'next/navigation'; 8 + import { PrcatLogo } from '@/components/ui/prcat-logo'; 8 9 9 10 interface SetupStatusAlertProps { 10 11 className?: string; ··· 18 19 <AlertCircle className="h-4 w-4" /> 19 20 <AlertTitle>Setup Incomplete</AlertTitle> 20 21 <AlertDescription className="flex justify-between items-center"> 21 - <span>Your Pandora setup is incomplete. Complete the setup to enable PR categorization.</span> 22 + <div className="flex items-center"> 23 + <span>Your </span> 24 + <PrcatLogo className="mx-1" iconSize="h-3 w-3" fontSize="text-sm" /> 25 + <span> setup is incomplete. Complete the setup to enable PR categorization.</span> 26 + </div> 22 27 <Button 23 28 size="sm" 24 29 onClick={() => router.push('/onboarding')}
+21 -13
lib/repositories/settings-repository.ts
··· 1 1 import { query, execute } from '@/lib/db'; 2 2 import { Setting } from '@/lib/types'; 3 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 4 + // Keys for AI settings 5 + const AI_PROVIDER_KEY = 'ai_provider'; 9 6 const AI_SELECTED_MODEL_ID_KEY = 'ai_selected_model_id'; 10 7 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 8 + const AI_GOOGLE_API_KEY_KEY = 'ai_google_api_key'; 12 9 const AI_ANTHROPIC_API_KEY_KEY = 'ai_anthropic_api_key'; 13 10 11 + // Valid AI provider values 12 + export type AIProvider = 'openai' | 'google' | 'anthropic' | null; 13 + 14 14 // Interface for data returned to the client (keys are not sent) 15 - export interface AiSettings { // Renamed from GetAiSettingsResponse for brevity in frontend 15 + export interface AiSettings { 16 + provider: AIProvider; 16 17 selectedModelId: string | null; 17 18 isOpenAiKeySet: boolean; 18 19 isGoogleKeySet: boolean; ··· 21 22 22 23 // Interface for payload when updating settings (actual keys are sent) 23 24 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. 25 + provider?: AIProvider; 26 + selectedModelId?: string | null; 27 + openaiApiKey?: string | null; 26 28 googleApiKey?: string | null; 27 29 anthropicApiKey?: string | null; 28 30 } ··· 51 53 } 52 54 53 55 export async function getOrganizationAiSettings(organizationId: number): Promise<AiSettings> { 56 + const provider = await getOrganizationSetting(organizationId, AI_PROVIDER_KEY) as AIProvider; 54 57 const selectedModelId = await getOrganizationSetting(organizationId, AI_SELECTED_MODEL_ID_KEY); 55 58 const openAiKey = await getOrganizationSetting(organizationId, AI_OPENAI_API_KEY_KEY); 56 59 const googleKey = await getOrganizationSetting(organizationId, AI_GOOGLE_API_KEY_KEY); 57 60 const anthropicKey = await getOrganizationSetting(organizationId, AI_ANTHROPIC_API_KEY_KEY); 58 61 59 62 return { 63 + provider: provider || null, 60 64 selectedModelId, 61 - isOpenAiKeySet: !!openAiKey, // True if key exists and is not empty string (could refine if empty string is valid) 65 + isOpenAiKeySet: !!openAiKey, 62 66 isGoogleKeySet: !!googleKey, 63 67 isAnthropicKeySet: !!anthropicKey, 64 68 }; ··· 68 72 organizationId: number, 69 73 payload: UpdateAiSettingsPayload 70 74 ): Promise<void> { 75 + if (payload.provider !== undefined) { 76 + await updateOrganizationSetting(organizationId, AI_PROVIDER_KEY, payload.provider); 77 + } 71 78 if (payload.selectedModelId !== undefined) { 72 79 await updateOrganizationSetting(organizationId, AI_SELECTED_MODEL_ID_KEY, payload.selectedModelId); 73 80 } ··· 82 89 } 83 90 } 84 91 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> { 92 + // Get a specific API key for an organization and provider 93 + export async function getOrganizationApiKey(organizationId: number, provider: AIProvider): Promise<string | null> { 94 + if (!provider) return null; 95 + 87 96 let apiKeyDBKey = ''; 88 97 switch (provider) { 89 98 case 'openai': ··· 96 105 apiKeyDBKey = AI_ANTHROPIC_API_KEY_KEY; 97 106 break; 98 107 default: 99 - // Should not happen with TypeScript, but good for safety 100 108 console.error(`Invalid provider specified for getOrganizationApiKey: ${provider}`); 101 109 return null; 102 110 }
+1 -1
package.json
··· 1 1 { 2 - "name": "pandora", 2 + "name": "prcat", 3 3 "version": "0.1.0", 4 4 "private": true, 5 5 "scripts": {