From ea38c6d1531272bbd73592b1f66e4774b9c239f0 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 4 Jul 2025 14:57:59 +1000 Subject: [PATCH] Review and merge branch (#36795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * allow creating branching without git * update branching modals * add account connections * edit branch * copy * update copy * enable branch modal changes * add gitless branching flag * update account connections * merge page * merge experiment * update merge * update pull requests empty state * use diff query * branch diffing * diff query * Clean up * refinements to gitless branching * branching merge and status * link * branch function diffing * update styling * refactor * remove hook * error handling * move * remove enable branching modal * re-add github linker * abstract away enable and disable * toggle fixes * update logic to lean on connection status * update form logic * sheet layout * gitless flag * style and workflow updates * fix side panel size * fix duplicate onerror * copy changes * refetch * merge mutation copy * remove import * add cost * allow connection details on create * initial queries * push button * merge cleanup * Fix TS issues * Fix TS issues * Couple of clean ups * Revert hardcode in useFlag * Fix TS * layout issues and github check * refactor * refactor to use new field * cleanup * style * failed merge * error positioning * refactoring merge * workflow refactor * hook move * clarification with github integration * replace branch dropdown button * update repo picker * updates * remove modal * fix small nits * change defaults * clean up * disable if not gitless and no connection * clean up * always show workflow run id * optimistic * fix branch query * fix issues * fetch edge diff * confirm merge * update edge functions key --------- Co-authored-by: Joshen Lim Co-authored-by: Kevin Grüneberg Co-authored-by: Alaister Young --- .../BranchManagement/Branch.Commands.tsx | 2 +- .../BranchManagement/BranchPanels.tsx | 2 +- .../BranchManagement/DatabaseDiffPanel.tsx | 66 +++ .../EdgeFunctionsDiffPanel.tsx | 217 ++++++++ .../BranchManagement/OutOfDateNotice.tsx | 119 +++++ .../BranchManagement/WorkflowLogsCard.tsx | 127 +++++ .../SidePanelGitHubRepoLinker.tsx | 2 +- .../layouts/AppLayout/BranchDropdown.tsx | 36 +- .../layouts/PageLayout/PageHeader.tsx | 4 +- .../layouts/PageLayout/PageLayout.tsx | 75 +-- apps/studio/components/ui/DiffViewer.tsx | 48 ++ .../studio/data/branches/branch-diff-query.ts | 56 +++ .../data/branches/branch-merge-mutation.ts | 92 ++++ .../data/branches/branch-push-mutation.ts | 52 ++ apps/studio/data/branches/keys.ts | 2 + .../database/migration-upsert-mutation.ts | 70 +++ .../edge-function-body-query.ts | 6 +- apps/studio/data/workflow-runs/keys.ts | 2 + .../data/workflow-runs/workflow-run-query.ts | 52 ++ .../hooks/branches/useBranchMergeDiff.ts | 240 +++++++++ .../hooks/branches/useEdgeFunctionsDiff.ts | 291 +++++++++++ .../hooks/branches/useWorkflowManagement.ts | 66 +++ apps/studio/pages/project/[ref]/merge.tsx | 471 ++++++++++++++++++ pnpm-lock.yaml | 2 +- 24 files changed, 2050 insertions(+), 50 deletions(-) create mode 100644 apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx create mode 100644 apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx create mode 100644 apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx create mode 100644 apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx create mode 100644 apps/studio/components/ui/DiffViewer.tsx create mode 100644 apps/studio/data/branches/branch-diff-query.ts create mode 100644 apps/studio/data/branches/branch-merge-mutation.ts create mode 100644 apps/studio/data/branches/branch-push-mutation.ts create mode 100644 apps/studio/data/database/migration-upsert-mutation.ts create mode 100644 apps/studio/data/workflow-runs/workflow-run-query.ts create mode 100644 apps/studio/hooks/branches/useBranchMergeDiff.ts create mode 100644 apps/studio/hooks/branches/useEdgeFunctionsDiff.ts create mode 100644 apps/studio/hooks/branches/useWorkflowManagement.ts create mode 100644 apps/studio/pages/project/[ref]/merge.tsx diff --git a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx index 40a6f0b94c..6980de4792 100644 --- a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx @@ -17,7 +17,7 @@ export function useBranchCommands() { let { data: branches } = useBranchesQuery( { - projectRef: selectedProject?.parentRef, + projectRef: selectedProject?.parent_project_ref || selectedProject?.ref, }, { enabled: isBranchingEnabled } ) diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index d6ccd43797..12d6633969 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -189,7 +189,7 @@ export const BranchRow = ({ }, }} > - + {branch.name} diff --git a/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx new file mode 100644 index 0000000000..9a04a071d0 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/DatabaseDiffPanel.tsx @@ -0,0 +1,66 @@ +import Link from 'next/link' +import { CircleAlert, Database, Wind } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle, Skeleton } from 'ui' + +import DiffViewer from 'components/ui/DiffViewer' + +interface DatabaseDiffPanelProps { + diffContent?: string + isLoading: boolean + error?: any + showRefreshButton?: boolean + currentBranchRef?: string +} + +const DatabaseDiffPanel = ({ + diffContent, + isLoading, + error, + currentBranchRef, +}: DatabaseDiffPanelProps) => { + if (isLoading) return + + if (error) + return ( +
+ +

Error loading branch diff

+

+ Please try again in a few minutes and contact support if the problem persists. +

+
+ ) + + if (!diffContent || diffContent.trim() === '') { + return ( +
+ +

No changes detected between branches

+

+ Any changes to your database schema will be shown here for review +

+
+ ) + } + + return ( + + + + + + Schema Changes + + + + + + + + ) +} + +export default DatabaseDiffPanel diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx new file mode 100644 index 0000000000..8a65e3c9ca --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -0,0 +1,217 @@ +import { Code, Wind } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useMemo, useState } from 'react' + +import DiffViewer from 'components/ui/DiffViewer' +import type { EdgeFunctionBodyData } from 'data/edge-functions/edge-function-body-query' +import type { + EdgeFunctionsDiffResult, + FileInfo, + FileStatus, +} from 'hooks/branches/useEdgeFunctionsDiff' +import { EMPTY_ARR } from 'lib/void' +import { basename } from 'path' +import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' + +interface EdgeFunctionsDiffPanelProps { + diffResults: EdgeFunctionsDiffResult + currentBranchRef?: string + mainBranchRef?: string +} + +interface FunctionDiffProps { + functionSlug: string + currentBody: EdgeFunctionBodyData + mainBody: EdgeFunctionBodyData + currentBranchRef?: string + fileInfos: FileInfo[] +} + +// Helper to canonicalize file identifiers to prevent mismatch due to differing root paths +const fileKey = (fullPath: string) => basename(fullPath) + +// Helper to get the status color for file indicators +const getStatusColor = (status: FileStatus): string => { + switch (status) { + case 'added': + return 'bg-brand' + case 'removed': + return 'bg-destructive' + case 'modified': + return 'bg-warning' + case 'unchanged': + return 'bg-muted' + default: + return 'bg-muted' + } +} + +const FunctionDiff = ({ + functionSlug, + currentBody, + mainBody, + currentBranchRef, + fileInfos, +}: FunctionDiffProps) => { + // Get all file keys from fileInfos + const allFileKeys = useMemo(() => fileInfos.map((info) => info.key), [fileInfos]) + + const [activeFileKey, setActiveFileKey] = useState(() => allFileKeys[0]) + + // Keep active tab in sync when allFileKeys changes (e.g. data fetch completes) + useEffect(() => { + if (!activeFileKey || !allFileKeys.includes(activeFileKey)) { + setActiveFileKey(allFileKeys[0]) + } + }, [allFileKeys, activeFileKey]) + + const currentFile = currentBody.find((f) => fileKey(f.name) === activeFileKey) + const mainFile = mainBody.find((f) => fileKey(f.name) === activeFileKey) + + const language = useMemo(() => { + if (!activeFileKey) return 'plaintext' + if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) return 'typescript' + if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) return 'javascript' + if (activeFileKey.endsWith('.json')) return 'json' + if (activeFileKey.endsWith('.sql')) return 'sql' + return 'plaintext' + }, [activeFileKey]) + + if (allFileKeys.length === 0) return null + + return ( + + + {/* Function title */} + + + + {functionSlug} + + + + {/* File list sidebar will be shown instead of top tabs */} + + +
+ {/* Sidebar file list */} +
+
    + {fileInfos.map((fileInfo) => ( +
  • + +
  • + ))} +
+
+ + {/* Diff viewer */} +
+ +
+
+
+
+ ) +} + +const EdgeFunctionsDiffPanel = ({ + diffResults, + currentBranchRef, + mainBranchRef, +}: EdgeFunctionsDiffPanelProps) => { + if (diffResults.isLoading) { + return + } + + if (!diffResults.hasChanges) { + return ( +
+ +

No changes detected between branches

+

+ Any changes to your edge functions will be shown here for review +

+
+ ) + } + + return ( +
+ {diffResults.addedSlugs.length > 0 && ( +
+
+ {diffResults.addedSlugs.map((slug) => ( + + ))} +
+
+ )} + + {diffResults.removedSlugs.length > 0 && ( +
+
+ {diffResults.removedSlugs.map((slug) => ( + + ))} +
+
+ )} + + {diffResults.modifiedSlugs.length > 0 && ( +
+ {diffResults.modifiedSlugs.map((slug) => ( + + ))} +
+ )} +
+ ) +} + +export default EdgeFunctionsDiffPanel diff --git a/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx new file mode 100644 index 0000000000..73444da1e9 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/OutOfDateNotice.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { Button } from 'ui' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from 'ui' +import { GitBranchIcon } from 'lucide-react' +import { Admonition } from 'ui-patterns' + +interface OutOfDateNoticeProps { + isBranchOutOfDateMigrations: boolean + missingMigrationsCount: number + hasMissingFunctions: boolean + missingFunctionsCount: number + hasOutOfDateFunctions: boolean + outOfDateFunctionsCount: number + hasEdgeFunctionModifications: boolean + modifiedFunctionsCount: number + isPushing: boolean + onPush: () => void +} + +export const OutOfDateNotice = ({ + isBranchOutOfDateMigrations, + missingMigrationsCount, + hasMissingFunctions, + missingFunctionsCount, + hasOutOfDateFunctions, + outOfDateFunctionsCount, + hasEdgeFunctionModifications, + modifiedFunctionsCount, + isPushing, + onPush, +}: OutOfDateNoticeProps) => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const hasOutdatedMigrations = isBranchOutOfDateMigrations && missingMigrationsCount > 0 + + const getTitle = () => { + if (hasOutdatedMigrations && (hasMissingFunctions || hasOutOfDateFunctions)) { + return 'Your database schema and edge functions are out of date' + } else if (hasOutdatedMigrations) { + return 'Your database schema is out of date' + } else if (hasMissingFunctions || hasOutOfDateFunctions) { + return 'Your functions are out of date' + } + return 'Branch is out of date' + } + + const getDescription = () => { + return 'Update this branch to get the latest changes from the production branch.' + } + + const handleUpdateClick = () => { + onPush() + } + + const handleConfirmUpdate = () => { + setIsDialogOpen(false) + onPush() + } + + return ( + +
+
+

{getTitle()}

+

{getDescription()}

+
+ + {hasEdgeFunctionModifications ? ( + + + + + + + Update branch with modified functions? + + This branch has {modifiedFunctionsCount} modified edge function + {modifiedFunctionsCount !== 1 ? 's' : ''} that will be overwritten when updating + with the latest functions from the production branch. This action cannot be + undone. + + + + Cancel + Update anyway + + + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx b/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx new file mode 100644 index 0000000000..a3b02ffdfe --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/WorkflowLogsCard.tsx @@ -0,0 +1,127 @@ +import { motion } from 'framer-motion' +import { CircleDotDashed, GitMerge, X } from 'lucide-react' +import { useEffect, useRef } from 'react' + +import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui' + +interface WorkflowRun { + id: string + status: string + branch_id?: string + check_run_id?: number | null + created_at?: string + updated_at?: string + workdir?: string | null + git_config?: unknown +} + +interface WorkflowLogsCardProps { + workflowRun: WorkflowRun | null | undefined + logs: string | undefined + isLoading?: boolean + onClose?: () => void + // Override props for failed workflows + overrideTitle?: string + overrideDescription?: string + overrideIcon?: React.ReactNode + overrideAction?: React.ReactNode +} + +const WorkflowLogsCard = ({ + workflowRun, + logs, + isLoading = false, + onClose, + overrideTitle, + overrideDescription, + overrideIcon, + overrideAction, +}: WorkflowLogsCardProps) => { + const scrollRef = useRef(null) + + // Auto-scroll to bottom when logs change + useEffect(() => { + if (scrollRef.current && logs) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [logs]) + + const showSuccessIcon = workflowRun?.status === 'FUNCTIONS_DEPLOYED' + const isFailed = + workflowRun?.status && ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status) + const isPolling = + workflowRun?.status !== 'FUNCTIONS_DEPLOYED' && + (!workflowRun?.status || + !['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowRun.status)) + + const displayTitle = + overrideTitle || + (isPolling + ? 'Processing...' + : showSuccessIcon + ? 'Workflow completed successfully' + : isFailed + ? 'Workflow failed' + : 'Workflow completed') + + const displayIcon = + overrideIcon || + (isPolling ? ( + + + + ) : showSuccessIcon ? ( + + ) : null) + + return ( + + +
+
+ {displayIcon} +
+ {displayTitle} + {overrideDescription && ( +
+ {overrideDescription} +
+ )} +
+
+
+ {overrideAction} + {onClose && ( +
+
+
+ + {/* sticky gradient overlay */} +
+ {logs ? ( +
{logs}
+ ) : ( +
+            {isLoading || isPolling ? 'Initializing workflow...' : 'Waiting for logs...'}
+          
+ )} + + + ) +} + +export default WorkflowLogsCard diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx index 609931bd89..36272d090a 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx @@ -108,7 +108,7 @@ const SidePanelGitHubRepoLinker = ({ projectRef }: SidePanelGitHubRepoLinkerProp ) const { data: existingBranches } = useBranchesQuery( - { projectRef: selectedProject?.ref }, + { projectRef: selectedProject?.parent_project_ref || selectedProject?.ref }, { enabled: !!selectedProject?.ref } ) diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx index 2021f3df78..b8e7d49c26 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -2,7 +2,7 @@ import { AlertCircle, Check, ChevronsUpDown, - GitBranch, + GitMerge, ListTree, MessageCircle, Plus, @@ -34,6 +34,7 @@ import { cn, } from 'ui' import { sanitizeRoute } from './ProjectDropdown' +import { useFlag } from 'hooks/ui/useFlag' const BranchLink = ({ branch, @@ -75,13 +76,18 @@ const BranchLink = ({ export const BranchDropdown = () => { const router = useRouter() const { ref } = useParams() + const gitlessBranching = useFlag('gitlessBranching') const projectDetails = useSelectedProject() const snap = useAppStateSnapshot() - const isBranch = projectDetails?.parent_project_ref !== undefined - const projectRef = - projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined - const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) + const projectRef = projectDetails?.parent_project_ref || ref + + const { + data: branches, + isLoading, + isError, + isSuccess, + } = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) }) const isBranchingEnabled = projectDetails?.is_branch_enabled === true const selectedBranch = branches?.find((branch) => branch.project_ref === ref) @@ -153,6 +159,26 @@ export const BranchDropdown = () => {

Create branch

+ {gitlessBranching && + isBranchingEnabled && + selectedBranch && + !selectedBranch.is_default && ( + <> + { + setOpen(false) + router.push(`/project/${ref}/merge`) + }} + onClick={() => setOpen(false)} + > + + + Review changes + + + + )} { diff --git a/apps/studio/components/layouts/PageLayout/PageHeader.tsx b/apps/studio/components/layouts/PageLayout/PageHeader.tsx index f32b096688..98e3fef0f7 100644 --- a/apps/studio/components/layouts/PageLayout/PageHeader.tsx +++ b/apps/studio/components/layouts/PageLayout/PageHeader.tsx @@ -15,8 +15,8 @@ import { import { ScaffoldDescription, ScaffoldTitle } from '../Scaffold' interface PageHeaderProps { - title?: string - subtitle?: string + title?: string | ReactNode + subtitle?: string | ReactNode icon?: ReactNode breadcrumbs?: Array<{ label?: string diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx index a8bc30fa78..f725e915cb 100644 --- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx +++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx @@ -14,11 +14,12 @@ export interface NavigationItem { icon?: ReactNode onClick?: () => void badge?: string + active?: boolean } interface PageLayoutProps { children?: ReactNode - title?: string + title?: string | ReactNode subtitle?: string icon?: ReactNode breadcrumbs?: Array<{ @@ -80,8 +81,8 @@ export const PageLayout = ({ className={cn( 'w-full mx-auto', size === 'full' && - (isCompact ? 'max-w-none !px-6 border-b' : 'max-w-none p!x-8 border-b'), - isCompact ? 'pt-4' : 'pt-12', + (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 border-b'), + size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'), navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'), className )} @@ -102,38 +103,42 @@ export const PageLayout = ({ {/* Navigation section */} {navigationItems.length > 0 && ( - {navigationItems.map((item) => ( - - {item.href ? ( - - {item.icon && {item.icon}} - {item.label} - {item.badge && {item.badge}} - - ) : ( - - )} - - ))} + {navigationItems.map((item) => { + const isActive = + item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href + return ( + + {item.href ? ( + + {item.icon && {item.icon}} + {item.label} + {item.badge && {item.badge}} + + ) : ( + + )} + + ) + })} )} diff --git a/apps/studio/components/ui/DiffViewer.tsx b/apps/studio/components/ui/DiffViewer.tsx new file mode 100644 index 0000000000..ae70313de0 --- /dev/null +++ b/apps/studio/components/ui/DiffViewer.tsx @@ -0,0 +1,48 @@ +import { DiffEditor } from '@monaco-editor/react' +import { editor as monacoEditor } from 'monaco-editor' + +interface DiffViewerProps { + /** Original/left hand side content (optional) */ + original?: string + /** Modified/right hand side content */ + modified: string | undefined + /** Language identifier understood by Monaco */ + language: string + /** Height for the editor container */ + height?: string | number + /** Whether to render diffs side-by-side */ + sideBySide?: boolean +} + +// Centralised set of options so all diff editors look the same +const DEFAULT_OPTIONS: monacoEditor.IStandaloneDiffEditorConstructionOptions = { + readOnly: true, + renderSideBySide: false, + minimap: { enabled: false }, + wordWrap: 'on', + lineNumbers: 'on', + folding: false, + padding: { top: 16, bottom: 16 }, + lineNumbersMinChars: 3, + fontSize: 13, + scrollBeyondLastLine: false, +} + +export const DiffViewer = ({ + original = '', + modified = '', + language, + height = '100%', + sideBySide = false, +}: DiffViewerProps) => ( + +) + +export default DiffViewer diff --git a/apps/studio/data/branches/branch-diff-query.ts b/apps/studio/data/branches/branch-diff-query.ts new file mode 100644 index 0000000000..eba205e190 --- /dev/null +++ b/apps/studio/data/branches/branch-diff-query.ts @@ -0,0 +1,56 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { branchKeys } from './keys' + +export type BranchDiffVariables = { + branchId: string + projectRef: string + includedSchemas?: string +} + +export async function getBranchDiff({ + branchId, + includedSchemas, +}: Pick) { + const { data: diffData, error } = await get('/v1/branches/{branch_id}/diff', { + params: { + path: { branch_id: branchId }, + query: includedSchemas ? { included_schemas: includedSchemas } : undefined, + }, + headers: { + Accept: 'text/plain', + }, + parseAs: 'text', + }) + + if (error) { + handleError(error) + } + + // Handle empty object responses (when no diff exists) + if (typeof diffData === 'object' && Object.keys(diffData).length === 0) { + return '' + } + + return diffData || '' +} + +type BranchDiffData = Awaited> + +export const useBranchDiffQuery = ( + { branchId, projectRef, includedSchemas = 'public' }: BranchDiffVariables, + { + enabled = true, + ...options + }: Omit, 'queryKey' | 'queryFn'> = {} +) => + useQuery( + branchKeys.diff(projectRef, branchId), + () => getBranchDiff({ branchId, includedSchemas }), + { + enabled: enabled && typeof branchId !== 'undefined' && branchId !== '', + ...options, + } + ) diff --git a/apps/studio/data/branches/branch-merge-mutation.ts b/apps/studio/data/branches/branch-merge-mutation.ts new file mode 100644 index 0000000000..bceacc3822 --- /dev/null +++ b/apps/studio/data/branches/branch-merge-mutation.ts @@ -0,0 +1,92 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { branchKeys } from './keys' +import { getBranchDiff } from './branch-diff-query' +import { upsertMigration } from '../database/migration-upsert-mutation' + +export type BranchMergeVariables = { + id: string + branchProjectRef: string + baseProjectRef: string + migration_version?: string +} + +export async function mergeBranch({ + id, + branchProjectRef, + baseProjectRef, + migration_version, +}: BranchMergeVariables) { + // Step 1: Get the diff output from the branch + const diffContent = await getBranchDiff({ branchId: id, includedSchemas: 'public' }) + + let migrationCreated = false + + // Step 2: If there are changes, create a migration before merging + if (diffContent && diffContent.trim() !== '') { + // Generate a descriptive migration name based on current timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + const migrationName = `branch_merge_${timestamp}` + + await upsertMigration({ + projectRef: branchProjectRef, + query: diffContent, + name: migrationName, + }) + + migrationCreated = true + } + + // Step 3: Call POST /v1/branches/id/merge to merge the branch + const { data, error } = await post('/v1/branches/{branch_id}/merge', { + params: { path: { branch_id: id } }, + body: { migration_version }, + }) + + if (error) { + handleError(error) + } + + return { + data, + migrationCreated, + hadChanges: diffContent && diffContent.trim() !== '', + workflowRunId: data?.workflow_run_id, + } +} + +type BranchMergeData = Awaited> + +export const useBranchMergeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => mergeBranch(vars), + { + async onSuccess(data, variables, context) { + const { baseProjectRef } = variables + await queryClient.invalidateQueries(branchKeys.list(baseProjectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + let errorMessage = data.message || 'Unknown error occurred' + + toast.error(`Failed to merge branch: ${errorMessage}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/branches/branch-push-mutation.ts b/apps/studio/data/branches/branch-push-mutation.ts new file mode 100644 index 0000000000..035dbc2252 --- /dev/null +++ b/apps/studio/data/branches/branch-push-mutation.ts @@ -0,0 +1,52 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { branchKeys } from './keys' + +export type BranchPushVariables = { + id: string + projectRef: string +} + +export async function pushBranch({ id }: Pick) { + const { data, error } = await post('/v1/branches/{branch_id}/push', { + params: { path: { branch_id: id } }, + body: {}, + }) + + if (error) handleError(error) + return data +} + +type BranchPushData = Awaited> + +export const useBranchPushMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => pushBranch(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(branchKeys.list(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to push branch: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/branches/keys.ts b/apps/studio/data/branches/keys.ts index 85ce513fd3..078b6cdb34 100644 --- a/apps/studio/data/branches/keys.ts +++ b/apps/studio/data/branches/keys.ts @@ -2,4 +2,6 @@ export const branchKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'branches'] as const, detail: (projectRef: string | undefined, id: string | undefined) => ['projects', projectRef, 'branches', id] as const, + diff: (projectRef: string | undefined, branchId: string | undefined) => + ['projects', projectRef, 'branch', branchId, 'diff'] as const, } diff --git a/apps/studio/data/database/migration-upsert-mutation.ts b/apps/studio/data/database/migration-upsert-mutation.ts new file mode 100644 index 0000000000..2dea72dfef --- /dev/null +++ b/apps/studio/data/database/migration-upsert-mutation.ts @@ -0,0 +1,70 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, put } from 'data/fetchers' +import type { ResponseError } from 'types' +import { databaseKeys } from './keys' + +export type MigrationUpsertVariables = { + projectRef: string + query: string + name?: string + idempotencyKey?: string +} + +export async function upsertMigration({ + projectRef, + query, + name, + idempotencyKey, +}: MigrationUpsertVariables) { + const headers: Record = {} + if (idempotencyKey) { + headers['Idempotency-Key'] = idempotencyKey + } + + const body: { query: string; name?: string } = { query } + if (name) { + body.name = name + } + + const { data, error } = await put('/v1/projects/{ref}/database/migrations', { + params: { path: { ref: projectRef } }, + body, + headers, + }) + + if (error) handleError(error) + return data +} + +type MigrationUpsertData = Awaited> + +export const useMigrationUpsertMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => upsertMigration(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(databaseKeys.migrations(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to upsert migration: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index af35720db6..d604b96395 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -54,10 +54,8 @@ export async function getEdgeFunctionBody( const { files } = await parseResponse.json() return files as EdgeFunctionFile[] } catch (error) { - console.error('Failed to parse edge function code:', error) - throw new Error( - 'Failed to parse function code. The file may be corrupted or in an invalid format.' - ) + handleError(error) + return [] } } diff --git a/apps/studio/data/workflow-runs/keys.ts b/apps/studio/data/workflow-runs/keys.ts index dfb7939902..f2877fff87 100644 --- a/apps/studio/data/workflow-runs/keys.ts +++ b/apps/studio/data/workflow-runs/keys.ts @@ -1,3 +1,5 @@ export const workflowRunKeys = { list: (projectRef: string | undefined) => ['projects', projectRef, 'workflow-runs'] as const, + detail: (projectRef: string | undefined, workflowRunId: string | undefined) => + ['projects', projectRef, 'workflow-run', workflowRunId] as const, } diff --git a/apps/studio/data/workflow-runs/workflow-run-query.ts b/apps/studio/data/workflow-runs/workflow-run-query.ts new file mode 100644 index 0000000000..e2164d1ec0 --- /dev/null +++ b/apps/studio/data/workflow-runs/workflow-run-query.ts @@ -0,0 +1,52 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { workflowRunKeys } from './keys' + +export type WorkflowRunVariables = { + projectRef?: string + workflowRunId?: string +} + +export async function getWorkflowRun( + { workflowRunId }: WorkflowRunVariables, + signal?: AbortSignal +): Promise<{ logs: string; workflowRunId: string }> { + if (!workflowRunId) throw new Error('workflowRunId is required') + + // Use the logs endpoint which fetches workflow run status + const { data, error } = await get('/platform/workflow-runs/{workflow_run_id}/logs', { + params: { + path: { + workflow_run_id: workflowRunId, + }, + }, + parseAs: 'text', + signal, + }) + + if (error) { + handleError(error) + } + + // Return an object with the logs and we'll extract status from headers if needed + return { logs: data as string, workflowRunId } +} + +export type WorkflowRunData = { logs: string; workflowRunId: string } +export type WorkflowRunError = ResponseError + +export const useWorkflowRunQuery = ( + { projectRef, workflowRunId }: WorkflowRunVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + workflowRunKeys.detail(projectRef, workflowRunId), + ({ signal }) => getWorkflowRun({ workflowRunId }, signal), + { + enabled: enabled && typeof workflowRunId !== 'undefined', + staleTime: 0, + ...options, + } + ) diff --git a/apps/studio/hooks/branches/useBranchMergeDiff.ts b/apps/studio/hooks/branches/useBranchMergeDiff.ts new file mode 100644 index 0000000000..a71789e903 --- /dev/null +++ b/apps/studio/hooks/branches/useBranchMergeDiff.ts @@ -0,0 +1,240 @@ +import { useMemo } from 'react' +import { useBranchDiffQuery } from 'data/branches/branch-diff-query' +import { useMigrationsQuery } from 'data/database/migrations-query' +import { useEdgeFunctionsDiff, type EdgeFunctionsDiffResult } from './useEdgeFunctionsDiff' + +interface UseBranchMergeDiffProps { + branchId?: string + currentBranchRef?: string + parentProjectRef?: string + currentBranchConnectionString?: string + parentBranchConnectionString?: string + currentBranchCreatedAt?: string +} + +export interface BranchMergeDiffResult { + // Database diff + diffContent: string | undefined + isDatabaseDiffLoading: boolean + isDatabaseDiffRefetching: boolean + databaseDiffError: any + refetchDatabaseDiff: () => void + + // Edge functions diff + edgeFunctionsDiff: EdgeFunctionsDiffResult + + // Migrations + currentBranchMigrations: any[] | undefined + mainBranchMigrations: any[] | undefined + refetchCurrentBranchMigrations: () => void + refetchMainBranchMigrations: () => void + + // Branch state + isBranchOutOfDateMigrations: boolean + hasEdgeFunctionModifications: boolean + missingFunctionsCount: number + hasMissingFunctions: boolean + outOfDateFunctionsCount: number + hasOutOfDateFunctions: boolean + isBranchOutOfDateOverall: boolean + missingMigrationsCount: number + modifiedFunctionsCount: number + + // Combined states + isLoading: boolean + hasChanges: boolean +} + +export const useBranchMergeDiff = ({ + branchId, + currentBranchRef, + parentProjectRef, + currentBranchConnectionString, + parentBranchConnectionString, + currentBranchCreatedAt, +}: UseBranchMergeDiffProps): BranchMergeDiffResult => { + // Get database diff + const { + data: diffContent, + isLoading: isDatabaseDiffLoading, + isRefetching: isDatabaseDiffRefetching, + error: databaseDiffError, + refetch: refetchDatabaseDiff, + } = useBranchDiffQuery( + { + branchId: branchId || '', + projectRef: parentProjectRef || '', + }, + { + enabled: !!branchId && !!parentProjectRef, + refetchOnMount: 'always', + refetchOnWindowFocus: false, + staleTime: 0, + } + ) + + // Get migrations for both current branch and main branch + const { data: currentBranchMigrations, refetch: refetchCurrentBranchMigrations } = + useMigrationsQuery( + { + projectRef: currentBranchRef, + connectionString: currentBranchConnectionString, + }, + { + enabled: !!currentBranchRef, + staleTime: 3000, + } + ) + + const { data: mainBranchMigrations, refetch: refetchMainBranchMigrations } = useMigrationsQuery( + { + projectRef: parentProjectRef, + connectionString: parentBranchConnectionString, + }, + { + enabled: !!parentProjectRef, + staleTime: 3000, + } + ) + + // Get edge functions diff + const edgeFunctionsDiff = useEdgeFunctionsDiff({ + currentBranchRef, + mainBranchRef: parentProjectRef, + }) + + // Check if current branch is out of date with main branch (migrations) + const isBranchOutOfDateMigrations = useMemo(() => { + if (!currentBranchMigrations || !mainBranchMigrations) return false + + // Get the latest migration version from main branch + const latestMainMigration = mainBranchMigrations[0] // migrations are ordered by version desc + if (!latestMainMigration) return false + + // Check if current branch has this latest migration + const hasLatestMigration = currentBranchMigrations.some( + (migration) => migration.version === latestMainMigration.version + ) + + return !hasLatestMigration + }, [currentBranchMigrations, mainBranchMigrations]) + + // Check if main branch has functions that are newer than the current branch versions + const outOfDateFunctionsCount = useMemo(() => { + if (!edgeFunctionsDiff.modifiedSlugs.length) return 0 + + return edgeFunctionsDiff.modifiedSlugs.filter((slug) => { + // Get both main and current branch function data + const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug) + const currentFunction = edgeFunctionsDiff.currentBranchFunctions?.find( + (func) => func.slug === slug + ) + + if (!mainFunction || !currentFunction) return false + + // Compare updated_at timestamps - if main branch function is newer, it was modified after current branch + const mainUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds + const currentUpdatedAt = currentFunction.updated_at * 1000 // Convert to milliseconds + + return mainUpdatedAt > currentUpdatedAt + }).length + }, [ + edgeFunctionsDiff.modifiedSlugs, + edgeFunctionsDiff.mainBranchFunctions, + edgeFunctionsDiff.currentBranchFunctions, + ]) + + // Check if main branch has functions that are newer than the current branch versions + const hasOutOfDateFunctions = outOfDateFunctionsCount > 0 + + // Check if current branch has any edge function modifications (not additions/removals) + // This only includes functions where the current branch version is newer than the main branch version + const hasEdgeFunctionModifications = + edgeFunctionsDiff.modifiedSlugs.length > outOfDateFunctionsCount + + // Count of removed functions that were updated on main branch after this branch was created + // Note: For removed functions, we use branch creation date since there's no current branch version to compare to + const missingFunctionsCount = useMemo(() => { + if (!currentBranchCreatedAt || !edgeFunctionsDiff.removedSlugs.length) return 0 + + const branchCreatedAt = new Date(currentBranchCreatedAt).getTime() + + return edgeFunctionsDiff.removedSlugs.filter((slug) => { + // Access main branch function data from the original functions list + const mainFunction = edgeFunctionsDiff.mainBranchFunctions?.find((func) => func.slug === slug) + if (!mainFunction) return false + + // Check if function was updated after branch creation + const functionUpdatedAt = mainFunction.updated_at * 1000 // Convert to milliseconds + return functionUpdatedAt > branchCreatedAt + }).length + }, [ + currentBranchCreatedAt, + edgeFunctionsDiff.removedSlugs, + edgeFunctionsDiff.mainBranchFunctions, + ]) + + // Check if main branch has functions removed from current branch that were updated after branch creation + const hasMissingFunctions = missingFunctionsCount > 0 + + // Update overall out-of-date check to include newer removed functions and newer modified functions + const isBranchOutOfDateOverall = + isBranchOutOfDateMigrations || hasMissingFunctions || hasOutOfDateFunctions + + // Get the count of migrations that the branch is missing + const missingMigrationsCount = useMemo(() => { + if (!currentBranchMigrations || !mainBranchMigrations || !isBranchOutOfDateMigrations) return 0 + + const currentVersions = new Set(currentBranchMigrations.map((m) => m.version)) + return mainBranchMigrations.filter((m) => !currentVersions.has(m.version)).length + }, [currentBranchMigrations, mainBranchMigrations, isBranchOutOfDateMigrations]) + + // Get count of modified functions + const modifiedFunctionsCount = edgeFunctionsDiff.modifiedSlugs.length + + // Check if there are any changes (database or edge functions) + const hasChanges = useMemo(() => { + // Check database changes + const hasDatabaseChanges = diffContent && diffContent.trim() !== '' + + // Check edge function changes + const hasEdgeFunctionChanges = edgeFunctionsDiff.hasChanges + + return hasDatabaseChanges || hasEdgeFunctionChanges + }, [diffContent, edgeFunctionsDiff.hasChanges]) + + const isLoading = isDatabaseDiffLoading || edgeFunctionsDiff.isLoading + + return { + // Database diff + diffContent, + isDatabaseDiffLoading, + isDatabaseDiffRefetching, + databaseDiffError, + refetchDatabaseDiff, + + // Edge functions diff + edgeFunctionsDiff, + + // Migrations + currentBranchMigrations, + mainBranchMigrations, + refetchCurrentBranchMigrations, + refetchMainBranchMigrations, + + // Branch state + isBranchOutOfDateMigrations, + hasEdgeFunctionModifications, + missingFunctionsCount, + hasMissingFunctions, + outOfDateFunctionsCount, + hasOutOfDateFunctions, + isBranchOutOfDateOverall, + missingMigrationsCount, + modifiedFunctionsCount, + + // Combined states + isLoading, + hasChanges, + } +} diff --git a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts new file mode 100644 index 0000000000..4319a90f7c --- /dev/null +++ b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts @@ -0,0 +1,291 @@ +import { useMemo, useCallback } from 'react' +import { useQueries, useQueryClient } from '@tanstack/react-query' +import { handleError } from 'data/fetchers' +import { + getEdgeFunctionBody, + type EdgeFunctionBodyData, +} from 'data/edge-functions/edge-function-body-query' +import { + useEdgeFunctionsQuery, + type EdgeFunctionsData, +} from 'data/edge-functions/edge-functions-query' +import { basename } from 'path' +import { edgeFunctionsKeys } from 'data/edge-functions/keys' + +interface UseEdgeFunctionsDiffProps { + currentBranchRef?: string + mainBranchRef?: string +} + +export type FileStatus = 'added' | 'removed' | 'modified' | 'unchanged' + +export interface FileInfo { + key: string + status: FileStatus +} + +export interface FunctionFileInfo { + [functionSlug: string]: FileInfo[] +} + +export interface EdgeFunctionsDiffResult { + addedSlugs: string[] + removedSlugs: string[] + modifiedSlugs: string[] + addedBodiesMap: Record + removedBodiesMap: Record + currentBodiesMap: Record + mainBodiesMap: Record + functionFileInfo: FunctionFileInfo + isLoading: boolean + hasChanges: boolean + refetchCurrentBranchFunctions: () => void + refetchMainBranchFunctions: () => void + currentBranchFunctions?: EdgeFunctionsData + mainBranchFunctions?: EdgeFunctionsData + clearDiffsOptimistically: () => void +} + +// Small helper around path.basename but avoids importing the full Node path lib for the browser bundle +const fileKey = (fullPath: string) => basename(fullPath) + +export const useEdgeFunctionsDiff = ({ + currentBranchRef, + mainBranchRef, +}: UseEdgeFunctionsDiffProps): EdgeFunctionsDiffResult => { + const queryClient = useQueryClient() + + // Fetch edge functions for both branches + const { + data: currentBranchFunctions, + isLoading: isCurrentFunctionsLoading, + refetch: refetchCurrentBranchFunctions, + } = useEdgeFunctionsQuery( + { projectRef: currentBranchRef }, + { + enabled: !!currentBranchRef, + refetchOnMount: 'always', + staleTime: 30000, // 30 seconds + } + ) + + const { + data: mainBranchFunctions, + isLoading: isMainFunctionsLoading, + refetch: refetchMainBranchFunctions, + } = useEdgeFunctionsQuery( + { projectRef: mainBranchRef }, + { + enabled: !!mainBranchRef, + refetchOnMount: 'always', + staleTime: 30000, // 30 seconds + } + ) + + // Identify added / removed / overlapping functions + const { + added = [], + removed = [], + overlap = [], + } = useMemo(() => { + if (!currentBranchFunctions || !mainBranchFunctions) { + return { added: [], removed: [], overlap: [] as typeof currentBranchFunctions } + } + + const currentFuncs = currentBranchFunctions ?? [] + const mainFuncs = mainBranchFunctions ?? [] + + const added = currentFuncs.filter((c) => !mainFuncs.find((m) => m.slug === c.slug)) + const removed = mainFuncs.filter((m) => !currentFuncs.find((c) => c.slug === m.slug)) + const overlap = currentFuncs.filter((c) => mainFuncs.find((m) => m.slug === c.slug)) + + return { added, removed, overlap } + }, [currentBranchFunctions, mainBranchFunctions]) + + const overlapSlugs = overlap.map((f) => f.slug) + const addedSlugs = added.map((f) => f.slug) + const removedSlugs = removed.map((f) => f.slug) + + // Fetch function bodies --------------------------------------------------- + const currentBodiesQueries = useQueries({ + queries: overlapSlugs.map((slug) => ({ + queryKey: ['edge-function-body', currentBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), + enabled: !!currentBranchRef, + refetchOnMount: 'always' as const, + })), + }) + + const mainBodiesQueries = useQueries({ + queries: overlapSlugs.map((slug) => ({ + queryKey: ['edge-function-body', mainBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), + enabled: !!mainBranchRef, + refetchOnMount: 'always' as const, + })), + }) + + const addedBodiesQueries = useQueries({ + queries: addedSlugs.map((slug) => ({ + queryKey: ['edge-function-body', currentBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: currentBranchRef, slug }, signal), + enabled: !!currentBranchRef, + refetchOnMount: 'always' as const, + })), + }) + + const removedBodiesQueries = useQueries({ + queries: removedSlugs.map((slug) => ({ + queryKey: ['edge-function-body', mainBranchRef, slug], + queryFn: ({ signal }: { signal?: AbortSignal }) => + getEdgeFunctionBody({ projectRef: mainBranchRef, slug }, signal), + enabled: !!mainBranchRef, + refetchOnMount: 'always' as const, + })), + }) + + // Flatten loading flags ---------------------------------------------------- + const isLoading = + [ + ...currentBodiesQueries, + ...mainBodiesQueries, + ...addedBodiesQueries, + ...removedBodiesQueries, + ].some((q) => q.isLoading) || + isCurrentFunctionsLoading || + isMainFunctionsLoading + + // Aggregate errors across all queries and handle the first encountered error. + const firstError = [ + ...currentBodiesQueries, + ...mainBodiesQueries, + ...addedBodiesQueries, + ...removedBodiesQueries, + ].find((q) => q.error)?.error + + if (firstError) { + handleError(firstError) + } + + // Build lookup maps -------------------------------------------------------- + const currentBodiesMap: Record = {} + currentBodiesQueries.forEach((q, idx) => { + if (q.data) currentBodiesMap[overlapSlugs[idx]] = q.data + }) + + const mainBodiesMap: Record = {} + mainBodiesQueries.forEach((q, idx) => { + if (q.data) mainBodiesMap[overlapSlugs[idx]] = q.data + }) + + const addedBodiesMap: Record = {} + addedBodiesQueries.forEach((q, idx) => { + if (q.data) addedBodiesMap[addedSlugs[idx]] = q.data + }) + + const removedBodiesMap: Record = {} + removedBodiesQueries.forEach((q, idx) => { + if (q.data) removedBodiesMap[removedSlugs[idx]] = q.data + }) + + // Determine modified slugs and build file info ----------------------------- + const modifiedSlugs: string[] = [] + const functionFileInfo: FunctionFileInfo = {} + + // Process overlapping functions to determine modifications and file info + overlapSlugs.forEach((slug) => { + const currentBody = currentBodiesMap[slug] + const mainBody = mainBodiesMap[slug] + if (!currentBody || !mainBody) return + + const allFileKeys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) + const fileInfos: FileInfo[] = [] + let hasModifications = false + + for (const key of allFileKeys) { + const currentFile = currentBody.find((f) => fileKey(f.name) === key) + const mainFile = mainBody.find((f) => fileKey(f.name) === key) + + let status: FileStatus = 'unchanged' + + if (!currentFile && mainFile) { + status = 'removed' + hasModifications = true + } else if (currentFile && !mainFile) { + status = 'added' + hasModifications = true + } else if (currentFile && mainFile && currentFile.content !== mainFile.content) { + status = 'modified' + hasModifications = true + } + + fileInfos.push({ key, status }) + } + + if (hasModifications) { + modifiedSlugs.push(slug) + } + + functionFileInfo[slug] = fileInfos + }) + + // Add file info for added functions + addedSlugs.forEach((slug) => { + const body = addedBodiesMap[slug] + if (body) { + functionFileInfo[slug] = body.map((file) => ({ + key: fileKey(file.name), + status: 'added' as FileStatus, + })) + } + }) + + // Add file info for removed functions + removedSlugs.forEach((slug) => { + const body = removedBodiesMap[slug] + if (body) { + functionFileInfo[slug] = body.map((file) => ({ + key: fileKey(file.name), + status: 'removed' as FileStatus, + })) + } + }) + + const hasChanges = addedSlugs.length > 0 || removedSlugs.length > 0 || modifiedSlugs.length > 0 + + const clearDiffsOptimistically = useCallback(() => { + if (!currentBranchRef || !mainBranchFunctions) return + + queryClient.setQueryData(edgeFunctionsKeys.list(currentBranchRef), mainBranchFunctions) + + mainBranchFunctions.forEach((func) => { + const mainBody = mainBodiesMap[func.slug] + if (mainBody) { + queryClient.setQueryData(['edge-function-body', currentBranchRef, func.slug], mainBody) + } + }) + }, [currentBranchRef, mainBranchFunctions, mainBodiesMap, queryClient]) + + return { + addedSlugs, + removedSlugs, + modifiedSlugs, + addedBodiesMap, + removedBodiesMap, + currentBodiesMap, + mainBodiesMap, + functionFileInfo, + isLoading, + hasChanges, + refetchCurrentBranchFunctions, + refetchMainBranchFunctions, + currentBranchFunctions, + mainBranchFunctions, + clearDiffsOptimistically, + } +} + +export default useEdgeFunctionsDiff diff --git a/apps/studio/hooks/branches/useWorkflowManagement.ts b/apps/studio/hooks/branches/useWorkflowManagement.ts new file mode 100644 index 0000000000..2bd1c4e458 --- /dev/null +++ b/apps/studio/hooks/branches/useWorkflowManagement.ts @@ -0,0 +1,66 @@ +import { useState, useEffect } from 'react' + +import { useWorkflowRunsQuery } from 'data/workflow-runs/workflow-runs-query' +import { useWorkflowRunQuery } from 'data/workflow-runs/workflow-run-query' + +interface UseWorkflowManagementProps { + workflowRunId?: string + projectRef?: string + onWorkflowComplete?: (status: string) => void +} + +export const useWorkflowManagement = ({ + workflowRunId, + projectRef, + onWorkflowComplete, +}: UseWorkflowManagementProps) => { + const [hasRefetched, setHasRefetched] = useState(false) + + // Get workflow runs and logs + const { data: workflowRuns } = useWorkflowRunsQuery( + { projectRef }, + { + enabled: Boolean(workflowRunId), + refetchInterval: 3000, + refetchOnMount: 'always', + staleTime: 0, + } + ) + + const { data: workflowRunLogs } = useWorkflowRunQuery( + { projectRef, workflowRunId }, + { + refetchInterval: 2000, + refetchOnMount: 'always', + staleTime: 0, + } + ) + + // Find current workflow run + const currentWorkflowRun = workflowRuns?.find((run) => run.id === workflowRunId) + + // Handle workflow completion + useEffect(() => { + if (!currentWorkflowRun?.status || !workflowRunId) return + + const isComplete = ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes( + currentWorkflowRun.status + ) + + // Only refetch once per workflow completion + if (isComplete && !hasRefetched) { + setHasRefetched(true) + onWorkflowComplete?.(currentWorkflowRun.status) + } + }, [currentWorkflowRun?.status, workflowRunId, hasRefetched, onWorkflowComplete]) + + // Reset refetch flag when workflow ID changes + useEffect(() => { + setHasRefetched(false) + }, [workflowRunId]) + + return { + currentWorkflowRun, + workflowRunLogs: workflowRunLogs?.logs, + } +} diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx new file mode 100644 index 0000000000..ec695e8e41 --- /dev/null +++ b/apps/studio/pages/project/[ref]/merge.tsx @@ -0,0 +1,471 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import { useRouter } from 'next/router' +import dayjs from 'dayjs' + +import { useParams } from 'common' +import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useBranchMergeMutation } from 'data/branches/branch-merge-mutation' +import { useBranchPushMutation } from 'data/branches/branch-push-mutation' +import { useBranchMergeDiff } from 'hooks/branches/useBranchMergeDiff' +import { useWorkflowManagement } from 'hooks/branches/useWorkflowManagement' +import DatabaseDiffPanel from 'components/interfaces/BranchManagement/DatabaseDiffPanel' +import EdgeFunctionsDiffPanel from 'components/interfaces/BranchManagement/EdgeFunctionsDiffPanel' +import { OutOfDateNotice } from 'components/interfaces/BranchManagement/OutOfDateNotice' +import { Badge, Button, NavMenu, NavMenuItem, cn, Alert } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { toast } from 'sonner' +import type { NextPageWithLayout } from 'types' + +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import { GitBranchIcon, GitMerge, Shield, ExternalLink, AlertTriangle } from 'lucide-react' +import Link from 'next/link' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import { useFlag } from 'hooks/ui/useFlag' + +const MergePage: NextPageWithLayout = () => { + const router = useRouter() + const { ref } = useParams() + + const gitlessBranching = useFlag('gitlessBranching') + + const project = useSelectedProject() + const [isSubmitting, setIsSubmitting] = useState(false) + const [workflowFinalStatus, setWorkflowFinalStatus] = useState(null) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + + const isBranch = project?.parent_project_ref !== undefined + const parentProjectRef = project?.parent_project_ref + + const parentProject = useProjectByRef(parentProjectRef) + + const { data: branches } = useBranchesQuery( + { projectRef: parentProjectRef }, + { + refetchOnMount: 'always', + refetchOnWindowFocus: true, + staleTime: 0, + } + ) + const currentBranch = branches?.find((branch) => branch.project_ref === ref) + const mainBranch = branches?.find((branch) => branch.is_default) + + const { + diffContent, + isDatabaseDiffLoading, + isDatabaseDiffRefetching, + databaseDiffError: diffError, + refetchDatabaseDiff: refetchDiff, + edgeFunctionsDiff, + isBranchOutOfDateMigrations, + hasEdgeFunctionModifications, + missingFunctionsCount, + hasMissingFunctions, + outOfDateFunctionsCount, + hasOutOfDateFunctions, + isBranchOutOfDateOverall, + missingMigrationsCount, + modifiedFunctionsCount, + isLoading: isCombinedDiffLoading, + hasChanges: combinedHasChanges, + } = useBranchMergeDiff({ + branchId: currentBranch?.id, + currentBranchRef: ref, + parentProjectRef, + currentBranchConnectionString: project?.connectionString || undefined, + parentBranchConnectionString: (parentProject as any)?.connectionString || undefined, + currentBranchCreatedAt: currentBranch?.created_at, + }) + + const currentWorkflowRunId = router.query.workflow_run_id as string | undefined + + useEffect(() => { + setWorkflowFinalStatus(null) + }, [currentWorkflowRunId]) + + const { currentWorkflowRun: currentBranchWorkflow, workflowRunLogs: currentBranchLogs } = + useWorkflowManagement({ + workflowRunId: currentWorkflowRunId, + projectRef: ref, + onWorkflowComplete: (status) => { + setWorkflowFinalStatus(status) + refetchDiff() + edgeFunctionsDiff.clearDiffsOptimistically() + }, + }) + + const { currentWorkflowRun: parentBranchWorkflow, workflowRunLogs: parentBranchLogs } = + useWorkflowManagement({ + workflowRunId: currentWorkflowRunId, + projectRef: parentProjectRef, + onWorkflowComplete: (status) => { + setWorkflowFinalStatus(status) + refetchDiff() + edgeFunctionsDiff.clearDiffsOptimistically() + }, + }) + + const currentWorkflowRun = currentBranchWorkflow || parentBranchWorkflow + const workflowRunLogs = currentBranchLogs || parentBranchLogs + + const hasCurrentWorkflowFailed = workflowFinalStatus + ? ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(workflowFinalStatus) + : currentWorkflowRun?.status && + ['MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(currentWorkflowRun.status) + + const isWorkflowRunning = + currentWorkflowRun?.status === 'RUNNING_MIGRATIONS' || + currentWorkflowRun?.status === 'CREATING_PROJECT' + + const addWorkflowRun = useCallback( + (workflowRunId: string) => { + router.push({ + pathname: router.pathname, + query: { ...router.query, workflow_run_id: workflowRunId }, + }) + }, + [router] + ) + + const clearWorkflowRun = useCallback(() => { + const { workflow_run_id, ...queryWithoutWorkflowId } = router.query + router.push({ + pathname: router.pathname, + query: queryWithoutWorkflowId, + }) + }, [router]) + + const { mutate: pushBranch, isLoading: isPushing } = useBranchPushMutation({ + onSuccess: (data) => { + toast.success('Branch update initiated!') + if (data?.workflow_run_id) { + addWorkflowRun(data.workflow_run_id) + } + }, + onError: (error) => { + toast.error(`Failed to update branch: ${error.message}`) + }, + }) + + const { mutate: mergeBranch, isLoading: isMerging } = useBranchMergeMutation({ + onSuccess: (data) => { + setIsSubmitting(false) + if (data.workflowRunId) { + toast.success('Branch merge initiated!') + addWorkflowRun(data.workflowRunId) + } else { + toast.info('No changes to merge') + } + }, + onError: (error) => { + setIsSubmitting(false) + toast.error(`Failed to merge branch: ${error.message}`) + }, + }) + + const handlePush = () => { + if (!currentBranch?.id || !parentProjectRef) return + pushBranch({ + id: currentBranch.id, + projectRef: parentProjectRef, + }) + } + + const handleMerge = () => { + if (!currentBranch?.id || !parentProjectRef || !ref) return + setIsSubmitting(true) + mergeBranch({ + id: currentBranch.id, + branchProjectRef: ref, + baseProjectRef: parentProjectRef, + migration_version: undefined, + }) + } + + const handleShowConfirmDialog = () => { + setShowConfirmDialog(true) + } + + const handleConfirmMerge = () => { + setShowConfirmDialog(false) + handleMerge() + } + + const handleCancelMerge = () => { + setShowConfirmDialog(false) + } + + const breadcrumbs = useMemo( + () => [ + { + label: 'Branches', + href: `/project/${parentProjectRef}/branches`, + }, + ], + [parentProjectRef] + ) + + const currentTab = (router.query.tab as string) || 'database' + + const navigationItems = useMemo(() => { + const buildHref = (tab: string) => { + const query: Record = { tab } + if (currentWorkflowRunId) query.workflow_run_id = currentWorkflowRunId + const qs = new URLSearchParams(query).toString() + return `/project/[ref]/merge?${qs}` + } + + return [ + { + label: 'Database', + href: buildHref('database'), + active: currentTab === 'database', + }, + { + label: 'Edge Functions', + href: buildHref('edge-functions'), + active: currentTab === 'edge-functions', + }, + ] + }, [currentWorkflowRunId, currentTab]) + + if (!gitlessBranching) { + return ( + + +
+ +

+ The branch merge feature is currently in development and will be available soon. +

+
+ +
+
+
+
+
+ ) + } + + // If not on a preview branch or branch info unavailable, show notice + if (!isBranch || !currentBranch) { + return ( + + +
+ +

+ This page is only available for preview branches. +

+
+
+
+
+ ) + } + + const isMergeDisabled = + !combinedHasChanges || isCombinedDiffLoading || isBranchOutOfDateOverall || isWorkflowRunning + + // Update primary actions - remove push button if branch is out of date (it will be in the notice) + const primaryActions = ( +
+ {isMergeDisabled ? ( + } + > + Merge branch + + ) : ( + + )} +
+ ) + + const pageTitle = () => ( + + Merge{' '} + + + + {currentBranch.name} + + {' '} + into{' '} + + + + {mainBranch?.name || 'main'} + + + + ) + + const pageSubtitle = () => { + if (!currentBranch?.created_at) return 'Branch information unavailable' + + const createdTime = dayjs(currentBranch.created_at).fromNow() + return `Branch created ${createdTime}` + } + + return ( + +
+ + {isBranchOutOfDateOverall && !currentWorkflowRunId ? ( + + ) : currentWorkflowRunId ? ( +
+ + ) : undefined + } + overrideAction={ + hasCurrentWorkflowFailed ? ( + + ) : undefined + } + /> +
+ ) : null} + + + {navigationItems.map((item) => { + const isActive = + item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href + return ( + + + {item.label} + + + ) + })} + +
+
+ + {currentTab === 'database' ? ( + + ) : ( + + )} + + + +
+ ) +} + +MergePage.getLayout = (page) => ( + + {page} + +) + +export default MergePage diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfdb226370..c6eb00d36a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31190,7 +31190,7 @@ snapshots: flags@4.0.1(@opentelemetry/api@1.9.0)(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@edge-runtime/cookies': 5.0.2 - jose: 5.2.1 + jose: 5.9.6 optionalDependencies: '@opentelemetry/api': 1.9.0 next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)