import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChevronDown, Loader2, PlusIcon, RefreshCw } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' import { IS_PLATFORM } from 'common' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { useBranchesQuery } from 'data/branches/branches-query' import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query' import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' import { useGitHubConnectionCreateMutation } from 'data/integrations/github-connection-create-mutation' import { useGitHubConnectionDeleteMutation } from 'data/integrations/github-connection-delete-mutation' import { useGitHubConnectionUpdateMutation } from 'data/integrations/github-connection-update-mutation' import { useGitHubRepositoriesQuery } from 'data/integrations/github-repositories-query' import type { GitHubConnection } from 'data/integrations/integrations.types' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { openInstallGitHubIntegrationWindow } from 'lib/github' import { EMPTY_ARR } from 'lib/void' import { Button, Card, CardContent, CardFooter, cn, Command_Shadcn_, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, CommandSeparator_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Switch, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' const GITHUB_ICON = ( GitHub icon ) interface GitHubIntegrationConnectionFormProps { disabled?: boolean connection?: GitHubConnection } const GitHubIntegrationConnectionForm = ({ disabled = false, connection, }: GitHubIntegrationConnectionFormProps) => { const { data: selectedProject } = useSelectedProjectQuery() const { data: selectedOrganization } = useSelectedOrganizationQuery() const [isConfirmingBranchChange, setIsConfirmingBranchChange] = useState(false) const [isConfirmingRepoChange, setIsConfirmingRepoChange] = useState(false) const [repoComboBoxOpen, setRepoComboboxOpen] = useState(false) const isParentProject = !selectedProject?.parent_project_ref const isProPlanAndUp = selectedOrganization?.plan?.id !== 'free' const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp const { can: canUpdateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'integrations.github_connections' ) const { can: canCreateGitHubConnection } = useAsyncCheckPermissions( PermissionAction.CREATE, 'integrations.github_connections' ) const { data: gitHubAuthorization, refetch: refetchGitHubAuthorization } = useGitHubAuthorizationQuery() const { data: githubReposData, isPending: isLoadingGitHubRepos, refetch: refetchGitHubRepositories, } = useGitHubRepositoriesQuery({ enabled: Boolean(gitHubAuthorization), }) const refetchGitHubAuthorizationAndRepositories = () => { setTimeout(() => { refetchGitHubAuthorization() refetchGitHubRepositories() }, 2000) // 2 second to delay to let github authorization and repositories to be updated } const { mutate: updateBranch } = useBranchUpdateMutation({ onSuccess: () => { toast.success('Production branch settings successfully updated') }, }) const { mutate: createBranch } = useBranchCreateMutation({ onSuccess: () => { toast.success('Production branch settings successfully updated') }, onError: (error) => { console.error('Failed to enable branching:', error) }, }) const { data: existingBranches } = useBranchesQuery( { projectRef: selectedProject?.ref }, { enabled: !!selectedProject?.ref } ) const { mutateAsync: checkGithubBranchValidity, isPending: isCheckingBranch } = useCheckGithubBranchValidity({ onError: () => {} }) const { mutate: createConnection, isPending: isCreatingConnection } = useGitHubConnectionCreateMutation({ onSuccess: () => { toast.success('GitHub integration successfully updated') }, }) const { mutateAsync: deleteConnection, isPending: isDeletingConnection } = useGitHubConnectionDeleteMutation({ onSuccess: () => { toast.success('Successfully removed GitHub integration') }, }) const { mutate: updateConnectionSettings, isPending: isUpdatingConnection } = useGitHubConnectionUpdateMutation() const githubRepos = useMemo( () => githubReposData?.repositories?.map((repo) => ({ id: repo.id.toString(), name: repo.name, installation_id: repo.installation_id, default_branch: repo.default_branch || 'main', })) ?? EMPTY_ARR, [githubReposData] ) const hasPartialResponseDueToSSO = githubReposData?.partial_response_due_to_sso ?? false const prodBranch = existingBranches?.find((branch) => branch.is_default) // Combined GitHub Settings Form const GitHubSettingsSchema = z .object({ repositoryId: z.string().min(1, 'Please select a repository'), enableProductionSync: z.boolean().default(true), branchName: z.string().default('main'), new_branch_per_pr: z.boolean().default(true), supabaseDirectory: z.string().default('.'), supabaseChangesOnly: z.boolean().default(true), branchLimit: z.string().default('50'), }) .superRefine(async (val, ctx) => { if (val.enableProductionSync && val.branchName && val.branchName.length > 0) { const repositoryId = val.repositoryId || connection?.repository.id.toString() if (repositoryId) { try { await checkGithubBranchValidity({ repositoryId: Number(repositoryId), branchName: val.branchName, }) } catch { const selectedRepo = githubRepos.find((repo) => repo.id === repositoryId) const repoName = selectedRepo?.name || connection?.repository.name || 'selected repository' ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Branch "${val.branchName}" not found in ${repoName}`, path: ['branchName'], }) } } } }) const githubSettingsForm = useForm>({ resolver: zodResolver(GitHubSettingsSchema), mode: 'onSubmit', reValidateMode: 'onBlur', defaultValues: { repositoryId: connection?.repository.id.toString() || '', enableProductionSync: true, branchName: 'main', new_branch_per_pr: true, supabaseDirectory: '.', supabaseChangesOnly: true, branchLimit: '50', }, }) const enableProductionSync = githubSettingsForm.watch('enableProductionSync') const newBranchPerPr = githubSettingsForm.watch('new_branch_per_pr') const currentRepositoryId = githubSettingsForm.watch('repositoryId') // Calculate selected repository based on current form value const selectedRepository = githubRepos.find((repo) => repo.id === currentRepositoryId) const handleCreateOrUpdateConnection = async (data: z.infer) => { if (!selectedProject?.ref || !selectedOrganization?.id) return try { if (connection) { // Check if repository is being changed if (connection.repository.id.toString() !== data.repositoryId) { setIsConfirmingRepoChange(true) return } // Update existing connection await handleUpdateConnection(data, connection) } else { // Create new connection const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId) if (!selectedRepo) { toast.error('Please select a repository') return } await handleCreateConnection(data, selectedRepo) } } catch (error) { console.error('Error managing connection:', error) } } const handleCreateConnection = async ( data: z.infer, selectedRepo: { id: string; installation_id: number } ) => { if (!selectedProject?.ref || !selectedOrganization?.id) return createConnection({ organizationId: selectedOrganization.id, connection: { installation_id: selectedRepo.installation_id, project_ref: selectedProject.ref, repository_id: Number(selectedRepo.id), workdir: data.supabaseDirectory, supabase_changes_only: data.supabaseChangesOnly, branch_limit: Number(data.branchLimit), new_branch_per_pr: data.new_branch_per_pr, }, }) if (!prodBranch) { createBranch({ projectRef: selectedProject.ref, branchName: 'main', gitBranch: data.branchName, is_default: true, }) } else { updateBranch({ branchRef: prodBranch.project_ref, projectRef: selectedProject.ref, gitBranch: data.branchName, }) } } const handleUpdateConnection = async ( data: z.infer, currentConnection: GitHubConnection ) => { if (!selectedProject?.ref || !selectedOrganization?.id) return const originalBranchName = prodBranch?.git_branch if (originalBranchName && data.branchName !== originalBranchName && data.enableProductionSync) { setIsConfirmingBranchChange(true) return } await executeUpdate(data, currentConnection) } const executeUpdate = async ( data: z.infer, currentConnection: GitHubConnection ) => { if (!selectedProject?.ref || !selectedOrganization?.id) return updateConnectionSettings({ connectionId: currentConnection.id, organizationId: selectedOrganization.id, connection: { workdir: data.supabaseDirectory, supabase_changes_only: data.supabaseChangesOnly, branch_limit: Number(data.branchLimit), new_branch_per_pr: data.new_branch_per_pr, }, }) if (prodBranch) { updateBranch({ branchRef: prodBranch.project_ref, projectRef: selectedProject.ref, gitBranch: data.enableProductionSync ? data.branchName : '', branchName: data.branchName || 'main', }) } setIsConfirmingBranchChange(false) } const onConfirmBranchChange = async () => { if (connection) { await executeUpdate(githubSettingsForm.getValues(), connection) } } const handleRemoveIntegration = async () => { if (!connection || !selectedOrganization?.id) return try { await deleteConnection({ organizationId: selectedOrganization.id, connectionId: connection.id, }) githubSettingsForm.reset({ repositoryId: '', enableProductionSync: true, branchName: 'main', new_branch_per_pr: true, supabaseDirectory: '.', supabaseChangesOnly: true, branchLimit: '50', }) } catch (error) { console.error('Error removing integration:', error) toast.error('Failed to remove integration') } } const onConfirmRepoChange = async () => { const data = githubSettingsForm.getValues() const selectedRepo = githubRepos.find((repo) => repo.id === data.repositoryId) if (!selectedRepo || !connection || !selectedOrganization?.id) return try { await deleteConnection({ organizationId: selectedOrganization.id, connectionId: connection.id, }) await handleCreateConnection(data, selectedRepo) setIsConfirmingRepoChange(false) } catch (error) { console.error('Error changing repository:', error) toast.error('Failed to change repository') } } useEffect(() => { if (connection) { const hasGitBranch = Boolean(prodBranch?.git_branch?.trim()) githubSettingsForm.reset({ repositoryId: connection.repository.id.toString(), enableProductionSync: hasGitBranch, branchName: prodBranch?.git_branch || 'main', new_branch_per_pr: connection.new_branch_per_pr, supabaseDirectory: connection.workdir || '', supabaseChangesOnly: connection.supabase_changes_only, branchLimit: String(connection.branch_limit), }) } }, [connection, prodBranch, githubSettingsForm]) // Handle clearing branch name when production sync is disabled useEffect(() => { if (!enableProductionSync) { githubSettingsForm.setValue('branchName', '') } else if (enableProductionSync && !githubSettingsForm.getValues().branchName) { githubSettingsForm.setValue('branchName', 'main') } }, [enableProductionSync, githubSettingsForm]) // Show authorization prompt if not authorized if (gitHubAuthorization === null) { return (

Authorize with GitHub

Connect your GitHub account to access and select repositories for integration.

) } const isLoading = isCreatingConnection || isUpdatingConnection || isDeletingConnection return ( <>
{/* Repository Selection */} ( No repositories found. {githubRepos.length > 0 ? ( {githubRepos.map((repo, i) => ( { field.onChange(repo.id) setRepoComboboxOpen(false) githubSettingsForm.setValue( 'branchName', repo.default_branch || 'main' ) }} >
{GITHUB_ICON}
{repo.name}
))}
) : null} openInstallGitHubIntegrationWindow( 'install', refetchGitHubAuthorizationAndRepositories ) } > Add GitHub Repositories {hasPartialResponseDueToSSO && ( <> { openInstallGitHubIntegrationWindow( 'authorize', refetchGitHubAuthorizationAndRepositories ) }} >
Re-authorize GitHub with SSO to show all repositories
)}
)} />
( )} /> {/* Production Branch Sync Section */}
( )} />
(
{isCheckingBranch && }
)} />
{/* Automatic Branching Section */}
( )} />
( )} /> ( field.onChange(val)} disabled={!newBranchPerPr || disabled || !canUpdateGitHubConnection} /> )} />
{connection && ( )}
{githubSettingsForm.formState.isDirty && ( )}
setIsConfirmingBranchChange(false)} onConfirm={onConfirmBranchChange} loading={isUpdatingConnection} >

Open pull requests will only update your Supabase project on merge if the git base branch matches this new production git branch.

setIsConfirmingRepoChange(false)} onConfirm={onConfirmRepoChange} loading={isLoading} >

This will disconnect your current repository and create a new connection with the selected repository. All existing Supabase branches that are connected to the old repository will no longer be synced.

) } export default GitHubIntegrationConnectionForm