@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,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
+ )