@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,94 @@
1
+ import type { ThemeConfig } from 'antd'
2
+ import { theme } from 'antd'
3
+
4
+ import { resolveToken } from '@/lib/css-color'
5
+
6
+ /**
7
+ * 把设计令牌桥接给 Ant Design。
8
+ *
9
+ * 两套东西必须共用同一份颜色:Tailwind 的 `@theme`(全站 522 处
10
+ * `var(--color-*)` 都读它)与 AntD 的主题 token。各自维护一份色值,
11
+ * 界面上就会出现"卡片换了皮、下拉还是旧色"这种谁也说不清的偏差。
12
+ * 因此这里**只读取** globals.css 里已有的令牌,不复制色值。
13
+ *
14
+ * 归一化到 sRGB 的缘由写在 `lib/css-color.ts` 里 —— 那是唯一能把
15
+ * oklch 变回 rgb 的读法,AntD 八级色阶的派生依赖它。
16
+ */
17
+
18
+ /**
19
+ * 字号阶梯。
20
+ *
21
+ * 与 `globals.css` 的 `--text-*` 是同一组数值(Tailwind 的 `text-xs`/`text-sm`
22
+ * 默认就是 12/14):AntD 组件正文与 Tailwind 的 `text-sm` 必须是同一个数,
23
+ * 否则 AntD 按钮挨着 Tailwind 标签排版时基线对不上。改一处就要同步另一处。
24
+ */
25
+ export const TYPE_SCALE = {
26
+ micro: 11, // globals.css 的 --text-2xs,Tailwind 没有 11px 这一级
27
+ small: 12, // text-xs
28
+ body: 14, // text-sm
29
+ title: 16, // text-base
30
+ } as const
31
+
32
+ /** 桥接过来的 token。 */
33
+ export function bridgedToken(): ThemeConfig['token'] {
34
+ return {
35
+ colorPrimary: resolveToken('--color-accent', '#5b8def'),
36
+ colorLink: resolveToken('--color-accent', '#5b8def'),
37
+ colorSuccess: resolveToken('--color-success', '#4ade80'),
38
+ colorWarning: resolveToken('--color-warning', '#fbbf24'),
39
+ colorError: resolveToken('--color-danger', '#f87171'),
40
+ colorInfo: resolveToken('--color-info', '#60a5fa'),
41
+
42
+ colorTextBase: resolveToken('--color-text-primary', '#f5f5f5'),
43
+ colorText: resolveToken('--color-text-primary', '#f5f5f5'),
44
+ colorTextSecondary: resolveToken('--color-text-secondary', '#c4c4cc'),
45
+ colorTextTertiary: resolveToken('--color-text-muted', '#8a8a94'),
46
+ colorTextQuaternary: resolveToken('--color-text-muted', '#8a8a94'),
47
+
48
+ // 三层背景必须各自对上:只给 container 不给 elevated 的话,
49
+ // 下拉、Tooltip、Modal 的浮层底色会和卡片不一致,深色下很显眼。
50
+ colorBgLayout: resolveToken('--color-surface-0', '#131317'),
51
+ colorBgContainer: resolveToken('--color-surface-1', '#1c1c22'),
52
+ colorBgElevated: resolveToken('--color-surface-2', '#26262e'),
53
+ colorBgSpotlight: resolveToken('--color-surface-3', '#32323c'),
54
+
55
+ colorBorder: resolveToken('--color-border-strong', '#5a5a68'),
56
+ colorBorderSecondary: resolveToken('--color-border-subtle', '#3c3c46'),
57
+
58
+ borderRadius: 6,
59
+ borderRadiusLG: 10,
60
+
61
+ fontSize: TYPE_SCALE.body,
62
+ fontSizeSM: TYPE_SCALE.small,
63
+ fontSizeLG: TYPE_SCALE.title,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 完整的 AntD 主题。
69
+ *
70
+ * 不做"一键退回默认外观"的开关:它的用途是桥接出问题时能快速回退,
71
+ * 而这件事由提交边界保证更干净 —— 引入主题的提交不碰任何页面文件,
72
+ * 出问题整体 revert 即可,代价与翻一个开关相同,却少一个要维护的开关、
73
+ * 少一条要穿过 Docker 构建参数的 env。
74
+ */
75
+ export function antdTheme(): ThemeConfig {
76
+ return {
77
+ algorithm: theme.darkAlgorithm,
78
+ // 固定的 key:将来若改用构建期抽取样式,两处必须一致,
79
+ // 不一致的表现是"刷新时样式闪一下/不生效",很难查。
80
+ cssVar: { key: 'ksc' },
81
+ token: bridgedToken(),
82
+ components: {
83
+ Button: {
84
+ // 小按钮保持 28px,与原来的 h-7 一致。AntD 默认的 24px 配 12px 字,
85
+ // 比这一版界面的正文(14px)小一档 —— 界面上最密的地方反而字最小,
86
+ // 正是之前被指出"看着挤"的那类问题。
87
+ //
88
+ // 标准尺寸不覆盖,用 AntD 的 32px:原来按钮是 36px 而输入框 32px,
89
+ // 同一行里本来就对不齐,现在两者同高。
90
+ controlHeightSM: 28,
91
+ },
92
+ },
93
+ }
94
+ }
@@ -0,0 +1,59 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { App as AntApp, ConfigProvider } from 'antd'
3
+ import zhCN from 'antd/locale/zh_CN'
4
+ import { useState, type ReactNode } from 'react'
5
+
6
+ import { antdTheme } from '@/app/antd-theme'
7
+ import { ApiError } from '@/lib/api-client'
8
+
9
+ /**
10
+ * 全局 Provider。
11
+ *
12
+ * AntD 的主题读的是 globals.css 已经生效的 CSS 令牌,所以初始化放在
13
+ * 首次渲染时(`useState` 的惰性初值只算一次):那时样式表一定已经注入 ——
14
+ * main.tsx 的 `import '@/styles/globals.css'` 会在渲染之前完成。
15
+ *
16
+ * 暂不挂 `<AntdApp>`:它会在 `#root` 与路由之间插入一个 div,而
17
+ * `html/body/#root { height: 100% }` 的高度链会就此断掉,整屏布局塌掉。
18
+ * 等真要弹 message 时再挂,并同时补上高度样式。
19
+ */
20
+ export function AppProviders({ children }: { children: ReactNode }) {
21
+ const [queryClient] = useState(
22
+ () =>
23
+ new QueryClient({
24
+ defaultOptions: {
25
+ queries: {
26
+ staleTime: 30_000,
27
+ refetchOnWindowFocus: false,
28
+ retry: (failureCount, error) => {
29
+ // 4xx 是确定性错误,重试无意义
30
+ if (error instanceof ApiError && error.status < 500) return false
31
+ return failureCount < 2
32
+ },
33
+ },
34
+ },
35
+ }),
36
+ )
37
+
38
+ const [antdConfig] = useState(() => antdTheme())
39
+
40
+ return (
41
+ <QueryClientProvider client={queryClient}>
42
+ {/* autoInsertSpace 关掉:AntD 会在**恰好两个汉字**的按钮文案中间插一个
43
+ 空格(「登 录」「保 存」),那是它为对话框里"确定/取消"这类词做的排版。
44
+ 这个界面里两字按钮与两字标签(「合并冲突」「待评审」)大量并排,只给
45
+ 按钮加空格反而更不齐;而且它一视同仁地改变了按钮的可访问名,
46
+ 按文本定位的地方会全部落空。 */}
47
+ <ConfigProvider locale={zhCN} theme={antdConfig} button={{ autoInsertSpace: false }}>
48
+ {/* App 提供 message / modal 的上下文。静态调用 `message.error()`
49
+ 在 React 18+ 的 StrictMode 下会打 error 级告警(找不到 context),
50
+ 而 e2e 把未预期的 console error 一律判为失败。
51
+
52
+ 它会在 #root 与路由之间插一层 div,所以必须带上 h-full:
53
+ `html/body/#root { height: 100% }` 这条高度链断掉的话,
54
+ 整屏布局会塌下来。 */}
55
+ <AntApp className="h-full">{children}</AntApp>
56
+ </ConfigProvider>
57
+ </QueryClientProvider>
58
+ )
59
+ }
@@ -0,0 +1,411 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import {
3
+ createRootRoute,
4
+ createRoute,
5
+ createRouter,
6
+ Navigate,
7
+ Outlet,
8
+ retainSearchParams,
9
+ RouterProvider,
10
+ useRouterState,
11
+ } from '@tanstack/react-router'
12
+ import type { ReactNode } from 'react'
13
+
14
+ import { authApi } from '@/api/auth'
15
+ import { FACET_KEYS } from '@/api/pulls'
16
+ import { repoOnly, withRepo } from '@/app/search'
17
+ import { AppShell } from '@/components/layout/AppShell'
18
+ import { useCurrentUser } from '@/hooks/use-current-user'
19
+ import { AttentionQueuePage } from '@/pages/attention/AttentionQueuePage'
20
+ import { BranchDetailPage } from '@/pages/branches/BranchDetailPage'
21
+ import { BranchListPage } from '@/pages/branches/BranchListPage'
22
+ import { MeetingDetailPage } from '@/pages/meetings/MeetingDetailPage'
23
+ import { MeetingListPage } from '@/pages/meetings/MeetingListPage'
24
+ import { MembersPage } from '@/pages/members/MembersPage'
25
+ import { DashboardPage } from '@/pages/dashboard/DashboardPage'
26
+ import { IssueListPage } from '@/pages/issues/IssueListPage'
27
+ import { LoginPage } from '@/pages/login/LoginPage'
28
+ import { PullDetailPage } from '@/pages/pulls/PullDetailPage'
29
+ import { PullListPage } from '@/pages/pulls/PullListPage'
30
+ import { AiSettingsPage } from '@/pages/settings/AiSettingsPage'
31
+ import { SettingsPage } from '@/pages/settings/SettingsPage'
32
+ import { SetupPage } from '@/pages/setup/SetupPage'
33
+
34
+ type PullSearch = {
35
+ q?: string
36
+ state?: string
37
+ /** 只看等待超过 N 天的。首页「超 30 天未评审」卡片据此跳过来。 */
38
+ waiting_days_gte?: number
39
+ /** 页码。第一页不写进 URL,免得链接长得没必要地啰嗦。 */
40
+ page?: number
41
+ stage?: string[]
42
+ kind?: string[]
43
+ branch?: string[]
44
+ author?: string[]
45
+ attention?: string[]
46
+ }
47
+
48
+ /**
49
+ * 把 URL 里的正整数字符串解析成数字,其余一律当作"没这个条件"。
50
+ *
51
+ * 地址栏里的值都是字符串,而 `Number('')` 是 0、`Number('abc')` 是 NaN ——
52
+ * 直接信任它们会让 `waiting_days_gte=0` 变成"只看到今天为止的所有 PR",
53
+ * 看起来像筛选没生效。
54
+ */
55
+ function positiveInt(value: unknown): number | undefined {
56
+ const parsed = typeof value === 'number' ? value : Number(value)
57
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
58
+ }
59
+
60
+ function BootScreen() {
61
+ return (
62
+ <div className="flex h-full items-center justify-center">
63
+ <div
64
+ role="status"
65
+ aria-label="加载中"
66
+ className="size-5 animate-spin rounded-full border-2 border-[var(--color-border-strong)] border-t-[var(--color-accent)]"
67
+ />
68
+ </div>
69
+ )
70
+ }
71
+
72
+ /**
73
+ * 根布局只负责「是否需要初始化」这一个全局判断。
74
+ * 登录态校验交给 RequireAuth,避免两处各自等待、互相干扰。
75
+ *
76
+ * `/setup` 也是这根布局的子路由,所以**必须把"已经在 /setup 上"排除掉**:
77
+ * 不排除的话它一直渲染 `<Navigate to="/setup" />`,`<Outlet />` 永远轮不到,
78
+ * 全新实例打开就是一片空白 —— 而全新实例正是所有人第一次见到它的样子。
79
+ */
80
+ function RootLayout() {
81
+ const { data: setup, isLoading } = useQuery({
82
+ queryKey: ['setup-status'],
83
+ queryFn: authApi.setupStatus,
84
+ staleTime: Infinity,
85
+ })
86
+ const pathname = useRouterState({ select: (state) => state.location.pathname })
87
+
88
+ if (isLoading) return <BootScreen />
89
+ if (setup?.needs_setup && pathname !== '/setup') return <Navigate to="/setup" />
90
+
91
+ return <Outlet />
92
+ }
93
+
94
+ function RequireAuth({ children }: { children: ReactNode }) {
95
+ const { user, isLoading } = useCurrentUser()
96
+
97
+ // 必须等待会话探测结束再决定去向,
98
+ // 否则首次渲染时 user 尚为 null,会把已登录用户误弹回登录页。
99
+ if (isLoading) return <BootScreen />
100
+ if (!user) return <Navigate to="/login" />
101
+
102
+ return <>{children}</>
103
+ }
104
+
105
+ const rootRoute = createRootRoute({ component: RootLayout })
106
+
107
+ const indexRoute = createRoute({
108
+ getParentRoute: () => rootRoute,
109
+ path: '/',
110
+ component: () => <Navigate to="/dashboard" />,
111
+ })
112
+
113
+ const loginRoute = createRoute({
114
+ getParentRoute: () => rootRoute,
115
+ path: '/login',
116
+ component: LoginPage,
117
+ })
118
+
119
+ const setupRoute = createRoute({
120
+ getParentRoute: () => rootRoute,
121
+ path: '/setup',
122
+ component: SetupPage,
123
+ })
124
+
125
+ const dashboardRoute = createRoute({
126
+ getParentRoute: () => rootRoute,
127
+ path: '/dashboard',
128
+ validateSearch: repoOnly,
129
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
130
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
131
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
132
+ search: { middlewares: [retainSearchParams(['repo'])] },
133
+ component: () => (
134
+ <RequireAuth>
135
+ <AppShell>
136
+ <DashboardPage />
137
+ </AppShell>
138
+ </RequireAuth>
139
+ ),
140
+ })
141
+
142
+ /**
143
+ * PR 列表的筛选走 URL 而不是组件内部状态。
144
+ *
145
+ * 平台上到处都有指向"某个版本的待处理 PR"、"某个 PR 编号"的链接
146
+ * (分支页、例会议题、发版说明),它们只能通过 URL 表达筛选条件。
147
+ * 顺带也就让筛选结果可分享、可收藏、可前进后退。
148
+ */
149
+ const pullListRoute = createRoute({
150
+ getParentRoute: () => rootRoute,
151
+ path: '/pulls',
152
+ validateSearch: withRepo((search: Record<string, unknown>): PullSearch => {
153
+ const arrays: Partial<PullSearch> = {}
154
+ for (const key of FACET_KEYS) {
155
+ const raw = search[key]
156
+ if (Array.isArray(raw)) arrays[key] = raw.map(String)
157
+ else if (typeof raw === 'string' && raw) arrays[key] = [raw]
158
+ }
159
+ return {
160
+ q: typeof search.q === 'string' ? search.q : undefined,
161
+ state: typeof search.state === 'string' ? search.state : undefined,
162
+ // 这两个必须在这里显式取出来。validateSearch 的返回值就是
163
+ // useSearch() 能看到的一切,没被列进来的参数会被静默丢掉 ——
164
+ // 链接的 href 里带着它,读回来却没有,表现为"点了链接但没过滤"。
165
+ waiting_days_gte: positiveInt(search.waiting_days_gte),
166
+ page: positiveInt(search.page),
167
+ ...arrays,
168
+ }
169
+ }),
170
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
171
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
172
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
173
+ search: { middlewares: [retainSearchParams(['repo'])] },
174
+ component: () => (
175
+ <RequireAuth>
176
+ <AppShell>
177
+ <PullListPage />
178
+ </AppShell>
179
+ </RequireAuth>
180
+ ),
181
+ })
182
+
183
+ const pullDetailRoute = createRoute({
184
+ getParentRoute: () => rootRoute,
185
+ path: '/pulls/$pullId',
186
+ component: () => {
187
+ const { pullId } = pullDetailRoute.useParams()
188
+ return (
189
+ <RequireAuth>
190
+ <AppShell>
191
+ <PullDetailPage pullId={pullId} />
192
+ </AppShell>
193
+ </RequireAuth>
194
+ )
195
+ },
196
+ })
197
+
198
+ const issueListRoute = createRoute({
199
+ getParentRoute: () => rootRoute,
200
+ path: '/issues',
201
+ validateSearch: repoOnly,
202
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
203
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
204
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
205
+ search: { middlewares: [retainSearchParams(['repo'])] },
206
+ component: () => (
207
+ <RequireAuth>
208
+ <AppShell>
209
+ <IssueListPage />
210
+ </AppShell>
211
+ </RequireAuth>
212
+ ),
213
+ })
214
+
215
+ const settingsRoute = createRoute({
216
+ getParentRoute: () => rootRoute,
217
+ path: '/settings/general',
218
+ component: () => (
219
+ <RequireAuth>
220
+ <AppShell>
221
+ <SettingsPage />
222
+ </AppShell>
223
+ </RequireAuth>
224
+ ),
225
+ })
226
+
227
+ const attentionRoute = createRoute({
228
+ getParentRoute: () => rootRoute,
229
+ path: '/attention',
230
+ validateSearch: repoOnly,
231
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
232
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
233
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
234
+ search: { middlewares: [retainSearchParams(['repo'])] },
235
+ component: () => (
236
+ <RequireAuth>
237
+ <AppShell>
238
+ <AttentionQueuePage />
239
+ </AppShell>
240
+ </RequireAuth>
241
+ ),
242
+ })
243
+
244
+ const aiSettingsRoute = createRoute({
245
+ getParentRoute: () => rootRoute,
246
+ path: '/settings/ai',
247
+ component: () => (
248
+ <RequireAuth>
249
+ <AppShell>
250
+ <AiSettingsPage />
251
+ </AppShell>
252
+ </RequireAuth>
253
+ ),
254
+ })
255
+
256
+ const branchListRoute = createRoute({
257
+ getParentRoute: () => rootRoute,
258
+ path: '/branches',
259
+ validateSearch: repoOnly,
260
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
261
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
262
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
263
+ search: { middlewares: [retainSearchParams(['repo'])] },
264
+ component: () => (
265
+ <RequireAuth>
266
+ <AppShell>
267
+ <BranchListPage />
268
+ </AppShell>
269
+ </RequireAuth>
270
+ ),
271
+ })
272
+
273
+ const branchDetailRoute = createRoute({
274
+ getParentRoute: () => rootRoute,
275
+ path: '/branches/$branch',
276
+ validateSearch: repoOnly,
277
+ // 从别的页面点进来时把仓库带上:这一组页面的每一项都属于某个仓库,而调用点
278
+ // (首页卡片、分支页的"待处理"、关注的"逐条处理")并不都知道自己在哪个仓库里 ——
279
+ // 让路由统一保留,比在每个链接上各写一遍可靠,也不会漏。
280
+ search: { middlewares: [retainSearchParams(['repo'])] },
281
+ component: () => {
282
+ const { branch } = branchDetailRoute.useParams()
283
+ return (
284
+ <RequireAuth>
285
+ <AppShell>
286
+ <BranchDetailPage branch={branch} />
287
+ </AppShell>
288
+ </RequireAuth>
289
+ )
290
+ },
291
+ })
292
+
293
+ const meetingListRoute = createRoute({
294
+ getParentRoute: () => rootRoute,
295
+ path: '/meetings',
296
+ component: () => (
297
+ <RequireAuth>
298
+ <AppShell>
299
+ <MeetingListPage />
300
+ </AppShell>
301
+ </RequireAuth>
302
+ ),
303
+ })
304
+
305
+ const meetingDetailRoute = createRoute({
306
+ getParentRoute: () => rootRoute,
307
+ path: '/meetings/$meetingId',
308
+ component: () => {
309
+ const { meetingId } = meetingDetailRoute.useParams()
310
+ return (
311
+ <RequireAuth>
312
+ <AppShell>
313
+ <MeetingDetailPage meetingId={meetingId} />
314
+ </AppShell>
315
+ </RequireAuth>
316
+ )
317
+ },
318
+ })
319
+
320
+ /**
321
+ * 成员页支持 ``?member=<login>``。
322
+ *
323
+ * 分支负责人、贡献排行榜、模块归属都指向人,而这些引用散在各页
324
+ * (分支页的 keeper、发版说明的贡献者)。没有这个参数的话,
325
+ * 点过去只能落到一张三百人的名单上,还得自己再搜一遍 ——
326
+ * 那等于链接没做。
327
+ */
328
+ const membersRoute = createRoute({
329
+ getParentRoute: () => rootRoute,
330
+ path: '/members',
331
+ validateSearch: (search: Record<string, unknown>): { member?: string } => ({
332
+ member: typeof search.member === 'string' && search.member ? search.member : undefined,
333
+ }),
334
+ component: () => {
335
+ const { member } = membersRoute.useSearch()
336
+ return (
337
+ <RequireAuth>
338
+ <AppShell>
339
+ <MembersPage member={member} />
340
+ </AppShell>
341
+ </RequireAuth>
342
+ )
343
+ },
344
+ })
345
+
346
+ const routeTree = rootRoute.addChildren([
347
+ indexRoute,
348
+ loginRoute,
349
+ setupRoute,
350
+ dashboardRoute,
351
+ pullListRoute,
352
+ pullDetailRoute,
353
+ issueListRoute,
354
+ attentionRoute,
355
+ settingsRoute,
356
+ aiSettingsRoute,
357
+ branchListRoute,
358
+ branchDetailRoute,
359
+ meetingListRoute,
360
+ meetingDetailRoute,
361
+ membersRoute,
362
+ ])
363
+
364
+ /**
365
+ * 查询串的序列化。
366
+ *
367
+ * 默认行为是把数组编码成 JSON(`?kind=["cve"]`),而后端的 `list[str]` 查询
368
+ * 参数按约定是**重复出现**(`?kind=cve&kind=bugfix`)—— 与 FastAPI 一致,
369
+ * 也与 `api/pulls.ts` 的 `toQuery` 一致。不统一的话,地址栏里的链接贴进
370
+ * 接口或另一处代码都读不出筛选条件。
371
+ */
372
+ function stringifySearch(search: Record<string, unknown>): string {
373
+ const params = new URLSearchParams()
374
+ for (const [key, value] of Object.entries(search)) {
375
+ if (value === undefined || value === null || value === '') continue
376
+ if (Array.isArray(value)) {
377
+ for (const item of value) params.append(key, String(item))
378
+ } else {
379
+ params.set(key, String(value))
380
+ }
381
+ }
382
+ const query = params.toString()
383
+ return query ? `?${query}` : ''
384
+ }
385
+
386
+ function parseSearch(searchStr: string): Record<string, string | string[]> {
387
+ const params = new URLSearchParams(searchStr.startsWith('?') ? searchStr.slice(1) : searchStr)
388
+ const result: Record<string, string | string[]> = {}
389
+ for (const key of new Set(params.keys())) {
390
+ const values = params.getAll(key)
391
+ result[key] = values.length > 1 ? values : values[0]
392
+ }
393
+ return result
394
+ }
395
+
396
+ export const router = createRouter({
397
+ routeTree,
398
+ defaultPreload: 'intent',
399
+ stringifySearch,
400
+ parseSearch,
401
+ })
402
+
403
+ declare module '@tanstack/react-router' {
404
+ interface Register {
405
+ router: typeof router
406
+ }
407
+ }
408
+
409
+ export function AppRouter() {
410
+ return <RouterProvider router={router} />
411
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 地址栏里的仓库标识。
3
+ *
4
+ * 仓库是**上下文**而不是筛选条件:`/branches/openEuler-24.03-LTS-SP4` 这个
5
+ * 路径在两个仓库里都成立,指的是两件不同的事。只有把仓库放进 URL 才说得清,
6
+ * 也才让链接可以安全地转给别人。
7
+ */
8
+ export type RepoSearch = { repo?: string }
9
+
10
+ const readRepo = (search: Record<string, unknown>): string | undefined =>
11
+ typeof search.repo === 'string' && search.repo ? search.repo : undefined
12
+
13
+ /**
14
+ * 只解析 repo 的路由用它。
15
+ *
16
+ * 每个需要仓库上下文的页面都要在 `validateSearch` 里声明它:那个函数的
17
+ * 返回值就是 `useSearch()` 能看到的一切,没被列进去的参数会被静默丢掉 ——
18
+ * 表现为"点了链接却停在原来那个仓库",而地址栏里明明写着另一个。
19
+ */
20
+ export const repoOnly = (search: Record<string, unknown>): RepoSearch => ({
21
+ repo: readRepo(search),
22
+ })
23
+
24
+ /** 把 repo 并进已有的解析逻辑。 */
25
+ export function withRepo<T extends object>(parse: (search: Record<string, unknown>) => T) {
26
+ return (search: Record<string, unknown>): T & RepoSearch => ({
27
+ ...parse(search),
28
+ repo: readRepo(search),
29
+ })
30
+ }
@@ -0,0 +1,58 @@
1
+ import { KIND_META, type Classification, type PRKind } from '@/api/classification'
2
+ import { Badge } from '@/components/ui/badge'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ /**
6
+ * 类型标签。
7
+ *
8
+ * 不确定的判定要能被一眼看出:AI 补判与人工指定加一个上标,
9
+ * 未判定的降低不透明度。把三者渲染成同一个样子,
10
+ * 使用者就无法判断"这个结论有多可信"。
11
+ */
12
+ export function ClassificationBadge({
13
+ kind,
14
+ source,
15
+ className,
16
+ }: {
17
+ kind: PRKind
18
+ source?: Classification['source']
19
+ className?: string
20
+ }) {
21
+ const meta = KIND_META[kind]
22
+ const uncertain = kind === 'unknown'
23
+
24
+ return (
25
+ <Badge
26
+ // 类别色来自 globals.css 的 kind 色板,不走 Badge 的 tone ——
27
+ // 十几种类别无法映射到 6 个语义 tone 上,硬映射会让
28
+ // "CVE" 和 "危险" 撞成同一个颜色。
29
+ style={{
30
+ color: meta.color,
31
+ backgroundColor: `color-mix(in oklch, ${meta.color} 16%, transparent)`,
32
+ borderColor: `color-mix(in oklch, ${meta.color} 35%, transparent)`,
33
+ }}
34
+ className={cn(className, uncertain && 'opacity-70')}
35
+ title={
36
+ source === 'manual'
37
+ ? '维护者人工指定'
38
+ : source === 'ai'
39
+ ? '规则未命中,由模型判定'
40
+ : undefined
41
+ }
42
+ >
43
+ {meta.label}
44
+ {source === 'ai' && <span className="text-[9px] leading-none">AI</span>}
45
+ {source === 'manual' && <span className="text-[9px] leading-none">人工</span>}
46
+ </Badge>
47
+ )
48
+ }
49
+
50
+ /** 子系统标签。与类型并列展示,构成"这是什么、改的哪里"两个维度。 */
51
+ export function SubsystemBadge({ label }: { label: string | null | undefined }) {
52
+ if (!label) return null
53
+ return (
54
+ <Badge tone="neutral" className="font-normal">
55
+ {label}
56
+ </Badge>
57
+ )
58
+ }
@@ -0,0 +1,13 @@
1
+ import { GATE_STAGE_META, type GateStage } from '@/api/pulls'
2
+ import { Badge } from '@/components/ui/badge'
3
+
4
+ /**
5
+ * 门禁阶段徽章。
6
+ *
7
+ * 阶段取值与语义色的映射集中在 api/pulls.ts,此处只做渲染。
8
+ * 后端下发 gate_stage,前端不复刻门禁规则 —— 规则只有一份。
9
+ */
10
+ export function GateBadge({ stage }: { stage: GateStage }) {
11
+ const meta = GATE_STAGE_META[stage] ?? { label: stage, tone: 'neutral' as const }
12
+ return <Badge tone={meta.tone}>{meta.label}</Badge>
13
+ }
@@ -0,0 +1,20 @@
1
+ import { SEVERITY_META, type AttentionSeverity } from '@/api/attention'
2
+ import { Badge } from '@/components/ui/badge'
3
+
4
+ /**
5
+ * 关注项严重程度标签。
6
+ *
7
+ * blocker 额外加一个实心圆点:它是唯一"必须处理才能推进"的一档,
8
+ * 与 critical 只靠颜色深浅区分不够 —— 色觉障碍使用者看不出差别。
9
+ */
10
+ export function SeverityBadge({ severity }: { severity: AttentionSeverity }) {
11
+ const meta = SEVERITY_META[severity]
12
+ return (
13
+ <Badge tone={meta.tone}>
14
+ {severity === 'blocker' && (
15
+ <span aria-hidden className="size-1.5 rounded-full bg-current" />
16
+ )}
17
+ {meta.label}
18
+ </Badge>
19
+ )
20
+ }