import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Table, Tag, Drawer, Space, Button, Typography, Empty, Tooltip, Popconfirm, message, Select, Tabs, Input, Alert, theme, } from 'antd'; import { RobotOutlined, ReloadOutlined, CheckOutlined, CloseOutlined, ClockCircleOutlined, FileTextOutlined, ExportOutlined, EditOutlined, } from '@ant-design/icons'; import { useApp } from '@nocobase/client-v2'; import { useGitManager } from '../context/GitManagerContext'; import { MarkdownView } from './MarkdownView'; import { useT } from '../locale'; const { Text, Title, Paragraph } = Typography; const { useToken } = theme; const STATUS_COLOR: Record = { pending: 'default', running: 'blue', completed: 'green', failed: 'red', }; const POST_STATUS_COLOR: Record = { pending_approval: 'orange', approved: 'cyan', posted: 'green', post_failed: 'red', skipped: 'default', rejected: 'red', }; export const ReviewHistory: React.FC<{ initialFilter?: 'all' | 'pending_approval' }> = ({ initialFilter = 'all' }) => { const t = useT(); const { token } = useToken(); const api = useApp().apiClient; const { repos } = useGitManager(); const [reviews, setReviews] = useState([]); const [loading, setLoading] = useState(false); const [statusFilter, setStatusFilter] = useState('all'); const [postFilter, setPostFilter] = useState( initialFilter === 'pending_approval' ? 'pending_approval' : 'all', ); const [triggerFilter, setTriggerFilter] = useState('all'); const [selected, setSelected] = useState(null); const [editing, setEditing] = useState(false); const [editedMarkdown, setEditedMarkdown] = useState(''); const reload = useCallback(async () => { setLoading(true); try { const filter: any = {}; if (statusFilter !== 'all') filter.status = statusFilter; if (postFilter !== 'all') filter.postStatus = postFilter; if (triggerFilter !== 'all') filter.triggeredBy = triggerFilter; const { data } = await api.request({ url: 'gitCodeReviews:list', params: { pageSize: 50, sort: ['-id'], filter: Object.keys(filter).length ? filter : undefined, }, }); setReviews(data?.data || []); } finally { setLoading(false); } }, [api, statusFilter, postFilter, triggerFilter]); useEffect(() => { reload(); }, [reload]); // Auto-refresh while there are running reviews useEffect(() => { if (!reviews.some((r) => r.status === 'running' || r.status === 'pending')) return; const tid = setInterval(reload, 4000); return () => clearInterval(tid); }, [reviews, reload]); const refreshOne = useCallback( async (id: number) => { const { data } = await api.request({ url: 'gitCodeReviews:get', params: { filterByTk: id } }); const fresh = data?.data; if (fresh) { setReviews((prev) => prev.map((r) => (r.id === id ? fresh : r))); if (selected?.id === id) setSelected(fresh); } }, [api, selected], ); const handleApprove = async (markdown: string) => { if (!selected) return; try { await api.request({ url: 'gitManager:reviewApprovePost', method: 'post', data: { reviewId: selected.id, editedMarkdown: markdown }, }); message.success(t('Review posted to merge request')); setEditing(false); refreshOne(selected.id); reload(); } catch (err: any) { message.error(err?.response?.data?.errors?.[0]?.message || err?.message || t('Failed to post review')); } }; const handleReject = async () => { if (!selected) return; try { await api.request({ url: 'gitManager:reviewReject', method: 'post', data: { reviewId: selected.id }, }); message.success(t('Review rejected')); refreshOne(selected.id); reload(); } catch (err: any) { message.error(err?.message || t('Failed to reject')); } }; const repoMap = useMemo(() => { const m: Record = {}; repos.forEach((r: any) => (m[r.id] = r)); return m; }, [repos]); const columns = [ { title: t('Target'), key: 'target', render: (_: any, record: any) => { const repo = repoMap[record.repositoryId]; const repoName = repo?.name || `#${record.repositoryId}`; if (record.targetType === 'mr') { return ( {repoName} !{record.mrIid} ); } if (record.targetType === 'commit') { return ( {repoName} {String(record.commitSha || '').slice(0, 7)} ); } if (record.targetType === 'folder') { return ( {repoName} {record.folderPath || '/'} {record.branch && {record.branch}} ); } return ( {repoName} {record.branch} ); }, }, { title: t('Type'), dataIndex: 'targetType', width: 80, render: (v: string) => {t(v.toUpperCase())}, }, { title: t('Status'), dataIndex: 'status', width: 120, render: (v: string) => { const color = STATUS_COLOR[v] || 'default'; const icon = v === 'running' ? : null; return ( {t(v)} ); }, }, { title: t('Post Status'), dataIndex: 'postStatus', width: 140, render: (v: string) => {t(v)}, }, { title: t('Commit'), key: 'sha', width: 140, render: (_: any, record: any) => { const head = record.headSha; const latest = record.latestSha; const hasNew = head && latest && head !== latest; if (!head) return ; return ( {String(head).slice(0, 7)} {hasNew && ( {t('new')} )} ); }, }, { title: t('Trigger'), dataIndex: 'triggeredBy', width: 90, render: (v: string) => ( {v === 'poll' ? t('Polling') : t('Manual')} ), }, { title: t('Duration'), dataIndex: 'durationMs', width: 90, render: (v: number | null) => (v ? `${(v / 1000).toFixed(1)}s` : '—'), }, { title: t('Created'), dataIndex: 'createdAt', width: 160, render: (v: string) => (v ? new Date(v).toLocaleString() : ''), }, { title: '', key: 'actions', width: 60, render: (_: any, record: any) => (