import { useCallback, useEffect, useState } from "react"; import { API } from "../config"; type PRScan = { id: number; pr_number: number; repo_name: string; pr_title: string; pr_url: string; source_branch: string; target_branch: string; author: string; state: "open" | "merged" | "closed"; scan_status: "pending" | "completed"; issues_count: number; security_issues: number; created_at: string; }; type PRDetail = PRScan & { scan_result: Record; scan_details_with_code: unknown[]; ai_review: unknown; report_path: string; }; type ChangedFile = { filename: string; status: string; additions: number; deletions: number; }; type FileContentIssue = { line: number; severity: string; message: string; rule?: string; scanner?: string; }; type FileContent = { content: string; issues: FileContentIssue[]; }; function normalizeFileContent(data: { content?: string; issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>; scan_issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>; }): FileContent { const raw = data.scan_issues ?? data.issues ?? []; const issues: FileContentIssue[] = raw.map((iss) => { const line = typeof iss.line === "number" ? iss.line : typeof iss.line === "string" ? parseInt(iss.line, 10) : 1; return { line: Number.isFinite(line) ? line : 1, severity: String(iss.severity ?? "info").toLowerCase(), message: String(iss.message ?? iss.description ?? ""), rule: iss.rule, scanner: iss.scanner, }; }); return { content: data.content ?? "", issues, }; } type View = "dashboard" | "pr-list" | "settings"; type PRHistoryItem = { pr_number: number; error_count?: number; warning_count?: number; total_issues?: number; }; type QualityGateProps = { view: View; }; const TREND_SLOTS = 15; const TREND_PX_PER_PR = 80; const TREND_CHART_HEIGHT = 220; function ProblemTrendChart({ history, loading, }: { history: PRHistoryItem[]; loading: boolean; }) { if (loading) { return (

问题趋势

加载趋势数据中...
); } if (!history || history.length === 0) { return (

问题趋势

暂无趋势数据
); } const n = history.length; const pad = Math.max(0, TREND_SLOTS - n); const labels = history.map((p) => `#${p.pr_number}`).concat(Array(pad).fill("")); const errorData: (number | null)[] = history.map((p) => p.error_count ?? 0).concat(Array(pad).fill(null)); const warningData: (number | null)[] = history.map((p) => p.warning_count ?? 0).concat(Array(pad).fill(null)); const width = TREND_SLOTS * TREND_PX_PER_PR; const height = TREND_CHART_HEIGHT; const padding = { top: 28, right: 24, bottom: 32, left: 40 }; const chartW = width - padding.left - padding.right; const chartH = height - padding.top - padding.bottom; const maxVal = Math.max( 1, ...errorData.filter((v): v is number => v != null), ...warningData.filter((v): v is number => v != null) ); const yMax = Math.ceil(maxVal / 2) * 2 || 2; const yTicks = Array.from({ length: yMax + 1 }, (_, i) => i); const xScale = (i: number) => padding.left + (i / (labels.length - 1 || 1)) * chartW; const yScale = (v: number) => padding.top + chartH - (v / yMax) * chartH; const toPath = (data: (number | null)[]) => { const parts: string[] = []; for (let i = 0; i < data.length; i++) { const v = data[i]; if (v == null) continue; const x = xScale(i); const y = yScale(v); parts.push(parts.length === 0 ? `M ${x} ${y}` : `L ${x} ${y}`); } return parts.join(" "); }; const toAreaPath = (data: (number | null)[]) => { const points: { i: number; v: number }[] = []; data.forEach((v, i) => { if (v != null) points.push({ i, v }); }); if (points.length === 0) return ""; const linePath = points.map(({ i, v }) => `${i === 0 ? "M" : "L"} ${xScale(i)} ${yScale(v)}`).join(" "); const lastX = xScale(points[points.length - 1].i); const baseY = padding.top + chartH; const startX = xScale(points[0].i); return `${linePath} L ${lastX} ${baseY} L ${startX} ${baseY} Z`; }; const errorPath = toPath(errorData); const warningPath = toPath(warningData); const errorAreaPath = toAreaPath(errorData); const warningAreaPath = toAreaPath(warningData); return (

问题趋势

{/* 网格线 */} {yTicks.map((tick, i) => ( ))} {labels.map((_, i) => ( ))} {/* 图例 */} 错误 警告 {/* 面积填充 */} {/* 折线 */} {/* Y 轴刻度 */} {yTicks.map((tick) => ( {tick} ))} {/* X 轴刻度 */} {labels.map((label, i) => ( {label || "#"} ))}
); } const BASE = API.quality; async function apiGet(path: string): Promise { const res = await fetch(`${BASE}${path}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } async function apiPost(path: string): Promise { const res = await fetch(`${BASE}${path}`, { method: "POST" }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } export default function QualityGate({ view }: QualityGateProps) { const [prs, setPrs] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all"); const [selectedPR, setSelectedPR] = useState(null); const [changedFiles, setChangedFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(""); const [fileContent, setFileContent] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [trendHistory, setTrendHistory] = useState([]); const [trendLoading, setTrendLoading] = useState(false); const fetchPRs = useCallback(async () => { setLoading(true); setError(""); try { const query = filter === "all" ? "" : `?state=${filter}`; const data = await apiGet<{ prs: PRScan[] } | PRScan[]>(`/prs${query}`); setPrs(Array.isArray(data) ? data : data.prs || []); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }, [filter]); useEffect(() => { fetchPRs(); }, [fetchPRs]); const fetchTrendHistory = useCallback(async () => { setTrendLoading(true); try { const data = await apiGet("/prs/history?limit=15"); const list = Array.isArray(data) ? data : data.history ?? []; setTrendHistory(list); } catch { setTrendHistory([]); } finally { setTrendLoading(false); } }, []); useEffect(() => { if (view === "dashboard") fetchTrendHistory(); }, [view, fetchTrendHistory]); const openPRDetail = useCallback(async (prId: number) => { setLoading(true); setError(""); try { const [detail, files] = await Promise.all([ apiGet(`/prs/${prId}`), apiGet<{ files: ChangedFile[] } | ChangedFile[]>(`/prs/${prId}/files`), ]); setSelectedPR(detail); const fileList = Array.isArray(files) ? files : files.files || []; setChangedFiles(fileList); if (fileList.length > 0) { setSelectedFile(fileList[0].filename); } setModalOpen(true); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }, []); useEffect(() => { if (!modalOpen || !selectedPR || !selectedFile) return; setFileContent(null); apiGet[0]>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`) .then((data) => setFileContent(normalizeFileContent(data))) .catch(() => setFileContent({ content: "// Failed to load file", issues: [] })); }, [modalOpen, selectedPR, selectedFile]); const handleMerge = async () => { if (!selectedPR) return; setLoading(true); try { await apiPost(`/prs/${selectedPR.id}/merge`); setModalOpen(false); fetchPRs(); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; const handleClose = async () => { if (!selectedPR) return; setLoading(true); try { await apiPost(`/prs/${selectedPR.id}/close`); setModalOpen(false); fetchPRs(); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } }; const stats = { pending: prs.filter((p) => p.state === "open").length, passed: prs.filter((p) => p.state === "merged").length, rejected: prs.filter((p) => p.state === "closed").length, totalIssues: prs.reduce((s, p) => s + p.issues_count, 0), }; return (
{error && (
{error}
)} {view === "dashboard" && ( <>

Overview

{[ { label: "Open PRs", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" }, { label: "Merged", value: stats.passed, color: "text-green-600", bg: "bg-green-50" }, { label: "Rejected", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" }, { label: "Total Issues", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" }, ].map((s) => (
{s.value}
{s.label}
))}

Recent Pull Requests

openPRDetail(pr.id)} /> )} {view === "pr-list" && ( <>

Pull Request List

{(["all", "open", "merged", "closed"] as const).map((f) => ( ))}
openPRDetail(pr.id)} /> )} {view === "settings" && (

Settings

Webhook Configuration

Add this URL to your Gitea repository webhook settings:

POST {window.location.origin}/quality-api/webhook/gitea

Quick Notes

  • Supports Gitea Push and Pull Request events
  • Runs Pylint, Flake8, ESLint, and Bandit automatically
  • Optional AI review using DeepSeek-V3
  • Scan summary can be pushed to Feishu channels
)} {modalOpen && selectedPR && (

#{selectedPR.pr_number} {selectedPR.pr_title}

{selectedPR.author} · {selectedPR.source_branch} → {selectedPR.target_branch} · {selectedPR.repo_name}

Changed Files

{changedFiles.map((f) => ( ))}
{/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 index copy.html) */}
{fileContent ? (
{(fileContent.content ?? "").split("\n").map((line, i) => { const lineNum = i + 1; const lineIssues = (fileContent.issues ?? []).filter((iss) => iss.line === lineNum); const hasIssue = lineIssues.length > 0; const reasonText = hasIssue ? lineIssues.map((iss) => (iss.scanner ? `[${iss.scanner}] ` : "") + (iss.message || "")).join(";") : ""; const displayText = line === "" ? "\u00A0" : line; return (
{/* 行号区 */}
{lineNum} {hasIssue && ( )}
{/* 代码内容 */}
{displayText}
{/* 虚线连接区 */}
{hasIssue && ( )}
{/* 缺陷标注(与该行对齐,在右侧) */}
{hasIssue && reasonText ? ( <> {reasonText} ) : ( - )}
); })}
) : (
{selectedFile ? "Loading file..." : "Select a file to inspect"}
)}
)}
); } function PRTable({ prs, loading, onView, }: { prs: PRScan[]; loading: boolean; onView: (pr: PRScan) => void; }) { if (loading && prs.length === 0) { return
Loading...
; } if (prs.length === 0) { return
No PR records found
; } return (
{prs.map((pr) => ( ))}
PR# Title Repository Author Branch State Issues Created Action
#{pr.pr_number} {pr.pr_title} {pr.repo_name} {pr.author} {pr.source_branch} → {pr.target_branch} {pr.state === "open" ? "Open" : pr.state === "merged" ? "Merged" : "Closed"} 0 ? "text-red-600" : "text-green-600"}`}> {pr.issues_count} {new Date(pr.created_at).toLocaleString("en-US")}
); }