@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,399 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Link, useNavigate, useSearch } from '@tanstack/react-router'
3
+ import { Select, Tooltip } from 'antd'
4
+ import type { ColumnsType } from 'antd/es/table'
5
+ import { useEffect, useMemo, useState } from 'react'
6
+
7
+ import {
8
+ FACET_KEYS,
9
+ pullsApi,
10
+ type FacetKey,
11
+ type FacetResponse,
12
+ type PullFilters,
13
+ type PullSummary,
14
+ } from '@/api/pulls'
15
+ import { useCurrentRepository } from '@/hooks/use-current-repository'
16
+ import { ClassificationBadge } from '@/components/ClassificationBadge'
17
+ import { GateBadge } from '@/components/GateBadge'
18
+ import { FacetRail } from '@/components/pulls/FacetRail'
19
+ import { Badge } from '@/components/ui/badge'
20
+ import { Input } from '@/components/ui/input'
21
+ import { DataTable } from '@/components/ui/data-table'
22
+ import { EmptyState } from '@/components/ui/empty-state'
23
+ import { Skeleton } from '@/components/ui/skeleton'
24
+ import { cn, formatNumber, formatRelativeTime } from '@/lib/utils'
25
+
26
+ const PER_PAGE = 30
27
+
28
+ /** 状态筛选:默认只看 open,已合并通常用于统计而非日常处理。 */
29
+ const STATE_OPTIONS = [
30
+ { value: 'open', label: '待处理' },
31
+ { value: 'merged', label: '已合并' },
32
+ { value: 'closed', label: '已关闭' },
33
+ { value: 'all', label: '全部' },
34
+ ]
35
+
36
+ const SORT_OPTIONS = [
37
+ { value: 'updated', label: '最近更新' },
38
+ { value: 'created', label: '最近创建' },
39
+ { value: 'waiting', label: '等待最久' },
40
+ { value: 'lines', label: '变更最多' },
41
+ { value: 'comments', label: '讨论最多' },
42
+ ]
43
+
44
+ /** 次要信息用 text-xs:AntD 给单元格的字号是正文的 14px,
45
+ * 编号、作者、时间这些压一档才有层次。 */
46
+ const MUTED = 'text-xs text-[var(--color-text-muted)]'
47
+
48
+ const COLUMNS: ColumnsType<PullSummary> = [
49
+ {
50
+ title: '编号',
51
+ width: 70,
52
+ render: (_, pull) => (
53
+ <span className="font-mono text-xs text-[var(--color-text-muted)]">#{pull.number}</span>
54
+ ),
55
+ },
56
+ {
57
+ title: '标题',
58
+ // 不说宽度:标题列吃掉剩下的全部空间,固定住它等于让别的列去挤
59
+ render: (_, pull) => (
60
+ <>
61
+ {/* Tooltip 而不是 title 属性:原生那个要等约一秒、没有样式,
62
+ 而被截断的正是最需要看全的 PR 标题。标题普遍超过一行的宽度,
63
+ 所以不做"只在溢出时弹"的判定 —— 那要多一套测量逻辑,
64
+ 省下的打扰却很有限。 */}
65
+ <Tooltip title={pull.title}>
66
+ <Link
67
+ to="/pulls/$pullId"
68
+ params={{ pullId: pull.id }}
69
+ className="line-clamp-1 hover:text-[var(--color-accent)]"
70
+ >
71
+ {pull.title}
72
+ </Link>
73
+ </Tooltip>
74
+ {/*
75
+ 只放内容类别与异常标记。上游的 kind/* 标签曾是这里的主角,
76
+ 但它们是流程状态(wait-on-review / ready-to-merge / abandoned),
77
+ 不是内容分类 —— 与门禁列表达同一件事,且英文对中文界面是噪音。
78
+ 原始标签在详情页的「标签」面板里按原色完整呈现。
79
+ */}
80
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
81
+ {pull.kind && <ClassificationBadge kind={pull.kind} />}
82
+ {pull.merge_conflict && <Badge tone="danger">冲突</Badge>}
83
+ {pull.draft && <Badge tone="neutral">草稿</Badge>}
84
+ </div>
85
+ </>
86
+ ),
87
+ },
88
+ { title: '门禁', width: 110, render: (_, pull) => <GateBadge stage={pull.gate_stage} /> },
89
+ {
90
+ title: '分支',
91
+ width: 100,
92
+ render: (_, pull) =>
93
+ pull.target_branch ? (
94
+ <Link
95
+ to="/branches/$branch"
96
+ params={{ branch: pull.target_branch }}
97
+ className="font-mono text-xs hover:text-[var(--color-accent)]"
98
+ title="查看该版本"
99
+ >
100
+ {pull.target_branch}
101
+ </Link>
102
+ ) : (
103
+ <span className={MUTED}>—</span>
104
+ ),
105
+ },
106
+ {
107
+ title: '作者',
108
+ width: 120,
109
+ render: (_, pull) => (
110
+ <span className="text-xs text-[var(--color-text-secondary)]">{pull.author_login ?? '—'}</span>
111
+ ),
112
+ },
113
+ {
114
+ title: '变更',
115
+ width: 100,
116
+ align: 'right',
117
+ render: (_, pull) => (
118
+ <span className="font-mono text-xs tabular-nums">
119
+ <span className="text-[var(--color-success)]">+{pull.added_lines}</span>
120
+ <span className="mx-0.5 text-[var(--color-text-muted)]">/</span>
121
+ <span className="text-[var(--color-danger)]">-{pull.removed_lines}</span>
122
+ </span>
123
+ ),
124
+ },
125
+ {
126
+ title: '更新',
127
+ width: 100,
128
+ render: (_, pull) => (
129
+ <span className={MUTED}>{formatRelativeTime(pull.atomgit_updated_at)}</span>
130
+ ),
131
+ },
132
+ ]
133
+
134
+ /**
135
+ * 列表本体。
136
+ *
137
+ * 交给 AntD 的 Table,主要是为了表头:滚动时它会跟着粘住,而这一页动辄
138
+ * 三十行,滚到下面时列名早已看不见 —— 这正是原来那版最难受的地方。
139
+ */
140
+ function PullTable({
141
+ items,
142
+ total,
143
+ loading,
144
+ fetching,
145
+ page,
146
+ pageSize,
147
+ totalPages,
148
+ onPageChange,
149
+ }: {
150
+ items: PullSummary[]
151
+ total: number
152
+ loading: boolean
153
+ fetching: boolean
154
+ page: number
155
+ pageSize: number
156
+ totalPages: number
157
+ onPageChange: (page: number) => void
158
+ }) {
159
+ return (
160
+ <DataTable<PullSummary>
161
+ rowKey="id"
162
+ columns={COLUMNS}
163
+ dataSource={items}
164
+ loading={loading}
165
+ // 取数中不整块变灰:AntD 自己有 loading 蒙层,两层叠加会糊成一片
166
+ className={cn(fetching && 'opacity-90')}
167
+ locale={{
168
+ emptyText: (
169
+ <EmptyState
170
+ title="没有符合条件的 PR"
171
+ description="左侧各栏的计数不含该栏自身的筛选,可以据此放宽条件。"
172
+ />
173
+ ),
174
+ }}
175
+ pagination={{
176
+ current: page,
177
+ pageSize,
178
+ total,
179
+ // 每页条数固定:列表页是扫读用的,改每页条数只会让
180
+ // "第 3 页"在不同人那里指向不同内容
181
+ showSizeChanger: false,
182
+ showQuickJumper: totalPages > 10,
183
+ showTotal: (count) => `共 ${formatNumber(count)} 条`,
184
+ onChange: onPageChange,
185
+ }}
186
+ />
187
+ )
188
+ }
189
+
190
+ /**
191
+ * PR 列表。
192
+ *
193
+ * 筛选由左侧分面栏承担,而分面栏的计数**不含该栏自身的筛选** ——
194
+ * 勾上 CVE 之后分支栏仍给出各分支的真实数量,于是还能在此基础上继续
195
+ * 收紧或放宽;若计数被自身筛选压窄,那一栏就只剩已选项非零,
196
+ * 除了全部清空没有别的出路。
197
+ *
198
+ * 筛选条件存在 URL 里:版本页、例会议题、发版说明都要把使用者带到
199
+ * "已经筛好的列表",而链接只能通过地址表达筛选。
200
+ */
201
+ export function PullListPage() {
202
+ const search = useSearch({ from: '/pulls' })
203
+ const navigate = useNavigate()
204
+
205
+ // 数组每次渲染都是新对象,直接进依赖会让 effect 每帧触发。
206
+ // 拼成字符串做键,依赖就都是基本类型了。
207
+ const arrayKey = FACET_KEYS.map((key) => (search[key] ?? []).join(',')).join('|')
208
+ const selected = useMemo<Record<string, string[]>>(() => {
209
+ const parts = arrayKey.split('|')
210
+ return Object.fromEntries(
211
+ FACET_KEYS.map((key, index) => [key, parts[index] ? parts[index].split(',') : []]),
212
+ )
213
+ }, [arrayKey])
214
+
215
+ const [filters, setFilters] = useState<PullFilters>(() => ({
216
+ state: search.state ?? 'open',
217
+ sort: 'updated',
218
+ order: 'desc',
219
+ page: search.page ?? 1,
220
+ per_page: PER_PAGE,
221
+ q: search.q,
222
+ waiting_days_gte: search.waiting_days_gte,
223
+ ...Object.fromEntries(FACET_KEYS.map((key) => [key, search[key]])),
224
+ }))
225
+ const [searchInput, setSearchInput] = useState(search.q ?? '')
226
+
227
+ useEffect(() => {
228
+ setFilters((prev) => ({
229
+ ...prev,
230
+ q: search.q,
231
+ state: search.state ?? 'open',
232
+ page: search.page ?? 1,
233
+ waiting_days_gte: search.waiting_days_gte,
234
+ ...Object.fromEntries(FACET_KEYS.map((key) => [key, search[key]])),
235
+ }))
236
+ setSearchInput(search.q ?? '')
237
+ // arrayKey 已覆盖全部数组维度的变化
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [search.q, search.state, search.page, search.waiting_days_gte, arrayKey])
240
+
241
+ const { repository, isLoading: reposLoading } = useCurrentRepository()
242
+
243
+ const { data: facets, isLoading: facetsLoading } = useQuery({
244
+ // 分面是各维度的计数,与翻到第几页无关:page 置空后翻页不会重查,
245
+ // 否则每翻一页都要为整栏的计数多打一次接口。
246
+ queryKey: ['pull-facets', repository?.id, { ...filters, page: undefined }],
247
+ queryFn: () => pullsApi.facets(repository!.id, filters),
248
+ enabled: Boolean(repository),
249
+ placeholderData: (previous: FacetResponse | undefined) => previous,
250
+ })
251
+
252
+ const { data, isLoading, isFetching } = useQuery({
253
+ queryKey: ['pulls', repository?.id, filters],
254
+ queryFn: () => pullsApi.list(repository!.id, filters),
255
+ enabled: Boolean(repository),
256
+ placeholderData: (previous) => previous,
257
+ })
258
+
259
+ const totalPages = useMemo(
260
+ () => (data ? Math.max(1, Math.ceil(data.total / (filters.per_page ?? PER_PAGE))) : 1),
261
+ [data, filters.per_page],
262
+ )
263
+
264
+ /** 把筛选写进 URL,让地址栏始终等于当前看到的列表。 */
265
+ function update(patch: Partial<PullFilters>) {
266
+ setFilters((prev) => {
267
+ const next = { ...prev, ...patch, page: patch.page ?? 1 }
268
+ void navigate({
269
+ to: '/pulls',
270
+ replace: true,
271
+ search: {
272
+ q: next.q || undefined,
273
+ // 默认状态不写进 URL,否则链接会长得没必要地啰嗦
274
+ state: next.state === 'open' ? undefined : next.state,
275
+ waiting_days_gte: next.waiting_days_gte,
276
+ page: next.page && next.page > 1 ? next.page : undefined,
277
+ ...Object.fromEntries(
278
+ FACET_KEYS.map((key) => {
279
+ const values = next[key] as string[] | undefined
280
+ return [key, values && values.length > 0 ? values : undefined]
281
+ }),
282
+ ),
283
+ },
284
+ })
285
+ return next
286
+ })
287
+ }
288
+
289
+ /** 同维取并、跨维取交:勾选是加入本维度的取值集合,不是替换。 */
290
+ function toggle(key: string, value: string) {
291
+ const current = (filters[key as FacetKey] as string[] | undefined) ?? []
292
+ const next = current.includes(value)
293
+ ? current.filter((item) => item !== value)
294
+ : [...current, value]
295
+ update({ [key]: next.length ? next : undefined } as Partial<PullFilters>)
296
+ }
297
+
298
+ function clear(key?: string) {
299
+ if (!key) {
300
+ const patch = Object.fromEntries(FACET_KEYS.map((item) => [item, undefined]))
301
+ update(patch as Partial<PullFilters>)
302
+ return
303
+ }
304
+ if (key.includes(':')) {
305
+ // 单个 chip 的移除,形如 "stage:ready_to_merge"
306
+ const [dimension, value] = key.split(':')
307
+ const current = (filters[dimension as FacetKey] as string[] | undefined) ?? []
308
+ const next = current.filter((item) => item !== value)
309
+ update({ [dimension]: next.length ? next : undefined } as Partial<PullFilters>)
310
+ return
311
+ }
312
+ update({ [key]: undefined } as Partial<PullFilters>)
313
+ }
314
+
315
+ if (reposLoading) return <Skeleton className="h-64 w-full" />
316
+ if (!repository) {
317
+ return (
318
+ <EmptyState
319
+ title="尚未纳管任何仓库"
320
+ description="请先在「设置 → 仓库与同步」中配置 AtomGit 凭据并纳管 openeuler/kernel。"
321
+ />
322
+ )
323
+ }
324
+
325
+ return (
326
+ <div className="space-y-4">
327
+ <header>
328
+ <h1 className="text-base font-semibold tracking-tight">Pull Request</h1>
329
+ <p className="mt-0.5 text-sm text-[var(--color-text-secondary)]">
330
+ {repository.display_name ?? `${repository.owner}/${repository.name}`}
331
+ {data ? ` · 共 ${formatNumber(data.total)} 个` : ''}
332
+ <span className="text-[var(--color-text-muted)]"> · 筛选结果可通过地址栏分享</span>
333
+ </p>
334
+ </header>
335
+
336
+ <div className="flex flex-wrap items-center gap-2">
337
+ <form
338
+ className="min-w-[220px] flex-1"
339
+ onSubmit={(event) => {
340
+ event.preventDefault()
341
+ update({ q: searchInput || undefined })
342
+ }}
343
+ >
344
+ <Input
345
+ placeholder="按标题或 PR 编号搜索,回车确认"
346
+ value={searchInput}
347
+ onChange={(event) => setSearchInput(event.target.value)}
348
+ className="h-8 text-sm"
349
+ />
350
+ </form>
351
+ <Select
352
+ aria-label="状态"
353
+ value={filters.state ?? 'open'}
354
+ options={STATE_OPTIONS}
355
+ onChange={(value) => update({ state: value })}
356
+ />
357
+ <Select
358
+ aria-label="排序"
359
+ value={filters.sort ?? 'updated'}
360
+ options={SORT_OPTIONS}
361
+ onChange={(value) => update({ sort: value })}
362
+ />
363
+ </div>
364
+
365
+ <div className="grid gap-4 lg:grid-cols-[200px_1fr]">
366
+ <aside className="lg:sticky lg:top-4 lg:self-start">
367
+ {facetsLoading && !facets ? (
368
+ <div className="space-y-2">
369
+ {Array.from({ length: 8 }).map((_, index) => (
370
+ <Skeleton key={index} className="h-5 w-full" />
371
+ ))}
372
+ </div>
373
+ ) : (
374
+ <FacetRail
375
+ facets={facets?.facets ?? []}
376
+ selected={selected}
377
+ total={facets?.total ?? 0}
378
+ onToggle={toggle}
379
+ onClear={clear}
380
+ />
381
+ )}
382
+ </aside>
383
+
384
+ <div className="min-w-0">
385
+ <PullTable
386
+ items={data?.items ?? []}
387
+ total={data?.total ?? 0}
388
+ loading={isLoading}
389
+ fetching={isFetching}
390
+ page={filters.page ?? 1}
391
+ pageSize={filters.per_page ?? PER_PAGE}
392
+ totalPages={totalPages}
393
+ onPageChange={(page) => update({ page })}
394
+ />
395
+ </div>
396
+ </div>
397
+ </div>
398
+ )
399
+ }