@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
3
|
+
import { Select, Tabs } from 'antd'
|
|
4
|
+
import { ArrowLeft, ExternalLink, Sparkles } from 'lucide-react'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
|
|
7
|
+
import { aiApi, STATUS_META, TASK_META, type AIAnalysis, type AITask } from '@/api/ai'
|
|
8
|
+
import { attentionApi } from '@/api/attention'
|
|
9
|
+
import {
|
|
10
|
+
classificationApi,
|
|
11
|
+
KIND_META,
|
|
12
|
+
KIND_ORDER,
|
|
13
|
+
SOURCE_META,
|
|
14
|
+
type PRKind,
|
|
15
|
+
} from '@/api/classification'
|
|
16
|
+
import { pullsApi, type PRCommit, type PRFile, type PullDetail } from '@/api/pulls'
|
|
17
|
+
import { ClassificationBadge } from '@/components/ClassificationBadge'
|
|
18
|
+
import { GateBadge } from '@/components/GateBadge'
|
|
19
|
+
import { DiscussionTimeline } from '@/components/pulls/DiscussionTimeline'
|
|
20
|
+
import { LabelChips } from '@/components/pulls/LabelChips'
|
|
21
|
+
import { SeverityBadge } from '@/components/SeverityBadge'
|
|
22
|
+
import { Alert } from '@/components/ui/alert'
|
|
23
|
+
import { Badge, type BadgeTone } from '@/components/ui/badge'
|
|
24
|
+
import { Button } from '@/components/ui/button'
|
|
25
|
+
import { Card, CardBody, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
26
|
+
import { Input } from '@/components/ui/input'
|
|
27
|
+
import { EmptyState } from '@/components/ui/empty-state'
|
|
28
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
29
|
+
import { cn, formatDateTime, formatRelativeTime } from '@/lib/utils'
|
|
30
|
+
|
|
31
|
+
type Tab = 'overview' | 'commits' | 'files' | 'discussion' | 'ai'
|
|
32
|
+
|
|
33
|
+
const TABS: { key: Tab; label: string }[] = [
|
|
34
|
+
{ key: 'overview', label: '概览' },
|
|
35
|
+
{ key: 'commits', label: '提交' },
|
|
36
|
+
{ key: 'files', label: '文件变更' },
|
|
37
|
+
{ key: 'discussion', label: '讨论' },
|
|
38
|
+
{ key: 'ai', label: 'AI 分析' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** 页签上的条数角标。0 不显示,缺省时也不显示。 */
|
|
42
|
+
function tabCount(key: Tab, pull: PullDetail): number {
|
|
43
|
+
if (key === 'commits') return pull.commits.length
|
|
44
|
+
if (key === 'files') return pull.files.length
|
|
45
|
+
if (key === 'discussion') return pull.comments.length
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tabPane(key: Tab, pull: PullDetail) {
|
|
50
|
+
switch (key) {
|
|
51
|
+
case 'overview':
|
|
52
|
+
return <OverviewTab pull={pull} />
|
|
53
|
+
case 'commits':
|
|
54
|
+
return <CommitsTab pull={pull} />
|
|
55
|
+
case 'files':
|
|
56
|
+
return <FilesTab pull={pull} />
|
|
57
|
+
case 'discussion':
|
|
58
|
+
return <DiscussionTimeline events={pull.review_events} comments={pull.comments} />
|
|
59
|
+
case 'ai':
|
|
60
|
+
return <AiTab pull={pull} />
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function PullDetailPage({ pullId }: { pullId: string }) {
|
|
65
|
+
const [tab, setTab] = useState<Tab>('overview')
|
|
66
|
+
|
|
67
|
+
const { data: pull, isLoading } = useQuery({
|
|
68
|
+
queryKey: ['pull', pullId],
|
|
69
|
+
queryFn: () => pullsApi.detail(pullId),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (isLoading) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-3">
|
|
75
|
+
<Skeleton className="h-7 w-2/3" />
|
|
76
|
+
<Skeleton className="h-40 w-full" />
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!pull) return <EmptyState title="PR 不存在" />
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-4">
|
|
85
|
+
<Link
|
|
86
|
+
to="/pulls"
|
|
87
|
+
search={{}}
|
|
88
|
+
className="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
|
89
|
+
>
|
|
90
|
+
<ArrowLeft className="size-3.5" aria-hidden />
|
|
91
|
+
返回列表
|
|
92
|
+
</Link>
|
|
93
|
+
|
|
94
|
+
<header className="space-y-2">
|
|
95
|
+
<h1 className="text-base font-semibold leading-snug tracking-tight">{pull.title}</h1>
|
|
96
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-[var(--color-text-secondary)]">
|
|
97
|
+
<span className="font-mono text-[var(--color-text-muted)]">#{pull.number}</span>
|
|
98
|
+
<GateBadge stage={pull.gate_stage} />
|
|
99
|
+
<HeaderClassification pullId={pull.id} />
|
|
100
|
+
<span>
|
|
101
|
+
{pull.author_login ?? '未知作者'} → <code>{pull.target_branch}</code>
|
|
102
|
+
</span>
|
|
103
|
+
<span className="text-[var(--color-text-muted)]">
|
|
104
|
+
创建于 {formatDateTime(pull.atomgit_created_at)}
|
|
105
|
+
</span>
|
|
106
|
+
{pull.html_url && (
|
|
107
|
+
<a
|
|
108
|
+
href={pull.html_url}
|
|
109
|
+
target="_blank"
|
|
110
|
+
rel="noreferrer noopener"
|
|
111
|
+
className="inline-flex items-center gap-1 text-[var(--color-accent)] hover:underline"
|
|
112
|
+
>
|
|
113
|
+
在 AtomGit 打开
|
|
114
|
+
<ExternalLink className="size-3" aria-hidden />
|
|
115
|
+
</a>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
</header>
|
|
119
|
+
|
|
120
|
+
<div className="grid gap-4 lg:grid-cols-[1fr_280px]">
|
|
121
|
+
<div className="min-w-0">
|
|
122
|
+
<Tabs
|
|
123
|
+
activeKey={tab}
|
|
124
|
+
onChange={(key) => setTab(key as Tab)}
|
|
125
|
+
items={TABS.map((item) => ({
|
|
126
|
+
key: item.key,
|
|
127
|
+
label: (
|
|
128
|
+
<span className="inline-flex items-center gap-1.5">
|
|
129
|
+
{item.label}
|
|
130
|
+
{tabCount(item.key, pull) > 0 && (
|
|
131
|
+
<span className="font-mono text-2xs text-[var(--color-text-muted)]">
|
|
132
|
+
{tabCount(item.key, pull)}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
</span>
|
|
136
|
+
),
|
|
137
|
+
children: tabPane(item.key, pull),
|
|
138
|
+
}))}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<aside className="space-y-4">
|
|
143
|
+
<GatePanel pull={pull} />
|
|
144
|
+
<LabelsPanel pull={pull} />
|
|
145
|
+
<ClassificationPanel pull={pull} />
|
|
146
|
+
<MetaPanel pull={pull} />
|
|
147
|
+
</aside>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 标题旁的分类标签。分类是"这是什么"的第一印象,放在标题行最省事。 */
|
|
154
|
+
function HeaderClassification({ pullId }: { pullId: string }) {
|
|
155
|
+
const { data } = useQuery({
|
|
156
|
+
queryKey: ['pull-classification', pullId],
|
|
157
|
+
queryFn: () => classificationApi.forPull(pullId),
|
|
158
|
+
})
|
|
159
|
+
if (!data) return null
|
|
160
|
+
return <ClassificationBadge kind={data.kind} source={data.source} />
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function OverviewTab({ pull }: { pull: PullDetail }) {
|
|
164
|
+
if (!pull.body?.trim()) {
|
|
165
|
+
return (
|
|
166
|
+
<EmptyState
|
|
167
|
+
title="该 PR 没有描述内容"
|
|
168
|
+
description="openEuler 的 PR 模板要求填写功能描述与关联 Issue,缺失描述会增加评审成本。"
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return (
|
|
173
|
+
<Card>
|
|
174
|
+
<CardBody>
|
|
175
|
+
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
|
176
|
+
{pull.body}
|
|
177
|
+
</pre>
|
|
178
|
+
</CardBody>
|
|
179
|
+
</Card>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function CommitsTab({ pull }: { pull: PullDetail }) {
|
|
184
|
+
if (pull.commits.length === 0) {
|
|
185
|
+
return (
|
|
186
|
+
<EmptyState
|
|
187
|
+
title="尚未同步提交信息"
|
|
188
|
+
description="补丁列表需要拉取详情,可在「设置 → 仓库与同步」中手动触发一次同步。"
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
return (
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
{pull.commits.map((commit: PRCommit) => (
|
|
195
|
+
<Card key={commit.sha}>
|
|
196
|
+
<CardBody>
|
|
197
|
+
<div className="flex items-start gap-3">
|
|
198
|
+
<span className="mt-0.5 font-mono text-2xs text-[var(--color-text-muted)]">
|
|
199
|
+
{String(commit.sequence).padStart(4, '0')}
|
|
200
|
+
</span>
|
|
201
|
+
<div className="min-w-0 flex-1">
|
|
202
|
+
<p className="text-sm font-medium">{commit.subject}</p>
|
|
203
|
+
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--color-text-muted)]">
|
|
204
|
+
<code>{commit.sha.slice(0, 12)}</code>
|
|
205
|
+
{commit.author_name && <span>{commit.author_name}</span>}
|
|
206
|
+
{commit.committed_at && <span>{formatDateTime(commit.committed_at)}</span>}
|
|
207
|
+
</div>
|
|
208
|
+
{commit.signed_off_bys && commit.signed_off_bys.length === 0 && (
|
|
209
|
+
<Badge tone="warning" className="mt-2">
|
|
210
|
+
缺少 Signed-off-by
|
|
211
|
+
</Badge>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</CardBody>
|
|
216
|
+
</Card>
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function FilesTab({ pull }: { pull: PullDetail }) {
|
|
223
|
+
if (pull.files.length === 0) {
|
|
224
|
+
return <EmptyState title="尚未同步文件变更" description="需要拉取 PR 详情。" />
|
|
225
|
+
}
|
|
226
|
+
return (
|
|
227
|
+
<div className="space-y-2">
|
|
228
|
+
{pull.files.map((file: PRFile) => (
|
|
229
|
+
<Card key={file.filename}>
|
|
230
|
+
<CardHeader className="p-4 pb-2">
|
|
231
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
232
|
+
<code className="break-all text-xs">{file.filename}</code>
|
|
233
|
+
<div className="flex items-center gap-2">
|
|
234
|
+
{file.is_binary && <Badge tone="danger">二进制文件</Badge>}
|
|
235
|
+
{file.too_large && <Badge tone="warning">diff 过大</Badge>}
|
|
236
|
+
<span className="font-mono text-xs tabular-nums">
|
|
237
|
+
<span className="text-[var(--color-success)]">+{file.additions}</span>
|
|
238
|
+
<span className="mx-0.5 text-[var(--color-text-muted)]">/</span>
|
|
239
|
+
<span className="text-[var(--color-danger)]">-{file.deletions}</span>
|
|
240
|
+
</span>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</CardHeader>
|
|
244
|
+
{file.diff && (
|
|
245
|
+
<CardContent className="p-0">
|
|
246
|
+
<pre className="max-h-80 overflow-auto border-t border-[var(--color-border-subtle)] p-3 text-xs leading-relaxed">
|
|
247
|
+
{file.diff.split('\n').map((line: string, index: number) => (
|
|
248
|
+
<div
|
|
249
|
+
key={index}
|
|
250
|
+
className={cn(
|
|
251
|
+
'whitespace-pre-wrap break-all',
|
|
252
|
+
line.startsWith('+') && !line.startsWith('+++') && 'text-[var(--color-success)]',
|
|
253
|
+
line.startsWith('-') && !line.startsWith('---') && 'text-[var(--color-danger)]',
|
|
254
|
+
line.startsWith('@@') && 'text-[var(--color-info)]',
|
|
255
|
+
)}
|
|
256
|
+
>
|
|
257
|
+
{line || ' '}
|
|
258
|
+
</div>
|
|
259
|
+
))}
|
|
260
|
+
</pre>
|
|
261
|
+
</CardContent>
|
|
262
|
+
)}
|
|
263
|
+
</Card>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 标签面板。
|
|
271
|
+
*
|
|
272
|
+
* 标签是上游门禁的载体 —— approved / lgtm / ci_successful / openeuler-cla/yes
|
|
273
|
+
* 这些取值直接决定 PR 能不能合入,因此按原色展示,不重新着色。
|
|
274
|
+
*/
|
|
275
|
+
function LabelsPanel({ pull }: { pull: PullDetail }) {
|
|
276
|
+
if (pull.labels.length === 0) return null
|
|
277
|
+
return (
|
|
278
|
+
<Card>
|
|
279
|
+
<CardHeader>
|
|
280
|
+
<CardTitle>标签</CardTitle>
|
|
281
|
+
</CardHeader>
|
|
282
|
+
<CardContent>
|
|
283
|
+
<LabelChips labels={pull.labels} />
|
|
284
|
+
</CardContent>
|
|
285
|
+
</Card>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 分类面板。
|
|
291
|
+
*
|
|
292
|
+
* 三项信息缺一不可:判成了什么、凭什么这么判、能不能改。
|
|
293
|
+
* 只显示结论的话,分类错了维护者只能干看着 —— 而自动分类一定会有错的时候。
|
|
294
|
+
*/
|
|
295
|
+
function ClassificationPanel({ pull }: { pull: PullDetail }) {
|
|
296
|
+
const queryClient = useQueryClient()
|
|
297
|
+
const [editing, setEditing] = useState(false)
|
|
298
|
+
const [kind, setKind] = useState<PRKind>('unknown')
|
|
299
|
+
const [subsystem, setSubsystem] = useState('')
|
|
300
|
+
|
|
301
|
+
const { data, isLoading } = useQuery({
|
|
302
|
+
queryKey: ['pull-classification', pull.id],
|
|
303
|
+
queryFn: () => classificationApi.forPull(pull.id),
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const { data: attention } = useQuery({
|
|
307
|
+
queryKey: ['pull-attention', pull.id],
|
|
308
|
+
queryFn: () => attentionApi.forPull(pull.id),
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const invalidate = () => {
|
|
312
|
+
queryClient.invalidateQueries({ queryKey: ['pull-classification', pull.id] })
|
|
313
|
+
queryClient.invalidateQueries({ queryKey: ['classifications'] })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const saveOverride = useMutation({
|
|
317
|
+
mutationFn: () => classificationApi.override(data!.id, kind, subsystem || null),
|
|
318
|
+
onSuccess: () => {
|
|
319
|
+
setEditing(false)
|
|
320
|
+
invalidate()
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const clearOverride = useMutation({
|
|
325
|
+
mutationFn: () => classificationApi.clearOverride(data!.id),
|
|
326
|
+
onSuccess: invalidate,
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const recompute = useMutation({
|
|
330
|
+
mutationFn: () => classificationApi.recompute(pull.repository_id),
|
|
331
|
+
onSuccess: invalidate,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<Card>
|
|
336
|
+
<CardHeader>
|
|
337
|
+
<CardTitle>分类</CardTitle>
|
|
338
|
+
</CardHeader>
|
|
339
|
+
<CardContent className="space-y-3">
|
|
340
|
+
{isLoading ? (
|
|
341
|
+
<Skeleton className="h-10 w-full" />
|
|
342
|
+
) : data ? (
|
|
343
|
+
<>
|
|
344
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
345
|
+
<ClassificationBadge kind={data.kind} source={data.source} />
|
|
346
|
+
{data.subsystem && (
|
|
347
|
+
<span className="text-sm text-[var(--color-text-secondary)]">
|
|
348
|
+
{data.subsystem_label ?? data.subsystem}
|
|
349
|
+
</span>
|
|
350
|
+
)}
|
|
351
|
+
{data.confidence < 1 && (
|
|
352
|
+
<span className="font-mono text-2xs text-[var(--color-text-muted)]">
|
|
353
|
+
置信度 {Math.round(data.confidence * 100)}%
|
|
354
|
+
</span>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
{data.reason && (
|
|
359
|
+
<p className="text-xs leading-relaxed text-[var(--color-text-muted)]">
|
|
360
|
+
{data.reason}
|
|
361
|
+
{SOURCE_META[data.source] && `(${SOURCE_META[data.source].hint})`}
|
|
362
|
+
</p>
|
|
363
|
+
)}
|
|
364
|
+
|
|
365
|
+
{data.cve_ids && data.cve_ids.length > 0 && (
|
|
366
|
+
<div className="flex flex-wrap gap-1.5">
|
|
367
|
+
{data.cve_ids.map((cve) => (
|
|
368
|
+
<Badge key={cve} tone="danger">
|
|
369
|
+
{cve}
|
|
370
|
+
</Badge>
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{data.related_subsystems && data.related_subsystems.length > 0 && (
|
|
376
|
+
<p className="text-xs text-[var(--color-text-muted)]">
|
|
377
|
+
相关子系统:{data.related_subsystems.join('、')}
|
|
378
|
+
</p>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{editing ? (
|
|
382
|
+
<div className="space-y-2 border-t border-[var(--color-border-subtle)] pt-3">
|
|
383
|
+
{/* optionFilterProp 默认按 value 过滤,而 value 是 driver_update
|
|
384
|
+
这类英文标识 —— 使用者记得住的是「驱动更新」。13 个类别配搜索
|
|
385
|
+
才有用,配了却搜不到中文就等于没有。 */}
|
|
386
|
+
<Select
|
|
387
|
+
aria-label="类型"
|
|
388
|
+
className="w-full"
|
|
389
|
+
showSearch
|
|
390
|
+
optionFilterProp="label"
|
|
391
|
+
options={KIND_OPTIONS}
|
|
392
|
+
value={kind}
|
|
393
|
+
onChange={(value) => setKind(value as PRKind)}
|
|
394
|
+
/>
|
|
395
|
+
<Input
|
|
396
|
+
aria-label="子系统"
|
|
397
|
+
placeholder="子系统标识,如 net / mm"
|
|
398
|
+
value={subsystem}
|
|
399
|
+
onChange={(event) => setSubsystem(event.target.value)}
|
|
400
|
+
/>
|
|
401
|
+
<div className="flex gap-2">
|
|
402
|
+
<Button
|
|
403
|
+
size="sm"
|
|
404
|
+
onClick={() => saveOverride.mutate()}
|
|
405
|
+
loading={saveOverride.isPending}
|
|
406
|
+
>
|
|
407
|
+
保存
|
|
408
|
+
</Button>
|
|
409
|
+
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
|
410
|
+
取消
|
|
411
|
+
</Button>
|
|
412
|
+
</div>
|
|
413
|
+
<p className="text-2xs text-[var(--color-text-muted)]">
|
|
414
|
+
人工指定后,规则重算与 AI 补判都不会再改写这条记录。
|
|
415
|
+
</p>
|
|
416
|
+
</div>
|
|
417
|
+
) : (
|
|
418
|
+
<div className="flex gap-2 border-t border-[var(--color-border-subtle)] pt-3">
|
|
419
|
+
<Button
|
|
420
|
+
variant="ghost"
|
|
421
|
+
size="sm"
|
|
422
|
+
onClick={() => {
|
|
423
|
+
setKind(data.kind)
|
|
424
|
+
setSubsystem(data.subsystem ?? '')
|
|
425
|
+
setEditing(true)
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
修改
|
|
429
|
+
</Button>
|
|
430
|
+
{data.is_override && (
|
|
431
|
+
<Button
|
|
432
|
+
variant="ghost"
|
|
433
|
+
size="sm"
|
|
434
|
+
onClick={() => clearOverride.mutate()}
|
|
435
|
+
loading={clearOverride.isPending}
|
|
436
|
+
>
|
|
437
|
+
撤销人工指定
|
|
438
|
+
</Button>
|
|
439
|
+
)}
|
|
440
|
+
{!data.is_override && (
|
|
441
|
+
<Button
|
|
442
|
+
variant="ghost"
|
|
443
|
+
size="sm"
|
|
444
|
+
onClick={() => recompute.mutate()}
|
|
445
|
+
loading={recompute.isPending}
|
|
446
|
+
>
|
|
447
|
+
重算
|
|
448
|
+
</Button>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</>
|
|
453
|
+
) : (
|
|
454
|
+
<p className="text-sm text-[var(--color-text-muted)]">
|
|
455
|
+
尚未分类。分类由定时任务执行,可在「设置 → 仓库与同步」触发同步后重试。
|
|
456
|
+
</p>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{attention && attention.length > 0 && (
|
|
460
|
+
<div className="border-t border-[var(--color-border-subtle)] pt-3">
|
|
461
|
+
<p className="mb-1.5 text-xs text-[var(--color-text-muted)]">
|
|
462
|
+
该 PR 在关注队列中
|
|
463
|
+
</p>
|
|
464
|
+
<ul className="space-y-1.5">
|
|
465
|
+
{attention.map((item) => (
|
|
466
|
+
<li key={item.id} className="flex items-start gap-2">
|
|
467
|
+
<SeverityBadge severity={item.severity} />
|
|
468
|
+
<div className="min-w-0">
|
|
469
|
+
<div className="text-xs">{item.title}</div>
|
|
470
|
+
{item.detail && (
|
|
471
|
+
<div className="text-2xs text-[var(--color-text-muted)]">
|
|
472
|
+
{item.detail}
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</li>
|
|
477
|
+
))}
|
|
478
|
+
</ul>
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
</CardContent>
|
|
482
|
+
</Card>
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// 下拉选项与列表筛选共用同一份顺序定义,避免两处手工维护后漂移
|
|
487
|
+
const KIND_OPTIONS = KIND_ORDER.map((value) => ({ value, label: KIND_META[value].label }))
|
|
488
|
+
|
|
489
|
+
const TASK_OPTIONS = (['summarize', 'risk_review', 'backport_verify', 'classify'] as AITask[]).map(
|
|
490
|
+
(value) => ({ value, label: TASK_META[value].label }),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* AI 分析面板。
|
|
495
|
+
*
|
|
496
|
+
* 只展示已有结果,不自动触发 —— 每次调用都要花钱,
|
|
497
|
+
* 让"什么时候分析"成为使用者的显式决定。
|
|
498
|
+
*/
|
|
499
|
+
function AiTab({ pull }: { pull: PullDetail }) {
|
|
500
|
+
const queryClient = useQueryClient()
|
|
501
|
+
const [task, setTask] = useState<AITask>('summarize')
|
|
502
|
+
const [error, setError] = useState<string | null>(null)
|
|
503
|
+
|
|
504
|
+
const { data, isLoading } = useQuery({
|
|
505
|
+
queryKey: ['pull-analyses', pull.id],
|
|
506
|
+
queryFn: () => aiApi.analyses('pull_request', pull.id),
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
const analyze = useMutation({
|
|
510
|
+
mutationFn: (force: boolean) => aiApi.analyzePull(pull.id, task, force),
|
|
511
|
+
onSuccess: async (analysis) => {
|
|
512
|
+
setError(analysis.error)
|
|
513
|
+
await queryClient.invalidateQueries({ queryKey: ['pull-analyses', pull.id] })
|
|
514
|
+
await queryClient.invalidateQueries({ queryKey: ['pull-classification', pull.id] })
|
|
515
|
+
},
|
|
516
|
+
onError: (cause) => setError(cause instanceof Error ? cause.message : '分析失败'),
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div className="space-y-3">
|
|
521
|
+
<Card>
|
|
522
|
+
<CardBody className="space-y-3">
|
|
523
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
524
|
+
<Select
|
|
525
|
+
aria-label="分析任务"
|
|
526
|
+
className="w-44"
|
|
527
|
+
options={TASK_OPTIONS}
|
|
528
|
+
value={task}
|
|
529
|
+
onChange={(value) => setTask(value as AITask)}
|
|
530
|
+
/>
|
|
531
|
+
<Button size="sm" onClick={() => analyze.mutate(false)} loading={analyze.isPending}>
|
|
532
|
+
<Sparkles className="size-3.5" aria-hidden />
|
|
533
|
+
分析
|
|
534
|
+
</Button>
|
|
535
|
+
<Button
|
|
536
|
+
variant="ghost"
|
|
537
|
+
size="sm"
|
|
538
|
+
onClick={() => analyze.mutate(true)}
|
|
539
|
+
loading={analyze.isPending}
|
|
540
|
+
>
|
|
541
|
+
强制重跑
|
|
542
|
+
</Button>
|
|
543
|
+
<span className="text-xs text-[var(--color-text-muted)]">
|
|
544
|
+
{TASK_META[task].purpose}
|
|
545
|
+
</span>
|
|
546
|
+
</div>
|
|
547
|
+
<p className="text-2xs leading-relaxed text-[var(--color-text-muted)]">
|
|
548
|
+
内容未变时不会重复调用模型;「强制重跑」才会绕开该检查。
|
|
549
|
+
风险审查与 Backport 核对需要已同步的补丁内容,否则结论不可信。
|
|
550
|
+
</p>
|
|
551
|
+
</CardBody>
|
|
552
|
+
</Card>
|
|
553
|
+
|
|
554
|
+
{error && <Alert tone="danger">{error}</Alert>}
|
|
555
|
+
|
|
556
|
+
{isLoading ? (
|
|
557
|
+
<Skeleton className="h-32 w-full" />
|
|
558
|
+
) : !data || data.length === 0 ? (
|
|
559
|
+
<EmptyState
|
|
560
|
+
title="尚无分析结果"
|
|
561
|
+
description="选择任务后点击「分析」。AI 调用按次计费,因此不自动执行。"
|
|
562
|
+
/>
|
|
563
|
+
) : (
|
|
564
|
+
data.map((analysis) => <AnalysisCard key={analysis.id} analysis={analysis} />)
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function AnalysisCard({ analysis }: { analysis: AIAnalysis }) {
|
|
571
|
+
const status = STATUS_META[analysis.status]
|
|
572
|
+
return (
|
|
573
|
+
<Card>
|
|
574
|
+
<CardHeader className="p-4 pb-2">
|
|
575
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
576
|
+
<CardTitle className="text-sm">{TASK_META[analysis.task].label}</CardTitle>
|
|
577
|
+
<Badge tone={status.tone}>{status.label}</Badge>
|
|
578
|
+
{analysis.model && (
|
|
579
|
+
<span className="font-mono text-2xs text-[var(--color-text-muted)]">
|
|
580
|
+
{analysis.provider_name}/{analysis.model}
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
<span className="ml-auto text-2xs text-[var(--color-text-muted)]">
|
|
584
|
+
{formatRelativeTime(analysis.created_at)}
|
|
585
|
+
{analysis.prompt_tokens + analysis.completion_tokens > 0 &&
|
|
586
|
+
` · ${analysis.prompt_tokens + analysis.completion_tokens} tokens`}
|
|
587
|
+
</span>
|
|
588
|
+
</div>
|
|
589
|
+
</CardHeader>
|
|
590
|
+
<CardBody className="pt-0">
|
|
591
|
+
{analysis.error && <Alert tone="danger">{analysis.error}</Alert>}
|
|
592
|
+
{analysis.result && <AnalysisResult task={analysis.task} result={analysis.result} />}
|
|
593
|
+
</CardBody>
|
|
594
|
+
</Card>
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** 按任务渲染结构化结果。每种任务的输出形状不同,统一渲染只会两边都难看。 */
|
|
599
|
+
function AnalysisResult({ task, result }: { task: AITask; result: Record<string, unknown> }) {
|
|
600
|
+
if (task === 'summarize') {
|
|
601
|
+
const changes = asStringArray(result.changes)
|
|
602
|
+
const risks = asStringArray(result.risk_areas)
|
|
603
|
+
return (
|
|
604
|
+
<div className="space-y-2.5">
|
|
605
|
+
{typeof result.summary === 'string' && (
|
|
606
|
+
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
|
607
|
+
{result.summary}
|
|
608
|
+
</p>
|
|
609
|
+
)}
|
|
610
|
+
{changes.length > 0 && <BulletList title="主要变更" items={changes} />}
|
|
611
|
+
{risks.length > 0 && <BulletList title="需重点看" items={risks} tone="warning" />}
|
|
612
|
+
</div>
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (task === 'risk_review') {
|
|
617
|
+
const risks = (result.risks as Record<string, unknown>[] | undefined) ?? []
|
|
618
|
+
const questions = asStringArray(result.questions)
|
|
619
|
+
const formatIssues = asStringArray(result.patch_format_issues)
|
|
620
|
+
const kabi = result.kabi_impact as Record<string, unknown> | undefined
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<div className="space-y-3">
|
|
624
|
+
{typeof result.summary === 'string' && (
|
|
625
|
+
<p className="text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
|
626
|
+
{result.summary}
|
|
627
|
+
</p>
|
|
628
|
+
)}
|
|
629
|
+
|
|
630
|
+
{kabi?.breaking === true && (
|
|
631
|
+
<Alert tone="danger">KABI 兼容性:{String(kabi.detail ?? '存在破坏性变更')}</Alert>
|
|
632
|
+
)}
|
|
633
|
+
|
|
634
|
+
{risks.length === 0 ? (
|
|
635
|
+
<p className="text-sm text-[var(--color-text-muted)]">未发现具体缺陷。</p>
|
|
636
|
+
) : (
|
|
637
|
+
<ul className="space-y-2">
|
|
638
|
+
{risks.map((risk, index) => (
|
|
639
|
+
<li
|
|
640
|
+
key={index}
|
|
641
|
+
className="rounded-[var(--radius-control)] border border-[var(--color-border-subtle)] p-3"
|
|
642
|
+
>
|
|
643
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
644
|
+
<Badge tone={severityTone(String(risk.severity))}>
|
|
645
|
+
{String(risk.severity ?? '提示')}
|
|
646
|
+
</Badge>
|
|
647
|
+
<code className="text-xs text-[var(--color-text-secondary)]">
|
|
648
|
+
{String(risk.file ?? '')}
|
|
649
|
+
</code>
|
|
650
|
+
{typeof risk.location === 'string' && risk.location && (
|
|
651
|
+
<span className="text-xs text-[var(--color-text-muted)]">
|
|
652
|
+
{risk.location}
|
|
653
|
+
</span>
|
|
654
|
+
)}
|
|
655
|
+
</div>
|
|
656
|
+
<p className="mt-1.5 text-sm">{String(risk.issue ?? '')}</p>
|
|
657
|
+
{typeof risk.rationale === 'string' && (
|
|
658
|
+
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
|
659
|
+
{risk.rationale}
|
|
660
|
+
</p>
|
|
661
|
+
)}
|
|
662
|
+
{typeof risk.suggestion === 'string' && (
|
|
663
|
+
<p className="mt-1 text-xs text-[var(--color-info)]">
|
|
664
|
+
建议:{risk.suggestion}
|
|
665
|
+
</p>
|
|
666
|
+
)}
|
|
667
|
+
</li>
|
|
668
|
+
))}
|
|
669
|
+
</ul>
|
|
670
|
+
)}
|
|
671
|
+
|
|
672
|
+
{questions.length > 0 && <BulletList title="需作者澄清" items={questions} tone="info" />}
|
|
673
|
+
{formatIssues.length > 0 && (
|
|
674
|
+
<BulletList title="补丁格式问题" items={formatIssues} tone="warning" />
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (task === 'classify') {
|
|
681
|
+
return (
|
|
682
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
683
|
+
<ClassificationBadge kind={String(result.kind ?? 'unknown') as PRKind} source="ai" />
|
|
684
|
+
{typeof result.subsystem === 'string' && result.subsystem && (
|
|
685
|
+
<span className="text-[var(--color-text-secondary)]">
|
|
686
|
+
{result.subsystem}
|
|
687
|
+
</span>
|
|
688
|
+
)}
|
|
689
|
+
{typeof result.confidence === 'number' && (
|
|
690
|
+
<span className="font-mono text-2xs text-[var(--color-text-muted)]">
|
|
691
|
+
置信度 {Math.round(result.confidence * 100)}%
|
|
692
|
+
</span>
|
|
693
|
+
)}
|
|
694
|
+
</div>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// backport_verify 及其他:原样展示结构化 JSON,胜过用一套通用模板强行套
|
|
699
|
+
return (
|
|
700
|
+
<pre className="overflow-auto rounded-[var(--radius-control)] bg-[var(--color-surface-2)] p-3 text-xs leading-relaxed">
|
|
701
|
+
{JSON.stringify(result, null, 2)}
|
|
702
|
+
</pre>
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function BulletList({
|
|
707
|
+
title,
|
|
708
|
+
items,
|
|
709
|
+
tone,
|
|
710
|
+
}: {
|
|
711
|
+
title: string
|
|
712
|
+
items: string[]
|
|
713
|
+
tone?: 'warning' | 'info'
|
|
714
|
+
}) {
|
|
715
|
+
return (
|
|
716
|
+
<div>
|
|
717
|
+
<div className="mb-1 text-xs text-[var(--color-text-muted)]">{title}</div>
|
|
718
|
+
<ul className="space-y-1">
|
|
719
|
+
{items.map((item, index) => (
|
|
720
|
+
<li key={index} className="flex items-start gap-2 text-sm">
|
|
721
|
+
<span
|
|
722
|
+
aria-hidden
|
|
723
|
+
className={cn(
|
|
724
|
+
'mt-1.5 size-1.5 shrink-0 rounded-full',
|
|
725
|
+
tone === 'warning'
|
|
726
|
+
? 'bg-[var(--color-warning)]'
|
|
727
|
+
: tone === 'info'
|
|
728
|
+
? 'bg-[var(--color-info)]'
|
|
729
|
+
: 'bg-[var(--color-text-muted)]',
|
|
730
|
+
)}
|
|
731
|
+
/>
|
|
732
|
+
<span className="text-[var(--color-text-secondary)]">{item}</span>
|
|
733
|
+
</li>
|
|
734
|
+
))}
|
|
735
|
+
</ul>
|
|
736
|
+
</div>
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function asStringArray(value: unknown): string[] {
|
|
741
|
+
if (!Array.isArray(value)) return []
|
|
742
|
+
return value.filter((item): item is string => typeof item === 'string')
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function severityTone(severity: string): BadgeTone {
|
|
746
|
+
switch (severity) {
|
|
747
|
+
case 'blocker':
|
|
748
|
+
case 'critical':
|
|
749
|
+
return 'danger'
|
|
750
|
+
case 'warning':
|
|
751
|
+
return 'warning'
|
|
752
|
+
default:
|
|
753
|
+
return 'neutral'
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function GatePanel({ pull }: { pull: PullDetail }) {
|
|
758
|
+
return (
|
|
759
|
+
<Card>
|
|
760
|
+
<CardHeader>
|
|
761
|
+
<CardTitle>合并门禁</CardTitle>
|
|
762
|
+
</CardHeader>
|
|
763
|
+
<CardContent className="space-y-3">
|
|
764
|
+
<div className="flex items-center gap-2">
|
|
765
|
+
<GateBadge stage={pull.gate_stage} />
|
|
766
|
+
{pull.ready_to_merge && (
|
|
767
|
+
<span className="text-xs text-[var(--color-success)]">可合入</span>
|
|
768
|
+
)}
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
{pull.blocking_reasons.length > 0 ? (
|
|
772
|
+
<ul className="space-y-1.5">
|
|
773
|
+
{pull.blocking_reasons.map((reason: string) => (
|
|
774
|
+
<li
|
|
775
|
+
key={reason}
|
|
776
|
+
className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)]"
|
|
777
|
+
>
|
|
778
|
+
<span
|
|
779
|
+
aria-hidden
|
|
780
|
+
className="size-1.5 shrink-0 rounded-full bg-[var(--color-warning)]"
|
|
781
|
+
/>
|
|
782
|
+
{reason}
|
|
783
|
+
</li>
|
|
784
|
+
))}
|
|
785
|
+
</ul>
|
|
786
|
+
) : (
|
|
787
|
+
<p className="text-sm text-[var(--color-success)]">门禁条件均已满足</p>
|
|
788
|
+
)}
|
|
789
|
+
|
|
790
|
+
{pull.lgtm_actors.length > 0 && (
|
|
791
|
+
<div className="border-t border-[var(--color-border-subtle)] pt-3">
|
|
792
|
+
<p className="mb-1.5 text-xs text-[var(--color-text-muted)]">已 LGTM 的评审人</p>
|
|
793
|
+
<div className="flex flex-wrap gap-1.5">
|
|
794
|
+
{pull.lgtm_actors.map((actor: string) => (
|
|
795
|
+
<Badge key={actor} tone="success">
|
|
796
|
+
{actor}
|
|
797
|
+
</Badge>
|
|
798
|
+
))}
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
)}
|
|
802
|
+
</CardContent>
|
|
803
|
+
</Card>
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function MetaPanel({ pull }: { pull: PullDetail }) {
|
|
808
|
+
const rows: [string, React.ReactNode][] = [
|
|
809
|
+
['状态', pull.state === 'open' ? '待处理' : pull.state === 'merged' ? '已合并' : '已关闭'],
|
|
810
|
+
['源分支', <code key="s">{pull.source_branch ?? '—'}</code>],
|
|
811
|
+
['目标分支', <code key="t">{pull.target_branch ?? '—'}</code>],
|
|
812
|
+
['变更行数', `${pull.added_lines} / ${pull.removed_lines}`],
|
|
813
|
+
['变更文件', pull.changed_files || '—'],
|
|
814
|
+
['评论数', pull.comments_count],
|
|
815
|
+
['合并冲突', pull.merge_conflict ? '是' : '否'],
|
|
816
|
+
['最后更新', formatRelativeTime(pull.atomgit_updated_at)],
|
|
817
|
+
]
|
|
818
|
+
return (
|
|
819
|
+
<Card>
|
|
820
|
+
<CardHeader>
|
|
821
|
+
<CardTitle>元信息</CardTitle>
|
|
822
|
+
</CardHeader>
|
|
823
|
+
<CardContent className="space-y-2">
|
|
824
|
+
{rows.map(([label, value]) => (
|
|
825
|
+
<div key={label} className="flex items-center justify-between gap-3 text-sm">
|
|
826
|
+
<span className="text-[var(--color-text-muted)]">{label}</span>
|
|
827
|
+
<span className="text-right text-[var(--color-text-secondary)]">{value}</span>
|
|
828
|
+
</div>
|
|
829
|
+
))}
|
|
830
|
+
</CardContent>
|
|
831
|
+
</Card>
|
|
832
|
+
)
|
|
833
|
+
}
|