@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,451 @@
|
|
|
1
|
+
"""关注队列的订阅设置与分组收敛。
|
|
2
|
+
|
|
3
|
+
队列此前是**逐条**罗列:每个命中对象一行。实测 4780 条里,
|
|
4
|
+
issue_stale 1755 + issue_unassigned 1259 占了 63%,CVE 相关 1004 条。
|
|
5
|
+
那不是"需要关注",那是"全部清单"—— 翻两页就会放弃的功能等于没做。
|
|
6
|
+
|
|
7
|
+
收敛分两步,都在这里:
|
|
8
|
+
|
|
9
|
+
1. **订阅**:规则可关、阈值可调、范围可限定(只看 PR / 只看 Issue)。
|
|
10
|
+
阈值写死在代码里的结果是没人用 —— 第一条不符合自己节奏的提醒
|
|
11
|
+
就会让人关掉整个队列。
|
|
12
|
+
2. **分组**:一条规则聚合成一行,给出条数、最久未处理的、最要紧的几条样本,
|
|
13
|
+
以及可以点进去的筛选链接。463 条 cve_aging 变成一行"CVE 未推进 · 463 个",
|
|
14
|
+
使用者据此判断要不要展开,而不是被迫逐条翻。
|
|
15
|
+
|
|
16
|
+
按主题(子系统)细分只对高量规则做:十几条的规则再分组,
|
|
17
|
+
只会把一屏能看完的东西摊成三屏。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import uuid
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
|
|
27
|
+
from sqlalchemy import Select, func, select
|
|
28
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
29
|
+
|
|
30
|
+
from app.domain.attention import RULE_INFO, RULE_INFO_BY_KEY, Thresholds
|
|
31
|
+
from app.models.attention import AttentionItem, AttentionSeverity
|
|
32
|
+
from app.models.attention_settings import AttentionRuleSetting
|
|
33
|
+
from app.models.classification import SubjectType
|
|
34
|
+
from app.models.issue import Issue
|
|
35
|
+
from app.models.pull_request import PullRequest
|
|
36
|
+
|
|
37
|
+
# 高量规则按主题细分时,一个主题至少要有这么多条才单列,
|
|
38
|
+
# 否则零散主题会把分组列表撑成原来那个样子
|
|
39
|
+
THEME_MIN_COUNT = 5
|
|
40
|
+
|
|
41
|
+
# 每组给出多少条样本
|
|
42
|
+
SAMPLE_SIZE = 5
|
|
43
|
+
|
|
44
|
+
# 严重程度排序。blocker 最前。
|
|
45
|
+
SEVERITY_RANK = {
|
|
46
|
+
AttentionSeverity.BLOCKER.value: 0,
|
|
47
|
+
AttentionSeverity.CRITICAL.value: 1,
|
|
48
|
+
AttentionSeverity.WARNING.value: 2,
|
|
49
|
+
AttentionSeverity.INFO.value: 3,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RuleSettingView:
|
|
55
|
+
"""一条规则的当前设置。界面据此渲染,不用自己拼默认值。"""
|
|
56
|
+
|
|
57
|
+
rule: str
|
|
58
|
+
label: str
|
|
59
|
+
meaning: str
|
|
60
|
+
action: str
|
|
61
|
+
subject: str
|
|
62
|
+
enabled: bool
|
|
63
|
+
days: int
|
|
64
|
+
default_days: int
|
|
65
|
+
subject_scope: str
|
|
66
|
+
high_volume: bool
|
|
67
|
+
open_count: int = 0
|
|
68
|
+
"""当前未解决的条数。关掉一条规则前应该先看到它拦下了多少东西。"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def load_settings(session: AsyncSession) -> list[RuleSettingView]:
|
|
72
|
+
"""规则设置。DB 里没有记录的规则用领域层默认值。
|
|
73
|
+
|
|
74
|
+
不给每条规则预插一行默认记录:那样领域层改默认值时要写数据迁移,
|
|
75
|
+
而默认值本来就该由代码定义。
|
|
76
|
+
"""
|
|
77
|
+
overrides = {
|
|
78
|
+
row.rule: row for row in (await session.scalars(select(AttentionRuleSetting))).all()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
counts_rows = (
|
|
82
|
+
await session.execute(
|
|
83
|
+
select(AttentionItem.rule, func.count())
|
|
84
|
+
.where(AttentionItem.resolved_at.is_(None))
|
|
85
|
+
.group_by(AttentionItem.rule)
|
|
86
|
+
)
|
|
87
|
+
).all()
|
|
88
|
+
counts = {
|
|
89
|
+
str(row[0].value if hasattr(row[0], "value") else row[0]): row[1] for row in counts_rows
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
defaults = Thresholds()
|
|
93
|
+
views: list[RuleSettingView] = []
|
|
94
|
+
for info in RULE_INFO:
|
|
95
|
+
override = overrides.get(info.rule.value)
|
|
96
|
+
default_days = int(getattr(defaults, info.threshold_field, info.default_days))
|
|
97
|
+
views.append(
|
|
98
|
+
RuleSettingView(
|
|
99
|
+
rule=info.rule.value,
|
|
100
|
+
label=info.label,
|
|
101
|
+
meaning=info.meaning,
|
|
102
|
+
action=info.action,
|
|
103
|
+
subject=info.subject,
|
|
104
|
+
enabled=override.enabled if override else True,
|
|
105
|
+
days=override.days if override and override.days is not None else default_days,
|
|
106
|
+
default_days=default_days,
|
|
107
|
+
subject_scope=override.subject_scope if override else "all",
|
|
108
|
+
high_volume=info.high_volume,
|
|
109
|
+
open_count=counts.get(info.rule.value, 0),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return views
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def save_setting(
|
|
116
|
+
session: AsyncSession,
|
|
117
|
+
*,
|
|
118
|
+
rule: str,
|
|
119
|
+
enabled: bool | None = None,
|
|
120
|
+
days: int | None = None,
|
|
121
|
+
subject_scope: str | None = None,
|
|
122
|
+
user_id: uuid.UUID | None = None,
|
|
123
|
+
) -> AttentionRuleSetting:
|
|
124
|
+
"""写入或更新一条规则的订阅设置。"""
|
|
125
|
+
row = await session.scalar(
|
|
126
|
+
select(AttentionRuleSetting).where(AttentionRuleSetting.rule == rule)
|
|
127
|
+
)
|
|
128
|
+
if row is None:
|
|
129
|
+
row = AttentionRuleSetting(rule=rule)
|
|
130
|
+
session.add(row)
|
|
131
|
+
|
|
132
|
+
if enabled is not None:
|
|
133
|
+
row.enabled = enabled
|
|
134
|
+
if days is not None:
|
|
135
|
+
row.days = max(0, days)
|
|
136
|
+
if subject_scope is not None:
|
|
137
|
+
row.subject_scope = subject_scope
|
|
138
|
+
|
|
139
|
+
row.updated_by = user_id
|
|
140
|
+
# 设置只影响下一次重算:已生成的条目带着上一轮的阈值,
|
|
141
|
+
# 当场按新阈值过滤会让"条数"与"列表"对不上,看起来像 bug
|
|
142
|
+
row.effective_at = datetime.now(UTC)
|
|
143
|
+
await session.flush()
|
|
144
|
+
return row
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class QueueSample:
|
|
149
|
+
number: int
|
|
150
|
+
title: str
|
|
151
|
+
subject_type: str
|
|
152
|
+
subject_id: str
|
|
153
|
+
url: str | None
|
|
154
|
+
severity: str
|
|
155
|
+
detail: str | None
|
|
156
|
+
age_days: int | None
|
|
157
|
+
"""对象等待了多久。None 表示该规则不带时间信息。"""
|
|
158
|
+
|
|
159
|
+
item_id: str
|
|
160
|
+
"""关注项自身的 id。认领作用于它,而不是作用于对象。"""
|
|
161
|
+
|
|
162
|
+
acknowledged_by: str | None
|
|
163
|
+
"""认领人的 user id。null 表示无人认领。"""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class QueueTheme:
|
|
168
|
+
"""主题细分。只对高量规则生成。"""
|
|
169
|
+
|
|
170
|
+
name: str
|
|
171
|
+
count: int
|
|
172
|
+
samples: list[QueueSample] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class QueueGroup:
|
|
177
|
+
"""一条规则的聚合结果。队列按它渲染一行。"""
|
|
178
|
+
|
|
179
|
+
rule: str
|
|
180
|
+
label: str
|
|
181
|
+
meaning: str
|
|
182
|
+
action: str
|
|
183
|
+
subject: str
|
|
184
|
+
count: int
|
|
185
|
+
severity: str
|
|
186
|
+
oldest_days: int | None
|
|
187
|
+
samples: list[QueueSample] = field(default_factory=list)
|
|
188
|
+
themes: list[QueueTheme] = field(default_factory=list)
|
|
189
|
+
high_volume: bool = False
|
|
190
|
+
|
|
191
|
+
acknowledged_count: int = 0
|
|
192
|
+
"""本组已被认领的条数。认领是逐条的动作,而分组是会收敛的视图 ——
|
|
193
|
+
被认领的那条未必落在样例里,所以数字单独统计,不能从样例里数。"""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class QueueOverview:
|
|
198
|
+
"""队列的全貌。
|
|
199
|
+
|
|
200
|
+
分组与计数必须出自同一次查询、同一份订阅设置:分别算的话,
|
|
201
|
+
关掉一条规则后首页与队列页会显示两个不同的数字,
|
|
202
|
+
而这种不一致没法从任一页面上看出来。
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
groups: list[QueueGroup]
|
|
206
|
+
by_severity: dict[str, int]
|
|
207
|
+
total: int
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _base_query(repository_id: uuid.UUID, enabled_rules: list[str]) -> Select:
|
|
211
|
+
return select(
|
|
212
|
+
AttentionItem.id,
|
|
213
|
+
AttentionItem.rule,
|
|
214
|
+
AttentionItem.severity,
|
|
215
|
+
AttentionItem.subject_type,
|
|
216
|
+
AttentionItem.subject_id,
|
|
217
|
+
AttentionItem.subject_number,
|
|
218
|
+
AttentionItem.subject_title,
|
|
219
|
+
AttentionItem.subject_url,
|
|
220
|
+
AttentionItem.detail,
|
|
221
|
+
AttentionItem.evidence,
|
|
222
|
+
AttentionItem.acknowledged_by,
|
|
223
|
+
AttentionItem.first_detected_at,
|
|
224
|
+
).where(
|
|
225
|
+
AttentionItem.repository_id == repository_id,
|
|
226
|
+
AttentionItem.resolved_at.is_(None),
|
|
227
|
+
AttentionItem.rule.in_(enabled_rules),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def build_queue(
|
|
232
|
+
session: AsyncSession,
|
|
233
|
+
repository_id: uuid.UUID,
|
|
234
|
+
*,
|
|
235
|
+
scope: str | None = None,
|
|
236
|
+
) -> list[QueueGroup]:
|
|
237
|
+
"""按订阅设置生成分组队列。"""
|
|
238
|
+
return (await build_overview(session, repository_id, scope=scope)).groups
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def build_overview(
|
|
242
|
+
session: AsyncSession,
|
|
243
|
+
repository_id: uuid.UUID,
|
|
244
|
+
*,
|
|
245
|
+
scope: str | None = None,
|
|
246
|
+
) -> QueueOverview:
|
|
247
|
+
"""分组队列 + 与它同源的严重程度分布。
|
|
248
|
+
|
|
249
|
+
只查启用的规则 —— 关掉的规则不该出现在队列里,也不该出现在计数里。
|
|
250
|
+
否则"关掉"就只是视觉上的隐藏,使用者仍会被那个数字干扰。
|
|
251
|
+
|
|
252
|
+
分布按**条目**统计而不是按分组的严重程度累加:同一条规则会因时长
|
|
253
|
+
落到不同的档(CI 失败 3 天内是警告、之后是严重),拿分组里最严重的那档
|
|
254
|
+
代表全组,会把警告档的条目算进严重档。
|
|
255
|
+
"""
|
|
256
|
+
settings = await load_settings(session)
|
|
257
|
+
by_rule = {item.rule: item for item in settings}
|
|
258
|
+
enabled = [
|
|
259
|
+
item.rule
|
|
260
|
+
for item in settings
|
|
261
|
+
if item.enabled and (scope is None or item.subject == scope or item.subject_scope == "all")
|
|
262
|
+
]
|
|
263
|
+
if not enabled:
|
|
264
|
+
return QueueOverview(groups=[], by_severity={}, total=0)
|
|
265
|
+
|
|
266
|
+
stmt = _base_query(repository_id, enabled)
|
|
267
|
+
if scope == "pull_request":
|
|
268
|
+
stmt = stmt.where(AttentionItem.subject_type == SubjectType.PULL_REQUEST.value)
|
|
269
|
+
elif scope == "issue":
|
|
270
|
+
stmt = stmt.where(AttentionItem.subject_type == SubjectType.ISSUE.value)
|
|
271
|
+
|
|
272
|
+
rows = (await session.execute(stmt)).all()
|
|
273
|
+
|
|
274
|
+
# 主题名要额外查一次:PR 用目标分支,Issue 用类型。
|
|
275
|
+
# 不用子系统 —— 实测只有 20% 的 PR 和 2.6% 的 Issue 有子系统归属
|
|
276
|
+
# (内核路径大多不在映射表内),按它分组会得到一大片"未归类",
|
|
277
|
+
# 等于没分组。分支是恒有且维护者本来就按它组织工作的维度。
|
|
278
|
+
themes = await _theme_names(session, [row.subject_id for row in rows])
|
|
279
|
+
|
|
280
|
+
grouped: dict[str, list] = defaultdict(list)
|
|
281
|
+
for row in rows:
|
|
282
|
+
grouped[str(row.rule.value if hasattr(row.rule, "value") else row.rule)].append(row)
|
|
283
|
+
|
|
284
|
+
groups: list[QueueGroup] = []
|
|
285
|
+
for rule, items in grouped.items():
|
|
286
|
+
info = RULE_INFO_BY_KEY.get(rule)
|
|
287
|
+
if info is None:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
samples = [
|
|
291
|
+
QueueSample(
|
|
292
|
+
number=row.subject_number,
|
|
293
|
+
title=row.subject_title,
|
|
294
|
+
subject_type=row.subject_type,
|
|
295
|
+
subject_id=str(row.subject_id),
|
|
296
|
+
url=row.subject_url,
|
|
297
|
+
severity=str(
|
|
298
|
+
row.severity.value if hasattr(row.severity, "value") else row.severity
|
|
299
|
+
),
|
|
300
|
+
detail=row.detail,
|
|
301
|
+
# 用规则自己算出的等待天数,而不是关注项的存在时长。
|
|
302
|
+
# 关注项是每轮重算时重新求值的,用它算年龄永远是 0 天 ——
|
|
303
|
+
# 使用者要问的是"这个 PR 等了多久",不是"这条提醒存在了多久"。
|
|
304
|
+
age_days=age_days(row.evidence),
|
|
305
|
+
item_id=str(row.id),
|
|
306
|
+
acknowledged_by=str(row.acknowledged_by) if row.acknowledged_by else None,
|
|
307
|
+
)
|
|
308
|
+
for row in items
|
|
309
|
+
]
|
|
310
|
+
samples.sort(key=lambda item: (SEVERITY_RANK.get(item.severity, 9), -(item.age_days or 0)))
|
|
311
|
+
|
|
312
|
+
severity = min(
|
|
313
|
+
(item.severity for item in samples),
|
|
314
|
+
key=lambda value: SEVERITY_RANK.get(value, 9),
|
|
315
|
+
default=AttentionSeverity.INFO.value,
|
|
316
|
+
)
|
|
317
|
+
ages = [item.age_days for item in samples if item.age_days is not None]
|
|
318
|
+
group = QueueGroup(
|
|
319
|
+
rule=rule,
|
|
320
|
+
label=info.label,
|
|
321
|
+
meaning=info.meaning,
|
|
322
|
+
action=info.action,
|
|
323
|
+
subject=info.subject,
|
|
324
|
+
count=len(items),
|
|
325
|
+
severity=severity,
|
|
326
|
+
oldest_days=max(ages) if ages else None,
|
|
327
|
+
samples=samples[:SAMPLE_SIZE],
|
|
328
|
+
high_volume=by_rule.get(rule).high_volume if by_rule.get(rule) else info.high_volume,
|
|
329
|
+
acknowledged_count=sum(1 for item in items if item.acknowledged_by is not None),
|
|
330
|
+
)
|
|
331
|
+
if group.high_volume:
|
|
332
|
+
group.themes = _themes(samples, themes)
|
|
333
|
+
groups.append(group)
|
|
334
|
+
|
|
335
|
+
# 分组排序:先看有没有必须处理的,再看是不是压着一堆
|
|
336
|
+
groups.sort(key=lambda item: (SEVERITY_RANK.get(item.severity, 9), -item.count))
|
|
337
|
+
|
|
338
|
+
by_severity: dict[str, int] = defaultdict(int)
|
|
339
|
+
for row in rows:
|
|
340
|
+
by_severity[
|
|
341
|
+
str(row.severity.value if hasattr(row.severity, "value") else row.severity)
|
|
342
|
+
] += 1
|
|
343
|
+
|
|
344
|
+
return QueueOverview(groups=groups, by_severity=dict(by_severity), total=len(rows))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# 各规则写进 evidence 的时间键不同,实测取值如下:
|
|
348
|
+
# review_sla_breach → waiting_days issue_unassigned → days_open
|
|
349
|
+
# stale / ci_failed / merge_conflict / issue_stale → days_since_update
|
|
350
|
+
# cla_pending_long → days cve_aging → hours(注意是小时)
|
|
351
|
+
# 这张表是照着真实数据列的,不是照着一厢情愿的命名约定写的。
|
|
352
|
+
_AGE_KEYS: tuple[tuple[str, int], ...] = (
|
|
353
|
+
("waiting_days", 1),
|
|
354
|
+
("days_open", 1),
|
|
355
|
+
("days_since_update", 1),
|
|
356
|
+
("days", 1),
|
|
357
|
+
("hours", 24),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def age_days(evidence: object) -> int | None:
|
|
362
|
+
"""条目对应的对象等了多久。
|
|
363
|
+
|
|
364
|
+
返回 None 表示这条规则**本来就不带时间信息**(如"缺关联 Issue"、
|
|
365
|
+
"已被 NACK")—— 这时候显示"0 天"是错的,那会被读成"刚发生"。
|
|
366
|
+
界面据此显示"——"。
|
|
367
|
+
|
|
368
|
+
不用关注项的创建时间兜底:关注项是每轮重算时重新生成的,
|
|
369
|
+
用它算年龄永远接近 0,而使用者问的是"这个 PR 等了多久"。
|
|
370
|
+
"""
|
|
371
|
+
if not isinstance(evidence, dict):
|
|
372
|
+
return None
|
|
373
|
+
for key, divisor in _AGE_KEYS:
|
|
374
|
+
value = evidence.get(key)
|
|
375
|
+
if isinstance(value, int | float):
|
|
376
|
+
return int(value / divisor)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _themes(samples: list[QueueSample], names: dict[str, str]) -> list[QueueTheme]:
|
|
381
|
+
"""把样本分主题。
|
|
382
|
+
|
|
383
|
+
只按**样本**分主题,不重查全量:用途是让使用者看出"这几百条集中在
|
|
384
|
+
哪几块",而不是给出精确统计 —— 那件事在分面里有。
|
|
385
|
+
"""
|
|
386
|
+
buckets: dict[str, list[QueueSample]] = defaultdict(list)
|
|
387
|
+
for sample in samples:
|
|
388
|
+
buckets[names.get(sample.subject_id) or "其他"].append(sample)
|
|
389
|
+
|
|
390
|
+
themes = [
|
|
391
|
+
QueueTheme(name=name, count=len(items), samples=items)
|
|
392
|
+
for name, items in buckets.items()
|
|
393
|
+
if len(items) >= THEME_MIN_COUNT or name != "其他"
|
|
394
|
+
]
|
|
395
|
+
themes.sort(key=lambda item: -item.count)
|
|
396
|
+
return themes
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def _theme_names(session: AsyncSession, subject_ids: list[uuid.UUID]) -> dict[str, str]:
|
|
400
|
+
"""批量取主题名:PR 用目标分支,Issue 用类型。
|
|
401
|
+
|
|
402
|
+
两条查询而不是一条 union:两者的取值来源不同,混在一个 SQL 里
|
|
403
|
+
要靠 CASE 分支拼,可读性换不来任何好处。
|
|
404
|
+
"""
|
|
405
|
+
if not subject_ids:
|
|
406
|
+
return {}
|
|
407
|
+
|
|
408
|
+
names: dict[str, str] = {}
|
|
409
|
+
branches = await session.execute(
|
|
410
|
+
select(PullRequest.id, PullRequest.target_branch).where(
|
|
411
|
+
PullRequest.id.in_(subject_ids), PullRequest.target_branch.is_not(None)
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
for row in branches.all():
|
|
415
|
+
names[str(row[0])] = row[1]
|
|
416
|
+
|
|
417
|
+
types = await session.execute(
|
|
418
|
+
select(Issue.id, Issue.issue_type).where(
|
|
419
|
+
Issue.id.in_(subject_ids), Issue.issue_type.is_not(None)
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
for row in types.all():
|
|
423
|
+
names.setdefault(str(row[0]), row[1])
|
|
424
|
+
|
|
425
|
+
return names
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass
|
|
429
|
+
class QueueSummary:
|
|
430
|
+
"""首页摘要用的口径。与队列同源,避免两处数字对不上。"""
|
|
431
|
+
|
|
432
|
+
total: int
|
|
433
|
+
group_count: int
|
|
434
|
+
by_severity: dict[str, int]
|
|
435
|
+
blocking: int
|
|
436
|
+
"""必须动手的组数(blocker 档)。"""
|
|
437
|
+
top: list[QueueGroup]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def queue_summary(session: AsyncSession, repository_id: uuid.UUID) -> QueueSummary:
|
|
441
|
+
overview = await build_overview(session, repository_id)
|
|
442
|
+
return QueueSummary(
|
|
443
|
+
total=overview.total,
|
|
444
|
+
group_count=len(overview.groups),
|
|
445
|
+
by_severity=overview.by_severity,
|
|
446
|
+
blocking=sum(
|
|
447
|
+
1 for group in overview.groups if group.severity == AttentionSeverity.BLOCKER.value
|
|
448
|
+
),
|
|
449
|
+
# 首页只放最要紧的三组:它是"瞥一眼",不是清单
|
|
450
|
+
top=overview.groups[:3],
|
|
451
|
+
)
|