kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
at main 611 lines 22 kB view raw
1import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2import { 3 AlertTriangle, 4 CheckCircle, 5 ExternalLink, 6 GitBranch, 7 Github, 8 Import, 9 Link, 10 RefreshCw, 11 Unlink, 12 XCircle, 13} from "lucide-react"; 14import React from "react"; 15import { useForm } from "react-hook-form"; 16import { z } from "zod/v4"; 17import { RepositoryBrowserModal } from "@/components/project/repository-browser-modal"; 18import { Badge } from "@/components/ui/badge"; 19import { Button } from "@/components/ui/button"; 20import { 21 Form, 22 FormControl, 23 FormField, 24 FormItem, 25 FormLabel, 26 FormMessage, 27} from "@/components/ui/form"; 28import { Input } from "@/components/ui/input"; 29import { Separator } from "@/components/ui/separator"; 30import type { VerifyGithubInstallationResponse } from "@/fetchers/github-integration/verify-github-installation"; 31import { 32 useCreateGithubIntegration, 33 useDeleteGithubIntegration, 34 useVerifyGithubInstallation, 35} from "@/hooks/mutations/github-integration/use-create-github-integration"; 36import useImportGithubIssues from "@/hooks/mutations/github-integration/use-import-github-issues"; 37import useGetGithubIntegration from "@/hooks/queries/github-integration/use-get-github-integration"; 38import { cn } from "@/lib/cn"; 39import { toast } from "@/lib/toast"; 40 41const githubIntegrationSchema = z.object({ 42 repositoryOwner: z 43 .string() 44 .min(1, "Repository owner is required") 45 .regex(/^[a-zA-Z0-9-]+$/, "Invalid repository owner format"), 46 repositoryName: z 47 .string() 48 .min(1, "Repository name is required") 49 .regex(/^[a-zA-Z0-9._-]+$/, "Invalid repository name format"), 50}); 51 52type GithubIntegrationFormValues = z.infer<typeof githubIntegrationSchema>; 53 54export function GitHubIntegrationSettings({ 55 projectId, 56}: { 57 projectId: string; 58}) { 59 const { data: integration, isLoading } = useGetGithubIntegration(projectId); 60 const { mutateAsync: createIntegration, isPending: isCreating } = 61 useCreateGithubIntegration(); 62 const { mutateAsync: deleteIntegration, isPending: isDeleting } = 63 useDeleteGithubIntegration(); 64 const { mutateAsync: verifyInstallation, isPending: isVerifying } = 65 useVerifyGithubInstallation(); 66 const { mutateAsync: importIssues, isPending: isImporting } = 67 useImportGithubIssues(); 68 69 const [verificationResult, setVerificationResult] = 70 React.useState<VerifyGithubInstallationResponse | null>(null); 71 const [showRepositoryBrowser, setShowRepositoryBrowser] = 72 React.useState(false); 73 74 const form = useForm<GithubIntegrationFormValues>({ 75 resolver: standardSchemaResolver(githubIntegrationSchema), 76 defaultValues: { 77 repositoryOwner: integration?.repositoryOwner || "", 78 repositoryName: integration?.repositoryName || "", 79 }, 80 }); 81 82 React.useEffect(() => { 83 if (integration) { 84 form.reset({ 85 repositoryOwner: integration.repositoryOwner, 86 repositoryName: integration.repositoryName, 87 }); 88 } 89 }, [integration, form]); 90 91 const repositoryOwner = form.watch("repositoryOwner"); 92 const repositoryName = form.watch("repositoryName"); 93 94 const handleVerifyInstallation = React.useCallback( 95 async (data: GithubIntegrationFormValues, showToast = true) => { 96 try { 97 const result = await verifyInstallation(data); 98 setVerificationResult(result); 99 100 if (showToast) { 101 if (result.isInstalled && result.hasRequiredPermissions) { 102 toast.success("GitHub App is properly installed!"); 103 } else if (result.isInstalled) { 104 toast.warning( 105 "GitHub App is installed but missing required permissions", 106 ); 107 } else if (result.repositoryExists) { 108 toast.warning( 109 "GitHub App needs to be installed on this repository", 110 ); 111 } else { 112 toast.error("Repository not found or not accessible"); 113 } 114 } 115 } catch (error) { 116 if (showToast) { 117 toast.error( 118 error instanceof Error 119 ? error.message 120 : "Failed to verify GitHub installation", 121 ); 122 } 123 setVerificationResult(null); 124 } 125 }, 126 [verifyInstallation], 127 ); 128 129 React.useEffect(() => { 130 if (repositoryOwner && repositoryName && form.formState.isValid) { 131 handleVerifyInstallation({ repositoryOwner, repositoryName }, false); 132 } 133 }, [ 134 repositoryOwner, 135 repositoryName, 136 form.formState.isValid, 137 handleVerifyInstallation, 138 ]); 139 140 const handleRepositorySelect = (repository: { 141 owner: string; 142 name: string; 143 }) => { 144 form.setValue("repositoryOwner", repository.owner, { 145 shouldValidate: true, 146 shouldDirty: true, 147 shouldTouch: true, 148 }); 149 form.setValue("repositoryName", repository.name, { 150 shouldValidate: true, 151 shouldDirty: true, 152 shouldTouch: true, 153 }); 154 setShowRepositoryBrowser(false); 155 156 setVerificationResult(null); 157 }; 158 159 const onSubmit = async (data: GithubIntegrationFormValues) => { 160 try { 161 const verification = await verifyInstallation(data); 162 163 if (!verification.isInstalled) { 164 toast.error("Please install the GitHub App on this repository first"); 165 return; 166 } 167 168 if (!verification.hasRequiredPermissions) { 169 toast.error( 170 `GitHub App is missing required permissions: ${verification.missingPermissions?.join(", ") || "issues"}. Please update the app permissions.`, 171 ); 172 return; 173 } 174 175 await createIntegration({ 176 projectId, 177 data, 178 }); 179 toast.success("GitHub integration updated successfully"); 180 } catch (error) { 181 toast.error( 182 error instanceof Error 183 ? error.message 184 : "Failed to update GitHub integration", 185 ); 186 } 187 }; 188 189 const handleDelete = async () => { 190 try { 191 await deleteIntegration(projectId); 192 form.reset({ repositoryOwner: "", repositoryName: "" }); 193 setVerificationResult(null); 194 toast.success("GitHub integration removed successfully"); 195 } catch (error) { 196 toast.error( 197 error instanceof Error 198 ? error.message 199 : "Failed to remove GitHub integration", 200 ); 201 } 202 }; 203 204 const handleImportIssues = async () => { 205 try { 206 await importIssues({ projectId }); 207 toast.success("Issues imported successfully"); 208 } catch (error) { 209 toast.error( 210 error instanceof Error ? error.message : "Failed to import issues", 211 ); 212 } 213 }; 214 215 if (isLoading) { 216 return ( 217 <div className="space-y-4"> 218 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 219 <div className="space-y-4"> 220 <div className="h-4 bg-muted rounded animate-pulse w-40" /> 221 <div className="h-4 bg-muted rounded animate-pulse w-full" /> 222 <div className="h-10 bg-muted rounded animate-pulse w-full" /> 223 </div> 224 </div> 225 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 226 <div className="space-y-4"> 227 <div className="h-4 bg-muted rounded animate-pulse w-40" /> 228 <div className="h-10 bg-muted rounded animate-pulse w-full" /> 229 <div className="h-10 bg-muted rounded animate-pulse w-full" /> 230 </div> 231 </div> 232 </div> 233 ); 234 } 235 236 const isConnected = !!integration && integration.isActive; 237 const canImport = 238 isConnected && 239 verificationResult?.isInstalled && 240 verificationResult?.hasRequiredPermissions; 241 242 return ( 243 <div className="space-y-4"> 244 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 245 <div className="flex items-center justify-between"> 246 <div className="space-y-0.5"> 247 <p className="text-sm font-medium">Connection Status</p> 248 {isConnected ? ( 249 <p className="text-xs text-muted-foreground"> 250 Repository connected and active 251 </p> 252 ) : ( 253 <p className="text-xs text-muted-foreground"> 254 No repository connected 255 </p> 256 )} 257 </div> 258 <div className="flex items-center gap-3"> 259 {isConnected ? ( 260 <div className="flex items-center gap-2"> 261 <Badge variant="secondary" className="gap-1"> 262 <CheckCircle className="w-3 h-3" /> 263 Connected 264 </Badge> 265 </div> 266 ) : ( 267 <Badge variant="outline" className="gap-1"> 268 <XCircle className="w-3 h-3" /> 269 Not Connected 270 </Badge> 271 )} 272 </div> 273 </div> 274 275 {isConnected && ( 276 <> 277 <Separator /> 278 <div className="flex items-center justify-between"> 279 <div className="space-y-0.5"> 280 <p className="text-sm font-medium">Repository</p> 281 <p className="text-xs text-muted-foreground"> 282 Connected GitHub repository 283 </p> 284 </div> 285 <div className="flex items-center gap-2 text-sm"> 286 <Github className="w-4 h-4" /> 287 <span className="font-medium"> 288 {integration.repositoryOwner}/{integration.repositoryName} 289 </span> 290 <a 291 href={`https://github.com/${integration.repositoryOwner}/${integration.repositoryName}`} 292 target="_blank" 293 rel="noopener noreferrer" 294 className="text-primary hover:text-primary/80 transition-colors" 295 > 296 <ExternalLink className="w-3 h-3" /> 297 </a> 298 </div> 299 </div> 300 </> 301 )} 302 303 {isConnected && verificationResult && ( 304 <> 305 <Separator /> 306 <div className="flex items-center justify-between"> 307 <div className="space-y-0.5"> 308 <p className="text-sm font-medium">GitHub App Status</p> 309 <p className="text-xs text-muted-foreground"> 310 Installation and permissions status 311 </p> 312 </div> 313 <div className="flex items-center gap-2 text-sm"> 314 {verificationResult.isInstalled && 315 verificationResult.hasRequiredPermissions ? ( 316 <> 317 <CheckCircle className="h-4 w-4 text-success-foreground" /> 318 <span className="font-medium text-success-foreground"> 319 Properly configured 320 </span> 321 </> 322 ) : verificationResult.isInstalled ? ( 323 <> 324 <AlertTriangle className="h-4 w-4 text-warning-foreground" /> 325 <span className="font-medium text-warning-foreground"> 326 Missing permissions 327 </span> 328 </> 329 ) : ( 330 <> 331 <XCircle className="h-4 w-4 text-destructive-foreground" /> 332 <span className="font-medium text-destructive-foreground"> 333 Not installed 334 </span> 335 </> 336 )} 337 </div> 338 </div> 339 </> 340 )} 341 </div> 342 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 343 <Form {...form}> 344 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 345 <FormField 346 control={form.control} 347 name="repositoryOwner" 348 render={({ field }) => ( 349 <FormItem> 350 <div className="flex items-center justify-between"> 351 <div className="space-y-0.5"> 352 <FormLabel className="text-sm font-medium"> 353 Repository Owner 354 </FormLabel> 355 <p className="text-xs text-muted-foreground"> 356 GitHub username or organization 357 </p> 358 </div> 359 <FormControl> 360 <Input 361 className="w-64" 362 placeholder="e.g., octocat" 363 {...field} 364 disabled={isCreating || isDeleting} 365 /> 366 </FormControl> 367 </div> 368 <FormMessage /> 369 </FormItem> 370 )} 371 /> 372 373 <Separator /> 374 375 <FormField 376 control={form.control} 377 name="repositoryName" 378 render={({ field }) => ( 379 <FormItem> 380 <div className="flex items-center justify-between"> 381 <div className="space-y-0.5"> 382 <FormLabel className="text-sm font-medium"> 383 Repository Name 384 </FormLabel> 385 <p className="text-xs text-muted-foreground"> 386 The repository name 387 </p> 388 </div> 389 <FormControl> 390 <Input 391 className="w-64" 392 placeholder="e.g., my-project" 393 {...field} 394 disabled={isCreating || isDeleting} 395 /> 396 </FormControl> 397 </div> 398 <FormMessage /> 399 </FormItem> 400 )} 401 /> 402 403 <Separator /> 404 405 <div className="flex items-center justify-between"> 406 <div className="space-y-0.5"> 407 <p className="text-sm font-medium">Actions</p> 408 <p className="text-xs text-muted-foreground"> 409 Manage your repository connection 410 </p> 411 </div> 412 <div className="flex flex-wrap gap-2"> 413 <Button 414 type="button" 415 variant="outline" 416 size="sm" 417 onClick={() => setShowRepositoryBrowser(true)} 418 className="gap-2" 419 > 420 <GitBranch className="size-3" /> 421 Browse 422 </Button> 423 424 <Button 425 type="button" 426 variant="outline" 427 size="sm" 428 onClick={() => handleVerifyInstallation(form.getValues())} 429 disabled={isVerifying || !form.formState.isValid} 430 className="gap-2" 431 > 432 <RefreshCw 433 className={cn("size-3", isVerifying && "animate-spin")} 434 /> 435 Verify 436 </Button> 437 438 <Button 439 type="submit" 440 size="sm" 441 disabled={ 442 isCreating || 443 isDeleting || 444 !form.formState.isValid || 445 (verificationResult 446 ? !verificationResult.isInstalled || 447 !verificationResult.hasRequiredPermissions 448 : false) 449 } 450 className="gap-2" 451 > 452 <Link className="size-3" /> 453 {isConnected ? "Update" : "Connect"} 454 </Button> 455 456 {isConnected && ( 457 <Button 458 type="button" 459 variant="destructive" 460 size="sm" 461 onClick={handleDelete} 462 disabled={isCreating || isDeleting} 463 className="gap-2" 464 > 465 <Unlink className="size-3" /> 466 Disconnect 467 </Button> 468 )} 469 </div> 470 </div> 471 </form> 472 </Form> 473 474 {verificationResult && ( 475 <> 476 <Separator /> 477 <div className="space-y-2"> 478 <div 479 className={cn( 480 "flex items-start gap-3 p-3 border rounded-md text-sm", 481 verificationResult.isInstalled && 482 verificationResult.hasRequiredPermissions 483 ? "border-success/25 bg-success/10" 484 : verificationResult.isInstalled 485 ? "border-warning/25 bg-warning/10" 486 : verificationResult.repositoryExists 487 ? "border-warning/25 bg-warning/10" 488 : "border-destructive/25 bg-destructive/10", 489 )} 490 > 491 {verificationResult.isInstalled && 492 verificationResult.hasRequiredPermissions ? ( 493 <CheckCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-success-foreground" /> 494 ) : verificationResult.isInstalled || 495 verificationResult.repositoryExists ? ( 496 <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning-foreground" /> 497 ) : ( 498 <XCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive-foreground" /> 499 )} 500 <div className="flex-1"> 501 <p className="font-medium">{verificationResult.message}</p> 502 503 {verificationResult.isInstalled && 504 !verificationResult.hasRequiredPermissions && 505 verificationResult.missingPermissions && ( 506 <div className="mt-2"> 507 <p className="text-xs mb-2"> 508 Missing permissions:{" "} 509 <strong> 510 {verificationResult.missingPermissions.join(", ")} 511 </strong> 512 </p> 513 <div className="flex gap-2"> 514 {verificationResult.settingsUrl && ( 515 <Button 516 variant="outline" 517 size="sm" 518 onClick={() => 519 window.open( 520 verificationResult.settingsUrl, 521 "_blank", 522 ) 523 } 524 className="gap-2" 525 > 526 <ExternalLink className="w-3 h-3" /> 527 Update Permissions 528 </Button> 529 )} 530 </div> 531 </div> 532 )} 533 534 {!verificationResult.isInstalled && 535 verificationResult.repositoryExists && ( 536 <div className="mt-2"> 537 {verificationResult.installationUrl && ( 538 <Button 539 variant="outline" 540 size="sm" 541 onClick={() => 542 window.open( 543 verificationResult.installationUrl, 544 "_blank", 545 ) 546 } 547 className="gap-2" 548 > 549 <ExternalLink className="w-3 h-3" /> 550 Install GitHub App 551 </Button> 552 )} 553 </div> 554 )} 555 </div> 556 </div> 557 </div> 558 </> 559 )} 560 </div> 561 562 {isConnected && ( 563 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 564 <div className="flex items-center justify-between"> 565 <div className="space-y-0.5"> 566 <p className="text-sm font-medium">Import GitHub Issues</p> 567 <p className="text-xs text-muted-foreground"> 568 Import existing issues from your GitHub repository as tasks 569 </p> 570 </div> 571 <div className="flex items-center gap-2"> 572 <Button 573 onClick={handleImportIssues} 574 disabled={isImporting || !canImport} 575 className="gap-2" 576 size="sm" 577 variant="outline" 578 > 579 {isImporting ? ( 580 <RefreshCw className="size-3 animate-spin" /> 581 ) : ( 582 <Import className="size-3" /> 583 )} 584 {isImporting ? "Importing..." : "Import Issues"} 585 </Button> 586 </div> 587 </div> 588 {!canImport && ( 589 <> 590 <Separator /> 591 <p className="text-xs text-muted-foreground"> 592 Complete the repository configuration above to enable importing 593 </p> 594 </> 595 )} 596 </div> 597 )} 598 599 <RepositoryBrowserModal 600 open={showRepositoryBrowser} 601 onOpenChange={setShowRepositoryBrowser} 602 onSelectRepository={handleRepositorySelect} 603 selectedRepository={ 604 repositoryOwner && repositoryName 605 ? `${repositoryOwner}/${repositoryName}` 606 : undefined 607 } 608 /> 609 </div> 610 ); 611}