Refactor merge page into smaller components + add admonition if branch cannot be merged via dashboard (#45515)

## Context

Main fix is actually just the disabled check on the "Merge branch"
button

We're preventing merging of branches via the dashboard if the project
has GH integration + "Deploy to production" enabled (the latter we're
checking via if the `git_branch` property from the main branch exists,
from the GET branches API endpoint)

However, the `git_branch` property persists even after disabling the GH
integration (by design), and hence we were incorrectly disabling the
"Merge branch" button if the user removed the GH integration. Hence the
fix is to also check if the project has an active GH integration

## Other changes
- Refactored the merge page into smaller components
- Added an admonition to callout the "Deploy to production" + what steps
to take (otherwise it's not clear at all what to do in this scenario)
<img width="1451" height="524" alt="image"
src="https://github.com/user-attachments/assets/9df7d432-b220-4f71-b8f4-5ed0fd426afc"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Redesigned merge request page interface with dedicated components for
title, subtitle, and merge actions, improving user clarity and
experience.
* Added GitHub production deployment restriction messaging—users cannot
proceed with merge requests when this integration deployment method is
enabled.

* **Refactor**
* Enhanced GitHub integration connection query patterns and overall code
organization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim
2026-05-05 16:32:37 +08:00
committed by GitHub
parent 4ac1231c0e
commit e98a302428
4 changed files with 306 additions and 148 deletions

View File

@@ -0,0 +1,248 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import dayjs from 'dayjs'
import { GitBranchIcon, GitMerge, MoreVertical, Shield } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
import { useIsPgDeltaDiffEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { ReviewWithAI } from '../BranchManagement/ReviewWithAI'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useProjectGitHubConnectionQuery } from '@/data/integrations/github-connections-query'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
import { useBranchMergeDiff } from '@/hooks/branches/useBranchMergeDiff'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const MergeTitle = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const pgDeltaDiffEnabled = useIsPgDeltaDiffEnabled()
const parentProjectRef = project?.parent_project_ref
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)
return (
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-2">
<span>Merge</span>
<code className="flex items-center text-code-inline gap-x-1.5 px-2 py-1 border border-border">
<GitBranchIcon strokeWidth={1.5} size={14} className="text-foreground-lighter" />
{currentBranch?.name}
</code>
<span>into</span>
<Link href={`/project/${mainBranch?.project_ref}`} className="font-mono inline-flex gap-4">
<code className="flex items-center text-code-inline font-mono gap-x-1.5 px-2 py-1 border border-border">
<Shield strokeWidth={1.5} size={14} className="text-warning" />
{mainBranch?.name || 'main'}
</code>
</Link>
</div>
{pgDeltaDiffEnabled && (
<FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF} />
)}
</div>
)
}
export const MergeSubtitle = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const parentProjectRef = project?.parent_project_ref
const { data: branches } = useBranchesQuery(
{ projectRef: parentProjectRef },
{
refetchOnMount: 'always',
refetchOnWindowFocus: true,
staleTime: 0,
}
)
const currentBranch = branches?.find((branch) => branch.project_ref === ref)
const subtitle = useMemo(() => {
if (!currentBranch?.created_at) return 'Branch information unavailable'
if (!currentBranch?.review_requested_at) {
return 'Not ready for review'
}
const reviewRequestedTime = dayjs(currentBranch.review_requested_at).fromNow()
return (
<>
Request opened{' '}
<TimestampInfo
className="text-sm"
utcTimestamp={currentBranch.review_requested_at}
label={reviewRequestedTime}
/>
</>
)
}, [currentBranch?.created_at, currentBranch?.review_requested_at])
return <p className="text-foreground-lighter text-sm">{subtitle}</p>
}
export const MergeActions = ({
isWorkflowRunning,
isSubmitting,
onSelectMerge,
}: {
isWorkflowRunning: boolean
isSubmitting: boolean
onSelectMerge: () => void
}) => {
const router = useRouter()
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrg } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: updateBranch, isPending: isUpdating } = useBranchUpdateMutation({
onError: (error) => {
toast.error(`Failed to update branch: ${error.message}`)
},
})
const parentProjectRef = project?.parent_project_ref
const { data: parentProject } = useProjectDetailQuery({ ref: parentProjectRef })
const { data: ghConnection } = useProjectGitHubConnectionQuery({ ref: 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,
isBranchOutOfDateOverall,
isLoading: isCombinedDiffLoading,
hasChanges: combinedHasChanges,
} = useBranchMergeDiff({
currentBranchRef: ref,
parentProjectRef,
currentBranchConnectionString: project?.connectionString || undefined,
parentBranchConnectionString: parentProject?.connectionString || undefined,
currentBranchCreatedAt: currentBranch?.created_at,
})
const isMergeDisabled =
!combinedHasChanges ||
isCombinedDiffLoading ||
isBranchOutOfDateOverall ||
isWorkflowRunning ||
(!!ghConnection && Boolean(mainBranch?.git_branch))
return (
<div className="flex items-end gap-2">
<ReviewWithAI
currentBranch={currentBranch}
mainBranch={mainBranch}
parentProjectRef={parentProjectRef}
diffContent={diffContent}
disabled={!currentBranch || !mainBranch || isCombinedDiffLoading}
/>
{isMergeDisabled ? (
<ButtonTooltip
tooltip={{
content: {
side: 'bottom',
text: !combinedHasChanges
? 'No changes to merge'
: isWorkflowRunning
? 'Workflow is currently running'
: !!ghConnection && Boolean(mainBranch?.git_branch)
? 'Deploy to production from GitHub is enabled'
: 'Unable to merge at this time',
},
}}
type="primary"
loading={isSubmitting}
disabled={isMergeDisabled}
onClick={onSelectMerge}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</ButtonTooltip>
) : (
<Button
type="primary"
loading={isSubmitting}
onClick={onSelectMerge}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" loading={isUpdating} className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
if (!ref || !parentProjectRef) return
updateBranch(
{
branchRef: ref,
projectRef: parentProjectRef,
requestReview: false,
},
{
onSuccess: () => {
toast.success('Successfully closed merge request')
router.push(`/project/${project?.ref}/branches?tab=prs`)
sendEvent({
action: 'branch_close_merge_request_button_clicked',
groups: {
project: parentProjectRef ?? 'Unknown',
organization: selectedOrg?.slug ?? 'Unknown',
},
})
},
}
)
}}
>
Close this merge request
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -47,6 +47,7 @@ export const IntegrationSettings = () => {
</Admonition>
</ScaffoldContainer>
)}
<GitHubSection />
{showVercelIntegration && (

View File

@@ -1,7 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { integrationKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type GitHubConnectionsVariables = {
@@ -47,3 +49,17 @@ export const useGitHubConnectionsQuery = <TData = GitHubConnectionsData>(
...options,
})
}
export const useProjectGitHubConnectionQuery = ({ ref }: { ref?: string }) => {
const { data: organization } = useSelectedOrganizationQuery()
const { data: connections, ...props } = useGitHubConnectionsQuery(
{ organizationId: organization?.id },
{ enabled: !!ref && !!organization?.id }
)
const existingConnection = useMemo(
() => connections?.find((c) => c.project.ref === ref),
[connections, ref]
)
return { data: existingConnection, ...props }
}

View File

@@ -1,41 +1,35 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import dayjs from 'dayjs'
import { AlertTriangle, GitBranchIcon, GitMerge, MoreVertical, Shield, X } from 'lucide-react'
import { useParams } from 'common'
import { AlertTriangle, GitBranchIcon, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Badge,
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
NavMenu,
NavMenuItem,
} from 'ui'
import { Button, cn, NavMenu, NavMenuItem } from 'ui'
import { Admonition } from 'ui-patterns'
import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
import { useIsPgDeltaDiffEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
MergeActions,
MergeSubtitle,
MergeTitle,
} from '@/components/interfaces/Branching/MergeRequest'
import { DatabaseDiffPanel } from '@/components/interfaces/BranchManagement/DatabaseDiffPanel'
import { EdgeFunctionsDiffPanel } from '@/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
import { OutOfDateNotice } from '@/components/interfaces/BranchManagement/OutOfDateNotice'
import { ReviewWithAI } from '@/components/interfaces/BranchManagement/ReviewWithAI'
import { WorkflowLogsCard } from '@/components/interfaces/BranchManagement/WorkflowLogsCard'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { PageLayout } from '@/components/layouts/PageLayout/PageLayout'
import { ProjectLayoutWithAuth } from '@/components/layouts/ProjectLayout'
import { ScaffoldContainer } from '@/components/layouts/Scaffold'
import ProductEmptyState from '@/components/to-be-cleaned/ProductEmptyState'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
import { InlineLink } from '@/components/ui/InlineLink'
import { useBranchDeleteMutation } from '@/data/branches/branch-delete-mutation'
import { useBranchMergeMutation } from '@/data/branches/branch-merge-mutation'
import { useBranchPushMutation } from '@/data/branches/branch-push-mutation'
import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useProjectGitHubConnectionQuery } from '@/data/integrations/github-connections-query'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
import { useBranchMergeDiff } from '@/hooks/branches/useBranchMergeDiff'
@@ -59,6 +53,7 @@ const MergePage: NextPageWithLayout = () => {
const parentProjectRef = project?.parent_project_ref
const { data: parentProject } = useProjectDetailQuery({ ref: parentProjectRef })
const { data: ghConnection } = useProjectGitHubConnectionQuery({ ref: parentProjectRef })
const { data: branches } = useBranchesQuery(
{ projectRef: parentProjectRef },
@@ -87,8 +82,6 @@ const MergePage: NextPageWithLayout = () => {
isBranchOutOfDateOverall,
missingMigrationsCount,
modifiedFunctionsCount,
isLoading: isCombinedDiffLoading,
hasChanges: combinedHasChanges,
} = useBranchMergeDiff({
currentBranchRef: ref,
parentProjectRef,
@@ -97,7 +90,7 @@ const MergePage: NextPageWithLayout = () => {
currentBranchCreatedAt: currentBranch?.created_at,
})
const { mutate: updateBranch, isPending: isUpdating } = useBranchUpdateMutation({
const { mutate: updateBranch } = useBranchUpdateMutation({
onError: (error) => {
toast.error(`Failed to update branch: ${error.message}`)
},
@@ -359,143 +352,42 @@ const MergePage: NextPageWithLayout = () => {
)
}
const isMergeDisabled =
!combinedHasChanges ||
isCombinedDiffLoading ||
isBranchOutOfDateOverall ||
isWorkflowRunning ||
Boolean(mainBranch?.git_branch)
const primaryActions = (
<div className="flex items-end gap-2">
<ReviewWithAI
currentBranch={currentBranch}
mainBranch={mainBranch}
parentProjectRef={parentProjectRef}
diffContent={diffContent}
disabled={!currentBranch || !mainBranch || isCombinedDiffLoading}
/>
{isMergeDisabled ? (
<ButtonTooltip
tooltip={{
content: {
text: !combinedHasChanges
? 'No changes to merge'
: isWorkflowRunning
? 'Workflow is currently running'
: Boolean(mainBranch?.git_branch)
? 'Deploy to production from GitHub is enabled'
: 'Unable to merge at this time',
},
}}
type="primary"
loading={isMerging || isSubmitting}
disabled={isMergeDisabled}
onClick={() => setShowConfirmDialog(true)}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</ButtonTooltip>
) : (
<Button
type="primary"
loading={isMerging || isSubmitting}
onClick={() => setShowConfirmDialog(true)}
icon={<GitMerge size={16} strokeWidth={1.5} className="text-brand" />}
>
Merge branch
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" loading={isUpdating} className="px-1.5" icon={<MoreVertical />} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
if (!ref || !parentProjectRef) return
updateBranch(
{
branchRef: ref,
projectRef: parentProjectRef,
requestReview: false,
},
{
onSuccess: () => {
toast.success('Successfully closed merge request')
router.push(`/project/${project?.ref}/branches?tab=prs`)
sendEvent({
action: 'branch_close_merge_request_button_clicked',
groups: {
project: parentProjectRef ?? 'Unknown',
organization: selectedOrg?.slug ?? 'Unknown',
},
})
},
}
)
}}
>
Close this merge request
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
const pageTitle = () => (
<div className="flex items-center gap-x-4">
<span>Merge</span>
<Link href={`/project/${ref}/editor`}>
<Badge className="font-mono text-sm gap-1 px-2">
<GitBranchIcon strokeWidth={1.5} size={16} className="text-foreground-muted" />
{currentBranch.name}
</Badge>
</Link>
<span>into</span>
<Link
href={`/project/${mainBranch?.project_ref}/editor`}
className="font-mono inline-flex gap-4"
>
<Badge className="font-mono text-sm gap-1 px-2">
<Shield strokeWidth={1.5} size={16} className="text-warning" />
{mainBranch?.name || 'main'}
</Badge>
</Link>
{pgDeltaDiffEnabled && (
<FeaturePreviewBadge featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF} />
)}
</div>
)
const pageSubtitle = () => {
if (!currentBranch?.created_at) return 'Branch information unavailable'
if (!currentBranch?.review_requested_at) {
return 'Not ready for review'
}
const reviewRequestedTime = dayjs(currentBranch.review_requested_at).fromNow()
return `Request opened ${reviewRequestedTime}`
}
const hasGHProductionDeployEnabled = !!ghConnection && Boolean(mainBranch?.git_branch)
return (
<PageLayout
title={pageTitle()}
subtitle={pageSubtitle()}
title={<MergeTitle />}
subtitle={<MergeSubtitle />}
breadcrumbs={breadcrumbs}
primaryActions={primaryActions}
primaryActions={
<MergeActions
isWorkflowRunning={isWorkflowRunning}
isSubmitting={isMerging || isSubmitting}
onSelectMerge={() => setShowConfirmDialog(true)}
/>
}
size="full"
className="h-full border-b-0 pb-0"
>
<div className="border-b">
<ScaffoldContainer size="full">
{isBranchOutOfDateOverall && !currentWorkflowRunId ? (
{hasGHProductionDeployEnabled ? (
<Admonition
type="default"
title="Branch cannot be merged as deploy to production from GitHub is enabled"
className="my-4"
>
<p className="text-balance">
Branches should be managed via GitHub to prevent drifts in migrations from your
repository's state. You may either move your schema changes to a GitHub pull
request, or disable "Deploy to production" in the{' '}
<InlineLink href={`/project/${parentProjectRef}/settings/integrations`}>
GitHub integration settings
</InlineLink>
.
</p>
</Admonition>
) : isBranchOutOfDateOverall && !currentWorkflowRunId ? (
<OutOfDateNotice
isBranchOutOfDateMigrations={isBranchOutOfDateMigrations}
missingMigrationsCount={missingMigrationsCount}
@@ -578,6 +470,7 @@ const MergePage: NextPageWithLayout = () => {
</NavMenu>
</ScaffoldContainer>
</div>
<ScaffoldContainer size="full" className="flex min-h-0 flex-1 flex-col pt-6 pb-12">
<div className="flex min-h-0 flex-1 flex-col">
{currentTab === 'database' ? (