import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, AutoComplete, Button, Card, Descriptions, Form, Input, Modal, Popconfirm, Radio, Select, Space, Switch, Table, Tag, Typography, message, } from 'antd'; import { DeleteOutlined, EditOutlined, HistoryOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { useFlowContext } from '@nocobase/flow-engine'; import { useT } from '../locale'; type SubtreePolicy = 'fastForward' | 'replace' | 'merge'; interface RepositoryItem { id: number; name: string; } interface SubtreeConfig { id: number; name: string; repositoryId: number; sourceBranch: string; sourcePrefix: string; sourcePrefixes?: string[]; targetBranch: string; remoteName: string; defaultPolicy: SubtreePolicy; pushAfterRun: boolean; enabled: boolean; lastStatus?: string; lastRunAt?: string; } type ConfigFormValues = Omit; type SearchOption = { label?: React.ReactNode; value?: string | number }; function optionText(option?: SearchOption): string { return String(option?.label ?? option?.value ?? '').toLocaleLowerCase(); } function filterOption(input: string, option?: SearchOption): boolean { return optionText(option).includes(input.trim().toLocaleLowerCase()); } function filterSort(optionA: SearchOption, optionB: SearchOption, info: { searchValue: string }): number { const search = info.searchValue.trim().toLocaleLowerCase(); const a = optionText(optionA); const b = optionText(optionB); const aStarts = a.startsWith(search); const bStarts = b.startsWith(search); if (aStarts !== bStarts) return aStarts ? -1 : 1; return a.localeCompare(b); } interface PreviewResult { sourceSha: string; splitSha: string; targetExists: boolean; targetSha: string | null; relationship: 'target-missing' | 'already-up-to-date' | 'fast-forward' | 'diverged'; recommendedPolicy: SubtreePolicy; } interface OptionsResult { branches: string[]; folders: string[]; } interface SubtreeRun { id: number; policy: SubtreePolicy; executionMode: 'app'; status: string; sourceSha?: string; splitSha?: string; targetBeforeSha?: string; targetAfterSha?: string; startedAt: string; finishedAt?: string; error?: string; } function errorMessage(error: unknown): string { if (error && typeof error === 'object') { const response = 'response' in error ? error.response : undefined; if (response && typeof response === 'object' && 'data' in response) { const data = response.data; if (data && typeof data === 'object' && 'errors' in data && Array.isArray(data.errors)) { const first = data.errors[0]; if (first && typeof first === 'object' && 'message' in first && typeof first.message === 'string') { return first.message; } } } if ('message' in error && typeof error.message === 'string') return error.message; } return String(error); } export default function SubtreeSplits() { const ctx = useFlowContext(); const t = useT(); const [form] = Form.useForm(); const [configs, setConfigs] = useState([]); const [repositories, setRepositories] = useState([]); const [branches, setBranches] = useState([]); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [configOpen, setConfigOpen] = useState(false); const [editing, setEditing] = useState(null); const [runningConfig, setRunningConfig] = useState(null); const [policy, setPolicy] = useState('fastForward'); const [pushAfterRun, setPushAfterRun] = useState(true); const [preview, setPreview] = useState(null); const [previewing, setPreviewing] = useState(false); const [running, setRunning] = useState(false); const [replaceConfirmation, setReplaceConfirmation] = useState(''); const [historyConfig, setHistoryConfig] = useState(null); const [historyRuns, setHistoryRuns] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const repositoryName = useMemo( () => new Map(repositories.map((repository) => [repository.id, repository.name])), [repositories], ); const loadData = useCallback(async () => { setLoading(true); try { const [configResponse, repositoryResponse] = await Promise.all([ ctx.api.request({ url: 'gitSubtreeConfigs:list', params: { pageSize: 200, sort: ['name'] } }), ctx.api.request({ url: 'gitRepositories:list', params: { pageSize: 200, sort: ['name'] } }), ]); setConfigs((configResponse.data?.data || []) as SubtreeConfig[]); setRepositories((repositoryResponse.data?.data || []) as RepositoryItem[]); } catch (error) { message.error(errorMessage(error)); } finally { setLoading(false); } }, [ctx.api]); useEffect(() => { loadData(); }, [loadData]); const loadOptions = async (repositoryId: number, sourceBranch?: string, remoteName = 'origin') => { const response = await ctx.api.request({ url: 'gitManager:subtreeOptions', method: 'post', params: { repositoryId, sourceBranch, remoteName }, }); // v1 and v2 requesters can wrap the action payload differently. // Normalize both `{ data: { branches } }` and `{ data: { data: { branches } } }`. const data = (response.data?.data?.data || response.data?.data) as OptionsResult; setBranches(data?.branches || []); setFolders(data?.folders || []); }; const openCreate = () => { setEditing(null); setBranches([]); setFolders([]); form.resetFields(); form.setFieldsValue({ remoteName: 'origin', defaultPolicy: 'fastForward', pushAfterRun: true, enabled: true }); setConfigOpen(true); }; const openEdit = async (config: SubtreeConfig) => { setEditing(config); form.setFieldsValue({ ...config, sourcePrefixes: config.sourcePrefixes?.length ? config.sourcePrefixes : [config.sourcePrefix], }); setConfigOpen(true); try { await loadOptions(config.repositoryId, config.sourceBranch, config.remoteName); } catch (error) { message.error(errorMessage(error)); } }; const saveConfig = async () => { const values = await form.validateFields(); const sourcePrefixes = values.sourcePrefixes || []; setSaving(true); try { await ctx.api.request({ url: editing ? 'gitSubtreeConfigs:update' : 'gitSubtreeConfigs:create', method: 'post', params: editing ? { filterByTk: editing.id } : undefined, data: { ...values, sourcePrefix: sourcePrefixes[0], sourcePrefixes }, }); message.success(t(editing ? 'Subtree configuration updated' : 'Subtree configuration created')); setConfigOpen(false); await loadData(); } catch (error) { message.error(errorMessage(error)); } finally { setSaving(false); } }; const deleteConfig = async (id: number) => { try { await ctx.api.request({ url: 'gitSubtreeConfigs:destroy', method: 'post', params: { filterByTk: id } }); message.success(t('Deleted')); await loadData(); } catch (error) { message.error(errorMessage(error)); } }; const openRun = (config: SubtreeConfig) => { setRunningConfig(config); setPolicy(config.defaultPolicy || 'fastForward'); setPushAfterRun(config.pushAfterRun !== false); setPreview(null); setReplaceConfirmation(''); }; const openHistory = async (config: SubtreeConfig) => { setHistoryConfig(config); setHistoryLoading(true); try { const response = await ctx.api.request({ url: 'gitSubtreeRuns:list', params: { filter: { configId: config.id }, pageSize: 100, sort: ['-startedAt'] }, }); setHistoryRuns((response.data?.data || []) as SubtreeRun[]); } catch (error) { message.error(errorMessage(error)); } finally { setHistoryLoading(false); } }; const previewRun = async () => { if (!runningConfig) return; setPreviewing(true); try { const response = await ctx.api.request({ url: 'gitManager:subtreePreview', method: 'post', data: { configId: runningConfig.id }, }); const data = response.data?.data as PreviewResult; setPreview(data); setPolicy(data.recommendedPolicy); } catch (error) { message.error(errorMessage(error)); } finally { setPreviewing(false); } }; const runSubtree = async () => { if (!runningConfig || !preview) return; if (policy === 'replace' && replaceConfirmation !== runningConfig.targetBranch) return; setRunning(true); try { await ctx.api.request({ url: policy === 'replace' ? 'gitManager:subtreeReplace' : 'gitManager:subtreeRun', method: 'post', data: { configId: runningConfig.id, policy, push: pushAfterRun, expectedTargetSha: preview.targetSha, }, }); message.success(t('Subtree split completed')); setRunningConfig(null); await loadData(); } catch (error) { message.error(errorMessage(error)); } finally { setRunning(false); } }; const columns = [ { title: t('Name'), dataIndex: 'name', key: 'name' }, { title: t('Repository'), dataIndex: 'repositoryId', key: 'repositoryId', render: (repositoryId: number) => repositoryName.get(repositoryId) || repositoryId, }, { title: t('Source'), key: 'source', render: (_: unknown, config: SubtreeConfig) => `${config.sourceBranch}:${(config.sourcePrefixes?.length ? config.sourcePrefixes : [config.sourcePrefix]).join( ', ', )}`, }, { title: t('Target branch'), dataIndex: 'targetBranch', key: 'targetBranch' }, { title: t('Last status'), dataIndex: 'lastStatus', key: 'lastStatus', render: (status?: string) => status ? ( {status} ) : ( '—' ), }, { title: t('Actions'), key: 'actions', render: (_: unknown, config: SubtreeConfig) => ( } > setConfigOpen(false)} onOk={saveConfig} confirmLoading={saving} width={680} destroyOnClose >
({ value: folder, label: folder }))} /> ({ value: branch, label: branch }))} /> setReplaceConfirmation(event.target.value)} /> } /> )} {t('Push target branch after success')} )}
setHistoryConfig(null)} footer={null} width={1000} >
new Date(value).toLocaleString(), }, { title: t('Policy'), dataIndex: 'policy' }, { title: t('Execution mode'), dataIndex: 'executionMode', render: () => t('App process') }, { title: t('Status'), dataIndex: 'status', render: (value: string) => ( {value} ), }, { title: t('Split SHA'), dataIndex: 'splitSha', ellipsis: true }, { title: t('Target before'), dataIndex: 'targetBeforeSha', ellipsis: true }, { title: t('Target after'), dataIndex: 'targetAfterSha', ellipsis: true }, { title: t('Error'), dataIndex: 'error', ellipsis: true }, ]} /> ); }