merge.tsx514 lines · main
1// @ts-nocheck
2import { useParams } from 'common'
3import { AlertTriangle, GitBranchIcon, X } from 'lucide-react'
4import Link from 'next/link'
5import { useRouter } from 'next/router'
6import { useCallback, useEffect, useMemo, useState } from 'react'
7import { toast } from 'sonner'
8import { Button, cn, NavMenu, NavMenuItem } from 'ui'
9import { Admonition } from 'ui-patterns'
10import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
11
12import { useIsPgDeltaDiffEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
13import {
14 MergeActions,
15 MergeSubtitle,
16 MergeTitle,
17} from '@/components/interfaces/Branching/MergeRequest'
18import { DatabaseDiffPanel } from '@/components/interfaces/BranchManagement/DatabaseDiffPanel'
19import { EdgeFunctionsDiffPanel } from '@/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
20import { OutOfDateNotice } from '@/components/interfaces/BranchManagement/OutOfDateNotice'
21import { WorkflowLogsCard } from '@/components/interfaces/BranchManagement/WorkflowLogsCard'
22import { DefaultLayout } from '@/components/layouts/DefaultLayout'
23import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
24import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout'
25import { ScaffoldContainer } from '@/components/layouts/Scaffold'
26import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
27import { InlineLink } from '@/components/ui/InlineLink'
28import { useBranchDeleteMutation } from '@/data/branches/branch-delete-mutation'
29import { useBranchMergeMutation } from '@/data/branches/branch-merge-mutation'
30import { useBranchPushMutation } from '@/data/branches/branch-push-mutation'
31import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
32import { useBranchesQuery } from '@/data/branches/branches-query'
33import { useProjectGitHubConnectionQuery } from '@/data/integrations/github-connections-query'
34import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
35import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
36import { useBranchMergeDiff } from '@/hooks/branches/useBranchMergeDiff'
37import { useWorkflowManagement } from '@/hooks/branches/useWorkflowManagement'
38import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
39import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
40import type { NextPageWithLayout } from '@/types'
41
42const MergePage: NextPageWithLayout = () => {
43 const router = useRouter()
44 const { ref, workflow_run_id: currentWorkflowRunId } = useParams()
45 const { data: project } = useSelectedProjectQuery()
46 const { data: selectedOrg } = useSelectedOrganizationQuery()
47 const pgDeltaDiffEnabled = useIsPgDeltaDiffEnabled()
48
49 const [isSubmitting, setIsSubmitting] = useState(false)
50 const [workflowFinalStatus, setWorkflowFinalStatus] = useState<'SUCCESS' | 'FAILED' | null>(null)
51 const [showConfirmDialog, setShowConfirmDialog] = useState(false)
52
53 const isBranch = project?.parent_project_ref !== undefined
54 const parentProjectRef = project?.parent_project_ref
55
56 const { data: parentProject } = useProjectDetailQuery({ ref: parentProjectRef })
57 const { data: ghConnection } = useProjectGitHubConnectionQuery({ ref: parentProjectRef })
58
59 const { data: branches } = useBranchesQuery(
60 { projectRef: parentProjectRef },
61 {
62 refetchOnMount: 'always',
63 refetchOnWindowFocus: true,
64 staleTime: 0,
65 }
66 )
67 const currentBranch = branches?.find((branch) => branch.project_ref === ref)
68 const mainBranch = branches?.find((branch) => branch.is_default)
69
70 const {
71 diffContent,
72 isDatabaseDiffLoading,
73 isDatabaseDiffRefetching,
74 databaseDiffError: diffError,
75 refetchDatabaseDiff: refetchDiff,
76 edgeFunctionsDiff,
77 isBranchOutOfDateMigrations,
78 hasEdgeFunctionModifications,
79 missingFunctionsCount,
80 hasMissingFunctions,
81 outOfDateFunctionsCount,
82 hasOutOfDateFunctions,
83 isBranchOutOfDateOverall,
84 missingMigrationsCount,
85 modifiedFunctionsCount,
86 } = useBranchMergeDiff({
87 currentBranchRef: ref,
88 parentProjectRef,
89 currentBranchConnectionString: project?.connectionString || undefined,
90 parentBranchConnectionString: parentProject?.connectionString || undefined,
91 currentBranchCreatedAt: currentBranch?.created_at,
92 })
93
94 const { mutate: updateBranch } = useBranchUpdateMutation({
95 onError: (error) => {
96 toast.error(`Failed to update branch: ${error.message}`)
97 },
98 })
99
100 const clearDiffsOptimistically = edgeFunctionsDiff.clearDiffsOptimistically
101
102 const handleCurrentBranchWorkflowComplete = useCallback(
103 (status: 'SUCCESS' | 'FAILED') => {
104 setWorkflowFinalStatus(status)
105 refetchDiff()
106 clearDiffsOptimistically()
107 },
108 [refetchDiff, clearDiffsOptimistically]
109 )
110
111 const handleParentBranchWorkflowComplete = useCallback(
112 (status: 'SUCCESS' | 'FAILED') => {
113 setWorkflowFinalStatus(status)
114 refetchDiff()
115 clearDiffsOptimistically()
116 if (ref && parentProjectRef && currentBranch?.review_requested_at) {
117 updateBranch(
118 {
119 branchRef: ref,
120 projectRef: parentProjectRef,
121 requestReview: false,
122 },
123 {
124 onSuccess: () => toast.success('Branch updated successfully'),
125 }
126 )
127 }
128 },
129 [
130 refetchDiff,
131 clearDiffsOptimistically,
132 parentProjectRef,
133 ref,
134 updateBranch,
135 currentBranch?.review_requested_at,
136 ]
137 )
138
139 const { run: currentBranchWorkflow, logs: currentBranchLogs } = useWorkflowManagement({
140 workflowRunId: currentWorkflowRunId,
141 projectRef: ref,
142 onWorkflowComplete: handleCurrentBranchWorkflowComplete,
143 })
144
145 const { run: parentBranchWorkflow, logs: parentBranchLogs } = useWorkflowManagement({
146 workflowRunId: currentWorkflowRunId,
147 projectRef: parentProjectRef,
148 onWorkflowComplete: handleParentBranchWorkflowComplete,
149 })
150
151 const currentWorkflowRun = currentBranchWorkflow || parentBranchWorkflow
152 const workflowRunLogs = currentBranchLogs || parentBranchLogs
153
154 const hasCurrentWorkflowFailed = workflowFinalStatus === 'FAILED'
155 const hasCurrentWorkflowCompleted = workflowFinalStatus === 'SUCCESS'
156 const isWorkflowRunning = currentWorkflowRun?.status === 'RUNNING'
157
158 const addWorkflowRun = useCallback(
159 (workflowRunId: string) => {
160 router.push({
161 pathname: router.pathname,
162 query: { ...router.query, workflow_run_id: workflowRunId },
163 })
164 },
165 [router]
166 )
167
168 const clearWorkflowRun = useCallback(() => {
169 const { workflow_run_id, ...queryWithoutWorkflowId } = router.query
170 router.push({
171 pathname: router.pathname,
172 query: queryWithoutWorkflowId,
173 })
174 }, [router])
175
176 const { mutate: pushBranch, isPending: isPushing } = useBranchPushMutation({
177 onSuccess: (data) => {
178 toast.success('Branch update initiated!')
179 if (data?.workflow_run_id) {
180 addWorkflowRun(data.workflow_run_id)
181 }
182
183 // Track branch update
184 sendEvent({
185 action: 'branch_updated',
186 properties: {
187 source: 'merge_page',
188 },
189 groups: {
190 project: parentProjectRef ?? 'Unknown',
191 organization: selectedOrg?.slug ?? 'Unknown',
192 },
193 })
194 },
195 onError: (error) => {
196 toast.error(`Failed to update branch: ${error.message}`)
197 },
198 })
199
200 const { mutate: sendEvent } = useSendEventMutation()
201
202 const { mutate: mergeBranch, isPending: isMerging } = useBranchMergeMutation({
203 onSuccess: (data) => {
204 setIsSubmitting(false)
205 if (data.workflowRunId) {
206 toast.success('Branch merge initiated!')
207 addWorkflowRun(data.workflowRunId)
208
209 // Track successful merge
210 sendEvent({
211 action: 'branch_merge_completed',
212 properties: {
213 branchType: currentBranch?.persistent ? 'persistent' : 'preview',
214 },
215 groups: {
216 project: parentProjectRef ?? 'Unknown',
217 organization: selectedOrg?.slug ?? 'Unknown',
218 },
219 })
220 } else {
221 toast.info('No changes to merge')
222 }
223 },
224 onError: (error) => {
225 setIsSubmitting(false)
226 toast.error(`Failed to merge branch: ${error.message}`)
227
228 // Track failed merge
229 sendEvent({
230 action: 'branch_merge_failed',
231 properties: {
232 branchType: currentBranch?.persistent ? 'persistent' : 'preview',
233 error: error.message,
234 },
235 groups: {
236 project: parentProjectRef ?? 'Unknown',
237 organization: selectedOrg?.slug ?? 'Unknown',
238 },
239 })
240 },
241 })
242
243 const { mutate: deleteBranch, isPending: isDeleting } = useBranchDeleteMutation({
244 onSuccess: () => {
245 toast.success('Branch closed successfully')
246 router.push(`/project/${parentProjectRef}/branches`)
247 // Track delete button click
248 sendEvent({
249 action: 'branch_delete_button_clicked',
250 properties: {
251 origin: 'merge_page',
252 },
253 groups: {
254 project: parentProjectRef ?? 'Unknown',
255 organization: selectedOrg?.slug ?? 'Unknown',
256 },
257 })
258 },
259 onError: (error) => {
260 toast.error(`Failed to close branch: ${error.message}`)
261 },
262 })
263
264 const handlePush = () => {
265 if (!ref || !parentProjectRef) return
266 pushBranch({
267 branchRef: ref,
268 projectRef: parentProjectRef,
269 })
270 }
271
272 const handleCloseBranch = () => {
273 if (!ref || !parentProjectRef) return
274 deleteBranch({
275 branchRef: ref,
276 projectRef: parentProjectRef,
277 })
278 }
279
280 const handleMerge = () => {
281 if (!ref || !parentProjectRef) return
282 setIsSubmitting(true)
283
284 // Track merge attempt
285 sendEvent({
286 action: 'branch_merge_submitted',
287 groups: {
288 project: parentProjectRef ?? 'Unknown',
289 organization: selectedOrg?.slug ?? 'Unknown',
290 },
291 })
292
293 mergeBranch({
294 branchProjectRef: ref,
295 baseProjectRef: parentProjectRef,
296 migration_version: undefined,
297 pgdelta: pgDeltaDiffEnabled,
298 })
299 }
300
301 const breadcrumbs = useMemo(
302 () => [
303 {
304 label: 'Merge requests',
305 href: `/project/${project?.ref}/branches/merge-requests`,
306 },
307 ],
308 [project?.ref]
309 )
310
311 const currentTab = (router.query.tab as string) || 'database'
312
313 const navigationItems = useMemo(() => {
314 const buildHref = (tab: string) => {
315 const query: Record<string, string> = { tab }
316 if (currentWorkflowRunId) query.workflow_run_id = currentWorkflowRunId
317 const qs = new URLSearchParams(query).toString()
318 return `/project/[ref]/merge?${qs}`
319 }
320
321 return [
322 {
323 label: 'Database',
324 href: buildHref('database'),
325 active: currentTab === 'database',
326 },
327 {
328 label: 'Edge Functions',
329 href: buildHref('edge-functions'),
330 active: currentTab === 'edge-functions',
331 },
332 ]
333 }, [currentWorkflowRunId, currentTab])
334
335 useEffect(() => {
336 setWorkflowFinalStatus(null)
337 }, [currentWorkflowRunId])
338
339 // If not on a preview branch or branch info unavailable, show notice
340 if (!isBranch || !currentBranch) {
341 return (
342 <PageLayout>
343 <ScaffoldContainer size="full">
344 <div className="flex items-center flex-col justify-center w-full py-16">
345 <ProductEmptyState title="Merge Request">
346 <p className="text-sm text-foreground-light">
347 You can only review changes when on a preview branch
348 </p>
349 </ProductEmptyState>
350 </div>
351 </ScaffoldContainer>
352 </PageLayout>
353 )
354 }
355
356 const hasGHProductionDeployEnabled = !!ghConnection && Boolean(mainBranch?.git_branch)
357
358 return (
359 <PageLayout
360 title={<MergeTitle />}
361 subtitle={<MergeSubtitle />}
362 breadcrumbs={breadcrumbs}
363 primaryActions={
364 <MergeActions
365 isWorkflowRunning={isWorkflowRunning}
366 isSubmitting={isMerging || isSubmitting}
367 onSelectMerge={() => setShowConfirmDialog(true)}
368 />
369 }
370 size="full"
371 className="h-full border-b-0 pb-0"
372 >
373 <div className="border-b">
374 <ScaffoldContainer size="full">
375 {hasGHProductionDeployEnabled ? (
376 <Admonition
377 type="default"
378 title="Branch cannot be merged as deploy to production from GitHub is enabled"
379 className="my-4"
380 >
381 <p className="text-balance">
382 Branches should be managed via GitHub to prevent drifts in migrations from your
383 repository's state. You may either move your schema changes to a GitHub pull
384 request, or disable "Deploy to production" in the{' '}
385 <InlineLink href={`/project/${parentProjectRef}/settings/integrations`}>
386 GitHub integration settings
387 </InlineLink>
388 .
389 </p>
390 </Admonition>
391 ) : isBranchOutOfDateOverall && !currentWorkflowRunId ? (
392 <OutOfDateNotice
393 isBranchOutOfDateMigrations={isBranchOutOfDateMigrations}
394 missingMigrationsCount={missingMigrationsCount}
395 hasMissingFunctions={hasMissingFunctions}
396 missingFunctionsCount={missingFunctionsCount}
397 hasOutOfDateFunctions={hasOutOfDateFunctions}
398 outOfDateFunctionsCount={outOfDateFunctionsCount}
399 hasEdgeFunctionModifications={hasEdgeFunctionModifications}
400 modifiedFunctionsCount={modifiedFunctionsCount}
401 isPushing={isPushing}
402 onPush={handlePush}
403 />
404 ) : currentWorkflowRunId ? (
405 <div className="my-6">
406 <WorkflowLogsCard
407 workflowRun={currentWorkflowRun}
408 logs={workflowRunLogs}
409 isLoading={!workflowRunLogs && !!currentWorkflowRunId}
410 onClose={clearWorkflowRun}
411 overrideTitle={hasCurrentWorkflowFailed ? 'Workflow failed' : undefined}
412 overrideDescription={
413 hasCurrentWorkflowFailed
414 ? 'Consider creating a fresh branch from the latest production branch to resolve potential conflicts.'
415 : undefined
416 }
417 overrideIcon={
418 hasCurrentWorkflowFailed ? (
419 <AlertTriangle
420 size={16}
421 strokeWidth={1.5}
422 className="text-destructive shrink-0"
423 />
424 ) : undefined
425 }
426 overrideAction={
427 hasCurrentWorkflowFailed ? (
428 <Button
429 type="default"
430 asChild
431 icon={<GitBranchIcon size={16} strokeWidth={1.5} />}
432 className="shrink-0"
433 >
434 <Link href={`/project/${parentProjectRef}/branches`}>Create new branch</Link>
435 </Button>
436 ) : hasCurrentWorkflowCompleted ? (
437 <Button
438 type="default"
439 onClick={handleCloseBranch}
440 loading={isDeleting}
441 icon={<X size={16} strokeWidth={1.5} />}
442 className="shrink-0"
443 >
444 Close branch
445 </Button>
446 ) : undefined
447 }
448 />
449 </div>
450 ) : null}
451
452 <NavMenu className="mt-4 border-none">
453 {navigationItems.map((item) => {
454 const isActive =
455 item.active !== undefined ? item.active : router.asPath.split('?')[0] === item.href
456 return (
457 <NavMenuItem key={item.label} active={isActive}>
458 <Link
459 href={
460 item.href.includes('[ref]') && !!ref
461 ? item.href.replace('[ref]', ref)
462 : item.href
463 }
464 className={cn('inline-flex items-center gap-2', isActive && 'text-foreground')}
465 >
466 {item.label}
467 </Link>
468 </NavMenuItem>
469 )
470 })}
471 </NavMenu>
472 </ScaffoldContainer>
473 </div>
474
475 <ScaffoldContainer size="full" className="flex min-h-0 flex-1 flex-col pt-6 pb-12">
476 <div className="flex min-h-0 flex-1 flex-col">
477 {currentTab === 'database' ? (
478 <DatabaseDiffPanel
479 diffContent={diffContent}
480 isLoading={isDatabaseDiffLoading || isDatabaseDiffRefetching}
481 error={diffError}
482 showRefreshButton={true}
483 currentBranchRef={ref}
484 />
485 ) : (
486 <EdgeFunctionsDiffPanel diffResults={edgeFunctionsDiff} currentBranchRef={ref} />
487 )}
488 </div>
489 </ScaffoldContainer>
490
491 <ConfirmationModal
492 visible={showConfirmDialog}
493 title="Confirm Branch Merge"
494 description={`Are you sure you want to merge "${currentBranch?.name}" into "${mainBranch?.name || 'main'}"? This action cannot be undone.`}
495 confirmLabel="Merge branch"
496 confirmLabelLoading="Merging..."
497 onConfirm={() => {
498 setShowConfirmDialog(false)
499 handleMerge()
500 }}
501 onCancel={() => setShowConfirmDialog(false)}
502 loading={isMerging || isSubmitting}
503 />
504 </PageLayout>
505 )
506}
507
508MergePage.getLayout = (page) => (
509 <DefaultLayout>
510 <ProjectLayoutWithAuth>{page}</ProjectLayoutWithAuth>
511 </DefaultLayout>
512)
513
514export default MergePage