@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,689 @@
1
+ """分类规则引擎与子系统归属推导。
2
+
3
+ **规则优先,AI 兜底**:规则确定性、零成本、可解释;只有规则未命中或
4
+ 置信度低时才值得调用模型。全部走大模型既慢又贵,而且对
5
+ "CVE-2026-1234" 这种标题来说,正则比模型准确得多。
6
+
7
+ 纯函数实现,无数据库与网络依赖。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass, field
15
+ from enum import StrEnum
16
+
17
+
18
+ def _p(*patterns: str) -> tuple[re.Pattern[str], ...]:
19
+ """把若干正则源码编译成模式元组。统一走大小写不敏感。"""
20
+ return tuple(re.compile(p, re.IGNORECASE) for p in patterns)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # 类型识别
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ class Kind(StrEnum):
29
+ """PR/Issue 的内容类别。
30
+
31
+ 划分依据是维护者"接下来要做什么",而不是学术上的完备分类:
32
+ 安全修复有时限、回合补丁要逐行核对上游、新增驱动要看厂商支持范围、
33
+ 自研特性要过 KABI —— 这些动作各不相同,因此必须能分开筛。
34
+ """
35
+
36
+ CVE = "cve"
37
+ RELEASE = "release"
38
+ BACKPORT = "backport"
39
+ DRIVER_NEW = "driver_new"
40
+ DRIVER_UPDATE = "driver_update"
41
+ SOC_SUPPORT = "soc_support"
42
+ OUT_OF_TREE = "out_of_tree"
43
+ BUGFIX = "bugfix"
44
+ FEATURE = "feature"
45
+ PERF = "perf"
46
+ REFACTOR = "refactor"
47
+ DOCS = "docs"
48
+ CLEANUP = "cleanup"
49
+ UNKNOWN = "unknown"
50
+
51
+
52
+ CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE)
53
+ # openEuler 的 PR 正文约定:fixes #123 / resolves #123 / closes #123
54
+ LINKED_ISSUE_PATTERN = re.compile(
55
+ r"(?:fix(?:e[sd])?|resolve[sd]?|close[sd]?)\s*:?\s*#(\d+)", re.IGNORECASE
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # 提交信息里的自声明元数据
61
+ # ---------------------------------------------------------------------------
62
+ #
63
+ # openEuler 的补丁在提交信息末尾带一段结构化标注,作者自己填写的:
64
+ #
65
+ # stable inclusion
66
+ # from stable-v6.6.64
67
+ # commit 05b1b33936b71e5f189a813a517f72e8a27fcb2f
68
+ # category: bugfix
69
+ # bugzilla: https://atomgit.com/openeuler/kernel/issues/9674
70
+ #
71
+ # 实测 1264 条提交中 1235 条带 category(97.7%)。这比从标题猜关键词
72
+ # 可靠得多 —— 标题描述的是"改了什么",而这里直接写明"属于哪一类"
73
+ # 以及"从哪来"。因此把它作为首选依据,标题规则退为兜底。
74
+
75
+ CATEGORY_PATTERN = re.compile(r"^\s*category\s*:\s*(\w+)", re.MULTILINE | re.IGNORECASE)
76
+ INCLUSION_PATTERN = re.compile(r"^\s*([\w-]+)\s+inclusion\b", re.MULTILINE | re.IGNORECASE)
77
+
78
+ # 作者声明的 category → 类别。实测取值只有这几种,另含一个拼写错误。
79
+ CATEGORY_TO_KIND: dict[str, Kind] = {
80
+ "bugfix": Kind.BUGFIX,
81
+ "feature": Kind.FEATURE,
82
+ "cleanup": Kind.CLEANUP,
83
+ "performance": Kind.PERF,
84
+ "perf": Kind.PERF,
85
+ "other": Kind.UNKNOWN,
86
+ # 实测存在 1 处拼写错误,宁可容错也不要把它当成新类别
87
+ "featrue": Kind.FEATURE,
88
+ "bugfx": Kind.BUGFIX,
89
+ }
90
+
91
+ # "X inclusion" 的来源标注 → 类别。
92
+ # 它回答的是"这个补丁从哪来",而来源直接决定评审方式:
93
+ # 回合的要对照上游,自研的要过 KABI,驱动的要看厂商支持范围。
94
+ INCLUSION_BACKPORT = frozenset({"mainline", "stable", "upstream", "lts", "linux", "next"})
95
+ INCLUSION_DRIVER = frozenset({"driver", "drivers", "netdev"})
96
+ INCLUSION_SOC = frozenset(
97
+ {
98
+ "kunpeng",
99
+ "hygon",
100
+ "phytium",
101
+ "zhaoxin",
102
+ "loongarch",
103
+ "loongson",
104
+ "ampere",
105
+ "virt",
106
+ }
107
+ )
108
+ INCLUSION_OUT_OF_TREE = frozenset(
109
+ {
110
+ "hulk",
111
+ "kylin",
112
+ "maillist",
113
+ "urma",
114
+ "cca",
115
+ "openeuler",
116
+ "anolis",
117
+ "deepin",
118
+ "opencloudos",
119
+ "nestos",
120
+ "uniontech",
121
+ }
122
+ )
123
+
124
+
125
+ def _inclusion_kind(name: str) -> Kind | None:
126
+ lowered = name.lower()
127
+ if lowered in INCLUSION_BACKPORT:
128
+ return Kind.BACKPORT
129
+ if lowered in INCLUSION_DRIVER:
130
+ return Kind.DRIVER_UPDATE
131
+ if lowered in INCLUSION_SOC:
132
+ return Kind.SOC_SUPPORT
133
+ if lowered in INCLUSION_OUT_OF_TREE:
134
+ return Kind.OUT_OF_TREE
135
+ # 未收录的 "<X> inclusion" 仍属非上游集成路径,归入自研特性而非猜测
136
+ return Kind.OUT_OF_TREE
137
+
138
+
139
+ def parse_commit_metadata(messages: Sequence[str]) -> tuple[Kind | None, str | None]:
140
+ """从提交信息中提取作者声明的类别与来源。
141
+
142
+ 返回 (类别, 来源标注)。多个提交时取首个非空值 ——
143
+ 一个 PR 里的补丁通常属于同一批次,混装的情况极少。
144
+ """
145
+ declared: Kind | None = None
146
+ inclusion: str | None = None
147
+ for message in messages:
148
+ if not message:
149
+ continue
150
+ if declared is None:
151
+ match = CATEGORY_PATTERN.search(message)
152
+ if match:
153
+ declared = CATEGORY_TO_KIND.get(match.group(1).lower())
154
+ if inclusion is None:
155
+ match = INCLUSION_PATTERN.search(message)
156
+ if match:
157
+ inclusion = match.group(1).lower()
158
+ if declared is not None and inclusion is not None:
159
+ break
160
+ return declared, inclusion
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # 厂商与自研标识
165
+ # ---------------------------------------------------------------------------
166
+
167
+ # 处理器/SoC 厂商名。两类刻意不收:
168
+ #
169
+ # - arm64 / x86 / riscv 这类通用架构前缀:它们在标题里几乎总是子系统前缀
170
+ # ("arm64: Fix ..."),算作"处理器支持"会把成片的普通补丁错判。
171
+ # - hisi / hisilicon:内核里的 hisi 是驱动命名空间(hns、hisi_lpc 等),
172
+ # 实测 "HISI-CCA and HISI-CCA-DA bugfixs" 这类标题被错判成了处理器支持,
173
+ # 而它实际是缺陷修复。
174
+ SOC_VENDORS = _p(
175
+ r"\bphytium\b",
176
+ r"\bzhaoxin\b",
177
+ r"\bloongarch\b",
178
+ r"\bloongson\b",
179
+ r"\bhygon\b",
180
+ r"\bkunpeng\b",
181
+ r"\bascend\b",
182
+ r"\bsunway\b",
183
+ r"\bshenwei\b",
184
+ r"\bampere\b",
185
+ r"飞腾",
186
+ r"兆芯",
187
+ r"龙芯",
188
+ r"海光",
189
+ r"鲲鹏",
190
+ r"申威",
191
+ )
192
+
193
+ # openEuler 的自研特性。这些子系统不在上游主线里,
194
+ # 因此不能按回合补丁的方式评审。
195
+ OUT_OF_TREE_MARKERS = _p(
196
+ r"\burma\b",
197
+ r"\bubcore\b",
198
+ r"\bub:",
199
+ r"\betmem\b",
200
+ r"\benfs\b",
201
+ r"\bmpath\b",
202
+ r"\bmultipath\b",
203
+ r"\bsyscare\b",
204
+ r"\bhulk\b",
205
+ r"\bsgx\b",
206
+ r"\bmilsa\b",
207
+ r"自研",
208
+ )
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class KindRule:
213
+ """一条类型识别规则。"""
214
+
215
+ name: str
216
+ kind: Kind
217
+ patterns: tuple[re.Pattern[str], ...]
218
+ priority: int
219
+ # 匹配范围:标题、正文、或两者
220
+ scope: str = "title"
221
+
222
+ def matches(self, title: str, body: str) -> bool:
223
+ target = title if self.scope == "title" else f"{title}\n{body}"
224
+ return any(pattern.search(target) for pattern in self.patterns)
225
+
226
+
227
+ # 具体形态规则:回答"这是什么"。
228
+ #
229
+ # 它们要优先于来源标注,因为来源只说"从哪来":
230
+ # "Add Phytium i3c controller support" 标着 openEuler inclusion,
231
+ # 但维护者更需要知道的是"这是一条新增驱动"。
232
+ SPECIFIC_TITLE_RULES: tuple[KindRule, ...] = (
233
+ KindRule(
234
+ # "add X driver" / "Add X controller support":新增驱动是独立的一类,
235
+ # 因为维护者要判断的是"这个驱动的支持范围与上游是否有重叠",
236
+ # 与普通新功能的评审关注点不同。
237
+ name="driver_new",
238
+ kind=Kind.DRIVER_NEW,
239
+ patterns=_p(
240
+ r"\badd(?:s|ed)?\b[^:]*\b(?:driver|controller)\b",
241
+ r"\bnew\s+(?:driver|controller)\b",
242
+ r"\bsupport\s+for\s+\w*\s*(?:driver|controller)\b",
243
+ r"新增驱动",
244
+ ),
245
+ priority=86,
246
+ ),
247
+ KindRule(
248
+ # 驱动版本升级:"update X driver to v1.2.3"。
249
+ # 实测这类补丁来自厂商,改动量大且难以逐行对照上游。
250
+ name="driver_update",
251
+ kind=Kind.DRIVER_UPDATE,
252
+ patterns=_p(
253
+ r"\bupdate\b.*\bdrivers?\b",
254
+ r"\bdrivers?\b.*\bto\s+v?\d+\.\d+",
255
+ r"\bupgrade\b.*\bdrivers?\b",
256
+ r"驱动更新",
257
+ r"驱动升级",
258
+ ),
259
+ priority=84,
260
+ ),
261
+ KindRule(
262
+ name="soc_support",
263
+ kind=Kind.SOC_SUPPORT,
264
+ patterns=SOC_VENDORS,
265
+ priority=80,
266
+ ),
267
+ KindRule(
268
+ name="out_of_tree",
269
+ kind=Kind.OUT_OF_TREE,
270
+ patterns=OUT_OF_TREE_MARKERS,
271
+ priority=74,
272
+ ),
273
+ )
274
+
275
+ # 泛化性质规则:回答"改动的性质"。
276
+ #
277
+ # 排在来源标注之后 —— 来源已经说清"从哪来"时先信来源。
278
+ # 例如 "net: fix NULL deref" 标着 mainline inclusion,
279
+ # 按性质是 bugfix,但维护者需要知道的是"这是回合上游的补丁"。
280
+ KIND_RULES: tuple[KindRule, ...] = (
281
+ KindRule(
282
+ # 版本发布。标题形如 "release 6.6.0-173.0.0.155",是源码包仓的主要工作,
283
+ # 在那里占了近九成 —— 没有这一类时它们全部落进 unknown,界面上看起来
284
+ # 像是分类没跑。放在最前面:它是标题层面的强信号,不该被别的规则抢走。
285
+ name="release",
286
+ kind=Kind.RELEASE,
287
+ patterns=_p(r"^release\s+\S"),
288
+ priority=110,
289
+ ),
290
+ KindRule(
291
+ name="cve",
292
+ kind=Kind.CVE,
293
+ patterns=_p(r"CVE-\d{4}-\d{4,}"),
294
+ priority=100,
295
+ ),
296
+ KindRule(
297
+ name="backport",
298
+ kind=Kind.BACKPORT,
299
+ # "from <version> commit <sha>" 与 "from stable/upstream" 是 openEuler
300
+ # 回合补丁的标准标注;仅凭标题提到 upstream 不足以判定,故不用宽泛词
301
+ patterns=_p(
302
+ r"\bbackport(?:ed|ing)?\b",
303
+ r"cherry[\s-]?pick",
304
+ r"\bfrom\s+(?:upstream|mainline|stable)\b",
305
+ r"\bfrom\s+(?:linux-)?v?\d+\.\d+",
306
+ r"回合",
307
+ r"从上游",
308
+ ),
309
+ priority=60,
310
+ ),
311
+ KindRule(
312
+ name="kabi_breaking",
313
+ kind=Kind.BUGFIX,
314
+ patterns=_p(r"\bkabi\b.*(break|不兼容|变更)"),
315
+ priority=58,
316
+ ),
317
+ KindRule(
318
+ # 内核补丁标题描述的是"改了什么",而不是"这属于哪一类"。
319
+ # 仅靠 fix/bug 两个字会漏掉大量实际是缺陷修复的上游风格标题
320
+ # (实测未命中样本里近半数属此类),因此把缺陷的**具体形态**也列为规则:
321
+ # 出现崩溃、泄漏、竞争、越界这类词,几乎必然是在修缺陷。
322
+ name="bugfix",
323
+ kind=Kind.BUGFIX,
324
+ patterns=_p(
325
+ r"\bfix(?:es|ed)?\b",
326
+ # 匹配 bug / bugs / bugfix / bugfixes —— 实测标题里 "bugfixs" 这种
327
+ # 拼写也存在,硬编码后缀清单会漏
328
+ r"\bbug\w*\b",
329
+ r"修复",
330
+ r"修正",
331
+ r"缺陷",
332
+ # 缺陷形态
333
+ r"\bpanic\b",
334
+ r"\bcrash(?:es|ed|ing)?\b",
335
+ r"\bleak(?:s|ed|ing)?\b",
336
+ r"\brace(?:s|d)?\b",
337
+ r"\bdeadlock\b",
338
+ r"\bhang(?:s|ing)?\b",
339
+ r"\bstuck\b",
340
+ r"\boops\b",
341
+ r"\bUAF\b",
342
+ r"\buse[- ]after[- ]free\b",
343
+ r"\bdouble[- ]free\b",
344
+ r"\bnull[- ]pointer\b",
345
+ r"\boob\b",
346
+ r"\boverflow\b",
347
+ r"\bunderflow\b",
348
+ r"\bout[- ]of[- ]bounds\b",
349
+ r"\bcorrupt(?:ion|ed)?\b",
350
+ r"\bregression\b",
351
+ r"\bincorrect(?:ly)?\b",
352
+ # 隐含"现在的行为不对"的措辞
353
+ r"\bmissing\b",
354
+ r"\bproperly\b",
355
+ r"\bprevent\b",
356
+ r"\bavoid\b",
357
+ r"\bdon'?t\b",
358
+ r"\bshould not\b",
359
+ r"\bno longer\b",
360
+ r"避免",
361
+ r"防止",
362
+ r"问题",
363
+ ),
364
+ priority=50,
365
+ ),
366
+ KindRule(
367
+ name="feature",
368
+ kind=Kind.FEATURE,
369
+ patterns=_p(
370
+ r"\bfeat(?:ure)?\b",
371
+ r"\badd support\b",
372
+ r"\bsupport for\b",
373
+ r"\bintroduce\b",
374
+ r"\benable\b",
375
+ r"新增",
376
+ r"支持",
377
+ ),
378
+ priority=40,
379
+ ),
380
+ KindRule(
381
+ name="refactor",
382
+ kind=Kind.REFACTOR,
383
+ patterns=_p(r"\brefactor\b", r"\bclean\s?up\b", r"\brework\b", r"重构"),
384
+ priority=30,
385
+ ),
386
+ KindRule(
387
+ name="docs",
388
+ kind=Kind.DOCS,
389
+ patterns=_p(r"\bdocs?\b", r"\bdocumentation\b", r"\bREADME\b", r"文档"),
390
+ priority=20,
391
+ ),
392
+ )
393
+
394
+ # 由提交信息自声明推导出的类别,其可信度高于标题关键词匹配 ——
395
+ # 它是作者填写的,而标题只是对改动内容的描述。
396
+ DECLARED_CONFIDENCE = 0.95
397
+ TITLE_CONFIDENCE = 0.9
398
+ # 未收录的 "<X> inclusion" 归入自研特性是靠推断,置信度相应降低
399
+ INFERRED_INCLUSION_CONFIDENCE = 0.75
400
+
401
+ # 标题前缀里的子系统标识,如 "mm: ..."、"net/sched: ..."
402
+ TITLE_SUBSYSTEM_PATTERN = re.compile(r"^\s*([a-zA-Z0-9_/+-]+)\s*:")
403
+
404
+
405
+ @dataclass
406
+ class ClassifyResult:
407
+ kind: Kind
408
+ confidence: float
409
+ rule_name: str | None = None
410
+ reason: str | None = None
411
+ cve_ids: list[str] = field(default_factory=list)
412
+ linked_issues: list[int] = field(default_factory=list)
413
+ subsystem: str | None = None
414
+ related_subsystems: list[str] = field(default_factory=list)
415
+ title_subsystem: str | None = None
416
+
417
+
418
+ def extract_cve_ids(text: str) -> list[str]:
419
+ """提取 CVE 编号并规范化大小写。"""
420
+ return sorted({match.upper() for match in CVE_PATTERN.findall(text or "")})
421
+
422
+
423
+ def extract_linked_issues(text: str) -> list[int]:
424
+ """提取正文中声明的关联 Issue 编号。"""
425
+ if not text:
426
+ return []
427
+ return sorted({int(match) for match in LINKED_ISSUE_PATTERN.findall(text)})
428
+
429
+
430
+ def extract_title_subsystem(title: str) -> str | None:
431
+ """从标题前缀提取子系统。
432
+
433
+ openEuler 与上游内核一致采用 "subsystem: description" 的标题约定,
434
+ 前缀是**作者自己声明的**子系统归属,比从路径推断更贴近意图。
435
+ """
436
+ match = TITLE_SUBSYSTEM_PATTERN.match(title or "")
437
+ if not match:
438
+ return None
439
+ candidate = match.group(1).strip()
440
+ # 排除 "Revert"、"v2"、"PATCH" 这类非子系统前缀
441
+ if candidate.lower() in {"revert", "patch", "rfc", "v2", "v3", "fix", "fixes"}:
442
+ return None
443
+ if re.fullmatch(r"v\d+", candidate, re.IGNORECASE):
444
+ return None
445
+ return candidate
446
+
447
+
448
+ def _first_match(rules: tuple[KindRule, ...], title: str, body: str) -> KindRule | None:
449
+ """按 priority 从高到低取首个命中的规则。"""
450
+ for rule in sorted(rules, key=lambda r: -r.priority):
451
+ if rule.matches(title, body):
452
+ return rule
453
+ return None
454
+
455
+
456
+ def _from_rule(rule: KindRule, linked: list[int], title_subsystem: str | None) -> ClassifyResult:
457
+ return ClassifyResult(
458
+ kind=rule.kind,
459
+ confidence=TITLE_CONFIDENCE,
460
+ rule_name=rule.name,
461
+ reason=f"标题或正文命中规则「{rule.name}」",
462
+ linked_issues=linked,
463
+ title_subsystem=title_subsystem,
464
+ )
465
+
466
+
467
+ def classify_by_rules(title: str, body: str, commit_messages: Sequence[str] = ()) -> ClassifyResult:
468
+ """按规则判定类型。
469
+
470
+ 判定顺序刻意是"作者声明 → 标题关键词":
471
+
472
+ 1. CVE 编号出现在标题或正文——安全修复有独立时限,优先级最高
473
+ 2. 提交信息里的 ``category:`` 与 ``<X> inclusion`` —— 作者自己填的,
474
+ 实测覆盖率 97.7%,比从标题揣测可靠
475
+ 3. 标题关键词规则
476
+
477
+ 都不命中时返回 unknown 且置信度为 0,让调用方知道
478
+ "规则没看懂,可考虑交给 AI"。
479
+ """
480
+ cve_ids = extract_cve_ids(f"{title}\n{body}")
481
+ linked = extract_linked_issues(body)
482
+ title_subsystem = extract_title_subsystem(title)
483
+
484
+ # CVE 编号出现在标题或正文即判定为 CVE,无论标题措辞如何
485
+ if cve_ids:
486
+ return ClassifyResult(
487
+ kind=Kind.CVE,
488
+ confidence=1.0,
489
+ rule_name="cve",
490
+ reason=f"检测到 CVE 编号 {', '.join(cve_ids)}",
491
+ cve_ids=cve_ids,
492
+ linked_issues=linked,
493
+ title_subsystem=title_subsystem,
494
+ )
495
+
496
+ text = (title or "", body or "")
497
+
498
+ # 具体形态优先于来源标注:来源只说"从哪来",
499
+ # 而"这是一条新增驱动"是维护者更需要的判断。
500
+ hit = _first_match(SPECIFIC_TITLE_RULES, *text)
501
+ if hit is not None:
502
+ return _from_rule(hit, linked, title_subsystem)
503
+
504
+ # 来源标注优先于泛化性质:同一个 category: bugfix 的补丁,
505
+ # 回合自 stable 还是出自自研分支,评审方式完全不同。
506
+ declared, inclusion = parse_commit_metadata(list(commit_messages or ()))
507
+ if inclusion is not None:
508
+ return ClassifyResult(
509
+ kind=_inclusion_kind(inclusion),
510
+ confidence=(
511
+ DECLARED_CONFIDENCE
512
+ if inclusion
513
+ in INCLUSION_BACKPORT | INCLUSION_DRIVER | INCLUSION_SOC | INCLUSION_OUT_OF_TREE
514
+ else INFERRED_INCLUSION_CONFIDENCE
515
+ ),
516
+ rule_name="commit_inclusion",
517
+ reason=f"提交信息声明 {inclusion} inclusion",
518
+ linked_issues=linked,
519
+ title_subsystem=title_subsystem,
520
+ )
521
+
522
+ hit = _first_match(KIND_RULES, *text)
523
+ if hit is not None:
524
+ return _from_rule(hit, linked, title_subsystem)
525
+
526
+ if declared is not None:
527
+ return ClassifyResult(
528
+ kind=declared,
529
+ confidence=DECLARED_CONFIDENCE,
530
+ rule_name="commit_category",
531
+ reason="提交信息声明 category",
532
+ linked_issues=linked,
533
+ title_subsystem=title_subsystem,
534
+ )
535
+
536
+ return ClassifyResult(
537
+ kind=Kind.UNKNOWN,
538
+ confidence=0.0,
539
+ rule_name=None,
540
+ reason="规则未命中",
541
+ linked_issues=linked,
542
+ title_subsystem=title_subsystem,
543
+ )
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # 子系统归属
548
+ # ---------------------------------------------------------------------------
549
+
550
+ # 路径前缀 → 子系统。按最长前缀优先匹配,避免 "drivers/net" 被 "drivers" 抢走。
551
+ SUBSYSTEM_PATHS: dict[str, str] = {
552
+ "arch/arm64": "arm64",
553
+ "arch/x86": "x86",
554
+ "arch/riscv": "riscv",
555
+ "arch/loongarch": "loongarch",
556
+ "drivers/net": "net-drivers",
557
+ "drivers/gpu": "drm",
558
+ "drivers/scsi": "scsi",
559
+ "drivers/nvme": "nvme",
560
+ "drivers/mmc": "mmc",
561
+ "drivers/usb": "usb",
562
+ "drivers/pci": "pci",
563
+ "drivers/acpi": "acpi",
564
+ "drivers/infiniband": "rdma",
565
+ "net/": "net",
566
+ "fs/": "fs",
567
+ "mm/": "mm",
568
+ "kernel/": "kernel-core",
569
+ "include/linux": "headers",
570
+ "security/": "security",
571
+ "block/": "block",
572
+ "io_uring/": "io_uring",
573
+ "virt/kvm": "kvm",
574
+ "crypto/": "crypto",
575
+ "tools/": "tools",
576
+ "Documentation/": "docs",
577
+ "drivers/": "drivers-other",
578
+ }
579
+
580
+ # 展示名:短名便于界面展示,避免 "net-drivers" 这种半中半英
581
+ SUBSYSTEM_LABELS: dict[str, str] = {
582
+ "net": "网络",
583
+ "net-drivers": "网络驱动",
584
+ "fs": "文件系统",
585
+ "mm": "内存管理",
586
+ "arm64": "ARM64 架构",
587
+ "x86": "x86 架构",
588
+ "riscv": "RISC-V 架构",
589
+ "loongarch": "LoongArch 架构",
590
+ "drm": "图形驱动",
591
+ "scsi": "SCSI",
592
+ "nvme": "NVMe",
593
+ "mmc": "MMC",
594
+ "usb": "USB",
595
+ "pci": "PCI",
596
+ "acpi": "ACPI",
597
+ "rdma": "RDMA",
598
+ "kernel-core": "内核核心",
599
+ "headers": "头文件",
600
+ "security": "安全子系统",
601
+ "block": "块层",
602
+ "io_uring": "io_uring",
603
+ "kvm": "虚拟化 KVM",
604
+ "crypto": "加密",
605
+ "tools": "工具",
606
+ "docs": "文档",
607
+ "drivers-other": "其他驱动",
608
+ }
609
+
610
+
611
+ def subsystem_for_path(path: str) -> str | None:
612
+ """按最长前缀匹配推导单个路径的子系统。"""
613
+ if not path:
614
+ return None
615
+ best: tuple[int, str] | None = None
616
+ for prefix, name in SUBSYSTEM_PATHS.items():
617
+ if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
618
+ best = (len(prefix), name)
619
+ return best[1] if best else None
620
+
621
+
622
+ def derive_subsystem(filenames: list[str], *, max_related: int = 3) -> tuple[str | None, list[str]]:
623
+ """从变更文件推导主子系统与相关子系统。
624
+
625
+ 主子系统 = 变更文件数最多的那个;平局时取路径更深的
626
+ (改动越具体越能代表这次变更的归属)。
627
+
628
+ 注意这不是"重要程度"排序,只是量上的多数 ——
629
+ 一个 PR 改了 100 个 drivers/ 文件和 1 个 mm/ 文件,
630
+ 主子系统是 drivers-other,但 mm 的那处改动可能是核心修复。
631
+ 因此相关子系统一并返回,不做取舍。
632
+ """
633
+ if not filenames:
634
+ return None, []
635
+
636
+ counts: dict[str, int] = {}
637
+ for filename in filenames:
638
+ name = subsystem_for_path(filename)
639
+ if name:
640
+ counts[name] = counts.get(name, 0) + 1
641
+
642
+ if not counts:
643
+ return None, []
644
+
645
+ ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
646
+ primary = ordered[0][0]
647
+ related = [name for name, _ in ordered[1 : max_related + 1]]
648
+ return primary, related
649
+
650
+
651
+ def subsystem_label(name: str | None) -> str:
652
+ if not name:
653
+ return "未归类"
654
+ return SUBSYSTEM_LABELS.get(name, name)
655
+
656
+
657
+ def normalize_subsystem(name: str | None) -> str | None:
658
+ """把标题里声明的子系统归一化到 SUBSYSTEM_PATHS 的取值域。
659
+
660
+ 标题前缀是自由文本:``mm``、``mm/hugetlb``、``drivers/net/ethernet``
661
+ 指的都是已知子系统,直接入库会让统计里出现几十个近义项。
662
+ 先按已知名称精确匹配,再退化为路径前缀匹配;仍不认识则返回 None ——
663
+ 与其塞进一个假的分类,不如让它留在"未归类"里被看见。
664
+ """
665
+ if not name:
666
+ return None
667
+ candidate = name.strip().strip("/").lower()
668
+ if not candidate:
669
+ return None
670
+ # 已是已知子系统名(mm、arm64、drm…)
671
+ if candidate in SUBSYSTEM_LABELS:
672
+ return candidate
673
+ # 路径形态(mm/hugetlb、net/sched、drivers/net/ethernet),
674
+ # 末尾补 "/" 让 "mm" 这类裸名也能走前缀匹配
675
+ return subsystem_for_path(candidate) or subsystem_for_path(f"{candidate}/")
676
+
677
+
678
+ # ---------------------------------------------------------------------------
679
+ # 分类标签名
680
+ # ---------------------------------------------------------------------------
681
+
682
+
683
+ def classification_label(kind: Kind) -> str:
684
+ """平台写回 AtomGit 时使用的标签名。
685
+
686
+ 统一 `kind/` 前缀,与仓库中已有的 `kind/abandoned`、`kind/kabi-need-review`
687
+ 等标签保持一致的命名习惯。
688
+ """
689
+ return f"kind/{kind.value}"