@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 API 客户端。
|
|
3
|
+
*
|
|
4
|
+
* 错误一律抛 ApiError,携带后端 RFC 7807 的 code / detail / context,
|
|
5
|
+
* 使调用方能按 code 分支处理(如 unauthorized → 跳登录)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class ApiError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
readonly status: number,
|
|
11
|
+
readonly code: string,
|
|
12
|
+
message: string,
|
|
13
|
+
readonly context?: Record<string, unknown>,
|
|
14
|
+
) {
|
|
15
|
+
super(message)
|
|
16
|
+
this.name = 'ApiError'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get isUnauthorized(): boolean {
|
|
20
|
+
return this.status === 401
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get isForbidden(): boolean {
|
|
24
|
+
return this.status === 403
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const API_BASE = '/api/v1'
|
|
29
|
+
|
|
30
|
+
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
31
|
+
let response: Response
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(`${API_BASE}${path}`, {
|
|
34
|
+
...init,
|
|
35
|
+
credentials: 'include',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
...init.headers,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
} catch (cause) {
|
|
42
|
+
throw new ApiError(0, 'network_error', '无法连接到服务器,请检查网络或服务状态', {
|
|
43
|
+
cause: String(cause),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (response.status === 204) {
|
|
48
|
+
return undefined as T
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const raw = await response.text()
|
|
52
|
+
let payload: Record<string, unknown> | null = null
|
|
53
|
+
if (raw) {
|
|
54
|
+
try {
|
|
55
|
+
payload = JSON.parse(raw)
|
|
56
|
+
} catch {
|
|
57
|
+
payload = null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new ApiError(
|
|
63
|
+
response.status,
|
|
64
|
+
(payload?.code as string) ?? 'unknown_error',
|
|
65
|
+
(payload?.detail as string) ??
|
|
66
|
+
(payload?.message as string) ??
|
|
67
|
+
`请求失败(HTTP ${response.status})`,
|
|
68
|
+
payload?.context as Record<string, unknown> | undefined,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return payload as T
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const api = {
|
|
76
|
+
get: <T>(path: string) => request<T>(path),
|
|
77
|
+
post: <T>(path: string, body?: unknown) =>
|
|
78
|
+
request<T>(path, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
81
|
+
}),
|
|
82
|
+
patch: <T>(path: string, body?: unknown) =>
|
|
83
|
+
request<T>(path, {
|
|
84
|
+
method: 'PATCH',
|
|
85
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
86
|
+
}),
|
|
87
|
+
put: <T>(path: string, body?: unknown) =>
|
|
88
|
+
request<T>(path, {
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
91
|
+
}),
|
|
92
|
+
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
|
93
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 读取 globals.css 里的 CSS 令牌。
|
|
3
|
+
*
|
|
4
|
+
* 零依赖:AntD 的主题与 ECharts 的图表配置都要用它,而从任何一方
|
|
5
|
+
* 引入都会把对方拖进自己的 chunk(ECharts 是 539KB 的独立块,
|
|
6
|
+
* 被静态拖回主包就白拆了)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 读取令牌原文,取不到返回空串。 */
|
|
10
|
+
export function cssVar(name: string): string {
|
|
11
|
+
if (typeof document === 'undefined') return ''
|
|
12
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const canvas = typeof document === 'undefined' ? null : document.createElement('canvas')
|
|
16
|
+
if (canvas) {
|
|
17
|
+
canvas.width = 1
|
|
18
|
+
canvas.height = 1
|
|
19
|
+
}
|
|
20
|
+
const context = canvas?.getContext('2d', { willReadFrequently: true }) ?? null
|
|
21
|
+
const OPAQUE = 255
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 把令牌归一化成 sRGB 的 `rgb(r, g, b)`。
|
|
25
|
+
*
|
|
26
|
+
* 存在的理由是 Ant Design:它要从种子色派生出 hover / active / border
|
|
27
|
+
* 一共八级色阶,而这件事用 `oklch(...)` 做不了。麻烦在于浏览器**不会**
|
|
28
|
+
* 替你转换 —— 实测(Chrome 153)下面三种读法都原样吐回 oklch 字符串:
|
|
29
|
+
* `getComputedStyle(el).color`、`computedStyleMap().get('color')`,
|
|
30
|
+
* 以及 canvas 的 `ctx.fillStyle` 读回值。
|
|
31
|
+
*
|
|
32
|
+
* 唯一可靠的是**画一个像素再读它**:落进帧缓冲的一定是 sRGB。
|
|
33
|
+
* 这与 `chart-theme.ts` 里那条注记是同一条认知的两面 —— 那边是
|
|
34
|
+
* "canvas 拿不到 CSS 变量",这边是"用 canvas 把 CSS 变量变成画布认得的形状"。
|
|
35
|
+
*
|
|
36
|
+
* 取不到或解析不了时返回 fallback:拿不到令牌本身不该让界面崩掉。
|
|
37
|
+
*/
|
|
38
|
+
export function resolveToken(name: string, fallback: string): string {
|
|
39
|
+
if (!context) return fallback
|
|
40
|
+
const raw = cssVar(name)
|
|
41
|
+
if (!raw) return fallback
|
|
42
|
+
|
|
43
|
+
// 先把填充色置为透明:颜色若不被识别,canvas 会**保留上一个值**,
|
|
44
|
+
// 于是这次填充什么也画不出来,像素 alpha 为 0 —— 据此判定"没认出来"。
|
|
45
|
+
context.clearRect(0, 0, 1, 1)
|
|
46
|
+
context.fillStyle = 'transparent'
|
|
47
|
+
context.fillStyle = raw
|
|
48
|
+
context.fillRect(0, 0, 1, 1)
|
|
49
|
+
const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data
|
|
50
|
+
|
|
51
|
+
if (a !== OPAQUE) {
|
|
52
|
+
// 解析失败时 AntD 会悄悄退回它自己的默认蓝:界面看着"有点不对",
|
|
53
|
+
// 而代码读起来完全正确 —— 和图表"所有柱子同一个灰"是同一个失败模式。
|
|
54
|
+
if (import.meta.env.DEV) {
|
|
55
|
+
throw new Error(`令牌 ${name} 无法解析为色值:${raw}`)
|
|
56
|
+
}
|
|
57
|
+
return fallback
|
|
58
|
+
}
|
|
59
|
+
return `rgb(${r}, ${g}, ${b})`
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx'
|
|
2
|
+
import { twMerge } from 'tailwind-merge'
|
|
3
|
+
|
|
4
|
+
/** 合并 Tailwind 类名,后写的同类工具类覆盖先写的。 */
|
|
5
|
+
export function cn(...inputs: ClassValue[]) {
|
|
6
|
+
return twMerge(clsx(inputs))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 相对时间描述,用于"最后活动"这类信息展示。 */
|
|
10
|
+
export function formatRelativeTime(iso: string | null | undefined): string {
|
|
11
|
+
if (!iso) return '—'
|
|
12
|
+
const then = new Date(iso).getTime()
|
|
13
|
+
if (Number.isNaN(then)) return '—'
|
|
14
|
+
|
|
15
|
+
const diffSeconds = Math.round((Date.now() - then) / 1000)
|
|
16
|
+
if (diffSeconds < 60) return '刚刚'
|
|
17
|
+
const minutes = Math.floor(diffSeconds / 60)
|
|
18
|
+
if (minutes < 60) return `${minutes} 分钟前`
|
|
19
|
+
const hours = Math.floor(minutes / 60)
|
|
20
|
+
if (hours < 24) return `${hours} 小时前`
|
|
21
|
+
const days = Math.floor(hours / 24)
|
|
22
|
+
if (days < 30) return `${days} 天前`
|
|
23
|
+
const months = Math.floor(days / 30)
|
|
24
|
+
if (months < 12) return `${months} 个月前`
|
|
25
|
+
return `${Math.floor(months / 12)} 年前`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 绝对时间,精确到分钟,使用本地时区。 */
|
|
29
|
+
export function formatDateTime(iso: string | null | undefined): string {
|
|
30
|
+
if (!iso) return '—'
|
|
31
|
+
const date = new Date(iso)
|
|
32
|
+
if (Number.isNaN(date.getTime())) return '—'
|
|
33
|
+
return new Intl.DateTimeFormat('zh-CN', {
|
|
34
|
+
year: 'numeric',
|
|
35
|
+
month: '2-digit',
|
|
36
|
+
day: '2-digit',
|
|
37
|
+
hour: '2-digit',
|
|
38
|
+
minute: '2-digit',
|
|
39
|
+
}).format(date)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 千分位数字,用于统计面板。 */
|
|
43
|
+
export function formatNumber(value: number): string {
|
|
44
|
+
return new Intl.NumberFormat('zh-CN').format(value)
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 全局样式必须先于任何组件模块求值:AntD 的主题令牌是从这些
|
|
2
|
+
// CSS 变量里读出来的,顺序反了会在开发期直接抛错(见 antd-theme.ts 的护栏)。
|
|
3
|
+
import '@/styles/globals.css'
|
|
4
|
+
|
|
5
|
+
import { StrictMode } from 'react'
|
|
6
|
+
import { createRoot } from 'react-dom/client'
|
|
7
|
+
|
|
8
|
+
import { AppProviders } from '@/app/providers'
|
|
9
|
+
import { AppRouter } from '@/app/router'
|
|
10
|
+
|
|
11
|
+
const container = document.getElementById('root')
|
|
12
|
+
if (!container) {
|
|
13
|
+
throw new Error('找不到 #root 挂载点')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
createRoot(container).render(
|
|
17
|
+
<StrictMode>
|
|
18
|
+
<AppProviders>
|
|
19
|
+
<AppRouter />
|
|
20
|
+
</AppProviders>
|
|
21
|
+
</StrictMode>,
|
|
22
|
+
)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
3
|
+
import { Select } from 'antd'
|
|
4
|
+
import { ChevronDown, Settings2 } from 'lucide-react'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
SEVERITY_META,
|
|
9
|
+
attentionApi,
|
|
10
|
+
attentionQueueApi,
|
|
11
|
+
type AttentionGroup,
|
|
12
|
+
type AttentionSample,
|
|
13
|
+
} from '@/api/attention'
|
|
14
|
+
import { useCurrentRepository } from '@/hooks/use-current-repository'
|
|
15
|
+
import { Badge } from '@/components/ui/badge'
|
|
16
|
+
import { Button } from '@/components/ui/button'
|
|
17
|
+
import { Card, CardBody } from '@/components/ui/card'
|
|
18
|
+
import { EmptyState } from '@/components/ui/empty-state'
|
|
19
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
20
|
+
import { useCurrentUser } from '@/hooks/use-current-user'
|
|
21
|
+
import { cn, formatNumber } from '@/lib/utils'
|
|
22
|
+
import { RuleSettingsPanel } from '@/pages/attention/RuleSettingsPanel'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 关注队列。
|
|
26
|
+
*
|
|
27
|
+
* 页面的核心是**收敛**:一条规则一行,而不是每个命中对象一行。
|
|
28
|
+
* 未收敛时有 4780 条,其中 63% 是陈旧 Issue —— 那个列表没有人会翻第二页。
|
|
29
|
+
* 收敛之后是十几行,每行给出条数、最久等待、集中在哪些分支与少量样例;
|
|
30
|
+
* 要逐条处理就点进对应的列表。
|
|
31
|
+
*
|
|
32
|
+
* 每一行都写明"在提醒什么"和"该做什么"。只列问题不给出路,
|
|
33
|
+
* 队列就只是噪音来源 —— 那正是它此前最大的问题。
|
|
34
|
+
*/
|
|
35
|
+
export function AttentionQueuePage() {
|
|
36
|
+
const [scope, setScope] = useState<'all' | 'pull_request' | 'issue'>('all')
|
|
37
|
+
const [showSettings, setShowSettings] = useState(false)
|
|
38
|
+
|
|
39
|
+
const { repository, isLoading: reposLoading } = useCurrentRepository()
|
|
40
|
+
|
|
41
|
+
const { data: groups, isLoading } = useQuery({
|
|
42
|
+
queryKey: ['attention-queue', repository?.id, scope],
|
|
43
|
+
queryFn: () =>
|
|
44
|
+
attentionQueueApi.queue(
|
|
45
|
+
repository!.id,
|
|
46
|
+
scope === 'all' ? undefined : (scope as 'pull_request' | 'issue'),
|
|
47
|
+
),
|
|
48
|
+
enabled: Boolean(repository),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (reposLoading) return <Skeleton className="h-64 w-full" />
|
|
52
|
+
if (!repository) return <EmptyState title="尚未纳管仓库" />
|
|
53
|
+
|
|
54
|
+
const total = (groups ?? []).reduce((sum, group) => sum + group.count, 0)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-5">
|
|
58
|
+
<header className="flex flex-wrap items-end justify-between gap-3">
|
|
59
|
+
<div>
|
|
60
|
+
<h1 className="text-lg font-semibold tracking-tight">关注队列</h1>
|
|
61
|
+
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
|
62
|
+
按规则聚合的待办,
|
|
63
|
+
<span className="text-[var(--color-text-primary)]">一条规则一行</span>
|
|
64
|
+
。逐条列出会把「需要关注」变成「全部清单」:
|
|
65
|
+
{groups && groups.length > 0
|
|
66
|
+
? `当前 ${formatNumber(total)} 个对象收敛为 ${groups.length} 组。`
|
|
67
|
+
: ''}
|
|
68
|
+
要逐个处理,用每组右侧的「逐条处理」。规则与阈值见「关注设置」。
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<Select
|
|
73
|
+
aria-label="范围"
|
|
74
|
+
value={scope}
|
|
75
|
+
options={[
|
|
76
|
+
{ value: 'all', label: '全部对象' },
|
|
77
|
+
{ value: 'pull_request', label: '仅 PR' },
|
|
78
|
+
{ value: 'issue', label: '仅 Issue' },
|
|
79
|
+
]}
|
|
80
|
+
onChange={(value) => setScope(value as typeof scope)}
|
|
81
|
+
/>
|
|
82
|
+
<Button
|
|
83
|
+
variant={showSettings ? 'primary' : 'ghost'}
|
|
84
|
+
size="sm"
|
|
85
|
+
onClick={() => setShowSettings((value) => !value)}
|
|
86
|
+
>
|
|
87
|
+
<Settings2 className="size-3.5" aria-hidden />
|
|
88
|
+
关注设置
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
{showSettings && <RuleSettingsPanel repositoryId={repository.id} />}
|
|
94
|
+
|
|
95
|
+
{isLoading ? (
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
{Array.from({ length: 5 }).map((_, index) => (
|
|
98
|
+
<Skeleton key={index} className="h-20 w-full" />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
) : !groups || groups.length === 0 ? (
|
|
102
|
+
<EmptyState
|
|
103
|
+
title="关注队列为空"
|
|
104
|
+
description="可能是各规则的阈值都未触发,也可能是规则被全部关闭 —— 可在「关注设置」中查看。"
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="space-y-3">
|
|
108
|
+
{groups.map((group) => (
|
|
109
|
+
<GroupCard key={group.rule} group={group} />
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function GroupCard({ group }: { group: AttentionGroup }) {
|
|
118
|
+
const queryClient = useQueryClient()
|
|
119
|
+
const { user } = useCurrentUser()
|
|
120
|
+
const [expanded, setExpanded] = useState(false)
|
|
121
|
+
const severity = SEVERITY_META[group.severity]
|
|
122
|
+
const critical = group.severity === 'critical' || group.severity === 'blocker'
|
|
123
|
+
|
|
124
|
+
const claim = useMutation({
|
|
125
|
+
mutationFn: ({ itemId, release }: { itemId: string; release: boolean }) =>
|
|
126
|
+
release ? attentionApi.release(itemId) : attentionApi.acknowledge(itemId),
|
|
127
|
+
onSuccess: () => {
|
|
128
|
+
queryClient.invalidateQueries({ queryKey: ['attention-queue'] })
|
|
129
|
+
queryClient.invalidateQueries({ queryKey: ['attention-rule-settings'] })
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// CVE 相关的规则点进去只看 CVE —— 否则"逐条处理"会把使用者丢回全量列表
|
|
134
|
+
const search =
|
|
135
|
+
group.subject === 'issue'
|
|
136
|
+
? undefined
|
|
137
|
+
: group.rule === 'cve_aging'
|
|
138
|
+
? { state: 'open' as const, kind: ['cve'] }
|
|
139
|
+
: { state: 'open' as const }
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Card
|
|
143
|
+
className={cn(
|
|
144
|
+
critical &&
|
|
145
|
+
'border-[color-mix(in_oklch,var(--color-danger)_26%,var(--color-border-subtle))]',
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
<CardBody>
|
|
149
|
+
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
|
150
|
+
<div className="min-w-0 flex-1">
|
|
151
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
152
|
+
<Badge tone={severity?.tone as never}>{severity?.label}</Badge>
|
|
153
|
+
<span className="text-sm font-medium">{group.label}</span>
|
|
154
|
+
<span className="font-mono text-sm tabular-nums">
|
|
155
|
+
{formatNumber(group.count)}
|
|
156
|
+
</span>
|
|
157
|
+
<Badge tone="neutral" className="font-normal">
|
|
158
|
+
{group.subject === 'issue' ? 'Issue' : 'PR'}
|
|
159
|
+
</Badge>
|
|
160
|
+
{group.oldest_days !== null && (
|
|
161
|
+
<span className="text-xs text-[var(--color-text-muted)]">
|
|
162
|
+
最久已等 {group.oldest_days} 天
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{group.acknowledged_count > 0 && (
|
|
166
|
+
<span className="text-xs text-[var(--color-text-muted)]">
|
|
167
|
+
已认领 {group.acknowledged_count}
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<p className="mt-1.5 text-sm leading-relaxed text-[var(--color-text-secondary)]">
|
|
173
|
+
{group.meaning}
|
|
174
|
+
</p>
|
|
175
|
+
<p className="mt-1 text-xs leading-relaxed text-[var(--color-accent)]">
|
|
176
|
+
建议:{group.action}
|
|
177
|
+
</p>
|
|
178
|
+
|
|
179
|
+
{group.themes.length > 0 && (
|
|
180
|
+
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
181
|
+
<span className="text-2xs text-[var(--color-text-muted)]">集中在</span>
|
|
182
|
+
{group.themes.slice(0, 6).map((theme) => (
|
|
183
|
+
<span
|
|
184
|
+
key={theme.name}
|
|
185
|
+
className="inline-flex items-center gap-1 rounded-full border border-[var(--color-border-subtle)] px-2 py-0.5 text-2xs"
|
|
186
|
+
>
|
|
187
|
+
<span className="font-mono">{theme.name}</span>
|
|
188
|
+
<span className="tabular-nums text-[var(--color-text-muted)]">
|
|
189
|
+
{theme.count}
|
|
190
|
+
</span>
|
|
191
|
+
</span>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
198
|
+
{group.subject === 'issue' ? (
|
|
199
|
+
<Link
|
|
200
|
+
to="/issues"
|
|
201
|
+
className="rounded-[var(--radius-control)] border border-[var(--color-border-subtle)] px-2.5 py-1 text-xs hover:border-[var(--color-border-strong)]"
|
|
202
|
+
>
|
|
203
|
+
逐条处理
|
|
204
|
+
</Link>
|
|
205
|
+
) : (
|
|
206
|
+
<Link
|
|
207
|
+
to="/pulls"
|
|
208
|
+
search={search}
|
|
209
|
+
className="rounded-[var(--radius-control)] border border-[var(--color-border-subtle)] px-2.5 py-1 text-xs hover:border-[var(--color-border-strong)]"
|
|
210
|
+
>
|
|
211
|
+
逐条处理
|
|
212
|
+
</Link>
|
|
213
|
+
)}
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={() => setExpanded((value) => !value)}
|
|
217
|
+
aria-expanded={expanded}
|
|
218
|
+
className="inline-flex items-center gap-1 rounded-[var(--radius-control)] px-2.5 py-1 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)]"
|
|
219
|
+
>
|
|
220
|
+
样例 {group.samples.length}
|
|
221
|
+
<ChevronDown
|
|
222
|
+
className={cn(
|
|
223
|
+
'size-3.5 transition-transform duration-150',
|
|
224
|
+
expanded && 'rotate-180',
|
|
225
|
+
)}
|
|
226
|
+
aria-hidden
|
|
227
|
+
/>
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{expanded && (
|
|
233
|
+
<ul className="mt-3 space-y-1 border-t border-[var(--color-border-subtle)] pt-3">
|
|
234
|
+
{group.samples.map((sample) => (
|
|
235
|
+
<SampleRow
|
|
236
|
+
key={sample.item_id}
|
|
237
|
+
sample={sample}
|
|
238
|
+
currentUserId={user?.id ?? null}
|
|
239
|
+
busy={claim.isPending && claim.variables?.itemId === sample.item_id}
|
|
240
|
+
onToggle={() =>
|
|
241
|
+
claim.mutate({
|
|
242
|
+
itemId: sample.item_id,
|
|
243
|
+
release: sample.acknowledged_by !== null,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</ul>
|
|
249
|
+
)}
|
|
250
|
+
</CardBody>
|
|
251
|
+
</Card>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SAMPLE_ROW =
|
|
256
|
+
'flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-control)] px-1.5 py-1 hover:bg-[var(--color-surface-2)]'
|
|
257
|
+
|
|
258
|
+
function SampleRow({
|
|
259
|
+
sample,
|
|
260
|
+
currentUserId,
|
|
261
|
+
busy,
|
|
262
|
+
onToggle,
|
|
263
|
+
}: {
|
|
264
|
+
sample: AttentionSample
|
|
265
|
+
currentUserId: string | null
|
|
266
|
+
busy: boolean
|
|
267
|
+
onToggle: () => void
|
|
268
|
+
}) {
|
|
269
|
+
const mine = sample.acknowledged_by !== null && sample.acknowledged_by === currentUserId
|
|
270
|
+
const claimedByOther = sample.acknowledged_by !== null && !mine
|
|
271
|
+
|
|
272
|
+
const inner = (
|
|
273
|
+
<>
|
|
274
|
+
<span className="w-16 shrink-0 font-mono text-xs tabular-nums text-[var(--color-text-muted)]">
|
|
275
|
+
#{sample.number}
|
|
276
|
+
</span>
|
|
277
|
+
<span className="min-w-0 flex-1 truncate text-sm">{sample.title}</span>
|
|
278
|
+
{sample.age_days !== null && (
|
|
279
|
+
<span className="shrink-0 font-mono text-2xs tabular-nums text-[var(--color-text-muted)]">
|
|
280
|
+
{sample.age_days} 天
|
|
281
|
+
</span>
|
|
282
|
+
)}
|
|
283
|
+
</>
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<li className="flex items-center gap-2">
|
|
288
|
+
{sample.subject_type === 'pull_request' ? (
|
|
289
|
+
<Link to="/pulls/$pullId" params={{ pullId: sample.subject_id }} className={SAMPLE_ROW}>
|
|
290
|
+
{inner}
|
|
291
|
+
</Link>
|
|
292
|
+
) : sample.url ? (
|
|
293
|
+
<a href={sample.url} target="_blank" rel="noreferrer noopener" className={SAMPLE_ROW}>
|
|
294
|
+
{inner}
|
|
295
|
+
</a>
|
|
296
|
+
) : (
|
|
297
|
+
<span className={SAMPLE_ROW}>{inner}</span>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{claimedByOther ? (
|
|
301
|
+
<Badge tone="info" className="shrink-0 font-normal">
|
|
302
|
+
已认领
|
|
303
|
+
</Badge>
|
|
304
|
+
) : (
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={onToggle}
|
|
308
|
+
disabled={busy}
|
|
309
|
+
className="shrink-0 rounded-[var(--radius-control)] px-2 py-0.5 text-2xs text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-accent)] disabled:opacity-50"
|
|
310
|
+
>
|
|
311
|
+
{mine ? '交还' : '认领'}
|
|
312
|
+
</button>
|
|
313
|
+
)}
|
|
314
|
+
</li>
|
|
315
|
+
)
|
|
316
|
+
}
|