diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 9e265a77b2c..f8b8733614e 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -1,5 +1,6 @@ import { useParams } from 'common' import { partition } from 'lodash' +import { MessageCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' @@ -11,20 +12,18 @@ import { IconAlertTriangle, IconExternalLink, IconGitHub, - IconSearch, - Input, Modal, } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation' import { useBranchesDisableMutation } from 'data/branches/branches-disable-mutation' import { Branch, useBranchesQuery } from 'data/branches/branches-query' -import { useGithubPullRequestsQuery } from 'data/integrations/integrations-github-pull-requests-query' -import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { useGitHubPullRequestsQuery } from 'data/integrations/github-pull-requests-query' import { useSelectedOrganization, useSelectedProject, useStore } from 'hooks' import { BranchLoader, BranchManagementSection, BranchRow } from './BranchPanels' import CreateBranchModal from './CreateBranchModal' @@ -34,7 +33,6 @@ import { PullRequestsEmptyState, } from './EmptyStates' import Overview from './Overview' -import { MessageCircle } from 'lucide-react' const BranchManagement = () => { const { ui } = useStore() @@ -55,12 +53,14 @@ const BranchManagement = () => { const [selectedBranchToDelete, setSelectedBranchToDelete] = useState() const { - data: integrations, - error: integrationsError, - isLoading: isLoadingIntegrations, - isError: isErrorIntegrations, - isSuccess: isSuccessIntegrations, - } = useOrgIntegrationsQuery({ orgSlug: selectedOrg?.slug }) + data: connections, + error: connectionsError, + isLoading: isLoadingConnections, + isSuccess: isSuccessConnections, + isError: isErrorConnections, + } = useGitHubConnectionsQuery({ + organizationId: selectedOrg?.id, + }) const { data: branches, @@ -79,16 +79,8 @@ const BranchManagement = () => { ? (branchesWithPRs.map((branch) => branch.pr_number).filter(Boolean) as number[]) : undefined - const githubIntegration = integrations?.find( - (integration) => - integration.integration.name === 'GitHub' && - integration.organization.slug === selectedOrg?.slug - ) - const githubConnection = githubIntegration?.connections?.find( - (connection) => connection.supabase_project_ref === projectRef - ) - const repo = githubConnection?.metadata.name ?? '' - const [repoOwner, repoName] = githubConnection?.metadata.name.split('/') || [] + const githubConnection = connections?.find((connection) => connection.project.ref === projectRef) + const repo = githubConnection?.repository.name ?? '' const { data: allPullRequests, @@ -96,17 +88,15 @@ const BranchManagement = () => { isLoading: isLoadingPullRequests, isError: isErrorPullRequests, isSuccess: isSuccessPullRequests, - } = useGithubPullRequestsQuery({ - organizationIntegrationId: githubIntegration?.id, - repoOwner, - repoName, + } = useGitHubPullRequestsQuery({ + connectionId: githubConnection?.id, prNumbers, }) const pullRequests = allPullRequests ?? [] - const isError = isErrorIntegrations || isErrorBranches - const isLoading = isLoadingIntegrations || isLoadingBranches - const isSuccess = isSuccessIntegrations && isSuccessBranches + const isError = isErrorConnections || isErrorBranches + const isLoading = isLoadingConnections || isLoadingBranches + const isSuccess = isSuccessConnections && isSuccessBranches const { mutate: deleteBranch, isLoading: isDeleting } = useBranchDeleteMutation({ onSuccess: () => { @@ -138,8 +128,8 @@ const BranchManagement = () => { if (githubConnection === undefined) return 'https://github.com' return branch !== undefined - ? `https://github.com/${githubConnection.metadata.name}/compare/${mainBranch?.git_branch}...${branch}` - : `https://github.com/${githubConnection.metadata.name}/compare` + ? `https://github.com/${githubConnection.repository.name}/compare/${mainBranch?.git_branch}...${branch}` + : `https://github.com/${githubConnection.repository.name}/compare` } const onConfirmDeleteBranch = () => { @@ -219,14 +209,14 @@ const BranchManagement = () => { - {isErrorIntegrations && ( + {isErrorConnections && ( )} - {isSuccessIntegrations && ( + {isSuccessConnections && (
diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 425e4b246ad..d60b2a11144 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -8,7 +8,7 @@ import { useInView } from 'react-intersection-observer' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useBranchQuery } from 'data/branches/branch-query' import { Branch } from 'data/branches/branches-query' -import { GitHubPullRequest } from 'data/integrations/integrations-github-pull-requests-query' +import { GitHubPullRequest } from 'data/integrations/github-pull-requests-query' import { Badge, Button, diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index bf556e33b6c..e7e98033248 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -4,6 +4,15 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import toast from 'react-hot-toast' +import * as z from 'zod' + +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { useSelectedOrganization, useSelectedProject } from 'hooks' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -21,15 +30,6 @@ import { Input_Shadcn_, Modal, } from 'ui' -import * as z from 'zod' - -import AlertError from 'components/ui/AlertError' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' -import { useBranchesQuery } from 'data/branches/branches-query' -import { useCheckGithubBranchValidity } from 'data/integrations/integrations-github-branch-check' -import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' -import { useSelectedOrganization, useSelectedProject } from 'hooks' interface CreateBranchModalProps { visible: boolean @@ -51,13 +51,13 @@ const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined const { - data: integrations, - error: integrationsError, - isLoading: isLoadingIntegrations, - isSuccess: isSuccessIntegrations, - isError: isErrorIntegrations, - } = useOrgIntegrationsQuery({ - orgSlug: selectedOrg?.slug, + data: connections, + error: connectionsError, + isLoading: isLoadingConnections, + isSuccess: isSuccessConnections, + isError: isErrorConnections, + } = useGitHubConnectionsQuery({ + organizationId: selectedOrg?.id, }) const { data: branches } = useBranchesQuery({ projectRef }) @@ -73,17 +73,10 @@ const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { }, }) - const githubIntegration = integrations?.find( - (integration) => - integration.integration.name === 'GitHub' && - integration.connections.some( - (connection) => connection.supabase_project_ref === projectDetails?.parentRef - ) + const githubConnection = connections?.find( + (connection) => connection.project.ref === projectDetails?.parentRef ) - const githubConnection = githubIntegration?.connections?.find( - (connection) => connection.supabase_project_ref === projectDetails?.parentRef - ) - const [repoOwner, repoName] = githubConnection?.metadata.name.split('/') || [] + const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] const formId = 'create-branch-form' const FormSchema = z.object({ @@ -98,10 +91,12 @@ const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { if (val.length > 0) { try { + if (!githubConnection?.id) { + throw new Error('No GitHub connection found') + } + await checkGithubBranchValidity({ - organizationIntegrationId: githubIntegration?.id, - repoOwner, - repoName, + connectionId: githubConnection.id, branchName: val, }) setIsValid(true) @@ -154,20 +149,20 @@ const CreateBranchModal = ({ visible, onClose }: CreateBranchModalProps) => { confirmText="Create Preview Branch" > - {isLoadingIntegrations && } - {isErrorIntegrations && ( + {isLoadingConnections && } + {isErrorConnections && ( )} - {isSuccessIntegrations && ( + {isSuccessConnections && (

Your project is currently connected to the repository:

-

{githubConnection?.metadata.name}

+

{githubConnection?.repository.name}

integration.integration.name === 'GitHub') - .flatMap((integration) => integration.connections) + const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id }) + const githubConnections = connections?.map((connection) => ({ + id: String(connection.id), + added_by: { + id: String(connection.user?.id), + primary_email: connection.user?.primary_email ?? '', + username: connection.user?.username ?? '', + }, + foreign_project_id: String(connection.repository.id), + supabase_project_ref: connection.project.ref, + organization_integration_id: 'unused', + inserted_at: connection.inserted_at, + updated_at: connection.updated_at, + metadata: { + name: connection.repository.name, + } as any, + })) const vercelConnections = integrations ?.filter((integration) => integration.integration.name === 'Vercel') .flatMap((integration) => integration.connections) diff --git a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx b/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx index 26414f1892d..14ea02bebf9 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx +++ b/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx @@ -2,6 +2,9 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { forwardRef, useCallback, useState } from 'react' import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, Button, DropdownMenu, DropdownMenuContent, @@ -14,14 +17,16 @@ import { IconTrash, Modal, } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { IntegrationConnection, IntegrationConnectionProps, } from 'components/interfaces/Integrations/IntegrationPanels' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { WarningIcon } from 'components/ui/Icons' import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useProjectsQuery } from 'data/projects/projects-query' import { useStore } from 'hooks' interface IntegrationConnectionItemProps extends IntegrationConnectionProps { @@ -32,18 +37,23 @@ interface IntegrationConnectionItemProps extends IntegrationConnectionProps { const IntegrationConnectionItem = forwardRef( ({ disabled, onDeleteConnection, ...props }, ref) => { const { ui } = useStore() + const router = useRouter() + + const { type, connection } = props + const { data: projects } = useProjectsQuery() + const project = projects?.find((project) => project.ref === connection.supabase_project_ref) + const isBranchingEnabled = project?.is_branch_enabled === true + const [isOpen, setIsOpen] = useState(false) const [dropdownVisible, setDropdownVisible] = useState(false) - const router = useRouter() - const onConfirm = useCallback(async () => { try { - await onDeleteConnection(props.connection) + await onDeleteConnection(connection) } finally { setIsOpen(false) } - }, [props.connection, onDeleteConnection]) + }, [connection, onDeleteConnection]) const onCancel = useCallback(() => { setIsOpen(false) @@ -61,14 +71,15 @@ const IntegrationConnectionItem = forwardRef { - syncEnvs({ connectionId: props.connection.id }) - }, [props.connection, syncEnvs]) + syncEnvs({ connectionId: connection.id }) + }, [connection, syncEnvs]) const projectIntegrationUrl = `/project/[ref]/settings/integrations` return ( <> } type="default"> @@ -86,37 +97,37 @@ const IntegrationConnectionItem = forwardRef - {props.type === 'Vercel' && ( - <> - {router.pathname !== projectIntegrationUrl && ( - - - View project configuration - - - )} - { - event.preventDefault() - onReSyncEnvVars() - }} - disabled={isSyncEnvLoading} - > - {isSyncEnvLoading ? ( - - ) : ( - + {router.pathname !== projectIntegrationUrl && ( + + Resync environment variables

-
- - + > + Configure connection + +
+ )} + {type === 'Vercel' && ( + { + event.preventDefault() + onReSyncEnvVars() + }} + disabled={isSyncEnvLoading} + > + {isSyncEnvLoading ? ( + + ) : ( + + )} +

Resync environment variables

+
+ )} + {(type === 'Vercel' || router.pathname !== projectIntegrationUrl) && ( + )} setIsOpen(true)}> @@ -130,16 +141,27 @@ const IntegrationConnectionItem = forwardRef - -

- {`This action cannot be undone. Are you sure you want to delete this connection?`} + + {type === 'GitHub' && isBranchingEnabled && ( + + + Branching will be disabled for this project + + Deleting this GitHub connection will remove all preview branches on this project, + and also disable branching for {project.name} + + + )} +

+ This action cannot be undone. Are you sure you want to delete this {type} connection?

diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx index c1b6f6c3cdb..58caf455aaa 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -154,7 +154,7 @@ const IntegrationConnection = React.forwardRef {showNode && (
@@ -164,14 +164,16 @@ const IntegrationConnection = React.forwardRef
- {project?.name} + + {project?.name} + {!connection?.metadata?.framework ? (
@@ -185,7 +187,21 @@ const IntegrationConnection = React.forwardRef )} - {connection.metadata?.name} + {type === 'GitHub' ? ( + + {connection.metadata?.name} + + ) : ( + + {connection.metadata?.name} + + )}
diff --git a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx index 0311a881d67..e1245df1607 100644 --- a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx +++ b/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -1,3 +1,4 @@ +import { PlusIcon } from 'lucide-react' import { ReactNode, useEffect, useRef, useState } from 'react' import { toast } from 'react-hot-toast' import { @@ -7,6 +8,7 @@ import { CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, + CommandSeparator_Shadcn_, Command_Shadcn_, IconChevronDown, PopoverContent_Shadcn_, @@ -14,6 +16,7 @@ import { Popover_Shadcn_, cn, } from 'ui' +import { useRouter } from 'next/router' import ShimmerLine from 'components/ui/ShimmerLine' import { @@ -22,6 +25,7 @@ import { } from 'data/integrations/integrations.types' import { useSelectedOrganization } from 'hooks' import { BASE_PATH } from 'lib/constants' +import { openInstallGitHubIntegrationWindow } from 'lib/github' export interface Project { id: string @@ -32,14 +36,15 @@ export interface Project { export interface ForeignProject { id: string name: string + installation_id?: number } export interface ProjectLinkerProps { - organizationIntegrationId: string | undefined + organizationIntegrationId?: string foreignProjects: ForeignProject[] supabaseProjects: Project[] onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void - installedConnections: IntegrationProjectConnection[] | undefined + installedConnections?: IntegrationProjectConnection[] isLoading?: boolean integrationIcon: ReactNode getForeignProjectIcon?: (project: ForeignProject) => ReactNode @@ -47,6 +52,7 @@ export interface ProjectLinkerProps { onSkip?: () => void loadingForeignProjects?: boolean loadingSupabaseProjects?: boolean + showNoEntitiesState?: boolean defaultSupabaseProjectRef?: string defaultForeignProjectId?: string @@ -65,10 +71,12 @@ const ProjectLinker = ({ onSkip, loadingForeignProjects, loadingSupabaseProjects, + showNoEntitiesState = true, defaultSupabaseProjectRef, defaultForeignProjectId, }: ProjectLinkerProps) => { + const router = useRouter() const [supabaseProjectsComboBoxOpen, setSupabaseProjectsComboboxOpen] = useState(false) const [foreignProjectsComboBoxOpen, setForeignProjectsComboboxOpen] = useState(false) const supabaseProjectsComboBoxRef = useRef(null) @@ -106,7 +114,6 @@ const ProjectLinker = ({ function onCreateConnections() { const projectDetails = selectedForeignProject - if (!organizationIntegrationId) return console.error('No integration ID set') if (!selectedForeignProject?.id) return console.error('No Foreign project ID set') if (!selectedSupabaseProject?.ref) return console.error('No Supabase project ref set') @@ -118,7 +125,7 @@ const ProjectLinker = ({ } _onCreateConnections({ - organizationIntegrationId, + organizationIntegrationId: organizationIntegrationId!, connection: { foreign_project_id: selectedForeignProject?.id, supabase_project_ref: selectedSupabaseProject?.ref, @@ -128,6 +135,11 @@ const ProjectLinker = ({ }, }, orgSlug: selectedOrganization?.slug, + new: { + installation_id: selectedForeignProject.installation_id!, + project_ref: selectedSupabaseProject.ref, + repository_id: Number(selectedForeignProject.id), + }, }) } @@ -160,10 +172,10 @@ const ProjectLinker = ({ {loadingForeignProjects || loadingSupabaseProjects ? (
-

Loading projects

+

Loading projects

- ) : noSupabaseProjects || noForeignProjects ? ( + ) : showNoEntitiesState && (noSupabaseProjects || noForeignProjects) ? (
No {missingEntity} Projects found

@@ -174,7 +186,7 @@ const ProjectLinker = ({

) : ( -
+
Supabase @@ -188,7 +200,6 @@ const ProjectLinker = ({ +
+ ) : ( + + )}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 80a0858d954..7e3fe15e480 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -2,12 +2,12 @@ import { zodResolver } from '@hookform/resolvers/zod' import { GitBranch, RotateCcw, Shield } from 'lucide-react' import { useRef, useState } from 'react' import { useForm } from 'react-hook-form' +import toast from 'react-hot-toast' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, - cn, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, @@ -27,46 +27,45 @@ import { PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, + Switch, + cn, } from 'ui' import * as z from 'zod' import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' import { useBranchesQuery } from 'data/branches/branches-query' -import { useGithubConnectionUpdateMutation } from 'data/integrations/github-connection-update-mutate' -import { useGithubBranchesQuery } from 'data/integrations/integrations-github-branches-query' -import { Integration, IntegrationProjectConnection } from 'data/integrations/integrations.types' -import { useSelectedProject, useStore } from 'hooks' +import { useGitHubBranchesQuery } from 'data/integrations/github-branches-query' +import { useGitHubConnectionUpdateMutation } from 'data/integrations/github-connection-update-mutation' +import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { useSelectedOrganization, useSelectedProject } from 'hooks' -const GitHubIntegrationConnectionForm = ({ - connection, - integration, -}: { +interface GitHubIntegrationConnectionFormProps { connection: IntegrationProjectConnection - integration: Integration -}) => { - const { ui } = useStore() +} + +const GitHubIntegrationConnectionForm = ({ connection }: GitHubIntegrationConnectionFormProps) => { + const org = useSelectedOrganization() const project = useSelectedProject() const [open, setOpen] = useState(false) const comboBoxRef = useRef(null) + const isBranchingEnabled = + project?.is_branch_enabled === true || project?.parent_project_ref !== undefined - const githubProjectIntegration = integration?.connections.find( - (connection) => connection.supabase_project_ref === project?.parentRef + const { data: githubBranches, isLoading: isLoadingBranches } = useGitHubBranchesQuery( + { + connectionId: Number(connection.id), + }, + { enabled: isBranchingEnabled } ) - const [repoOwner, repoName] = githubProjectIntegration?.metadata.name.split('/') ?? [] - - const { data: githubBranches, isLoading: isLoadingBranches } = useGithubBranchesQuery({ - organizationIntegrationId: integration?.id, - repoOwner, - repoName, - }) + const { mutate: updateConnection, isLoading: isUpdatingConnection } = + useGitHubConnectionUpdateMutation({ + onSuccess: () => toast.success('Successfully updated connection settings'), + }) const { mutate: updateBranch, isLoading: isUpdatingProdBranch } = useBranchUpdateMutation({ onSuccess: (data) => { - ui.setNotification({ - category: 'success', - message: `Changed Production Branch to ${data.git_branch}`, - }) + toast.success(`Successfully updated production branch to ${data.git_branch}`) setOpen(false) }, }) @@ -93,156 +92,129 @@ const GitHubIntegrationConnectionForm = ({ supabaseDirectory: z .string() .default(connection.metadata?.supabaseConfig?.supabaseDirectory ?? ''), + supabaseChangesOnly: z + .boolean() + .default(connection.metadata?.supabaseConfig?.supabaseChangesOnly ?? false), }) const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { supabaseDirectory: connection?.metadata?.supabaseConfig?.supabaseDirectory, + supabaseChangesOnly: connection?.metadata?.supabaseConfig?.supabaseChangesOnly, }, }) - const { mutate: updateGithubConnection, isLoading: isUpdatingGithubConnection } = - useGithubConnectionUpdateMutation({ - onSuccess: () => { - ui.setNotification({ category: 'success', message: `Updated Supabase directory` }) - setOpen(false) - }, - }) - function onSubmit(data: z.infer) { - const metadata = { - ...connection.metadata, - supabaseConfig: { - ...connection.metadata?.supabaseConfig, - supabaseDirectory: data.supabaseDirectory, - }, - } - - updateGithubConnection({ - id: connection.id, - metadata, - organizationIntegrationId: integration.id, + if (org?.id === undefined) return console.error('Org ID is required') + updateConnection({ + connectionId: connection.id, + organizationId: org?.id, + workdir: data.supabaseDirectory, + supabaseChangesOnly: data.supabaseChangesOnly, }) } return ( -
-
- Production branch -

- All other branches will be treated as Preview branches -

+
+ {isBranchingEnabled && ( +
+ Production branch +

+ All other branches will be treated as Preview branches +

- - - Changing Git branch for Production Branch coming soon - - - If you wish to change the Git branch that is used for the Production Branch you will - need to disable Branching and opt back in. - - + {/*
! This should only work if branching is turned on !
*/} - {/*
! This should only work if branching is turned on !
*/} - - - + + - {productionPreviewBranch?.git_branch || 'Select a branch'} - - - - - - - No branches found - - {githubBranches?.map((branch) => { - const active = branch.name === productionPreviewBranch?.git_branch - return ( - { - setOpen(false) - onUpdateProductionBranch(branch.name) - }} - > -
- {active ? ( - - ) : ( - - )} - {branch.name} -
- {branch.name === productionPreviewBranch?.git_branch && } -
- ) - })} -
-
-
-
-
-
+ + + + No branches found + + {githubBranches?.map((branch) => { + const active = branch.name === productionPreviewBranch?.git_branch + return ( + { + setOpen(false) + onUpdateProductionBranch(branch.name) + }} + > +
+ {active ? ( + + ) : ( + + )} + {branch.name} +
+ {branch.name === productionPreviewBranch?.git_branch && } +
+ ) + })} +
+
+
+ + +
+ )} -
- Supabase directory - - Migration and seed SQL files will be run from this directory. - - - - - Changing Supabase directory is currently not supported - - - You will need to disable Branching and opt back into Branching to change the - Production Branch. your Git repository. - - - + ( - - + + Supabase directory + + Path in your repository where supabase directory for this connection + lives. + +
{ - if (event.key === 'Escape') { - form.reset() - } + if (event.key === 'Escape') form.reset() }} /> form.reset()} /> +
- +
+ )} + /> + + ( + + + { + field.onChange(e) + form.handleSubmit(onSubmit)() + }} + /> + +
+ Supabase changes only + + Trigger branch creation only when there were changes to supabase{' '} + directory. + +
)} /> diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 515d7823970..a9645e1005b 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -1,11 +1,8 @@ -import { useParams } from 'common' import { useCallback } from 'react' +import { useParams } from 'common' import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection' -import { - IntegrationConnectionHeader, - IntegrationInstallation, -} from 'components/interfaces/Integrations/IntegrationPanels' +import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldContainer, @@ -13,13 +10,17 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from 'components/layouts/Scaffold' -import { useIntegrationsGitHubInstalledConnectionDeleteMutation } from 'data/integrations/integrations-github-connection-delete-mutation' -import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useBranchesDisableMutation } from 'data/branches/branches-disable-mutation' +import { useGitHubConnectionDeleteMutation } from 'data/integrations/github-connection-delete-mutation' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { IntegrationName, IntegrationProjectConnection } from 'data/integrations/integrations.types' import { useSelectedOrganization, useSelectedProject, useStore } from 'hooks' -import { pluralize } from 'lib/helpers' +import { OPT_IN_TAGS } from 'lib/constants' +import { useSidePanelsStateSnapshot } from 'state/side-panels' +import { cn } from 'ui' import { IntegrationImageHandler } from '../IntegrationsSettings' import GitHubIntegrationConnectionForm from './GitHubIntegrationConnectionForm' +import { useBranchesQuery } from 'data/branches/branches-query' const GitHubTitle = `GitHub Connections` @@ -33,43 +34,63 @@ const GitHubContentSectionTop = ` You will be able to connect a GitHub repository to a Supabase project. The GitHub app will watch for changes in your repository such as file changes, branch changes as well as pull request activity. - -These connections will be part of a GitHub workflow that is currently in development. ` const GitHubSection = () => { const { ui } = useStore() + const { ref: projectRef } = useParams() const project = useSelectedProject() const org = useSelectedOrganization() - const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug }) - const { ref: projectRef } = useParams() + const sidePanelsStateSnapshot = useSidePanelsStateSnapshot() + const { data: allConnections } = useGitHubConnectionsQuery({ organizationId: org?.id }) + const { data: branches } = useBranchesQuery({ projectRef }) + + const { mutate: deleteGitHubConnection } = useGitHubConnectionDeleteMutation({ + onSuccess: () => { + ui.setNotification({ + category: 'success', + message: 'Successfully deleted Github connection', + }) + }, + }) + + const { mutate: disableBranching } = useBranchesDisableMutation() + + const previewBranches = (branches ?? []).filter((branch) => !branch.is_default) + const hasAccessToBranching = org?.opt_in_tags?.includes(OPT_IN_TAGS.PREVIEW_BRANCHES) ?? false const isBranch = project?.parent_project_ref !== undefined + const isBranchingEnabled = + project?.is_branch_enabled === true || project?.parent_project_ref !== undefined - const githubIntegrations = data?.filter( - (integration) => integration.integration.name === 'GitHub' - ) + const connections = + allConnections?.filter((connection) => + isBranch + ? connection.project.ref === project.parent_project_ref + : connection.project.ref === projectRef + ) ?? [] - const { mutate: deleteGitHubConnection } = useIntegrationsGitHubInstalledConnectionDeleteMutation( - { - onSuccess: () => { - ui.setNotification({ - category: 'success', - message: 'Successfully deleted Github connection', - }) - }, - } - ) + const onAddGitHubConnection = useCallback(() => { + sidePanelsStateSnapshot.setGithubConnectionsOpen(true) + }, [sidePanelsStateSnapshot]) const onDeleteGitHubConnection = useCallback( async (connection: IntegrationProjectConnection) => { - deleteGitHubConnection({ - connectionId: connection.id, - integrationId: connection.organization_integration_id, - orgSlug: org?.slug, - }) + if (isBranchingEnabled) { + if (!projectRef) throw new Error('Project ref not found') + disableBranching({ projectRef, branchIds: previewBranches?.map((branch) => branch.id) }) + } + if (!org?.id) throw new Error('Organization not found') + deleteGitHubConnection({ connectionId: connection.id, organizationId: org.id }) }, - [deleteGitHubConnection, org?.slug] + [ + deleteGitHubConnection, + disableBranching, + isBranchingEnabled, + org?.id, + previewBranches, + projectRef, + ] ) return ( @@ -81,62 +102,85 @@ const GitHubSection = () => { - {githubIntegrations && - githubIntegrations.length > 0 && - githubIntegrations.map((integration, i) => { - const connections = integration.connections.filter((connection) => - isBranch - ? connection.supabase_project_ref === project.parent_project_ref - : connection.supabase_project_ref === projectRef - ) - return ( -
- 0 ? ( +
    + {connections.map((connection) => ( +
    + - {connections.length > 0 ? ( - <> - -
      - {connections.map((connection) => ( -
      - -
      - -
      -
      - ))} -
    - - ) : ( - + - )} +
- ) - })} + ))} + + ) : hasAccessToBranching ? ( + + Add new project connection + + ) : ( +

+ Access to{' '} + + branching + {' '} + is required to add GitHub connections. +

+ )}
diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx index 1267c61569b..b7a64cda1f6 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/EnableBranchingModal.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' import { last } from 'lodash' +import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { @@ -19,17 +20,17 @@ import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' -import { useCheckGithubBranchValidity } from 'data/integrations/integrations-github-branch-check' -import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { useGitHubRepositoriesQuery } from 'data/integrations/github-repositories-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganization, useStore } from 'hooks' import { useAppStateSnapshot } from 'state/app-state' import BranchingPITRNotice from './BranchingPITRNotice' +import BranchingPlanNotice from './BranchingPlanNotice' import BranchingPostgresVersionNotice from './BranchingPostgresVersionNotice' import GithubRepositorySelection from './GithubRepositorySelection' -import Link from 'next/link' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import BranchingPlanNotice from './BranchingPlanNotice' const EnableBranchingModal = () => { const { ui } = useStore() @@ -43,14 +44,20 @@ const EnableBranchingModal = () => { const [isValid, setIsValid] = useState(false) const { - data: integrations, - error: integrationsError, - isLoading: isLoadingIntegrations, - isSuccess: isSuccessIntegrations, - isError: isErrorIntegrations, - } = useOrgIntegrationsQuery({ - orgSlug: selectedOrg?.slug, - }) + data: repositories, + error: repositoriesError, + isLoading: isLoadingRepositories, + isSuccess: isSuccessRepositories, + isError: isErrorRepositories, + } = useGitHubRepositoriesQuery() + + const { + data: connections, + error: connectionsError, + isLoading: isLoadingConnections, + isSuccess: isSuccessConnections, + isError: isErrorConnections, + } = useGitHubConnectionsQuery({ organizationId: selectedOrg?.id }) const { data, @@ -71,17 +78,8 @@ const EnableBranchingModal = () => { const hasPitrEnabled = (addons?.selected_addons ?? []).find((addon) => addon.type === 'pitr') !== undefined - const hasGithubIntegrationInstalled = - integrations?.some((integration) => integration.integration.name === 'GitHub') ?? false - const githubIntegration = integrations?.find( - (integration) => - integration.integration.name === 'GitHub' && - integration.organization.slug === selectedOrg?.slug - ) - const githubConnection = githubIntegration?.connections.find( - (connection) => connection.supabase_project_ref === ref - ) - const [repoOwner, repoName] = githubConnection?.metadata.name.split('/') ?? [] + const githubConnection = connections?.find((connection) => connection.project.ref === ref) + const [repoOwner, repoName] = githubConnection?.repository.name.split('/') ?? [] const { mutateAsync: checkGithubBranchValidity, isLoading: isChecking } = useCheckGithubBranchValidity({ onError: () => {} }) @@ -101,10 +99,12 @@ const EnableBranchingModal = () => { .refine(async (val) => { try { if (val.length > 0) { + if (!githubConnection?.id) { + throw new Error('No GitHub connection found') + } + await checkGithubBranchValidity({ - organizationIntegrationId: githubIntegration?.id, - repoOwner, - repoName, + connectionId: githubConnection.id, branchName: val, }) setIsValid(true) @@ -123,9 +123,9 @@ const EnableBranchingModal = () => { defaultValues: { branchName: '' }, }) - const isLoading = isLoadingIntegrations || isLoadingUpgradeEligibility - const isError = isErrorIntegrations || isErrorUpgradeEligibility - const isSuccess = isSuccessIntegrations && isSuccessUpgradeEligibility + const isLoading = isLoadingRepositories + const isError = isErrorRepositories || isErrorUpgradeEligibility + const isSuccess = isSuccessRepositories && isSuccessUpgradeEligibility const canSubmit = form.getValues('branchName').length > 0 && !isChecking && isValid const onSubmit = (data: z.infer) => { @@ -183,15 +183,14 @@ const EnableBranchingModal = () => { )} - {isError && ( <> - {isErrorIntegrations ? ( + {isErrorRepositories ? ( ) : isErrorUpgradeEligibility ? ( { )} - {isSuccess && ( <> {isFreePlan ? ( @@ -216,8 +214,7 @@ const EnableBranchingModal = () => { form={form} isChecking={isChecking} isValid={canSubmit} - integration={githubIntegration} - hasGithubIntegrationInstalled={hasGithubIntegrationInstalled} + githubConnection={githubConnection} /> {!hasPitrEnabled && } @@ -262,11 +259,10 @@ const EnableBranchingModal = () => {
+ )} - -
- )} - {hasGithubIntegrationInstalled && !githubConnection && ( - onSelectConnectRepo()} - orgSlug={org?.slug} - /> - )} - {integration && githubConnection && ( + + {githubConnection ? ( <>
    onSelectConnectRepo()}> Configure connection } - orientation="vertical" + orientation="horizontal" />
@@ -129,6 +110,12 @@ const GithubRepositorySelection = ({ )} /> + ) : ( + onSelectConnectRepo()} + orgSlug={org?.slug} + /> )}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 8763801d2ef..ed2373894bc 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -10,7 +10,7 @@ import ProjectDropdown from 'components/layouts/AppLayout/ProjectDropdown' import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useOrgUsageQuery } from 'data/usage/org-usage-query' -import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks' +import { useSelectedOrganization, useSelectedProject } from 'hooks' import { IS_PLATFORM } from 'lib/constants' import BreadcrumbsView from './BreadcrumbsView' import FeedbackDropdown from './FeedbackDropdown' @@ -21,9 +21,7 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = const { ref: projectRef } = useParams() const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() - - const isBranchingEnabled = - selectedProject?.is_branch_enabled === true || selectedProject?.parent_project_ref !== undefined + const isBranchingEnabled = selectedProject?.is_branch_enabled === true const { data: orgUsage } = useOrgUsageQuery({ orgSlug: selectedOrganization?.slug }) diff --git a/apps/studio/data/api.d.ts b/apps/studio/data/api.d.ts index e37a869108f..ddf7a7b6100 100644 --- a/apps/studio/data/api.d.ts +++ b/apps/studio/data/api.d.ts @@ -796,15 +796,15 @@ export interface paths { /** Updates a Vercel connection for a supabase project */ patch: operations['VercelConnectionsController_updateVercelConnection'] } - '/platform/integrations/github': { - /** Create github integration */ - post: operations['GitHubIntegrationController_createGitHubIntegration'] - } - '/platform/integrations/github/connections/{organization_integration_id}': { - /** Gets installed github project connections for the given organization integration */ - get: operations['GitHubConnectionsController_getGitHubConnections'] + '/platform/integrations/github/authorization': { + /** Get GitHub authorization */ + get: operations['GitHubAuthorizationsController_getGitHubAuthorization'] + /** Create GitHub authorization */ + post: operations['GitHubAuthorizationsController_createGitHubAuthorization'] } '/platform/integrations/github/connections': { + /** List organization GitHub connections */ + get: operations['GitHubConnectionsController_listOrganizationGitHubConnections'] /** Connects a GitHub project to a supabase project */ post: operations['GitHubConnectionsController_createGitHubConnection'] } @@ -814,25 +814,25 @@ export interface paths { /** Updates a GitHub connection for a supabase project */ patch: operations['GitHubConnectionsController_updateGitHubConnection'] } - '/platform/integrations/github/repos/{organization_integration_id}': { - /** Gets github repos for the given organization */ - get: operations['GitHubRepoController_getRepos'] + '/platform/integrations/github/branches/{connectionId}': { + /** List GitHub connection branches */ + get: operations['GitHubBranchesController_listConnectionBranches'] } - '/platform/integrations/github/branches/{organization_integration_id}/{repo_owner}/{repo_name}': { - /** Gets github branches for a given repo */ - get: operations['GitHubBranchController_getBranches'] + '/platform/integrations/github/branches/{connectionId}/{branchName}': { + /** Get GitHub connection branch */ + get: operations['GitHubBranchesController_getConnectionBranch'] } - '/platform/integrations/github/branches/{organization_integration_id}/{repo_owner}/{repo_name}/{branch_name}': { - /** Gets a specific github branch for a given repo */ - get: operations['GitHubBranchController_getBranchByName'] + '/platform/integrations/github/pull-requests/{connectionId}': { + /** List GitHub connection pull requests */ + get: operations['GitHubPullRequestsController_getConnectionPullRequests'] } - '/platform/integrations/github/pull-requests/{organization_integration_id}/{repo_owner}/{repo_name}': { - /** Gets github pull requests for a given repo */ - get: operations['GitHubPullRequestController_getPullRequestsByNumber'] + '/platform/integrations/github/pull-requests/{connectionId}/{branchName}': { + /** List GitHub pull requests for a specific branch */ + get: operations['GitHubPullRequestsController_validateConnectionBranch'] } - '/platform/integrations/github/pull-requests/{organization_integration_id}/{repo_owner}/{repo_name}/{target}': { - /** Gets github pull requests for a given repo */ - get: operations['GitHubPullRequestController_getPullRequests'] + '/platform/integrations/github/repositories': { + /** Gets GitHub repositories for user */ + get: operations['GitHubRepositoriesController_listRepositories'] } '/platform/cli/login': { /** Create CLI login session */ @@ -4278,58 +4278,45 @@ export interface components { DeleteVercelConnectionResponse: { id: string } - CreateGitHubIntegrationBody: { - installation_id: number - organization_slug: string - metadata: Record + CreateGitHubAuthorizationBody: { + code: string } - CreateGitHubIntegrationResponse: { - id: string - } - GetGitHubConnections: { - id: string - inserted_at: string - updated_at: string - organization_integration_id: string - supabase_project_ref: string - foreign_project_id: string - metadata: Record - } - IntegrationConnectionGithub: { - foreign_project_id: string - supabase_project_ref: string - integration_id: string - metadata: Record - } - CreateGitHubConnectionsBody: { - organization_integration_id: string - connection: components['schemas']['IntegrationConnectionGithub'] - } - UpdateGitHubConnectionsBody: { - metadata: Record - } - GetGithubRepo: { + ListGitHubConnectionsProject: { id: number - full_name: string - } - GetGithubBranch: { + ref: string name: string } - GitRef: { - repo: string - branch: string - label?: string - } - GetGithubPullRequest: { + ListGitHubConnectionsRepository: { id: number - url: string - title: string - target: components['schemas']['GitRef'] - created_at: string - created_by?: string - repo: string - branch: string - label?: string + name: string + } + ListGitHubConnectionsUser: { + id: number + username: string + primary_email: string | null + } + ListGitHubConnectionsConnection: { + id: number + inserted_at: string + updated_at: string + installation_id: number + project: components['schemas']['ListGitHubConnectionsProject'] + repository: components['schemas']['ListGitHubConnectionsRepository'] + user: components['schemas']['ListGitHubConnectionsUser'] | null + workdir: string + supabase_changes_only: boolean + } + ListGitHubConnectionsResponse: { + connections: components['schemas']['ListGitHubConnectionsConnection'][] + } + CreateGitHubConnectionsBody: { + project_ref: string + installation_id: number + repository_id: number + } + UpdateGitHubConnectionsBody: { + workdir?: string + supabase_changes_only?: boolean } CreateCliLoginSessionBody: { session_id: string @@ -4587,6 +4574,7 @@ export interface components { | 'sa-east-1' /** @deprecated */ kps_enabled?: boolean + desired_instance_size?: components['schemas']['DesiredInstanceSize'] } ApiKeyResponse: { name: string @@ -10866,39 +10854,48 @@ export interface operations { } } } - /** Create github integration */ - GitHubIntegrationController_createGitHubIntegration: { - requestBody: { - content: { - 'application/json': components['schemas']['CreateGitHubIntegrationBody'] - } - } + /** Get GitHub authorization */ + GitHubAuthorizationsController_getGitHubAuthorization: { responses: { - 201: { - content: { - 'application/json': components['schemas']['CreateGitHubIntegrationResponse'] - } - } - /** @description Failed to create github integration */ + /** @description Failed to get GitHub authorization */ 500: { content: never } } } - /** Gets installed github project connections for the given organization integration */ - GitHubConnectionsController_getGitHubConnections: { + /** Create GitHub authorization */ + GitHubAuthorizationsController_createGitHubAuthorization: { + requestBody: { + content: { + 'application/json': components['schemas']['CreateGitHubAuthorizationBody'] + } + } + responses: { + 201: { + content: { + 'application/json': Record + } + } + /** @description Failed to create GitHub authorization */ + 500: { + content: never + } + } + } + /** List organization GitHub connections */ + GitHubConnectionsController_listOrganizationGitHubConnections: { parameters: { - path: { - organization_integration_id: string + query: { + organization_id: number } } responses: { 200: { content: { - 'application/json': components['schemas']['GetGitHubConnections'][] + 'application/json': components['schemas']['ListGitHubConnectionsResponse'] } } - /** @description Failed to get installed github connections for the given organization integration */ + /** @description Failed to list organization GitHub connections */ 500: { content: never } @@ -10929,7 +10926,7 @@ export interface operations { } } responses: { - 200: { + 204: { content: never } /** @description Failed to delete github integration project connection */ @@ -10951,7 +10948,7 @@ export interface operations { } } responses: { - 200: { + 204: { content: never } /** @description Failed to update GitHub connection */ @@ -10960,121 +10957,102 @@ export interface operations { } } } - /** Gets github repos for the given organization */ - GitHubRepoController_getRepos: { + /** List GitHub connection branches */ + GitHubBranchesController_listConnectionBranches: { parameters: { query?: { per_page?: number page?: number } path: { - organization_integration_id: string + connectionId: number } } responses: { 200: { content: { - 'application/json': components['schemas']['GetGithubRepo'][] + 'application/json': Record[] } } - /** @description Failed to get github repos for the given organization */ + /** @description Failed to list GitHub connection branches */ 500: { content: never } } } - /** Gets github branches for a given repo */ - GitHubBranchController_getBranches: { - parameters: { - query?: { - per_page?: number - page?: number - } - path: { - organization_integration_id: string - repo_owner: string - repo_name: string - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['GetGithubBranch'][] - } - } - /** @description Failed to get github branches for a given repo */ - 500: { - content: never - } - } - } - /** Gets a specific github branch for a given repo */ - GitHubBranchController_getBranchByName: { + /** Get GitHub connection branch */ + GitHubBranchesController_getConnectionBranch: { parameters: { path: { - organization_integration_id: string - repo_owner: string - repo_name: string - branch_name: string + connectionId: number + branchName: string } } responses: { 200: { content: { - 'application/json': components['schemas']['GetGithubBranch'] + 'application/json': Record } } - /** @description Failed to get github branch for a given repo */ + /** @description Failed to get GitHub connection branch */ 500: { content: never } } } - /** Gets github pull requests for a given repo */ - GitHubPullRequestController_getPullRequestsByNumber: { + /** List GitHub connection pull requests */ + GitHubPullRequestsController_getConnectionPullRequests: { parameters: { query: { pr_number: number[] } path: { - organization_integration_id: string - repo_owner: string - repo_name: string + connectionId: number } } responses: { 200: { content: { - 'application/json': components['schemas']['GetGithubPullRequest'][] + 'application/json': Record[] } } - /** @description Failed to get github pull requests for a given repo */ + /** @description Failed to list GitHub connection pull requests */ 500: { content: never } } } - /** Gets github pull requests for a given repo */ - GitHubPullRequestController_getPullRequests: { + /** List GitHub pull requests for a specific branch */ + GitHubPullRequestsController_validateConnectionBranch: { parameters: { query?: { per_page?: number page?: number } path: { - organization_integration_id: string - repo_owner: string - repo_name: string - target: string + connectionId: number + branchName: string } } responses: { 200: { content: { - 'application/json': components['schemas']['GetGithubPullRequest'][] + 'application/json': Record[] } } - /** @description Failed to get github pull requests for a given repo */ + /** @description Failed to validate GitHub connection branch */ + 500: { + content: never + } + } + } + /** Gets GitHub repositories for user */ + GitHubRepositoriesController_listRepositories: { + responses: { + 200: { + content: never + } + /** @description Failed to get GitHub repositories for user */ 500: { content: never } diff --git a/apps/studio/data/branches/branch-create-mutation.ts b/apps/studio/data/branches/branch-create-mutation.ts index bd9933ddff0..45ae4585913 100644 --- a/apps/studio/data/branches/branch-create-mutation.ts +++ b/apps/studio/data/branches/branch-create-mutation.ts @@ -52,6 +52,7 @@ export const useBranchCreateMutation = ({ const { projectRef } = variables await queryClient.invalidateQueries(branchKeys.list(projectRef)) await queryClient.invalidateQueries(projectKeys.detail(projectRef)) + await queryClient.invalidateQueries(projectKeys.list()) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/integrations/github-authorization-create-mutation.ts b/apps/studio/data/integrations/github-authorization-create-mutation.ts new file mode 100644 index 00000000000..b7c9d56f2d0 --- /dev/null +++ b/apps/studio/data/integrations/github-authorization-create-mutation.ts @@ -0,0 +1,60 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' + +export type GitHubAuthorizationCreateVariables = { + code: string +} + +export async function createGitHubAuthorization({ code }: GitHubAuthorizationCreateVariables) { + const { data, error } = await post('/platform/integrations/github/authorization', { + body: { code }, + }) + + if (error) throw error + return data +} + +type GitHubAuthorizationCreateData = Awaited> + +export const useGitHubAuthorizationCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + GitHubAuthorizationCreateData, + ResponseError, + GitHubAuthorizationCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + GitHubAuthorizationCreateData, + ResponseError, + GitHubAuthorizationCreateVariables + >((vars) => createGitHubAuthorization(vars), { + async onSuccess(data, variables, context) { + // const { projectRef, id } = variables + + // await Promise.all([ + // queryClient.invalidateQueries(githubAuthorizationKeys.list(projectRef)), + // queryClient.invalidateQueries(githubAuthorizationKeys.githubAuthorization(projectRef, id)), + // ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/integrations/github-authorization-query.ts b/apps/studio/data/integrations/github-authorization-query.ts new file mode 100644 index 00000000000..d2be21a104e --- /dev/null +++ b/apps/studio/data/integrations/github-authorization-query.ts @@ -0,0 +1,30 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { integrationKeys } from './keys' + +// FIXME(kamil): Do not retry, a single check is fine. +export async function getGitHubAuthorization(signal?: AbortSignal) { + const { data, error } = await get('/platform/integrations/github/authorization', { + signal, + }) + return error ? null : data +} + +export type GitHubAuthorizationData = Awaited> +export type ProjectGitHubRepositoryConnectionsData = Awaited< + ReturnType +> +export type GitHubAuthorizationError = ResponseError + +export const useGitHubAuthorizationQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => { + return useQuery( + integrationKeys.githubAuthorization(), + ({ signal }) => getGitHubAuthorization(signal), + { enabled, staleTime: 0, ...options } + ) +} diff --git a/apps/studio/data/integrations/integrations-github-branch-check.ts b/apps/studio/data/integrations/github-branch-check-query.ts similarity index 70% rename from apps/studio/data/integrations/integrations-github-branch-check.ts rename to apps/studio/data/integrations/github-branch-check-query.ts index 6b5ff72dde7..0c82002e71b 100644 --- a/apps/studio/data/integrations/integrations-github-branch-check.ts +++ b/apps/studio/data/integrations/github-branch-check-query.ts @@ -5,27 +5,21 @@ import { get } from 'data/fetchers' import { ResponseError } from 'types' export type GithubBranchVariables = { - organizationIntegrationId?: string - repoOwner: string - repoName: string + connectionId: number branchName: string } export async function checkGithubBranchValidity( - { organizationIntegrationId, repoOwner, repoName, branchName }: GithubBranchVariables, + { connectionId, branchName }: GithubBranchVariables, signal?: AbortSignal ) { - if (!organizationIntegrationId) throw new Error('Organization integration ID is required') - const { data, error } = await get( - '/platform/integrations/github/branches/{organization_integration_id}/{repo_owner}/{repo_name}/{branch_name}', + '/platform/integrations/github/branches/{connectionId}/{branchName}', { params: { path: { - organization_integration_id: organizationIntegrationId, - repo_owner: repoOwner, - repo_name: repoName, - branch_name: branchName, + connectionId, + branchName, }, }, signal, diff --git a/apps/studio/data/integrations/github-branches-query.ts b/apps/studio/data/integrations/github-branches-query.ts new file mode 100644 index 00000000000..94260616aa9 --- /dev/null +++ b/apps/studio/data/integrations/github-branches-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { integrationKeys } from './keys' +import { ResponseError } from 'types' + +export type GitHubBranchesVariables = { + connectionId?: number +} + +export async function getGitHubBranches( + { connectionId }: GitHubBranchesVariables, + signal?: AbortSignal +) { + if (!connectionId) throw new Error('connectionId is required') + + const { data, error } = await get(`/platform/integrations/github/branches/{connectionId}`, { + params: { path: { connectionId } }, + signal, + }) + + if (error) throw new Error((error as ResponseError).message) + return data +} + +export type GitHubBranchesData = Awaited> +export type GitHubBranchesError = ResponseError + +export const useGitHubBranchesQuery = ( + { connectionId }: GitHubBranchesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.githubBranchesList(connectionId), + ({ signal }) => getGitHubBranches({ connectionId }, signal), + { + enabled: enabled && typeof connectionId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/integrations/github-connection-create-mutation.ts b/apps/studio/data/integrations/github-connection-create-mutation.ts new file mode 100644 index 00000000000..62a494ed2e1 --- /dev/null +++ b/apps/studio/data/integrations/github-connection-create-mutation.ts @@ -0,0 +1,52 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { GitHubConnectionCreateVariables } from './integrations.types' +import { integrationKeys } from './keys' + +export async function createGitHubConnection({ connection }: GitHubConnectionCreateVariables) { + const { data, error } = await post('/platform/integrations/github/connections', { + body: connection, + }) + if (error) { + throw error + } + + return data +} + +export type GitHubConnectionCreateData = Awaited> + +export const useGitHubConnectionCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => createGitHubConnection(vars), + { + async onSuccess(data, variables, context) { + await Promise.all([ + queryClient.invalidateQueries( + integrationKeys.githubConnectionsList(variables.organizationId) + ), + ]) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create Github connection: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/integrations/integrations-github-connection-delete-mutation.ts b/apps/studio/data/integrations/github-connection-delete-mutation.ts similarity index 71% rename from apps/studio/data/integrations/integrations-github-connection-delete-mutation.ts rename to apps/studio/data/integrations/github-connection-delete-mutation.ts index f3bd4f08cfd..d6fc6067e10 100644 --- a/apps/studio/data/integrations/integrations-github-connection-delete-mutation.ts +++ b/apps/studio/data/integrations/github-connection-delete-mutation.ts @@ -6,15 +6,14 @@ import { ResponseError } from 'types' import { integrationKeys } from './keys' type DeleteVariables = { - connectionId: string - - integrationId: string - orgSlug: string | undefined + connectionId: string | number + organizationId: number } export async function deleteConnection({ connectionId }: DeleteVariables, signal?: AbortSignal) { const { data, error } = await del('/platform/integrations/github/connections/{connection_id}', { - params: { path: { connection_id: connectionId } }, + params: { path: { connection_id: String(connectionId) } }, + signal, }) if (error) throw error @@ -23,7 +22,7 @@ export async function deleteConnection({ connectionId }: DeleteVariables, signal type DeleteContentData = Awaited> -export const useIntegrationsGitHubInstalledConnectionDeleteMutation = ({ +export const useGitHubConnectionDeleteMutation = ({ onSuccess, onError, ...options @@ -37,11 +36,8 @@ export const useIntegrationsGitHubInstalledConnectionDeleteMutation = ({ { async onSuccess(data, variables, context) { await Promise.all([ - queryClient.invalidateQueries(integrationKeys.integrationsList()), - queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), - queryClient.invalidateQueries(integrationKeys.githubRepoList(variables.integrationId)), queryClient.invalidateQueries( - integrationKeys.githubConnectionsList(variables.integrationId) + integrationKeys.githubConnectionsList(variables.organizationId) ), ]) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/integrations/github-connection-update-mutate.ts b/apps/studio/data/integrations/github-connection-update-mutation.ts similarity index 50% rename from apps/studio/data/integrations/github-connection-update-mutate.ts rename to apps/studio/data/integrations/github-connection-update-mutation.ts index 2d9b68ba51f..3af0320f8ea 100644 --- a/apps/studio/data/integrations/github-connection-update-mutate.ts +++ b/apps/studio/data/integrations/github-connection-update-mutation.ts @@ -3,46 +3,49 @@ import { toast } from 'react-hot-toast' import { patch } from 'data/fetchers' import { ResponseError } from 'types' -import { UpdateConnectionPayload } from './integrations.types' import { integrationKeys } from './keys' -export async function updateGithubConnection({ - id, - metadata, - organizationIntegrationId, -}: UpdateConnectionPayload) { - const { data, error } = await patch('/platform/integrations/github/connections/{connection_id}', { - params: { - path: { connection_id: id }, - }, - body: { - // @ts-expect-error - metadata, - }, - }) +type UpdateVariables = { + connectionId: string | number + organizationId: number + workdir: string + supabaseChangesOnly: boolean +} +export async function updateConnection( + { connectionId, workdir, supabaseChangesOnly }: UpdateVariables, + signal?: AbortSignal +) { + const { data, error } = await patch('/platform/integrations/github/connections/{connection_id}', { + params: { path: { connection_id: String(connectionId) } }, + signal, + body: { workdir, supabase_changes_only: supabaseChangesOnly }, + }) if (error) throw error + return data } -type UpdateGithubConnectionData = Awaited> +type UpdateContentData = Awaited> -export const useGithubConnectionUpdateMutation = ({ +export const useGitHubConnectionUpdateMutation = ({ onSuccess, onError, ...options }: Omit< - UseMutationOptions, + UseMutationOptions, 'mutationFn' > = {}) => { const queryClient = useQueryClient() - return useMutation( - (vars) => updateGithubConnection(vars), + return useMutation( + (args) => updateConnection(args), { async onSuccess(data, variables, context) { - await queryClient.invalidateQueries( - integrationKeys.githubConnectionsList(variables.organizationIntegrationId) - ) + await Promise.all([ + queryClient.invalidateQueries( + integrationKeys.githubConnectionsList(variables.organizationId) + ), + ]) await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/integrations/github-connections-query.ts b/apps/studio/data/integrations/github-connections-query.ts new file mode 100644 index 00000000000..ef491049153 --- /dev/null +++ b/apps/studio/data/integrations/github-connections-query.ts @@ -0,0 +1,46 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { integrationKeys } from './keys' + +export type GitHubConnectionsVariables = { + organizationId?: number +} + +export async function getGitHubConnections( + { organizationId }: GitHubConnectionsVariables, + signal?: AbortSignal +) { + if (!organizationId) throw new Error('organizationId is required') + + const { data, error } = await get('/platform/integrations/github/connections', { + params: { + query: { + organization_id: organizationId, + }, + }, + signal, + }) + if (error) throw new Error((error as ResponseError).message) + return data.connections +} + +export type GitHubConnectionsData = Awaited> +export type GitHubConnectionsError = ResponseError + +export type GitHubConnection = GitHubConnectionsData[0] + +export const useGitHubConnectionsQuery = ( + { organizationId }: GitHubConnectionsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + return useQuery( + integrationKeys.githubConnectionsList(organizationId), + ({ signal }) => getGitHubConnections({ organizationId }, signal), + { enabled, ...options } + ) +} diff --git a/apps/studio/data/integrations/github-integration-create-mutation.ts b/apps/studio/data/integrations/github-integration-create-mutation.ts deleted file mode 100644 index 09e62724062..00000000000 --- a/apps/studio/data/integrations/github-integration-create-mutation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'react-hot-toast' - -import { post } from 'data/fetchers' -import { ResponseError } from 'types' -import { integrationKeys } from './keys' - -export type GitHubIntegrationCreateVariables = { - installationId: number - orgSlug: string - metadata: { supabaseConfig: { supabaseDirectory: string } } -} - -export async function createGitHubIntegration({ - installationId, - orgSlug, - metadata, -}: GitHubIntegrationCreateVariables) { - const { data, error } = await post('/platform/integrations/github', { - body: { - installation_id: installationId, - organization_slug: orgSlug, - metadata: metadata as any, - }, - }) - if (error) throw error - - return data -} - -type GitHubIntegrationCreateData = Awaited> - -export const useGitHubIntegrationCreateMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseMutationOptions, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - return useMutation( - (vars) => createGitHubIntegration(vars), - { - async onSuccess(data, variables, context) { - await Promise.all([ - queryClient.invalidateQueries(integrationKeys.integrationsList()), - queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), - queryClient.invalidateQueries(integrationKeys.githubRepoList(data.id)), - ]) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to create Github integration: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/data/integrations/github-pull-requests-query.ts b/apps/studio/data/integrations/github-pull-requests-query.ts new file mode 100644 index 00000000000..76345211bdc --- /dev/null +++ b/apps/studio/data/integrations/github-pull-requests-query.ts @@ -0,0 +1,54 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { integrationKeys } from './keys' +import { ResponseError } from 'types' +import { components } from 'data/api' + +export type GitHubPullRequestsVariables = { + connectionId?: number + prNumbers?: number[] +} + +// TODO(alaister): find the actual type +export type GitHubPullRequest = any + +export async function getGitHubPullRequests( + { connectionId, prNumbers = [] }: GitHubPullRequestsVariables, + signal?: AbortSignal +) { + if (!connectionId) throw new Error('connectionId is required') + + const { data, error } = await get(`/platform/integrations/github/pull-requests/{connectionId}`, { + params: { + path: { + connectionId, + }, + query: { + pr_number: prNumbers, + }, + }, + signal, + }) + + if (error) throw error + return data +} + +export type GitHubPullRequestsData = Awaited> +export type GitHubPullRequestsError = ResponseError + +export const useGitHubPullRequestsQuery = ( + { connectionId, prNumbers }: GitHubPullRequestsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + integrationKeys.githubPullRequestsList(connectionId, prNumbers), + ({ signal }) => getGitHubPullRequests({ connectionId, prNumbers }, signal), + { + enabled: enabled && typeof connectionId !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/integrations/github-repositories-query.ts b/apps/studio/data/integrations/github-repositories-query.ts new file mode 100644 index 00000000000..07fc7ad3c9b --- /dev/null +++ b/apps/studio/data/integrations/github-repositories-query.ts @@ -0,0 +1,32 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { integrationKeys } from './keys' + +export async function getGitHubRepositories(signal?: AbortSignal) { + const { data, error } = await get('/platform/integrations/github/repositories', { + signal, + }) + if (error) throw new Error((error as ResponseError).message) + + // [Alaister]: temp fix until we have a proper response type + return (data as any).repositories +} + +export type GitHubRepositoriesData = Awaited> +export type ProjectGitHubRepositoryConnectionsData = Awaited< + ReturnType +> +export type GitHubRepositoriesError = ResponseError + +export const useGitHubRepositoriesQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => { + return useQuery( + integrationKeys.githubRepositoriesList(), + ({ signal }) => getGitHubRepositories(signal), + { enabled, staleTime: 0, ...options } + ) +} diff --git a/apps/studio/data/integrations/integrations-github-branches-query.ts b/apps/studio/data/integrations/integrations-github-branches-query.ts deleted file mode 100644 index c894ec47223..00000000000 --- a/apps/studio/data/integrations/integrations-github-branches-query.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { integrationKeys } from './keys' -import { ResponseError } from 'types' - -export type GithubBranchesVariables = { - organizationIntegrationId?: string - repoOwner: string - repoName: string -} - -export async function getGithubBranches( - { organizationIntegrationId, repoOwner, repoName }: GithubBranchesVariables, - signal?: AbortSignal -) { - if (!organizationIntegrationId) throw new Error('Organization integration ID is required') - - const { data, error } = await get( - `/platform/integrations/github/branches/{organization_integration_id}/{repo_owner}/{repo_name}`, - { - params: { - path: { - organization_integration_id: organizationIntegrationId, - repo_owner: repoOwner, - repo_name: repoName, - }, - }, - signal, - } - ) - - if (error) throw error - return data -} - -export type GithubBranchesData = Awaited> -export type GithubBranchesError = ResponseError - -export const useGithubBranchesQuery = ( - { organizationIntegrationId, repoOwner, repoName }: GithubBranchesVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - integrationKeys.githubBranchesList(organizationIntegrationId, repoOwner, repoName), - ({ signal }) => getGithubBranches({ organizationIntegrationId, repoOwner, repoName }, signal), - { - enabled: - enabled && - typeof organizationIntegrationId !== 'undefined' && - typeof repoOwner !== 'undefined' && - typeof repoName !== 'undefined', - ...options, - } - ) diff --git a/apps/studio/data/integrations/integrations-github-connections-create-mutation.ts b/apps/studio/data/integrations/integrations-github-connections-create-mutation.ts deleted file mode 100644 index b9cd426eef3..00000000000 --- a/apps/studio/data/integrations/integrations-github-connections-create-mutation.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'react-hot-toast' - -import { post } from 'data/fetchers' -import { ResponseError } from 'types' -import { integrationKeys } from './keys' -import { IntegrationConnectionsCreateVariables } from './integrations.types' - -export async function createIntegrationGitHubConnections({ - organizationIntegrationId, - connection, -}: IntegrationConnectionsCreateVariables) { - const { data, error } = await post('/platform/integrations/github/connections', { - body: { - organization_integration_id: organizationIntegrationId, - connection, - }, - }) - if (error) { - throw error - } - - return data -} - -export type IntegrationGitHubConnectionsCreateData = Awaited< - ReturnType -> - -export const useIntegrationGitHubConnectionsCreateMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseMutationOptions< - IntegrationGitHubConnectionsCreateData, - ResponseError, - IntegrationConnectionsCreateVariables - >, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - return useMutation< - IntegrationGitHubConnectionsCreateData, - ResponseError, - IntegrationConnectionsCreateVariables - >((vars) => createIntegrationGitHubConnections(vars), { - async onSuccess(data, variables, context) { - await Promise.all([ - queryClient.invalidateQueries(integrationKeys.integrationsList()), - queryClient.invalidateQueries(integrationKeys.integrationsListWithOrg(variables.orgSlug)), - queryClient.invalidateQueries( - integrationKeys.githubRepoList(variables.organizationIntegrationId) - ), - queryClient.invalidateQueries( - integrationKeys.githubConnectionsList(variables.organizationIntegrationId) - ), - ]) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to create Github connection: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - }) -} diff --git a/apps/studio/data/integrations/integrations-github-pull-requests-query.ts b/apps/studio/data/integrations/integrations-github-pull-requests-query.ts deleted file mode 100644 index 326cc548f00..00000000000 --- a/apps/studio/data/integrations/integrations-github-pull-requests-query.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { integrationKeys } from './keys' -import { ResponseError } from 'types' -import { components } from 'data/api' - -export type GithubPullRequestsVariables = { - organizationIntegrationId?: string - repoOwner: string - repoName: string - prNumbers?: number[] -} - -export type GitHubPullRequest = components['schemas']['GetGithubPullRequest'] - -export async function getGitHubPullRequests( - { organizationIntegrationId, repoOwner, repoName, prNumbers }: GithubPullRequestsVariables, - signal?: AbortSignal -) { - if (!organizationIntegrationId) throw new Error('Organization integration ID is required') - if (!prNumbers) throw new Error('A list of PR numbers is required') - - const { data, error } = await get( - `/platform/integrations/github/pull-requests/{organization_integration_id}/{repo_owner}/{repo_name}`, - { - params: { - path: { - organization_integration_id: organizationIntegrationId, - repo_owner: repoOwner, - repo_name: repoName, - }, - query: { - // @ts-ignore generated api types is incorrect here, to remove ignore statement once fixed - pr_number: prNumbers, - }, - }, - signal, - } - ) - - if (error) throw error - return data -} - -export type GithubPullRequestsData = Awaited> -export type GithubPullRequestsError = ResponseError - -export const useGithubPullRequestsQuery = ( - { organizationIntegrationId, repoOwner, repoName, prNumbers }: GithubPullRequestsVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - // [Joshen] Just fyi im not sure if we need to include prNumber in the RQ key here - integrationKeys.githubPullRequestsList(organizationIntegrationId, repoOwner, repoName), - ({ signal }) => - getGitHubPullRequests({ organizationIntegrationId, repoOwner, repoName, prNumbers }, signal), - { - enabled: - enabled && - typeof organizationIntegrationId !== 'undefined' && - typeof repoOwner !== 'undefined' && - typeof repoName !== 'undefined' && - typeof prNumbers !== 'undefined', - ...options, - } - ) diff --git a/apps/studio/data/integrations/integrations-github-repos-query.ts b/apps/studio/data/integrations/integrations-github-repos-query.ts deleted file mode 100644 index 65b7ad32f2a..00000000000 --- a/apps/studio/data/integrations/integrations-github-repos-query.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import { get } from 'data/fetchers' -import { ResponseError } from 'types' -import { integrationKeys } from './keys' - -export type GitHubReposVariables = { - integrationId: string | undefined -} - -// We don't want to fetch more than 1000 repositories, as it's already more than enough. -const MAX_PAGES = 10 - -export async function getGitHubRepos( - { integrationId }: GitHubReposVariables, - signal?: AbortSignal -) { - if (!integrationId) { - throw new Error('integrationId is required') - } - - const repos: Awaited> = [] - const perPage = 100 - let page = 1 - - // This is unfortunate, because we will do redundant API call in case there are exactly 100 results, - // but the API is returning an array instead of `{ repos: [], total_count: n }`. - while (true) { - const reposChunk = await getSingleGithubReposPage(integrationId, perPage, page, signal) - repos.push(...reposChunk) - page += 1 - - // Stop asking for more data if last request was exhaustive or we reached a limit, . - if (reposChunk.length < perPage || page > MAX_PAGES) { - break - } - } - - return repos -} - -async function getSingleGithubReposPage( - integrationId: string, - perPage: number, - page: number, - signal?: AbortSignal -) { - const { data, error } = await get( - '/platform/integrations/github/repos/{organization_integration_id}', - { - params: { - path: { - organization_integration_id: integrationId, - }, - query: { - per_page: perPage, - page, - }, - }, - signal, - } - ) - if (error) { - throw error - } - return data -} - -export type GitHubReposData = Awaited> -export type GitHubReposError = ResponseError - -export const useGitHubReposQuery = ( - { integrationId }: GitHubReposVariables, - { enabled = true, ...options }: UseQueryOptions = {} -) => - useQuery( - integrationKeys.githubRepoList(integrationId), - ({ signal }) => getGitHubRepos({ integrationId }, signal), - { - enabled: enabled && typeof integrationId !== 'undefined', - ...options, - } - ) diff --git a/apps/studio/data/integrations/integrations.types.ts b/apps/studio/data/integrations/integrations.types.ts index f28a458e85b..efd87d3f4f6 100644 --- a/apps/studio/data/integrations/integrations.types.ts +++ b/apps/studio/data/integrations/integrations.types.ts @@ -1,3 +1,5 @@ +import { components } from 'data/api' + export type VercelFramework = | ( | 'blitzjs' @@ -137,6 +139,7 @@ export type Imetadata = { preview: boolean } supabaseDirectory?: string + supabaseChangesOnly?: boolean } link?: VercelGitLink name: string @@ -249,6 +252,16 @@ export type IntegrationConnectionsCreateVariables = { metadata: any } orgSlug: string | undefined + new?: { + installation_id: number + project_ref: string + repository_id: number + } +} + +export type GitHubConnectionCreateVariables = { + organizationId: number + connection: components['schemas']['CreateGitHubConnectionsBody'] } export type UpdateConnectionPayload = { diff --git a/apps/studio/data/integrations/keys.ts b/apps/studio/data/integrations/keys.ts index d45a653c2ba..fd30674f139 100644 --- a/apps/studio/data/integrations/keys.ts +++ b/apps/studio/data/integrations/keys.ts @@ -4,8 +4,6 @@ export const integrationKeys = { integrationsList: () => ['organizations', 'integrations'] as const, vercelProjectList: (organization_integration_id: string | undefined) => ['organizations', organization_integration_id, 'vercel-projects'] as const, - githubRepoList: (organization_integration_id: string | undefined) => - ['organizations', organization_integration_id, 'github-repos'] as const, vercelConnectionsList: (organization_integration_id: string | undefined) => ['organizations', organization_integration_id, 'vercel-connections'] as const, githubBranch: ( @@ -21,16 +19,14 @@ export const integrationKeys = { repo_name, branch_name, ], - githubBranchesList: ( - organization_integration_id: string | undefined, - repo_owner: string, - repo_name: string - ) => ['organizations', organization_integration_id, 'branches', repo_owner, repo_name], - githubPullRequestsList: ( - organization_integration_id: string | undefined, - repo_owner: string, - repo_name: string - ) => ['organizations', organization_integration_id, 'pull-requests', repo_owner, repo_name], - githubConnectionsList: (organization_integration_id: string | undefined) => - ['organizations', organization_integration_id, 'github-connections'] as const, + githubAuthorization: () => ['github-authorization'] as const, + githubRepositoriesList: () => ['github-repositories'] as const, + githubBranchesList: (connectionId: number | undefined) => ['github-branches', connectionId], + githubPullRequestsList: (connectionId: number | undefined, prNumbers: number[] | undefined) => [ + 'github-pull-requests', + connectionId, + { prNumbers }, + ], + githubConnectionsList: (organizationId: number | undefined) => + ['organizations', organizationId, 'github-connections'] as const, } diff --git a/apps/studio/lib/github-integration.ts b/apps/studio/lib/github-integration.ts deleted file mode 100644 index b7ae7c28e75..00000000000 --- a/apps/studio/lib/github-integration.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useParams } from 'common' -import { useGitHubIntegrationCreateMutation } from 'data/integrations/github-integration-create-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsQuery } from 'data/projects/projects-query' -import { useEffect, useState } from 'react' - -export function useGitHubIntegrationAutoInstall(callback?: (id: string, orgSlug: string) => void) { - const { installation_id: installationId, state: projectRef } = useParams() - const shouldAttemptInstall = projectRef !== undefined && installationId !== undefined - - const [isAutoInstalling, setIsAutoInstalling] = useState(false) - - const { data: projects, isSuccess: isProjectsSuccess } = useProjectsQuery({ - enabled: shouldAttemptInstall, - }) - const { data: organizations, isSuccess: isOrganizationsSuccess } = useOrganizationsQuery({ - enabled: shouldAttemptInstall, - }) - - const { mutate: createIntegration } = useGitHubIntegrationCreateMutation() - - useEffect(() => { - async function autoInstall() { - if (shouldAttemptInstall) { - setIsAutoInstalling(true) - } - - if (projectRef && isProjectsSuccess && isOrganizationsSuccess) { - const project = projects.find((x) => x.ref === projectRef) - const organization = organizations.find((x) => x.id === project?.organization_id) - - if (project && organization) { - createIntegration( - { - installationId: Number(installationId), - orgSlug: organization.slug, - metadata: { - supabaseConfig: { - supabaseDirectory: '/supabase', - }, - }, - }, - { - onSuccess({ id }) { - setIsAutoInstalling(false) - - callback?.(id, organization.slug) - }, - onError() { - setIsAutoInstalling(false) - }, - } - ) - } else { - setIsAutoInstalling(false) - } - } - } - - autoInstall() - }, [ - callback, - createIntegration, - installationId, - isOrganizationsSuccess, - isProjectsSuccess, - organizations, - projectRef, - projects, - shouldAttemptInstall, - ]) - - return isAutoInstalling -} diff --git a/apps/studio/lib/github.tsx b/apps/studio/lib/github.tsx new file mode 100644 index 00000000000..981e3726fd6 --- /dev/null +++ b/apps/studio/lib/github.tsx @@ -0,0 +1,53 @@ +const GITHUB_INTEGRATION_INSTALLATION_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' + ? `https://github.com/apps/supabase/installations/new` + : process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? `https://github.com/apps/supabase-staging/installations/new` + : `https://github.com/apps/supabase-github-v2-migration-test/installations/new` + +const GITHUB_INTEGRATION_CLIENT_ID = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' + ? `Iv1.b91a6d8eaa272168` + : process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? `Iv1.2681ab9a0360d8ad` + : `Iv1.5022a3b44d150fbf` + +const GITHUB_INTEGRATION_AUTHORIZATION_URL = `https://github.com/login/oauth/authorize?client_id=${GITHUB_INTEGRATION_CLIENT_ID}` + +export function openInstallGitHubIntegrationWindow(type: 'install' | 'authorize') { + const w = 600 + const h = 800 + + const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX + const dualScreenTop = window.screenTop !== undefined ? window.screenTop : window.screenY + + const width = window.innerWidth + ? window.innerWidth + : document.documentElement.clientWidth + ? document.documentElement.clientWidth + : screen.width + const height = window.innerHeight + ? window.innerHeight + : document.documentElement.clientHeight + ? document.documentElement.clientHeight + : screen.height + + const windowUrl = + type === 'install' ? GITHUB_INTEGRATION_INSTALLATION_URL : GITHUB_INTEGRATION_AUTHORIZATION_URL + const systemZoom = width / window.screen.availWidth + const left = (width - w) / 2 / systemZoom + dualScreenLeft + const top = (height - h) / 2 / systemZoom + dualScreenTop + const newWindow = window.open( + windowUrl, + 'GitHub', + `scrollbars=yes,resizable=no,status=no,location=no,toolbar=no,menubar=no, + width=${w / systemZoom}, + height=${h / systemZoom}, + top=${top}, + left=${left} + ` + ) + if (newWindow) { + newWindow.focus() + } +} diff --git a/apps/studio/pages/integrations/github/[integrationId]/choose-project.tsx b/apps/studio/pages/integrations/github/[integrationId]/choose-project.tsx deleted file mode 100644 index 575935b8459..00000000000 --- a/apps/studio/pages/integrations/github/[integrationId]/choose-project.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useRouter } from 'next/router' -import { useMemo } from 'react' - -import { useParams } from 'common' -import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' -import { Markdown } from 'components/interfaces/Markdown' -import GitHubIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/GitHubIntegrationWindowLayout' -import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' -import { useIntegrationGitHubConnectionsCreateMutation } from 'data/integrations/integrations-github-connections-create-mutation' -import { useGitHubReposQuery } from 'data/integrations/integrations-github-repos-query' -import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsQuery } from 'data/projects/projects-query' -import { BASE_PATH } from 'lib/constants' -import { EMPTY_ARR } from 'lib/void' -import { NextPageWithLayout } from 'types' -import { IconBook, IconLifeBuoy, LoadingLine } from 'ui' - -const GITHUB_ICON = ( - GitHub Icon -) - -const ChooseProjectGitHubPage: NextPageWithLayout = () => { - const router = useRouter() - const { integrationId, slug, organizationSlug, state: projectRef } = useParams() - const orgSlug = slug ?? organizationSlug - - const { data: integrations } = useOrgIntegrationsQuery({ - orgSlug, - }) - const { data: organizations } = useOrganizationsQuery() - const { data: allProjects, isLoading: isLoadingSupabaseProjectsData } = useProjectsQuery() - const { data: allRepos, isLoading: isLoadingGithubReposData } = useGitHubReposQuery({ - integrationId, - }) - - const integration = integrations?.find((integration) => integration.id === integrationId) - const organization = organizations?.find((organization) => organization.slug === orgSlug) - const projects = useMemo( - () => - allProjects - ?.filter((project) => project.organization_id === organization?.id) - .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? - EMPTY_ARR, - [allProjects, organization?.id] - ) - - const repos = useMemo( - () => - allRepos?.map((repo) => ({ - id: repo.id.toString(), - name: repo.full_name, - })) ?? EMPTY_ARR, - [allRepos] - ) - - function onDone() { - if (projectRef) { - router.push(`/project/${projectRef}?enableBranching=true`) - } else { - router.push(`/org/${orgSlug}/integrations`) - } - } - - const { mutate: createConnections, isLoading: isCreatingConnection } = - useIntegrationGitHubConnectionsCreateMutation({ - onSuccess: onDone, - }) - - return ( - <> -
- - <> - -
-

- Link a Supabase project to a GitHub repository -

- -
- -
- - - -
- -
- Docs -
-
- Support -
-
- - ) -} - -ChooseProjectGitHubPage.getLayout = (page) => ( - {page} -) - -export default ChooseProjectGitHubPage diff --git a/apps/studio/pages/integrations/github/authorize.tsx b/apps/studio/pages/integrations/github/authorize.tsx new file mode 100644 index 00000000000..52a9c784eb1 --- /dev/null +++ b/apps/studio/pages/integrations/github/authorize.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react' + +import { useParams } from 'common' +import { useGitHubAuthorizationCreateMutation } from 'data/integrations/github-authorization-create-mutation' + +const GitHubIntegrationAuthorize = () => { + const { code } = useParams() + + const { mutate, isSuccess } = useGitHubAuthorizationCreateMutation({ + onSuccess() { + window.close() + }, + }) + + useEffect(() => { + if (code) { + mutate({ code }) + } + }, [code, mutate]) + + return ( +
+

Completing GitHub Authorization...

+ + {isSuccess ?

You can now close this window.

:

} +

+ ) +} + +export default GitHubIntegrationAuthorize diff --git a/apps/studio/pages/integrations/github/install.tsx b/apps/studio/pages/integrations/github/install.tsx deleted file mode 100644 index fad21041cdd..00000000000 --- a/apps/studio/pages/integrations/github/install.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useRouter } from 'next/router' -import { useCallback, useMemo, useState } from 'react' - -import { useParams } from 'common' -import OrganizationPicker from 'components/interfaces/Integrations/OrganizationPicker' -import { Markdown } from 'components/interfaces/Markdown' -import GitHubIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/GitHubIntegrationWindowLayout' -import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils' -import { ScaffoldColumn, ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' -import { useGitHubIntegrationCreateMutation } from 'data/integrations/github-integration-create-mutation' -import { useIntegrationsQuery } from 'data/integrations/integrations-query' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useStore } from 'hooks' -import { NextPageWithLayout, Organization } from 'types' -import { - Alert, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - IconAlertTriangle, - IconBook, - IconLifeBuoy, - LoadingLine, -} from 'ui' -import { useGitHubIntegrationAutoInstall } from 'lib/github-integration' - -const GitHubIntegration: NextPageWithLayout = () => { - const router = useRouter() - const { ui } = useStore() - const { installation_id: installationId } = useParams() - const [selectedOrg, setSelectedOrg] = useState(null) - - /** - * Fetch the list of organization based integration installations for GitHub. - * - * Array of integrations installed on all - */ - const { data: integrationData } = useIntegrationsQuery() - - const { data: organizationsData } = useOrganizationsQuery({ - onSuccess(organizations) { - const firstOrg = organizations?.[0] - if (firstOrg && selectedOrg === null) { - setSelectedOrg(firstOrg) - router.query.organizationSlug = firstOrg.slug - } - }, - }) - - const onInstalled = useCallback( - (id: string, orgSlug?: string) => { - router.push({ - pathname: `/integrations/github/${id}/choose-project`, - query: { ...router.query, slug: orgSlug }, - }) - }, - [router] - ) - - const { mutate, isLoading: isLoadingGitHubIntegrationCreateMutation } = - useGitHubIntegrationCreateMutation({ - onSuccess({ id }) { - onInstalled(id, selectedOrg?.slug) - }, - }) - - const isAutoInstalling = useGitHubIntegrationAutoInstall(onInstalled) - - const installed = useMemo( - () => - integrationData && organizationsData - ? getHasInstalledObject({ - integrationName: 'GitHub', - integrationData, - organizationsData, - installationId, - }) - : {}, - [installationId, integrationData, organizationsData] - ) - - function onInstall() { - const orgSlug = selectedOrg?.slug - - const installedIntegration = integrationData?.find( - (x) => - x.organization.slug === orgSlug && - x.metadata !== undefined && - 'installation_id' in x.metadata && - String(x.metadata?.installation_id) === String(installationId) - ) - const isIntegrationInstalled = Boolean(installedIntegration) - - if (!orgSlug) { - return ui.setNotification({ category: 'error', message: 'Please select an organization' }) - } - - if (!installationId) { - return ui.setNotification({ category: 'error', message: 'GitHub Installation ID is missing' }) - } - - /** - * Only install if Integration hasn't already been installed - */ - if (!isIntegrationInstalled) { - mutate({ - installationId: Number(installationId), - orgSlug, - metadata: { - supabaseConfig: { - supabaseDirectory: '/supabase', - }, - }, - }) - } else { - router.push({ - pathname: `/integrations/github/${installedIntegration?.id}/choose-project`, - query: router.query, - }) - } - } - - const disableInstallationForm = Boolean(selectedOrg && installed[selectedOrg.slug]) - - return ( - <> -
- - - -

Choose organization

- <> - - { - router.query.organizationSlug = org.slug - setSelectedOrg(org) - }} - configurationId={installationId} - /> - {disableInstallationForm && ( - - - GitHub Integration is already installed. - - You will need to choose another organization to install the integration. - - - )} -
- -
- -
-
- - - - - - - -
- -
- Docs -
-
- Support -
-
- - ) -} - -GitHubIntegration.getLayout = (page) => ( - {page} -) - -export default GitHubIntegration diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index c303957422b..f52f3bcc022 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -7,6 +7,7 @@ import ShimmeringCard from 'components/interfaces/Home/ProjectList/ShimmeringCar import AppLayout from 'components/layouts/AppLayout/AppLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' +import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' import { useProjectsQuery } from 'data/projects/projects-query' import { useSelectedOrganization } from 'hooks' @@ -27,9 +28,23 @@ const ProjectsPage: NextPageWithLayout = () => { .sort((a, b) => a.name.localeCompare(b.name)) const { data: integrations } = useOrgIntegrationsQuery({ orgSlug: organization?.slug }) - const githubConnections = integrations - ?.filter((integration) => integration.integration.name === 'GitHub') - .flatMap((integration) => integration.connections) + const { data: connections } = useGitHubConnectionsQuery({ organizationId: organization?.id }) + const githubConnections = connections?.map((connection) => ({ + id: String(connection.id), + added_by: { + id: String(connection.user?.id), + primary_email: connection.user?.primary_email ?? '', + username: connection.user?.username ?? '', + }, + foreign_project_id: String(connection.repository.id), + supabase_project_ref: connection.project.ref, + organization_integration_id: 'unused', + inserted_at: connection.inserted_at, + updated_at: connection.updated_at, + metadata: { + name: connection.repository.name, + } as any, + })) const vercelConnections = integrations ?.filter((integration) => integration.integration.name === 'Vercel') .flatMap((integration) => integration.connections) diff --git a/apps/studio/state/side-panels.ts b/apps/studio/state/side-panels.ts index 1719269e02a..e45b7fec4aa 100644 --- a/apps/studio/state/side-panels.ts +++ b/apps/studio/state/side-panels.ts @@ -23,11 +23,6 @@ export const sidePanelsState = proxy({ setGithubConnectionsOpen: (bool: boolean) => { sidePanelsState.githubConnectionsOpen = bool }, - // ID to determine which github integration installation to use - githubConnectionsIntegrationId: undefined as undefined | string, - setGithubConnectionsIntegrationId: (id: string) => { - sidePanelsState.githubConnectionsIntegrationId = id - }, }) export const getSidePanelsState = () => snapshot(sidePanelsState)