import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, Dropdown, Modal, Form, Select, Input, message, Space, Tooltip, Tag, Alert } from 'antd'; import { RobotOutlined, MessageOutlined, ThunderboltOutlined, ReloadOutlined } from '@ant-design/icons'; import { useApp } from '@nocobase/client-v2'; import * as aiClient from '@nocobase/plugin-ai/client'; import { REVIEW_PROMPT_TEMPLATES } from '../promptTemplates'; import { useT } from '../locale'; interface ReviewFlow { id: number; name: string; enabled: boolean; triggerMode: string; aiEmployeeUsername?: string; postMode: string; repositoryId?: number; llmService?: string; model?: string; instructions?: string; } type Target = | { type: 'mr'; repositoryId: number; mrIid: number; title?: string } | { type: 'commit'; repositoryId: number; commitSha: string; title?: string } | { type: 'branch'; repositoryId: number; branch: string; title?: string } | { type: 'folder'; repositoryId: number; folderPath: string; ref?: string; title?: string }; /** * Button that lets the user kick off a code review for a given target. * Two paths: * - "Run review" → POST gitManager:triggerReview (server-side, async) * - "Open chat" → call (aiClient as any).useChatBoxActions?.() || {}.triggerTask if plugin-ai is loaded, * so the user can chat with the AI employee using the same context. */ export const RunReviewButton: React.FC<{ target: Target; size?: 'small' | 'middle' | 'large'; type?: 'default' | 'primary' | 'link'; onTriggered?: (reviewId: number) => void; }> = ({ target, size = 'small', type = 'default', onTriggered }) => { const t = useT(); const api = useApp().apiClient; const aiConfigRepository = (aiClient as any).useAIConfigRepository?.() || {}; const { triggerTask } = (aiClient as any).useChatBoxActions?.() || {}; const [open, setOpen] = useState(false); const [flows, setFlows] = useState([]); const [submitting, setSubmitting] = useState(false); const [asking, setAsking] = useState(false); const [existingReview, setExistingReview] = useState(null); const [templateKey, setTemplateKey] = useState(); const [form] = Form.useForm(); const loadExistingReview = useCallback(() => { let cancelled = false; const filter: any = { repositoryId: target.repositoryId, targetType: target.type, }; if (target.type === 'mr') filter.mrIid = target.mrIid; if (target.type === 'commit') filter.commitSha = target.commitSha; if (target.type === 'branch') filter.branch = target.branch; if (target.type === 'folder') { filter.folderPath = target.folderPath; filter.branch = target.ref || null; } api .request({ url: 'gitCodeReviews:list', params: { pageSize: 1, sort: ['-id'], filter, }, }) .then((res) => { if (cancelled) return; const list = res?.data?.data || []; setExistingReview(list[0] || null); }) .catch(() => undefined); return () => { cancelled = true; }; }, [api, target]); useEffect(() => { return loadExistingReview(); }, [loadExistingReview]); const loadFlows = useCallback(async () => { const { data } = await api.request({ url: 'gitReviewFlows:list', params: { pageSize: 100, filter: { enabled: true, $or: [{ repositoryId: target.repositoryId }, { repositoryId: null }], }, }, }); const list: ReviewFlow[] = data?.data || []; setFlows(list); return list; }, [api, target.repositoryId]); const pickFlow = useCallback( (list: ReviewFlow[]) => { const repoFlow = list.find((f) => f.repositoryId === target.repositoryId); return repoFlow ?? list[0] ?? null; }, [target.repositoryId], ); useEffect(() => { if (!open) return; setTemplateKey(undefined); let cancelled = false; loadFlows() .then((list) => { if (cancelled) return; const flow = pickFlow(list); if (flow?.id) form.setFieldValue('flowId', flow.id); }) .catch(() => undefined); return () => { cancelled = true; }; }, [open, loadFlows, pickFlow, form]); const handleRun = async () => { try { const values = await form.validateFields(); setSubmitting(true); const params: any = { repositoryId: target.repositoryId, targetType: target.type, flowId: values.flowId, extraInstructions: values.extraInstructions || undefined, }; if (target.type === 'mr') params.mrIid = target.mrIid; if (target.type === 'commit') params.commitSha = target.commitSha; if (target.type === 'branch') params.branch = target.branch; if (target.type === 'folder') { params.folderPath = target.folderPath; params.branch = target.ref; } const res = await api.request({ url: 'gitManager:triggerReview', method: 'post', data: params, }); const reviewId = res?.data?.data?.reviewId; message.success(t('Review started')); setOpen(false); form.resetFields(); loadExistingReview(); onTriggered?.(reviewId); } catch (err: any) { if (err?.errorFields) return; // validation error message.error(err?.response?.data?.errors?.[0]?.message || err?.message || t('Failed to trigger review')); } finally { setSubmitting(false); } }; const buildWorkContext = () => { const title = target.title || buildContextHint(); if (target.type === 'mr') { return { type: 'git-merge-request', uid: `${target.repositoryId}:${target.mrIid}`, title, content: { repositoryId: target.repositoryId, mrIid: target.mrIid, title, }, }; } if (target.type === 'commit') { return { type: 'git-commit', uid: `${target.repositoryId}:${target.commitSha}`, title, content: { repositoryId: target.repositoryId, commitSha: target.commitSha, title, }, }; } if (target.type === 'folder') { return { type: 'git-repository', uid: `${target.repositoryId}:folder:${target.folderPath}`, title, content: { repositoryId: target.repositoryId, folderPath: target.folderPath, ref: target.ref, title, }, }; } return { type: 'git-repository', uid: String(target.repositoryId), title, content: { repositoryId: target.repositoryId, branch: target.branch, title, }, }; }; const buildChatPrompt = () => { if (target.type === 'mr') { return `Please review merge request !${target.mrIid} (${ target.title || 'untitled' }). Use the attached Git merge request context and the available git tools when you need more detail.`; } if (target.type === 'commit') { return `Please review commit ${target.commitSha} (${ target.title || 'untitled' }). Use the attached Git commit context and the available git tools when you need more detail.`; } if (target.type === 'folder') { return `Please review the full code inside folder "${target.folderPath || '/'}" at ref ${ target.ref || 'HEAD' }. Use git_list_files (recursive=true) to enumerate the files, then git_get_file_content to read them.`; } return `Please help me inspect branch ${target.branch}. Use the attached Git repository context and the available git tools when you need more detail.`; }; const handleOpenChat = async () => { if (!triggerTask || !aiConfigRepository?.getAIEmployees) { message.warning(t('AI plugin is not available')); return; } setAsking(true); try { const list = flows.length ? flows : await loadFlows(); const flow = pickFlow(list); if (!flow?.aiEmployeeUsername) { message.warning(t('No matching flow available')); return; } const employees: any[] = aiConfigRepository.aiEmployees?.length ? aiConfigRepository.aiEmployees : await aiConfigRepository.getAIEmployees(); const aiEmployee = employees.find((item) => item.username === flow.aiEmployeeUsername); if (!aiEmployee) { message.warning(t('AI employee not found')); return; } await triggerTask({ aiEmployee, tasks: [ { title: `${flow.name}: ${buildContextHint()}`, message: { user: buildChatPrompt(), system: flow.instructions || undefined, workContext: [buildWorkContext()], }, model: flow.llmService && flow.model ? { llmService: flow.llmService, model: flow.model } : null, autoSend: false, }, ], }); } catch (err: any) { message.error(err?.message || t('Failed to open AI chat')); } finally { setAsking(false); } }; const buildContextHint = () => { if (target.type === 'mr') return `MR !${target.mrIid}`; if (target.type === 'commit') return `Commit ${String(target.commitSha).slice(0, 7)}`; if (target.type === 'folder') return `${t('Folder')} ${target.folderPath || '/'}`; return `Branch ${target.branch}`; }; // Derive re-run state for MR targets const hasNewCommits = existingReview && existingReview.headSha && existingReview.latestSha && existingReview.headSha !== existingReview.latestSha; const isReReview = !!existingReview && existingReview.status !== 'pending'; const buttonLabel = isReReview ? hasNewCommits ? t('Re-review (new commits)') : t('Re-run review') : t('Code Review'); const ButtonIcon = isReReview ? ReloadOutlined : RobotOutlined; const items = useMemo( () => [ { key: 'run', icon: , label: isReReview ? t('Re-run automated review') : t('Run automated review'), onClick: () => setOpen(true), }, ...(existingReview ? [ { key: 'view', icon: , label: t(`Status: ${existingReview.status}`) + ' - ' + t('View in Review History tab'), onClick: () => { message.info(t('Please switch to the "Review History" tab to see the details.')); }, }, ] : []), { key: 'chat', icon: , label: t('Ask AI Employee'), onClick: handleOpenChat, disabled: asking, }, ], [isReReview, existingReview, asking, handleOpenChat, t], ); return ( <> {isReReview ? t('Re-run code review') : t('Run code review')} {buildContextHint()} } open={open} onCancel={() => setOpen(false)} onOk={handleRun} okText={isReReview ? t('Re-run') : t('Start Review')} confirmLoading={submitting} destroyOnClose > {isReReview && ( {existingReview?.headSha && ( {t('Reviewed at')}:  {String(existingReview.headSha).slice(0, 7)} )} {hasNewCommits && existingReview?.latestSha && ( {t('Latest')}:  {String(existingReview.latestSha).slice(0, 7)} )} } /> )}
{t('No enabled flow found for this repository')} ) : null } > ({ value: tpl.key, label: t(tpl.label) }))} onChange={(key) => { setTemplateKey(key); const tpl = REVIEW_PROMPT_TEMPLATES.find((item) => item.key === key); if (tpl) form.setFieldValue('extraInstructions', tpl.text); }} />
); };