From e8a6df356b03260bd8fa807f121526c5165209dc Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 25 Jun 2025 20:06:17 +1000 Subject: [PATCH] refactor --- .../BranchManagement/CreateBranchModal.tsx | 5 +- .../SidePanelGitHubRepoLinker.tsx | 702 +++--------------- .../AutomaticBranchingRow.tsx | 100 --- .../GithubIntegration/GithubSection.tsx | 551 +++++++++++++- 4 files changed, 659 insertions(+), 699 deletions(-) delete mode 100644 apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/AutomaticBranchingRow.tsx diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 455aaad2ddd..47e0bfb380b 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -266,7 +266,7 @@ export const CreateBranchModal = () => {
- + {!gitlessBranching && ( Required @@ -274,8 +274,7 @@ export const CreateBranchModal = () => { )}

- Automatically create, sync, and merge branches in Supabase when you make - changes to your GitHub repository. + Keep this Supabase branch in sync with a Git branch

-
- ) : ( -
- ( - - - - - - - - - - - No results found. - - {githubRepos.map((repo) => ( - { - if (repo.id === selectedRepoId) { - setForeignProjectsComboboxOpen(false) - return - } - const newRepoData = (githubReposData as any[])?.find( - (r) => r.id.toString() === repo.id - ) - form.reset({ - autoBranchingEnabled: - form.getValues('autoBranchingEnabled'), - repo: newRepoData.name, - branchName: newRepoData.default_branch ?? 'main', - supabaseDirectory: '', - supabaseChangesOnly: false, - branchLimit: '50', - }) - setSelectedRepoId(repo.id) - setForeignProjectsComboboxOpen(false) - }} - > - - - {repo.name} - - - ))} - - - - - - - - )} - /> - - ( - -
- - - -
- {isCheckingBranch && } - {field.value && - !isCheckingBranch && - !form.formState.errors.branchName && - autoBranchingEnabled && ( - - )} -
-
-
- )} - /> - - ( - - - - - - - )} - /> - - ( - - - - - - - )} - /> - - ( - - - field.onChange(val)} - disabled={!autoBranchingEnabled} - /> - - - )} - /> -
- )} - - - {autoBranchingEnabled && ( -
-
- -
-
-

- Preview branches are billed $0.32 per day -

-

- This cost will continue for as long as the branch is active -

-
-
- )} -
- - -
-
- - - setIsConfirmingBranchChange(false)} - onConfirm={onConfirmBranchChange} - loading={isUpdatingConnection || isCreatingConnection} - > -

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

-
- - + {gitHubAuthorization === null ? ( +
+

+ Connect your Supabase projects with your GitHub repositories +

+

+ Authorize with GitHub to retrieve your GitHub repositories +

+ +
+ ) : ( + + )} + + + ) } diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/AutomaticBranchingRow.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/AutomaticBranchingRow.tsx deleted file mode 100644 index 4a5ae20f996..00000000000 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/AutomaticBranchingRow.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { useParams } from 'common' -import { useBranchesQuery } from 'data/branches/branches-query' -import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' -import { useProjectsQuery } from 'data/projects/projects-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' -import { useSidePanelsStateSnapshot } from 'state/side-panels' -import { Badge, Button, Card, CardContent } from 'ui' - -export const AutomaticBranchingRow = () => { - const { ref: projectRef } = useParams() - const project = useSelectedProject() - const org = useSelectedOrganization() - const sidePanelsStateSnapshot = useSidePanelsStateSnapshot() - - const isBranch = project?.parent_project_ref !== undefined - - // Permissions for reading and creating GitHub connections - const canReadGitHubConnection = useCheckPermissions( - PermissionAction.READ, - 'integrations.github_connections' - ) - const canCreateGitHubConnection = useCheckPermissions( - PermissionAction.CREATE, - 'integrations.github_connections' - ) - - const { data: allConnections } = useGitHubConnectionsQuery({ organizationId: org?.id }) - - // Determine automatic branching status - const baseProjectRef = isBranch ? project?.parent_project_ref : projectRef - const { data: branches } = useBranchesQuery( - { projectRef: baseProjectRef }, - { enabled: Boolean(baseProjectRef) } - ) - - const prodBranch = branches?.find((b) => b.is_default) ?? branches?.[0] - - const { data: projects } = useProjectsQuery() - const baseProject = isBranch - ? projects?.find((p) => p.ref === project?.parent_project_ref) - : project - - // Filter connections for current (base) project - const connections = - allConnections?.filter((connection) => - isBranch - ? connection.project.ref === project?.parent_project_ref - : connection.project.ref === projectRef - ) ?? [] - - const autoBranchingEnabled = - connections.length > 0 && - Boolean(baseProject?.is_branch_enabled) && - Boolean(prodBranch?.git_branch && prodBranch.git_branch.trim().length > 0) - - /* - If the user does not have permission to read GitHub connections, we simply do not - render the row. The surrounding page should handle showing a more global - NoPermission component if needed. - */ - if (!canReadGitHubConnection) { - return null - } - - return ( - - - {/* Left: label and description */} -
-
Automatic Branching
-

- Create a Supabase branch for every GitHub pull request and sync them on every commit and - merge. -

-
- - {/* Right: status badge + configure button */} -
- {autoBranchingEnabled ? ( - Enabled - ) : ( - Disabled - )} - - -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 04ed5e793df..e7ddf80f298 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -1,15 +1,51 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Check, Github, Loader2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' +import { useParams } from 'common' import { ScaffoldContainer, ScaffoldSection, ScaffoldSectionContent, ScaffoldSectionDetail, } from 'components/layouts/Scaffold' +import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import NoPermission from 'components/ui/NoPermission' +import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' +import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' +import { useBranchesQuery } from 'data/branches/branches-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 { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import type { IntegrationProjectConnection } from 'data/integrations/integrations.types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' -import { AutomaticBranchingRow } from './AutomaticBranchingRow' +import { useSidePanelsStateSnapshot } from 'state/side-panels' +import { + Badge, + Button, + Card, + CardContent, + CardFooter, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormMessage_Shadcn_, + Input_Shadcn_, + Switch, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection' const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => { return ( @@ -22,13 +58,260 @@ const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' }) => { } const GitHubSection = () => { + const { ref: projectRef } = useParams() + const selectedProject = useSelectedProject() + const selectedOrganization = useSelectedOrganization() + const sidePanelStateSnapshot = useSidePanelsStateSnapshot() + + const [isConfirmingBranchChange, setIsConfirmingBranchChange] = useState(false) + const canReadGitHubConnection = useCheckPermissions( PermissionAction.READ, 'integrations.github_connections' ) + const canCreateGitHubConnection = useCheckPermissions( + PermissionAction.CREATE, + 'integrations.github_connections' + ) + const canUpdateGitHubConnection = useCheckPermissions( + PermissionAction.UPDATE, + 'integrations.github_connections' + ) + + const { data: connections } = useGitHubConnectionsQuery( + { organizationId: selectedOrganization?.id }, + { enabled: !!projectRef && !!selectedOrganization?.id } + ) + + const existingConnection = useMemo( + () => connections?.find((c) => c.project.ref === projectRef), + [connections, projectRef] + ) + + const { mutate: createBranch } = useBranchCreateMutation() + const { mutate: updateBranch } = useBranchUpdateMutation() + + const { data: existingBranches } = useBranchesQuery({ projectRef }, { enabled: !!projectRef }) + + const isBranchingEnabled = selectedProject?.is_branch_enabled ?? false + + const { mutate: createConnection, isLoading: isCreatingConnection } = + useGitHubConnectionCreateMutation({}) + + const { mutate: deleteConnection } = useGitHubConnectionDeleteMutation({ + onSuccess: () => { + toast.success('Successfully deleted GitHub connection') + }, + }) + + const { mutateAsync: checkGithubBranchValidity, isLoading: isCheckingBranch } = + useCheckGithubBranchValidity({ onError: () => {} }) + + const { mutate: updateConnectionSettings, isLoading: isUpdatingConnection } = + useGitHubConnectionUpdateMutation() + + const prodBranch = existingBranches?.find((b: any) => b.is_default) + const isConnected = Boolean(existingConnection) const GitHubTitle = `GitHub Integration` + // Production Branch Form + const ProductionBranchSchema = z + .object({ + branchName: z.string(), + }) + .superRefine(async (val, ctx) => { + if (isConnected && existingConnection && val.branchName && val.branchName.length > 0) { + try { + await checkGithubBranchValidity({ + repositoryId: existingConnection.repository.id, + branchName: val.branchName, + }) + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Branch "${val.branchName}" not found in repository`, + path: ['branchName'], + }) + } + } + }) + + const productionBranchForm = useForm>({ + resolver: zodResolver(ProductionBranchSchema), + mode: 'onSubmit', + reValidateMode: 'onBlur', + defaultValues: { + branchName: 'main', + }, + }) + + // Automatic Branching Form + const AutomaticBranchingSchema = z.object({ + autoBranchingEnabled: z.boolean().default(false), + supabaseDirectory: z.string().default(''), + supabaseChangesOnly: z.boolean().default(false), + branchLimit: z.string().default('50'), + }) + + const automaticBranchingForm = useForm>({ + resolver: zodResolver(AutomaticBranchingSchema), + defaultValues: { + autoBranchingEnabled: false, + supabaseDirectory: '', + supabaseChangesOnly: false, + branchLimit: '50', + }, + }) + + const autoBranchingEnabled = automaticBranchingForm.watch('autoBranchingEnabled') + + const onSubmitProductionBranch = async (data: z.infer) => { + const originalBranchName = prodBranch?.git_branch + + if (originalBranchName && data.branchName !== originalBranchName) { + setIsConfirmingBranchChange(true) + } else { + await executeProductionBranchSave(data) + } + } + + const executeProductionBranchSave = async (data: z.infer) => { + if (!projectRef) return + + if (!isBranchingEnabled) { + // create production branch (enables branching) + createBranch({ + projectRef, + branchName: data.branchName, + gitBranch: data.branchName, + }) + } else { + if (prodBranch?.id) { + updateBranch({ + id: prodBranch.id, + projectRef, + branchName: prodBranch.name, + gitBranch: data.branchName, + }) + } + } + + toast.success('Production branch configuration updated') + setIsConfirmingBranchChange(false) + } + + const onConfirmBranchChange = async () => { + await executeProductionBranchSave(productionBranchForm.getValues()) + } + + const onSubmitAutomaticBranching = async (data: z.infer) => { + if (!projectRef || !selectedOrganization) return console.error('Missing required data') + if (!existingConnection) return toast.error('Please connect to a repository first.') + + if (data.autoBranchingEnabled) { + const defaultBranch = productionBranchForm.getValues().branchName + + if (!isBranchingEnabled) { + // create production branch (enables branching) + createBranch({ + projectRef, + branchName: defaultBranch, + gitBranch: defaultBranch, + }) + } + } + + updateConnectionSettings({ + connectionId: existingConnection.id, + organizationId: selectedOrganization.id, + workdir: data.supabaseDirectory, + supabaseChangesOnly: data.supabaseChangesOnly, + branchLimit: Number(data.branchLimit), + }) + + if (data.autoBranchingEnabled) { + toast.success('Automatic branching enabled') + } else { + toast.success('Automatic branching disabled') + } + } + + const openLinkerPanel = () => { + sidePanelStateSnapshot.setGithubConnectionsOpen(true) + } + + const onDeleteGitHubConnection = useCallback( + async (connection: IntegrationProjectConnection) => { + if (!selectedOrganization?.id) { + toast.error('Organization not found') + return + } + + deleteConnection({ + connectionId: Number(connection.id), + organizationId: selectedOrganization.id, + }) + }, + [deleteConnection, selectedOrganization?.id] + ) + + useEffect(() => { + if (existingConnection) { + automaticBranchingForm.reset({ + autoBranchingEnabled: true, + supabaseDirectory: + (existingConnection as any)?.workdir ?? + (existingConnection as any)?.metadata?.supabaseConfig?.supabaseDirectory ?? + '', + supabaseChangesOnly: + (existingConnection as any)?.supabase_changes_only ?? + (existingConnection as any)?.metadata?.supabaseConfig?.supabaseChangesOnly ?? + false, + branchLimit: String( + ((existingConnection as any)?.branch_limit as number | undefined) ?? + (existingConnection as any)?.metadata?.supabaseConfig?.branchLimit ?? + 50 + ), + }) + } else { + automaticBranchingForm.reset({ + autoBranchingEnabled: false, + supabaseDirectory: '', + supabaseChangesOnly: false, + branchLimit: '50', + }) + } + }, [existingConnection, automaticBranchingForm]) + + useEffect(() => { + if (prodBranch?.git_branch) { + productionBranchForm.reset({ + branchName: prodBranch.git_branch, + }) + } else { + productionBranchForm.reset({ + branchName: 'main', + }) + } + }, [prodBranch, productionBranchForm]) + + if (!canReadGitHubConnection) { + return ( + + + +

Connect any of your GitHub repositories to a project.

+ +
+ + + +
+
+ ) + } + return ( @@ -37,14 +320,268 @@ const GitHubSection = () => { - {!canReadGitHubConnection ? ( - - ) : ( +
+ {/* Section 1: GitHub Connection */}
-

Integration features

- +
GitHub Repository
+ {existingConnection ? ( +
    + +
+ ) : ( + + Connect repository + + )}
- )} + + {/* Section 2: Production Branch Configuration */} + +
+ + + ( + +
+ + + +
+ {isCheckingBranch && } + {field.value && + !isCheckingBranch && + !productionBranchForm.formState.errors.branchName && + isConnected && ( + + )} +
+
+ +
+ )} + /> +
+ + {productionBranchForm.formState.isDirty && ( + + )} + + +
+
+
+ + {/* Section 3: Automatic Branching Configuration */} + +
+ + + ( + + + + + + )} + /> + +
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + ( + + + field.onChange(val)} + disabled={ + !autoBranchingEnabled || + !isConnected || + !canUpdateGitHubConnection + } + /> + + + )} + /> +
+
+ + {automaticBranchingForm.formState.isDirty && ( + + )} + + +
+
+
+
+ + setIsConfirmingBranchChange(false)} + onConfirm={onConfirmBranchChange} + loading={isUpdatingConnection || isCreatingConnection} + > +

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

+