Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions moon/apps/web/components/MrBox/ChecksSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full px-3 py-0 items-center">
{hasFailures? <AlertIcon className="h-5 w-5 mr-3 text-yellow-600"/> : <CheckIcon className="h-5 w-5 mr-3 text-green-700"/>}
<div>
<p className="font-semibold">
{hasFailures ? 'Some checks were not successful' : 'All checks have passed'}
</p>
<p className="text-sm text-gray-600">
{statusInfo.join(', ')}
</p>
</div>
<button className="justify-self-end ml-auto">
{open ? <ArrowUpIcon /> : <ArrowDownIcon />}
</button>
</div>
)
}

const CheckGroup = ({ title, checks }: CheckGroupProps) => (
<div className="mb-2">
<h4 className="text-xs font-bold text-gray-500 uppercase px-2 py-1">{title} ({checks.length})</h4>
<div>
{checks.map(check => <MergeCheckItem key={check.build_id} check={check} />)}
</div>
</div>
);

const CheckList = ({groupedChecks}: CheckListProps) => (
<div className="border-t mt-2 pt-2 max-h-[300px] overflow-y-auto">
{groupedChecks.failing.length > 0 && <CheckGroup title="Failing" checks={groupedChecks.failing} />}
{groupedChecks.pending.length > 0 && <CheckGroup title="In progress" checks={groupedChecks.pending} />}
{groupedChecks.success.length > 0 && <CheckGroup title="Successful" checks={groupedChecks.success} />}
</div>
)

interface AdditionalCheckItemProps {
check: AdditionalCheckItem;
}

const getStatusIcon = (status: AdditionalCheckStatus) => {
switch (status) {
case 'Success':
return <CheckIcon className="w-4 h-4 text-green-600" />;
case 'Failure':
return <AlertIcon className="w-4 h-4 text-red-600" />;
case 'Warning':
return <WarningTriangleIcon className="w-4 h-4 text-yellow-600" />;
case 'Pending':
return <ClockIcon className="w-4 h-4 text-gray-500" />;
default:
return <ClockIcon className="w-4 h-4 text-gray-500" />;
}
};

const AdditionalCheckItemComponent = ({ check }: AdditionalCheckItemProps) => (
<div className="flex items-start px-2 py-2 border-b border-gray-100 last:border-b-0">
<div className="flex-shrink-0 mr-3 mt-0.5">
{getStatusIcon(check.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-gray-900">
{ADDITIONAL_CHECK_LABELS[check.type]}
</h5>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
check.status === 'Success' ? 'bg-green-100 text-green-800' :
check.status === 'Failure' ? 'bg-red-100 text-red-800' :
check.status === 'Warning' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{check.status}
</span>
</div>
{check.message && (
<p className="text-sm text-gray-600 mt-1">{check.message}</p>
)}
{check.errors && check.errors.length > 0 && (
<ul className="text-sm text-red-600 mt-1 list-disc list-inside">
{check.errors.map((error, index) => (
// eslint-disable-next-line react/no-array-index-key
<li key={index}>{error}</li>
))}
</ul>
)}
</div>
</div>
);

interface AdditionalChecksSectionProps {
additionalChecks: AdditionalCheckItem[];
}

const AdditionalChecksSection = ({ additionalChecks }: AdditionalChecksSectionProps) => {
if (!additionalChecks || additionalChecks.length === 0) {
return null;
}

return (
<div className="border-t mt-2 pt-2">
<h4 className="text-xs font-bold text-gray-500 uppercase px-2 py-1 mb-2">
Additional Checks ({additionalChecks.length})
</h4>
<div className="space-y-1">
{additionalChecks.map(check => (
<AdditionalCheckItemComponent key={check.message} check={check} />
))}
</div>
</div>
);
};

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<TaskData['status'], number>);
}, [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 (
<>
<Collapsible.Root
open={open}
onOpenChange={setOpen}
>
{/* CheckStatus 部分 */}
<Collapsible.Trigger
className="w-full hover:bg-gray-100 rounded-md cursor-pointer flex"
>
<CheckStatus
hasFailures={hasFailures}
failureCount={failureCount}
inProgressCount={inProgressCount}
successCount={successCount}
open={open}
/>
</Collapsible.Trigger>

{/* CheckList 部分 */}
<Collapsible.Content>
<CheckList groupedChecks={groupedChecks} />
<AdditionalChecksSection additionalChecks={additionalChecks || []} />
</Collapsible.Content>
</Collapsible.Root>

</>
);
}
77 changes: 77 additions & 0 deletions moon/apps/web/components/MrBox/MergeBox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex">
<FeedMergedIcon size={ 24 } className='text-gray-500 ml-1'/>
<div className="border rounded-lg bg-white divide-y ml-3 w-full">
<ReviewerSection
required={ REQUIRED_REVIEWERS }
onStatusChange={ setIsReviewerApproved }
/>
<ChecksSection
checks={ checks }
onStatusChange={ setHasCheckFailures }
additionalChecks={[
{
type: 'GPG_SIGNATURE',
status: 'Failure',
message: 'GPG 签名验证失败',
errors: ['提交 abc123 缺少有效的 GPG 签名', '提交 def456 的 GPG 签名无法验证']
},
{
type: 'BRANCH_PROTECTION',
status: 'Success',
message: '分支保护规则检查通过'
},
{
type: 'COMMIT_MESSAGE_FORMAT',
status: 'Warning',
message: '部分提交信息不符合规范',
errors: ['提交 "fix bug" 应使用 "fix: bug description" 格式']
}
]}
/>
<MergeSection
isReviewerApproved={ isReviewerApproved }
hasCheckFailures={ hasCheckFailures }
onMerge={ handleMerge }
isMerging={ mrMergeIsPending }
/>
</div>
</div>
);
}
52 changes: 52 additions & 0 deletions moon/apps/web/components/MrBox/MergeSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AlertIcon, WarningTriangleIcon, CheckCircleIcon, LoadingSpinner } from "@gitmono/ui";
import React from "react";

interface MergeSectionProps {
isReviewerApproved: boolean;
hasCheckFailures: boolean;
onMerge: () => Promise<void>;
isMerging: boolean;
}

export function MergeSection({ isReviewerApproved, hasCheckFailures, onMerge, isMerging }: MergeSectionProps) {
let statusNode: React.ReactNode;
const isMergeable = isReviewerApproved && !hasCheckFailures;

if (!isReviewerApproved) {
statusNode = (
<div className="flex items-center text-yellow-700">
<WarningTriangleIcon className="h-5 w-5 mr-3" />
<span className="font-semibold">Merging is blocked</span>
</div>
);
} else if (hasCheckFailures) {
statusNode = (
<div className="flex items-center text-red-700">
<AlertIcon className="h-5 w-5 mr-3" />
<span className="font-semibold">Merging is blocked</span>
</div>
);
} else {
statusNode = (
<div className="flex items-center text-green-700">
<CheckCircleIcon className="h-5 w-5 mr-3" />
<span className="font-semibold">Allow merging</span>
</div>
);
}

return (
<div className="p-3">
{statusNode}
<button
onClick={onMerge}
disabled={!isMergeable}
className="w-full mt-3 px-4 py-2 font-bold text-white bg-green-600 rounded-md
hover:bg-green-700
disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isMerging ? <LoadingSpinner/> : "Confirm Merge"}
</button>
</div>
);
}
45 changes: 45 additions & 0 deletions moon/apps/web/components/MrBox/ReviewerSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center p-3 text-green-700">
<CheckCircleIcon className="h-5 w-5 mr-3" />
<div>
<span className="font-semibold">All required reviewers have approved</span>
</div>
</div>
);
}

return (
<div className="flex items-center p-3 text-gray-800">
<AlertIcon className="h-5 w-5 mr-3 text-yellow-600" />
<div>
<div className="font-semibold">Review required</div>
<div className="ml-auto text-sm text-gray-500">
{`At least ${required} reviewer${required > 1 ? 's' : ''} required with write access.`}
</div>
</div>
</div>
);
}
Loading