@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.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { Sidebar } from './Sidebar'
4
+ import { TopBar } from './TopBar'
5
+
6
+ export function AppShell({ children }: { children: ReactNode }) {
7
+ return (
8
+ <div className="flex h-full">
9
+ <Sidebar />
10
+ <div className="flex min-w-0 flex-1 flex-col">
11
+ <TopBar />
12
+ <main className="flex-1 overflow-y-auto p-6">{children}</main>
13
+ </div>
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,32 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export function AuthLayout({
4
+ title,
5
+ description,
6
+ children,
7
+ }: {
8
+ title: string
9
+ description: string
10
+ children: ReactNode
11
+ }) {
12
+ return (
13
+ <div className="flex min-h-full items-center justify-center p-6">
14
+ <div className="w-full max-w-[380px]">
15
+ <div className="mb-7">
16
+ <div className="mb-5 flex items-center gap-2.5">
17
+ <div
18
+ aria-hidden
19
+ className="grid size-8 place-items-center rounded-[var(--radius-control)] bg-[var(--color-accent)] text-base font-bold text-white"
20
+ >
21
+ K
22
+ </div>
23
+ <span className="text-sm font-semibold tracking-tight">Kernel SIG Console</span>
24
+ </div>
25
+ <h1 className="text-lg font-semibold tracking-tight">{title}</h1>
26
+ <p className="mt-1 text-sm text-[var(--color-text-secondary)]">{description}</p>
27
+ </div>
28
+ {children}
29
+ </div>
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,223 @@
1
+ import { Link, useNavigate } from '@tanstack/react-router'
2
+ import { Select } from 'antd'
3
+ import {
4
+ Activity,
5
+ BarChart3,
6
+ Bot,
7
+ CalendarDays,
8
+ GitPullRequest,
9
+ GitBranch,
10
+ LayoutDashboard,
11
+ ListChecks,
12
+ ScrollText,
13
+ Settings,
14
+ ShieldCheck,
15
+ Users,
16
+ type LucideIcon,
17
+ } from 'lucide-react'
18
+
19
+ import type { Permission } from '@/api/auth'
20
+ import { repoKey, type Repository } from '@/api/repositories'
21
+ import { useCurrentRepository } from '@/hooks/use-current-repository'
22
+ import { can, useCurrentUser } from '@/hooks/use-current-user'
23
+ import { cn } from '@/lib/utils'
24
+
25
+ interface NavItem {
26
+ /** 已实现的模块指向具体路由;未实现的留空并置 soon。 */
27
+ to?:
28
+ | '/dashboard'
29
+ | '/pulls'
30
+ | '/issues'
31
+ | '/attention'
32
+ | '/branches'
33
+ | '/meetings'
34
+ | '/members'
35
+ | '/settings/general'
36
+ | '/settings/ai'
37
+ label: string
38
+ icon: LucideIcon
39
+ permission?: Permission
40
+ soon?: boolean
41
+ /** 属于当前仓库的视图:这些页面把仓库带在地址里,切换器只管它们。 */
42
+ repoScoped?: boolean
43
+ }
44
+
45
+ interface NavSection {
46
+ title?: string
47
+ items: NavItem[]
48
+ }
49
+
50
+ const SECTIONS: NavSection[] = [
51
+ {
52
+ // 这一组的每一项都属于**当前仓库**。分出来是有意的:纳管两个仓库之后,
53
+ // "这条 PR 是哪个仓库的"必须先有答案,而这些页面正是答案的那一半。
54
+ title: '仓库视图',
55
+ items: [
56
+ { to: '/dashboard', label: '总览', icon: LayoutDashboard, repoScoped: true },
57
+ { to: '/pulls', label: 'PR', icon: GitPullRequest, repoScoped: true },
58
+ { to: '/issues', label: 'Issue', icon: ListChecks, repoScoped: true },
59
+ { to: '/attention', label: '关注队列', icon: Activity, repoScoped: true },
60
+ { to: '/branches', label: '版本与分支', icon: GitBranch, repoScoped: true },
61
+ { label: '评审中心', icon: ShieldCheck, soon: true },
62
+ { label: '统计', icon: BarChart3, soon: true },
63
+ ],
64
+ },
65
+ {
66
+ // SIG 的事务与仓库无关:例会纪要和成员名单都来自 community 仓库的
67
+ // sig/Kernel 文档,换仓库不会换出另一份名单。
68
+ title: 'SIG',
69
+ items: [
70
+ { to: '/meetings', label: 'SIG 例会', icon: CalendarDays },
71
+ { to: '/members', label: '成员与归属', icon: Users },
72
+ ],
73
+ },
74
+ {
75
+ title: '管理',
76
+ items: [
77
+ { label: '用户', icon: Users, permission: 'manage_users', soon: true },
78
+ {
79
+ to: '/settings/ai',
80
+ label: 'AI 对接',
81
+ icon: Bot,
82
+ permission: 'manage_credentials',
83
+ },
84
+ {
85
+ to: '/settings/general',
86
+ label: '仓库与同步',
87
+ icon: Settings,
88
+ permission: 'manage_repository',
89
+ },
90
+ { label: '审计日志', icon: ScrollText, permission: 'view_audit_log', soon: true },
91
+ ],
92
+ },
93
+ ]
94
+
95
+ export function Sidebar() {
96
+ const { user } = useCurrentUser()
97
+ const { repository, repositories, key: repo } = useCurrentRepository()
98
+
99
+ return (
100
+ <aside className="flex w-56 shrink-0 flex-col border-r border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
101
+ <div className="flex h-14 items-center gap-2.5 border-b border-[var(--color-border-subtle)] px-4">
102
+ <div
103
+ aria-hidden
104
+ className="grid size-7 place-items-center rounded-[var(--radius-control)] bg-[var(--color-accent)] text-sm font-bold text-white"
105
+ >
106
+ K
107
+ </div>
108
+ <span className="text-sm font-semibold tracking-tight">Kernel SIG</span>
109
+ </div>
110
+
111
+ <RepositorySwitcher repository={repository} repositories={repositories} />
112
+
113
+ <nav className="flex-1 overflow-y-auto p-2">
114
+ {SECTIONS.map((section, index) => {
115
+ const visible = section.items.filter(
116
+ (item) => !item.permission || can(user, item.permission),
117
+ )
118
+ if (visible.length === 0) return null
119
+
120
+ return (
121
+ <div key={section.title ?? `section-${index}`} className={cn(index > 0 && 'mt-4')}>
122
+ {section.title && (
123
+ <div className="px-2.5 pb-1.5 text-2xs font-medium tracking-wide text-[var(--color-text-muted)]">
124
+ {section.title}
125
+ </div>
126
+ )}
127
+ <ul className="space-y-0.5">
128
+ {visible.map((item) => (
129
+ <li key={item.label}>
130
+ <NavEntry item={item} repo={repo} />
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ </div>
135
+ )
136
+ })}
137
+ </nav>
138
+ </aside>
139
+ )
140
+ }
141
+
142
+ const entryClass =
143
+ 'flex items-center gap-2.5 rounded-[var(--radius-control)] px-2.5 py-1.5 text-sm font-medium transition-colors duration-150'
144
+
145
+ function NavEntry({ item, repo }: { item: NavItem; repo: string | undefined }) {
146
+ const Icon = item.icon
147
+
148
+ if (item.soon || !item.to) {
149
+ return (
150
+ <div
151
+ aria-disabled="true"
152
+ title="尚未开放"
153
+ className={cn(entryClass, 'cursor-default text-[var(--color-text-muted)] opacity-60')}
154
+ >
155
+ <Icon className="size-4 shrink-0" aria-hidden />
156
+ <span className="flex-1">{item.label}</span>
157
+ <span className="text-2xs tracking-tight">即将开放</span>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ return (
163
+ <Link
164
+ to={item.to}
165
+ // 仓库维度的页面把仓库带在地址里,点进去才不会掉回"第一个仓库"
166
+ search={item.repoScoped && repo ? { repo } : undefined}
167
+ activeProps={{
168
+ className: 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]',
169
+ }}
170
+ inactiveProps={{
171
+ className:
172
+ 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]',
173
+ }}
174
+ className={entryClass}
175
+ >
176
+ <Icon className="size-4 shrink-0" aria-hidden />
177
+ {item.label}
178
+ </Link>
179
+ )
180
+ }
181
+
182
+ /**
183
+ * 仓库切换器。
184
+ *
185
+ * 放在侧栏最上方、紧挨着"仓库视图"那组,是为了回答一个随时会出现的问题:
186
+ * 我现在看的这些数字是哪个仓库的。纳管两个仓库之后,"第一个仓库"已经不是
187
+ * 一个使用者能理解的概念了。
188
+ *
189
+ * 切换时只换地址里的 repo,停在当前页面 —— 在 PR 列表里比较两个仓库是常事。
190
+ * 若当前页面在新仓库里不成立(比如那个分支名不存在),页面自己会说明。
191
+ */
192
+ function RepositorySwitcher({
193
+ repository,
194
+ repositories,
195
+ }: {
196
+ repository: Repository | undefined
197
+ repositories: Repository[]
198
+ }) {
199
+ const navigate = useNavigate()
200
+
201
+ // 只有一个仓库时不占位置:那时候没有可切的,放个下拉只是噪音
202
+ if (repositories.length < 2) return null
203
+
204
+ return (
205
+ <div className="border-b border-[var(--color-border-subtle)] p-2">
206
+ <Select
207
+ aria-label="当前仓库"
208
+ className="w-full"
209
+ size="small"
210
+ value={repository ? repoKey(repository) : undefined}
211
+ options={repositories.map((item) => ({
212
+ value: repoKey(item),
213
+ label: item.display_name ?? repoKey(item),
214
+ }))}
215
+ onChange={(value) =>
216
+ // 相对当前路由,只换 repo:在 PR 列表里比较两个仓库是常事,
217
+ // 跳到别处反而丢了上下文
218
+ navigate({ to: '.', search: (prev) => ({ ...prev, repo: value }) })
219
+ }
220
+ />
221
+ </div>
222
+ )
223
+ }
@@ -0,0 +1,47 @@
1
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
2
+ import { useNavigate } from '@tanstack/react-router'
3
+ import { LogOut } from 'lucide-react'
4
+
5
+ import { authApi } from '@/api/auth'
6
+ import { Button } from '@/components/ui/button'
7
+ import { useCurrentUser } from '@/hooks/use-current-user'
8
+
9
+ export function TopBar() {
10
+ const navigate = useNavigate()
11
+ const queryClient = useQueryClient()
12
+ const { user } = useCurrentUser()
13
+
14
+ const logout = useMutation({
15
+ mutationFn: authApi.logout,
16
+ onSuccess: async () => {
17
+ // 清空缓存让会话探测重新执行;此时 /auth/me 会返回 401,
18
+ // RequireAuth 随即把用户送回登录页。
19
+ queryClient.clear()
20
+ await navigate({ to: '/login' })
21
+ },
22
+ })
23
+
24
+ return (
25
+ <header className="flex h-14 shrink-0 items-center justify-end gap-3 border-b border-[var(--color-border-subtle)] px-4">
26
+ {user && (
27
+ <div className="flex items-center gap-2">
28
+ <span className="text-sm text-[var(--color-text-secondary)]">
29
+ {user.display_name}
30
+ </span>
31
+ <span className="rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-2xs text-[var(--color-text-muted)]">
32
+ {user.role}
33
+ </span>
34
+ </div>
35
+ )}
36
+ <Button
37
+ variant="ghost"
38
+ size="sm"
39
+ onClick={() => logout.mutate()}
40
+ loading={logout.isPending}
41
+ aria-label="退出登录"
42
+ >
43
+ <LogOut className="size-4" aria-hidden />
44
+ </Button>
45
+ </header>
46
+ )
47
+ }
@@ -0,0 +1,145 @@
1
+ import { useMemo } from 'react'
2
+
3
+ import type { PRComment, ReviewEvent } from '@/api/pulls'
4
+ import { Badge, type BadgeTone } from '@/components/ui/badge'
5
+ import { EmptyState } from '@/components/ui/empty-state'
6
+ import { cn, formatDateTime } from '@/lib/utils'
7
+
8
+ const EVENT_LABELS: Record<string, { label: string; tone: BadgeTone }> = {
9
+ lgtm: { label: 'LGTM', tone: 'success' },
10
+ approve: { label: 'Approved', tone: 'success' },
11
+ ack: { label: 'Acked', tone: 'info' },
12
+ reject: { label: 'NACK', tone: 'danger' },
13
+ ci_pass: { label: 'CI 通过', tone: 'success' },
14
+ ci_fail: { label: 'CI 失败', tone: 'danger' },
15
+ ci_running: { label: 'CI 进行中', tone: 'info' },
16
+ cla_signed: { label: 'CLA 通过', tone: 'success' },
17
+ cla_denied: { label: 'CLA 未通过', tone: 'danger' },
18
+ }
19
+
20
+ type Entry =
21
+ | { key: string; at: string; kind: 'event'; event: ReviewEvent }
22
+ | { key: string; at: string; kind: 'comment'; comment: PRComment }
23
+
24
+ /**
25
+ * 讨论时间线。
26
+ *
27
+ * 评审事件与评论原文合成一条时间线 —— 事件本来就是从评论(``/lgtm``、
28
+ * ``/approve`` 等指令)里推导出来的,分成两个标签页反而要求使用者在
29
+ * "谁怎么说的"和"系统算出了什么"之间来回对照。
30
+ *
31
+ * 评论正文一律按纯文本渲染。上游正文含 HTML 表格与 ``<a href>``,
32
+ * 交给 innerHTML 就是一个注入面。
33
+ */
34
+ export function DiscussionTimeline({
35
+ events,
36
+ comments,
37
+ }: {
38
+ events: ReviewEvent[]
39
+ comments: PRComment[]
40
+ }) {
41
+ const entries = useMemo<Entry[]>(() => {
42
+ const merged: Entry[] = [
43
+ ...events.map((event, index) => ({
44
+ key: `event-${index}`,
45
+ at: event.occurred_at,
46
+ kind: 'event' as const,
47
+ event,
48
+ })),
49
+ ...comments.map((comment) => ({
50
+ key: `comment-${comment.comment_id}`,
51
+ at: comment.atomgit_created_at ?? '',
52
+ kind: 'comment' as const,
53
+ comment,
54
+ })),
55
+ ]
56
+ // 时间缺失的记录排在最后,而不是被空串顶到最前面
57
+ return merged.sort((a, b) => (a.at || '9999').localeCompare(b.at || '9999'))
58
+ }, [events, comments])
59
+
60
+ if (entries.length === 0) {
61
+ return (
62
+ <EmptyState
63
+ title="尚无讨论"
64
+ description="评论与评审事件都来自上游。若确实有评论,可能是详情尚未同步 —— 可在「设置 → 仓库与同步」手动触发一次。"
65
+ />
66
+ )
67
+ }
68
+
69
+ return (
70
+ <ol className="space-y-3">
71
+ {entries.map((entry) =>
72
+ entry.kind === 'event' ? (
73
+ <li key={entry.key} className="flex items-center gap-3 px-1">
74
+ <EventBadge eventType={entry.event.event_type} />
75
+ <span className="text-sm">{entry.event.actor_login || '(无特定人)'}</span>
76
+ <span className="ml-auto text-xs text-[var(--color-text-muted)]">
77
+ {formatDateTime(entry.at)}
78
+ </span>
79
+ </li>
80
+ ) : (
81
+ <li key={entry.key}>
82
+ <CommentCard comment={entry.comment} />
83
+ </li>
84
+ ),
85
+ )}
86
+ </ol>
87
+ )
88
+ }
89
+
90
+ export function EventBadge({ eventType }: { eventType: string }) {
91
+ const meta = EVENT_LABELS[eventType] ?? { label: eventType, tone: 'neutral' as BadgeTone }
92
+ return <Badge tone={meta.tone}>{meta.label}</Badge>
93
+ }
94
+
95
+ /**
96
+ * 单条评论。
97
+ *
98
+ * 机器人评论默认折叠:openEuler 的机器人会为每个 PR 贴欢迎语、受保护分支表格
99
+ * 与 CI 提示,一个 PR 动辄十几条。全展开会把人写的评审意见挤到几屏之外,
100
+ * 而人写的才是要看的。折叠而非隐藏,是为了不丢掉原文。
101
+ */
102
+ function CommentCard({ comment }: { comment: PRComment }) {
103
+ const header = (
104
+ <>
105
+ <span className="text-sm font-medium">{comment.author_login}</span>
106
+ {comment.is_bot && (
107
+ <Badge tone="neutral" className="font-normal">
108
+ 机器人
109
+ </Badge>
110
+ )}
111
+ <span className="ml-auto shrink-0 text-xs text-[var(--color-text-muted)]">
112
+ {formatDateTime(comment.atomgit_created_at)}
113
+ </span>
114
+ </>
115
+ )
116
+
117
+ const body = (
118
+ <p
119
+ className={cn(
120
+ 'comment-body mt-2 border-t border-[var(--color-border-subtle)] pt-2',
121
+ 'text-sm leading-relaxed text-[var(--color-text-secondary)]',
122
+ )}
123
+ >
124
+ {comment.body}
125
+ </p>
126
+ )
127
+
128
+ if (comment.is_bot) {
129
+ return (
130
+ <details className="rounded-[var(--radius-card)] border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
131
+ <summary className="flex cursor-pointer items-center gap-2 px-4 py-3 text-[var(--color-text-secondary)] marker:text-[var(--color-text-muted)]">
132
+ {header}
133
+ </summary>
134
+ <div className="px-4 pb-3">{body}</div>
135
+ </details>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <div className="rounded-[var(--radius-card)] border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)] px-4 py-3">
141
+ <div className="flex items-center gap-2">{header}</div>
142
+ {body}
143
+ </div>
144
+ )
145
+ }
@@ -0,0 +1,199 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { Checkbox } from 'antd'
3
+ import { X } from 'lucide-react'
4
+
5
+ import { RULE_LABELS, type AttentionRule } from '@/api/attention'
6
+ import { GATE_STAGE_META, type GateStage } from '@/api/pulls'
7
+ import { KIND_META, type PRKind } from '@/api/classification'
8
+ import type { Facet, FacetValue } from '@/api/pulls'
9
+ import { cn } from '@/lib/utils'
10
+
11
+ /**
12
+ * 分面栏。
13
+ *
14
+ * 与普通筛选器的区别只有一条,但它决定这套东西能不能用:**每一栏的计数
15
+ * 是把该栏自身的筛选去掉后算出来的**。勾上 CVE 之后,分支栏仍然给出各分支
16
+ * 的真实数量,于是使用者可以在已有选择上继续收紧或放宽;若计数被自身筛选
17
+ * 压窄,那一栏就只剩已选项非零,除了全部清空没有别的出路。
18
+ *
19
+ * 另外两条来自同一考虑:
20
+ * - 计数为零的取值保留并置灰。一个勾选框就重排一次的列表是点不准的。
21
+ * - 已勾选但计数为零的取值同样保留,否则只能靠手改地址栏来取消它。
22
+ */
23
+ export function FacetRail({
24
+ facets,
25
+ selected,
26
+ onToggle,
27
+ onClear,
28
+ total,
29
+ }: {
30
+ facets: Facet[]
31
+ selected: Record<string, string[]>
32
+ onToggle: (key: string, value: string) => void
33
+ onClear: (key?: string) => void
34
+ total: number
35
+ }) {
36
+ return (
37
+ <div className="space-y-4">
38
+ <div className="flex items-baseline justify-between">
39
+ <span className="text-xs text-[var(--color-text-muted)]">筛选</span>
40
+ <span className="font-mono text-xs tabular-nums">{total}</span>
41
+ </div>
42
+
43
+ {Object.values(selected).some((values) => values.length > 0) && (
44
+ <ActiveFilters selected={selected} onClear={onClear} />
45
+ )}
46
+
47
+ {facets.map((facet) => (
48
+ <div key={facet.key}>
49
+ <div className="mb-1.5 flex items-baseline justify-between">
50
+ <span className="text-2xs uppercase tracking-wide text-[var(--color-text-muted)]">
51
+ {facet.label}
52
+ </span>
53
+ {(selected[facet.key]?.length ?? 0) > 0 && (
54
+ <button
55
+ type="button"
56
+ onClick={() => onClear(facet.key)}
57
+ className="text-2xs text-[var(--color-accent)] hover:underline"
58
+ >
59
+ 清除
60
+ </button>
61
+ )}
62
+ </div>
63
+ <ul className="space-y-0.5">
64
+ {facet.values.map((value) => (
65
+ <FacetRow
66
+ key={value.value}
67
+ facetKey={facet.key}
68
+ value={value}
69
+ onToggle={onToggle}
70
+ />
71
+ ))}
72
+ </ul>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ /**
80
+ * 一个可选值。
81
+ *
82
+ * 用 AntD 的 Checkbox 而不是自绘的方块加 `<button aria-pressed>`:
83
+ * 多选在语义上就是复选框,读屏器会念"已选中/未选中"并支持空格键切换,
84
+ * 而按钮只会被念成按钮。选中态的背景色由 className 落在 wrapper 上,
85
+ * 整行仍然可点(点文字即可勾选,这是 Checkbox 内建的行为)。
86
+ */
87
+ function FacetRow({
88
+ facetKey,
89
+ value,
90
+ onToggle,
91
+ }: {
92
+ facetKey: string
93
+ value: FacetValue
94
+ onToggle: (key: string, value: string) => void
95
+ }) {
96
+ const empty = value.count === 0 && !value.selected
97
+ return (
98
+ <li>
99
+ <Checkbox
100
+ checked={value.selected}
101
+ disabled={empty}
102
+ onChange={() => onToggle(facetKey, value.value)}
103
+ className={cn(
104
+ 'flex w-full items-center gap-2 rounded-[var(--radius-control)] px-1.5 py-1 text-xs transition-colors duration-150',
105
+ '[&_.ant-checkbox+span]:flex [&_.ant-checkbox+span]:min-w-0 [&_.ant-checkbox+span]:flex-1',
106
+ '[&_.ant-checkbox+span]:items-center [&_.ant-checkbox+span]:gap-2',
107
+ value.selected
108
+ ? 'bg-[color-mix(in_oklch,var(--color-accent)_16%,transparent)] text-[var(--color-text-primary)]'
109
+ : 'hover:bg-[var(--color-surface-2)]',
110
+ empty && 'cursor-default opacity-40',
111
+ )}
112
+ >
113
+ <FacetLabel facetKey={facetKey} value={value.value} />
114
+ <span className="ml-auto shrink-0 font-mono text-2xs tabular-nums text-[var(--color-text-muted)]">
115
+ {value.count}
116
+ </span>
117
+ </Checkbox>
118
+ </li>
119
+ )
120
+ }
121
+
122
+ /** 取值的中文名。后端只下发原始值,展示名在这一处统一处理。 */
123
+ function FacetLabel({ facetKey, value }: { facetKey: string; value: string }) {
124
+ if (facetKey === 'stage') {
125
+ return <span className="truncate">{GATE_STAGE_META[value as GateStage]?.label ?? value}</span>
126
+ }
127
+ if (facetKey === 'kind') {
128
+ const meta = KIND_META[value as PRKind]
129
+ return (
130
+ <span className="flex min-w-0 items-center gap-1.5">
131
+ <span
132
+ aria-hidden
133
+ className="size-1.5 shrink-0 rounded-full"
134
+ style={{ backgroundColor: meta?.color ?? 'var(--color-text-muted)' }}
135
+ />
136
+ <span className="truncate">{meta?.label ?? value}</span>
137
+ </span>
138
+ )
139
+ }
140
+ if (facetKey === 'attention') {
141
+ return <span className="truncate">{RULE_LABELS[value as AttentionRule] ?? value}</span>
142
+ }
143
+ return <span className="truncate font-mono">{value}</span>
144
+ }
145
+
146
+ /** 已选条件。可逐个删除,也要能一次清空。 */
147
+ function ActiveFilters({
148
+ selected,
149
+ onClear,
150
+ }: {
151
+ selected: Record<string, string[]>
152
+ onClear: (key?: string) => void
153
+ }) {
154
+ const chips = Object.entries(selected).flatMap(([key, values]) =>
155
+ values.map((value) => ({ key, value })),
156
+ )
157
+ return (
158
+ <div className="flex flex-wrap gap-1">
159
+ {chips.map(({ key, value }) => (
160
+ <span
161
+ key={`${key}:${value}`}
162
+ className="inline-flex items-center gap-1 rounded-full border border-[var(--color-border-strong)] px-1.5 py-0.5 text-2xs"
163
+ >
164
+ <FacetLabel facetKey={key} value={value} />
165
+ <button
166
+ type="button"
167
+ onClick={() => onClear(`${key}:${value}`)}
168
+ aria-label={`移除 ${value}`}
169
+ className="text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
170
+ >
171
+ <X className="size-3" aria-hidden />
172
+ </button>
173
+ </span>
174
+ ))}
175
+ {chips.length > 1 && (
176
+ <button
177
+ type="button"
178
+ onClick={() => onClear()}
179
+ className="px-1.5 py-0.5 text-2xs text-[var(--color-accent)] hover:underline"
180
+ >
181
+ 全部清除
182
+ </button>
183
+ )}
184
+ </div>
185
+ )
186
+ }
187
+
188
+ /** 分面里的分支取值链接到版本页 —— 从"筛出某版本的 PR"到"看该版本全貌"只需一次点击。 */
189
+ export function BranchFacetLink({ branch }: { branch: string }) {
190
+ return (
191
+ <Link
192
+ to="/branches/$branch"
193
+ params={{ branch }}
194
+ className="text-2xs text-[var(--color-accent)] hover:underline"
195
+ >
196
+ 版本页 →
197
+ </Link>
198
+ )
199
+ }