@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,403 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Link } from '@tanstack/react-router'
3
+ import { ArrowLeft, ExternalLink } from 'lucide-react'
4
+ import { useState } from 'react'
5
+
6
+ import { KIND_META } from '@/api/classification'
7
+ import {
8
+ MEETING_STATUS_META,
9
+ sigApi,
10
+ type MeetingAgenda,
11
+ type ReleaseReport,
12
+ } from '@/api/sig'
13
+ import { Badge } from '@/components/ui/badge'
14
+ import { Card, CardBody, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
15
+ import { EmptyState } from '@/components/ui/empty-state'
16
+ import { Skeleton } from '@/components/ui/skeleton'
17
+ import { cn, formatNumber } from '@/lib/utils'
18
+
19
+ const NUM_COLORS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
20
+
21
+ /**
22
+ * 一场例会的详情。
23
+ *
24
+ * 议题里的 PR 引用链到本平台,正文里的原始链接保留 ——
25
+ * 纪要原文是外部系统的既有事实,改不得;但把编号变成能点的链接,
26
+ * 是维护者从"看纪要"到"处理 PR"之间省掉的那一步。
27
+ */
28
+ export function MeetingDetailPage({ meetingId }: { meetingId: string }) {
29
+ const { data: meeting, isLoading } = useQuery({
30
+ queryKey: ['meeting', meetingId],
31
+ queryFn: () => sigApi.meeting(meetingId),
32
+ })
33
+
34
+ // 前后场:翻上一场/下一场的纪要,不该先退回列表再点一次。
35
+ // 列表查询与列表页共用同一个 key,命中缓存时不会多发请求。
36
+ const { data: meetings } = useQuery({
37
+ queryKey: ['meetings'],
38
+ queryFn: () => sigApi.meetings(undefined, 50),
39
+ })
40
+
41
+ if (isLoading) {
42
+ return (
43
+ <div className="space-y-3">
44
+ <Skeleton className="h-7 w-1/2" />
45
+ <Skeleton className="h-40 w-full" />
46
+ </div>
47
+ )
48
+ }
49
+ if (!meeting) return <EmptyState title="例会不存在" />
50
+
51
+ const meta = MEETING_STATUS_META[meeting.status]
52
+
53
+ const index = (meetings ?? []).findIndex((item) => item.id === meetingId)
54
+ const previous = index >= 0 ? (meetings ?? [])[index + 1] : undefined
55
+ const next = index > 0 ? (meetings ?? [])[index - 1] : undefined
56
+
57
+ const prRefs = meeting.agendas.flatMap((agenda) => agenda.pull_refs)
58
+ const uniqueRefs = new Map(prRefs.map((ref) => [ref.number, ref]))
59
+ const stillOpen = [...uniqueRefs.values()].filter((ref) => ref.state === 'open')
60
+ const owners = countOwners(meeting.agendas)
61
+
62
+ return (
63
+ <div className="space-y-4">
64
+ <div className="flex flex-wrap items-center justify-between gap-2">
65
+ <Link
66
+ to="/meetings"
67
+ className="inline-flex items-center gap-1.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
68
+ >
69
+ <ArrowLeft className="size-3.5" aria-hidden />
70
+ 返回例会
71
+ </Link>
72
+ <div className="flex items-center gap-3 text-xs">
73
+ {previous ? (
74
+ <Link
75
+ to="/meetings/$meetingId"
76
+ params={{ meetingId: previous.id }}
77
+ className="text-[var(--color-text-secondary)] hover:text-[var(--color-accent)]"
78
+ >
79
+ ← 上一场 {previous.held_on}
80
+ </Link>
81
+ ) : (
82
+ <span className="text-[var(--color-text-muted)]">—</span>
83
+ )}
84
+ {next && (
85
+ <Link
86
+ to="/meetings/$meetingId"
87
+ params={{ meetingId: next.id }}
88
+ className="text-[var(--color-text-secondary)] hover:text-[var(--color-accent)]"
89
+ >
90
+ 下一场 {next.held_on} →
91
+ </Link>
92
+ )}
93
+ </div>
94
+ </div>
95
+
96
+ <header className="space-y-2.5">
97
+ <div className="flex flex-wrap items-center gap-3">
98
+ <h1 className="text-base font-semibold tracking-tight">{meeting.title}</h1>
99
+ <Badge tone={meta?.tone as never}>{meta?.label ?? meeting.status}</Badge>
100
+ </div>
101
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-[var(--color-text-secondary)]">
102
+ <span className="font-mono">
103
+ {meeting.held_on}
104
+ {meeting.start_time && ` ${meeting.start_time}`}
105
+ </span>
106
+ <span>
107
+ 主持{' '}
108
+ <Link
109
+ to="/members"
110
+ search={{ member: meeting.host ?? undefined }}
111
+ className="hover:text-[var(--color-accent)]"
112
+ >
113
+ {meeting.host ?? '—'}
114
+ </Link>
115
+ </span>
116
+ {meeting.next_host && <span>下次主持 {meeting.next_host}</span>}
117
+ {meeting.meeting_url && (
118
+ <a
119
+ href={meeting.meeting_url}
120
+ target="_blank"
121
+ rel="noreferrer noopener"
122
+ className="inline-flex items-center gap-1 text-[var(--color-accent)] hover:underline"
123
+ >
124
+ 会议链接
125
+ <ExternalLink className="size-3" aria-hidden />
126
+ </a>
127
+ )}
128
+ {meeting.minutes_url && (
129
+ <a
130
+ href={meeting.minutes_url}
131
+ target="_blank"
132
+ rel="noreferrer noopener"
133
+ className="inline-flex items-center gap-1 text-[var(--color-accent)] hover:underline"
134
+ >
135
+ Etherpad 纪要
136
+ <ExternalLink className="size-3" aria-hidden />
137
+ </a>
138
+ )}
139
+ </div>
140
+ {meeting.host_order && meeting.host_order.length > 0 && (
141
+ <p className="text-xs text-[var(--color-text-muted)]">
142
+ 轮值顺序:{meeting.host_order.join(' → ')}
143
+ </p>
144
+ )}
145
+ </header>
146
+
147
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
148
+ <Stat label="议题" value={meeting.agendas.length} />
149
+ <Stat label="版本进展报告" value={meeting.reports.length} />
150
+ <Stat label="涉及 PR" value={uniqueRefs.size} hint="议题中引用的编号" />
151
+ <Stat
152
+ label="其中仍未合入"
153
+ value={stillOpen.length}
154
+ hint={
155
+ stillOpen.length > 0 ? `最早 #${stillOpen[stillOpen.length - 1].number}` : undefined
156
+ }
157
+ to={stillOpen.length > 0 ? { search: { state: 'open' } } : undefined}
158
+ />
159
+ </div>
160
+
161
+ {owners.length > 0 && (
162
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
163
+ <span className="text-xs text-[var(--color-text-muted)]">议题负责人</span>
164
+ {owners.map(([owner, count]) => (
165
+ <Link
166
+ key={owner}
167
+ to="/pulls"
168
+ search={{ state: 'open', author: [owner] }}
169
+ title={`查看 ${owner} 提交的未关闭 PR`}
170
+ className="inline-flex items-center gap-1 rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-2xs hover:border-[var(--color-border-strong)] hover:text-[var(--color-accent)]"
171
+ >
172
+ <span className="font-mono">{owner}</span>
173
+ <span className="tabular-nums text-[var(--color-text-muted)]">{count}</span>
174
+ </Link>
175
+ ))}
176
+ </div>
177
+ )}
178
+
179
+ {meeting.reports.length > 0 && (
180
+ <section className="space-y-3">
181
+ <h2 className="text-sm font-semibold">版本进展</h2>
182
+ <div className="grid gap-3 lg:grid-cols-2">
183
+ {meeting.reports.map((report) => (
184
+ <ReportCard key={report.id} report={report} />
185
+ ))}
186
+ </div>
187
+ </section>
188
+ )}
189
+
190
+ <section className="space-y-3">
191
+ <h2 className="text-sm font-semibold">
192
+ 议题
193
+ <span className="ml-1.5 font-normal text-[var(--color-text-muted)]">
194
+ {meeting.agendas.length} 项
195
+ </span>
196
+ </h2>
197
+ {meeting.agendas.length === 0 ? (
198
+ <EmptyState
199
+ title="本期尚无议题"
200
+ description="会议前主持人会在 Etherpad 上补充议题,同步后这里会显示。"
201
+ />
202
+ ) : (
203
+ <div className="space-y-2">
204
+ {meeting.agendas.map((agenda) => (
205
+ <AgendaCard key={agenda.id} agenda={agenda} />
206
+ ))}
207
+ </div>
208
+ )}
209
+ </section>
210
+
211
+ {meeting.legacy?.trim() && (
212
+ <section className="space-y-3">
213
+ <h2 className="text-sm font-semibold">上期遗留</h2>
214
+ <Card>
215
+ <CardBody>
216
+ <pre className="comment-body text-sm leading-relaxed text-[var(--color-text-secondary)]">
217
+ {meeting.legacy}
218
+ </pre>
219
+ </CardBody>
220
+ </Card>
221
+ </section>
222
+ )}
223
+ </div>
224
+ )
225
+ }
226
+
227
+ function Stat({
228
+ label,
229
+ value,
230
+ hint,
231
+ to,
232
+ }: {
233
+ label: string
234
+ value: number
235
+ hint?: string
236
+ to?: { search: { state: string } }
237
+ }) {
238
+ const content = (
239
+ <Card
240
+ className={cn(
241
+ 'h-full',
242
+ to && 'transition-colors duration-150 hover:border-[var(--color-border-strong)]',
243
+ )}
244
+ >
245
+ <CardBody>
246
+ <div className="text-xs text-[var(--color-text-muted)]">{label}</div>
247
+ <div className="mt-1 font-mono text-xl tabular-nums">{formatNumber(value)}</div>
248
+ {hint && <div className="mt-0.5 text-2xs text-[var(--color-text-muted)]">{hint}</div>}
249
+ </CardBody>
250
+ </Card>
251
+ )
252
+ if (!to) return content
253
+ return (
254
+ <Link to="/pulls" search={to.search} className="block">
255
+ {content}
256
+ </Link>
257
+ )
258
+ }
259
+
260
+ /** 议题负责人出现次数,多到少。 */
261
+ function countOwners(agendas: MeetingAgenda[]): [string, number][] {
262
+ const counts = new Map<string, number>()
263
+ for (const agenda of agendas) {
264
+ if (!agenda.owner) continue
265
+ counts.set(agenda.owner, (counts.get(agenda.owner) ?? 0) + 1)
266
+ }
267
+ return [...counts.entries()].sort((left, right) => right[1] - left[1])
268
+ }
269
+
270
+ function ReportCard({ report }: { report: ReleaseReport }) {
271
+ return (
272
+ <Card>
273
+ <CardHeader className="pb-2">
274
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
275
+ <CardTitle className="font-mono text-sm">{report.branch}</CardTitle>
276
+ <span className="font-mono text-2xs text-[var(--color-text-muted)]">
277
+ {report.from_tag ?? '—'} → {report.to_tag ?? '—'}
278
+ </span>
279
+ </div>
280
+ </CardHeader>
281
+ <CardContent className="space-y-2">
282
+ <p className="text-sm text-[var(--color-text-secondary)]">
283
+ 合入{' '}
284
+ <span className="font-mono tabular-nums text-[var(--color-text-primary)]">
285
+ {formatNumber(report.patch_count ?? report.listed_count)}
286
+ </span>{' '}
287
+ 个补丁
288
+ {report.cve_ids && report.cve_ids.length > 0 && (
289
+ <> · CVE {report.cve_ids.length} 个</>
290
+ )}
291
+ </p>
292
+ {report.groups && report.groups.length > 0 && (
293
+ <div className="flex flex-wrap gap-1.5">
294
+ {report.groups.map((group) => (
295
+ <Badge key={group.name} tone="neutral" className="font-normal">
296
+ {group.name}
297
+ {group.count != null && (
298
+ <span className="ml-1 font-mono tabular-nums text-[var(--color-text-muted)]">
299
+ {group.count}
300
+ </span>
301
+ )}
302
+ </Badge>
303
+ ))}
304
+ </div>
305
+ )}
306
+ <Link
307
+ to="/branches/$branch"
308
+ params={{ branch: report.branch }}
309
+ className="inline-block text-xs text-[var(--color-accent)] hover:underline"
310
+ >
311
+ 查看 {report.branch} 版本页 →
312
+ </Link>
313
+ </CardContent>
314
+ </Card>
315
+ )
316
+ }
317
+
318
+ function AgendaCard({ agenda }: { agenda: MeetingAgenda }) {
319
+ const [expanded, setExpanded] = useState(false)
320
+ const longBody = (agenda.body?.length ?? 0) > 400
321
+
322
+ return (
323
+ <Card>
324
+ <CardHeader className="pb-2">
325
+ <div className="flex flex-wrap items-center gap-2">
326
+ <span className="font-mono text-xs text-[var(--color-text-muted)]">
327
+ 议题{NUM_COLORS[agenda.sequence % 10]}
328
+ </span>
329
+ <CardTitle className="text-sm">{agenda.title}</CardTitle>
330
+ {agenda.owner && (
331
+ <Badge tone="neutral" className="font-normal">
332
+ {agenda.owner}
333
+ </Badge>
334
+ )}
335
+ {agenda.pull_refs.length > 0 && (
336
+ <span className="text-xs text-[var(--color-text-muted)]">
337
+ {agenda.pull_refs.length} 个 PR
338
+ </span>
339
+ )}
340
+ </div>
341
+ </CardHeader>
342
+ <CardContent className="space-y-2">
343
+ {agenda.pull_refs.length > 0 && (
344
+ <div className="flex flex-wrap gap-1.5">
345
+ {agenda.pull_refs.map((ref) => (
346
+ <span key={ref.number} className="inline-flex items-center gap-1">
347
+ {/* 编号能解析到本地 PR 就链过去,否则退回 AtomGit 原文 */}
348
+ {ref.id ? (
349
+ <Link
350
+ to="/pulls/$pullId"
351
+ params={{ pullId: ref.id }}
352
+ title={ref.title ?? undefined}
353
+ className="font-mono text-xs text-[var(--color-accent)] hover:underline"
354
+ >
355
+ #{ref.number}
356
+ </Link>
357
+ ) : (
358
+ <a
359
+ href={`https://atomgit.com/openeuler/kernel/pull/${ref.number}`}
360
+ target="_blank"
361
+ rel="noreferrer noopener"
362
+ className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-accent)]"
363
+ >
364
+ #{ref.number}
365
+ </a>
366
+ )}
367
+ {ref.kind && (
368
+ <span
369
+ aria-hidden
370
+ className="size-1.5 rounded-full"
371
+ style={{ backgroundColor: KIND_META[ref.kind]?.color }}
372
+ />
373
+ )}
374
+ </span>
375
+ ))}
376
+ </div>
377
+ )}
378
+
379
+ {agenda.body?.trim() && (
380
+ <>
381
+ <pre
382
+ className={cn(
383
+ 'comment-body text-xs leading-relaxed text-[var(--color-text-secondary)]',
384
+ longBody && !expanded && 'max-h-32 overflow-hidden',
385
+ )}
386
+ >
387
+ {agenda.body}
388
+ </pre>
389
+ {longBody && (
390
+ <button
391
+ type="button"
392
+ onClick={() => setExpanded((value) => !value)}
393
+ className="text-xs text-[var(--color-accent)] hover:underline"
394
+ >
395
+ {expanded ? '收起' : '展开全文'}
396
+ </button>
397
+ )}
398
+ </>
399
+ )}
400
+ </CardContent>
401
+ </Card>
402
+ )
403
+ }
@@ -0,0 +1,264 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { Link } from '@tanstack/react-router'
3
+ import { Tooltip } from 'antd'
4
+ import { CalendarPlus, ExternalLink, RefreshCw } from 'lucide-react'
5
+ import { useMemo } from 'react'
6
+
7
+ import { MEETING_STATUS_META, sigApi, type MeetingStatus, type MeetingSummary } from '@/api/sig'
8
+ import { Alert } from '@/components/ui/alert'
9
+ import { Badge } from '@/components/ui/badge'
10
+ import { Button } from '@/components/ui/button'
11
+ import { Card, CardBody, CardContent } from '@/components/ui/card'
12
+ import { EmptyState } from '@/components/ui/empty-state'
13
+ import { Skeleton } from '@/components/ui/skeleton'
14
+ import { cn, formatNumber } from '@/lib/utils'
15
+
16
+ /**
17
+ * 会议列表。双周例会,最近的在最前。
18
+ *
19
+ * 按年分组、沿一条时间轴排而不是一张卡片一场会:一屏能看到场的数量
20
+ * 直接决定这个页面有没有用 —— 卡片样式每场要占四行,半年就翻不到底。
21
+ * 轴上再标出下一次会议的轮值主持人,例会前要问的那件事就在这里。
22
+ */
23
+ export function MeetingListPage() {
24
+ const queryClient = useQueryClient()
25
+ const { data: meetings, isLoading } = useQuery({
26
+ queryKey: ['meetings'],
27
+ queryFn: () => sigApi.meetings(undefined, 50),
28
+ })
29
+
30
+ const sync = useMutation({
31
+ mutationFn: sigApi.syncMeetings,
32
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['meetings'] }),
33
+ })
34
+
35
+ const list = meetings ?? []
36
+ const upcoming = list.filter((item) => item.status === 'scheduled')
37
+ const done = list.filter((item) => item.status === 'done')
38
+ const latest = done[0]
39
+
40
+ const byYear = useMemo(() => groupByYear(list), [list])
41
+ const hosts = useMemo(() => countHosts(done), [done])
42
+ const agendaTotal = list.reduce((sum, item) => sum + item.agenda_count, 0)
43
+ const reportTotal = list.reduce((sum, item) => sum + item.report_count, 0)
44
+
45
+ return (
46
+ <div className="space-y-5">
47
+ <header className="flex flex-wrap items-end justify-between gap-3">
48
+ <div>
49
+ <h1 className="text-base font-semibold tracking-tight">SIG 例会</h1>
50
+ <p className="mt-0.5 text-sm text-[var(--color-text-secondary)]">
51
+ 双周例会 · 数据来自 Etherpad 纪要
52
+ {upcoming.length > 0 && ` · ${upcoming.length} 场待召开`}
53
+ </p>
54
+ </div>
55
+ <Button
56
+ variant="ghost"
57
+ size="sm"
58
+ onClick={() => sync.mutate()}
59
+ loading={sync.isPending}
60
+ >
61
+ <RefreshCw className="size-3.5" aria-hidden />
62
+ 立即同步纪要
63
+ </Button>
64
+ </header>
65
+
66
+ {sync.isError && <Alert tone="danger">同步失败:{String(sync.error)}</Alert>}
67
+ {sync.isSuccess && <Alert tone="success">{sync.data.message}</Alert>}
68
+
69
+ {isLoading ? (
70
+ <div className="space-y-2">
71
+ {Array.from({ length: 6 }).map((_, index) => (
72
+ <Skeleton key={index} className="h-16 w-full" />
73
+ ))}
74
+ </div>
75
+ ) : list.length === 0 ? (
76
+ <EmptyState
77
+ title="尚无例会记录"
78
+ description="纪要来自 Etherpad 上的 Kernel-meetings 页。点击「立即同步纪要」拉取一次。"
79
+ />
80
+ ) : (
81
+ <>
82
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
83
+ <Stat label="已开例会" value={done.length} hint={latest ? `最近一次 ${latest.held_on}` : undefined} />
84
+ <Stat label="待召开" value={upcoming.length} />
85
+ <Stat label="累计议题" value={agendaTotal} hint="来自历次纪要" />
86
+ <Stat label="版本进展报告" value={reportTotal} hint="各版本的合入汇报" />
87
+ </div>
88
+
89
+ <div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
90
+ <Card>
91
+ <CardContent className="p-0">
92
+ <div className="space-y-4 px-4 py-4">
93
+ {[...byYear.entries()].map(([year, items]) => (
94
+ <div key={year}>
95
+ <div className="mb-2 flex items-center gap-2">
96
+ <span className="font-mono text-xs text-[var(--color-text-muted)]">
97
+ {year}
98
+ </span>
99
+ <span className="text-2xs text-[var(--color-text-muted)]">
100
+ {items.length} 场
101
+ </span>
102
+ <span className="h-px flex-1 bg-[var(--color-border-subtle)]" />
103
+ </div>
104
+ <ul className="space-y-0.5 border-l border-[var(--color-border-subtle)] pl-4">
105
+ {items.map((meeting) => (
106
+ <MeetingRow key={meeting.id} meeting={meeting} />
107
+ ))}
108
+ </ul>
109
+ </div>
110
+ ))}
111
+ </div>
112
+ </CardContent>
113
+ </Card>
114
+
115
+ <Card>
116
+ <CardBody className="space-y-4">
117
+ <div>
118
+ <h2 className="text-sm font-semibold">主持轮值</h2>
119
+ <p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
120
+ 按已召开的例会统计。轮值顺序取自最近一次纪要。
121
+ </p>
122
+ </div>
123
+
124
+ <ul className="space-y-2">
125
+ {hosts.slice(0, 10).map(([name, count]) => (
126
+ <li key={name} className="flex items-center gap-3 text-sm">
127
+ <span className="w-28 truncate">{name}</span>
128
+ <span className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
129
+ <span
130
+ className="block h-full rounded-full bg-[var(--color-accent)]"
131
+ style={{ width: `${(count / hosts[0][1]) * 100}%` }}
132
+ />
133
+ </span>
134
+ <span className="w-8 shrink-0 text-right font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
135
+ {count}
136
+ </span>
137
+ </li>
138
+ ))}
139
+ </ul>
140
+
141
+ {list[0]?.host_order && list[0].host_order.length > 0 && (
142
+ <div className="border-t border-[var(--color-border-subtle)] pt-3">
143
+ <p className="mb-1.5 text-xs text-[var(--color-text-muted)]">轮值顺序</p>
144
+ <p className="text-xs leading-relaxed text-[var(--color-text-secondary)]">
145
+ {list[0].host_order.join(' → ')}
146
+ </p>
147
+ </div>
148
+ )}
149
+ </CardBody>
150
+ </Card>
151
+ </div>
152
+ </>
153
+ )}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ function Stat({ label, value, hint }: { label: string; value: number; hint?: string }) {
159
+ return (
160
+ <Card>
161
+ <CardBody>
162
+ <div className="text-xs text-[var(--color-text-muted)]">{label}</div>
163
+ <div className="mt-1 font-mono text-xl tabular-nums">{formatNumber(value)}</div>
164
+ {hint && <div className="mt-0.5 text-2xs text-[var(--color-text-muted)]">{hint}</div>}
165
+ </CardBody>
166
+ </Card>
167
+ )
168
+ }
169
+
170
+ /**
171
+ * 轴上的一场会。
172
+ *
173
+ * 整行是链接:下一场会议最要紧的动作是"看议题详情",
174
+ * 而"会议链接"与"纪要"是外部入口,单独放在行尾,不与主链接混在一起。
175
+ */
176
+ function MeetingRow({ meeting }: { meeting: MeetingSummary }) {
177
+ const meta = MEETING_STATUS_META[meeting.status as MeetingStatus]
178
+ const upcoming = meeting.status === 'scheduled'
179
+
180
+ return (
181
+ <li className="group flex items-center gap-x-3 gap-y-1 py-1.5 text-sm">
182
+ <span
183
+ aria-hidden
184
+ className={cn(
185
+ '-ml-[21px] mr-1 size-2 shrink-0 rounded-full border-2 border-[var(--color-surface-1)]',
186
+ upcoming ? 'bg-[var(--color-accent)]' : 'bg-[var(--color-border-strong)]',
187
+ )}
188
+ />
189
+ <span className="w-24 shrink-0 font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
190
+ {meeting.held_on}
191
+ {meeting.start_time && (
192
+ <span className="ml-1 text-[var(--color-text-muted)]">{meeting.start_time}</span>
193
+ )}
194
+ </span>
195
+ <Badge tone={meta?.tone as never}>{meta?.label ?? meeting.status}</Badge>
196
+
197
+ <Tooltip title={meeting.title}>
198
+ <Link
199
+ to="/meetings/$meetingId"
200
+ params={{ meetingId: meeting.id }}
201
+ className="min-w-0 flex-1 truncate hover:text-[var(--color-accent)]"
202
+ >
203
+ {meeting.title}
204
+ </Link>
205
+ </Tooltip>
206
+
207
+ <span className="shrink-0 text-xs text-[var(--color-text-secondary)]">
208
+ 主持 {meeting.host ?? '—'}
209
+ </span>
210
+ <span className="shrink-0 font-mono text-2xs tabular-nums text-[var(--color-text-muted)]">
211
+ 议题 {meeting.agenda_count}
212
+ {meeting.report_count > 0 && ` · 报告 ${meeting.report_count}`}
213
+ </span>
214
+
215
+ <span className="flex shrink-0 items-center gap-2">
216
+ {meeting.meeting_url && (
217
+ <a
218
+ href={meeting.meeting_url}
219
+ target="_blank"
220
+ rel="noreferrer noopener"
221
+ title="会议链接"
222
+ aria-label="会议链接"
223
+ className="text-[var(--color-text-muted)] hover:text-[var(--color-accent)]"
224
+ >
225
+ <CalendarPlus className="size-3.5" aria-hidden />
226
+ </a>
227
+ )}
228
+ {meeting.minutes_url && (
229
+ <a
230
+ href={meeting.minutes_url}
231
+ target="_blank"
232
+ rel="noreferrer noopener"
233
+ title="Etherpad 纪要"
234
+ aria-label="Etherpad 纪要"
235
+ className="text-[var(--color-text-muted)] hover:text-[var(--color-accent)]"
236
+ >
237
+ <ExternalLink className="size-3.5" aria-hidden />
238
+ </a>
239
+ )}
240
+ </span>
241
+ </li>
242
+ )
243
+ }
244
+
245
+ /** 按年分组,组内仍是新到旧。 */
246
+ function groupByYear(list: MeetingSummary[]): Map<string, MeetingSummary[]> {
247
+ const map = new Map<string, MeetingSummary[]>()
248
+ for (const meeting of list) {
249
+ const year = meeting.held_on.slice(0, 4)
250
+ if (!map.has(year)) map.set(year, [])
251
+ map.get(year)!.push(meeting)
252
+ }
253
+ return map
254
+ }
255
+
256
+ /** 主持人出场次数,多到少。 */
257
+ function countHosts(done: MeetingSummary[]): [string, number][] {
258
+ const counts = new Map<string, number>()
259
+ for (const meeting of done) {
260
+ if (!meeting.host) continue
261
+ counts.set(meeting.host, (counts.get(meeting.host) ?? 0) + 1)
262
+ }
263
+ return [...counts.entries()].sort((left, right) => right[1] - left[1])
264
+ }