kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): add Gitea integration settings and workflow UI

Tin f1362d9f 90badc77

+951 -32
+727
apps/web/src/components/project/gitea-integration-settings.tsx
··· 1 + import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2 + import { 3 + AlertTriangle, 4 + CheckCircle, 5 + ExternalLink, 6 + GitBranch, 7 + Import, 8 + Link, 9 + RefreshCw, 10 + Unlink, 11 + XCircle, 12 + } from "lucide-react"; 13 + import React from "react"; 14 + import { useForm } from "react-hook-form"; 15 + import { useTranslation } from "react-i18next"; 16 + import { z } from "zod/v4"; 17 + import { GiteaRepositoryBrowserModal } from "@/components/project/gitea-repository-browser-modal"; 18 + import { Badge } from "@/components/ui/badge"; 19 + import { Button } from "@/components/ui/button"; 20 + import { 21 + Form, 22 + FormControl, 23 + FormField, 24 + FormItem, 25 + FormLabel, 26 + FormMessage, 27 + } from "@/components/ui/form"; 28 + import { Input } from "@/components/ui/input"; 29 + import { Separator } from "@/components/ui/separator"; 30 + import { Switch } from "@/components/ui/switch"; 31 + import type { VerifyGiteaAccessResponse } from "@/fetchers/gitea-integration/verify-gitea-access"; 32 + import { 33 + useCreateGiteaIntegration, 34 + useDeleteGiteaIntegration, 35 + useVerifyGiteaAccess, 36 + } from "@/hooks/mutations/gitea-integration/use-create-gitea-integration"; 37 + import useImportGiteaIssues from "@/hooks/mutations/gitea-integration/use-import-gitea-issues"; 38 + import { useUpdateGiteaIntegration } from "@/hooks/mutations/gitea-integration/use-update-gitea-integration"; 39 + import useGetGiteaIntegration from "@/hooks/queries/gitea-integration/use-get-gitea-integration"; 40 + import { cn } from "@/lib/cn"; 41 + import { toast } from "@/lib/toast"; 42 + 43 + type GiteaIntegrationFormValues = { 44 + baseUrl: string; 45 + accessToken: string; 46 + repositoryOwner: string; 47 + repositoryName: string; 48 + }; 49 + 50 + export function GiteaIntegrationSettings({ projectId }: { projectId: string }) { 51 + const { t } = useTranslation(); 52 + 53 + const giteaIntegrationSchema = React.useMemo( 54 + () => 55 + z.object({ 56 + baseUrl: z 57 + .string() 58 + .min(1, t("settings:giteaIntegration.validation.baseUrlRequired")) 59 + .refine((s) => { 60 + try { 61 + new URL(s); 62 + return true; 63 + } catch { 64 + return false; 65 + } 66 + }, t("settings:giteaIntegration.validation.baseUrlInvalid")), 67 + accessToken: z.string(), 68 + repositoryOwner: z 69 + .string() 70 + .min(1, t("settings:giteaIntegration.validation.ownerRequired")) 71 + .regex( 72 + /^[a-zA-Z0-9_.-]+$/, 73 + t("settings:giteaIntegration.validation.ownerInvalid"), 74 + ), 75 + repositoryName: z 76 + .string() 77 + .min(1, t("settings:giteaIntegration.validation.nameRequired")) 78 + .regex( 79 + /^[a-zA-Z0-9._-]+$/, 80 + t("settings:giteaIntegration.validation.nameInvalid"), 81 + ), 82 + }), 83 + [t], 84 + ); 85 + 86 + const { data: integration, isLoading } = useGetGiteaIntegration(projectId); 87 + const { mutateAsync: createIntegration, isPending: isCreating } = 88 + useCreateGiteaIntegration(); 89 + const { mutateAsync: deleteIntegration, isPending: isDeleting } = 90 + useDeleteGiteaIntegration(); 91 + const { mutateAsync: verifyAccess, isPending: isVerifying } = 92 + useVerifyGiteaAccess(); 93 + const { mutateAsync: importIssues, isPending: isImporting } = 94 + useImportGiteaIssues(); 95 + const { mutateAsync: updateGiteaSettings, isPending: isUpdatingSettings } = 96 + useUpdateGiteaIntegration(); 97 + 98 + const [verificationResult, setVerificationResult] = 99 + React.useState<VerifyGiteaAccessResponse | null>(null); 100 + const [showRepositoryBrowser, setShowRepositoryBrowser] = 101 + React.useState(false); 102 + 103 + const form = useForm<GiteaIntegrationFormValues>({ 104 + resolver: standardSchemaResolver(giteaIntegrationSchema), 105 + defaultValues: { 106 + baseUrl: "", 107 + accessToken: "", 108 + repositoryOwner: "", 109 + repositoryName: "", 110 + }, 111 + }); 112 + 113 + React.useEffect(() => { 114 + if (integration?.baseUrl) { 115 + form.reset({ 116 + baseUrl: integration.baseUrl, 117 + accessToken: "", 118 + repositoryOwner: integration.repositoryOwner, 119 + repositoryName: integration.repositoryName, 120 + }); 121 + } 122 + }, [integration, form]); 123 + 124 + const runVerify = React.useCallback( 125 + async (data: GiteaIntegrationFormValues, showToast = true) => { 126 + const token = data.accessToken.trim(); 127 + if (!token && integration) { 128 + return; 129 + } 130 + if (!token && !integration) { 131 + if (showToast) { 132 + toast.error(t("settings:giteaIntegration.toast.tokenRequiredVerify")); 133 + } 134 + setVerificationResult(null); 135 + return; 136 + } 137 + try { 138 + const result = await verifyAccess({ 139 + baseUrl: data.baseUrl, 140 + accessToken: token, 141 + repositoryOwner: data.repositoryOwner, 142 + repositoryName: data.repositoryName, 143 + }); 144 + setVerificationResult(result); 145 + if (showToast) { 146 + if (result.isInstalled && result.hasRequiredPermissions) { 147 + toast.success(t("settings:giteaIntegration.toast.verifyOk")); 148 + } else if (!result.repositoryExists) { 149 + toast.error(t("settings:giteaIntegration.toast.repoNotFound")); 150 + } else { 151 + toast.warning(t("settings:giteaIntegration.toast.verifyWarning")); 152 + } 153 + } 154 + } catch (error) { 155 + if (showToast) { 156 + toast.error( 157 + error instanceof Error 158 + ? error.message 159 + : t("settings:giteaIntegration.toast.verifyError"), 160 + ); 161 + } 162 + setVerificationResult(null); 163 + } 164 + }, 165 + [verifyAccess, integration, t], 166 + ); 167 + 168 + const baseUrl = form.watch("baseUrl"); 169 + const accessToken = form.watch("accessToken"); 170 + const repositoryOwner = form.watch("repositoryOwner"); 171 + const repositoryName = form.watch("repositoryName"); 172 + 173 + React.useEffect(() => { 174 + if ( 175 + !baseUrl || 176 + !repositoryOwner || 177 + !repositoryName || 178 + !form.formState.isValid 179 + ) { 180 + return; 181 + } 182 + if (!accessToken.trim()) { 183 + return; 184 + } 185 + runVerify(form.getValues(), false); 186 + }, [ 187 + baseUrl, 188 + repositoryOwner, 189 + repositoryName, 190 + accessToken, 191 + form.formState.isValid, 192 + runVerify, 193 + form, 194 + ]); 195 + 196 + const onSubmit = async (data: GiteaIntegrationFormValues) => { 197 + try { 198 + if (!data.accessToken.trim() && !integration) { 199 + toast.error(t("settings:giteaIntegration.toast.tokenRequired")); 200 + return; 201 + } 202 + 203 + if (data.accessToken.trim()) { 204 + const verification = await verifyAccess({ 205 + baseUrl: data.baseUrl, 206 + accessToken: data.accessToken.trim(), 207 + repositoryOwner: data.repositoryOwner, 208 + repositoryName: data.repositoryName, 209 + }); 210 + 211 + if (!verification.isInstalled || !verification.hasRequiredPermissions) { 212 + toast.error(t("settings:giteaIntegration.toast.verifyFirst")); 213 + return; 214 + } 215 + } 216 + 217 + await createIntegration({ 218 + projectId, 219 + data: { 220 + baseUrl: data.baseUrl, 221 + ...(data.accessToken.trim() 222 + ? { accessToken: data.accessToken.trim() } 223 + : {}), 224 + repositoryOwner: data.repositoryOwner, 225 + repositoryName: data.repositoryName, 226 + }, 227 + }); 228 + form.setValue("accessToken", ""); 229 + toast.success(t("settings:giteaIntegration.toast.updated")); 230 + } catch (error) { 231 + toast.error( 232 + error instanceof Error 233 + ? error.message 234 + : t("settings:giteaIntegration.toast.updateError"), 235 + ); 236 + } 237 + }; 238 + 239 + const handleDelete = async () => { 240 + try { 241 + await deleteIntegration(projectId); 242 + form.reset({ 243 + baseUrl: "", 244 + accessToken: "", 245 + repositoryOwner: "", 246 + repositoryName: "", 247 + }); 248 + setVerificationResult(null); 249 + toast.success(t("settings:giteaIntegration.toast.removed")); 250 + } catch (error) { 251 + toast.error( 252 + error instanceof Error 253 + ? error.message 254 + : t("settings:giteaIntegration.toast.removeError"), 255 + ); 256 + } 257 + }; 258 + 259 + const handleImportIssues = async () => { 260 + try { 261 + await importIssues(projectId); 262 + toast.success(t("settings:giteaIntegration.toast.issuesImported")); 263 + } catch (error) { 264 + toast.error( 265 + error instanceof Error 266 + ? error.message 267 + : t("settings:giteaIntegration.toast.importError"), 268 + ); 269 + } 270 + }; 271 + 272 + const handleRepositorySelect = (repository: { 273 + owner: string; 274 + name: string; 275 + }) => { 276 + form.setValue("repositoryOwner", repository.owner, { 277 + shouldValidate: true, 278 + shouldDirty: true, 279 + shouldTouch: true, 280 + }); 281 + form.setValue("repositoryName", repository.name, { 282 + shouldValidate: true, 283 + shouldDirty: true, 284 + shouldTouch: true, 285 + }); 286 + setShowRepositoryBrowser(false); 287 + setVerificationResult(null); 288 + }; 289 + 290 + if (isLoading) { 291 + return ( 292 + <div className="space-y-4"> 293 + <div className="h-10 bg-muted rounded animate-pulse w-full" /> 294 + </div> 295 + ); 296 + } 297 + 298 + const isConnected = !!integration && integration.isActive; 299 + const canImport = 300 + isConnected && 301 + verificationResult?.isInstalled && 302 + verificationResult?.hasRequiredPermissions; 303 + 304 + const repoUrl = 305 + integration?.baseUrl && integration.repositoryOwner 306 + ? `${integration.baseUrl.replace(/\/$/, "")}/${integration.repositoryOwner}/${integration.repositoryName}` 307 + : null; 308 + 309 + return ( 310 + <div className="space-y-4"> 311 + <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 312 + <div className="flex items-center justify-between"> 313 + <div className="space-y-0.5"> 314 + <p className="text-sm font-medium"> 315 + {t("settings:giteaIntegration.connectionStatus")} 316 + </p> 317 + {isConnected ? ( 318 + <p className="text-xs text-muted-foreground"> 319 + {t("settings:giteaIntegration.connectedActive")} 320 + </p> 321 + ) : ( 322 + <p className="text-xs text-muted-foreground"> 323 + {t("settings:giteaIntegration.notConnectedHint")} 324 + </p> 325 + )} 326 + </div> 327 + {isConnected ? ( 328 + <Badge variant="secondary" className="gap-1"> 329 + <CheckCircle className="w-3 h-3" /> 330 + {t("settings:giteaIntegration.badgeConnected")} 331 + </Badge> 332 + ) : ( 333 + <Badge variant="outline" className="gap-1"> 334 + <XCircle className="w-3 h-3" /> 335 + {t("settings:giteaIntegration.badgeNotConnected")} 336 + </Badge> 337 + )} 338 + </div> 339 + 340 + {isConnected && integration && ( 341 + <> 342 + <Separator /> 343 + <div className="flex items-center justify-between"> 344 + <div className="space-y-0.5"> 345 + <p className="text-sm font-medium"> 346 + {t("settings:giteaIntegration.repository")} 347 + </p> 348 + <p className="text-xs text-muted-foreground"> 349 + {t("settings:giteaIntegration.repositoryHint")} 350 + </p> 351 + </div> 352 + <div className="flex items-center gap-2 text-sm"> 353 + <span className="font-medium"> 354 + {integration.repositoryOwner}/{integration.repositoryName} 355 + </span> 356 + {repoUrl && ( 357 + <a 358 + href={repoUrl} 359 + target="_blank" 360 + rel="noopener noreferrer" 361 + className="text-primary hover:text-primary/80 transition-colors" 362 + > 363 + <ExternalLink className="w-3 h-3" /> 364 + </a> 365 + )} 366 + </div> 367 + </div> 368 + 369 + <Separator /> 370 + <div className="flex items-center justify-between gap-4"> 371 + <div className="min-w-0 flex-1 space-y-0.5"> 372 + <p className="text-sm font-medium"> 373 + {t("settings:giteaIntegration.commentTaskLinkTitle")} 374 + </p> 375 + <p className="text-xs text-muted-foreground"> 376 + {t("settings:giteaIntegration.commentTaskLinkHint")} 377 + </p> 378 + </div> 379 + <Switch 380 + checked={integration.commentTaskLinkOnGiteaIssue !== false} 381 + onCheckedChange={async (checked) => { 382 + try { 383 + await updateGiteaSettings({ 384 + projectId, 385 + json: { commentTaskLinkOnGiteaIssue: checked }, 386 + }); 387 + toast.success( 388 + checked 389 + ? t("settings:giteaIntegration.toast.commentOnEnabled") 390 + : t( 391 + "settings:giteaIntegration.toast.commentOnDisabled", 392 + ), 393 + ); 394 + } catch (error) { 395 + toast.error( 396 + error instanceof Error 397 + ? error.message 398 + : t( 399 + "settings:giteaIntegration.toast.settingsUpdateError", 400 + ), 401 + ); 402 + } 403 + }} 404 + disabled={isUpdatingSettings} 405 + /> 406 + </div> 407 + 408 + {integration.webhookUrl && ( 409 + <> 410 + <Separator /> 411 + <div className="space-y-2 text-xs"> 412 + <p className="font-medium text-sm"> 413 + {t("settings:giteaIntegration.webhookTitle")} 414 + </p> 415 + <p className="text-muted-foreground"> 416 + {t("settings:giteaIntegration.webhookHint")} 417 + </p> 418 + <code className="block break-all rounded bg-muted px-2 py-1 text-[11px]"> 419 + {integration.webhookUrl} 420 + </code> 421 + <p className="text-muted-foreground mt-2"> 422 + {t("settings:giteaIntegration.webhookSecretLabel")} 423 + </p> 424 + <code className="block break-all rounded bg-muted px-2 py-1 text-[11px]"> 425 + {integration.webhookSecret} 426 + </code> 427 + </div> 428 + </> 429 + )} 430 + </> 431 + )} 432 + </div> 433 + 434 + <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 435 + <Form {...form}> 436 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 437 + <FormField 438 + control={form.control} 439 + name="baseUrl" 440 + render={({ field }) => ( 441 + <FormItem> 442 + <div className="flex items-center justify-between gap-4"> 443 + <div className="space-y-0.5"> 444 + <FormLabel className="text-sm font-medium"> 445 + {t("settings:giteaIntegration.baseUrlLabel")} 446 + </FormLabel> 447 + <p className="text-xs text-muted-foreground"> 448 + {t("settings:giteaIntegration.baseUrlHint")} 449 + </p> 450 + </div> 451 + <FormControl> 452 + <Input 453 + className="w-72" 454 + placeholder="https://gitea.example.com" 455 + {...field} 456 + disabled={isCreating || isDeleting} 457 + /> 458 + </FormControl> 459 + </div> 460 + <FormMessage /> 461 + </FormItem> 462 + )} 463 + /> 464 + 465 + <Separator /> 466 + 467 + <FormField 468 + control={form.control} 469 + name="accessToken" 470 + render={({ field }) => ( 471 + <FormItem> 472 + <div className="flex items-center justify-between gap-4"> 473 + <div className="space-y-0.5"> 474 + <FormLabel className="text-sm font-medium"> 475 + {t("settings:giteaIntegration.tokenLabel")} 476 + </FormLabel> 477 + <p className="text-xs text-muted-foreground"> 478 + {t("settings:giteaIntegration.tokenHint")} 479 + {integration?.maskedAccessToken 480 + ? ` (${t("settings:giteaIntegration.currentToken")}: ${integration.maskedAccessToken})` 481 + : null} 482 + </p> 483 + </div> 484 + <FormControl> 485 + <Input 486 + className="w-72" 487 + type="password" 488 + autoComplete="off" 489 + placeholder={ 490 + integration 491 + ? t( 492 + "settings:giteaIntegration.tokenPlaceholderUpdate", 493 + ) 494 + : t("settings:giteaIntegration.tokenPlaceholder") 495 + } 496 + {...field} 497 + disabled={isCreating || isDeleting} 498 + /> 499 + </FormControl> 500 + </div> 501 + <FormMessage /> 502 + </FormItem> 503 + )} 504 + /> 505 + 506 + <Separator /> 507 + 508 + <FormField 509 + control={form.control} 510 + name="repositoryOwner" 511 + render={({ field }) => ( 512 + <FormItem> 513 + <div className="flex items-center justify-between gap-4"> 514 + <div className="space-y-0.5"> 515 + <FormLabel className="text-sm font-medium"> 516 + {t("settings:giteaIntegration.ownerLabel")} 517 + </FormLabel> 518 + <p className="text-xs text-muted-foreground"> 519 + {t("settings:giteaIntegration.ownerHint")} 520 + </p> 521 + </div> 522 + <FormControl> 523 + <Input 524 + className="w-64" 525 + {...field} 526 + disabled={isCreating || isDeleting} 527 + /> 528 + </FormControl> 529 + </div> 530 + <FormMessage /> 531 + </FormItem> 532 + )} 533 + /> 534 + 535 + <Separator /> 536 + 537 + <FormField 538 + control={form.control} 539 + name="repositoryName" 540 + render={({ field }) => ( 541 + <FormItem> 542 + <div className="flex items-center justify-between gap-4"> 543 + <div className="space-y-0.5"> 544 + <FormLabel className="text-sm font-medium"> 545 + {t("settings:giteaIntegration.repoNameLabel")} 546 + </FormLabel> 547 + <p className="text-xs text-muted-foreground"> 548 + {t("settings:giteaIntegration.repoNameHint")} 549 + </p> 550 + </div> 551 + <FormControl> 552 + <Input 553 + className="w-64" 554 + {...field} 555 + disabled={isCreating || isDeleting} 556 + /> 557 + </FormControl> 558 + </div> 559 + <FormMessage /> 560 + </FormItem> 561 + )} 562 + /> 563 + 564 + <Separator /> 565 + 566 + <div className="flex items-center justify-between"> 567 + <div className="space-y-0.5"> 568 + <p className="text-sm font-medium"> 569 + {t("settings:giteaIntegration.actionsTitle")} 570 + </p> 571 + <p className="text-xs text-muted-foreground"> 572 + {t("settings:giteaIntegration.actionsHint")} 573 + </p> 574 + </div> 575 + <div className="flex flex-wrap gap-2"> 576 + <Button 577 + type="button" 578 + variant="outline" 579 + size="sm" 580 + onClick={() => setShowRepositoryBrowser(true)} 581 + className="gap-2" 582 + disabled={!baseUrl || !accessToken.trim()} 583 + > 584 + <GitBranch className="size-3" /> 585 + {t("settings:giteaIntegration.browse")} 586 + </Button> 587 + 588 + <Button 589 + type="button" 590 + variant="outline" 591 + size="sm" 592 + onClick={() => runVerify(form.getValues())} 593 + disabled={ 594 + isVerifying || 595 + !form.formState.isValid || 596 + (!accessToken.trim() && !integration) 597 + } 598 + className="gap-2" 599 + > 600 + <RefreshCw 601 + className={cn("size-3", isVerifying && "animate-spin")} 602 + /> 603 + {t("settings:giteaIntegration.verify")} 604 + </Button> 605 + 606 + <Button 607 + type="submit" 608 + size="sm" 609 + disabled={ 610 + isCreating || 611 + isDeleting || 612 + !form.formState.isValid || 613 + (verificationResult 614 + ? !verificationResult.isInstalled || 615 + !verificationResult.hasRequiredPermissions 616 + : false) 617 + } 618 + className="gap-2" 619 + > 620 + <Link className="size-3" /> 621 + {isConnected 622 + ? t("settings:giteaIntegration.update") 623 + : t("settings:giteaIntegration.connect")} 624 + </Button> 625 + 626 + {isConnected && ( 627 + <Button 628 + type="button" 629 + variant="destructive" 630 + size="sm" 631 + onClick={handleDelete} 632 + disabled={isCreating || isDeleting} 633 + className="gap-2" 634 + > 635 + <Unlink className="size-3" /> 636 + {t("settings:giteaIntegration.disconnect")} 637 + </Button> 638 + )} 639 + </div> 640 + </div> 641 + </form> 642 + </Form> 643 + 644 + {verificationResult && ( 645 + <> 646 + <Separator /> 647 + <div 648 + className={cn( 649 + "flex items-start gap-3 p-3 border rounded-md text-sm", 650 + verificationResult.isInstalled && 651 + verificationResult.hasRequiredPermissions 652 + ? "border-success/25 bg-success/10" 653 + : verificationResult.repositoryExists 654 + ? "border-warning/25 bg-warning/10" 655 + : "border-destructive/25 bg-destructive/10", 656 + )} 657 + > 658 + {verificationResult.isInstalled && 659 + verificationResult.hasRequiredPermissions ? ( 660 + <CheckCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-success-foreground" /> 661 + ) : verificationResult.repositoryExists ? ( 662 + <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-warning-foreground" /> 663 + ) : ( 664 + <XCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive-foreground" /> 665 + )} 666 + <div className="flex-1"> 667 + <p className="font-medium">{verificationResult.message}</p> 668 + </div> 669 + </div> 670 + </> 671 + )} 672 + </div> 673 + 674 + {isConnected && ( 675 + <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 676 + <div className="flex items-center justify-between"> 677 + <div className="space-y-0.5"> 678 + <p className="text-sm font-medium"> 679 + {t("settings:giteaIntegration.importSectionTitle")} 680 + </p> 681 + <p className="text-xs text-muted-foreground"> 682 + {t("settings:giteaIntegration.importSectionHint")} 683 + </p> 684 + </div> 685 + <Button 686 + onClick={handleImportIssues} 687 + disabled={isImporting || !canImport} 688 + className="gap-2" 689 + size="sm" 690 + variant="outline" 691 + > 692 + {isImporting ? ( 693 + <RefreshCw className="size-3 animate-spin" /> 694 + ) : ( 695 + <Import className="size-3" /> 696 + )} 697 + {isImporting 698 + ? t("settings:giteaIntegration.importing") 699 + : t("settings:giteaIntegration.importIssues")} 700 + </Button> 701 + </div> 702 + {!canImport && ( 703 + <> 704 + <Separator /> 705 + <p className="text-xs text-muted-foreground"> 706 + {t("settings:giteaIntegration.importDisabledHint")} 707 + </p> 708 + </> 709 + )} 710 + </div> 711 + )} 712 + 713 + <GiteaRepositoryBrowserModal 714 + open={showRepositoryBrowser} 715 + onOpenChange={setShowRepositoryBrowser} 716 + onSelectRepository={handleRepositorySelect} 717 + selectedRepository={ 718 + repositoryOwner && repositoryName 719 + ? `${repositoryOwner}/${repositoryName}` 720 + : undefined 721 + } 722 + baseUrl={baseUrl} 723 + accessToken={accessToken} 724 + /> 725 + </div> 726 + ); 727 + }
+169
apps/web/src/components/project/gitea-repository-browser-modal.tsx
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { ExternalLink, GitBranch, Lock, Search } from "lucide-react"; 3 + import React from "react"; 4 + import { useTranslation } from "react-i18next"; 5 + import { Badge } from "@/components/ui/badge"; 6 + import { Button } from "@/components/ui/button"; 7 + import { 8 + Dialog, 9 + DialogContent, 10 + DialogDescription, 11 + DialogHeader, 12 + DialogTitle, 13 + } from "@/components/ui/dialog"; 14 + import { Input } from "@/components/ui/input"; 15 + import listGiteaRepositories, { 16 + type ListGiteaRepositoriesResponse, 17 + } from "@/fetchers/gitea-integration/list-gitea-repositories"; 18 + import { cn } from "@/lib/cn"; 19 + 20 + type GiteaRepositoryBrowserModalProps = { 21 + open: boolean; 22 + onOpenChange: (open: boolean) => void; 23 + onSelectRepository: (repository: { owner: string; name: string }) => void; 24 + selectedRepository?: string; 25 + baseUrl: string; 26 + accessToken: string; 27 + }; 28 + 29 + export function GiteaRepositoryBrowserModal({ 30 + open, 31 + onOpenChange, 32 + onSelectRepository, 33 + selectedRepository, 34 + baseUrl, 35 + accessToken, 36 + }: GiteaRepositoryBrowserModalProps) { 37 + const { t } = useTranslation(); 38 + const [searchTerm, setSearchTerm] = React.useState(""); 39 + 40 + const canFetch = 41 + open && baseUrl.trim().length > 0 && accessToken.trim().length > 0; 42 + 43 + const { data, isLoading, error, refetch } = useQuery({ 44 + queryKey: ["gitea-repositories", baseUrl, accessToken], 45 + queryFn: () => listGiteaRepositories({ baseUrl, accessToken }), 46 + enabled: canFetch, 47 + }); 48 + 49 + const filteredRepositories = React.useMemo(() => { 50 + if (!data?.repositories) return []; 51 + 52 + if (!searchTerm) return data.repositories; 53 + 54 + const search = searchTerm.toLowerCase(); 55 + return data.repositories.filter((repo) => 56 + repo.full_name.toLowerCase().includes(search), 57 + ); 58 + }, [data?.repositories, searchTerm]); 59 + 60 + const handleSelectRepository = ( 61 + repository: ListGiteaRepositoriesResponse["repositories"][number], 62 + ) => { 63 + onSelectRepository({ 64 + owner: repository.owner.login, 65 + name: repository.name, 66 + }); 67 + onOpenChange(false); 68 + }; 69 + 70 + const resetAndCloseModal = (next: boolean) => { 71 + if (!next) { 72 + setSearchTerm(""); 73 + } 74 + onOpenChange(next); 75 + }; 76 + 77 + return ( 78 + <Dialog open={open} onOpenChange={resetAndCloseModal}> 79 + <DialogContent className="!max-w-2xl max-h-[85vh] p-0 gap-0 flex flex-col"> 80 + <DialogHeader className="px-6 pt-6 pb-4"> 81 + <DialogTitle className="flex items-center gap-2"> 82 + <GitBranch className="size-5" /> 83 + {t("settings:giteaIntegration.browseModalTitle")} 84 + </DialogTitle> 85 + <DialogDescription> 86 + {t("settings:giteaIntegration.browseModalHint")} 87 + </DialogDescription> 88 + </DialogHeader> 89 + 90 + <div className="px-6 pb-4"> 91 + <div className="relative"> 92 + <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> 93 + <Input 94 + className="pl-9" 95 + placeholder={t("settings:giteaIntegration.searchRepos")} 96 + value={searchTerm} 97 + onChange={(e) => setSearchTerm(e.target.value)} 98 + /> 99 + </div> 100 + </div> 101 + 102 + <div className="flex-1 overflow-y-auto border-t border-border px-6 py-2"> 103 + {!canFetch && ( 104 + <p className="text-sm text-muted-foreground py-8 text-center"> 105 + {t("settings:giteaIntegration.browseNeedsCredentials")} 106 + </p> 107 + )} 108 + {canFetch && isLoading && ( 109 + <p className="text-sm text-muted-foreground py-8 text-center"> 110 + {t("settings:giteaIntegration.loadingRepos")} 111 + </p> 112 + )} 113 + {canFetch && error && ( 114 + <div className="py-6 text-center space-y-2"> 115 + <p className="text-sm text-destructive"> 116 + {error instanceof Error ? error.message : "Error"} 117 + </p> 118 + <Button 119 + type="button" 120 + variant="outline" 121 + size="sm" 122 + onClick={() => refetch()} 123 + > 124 + {t("settings:giteaIntegration.retry")} 125 + </Button> 126 + </div> 127 + )} 128 + {canFetch && data && ( 129 + <ul className="space-y-1"> 130 + {filteredRepositories.map((repo) => ( 131 + <li key={repo.id}> 132 + <button 133 + type="button" 134 + onClick={() => handleSelectRepository(repo)} 135 + className={cn( 136 + "w-full flex items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/80 transition-colors", 137 + selectedRepository === repo.full_name && "bg-muted", 138 + )} 139 + > 140 + <span className="font-medium truncate"> 141 + {repo.full_name} 142 + </span> 143 + <div className="flex items-center gap-2 shrink-0"> 144 + {repo.private ? ( 145 + <Lock className="size-3.5 text-muted-foreground" /> 146 + ) : null} 147 + <Badge variant="secondary" className="text-xs"> 148 + {repo.owner.login} 149 + </Badge> 150 + <a 151 + href={repo.html_url} 152 + target="_blank" 153 + rel="noopener noreferrer" 154 + className="text-primary" 155 + onClick={(e) => e.stopPropagation()} 156 + > 157 + <ExternalLink className="size-3.5" /> 158 + </a> 159 + </div> 160 + </button> 161 + </li> 162 + ))} 163 + </ul> 164 + )} 165 + </div> 166 + </DialogContent> 167 + </Dialog> 168 + ); 169 + }
+39 -31
apps/web/src/components/project/workflow-editor.tsx
··· 30 30 useGetWorkflowRules(projectId); 31 31 const { mutateAsync: upsertRule } = useUpsertWorkflowRule(); 32 32 33 - const handleChange = async (eventType: string, columnId: string | null) => { 34 - if (!columnId) return; 35 - 36 - try { 37 - await upsertRule({ 38 - projectId, 39 - data: { 40 - integrationType: "github", 41 - eventType, 42 - columnId, 43 - }, 44 - }); 45 - toast.success(t("settings:workflowEditor.toastUpdated")); 46 - } catch (error) { 47 - toast.error( 48 - error instanceof Error 49 - ? error.message 50 - : t("settings:workflowEditor.toastError"), 51 - ); 52 - } 53 - }; 54 - 55 33 if (columnsLoading || rulesLoading) { 56 34 return ( 57 35 <div className="text-sm text-muted-foreground"> ··· 68 46 ); 69 47 } 70 48 71 - const githubRules = rules?.filter((r) => r.integrationType === "github"); 72 - 73 - return ( 49 + const renderRuleSection = ( 50 + integrationType: "github" | "gitea", 51 + headingKey: "githubHeading" | "giteaHeading", 52 + hintKey: "githubHint" | "giteaHint", 53 + ) => ( 74 54 <div className="space-y-4"> 75 55 <div className="space-y-1"> 76 56 <h3 className="text-sm font-medium"> 77 - {t("settings:workflowEditor.githubHeading")} 57 + {t(`settings:workflowEditor.${headingKey}`)} 78 58 </h3> 79 59 <p className="text-xs text-muted-foreground"> 80 - {t("settings:workflowEditor.githubHint")} 60 + {t(`settings:workflowEditor.${hintKey}`)} 81 61 </p> 82 62 </div> 83 63 84 64 <div className="space-y-2"> 85 65 {GITHUB_EVENT_TYPES.map((eventType) => { 86 - const currentRule = githubRules?.find( 87 - (r) => r.eventType === eventType, 66 + const currentRule = rules?.find( 67 + (r) => 68 + r.integrationType === integrationType && 69 + r.eventType === eventType, 88 70 ); 89 71 90 72 return ( 91 73 <div 92 - key={eventType} 74 + key={`${integrationType}-${eventType}`} 93 75 className="flex items-center justify-between gap-4 p-3 border border-border rounded-md bg-sidebar" 94 76 > 95 77 <span className="text-sm"> ··· 97 79 </span> 98 80 <Select 99 81 value={currentRule?.columnId ?? ""} 100 - onValueChange={(value) => handleChange(eventType, value)} 82 + onValueChange={async (value) => { 83 + if (!value) return; 84 + try { 85 + await upsertRule({ 86 + projectId, 87 + data: { 88 + integrationType, 89 + eventType, 90 + columnId: value, 91 + }, 92 + }); 93 + toast.success(t("settings:workflowEditor.toastUpdated")); 94 + } catch (error) { 95 + toast.error( 96 + error instanceof Error 97 + ? error.message 98 + : t("settings:workflowEditor.toastError"), 99 + ); 100 + } 101 + }} 101 102 > 102 103 <SelectTrigger className="w-48 h-8 text-sm"> 103 104 <SelectValue ··· 122 123 ); 123 124 })} 124 125 </div> 126 + </div> 127 + ); 128 + 129 + return ( 130 + <div className="space-y-10"> 131 + {renderRuleSection("github", "githubHeading", "githubHint")} 132 + {renderRuleSection("gitea", "giteaHeading", "giteaHint")} 125 133 </div> 126 134 ); 127 135 }
+6 -1
apps/web/src/components/task/task-properties-sidebar.tsx
··· 19 19 TooltipTrigger, 20 20 } from "@/components/ui/tooltip"; 21 21 import labelColors from "@/constants/label-colors"; 22 + import useGetGiteaIntegration from "@/hooks/queries/gitea-integration/use-get-gitea-integration"; 22 23 import useGetGithubIntegration from "@/hooks/queries/github-integration/use-get-github-integration"; 23 24 import useGetLabelsByTask from "@/hooks/queries/label/use-get-labels-by-task"; 24 25 import useGetProject from "@/hooks/queries/project/use-get-project"; ··· 80 81 const { data: workspaceUsers } = useGetActiveWorkspaceUsers(workspaceId); 81 82 const { data: taskLabels = [] } = useGetLabelsByTask(taskId ?? ""); 82 83 const { data: githubIntegration } = useGetGithubIntegration(projectId); 84 + const { data: giteaIntegration } = useGetGiteaIntegration(projectId); 83 85 84 86 const projectSlug = project?.slug; 85 87 const taskNumber = task?.number; 86 - const branchPattern = githubIntegration?.branchPattern || "{slug}-{number}"; 88 + const branchPattern = 89 + githubIntegration?.branchPattern || 90 + giteaIntegration?.branchPattern || 91 + "{slug}-{number}"; 87 92 88 93 const assignee = workspaceUsers?.members?.find( 89 94 (member) => member.userId === task?.userId,
+10
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/integrations.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { 3 3 ChevronDown, 4 + FolderGit, 4 5 Github, 5 6 MessageCircle, 6 7 Radio, ··· 11 12 import PageTitle from "@/components/page-title"; 12 13 import { DiscordIntegrationSettings } from "@/components/project/discord-integration-settings"; 13 14 import { GenericWebhookIntegrationSettings } from "@/components/project/generic-webhook-integration-settings"; 15 + import { GiteaIntegrationSettings } from "@/components/project/gitea-integration-settings"; 14 16 import { GitHubIntegrationSettings } from "@/components/project/github-integration-settings"; 15 17 import { SlackIntegrationSettings } from "@/components/project/slack-integration-settings"; 16 18 import { ··· 50 52 title={t("settings:projectIntegrations.githubSectionTitle")} 51 53 > 52 54 <GitHubIntegrationSettings projectId={projectId} /> 55 + </IntegrationSection> 56 + 57 + <IntegrationSection 58 + icon={<FolderGit className="size-4" />} 59 + subtitle={t("settings:projectIntegrations.giteaSectionSubtitle")} 60 + title={t("settings:projectIntegrations.giteaSectionTitle")} 61 + > 62 + <GiteaIntegrationSettings projectId={projectId} /> 53 63 </IntegrationSection> 54 64 55 65 <IntegrationSection