diff --git a/moon/apps/web/components/MrBox/ChecksSection.tsx b/moon/apps/web/components/MrBox/ChecksSection.tsx new file mode 100644 index 000000000..c60534a27 --- /dev/null +++ b/moon/apps/web/components/MrBox/ChecksSection.tsx @@ -0,0 +1,214 @@ +import * as Collapsible from '@radix-ui/react-collapsible'; +import { ArrowDownIcon, ArrowUpIcon, AlertIcon, CheckIcon, ClockIcon, WarningTriangleIcon } from "@gitmono/ui"; +import { useEffect, useMemo, useState } from 'react'; +import { MergeCheckItem } from './components/MergeCheckItem'; +import type { TaskData, AdditionalCheckItem, AdditionalCheckStatus } from './types/mergeCheck.config'; +import { ADDITIONAL_CHECK_LABELS } from './types/mergeCheck.config'; + +interface ChecksSectionProps { + checks: TaskData[]; + onStatusChange: (hasFailures: boolean) => void; + additionalChecks?: AdditionalCheckItem[]; +} +interface CheckStatusProps { + hasFailures: boolean; + failureCount: number; + inProgressCount: number; + successCount: number; + open: boolean; +} +interface CheckGroupProps { + title: string; + checks: TaskData[]; +} +interface CheckListProps { + groupedChecks: { + failing: TaskData[]; + pending: TaskData[]; + success: TaskData[]; + }; +} + +const CheckStatus = ({hasFailures, failureCount, inProgressCount, successCount, open}: CheckStatusProps) => { + let statusInfo: string[] = [] + + if (failureCount > 0) { + statusInfo.push(`${failureCount} failed`) + } + if (inProgressCount > 0) { + statusInfo.push(`${inProgressCount} in progress`) + } + if (successCount > 0) { + statusInfo.push(`${successCount} successful`) + } + if (statusInfo.length === 0) { + statusInfo.push('No checks have run yet') + } + + return ( +
+ {hasFailures? : } +
+

+ {hasFailures ? 'Some checks were not successful' : 'All checks have passed'} +

+

+ {statusInfo.join(', ')} +

+
+ +
+ ) +} + +const CheckGroup = ({ title, checks }: CheckGroupProps) => ( +
+

{title} ({checks.length})

+
+ {checks.map(check => )} +
+
+); + +const CheckList = ({groupedChecks}: CheckListProps) => ( +
+ {groupedChecks.failing.length > 0 && } + {groupedChecks.pending.length > 0 && } + {groupedChecks.success.length > 0 && } +
+) + +interface AdditionalCheckItemProps { + check: AdditionalCheckItem; +} + +const getStatusIcon = (status: AdditionalCheckStatus) => { + switch (status) { + case 'Success': + return ; + case 'Failure': + return ; + case 'Warning': + return ; + case 'Pending': + return ; + default: + return ; + } +}; + +const AdditionalCheckItemComponent = ({ check }: AdditionalCheckItemProps) => ( +
+
+ {getStatusIcon(check.status)} +
+
+
+
+ {ADDITIONAL_CHECK_LABELS[check.type]} +
+ + {check.status} + +
+ {check.message && ( +

{check.message}

+ )} + {check.errors && check.errors.length > 0 && ( +
    + {check.errors.map((error, index) => ( + // eslint-disable-next-line react/no-array-index-key +
  • {error}
  • + ))} +
+ )} +
+
+); + +interface AdditionalChecksSectionProps { + additionalChecks: AdditionalCheckItem[]; +} + +const AdditionalChecksSection = ({ additionalChecks }: AdditionalChecksSectionProps) => { + if (!additionalChecks || additionalChecks.length === 0) { + return null; + } + + return ( +
+

+ Additional Checks ({additionalChecks.length}) +

+
+ {additionalChecks.map(check => ( + + ))} +
+
+ ); +}; + +export function ChecksSection({ checks, onStatusChange, additionalChecks }: ChecksSectionProps) { + const summary = useMemo(() => { + return checks.reduce((acc, check) => { + acc[check.status] = (acc[check.status] || 0) + 1; + return acc; + }, {} as Record); + }, [checks]); + + const failureCount = summary.Failure || 0; + const inProgressCount = summary.Pending || 0; + const successCount = summary.Success || 0; + const hasFailures = failureCount > 0; + + useEffect(() => { + onStatusChange(hasFailures); + }, [hasFailures, onStatusChange]); + + const groupedChecks = useMemo(() => { + const failing = checks.filter(c => c.status === 'Failure'); + const pending = checks.filter(c => c.status === 'Pending'); + const success = checks.filter(c => c.status === 'Success'); + + return { failing, pending, success }; + }, [checks]); + + const [open, setOpen] = useState(false); + + return ( + <> + + {/* CheckStatus 部分 */} + + + + + {/* CheckList 部分 */} + + + + + + + + ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/MergeBox.tsx b/moon/apps/web/components/MrBox/MergeBox.tsx new file mode 100644 index 000000000..bcb493a73 --- /dev/null +++ b/moon/apps/web/components/MrBox/MergeBox.tsx @@ -0,0 +1,77 @@ +import React, { useState, useCallback } from 'react'; +import { useMergeChecks } from './hooks/useMergeChecks'; +import { ReviewerSection } from './ReviewerSection'; +import { ChecksSection } from './ChecksSection'; +import { MergeSection } from './MergeSection'; +import { FeedMergedIcon } from "@primer/octicons-react"; +import { usePostMrMerge } from "@/hooks/usePostMrMerge"; + +const REQUIRED_REVIEWERS = 2; // 假设需要2个 reviewer + +export function MergeBox({ prId }: { prId: string }) { + const { checks, refresh } = useMergeChecks(prId); + const [isReviewerApproved, setIsReviewerApproved] = useState(false); + const [hasCheckFailures, setHasCheckFailures] = useState(true); + const { mutate: approveMr, isPending: mrMergeIsPending } = usePostMrMerge(prId) + + // 定义最终的合并处理函数 + const handleMerge = useCallback(async () => { + // console.log('Final validation before merge...'); + // TODO: 再次发送校验请求 + refresh(); + + // 模拟校验结果 + const stillHasFailures = false; + + if (stillHasFailures) { + alert("阻止合并:仍有检查项未通过,请刷新页面查看详情。"); + } else { + // console.log('All checks passed. Sending merge request to backend...'); + + approveMr(undefined) + + alert("合并请求已发送!"); + } + }, [approveMr, refresh]); + + return ( +
+ +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/MergeSection.tsx b/moon/apps/web/components/MrBox/MergeSection.tsx new file mode 100644 index 000000000..647200411 --- /dev/null +++ b/moon/apps/web/components/MrBox/MergeSection.tsx @@ -0,0 +1,52 @@ +import { AlertIcon, WarningTriangleIcon, CheckCircleIcon, LoadingSpinner } from "@gitmono/ui"; +import React from "react"; + +interface MergeSectionProps { + isReviewerApproved: boolean; + hasCheckFailures: boolean; + onMerge: () => Promise; + isMerging: boolean; +} + +export function MergeSection({ isReviewerApproved, hasCheckFailures, onMerge, isMerging }: MergeSectionProps) { + let statusNode: React.ReactNode; + const isMergeable = isReviewerApproved && !hasCheckFailures; + + if (!isReviewerApproved) { + statusNode = ( +
+ + Merging is blocked +
+ ); + } else if (hasCheckFailures) { + statusNode = ( +
+ + Merging is blocked +
+ ); + } else { + statusNode = ( +
+ + Allow merging +
+ ); + } + + return ( +
+ {statusNode} + +
+ ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/ReviewerSection.tsx b/moon/apps/web/components/MrBox/ReviewerSection.tsx new file mode 100644 index 000000000..7b9e8ed79 --- /dev/null +++ b/moon/apps/web/components/MrBox/ReviewerSection.tsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; +import { CheckCircleIcon, AlertIcon } from "@gitmono/ui"; + +interface ReviewerSectionProps { + required: number; + onStatusChange: (isApproved: boolean) => void; +} + +export function ReviewerSection({ required, onStatusChange }: ReviewerSectionProps) { + // 实际的 review 人数,暂时用 state 模拟,未来应由 API 获取 + const [actual, _setActual] = useState(1); + const isApproved = actual >= required; + + useEffect(() => { + // TODO: 调用 API 获取实际 review 人数并更新 setActual + // fetch('/api/.../reviewers').then(res => res.json()).then(data => setActual(data.count)); + }, []); + + useEffect(() => { + onStatusChange(isApproved); + }, [isApproved, onStatusChange]); + + if (isApproved) { + return ( +
+ +
+ All required reviewers have approved +
+
+ ); + } + + return ( +
+ +
+
Review required
+
+ {`At least ${required} reviewer${required > 1 ? 's' : ''} required with write access.`} +
+
+
+ ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/components/CheckGroup.tsx b/moon/apps/web/components/MrBox/components/CheckGroup.tsx new file mode 100644 index 000000000..9043606dd --- /dev/null +++ b/moon/apps/web/components/MrBox/components/CheckGroup.tsx @@ -0,0 +1,40 @@ +import * as Collapsible from '@radix-ui/react-collapsible'; +import { ArrowDownIcon, ArrowUpIcon } from "@gitmono/ui"; +import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { GroupStatus } from "@/components/MrBox/types/mergeCheck.config"; + +const groupStatusMap = { + Pending: { color: 'border-yellow-400' }, + Success: { color: 'border-green-400' }, + Failure: { color: 'border-red-400' }, +}; + +interface CheckGroupProps { + title: string; + summary: string; + status: GroupStatus; + children: ReactNode; +} + +export function CheckGroup({ title, summary, status, children }: CheckGroupProps) { + const [isOpen, setIsOpen] = useState(true); // 默认展开 + const { color } = groupStatusMap[status]; + + return ( +
+ + +
+

{ title }

+ { summary } +
+ { isOpen? : } +
+ + { children } + +
+
+ ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/components/MergeCheckItem.tsx b/moon/apps/web/components/MrBox/components/MergeCheckItem.tsx new file mode 100644 index 000000000..ee78dd8d1 --- /dev/null +++ b/moon/apps/web/components/MrBox/components/MergeCheckItem.tsx @@ -0,0 +1,26 @@ +import {CheckCircleIcon, AlertIcon, WarningTriangleIcon} from "@gitmono/ui"; +import {TaskData} from "../types/mergeCheck.config"; + +const statusMap = { + Pending: { Icon: WarningTriangleIcon, className: 'text-yellow-600' }, + Success: { Icon: CheckCircleIcon, className: 'text-green-600' }, + Failure: { Icon: AlertIcon, className: 'text-red-600' }, + Warning: { Icon: WarningTriangleIcon, className: 'text-yellow-600' }, // GitHub 通常用黄点表示 in-progress 或 warning +}; + +export function MergeCheckItem({ check }: { check: TaskData }) { + const { Icon, className } = statusMap[check.status]; + + return ( +
+ +
+ {check.repo_name} + {check.arguments && {check.arguments}} +
+ +
+ ); +} \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/hooks/useMergeChecks.ts b/moon/apps/web/components/MrBox/hooks/useMergeChecks.ts new file mode 100644 index 000000000..f710ed21d --- /dev/null +++ b/moon/apps/web/components/MrBox/hooks/useMergeChecks.ts @@ -0,0 +1,58 @@ +import { GetApiTasksData } from "@/components/MrBox/types/mergeCheck.config"; + +export const useMergeChecks = (_prId: string) => { + const checks: GetApiTasksData = [ + { + repo_name: "frontend-app", + status: "Success", + arguments: "--env=production", + build_id: "build-123", + end_at: "2025-08-18T10:00:00Z", + exit_code: 0, + mr: "MR-456", + output_file: "output.log", + start_at: "2025-08-18T09:50:00Z", + target: "main" + }, + { + repo_name: "backend-service", + status: "Pending", + arguments: "--mode=test", + build_id: "build-124", + end_at: "2025-08-18T10:15:00Z", + exit_code: 0, + mr: "MR-457", + output_file: "test.log", + start_at: "2025-08-18T10:00:00Z", + target: "develop" + }, + { + repo_name: "shared-lib", + status: "Failure", + arguments: "--check=lint", + build_id: "build-125", + end_at: "2025-08-18T10:30:00Z", + exit_code: 1, + mr: "MR-458", + output_file: "error.log", + start_at: "2025-08-18T10:20:00Z", + target: "feature" + }, + { + repo_name: "config-repo", + status: "Warning", + arguments: "--validate", + build_id: "build-126", + end_at: "2025-08-18T10:45:00Z", + exit_code: 0, + mr: "MR-459", + output_file: "warning.log", + start_at: "2025-08-18T10:35:00Z", + target: "main" + } + ]; + // eslint-disable-next-line no-empty-function + const refresh = () => {} + + return { checks, refresh }; +}; \ No newline at end of file diff --git a/moon/apps/web/components/MrBox/types/mergeCheck.config.ts b/moon/apps/web/components/MrBox/types/mergeCheck.config.ts new file mode 100644 index 000000000..b529a256a --- /dev/null +++ b/moon/apps/web/components/MrBox/types/mergeCheck.config.ts @@ -0,0 +1,40 @@ +export interface TaskData { + "arguments": string, + "build_id": string, + "end_at": string, + "exit_code": number, + "mr": string, + "output_file": string, + "repo_name": string, + "start_at": string, + "status": "Pending" | "Success" | "Failure" | "Warning", + "target": string +} + +export type GetApiTasksData = TaskData[] + +export type GroupStatus = 'Success' | 'Failure' | 'Pending'; + +export type AdditionalCheckStatus = 'Success' | 'Failure' | 'Pending' | 'Warning'; + +export interface AdditionalCheckItem { + type: AdditionalCheckType; + status: AdditionalCheckStatus; + message?: string; + errors?: string[]; +} + +export type AdditionalCheckType = + | 'GPG_SIGNATURE' + | 'BRANCH_PROTECTION' + | 'COMMIT_MESSAGE_FORMAT' + | 'PR_UPDATE_STATUS' + | 'CONFLICT_DETECTION'; + +export const ADDITIONAL_CHECK_LABELS: Record = { + GPG_SIGNATURE: 'GPG 签名验证', + BRANCH_PROTECTION: '分支保护规则', + COMMIT_MESSAGE_FORMAT: '提交信息规范', + PR_UPDATE_STATUS: 'PR 更新状态', + CONFLICT_DETECTION: '冲突检测' +}; \ No newline at end of file diff --git a/moon/apps/web/pages/[org]/mr/[link]/index.tsx b/moon/apps/web/pages/[org]/mr/[link]/index.tsx index d909fcfec..8f9ebffa4 100644 --- a/moon/apps/web/pages/[org]/mr/[link]/index.tsx +++ b/moon/apps/web/pages/[org]/mr/[link]/index.tsx @@ -42,12 +42,13 @@ import { useGetMrFilesChanged } from '@/hooks/useGetMrFilesChanged' import { usePostMrClose } from '@/hooks/usePostMrClose' import { usePostMrComment } from '@/hooks/usePostMrComment' import { usePostMRLabels } from '@/hooks/usePostMRLabels' -import { usePostMrMerge } from '@/hooks/usePostMrMerge' +// import { usePostMrMerge } from '@/hooks/usePostMrMerge' import { usePostMrReopen } from '@/hooks/usePostMrReopen' import { useUploadHelpers } from '@/hooks/useUploadHelpers' import { apiErrorToast } from '@/utils/apiErrorToast' import { trimHtml } from '@/utils/trimHtml' import { PageWithLayout } from '@/utils/types' +import { MergeBox } from "@/components/MrBox/MergeBox"; export interface MRDetail { status: string @@ -73,21 +74,21 @@ const MRDetailPage: PageWithLayout = () => { const [loading, setLoading] = useState(false) const Checks = dynamic(() => import('@/components/MrView/components/Checks')) - if (mrDetail && typeof mrDetail.status === 'string') { + if (mrDetail) { mrDetail.status = mrDetail.status.toLowerCase() } const { data: MrFilesChangedData, isLoading: fileChgIsLoading } = useGetMrFilesChanged(id) const { mutate: modifyTitle } = usePostMrTitle() - const { mutate: approveMr, isPending: mrMergeIsPending } = usePostMrMerge(id) - const handleMrApprove = () => { - approveMr(undefined, { - onSuccess: () => { - router.push(`/${scope}/mr`) - } - }) - } + // const { mutate: approveMr, isPending: mrMergeIsPending } = usePostMrMerge(id) + // const handleMrApprove = () => { + // approveMr(undefined, { + // onSuccess: () => { + // router.push(`/${scope}/mr`) + // } + // }) + // } const [_, setEditId] = useAtom(editIdAtom) const [refresh, setRefresh] = useAtom(refreshAtom) @@ -247,17 +248,22 @@ const MRDetailPage: PageWithLayout = () => { mrDetail && )}
-
+ {/*
*/} + {/* {mrDetail && mrDetail.status === 'open' && (*/} + {/* */} + {/* Merge MR*/} + {/* */} + {/* )}*/} + {/*
*/} +
{mrDetail && mrDetail.status === 'open' && ( - + )}

Add a comment