GitHubIntegrationConnectionForm.tsx702 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { AnimatePresence, motion } from 'framer-motion'
5import { Loader2 } from 'lucide-react'
6import { useEffect, useState } from 'react'
7import { useForm } from 'react-hook-form'
8import { toast } from 'sonner'
9import {
10 Button,
11 Card,
12 CardContent,
13 CardFooter,
14 cn,
15 Form,
16 FormControl,
17 FormField,
18 Input,
19 Switch,
20} from 'ui'
21import { Admonition } from 'ui-patterns/admonition'
22import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
23import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
24import * as z from 'zod'
25
26import {
27 GitHubRepositoryField,
28 useGitHubRepositoryOptions,
29} from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField'
30import { InlineLink } from '@/components/ui/InlineLink'
31import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
32import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation'
33import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
34import { useBranchesQuery } from '@/data/branches/branches-query'
35import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query'
36import { useGitHubConnectionCreateMutation } from '@/data/integrations/github-connection-create-mutation'
37import { useGitHubConnectionDeleteMutation } from '@/data/integrations/github-connection-delete-mutation'
38import { useGitHubConnectionUpdateMutation } from '@/data/integrations/github-connection-update-mutation'
39import type { GitHubConnection } from '@/data/integrations/integrations.types'
40import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
41import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
42import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
43import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
44import { DOCS_URL } from '@/lib/constants'
45
46interface GitHubIntegrationConnectionFormProps {
47 connection?: GitHubConnection
48}
49
50export const GitHubIntegrationConnectionForm = ({
51 connection,
52}: GitHubIntegrationConnectionFormProps) => {
53 const { data: selectedProject } = useSelectedProjectQuery()
54 const { data: selectedOrganization } = useSelectedOrganizationQuery()
55 const [isConfirmingBranchChange, setIsConfirmingBranchChange] = useState(false)
56 const [isConfirmingRepoChange, setIsConfirmingRepoChange] = useState(false)
57 const isParentProject = !selectedProject?.parent_project_ref
58
59 const { hasAccess: hasAccessToGitHubIntegration, isLoading: isLoadingEntitlements } =
60 useCheckEntitlements('integrations.github_connections')
61
62 const { hasAccess: hasAccessToBranching } = useCheckEntitlements('branching_limit')
63
64 const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions(
65 PermissionAction.UPDATE,
66 'integrations.github_connections'
67 )
68 const { can: canCreateGitHubConnection } = useAsyncCheckPermissions(
69 PermissionAction.CREATE,
70 'integrations.github_connections'
71 )
72
73 const {
74 gitHubAuthorization,
75 githubRepos,
76 hasPartialResponseDueToSSO,
77 isLoading: isLoadingRepositoryOptions,
78 refetch: refetchRepositoryOptions,
79 } = useGitHubRepositoryOptions()
80
81 const { mutate: updateBranch } = useBranchUpdateMutation({
82 onSuccess: () => {
83 toast.success('Production branch settings successfully updated')
84 },
85 })
86 const { mutate: createBranch } = useBranchCreateMutation({
87 onSuccess: () => {
88 toast.success('Production branch settings successfully updated')
89 },
90 onError: (error) => {
91 console.error('Failed to enable branching:', error)
92 },
93 })
94
95 const { data: existingBranches } = useBranchesQuery(
96 { projectRef: selectedProject?.ref },
97 { enabled: !!selectedProject?.ref }
98 )
99
100 const { mutateAsync: checkGithubBranchValidity, isPending: isCheckingBranch } =
101 useCheckGithubBranchValidity({ onError: () => {} })
102
103 const { mutate: createConnection, isPending: isCreatingConnection } =
104 useGitHubConnectionCreateMutation({
105 onSuccess: () => {
106 toast.success('GitHub integration successfully updated')
107 },
108 onError: (error) => {
109 // Don't show error toast when connection already exists - the branch
110 // settings update will still proceed and show its own success toast
111 if (!error.message?.includes('already exists')) {
112 toast.error(`Failed to create GitHub connection: ${error.message}`)
113 }
114 },
115 })
116
117 const { mutateAsync: deleteConnection, isPending: isDeletingConnection } =
118 useGitHubConnectionDeleteMutation({
119 onSuccess: () => {
120 toast.success('Successfully removed GitHub integration')
121 },
122 })
123
124 const { mutate: updateConnectionSettings, isPending: isUpdatingConnection } =
125 useGitHubConnectionUpdateMutation()
126
127 const prodBranch = existingBranches?.find((branch) => branch.is_default)
128
129 // Combined GitHub Settings Form
130 const GitHubSettingsSchema = z
131 .object({
132 repositoryId: z.string().min(1, 'Please select a repository'),
133 enableProductionSync: z.boolean().default(true),
134 branchName: z.string().default('main'),
135 new_branch_per_pr: z.boolean().default(true),
136 brivenDirectory: z.string().default('.'),
137 brivenChangesOnly: z.boolean().default(true),
138 branchLimit: z.string().default('50'),
139 })
140 .superRefine(async (val, ctx) => {
141 if (val.enableProductionSync && val.branchName && val.branchName.length > 0) {
142 const repositoryId = val.repositoryId || connection?.repository.id.toString()
143 if (repositoryId) {
144 try {
145 await checkGithubBranchValidity({
146 repositoryId: Number(repositoryId),
147 branchName: val.branchName,
148 })
149 } catch {
150 const selectedRepo = githubRepos.find((repo) => repo.id === repositoryId)
151 const repoName =
152 selectedRepo?.name || connection?.repository.name || 'selected repository'
153 ctx.addIssue({
154 code: z.ZodIssueCode.custom,
155 message: `Branch "${val.branchName}" not found in ${repoName}`,
156 path: ['branchName'],
157 })
158 }
159 }
160 }
161 })
162
163 const githubSettingsForm = useForm<z.infer<typeof GitHubSettingsSchema>>({
164 resolver: zodResolver(GitHubSettingsSchema as any),
165 mode: 'onSubmit',
166 reValidateMode: 'onBlur',
167 defaultValues: {
168 repositoryId: connection?.repository.id.toString() || '',
169 enableProductionSync: true,
170 branchName: 'main',
171 new_branch_per_pr: true,
172 brivenDirectory: '.',
173 brivenChangesOnly: true,
174 branchLimit: '3',
175 },
176 })
177
178 const enableProductionSync = githubSettingsForm.watch('enableProductionSync')
179 const newBranchPerPr = githubSettingsForm.watch('new_branch_per_pr')
180 const currentRepositoryId = githubSettingsForm.watch('repositoryId')
181
182 const handleCreateOrUpdateConnection = async (data: z.infer<typeof GitHubSettingsSchema>) => {
183 if (!selectedProject?.ref || !selectedOrganization?.id) return
184
185 try {
186 if (connection) {
187 // Check if repository is being changed
188 if (connection.repository.id.toString() !== data.repositoryId) {
189 setIsConfirmingRepoChange(true)
190 return
191 }
192 // Update existing connection
193 await handleUpdateConnection(data, connection)
194 } else {
195 // Create new connection
196 const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId)
197 if (!selectedRepo) {
198 toast.error('Please select a repository')
199 return
200 }
201 await handleCreateConnection(data, selectedRepo)
202 }
203 } catch (error) {
204 console.error('Error managing connection:', error)
205 }
206 }
207
208 const handleCreateConnection = async (
209 data: z.infer<typeof GitHubSettingsSchema>,
210 selectedRepo: { id: string; installation_id: number }
211 ) => {
212 if (!selectedProject?.ref || !selectedOrganization?.id) return
213
214 createConnection({
215 organizationId: selectedOrganization.id,
216 connection: {
217 installation_id: selectedRepo.installation_id,
218 project_ref: selectedProject.ref,
219 repository_id: Number(selectedRepo.id),
220 workdir: data.brivenDirectory,
221 briven_changes_only: data.brivenChangesOnly,
222 branch_limit: Number(data.branchLimit),
223 new_branch_per_pr: data.new_branch_per_pr,
224 },
225 })
226
227 if (!prodBranch) {
228 createBranch({
229 projectRef: selectedProject.ref,
230 branchName: 'main',
231 gitBranch: data.branchName,
232 is_default: true,
233 })
234 } else {
235 updateBranch({
236 branchRef: prodBranch.project_ref,
237 projectRef: selectedProject.ref,
238 gitBranch: data.branchName,
239 })
240 }
241 }
242
243 const handleUpdateConnection = async (
244 data: z.infer<typeof GitHubSettingsSchema>,
245 currentConnection: GitHubConnection
246 ) => {
247 if (!selectedProject?.ref || !selectedOrganization?.id) return
248
249 const originalBranchName = prodBranch?.git_branch
250
251 if (originalBranchName && data.branchName !== originalBranchName && data.enableProductionSync) {
252 setIsConfirmingBranchChange(true)
253 return
254 }
255
256 await executeUpdate(data, currentConnection)
257 }
258
259 const executeUpdate = async (
260 data: z.infer<typeof GitHubSettingsSchema>,
261 currentConnection: GitHubConnection
262 ) => {
263 if (!selectedProject?.ref || !selectedOrganization?.id) return
264
265 updateConnectionSettings({
266 connectionId: currentConnection.id,
267 organizationId: selectedOrganization.id,
268 connection: {
269 workdir: data.brivenDirectory,
270 briven_changes_only: data.brivenChangesOnly,
271 branch_limit: Number(data.branchLimit),
272 new_branch_per_pr: data.new_branch_per_pr,
273 },
274 })
275
276 if (prodBranch) {
277 updateBranch({
278 branchRef: prodBranch.project_ref,
279 projectRef: selectedProject.ref,
280 gitBranch: data.enableProductionSync ? data.branchName : '',
281 branchName: data.branchName || 'main',
282 })
283 } else {
284 // if for some reason, the project doesn't have a default branch yet, create it.
285 createBranch({
286 projectRef: selectedProject.ref,
287 gitBranch: data.enableProductionSync ? data.branchName : '',
288 branchName: data.branchName || 'main',
289 is_default: true,
290 })
291 }
292
293 setIsConfirmingBranchChange(false)
294 }
295
296 const onConfirmBranchChange = async () => {
297 if (connection) {
298 await executeUpdate(githubSettingsForm.getValues(), connection)
299 }
300 }
301
302 const handleRemoveIntegration = async () => {
303 if (!connection || !selectedOrganization?.id) return
304
305 try {
306 await deleteConnection({
307 organizationId: selectedOrganization.id,
308 connectionId: connection.id,
309 })
310
311 githubSettingsForm.reset({
312 repositoryId: '',
313 enableProductionSync: true,
314 branchName: 'main',
315 new_branch_per_pr: true,
316 brivenDirectory: '.',
317 brivenChangesOnly: true,
318 branchLimit: '3',
319 })
320 } catch (error) {
321 console.error('Error removing integration:', error)
322 toast.error('Failed to remove integration')
323 }
324 }
325
326 const onConfirmRepoChange = async () => {
327 const data = githubSettingsForm.getValues()
328 const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId)
329
330 if (!selectedRepo || !connection || !selectedOrganization?.id) return
331
332 try {
333 await deleteConnection({
334 organizationId: selectedOrganization.id,
335 connectionId: connection.id,
336 })
337
338 await handleCreateConnection(data, selectedRepo)
339
340 setIsConfirmingRepoChange(false)
341 } catch (error) {
342 console.error('Error changing repository:', error)
343 toast.error('Failed to change repository')
344 }
345 }
346
347 useEffect(() => {
348 if (connection) {
349 const hasGitBranch = Boolean(prodBranch?.git_branch?.trim())
350
351 githubSettingsForm.reset({
352 repositoryId: connection.repository.id.toString(),
353 enableProductionSync: hasGitBranch,
354 branchName: prodBranch?.git_branch || 'main',
355 new_branch_per_pr: connection.new_branch_per_pr,
356 brivenDirectory: connection.workdir || '',
357 brivenChangesOnly: connection.briven_changes_only,
358 branchLimit: String(connection.branch_limit),
359 })
360 }
361 }, [connection, prodBranch, githubSettingsForm])
362
363 // Handle clearing branch name when production sync is disabled
364 useEffect(() => {
365 if (!enableProductionSync) {
366 githubSettingsForm.setValue('branchName', '')
367 } else if (enableProductionSync && !githubSettingsForm.getValues().branchName) {
368 githubSettingsForm.setValue('branchName', 'main')
369 }
370 }, [enableProductionSync, githubSettingsForm])
371
372 const isLoading =
373 isLoadingEntitlements ||
374 isCreatingConnection ||
375 isUpdatingConnection ||
376 isDeletingConnection ||
377 isLoadingRepositoryOptions
378
379 return (
380 <>
381 <Form {...githubSettingsForm}>
382 <form
383 onSubmit={githubSettingsForm.handleSubmit(handleCreateOrUpdateConnection)}
384 className={cn(!isParentProject && 'opacity-25 pointer-events-none')}
385 >
386 <Card>
387 <CardContent className="space-y-6">
388 <GitHubRepositoryField
389 form={githubSettingsForm}
390 name="repositoryId"
391 label="GitHub Repository"
392 layout="flex-row-reverse"
393 description={
394 connection
395 ? 'Change the connected repository'
396 : 'Select the repository to connect to your project'
397 }
398 disabled={
399 (!connection && !canCreateGitHubConnection) ||
400 (connection && !canUpdateGitHubConnection)
401 }
402 selectedRepositoryName={connection?.repository.name}
403 repositories={githubRepos}
404 gitHubAuthorization={gitHubAuthorization}
405 hasPartialResponseDueToSSO={hasPartialResponseDueToSSO}
406 isLoading={isLoadingRepositoryOptions}
407 refetch={refetchRepositoryOptions}
408 onRepositorySelect={(repo) => {
409 githubSettingsForm.setValue('branchName', repo.default_branch || 'main')
410 }}
411 />
412 </CardContent>
413
414 <AnimatePresence>
415 {gitHubAuthorization !== null && !!currentRepositoryId && (
416 <motion.div
417 initial={{ opacity: 0, y: -16 }}
418 animate={{ opacity: 1, y: 0 }}
419 exit={{ opacity: 0, y: -16 }}
420 >
421 <CardContent>
422 <FormField
423 control={githubSettingsForm.control}
424 name="brivenDirectory"
425 render={({ field }) => (
426 <FormItemLayout
427 layout="flex-row-reverse"
428 label="Working directory"
429 description={
430 <>
431 Relative path to the directory containing your{' '}
432 <code className="text-code-inline whitespace-nowrap">briven/</code>{' '}
433 folder.{' '}
434 <InlineLink
435 href={`${DOCS_URL}/guides/deployment/branching/github-integration#set-the-working-directory`}
436 >
437 Learn more
438 </InlineLink>
439 </>
440 }
441 >
442 <FormControl>
443 <Input
444 {...field}
445 placeholder="."
446 autoComplete="off"
447 disabled={!canUpdateGitHubConnection}
448 />
449 </FormControl>
450 </FormItemLayout>
451 )}
452 />
453 </CardContent>
454 <CardContent>
455 {/* Production Branch Sync Section */}
456 <div className="space-y-4">
457 <FormField
458 control={githubSettingsForm.control}
459 name="enableProductionSync"
460 render={({ field }) => (
461 <FormItemLayout
462 layout="flex-row-reverse"
463 label="Deploy to production"
464 description="Deploy changes to production on push including PR merges"
465 >
466 <FormControl>
467 <Switch
468 checked={field.value}
469 onCheckedChange={field.onChange}
470 disabled={!canUpdateGitHubConnection}
471 />
472 </FormControl>
473 </FormItemLayout>
474 )}
475 />
476
477 <div
478 className={cn(
479 'space-y-4 pl-6 border-l',
480 !enableProductionSync && 'opacity-25 pointer-events-none'
481 )}
482 >
483 <FormField
484 control={githubSettingsForm.control}
485 name="branchName"
486 render={({ field }) => (
487 <FormItemLayout
488 layout="flex-row-reverse"
489 label="Production branch name"
490 description="The GitHub branch to sync with your production database (e.g., main, master)"
491 >
492 <div className="relative w-full">
493 <FormControl>
494 <Input
495 {...field}
496 autoComplete="off"
497 disabled={!canUpdateGitHubConnection || !enableProductionSync}
498 />
499 </FormControl>
500 <div className="absolute top-2.5 right-3 flex items-center gap-2">
501 {isCheckingBranch && (
502 <Loader2 size={14} className="animate-spin" />
503 )}
504 </div>
505 </div>
506 </FormItemLayout>
507 )}
508 />
509 </div>
510 </div>
511 </CardContent>
512 <CardContent>
513 {hasAccessToBranching ? (
514 <Admonition type="warning" title="Branching and billing" className="mb-4">
515 Branching Compute is not covered by your organization&apos;s Spend Cap.
516 Costs should be closely monitored, as they may be incurred.{' '}
517 <InlineLink
518 href={`${DOCS_URL}/guides/platform/cost-control#usage-items-not-covered-by-the-spend-cap`}
519 >
520 Learn more
521 </InlineLink>
522 </Admonition>
523 ) : (
524 <UpgradeToPro
525 className="mb-4"
526 layout="vertical"
527 source="projectIntegrations"
528 featureProposition="automatically create preview branches from pull requests"
529 primaryText="Branching with GitHub integration"
530 secondaryText="Upgrade to the Pro Plan to enable branching and automatically create preview branches for every pull request"
531 docsUrl={`${DOCS_URL}/guides/deployment/branching`}
532 />
533 )}
534
535 {/* Automatic Branching Section */}
536 <div className="space-y-4">
537 <FormField
538 disabled={!hasAccessToBranching}
539 control={githubSettingsForm.control}
540 name="new_branch_per_pr"
541 render={({ field }) => (
542 <FormItemLayout
543 layout="flex-row-reverse"
544 label="Automatic branching"
545 className={cn(!hasAccessToBranching && 'opacity-25')}
546 description="Create preview branches for every pull request"
547 >
548 <FormControl>
549 <Switch
550 checked={!hasAccessToBranching ? false : field.value}
551 onCheckedChange={field.onChange}
552 disabled={!hasAccessToBranching || !canCreateGitHubConnection}
553 />
554 </FormControl>
555 </FormItemLayout>
556 )}
557 />
558
559 <div
560 className={cn(
561 'space-y-4 pl-6 border-l',
562 (!hasAccessToBranching || !newBranchPerPr) &&
563 'opacity-25 pointer-events-none'
564 )}
565 >
566 <FormField
567 control={githubSettingsForm.control}
568 name="branchLimit"
569 render={({ field }) => (
570 <FormItemLayout
571 layout="flex-row-reverse"
572 label="Branch limit"
573 description="Maximum number of preview branches"
574 >
575 <FormControl>
576 <Input
577 {...field}
578 type="number"
579 autoComplete="off"
580 value={!hasAccessToBranching ? 0 : field.value}
581 disabled={
582 !hasAccessToBranching ||
583 !newBranchPerPr ||
584 !canUpdateGitHubConnection
585 }
586 />
587 </FormControl>
588 </FormItemLayout>
589 )}
590 />
591
592 <FormField
593 control={githubSettingsForm.control}
594 name="brivenChangesOnly"
595 render={({ field }) => (
596 <FormItemLayout
597 layout="flex-row-reverse"
598 label="Briven changes only"
599 description="Only create branches when Briven files change"
600 >
601 <FormControl>
602 <Switch
603 checked={!hasAccessToBranching ? false : field.value}
604 onCheckedChange={(val) => field.onChange(val)}
605 disabled={
606 !hasAccessToBranching ||
607 !newBranchPerPr ||
608 !canUpdateGitHubConnection
609 }
610 />
611 </FormControl>
612 </FormItemLayout>
613 )}
614 />
615 </div>
616 </div>
617 </CardContent>
618 <CardFooter className="flex justify-between items-center">
619 <div>
620 {connection && (
621 <Button
622 type="outline"
623 onClick={handleRemoveIntegration}
624 disabled={isDeletingConnection || isCheckingBranch}
625 loading={isDeletingConnection}
626 >
627 Disable integration
628 </Button>
629 )}
630 </div>
631 <div className="flex space-x-2">
632 {githubSettingsForm.formState.isDirty && (
633 <Button
634 type="default"
635 onClick={() => githubSettingsForm.reset()}
636 disabled={!canUpdateGitHubConnection || isCheckingBranch}
637 >
638 Cancel
639 </Button>
640 )}
641 <Button
642 type="primary"
643 htmlType="submit"
644 disabled={
645 !hasAccessToGitHubIntegration ||
646 (!connection && !canCreateGitHubConnection) ||
647 (connection && !canUpdateGitHubConnection) ||
648 isCheckingBranch ||
649 isLoading ||
650 (!connection && !githubSettingsForm.getValues().repositoryId) ||
651 (connection && !githubSettingsForm.formState.isDirty)
652 }
653 loading={isLoading}
654 >
655 {connection ? 'Save changes' : 'Enable integration'}
656 </Button>
657 </div>
658 </CardFooter>
659 </motion.div>
660 )}
661 </AnimatePresence>
662 </Card>
663 </form>
664 </Form>
665
666 <ConfirmationModal
667 variant="warning"
668 visible={isConfirmingBranchChange}
669 title="Changing production git branch"
670 confirmLabel="Confirm"
671 size="medium"
672 onCancel={() => setIsConfirmingBranchChange(false)}
673 onConfirm={onConfirmBranchChange}
674 loading={isUpdatingConnection}
675 >
676 <p className="text-sm text-foreground-light">
677 Open pull requests will only update your Briven project on merge if the git base branch
678 matches this new production git branch.
679 </p>
680 </ConfirmationModal>
681
682 <ConfirmationModal
683 variant="warning"
684 visible={isConfirmingRepoChange}
685 title="Changing GitHub repository"
686 confirmLabel="Change repository"
687 size="medium"
688 onCancel={() => setIsConfirmingRepoChange(false)}
689 onConfirm={onConfirmRepoChange}
690 loading={isLoading}
691 >
692 <div className="space-y-3">
693 <p className="text-sm text-foreground-light">
694 This will disconnect your current repository and create a new connection with the
695 selected repository. All existing Briven branches that are connected to the old
696 repository will no longer be synced.
697 </p>
698 </div>
699 </ConfirmationModal>
700 </>
701 )
702}