@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,416 @@
1
+ """评审状态解析与合并门禁判定。
2
+
3
+ AtomGit 的 PR 对象**不携带**评审关系,评审状态由标签与机器人评论承载。
4
+ 实测已合并 PR 的标签组合为:
5
+
6
+ ['openeuler-cla/yes', 'sig/Kernel', 'ci_successful', 'lgtm', 'approved']
7
+
8
+ 门禁链条:CLA → CI → 关联 Issue → LGTM → 可合入。
9
+
10
+ ``approved`` **不在**这条链上:实测 200 个 open PR 中 0 个带该标签,
11
+ 而 200 个已合并 PR 中 199 个带 —— 它是合入时补记的标记,不是前置条件。
12
+
13
+ 评审人身份同样不在标签里(PR 上用的是整体 ``lgtm``),
14
+ 而在人类的 ``/lgtm`` 评论与机器人的确认回执中。
15
+
16
+ 本模块把标签与评论解析为结构化的评审事件与门禁状态,
17
+ 是 SLA 统计、评审人工作量、等待时长等一切评审指标的基础。
18
+
19
+ 纯函数实现,不依赖数据库与网络,便于独立测试与在 worker 中复用。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from collections.abc import Iterable, Sequence
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime
28
+ from enum import StrEnum
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # 标签解析
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ class EventType(StrEnum):
36
+ """评审事件类型。**全平台唯一的一份定义**。
37
+
38
+ 数据库枚举、解析器与统计逻辑共用此枚举 ——
39
+ 同一概念若在两处各写一份,取值集合必然漂移,
40
+ 表现为"解析出的值写不进数据库"。
41
+ """
42
+
43
+ LGTM = "lgtm"
44
+ APPROVE = "approve"
45
+ ACK = "ack"
46
+ REJECT = "reject"
47
+ CI_PASS = "ci_pass"
48
+ CI_FAIL = "ci_fail"
49
+ CI_RUNNING = "ci_running"
50
+ CLA_SIGNED = "cla_signed"
51
+ CLA_DENIED = "cla_denied"
52
+ CLOSE = "close"
53
+ REOPEN = "reopen"
54
+ RETEST = "retest"
55
+ LABEL_ADDED = "label_added"
56
+ LABEL_REMOVED = "label_removed"
57
+
58
+
59
+ class GateStage(StrEnum):
60
+ """PR 在评审流程中的阶段。"""
61
+
62
+ DRAFT = "draft"
63
+ CLA_PENDING = "cla_pending"
64
+ CLA_DENIED = "cla_denied"
65
+ CI_PENDING = "ci_pending"
66
+ CI_RUNNING = "ci_running"
67
+ CI_FAILED = "ci_failed"
68
+ NEEDS_ISSUE = "needs_issue"
69
+ REVIEW_PENDING = "review_pending"
70
+ REJECTED = "rejected"
71
+ READY_TO_MERGE = "ready_to_merge"
72
+ MERGED = "merged"
73
+ CLOSED = "closed"
74
+
75
+
76
+ # 标签取值来自对真实仓库的实测统计(300 个 open PR 的标签分布),
77
+ # 而非文档或猜测。注意 ``ci_failed`` 才是实际最常用的 CI 失败标签,
78
+ # 仅处理 ci_must_go_failed / ci_block_force_merge 会把失败的 PR
79
+ # 误判为"CI 未完成",向维护者展示完全错误的阻塞原因。
80
+ #
81
+ # 顺序要求:具体规则必须排在通用规则之前,否则 lgtm-<user> 会被 lgtm 抢先匹配。
82
+ LABEL_RULES: tuple[tuple[re.Pattern[str], EventType], ...] = (
83
+ (re.compile(r"^lgtm-(?P<user>.+)$"), EventType.LGTM),
84
+ (re.compile(r"^lgtm$"), EventType.LGTM),
85
+ (re.compile(r"^approved$"), EventType.APPROVE),
86
+ # Acked 是正向评审信号,但并非合并门禁的必需项
87
+ # (实测已合并 PR 的门禁为 cla + ci_successful + lgtm + approved),
88
+ # 因此单独记录而不计入 approved 判定。
89
+ (re.compile(r"^Acked$"), EventType.ACK),
90
+ (re.compile(r"^NACK$"), EventType.REJECT),
91
+ (re.compile(r"^openeuler-cla/yes$"), EventType.CLA_SIGNED),
92
+ (re.compile(r"^openeuler-cla/no$"), EventType.CLA_DENIED),
93
+ (re.compile(r"^ci_successful$"), EventType.CI_PASS),
94
+ (re.compile(r"^ci_failed$"), EventType.CI_FAIL),
95
+ (re.compile(r"^ci_must_go_failed$"), EventType.CI_FAIL),
96
+ (re.compile(r"^ci_block_force_merge$"), EventType.CI_FAIL),
97
+ (re.compile(r"^ci_processing$"), EventType.CI_RUNNING),
98
+ # 同义标签:连字符与下划线两种写法在仓库中并存
99
+ (re.compile(r"^ci-process$"), EventType.CI_RUNNING),
100
+ )
101
+
102
+ # 已知但与评审门禁无关的标签。
103
+ # ``needs-issue`` 单独处理(它是门禁条件,不是可忽略的分类标签)。
104
+ _IGNORED_LABEL_PREFIXES = ("sig/", "kind/", "ai-")
105
+
106
+ # openEuler 硬规则:PR 必须至少关联一个 Issue(CI 机器人会明确提示)
107
+ NEEDS_ISSUE_LABEL = "needs-issue"
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ParsedEvent:
112
+ """从标签或评论解析出的一条评审事件。"""
113
+
114
+ event_type: EventType
115
+ actor_login: str
116
+ occurred_at: datetime
117
+ source: str # "label" | "comment"
118
+ raw: str
119
+
120
+ @property
121
+ def actor(self) -> str:
122
+ """空 actor 表示"非特定人触发"(如整体 lgtm 标签)。"""
123
+ return self.actor_login
124
+
125
+
126
+ @dataclass
127
+ class MergeGate:
128
+ """合并门禁状态。
129
+
130
+ 门禁链条:CLA → CI → 关联 Issue → LGTM → Approved。
131
+ 全部满足才可合入;任一环节被显式否决(CLA 拒绝 / CI 失败 / NACK)
132
+ 都优先于"未完成"展示 —— 维护者需要看到的是"出问题了",
133
+ 而不是"还在等"。
134
+ """
135
+
136
+ cla_signed: bool = False
137
+ cla_denied: bool = False
138
+ ci_passed: bool = False
139
+ ci_failed: bool = False
140
+ ci_running: bool = False
141
+ approved: bool = False
142
+ rejected: bool = False
143
+ needs_issue: bool = False
144
+ lgtm_actors: set[str] = field(default_factory=set)
145
+ has_plain_lgtm: bool = False
146
+ is_draft: bool = False
147
+ is_merged: bool = False
148
+ is_closed: bool = False
149
+
150
+ @property
151
+ def has_lgtm(self) -> bool:
152
+ """存在整体 lgtm 标签,或至少一位具名评审人给出 lgtm。"""
153
+ return self.has_plain_lgtm or bool(self.lgtm_actors)
154
+
155
+ @property
156
+ def ci_blocked(self) -> bool:
157
+ return self.ci_failed
158
+
159
+ @property
160
+ def ready_to_merge(self) -> bool:
161
+ """维护者当前即可合入。
162
+
163
+ **不要求 approved 标签**。实测数据:200 个 open PR 中 0 个带 approved,
164
+ 而 200 个已合并 PR 中 199 个带 approved —— 说明 approved 是**合入时**
165
+ 由机器人补记的标记,而非合入前需要等待的门禁条件。
166
+ 若把它作为前置条件,"可合入"状态在 open PR 上永不可能出现,
167
+ 维护者最需要的那份待办队列会直接失效。
168
+ """
169
+ return (
170
+ self.cla_signed
171
+ and not self.cla_denied
172
+ and self.ci_passed
173
+ and not self.ci_failed
174
+ and self.has_lgtm
175
+ and not self.rejected
176
+ and not self.needs_issue
177
+ and not self.is_draft
178
+ )
179
+
180
+ @property
181
+ def is_approved(self) -> bool:
182
+ """approved 标签是否已打上(合入前后的标记,非门禁前置条件)。"""
183
+ return self.approved
184
+
185
+ @property
186
+ def blocking_reasons(self) -> list[str]:
187
+ """按门禁链条顺序列出尚未满足的条件,供界面直接展示。"""
188
+ if self.is_merged or self.is_closed:
189
+ return []
190
+
191
+ reasons: list[str] = []
192
+ if self.is_draft:
193
+ reasons.append("草稿状态")
194
+ if self.cla_denied:
195
+ reasons.append("CLA 未通过")
196
+ elif not self.cla_signed:
197
+ reasons.append("CLA 未签署")
198
+
199
+ if self.ci_failed:
200
+ reasons.append("CI 未通过")
201
+ elif self.ci_running:
202
+ reasons.append("CI 进行中")
203
+ elif not self.ci_passed:
204
+ reasons.append("CI 未完成")
205
+
206
+ if self.needs_issue:
207
+ reasons.append("未关联 Issue")
208
+ if self.rejected:
209
+ reasons.append("已被 NACK")
210
+ if not self.has_lgtm:
211
+ reasons.append("缺少 LGTM")
212
+ return reasons
213
+
214
+ @property
215
+ def stage(self) -> GateStage:
216
+ if self.is_merged:
217
+ return GateStage.MERGED
218
+ if self.is_closed:
219
+ return GateStage.CLOSED
220
+ if self.is_draft:
221
+ return GateStage.DRAFT
222
+ if self.cla_denied:
223
+ return GateStage.CLA_DENIED
224
+ if not self.cla_signed:
225
+ return GateStage.CLA_PENDING
226
+ if self.ci_failed:
227
+ return GateStage.CI_FAILED
228
+ if self.ci_running:
229
+ return GateStage.CI_RUNNING
230
+ if not self.ci_passed:
231
+ return GateStage.CI_PENDING
232
+ if self.needs_issue:
233
+ return GateStage.NEEDS_ISSUE
234
+ if self.rejected:
235
+ return GateStage.REJECTED
236
+ if not self.has_lgtm:
237
+ return GateStage.REVIEW_PENDING
238
+ return GateStage.READY_TO_MERGE
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # 机器人评论中的指令
243
+ # ---------------------------------------------------------------------------
244
+
245
+ # openEuler 的 CI 机器人响应这些指令,形如 "/lgtm"、"/approve"
246
+ COMMAND_PATTERN = re.compile(r"^\s*/(?P<cmd>lgtm|approve|close|reopen|retest)\s*$", re.IGNORECASE)
247
+
248
+ # 机器人账号识别。
249
+ #
250
+ # 仅匹配后缀不够:仓库里实际的机器人叫 devstation-robot、ci-robot1、
251
+ # openeuler-infra-bot —— 前两者以 "robot" 而非 "bot" 结尾,只匹配后缀会漏掉
252
+ # 合计 600 多个 PR 的自动化账号,让评审工作量与作者榜单失去意义。
253
+ #
254
+ # 但也不能放宽到"包含 robot 前缀":那会把 robotics-team 这类正常名称误判。
255
+ # 因此按分隔符切词,词段须**恰好**是 bot/robot 加可选数字。
256
+ _BOT_SEGMENT_PATTERN = re.compile(r"^(?:bot|robot)\d*$")
257
+ BOT_SUFFIXES = ("-bot", "_bot", "[bot]")
258
+
259
+
260
+ def is_bot(login: str | None) -> bool:
261
+ if not login:
262
+ return False
263
+ lowered = login.lower()
264
+ if lowered.endswith(BOT_SUFFIXES):
265
+ return True
266
+ return any(
267
+ _BOT_SEGMENT_PATTERN.match(segment) for segment in re.split(r"[-_.\s]+", lowered) if segment
268
+ )
269
+
270
+
271
+ def parse_label(name: str, actor_login: str, occurred_at: datetime) -> ParsedEvent | None:
272
+ """把单个标签解析为评审事件。无法识别时返回 None。"""
273
+ for pattern, kind in LABEL_RULES:
274
+ match = pattern.match(name)
275
+ if not match:
276
+ continue
277
+
278
+ # lgtm-<user> 的 actor 是标签里携带的用户名;
279
+ # 整体 lgtm 标签没有具体人(actor 记为空)
280
+ actor = match.groupdict().get("user") or actor_login
281
+
282
+ return ParsedEvent(
283
+ event_type=kind,
284
+ actor_login=actor or "",
285
+ occurred_at=occurred_at,
286
+ source="label",
287
+ raw=name,
288
+ )
289
+ return None
290
+
291
+
292
+ def parse_comment_command(body: str, actor_login: str, occurred_at: datetime) -> ParsedEvent | None:
293
+ """解析评论中的机器人指令。
294
+
295
+ openEuler 的 CI 机器人接受 ``/lgtm`` ``/approve`` ``/close`` 等指令,
296
+ 这些是评审动作的直接记录,比标签更精确地反映"谁在何时做了什么"。
297
+ """
298
+ for line in body.splitlines():
299
+ match = COMMAND_PATTERN.match(line)
300
+ if not match:
301
+ continue
302
+ cmd = match.group("cmd").lower()
303
+ mapping = {
304
+ "lgtm": EventType.LGTM,
305
+ "approve": EventType.APPROVE,
306
+ "close": EventType.CLOSE,
307
+ "reopen": EventType.REOPEN,
308
+ "retest": EventType.RETEST,
309
+ }
310
+ return ParsedEvent(
311
+ event_type=mapping[cmd],
312
+ actor_login=actor_login,
313
+ occurred_at=occurred_at,
314
+ source="comment",
315
+ raw=f"/{cmd}",
316
+ )
317
+ return None
318
+
319
+
320
+ def parse_events(
321
+ labels: Iterable[tuple[str, datetime]],
322
+ comments: Iterable[tuple[str, str, datetime]],
323
+ ) -> list[ParsedEvent]:
324
+ """把标签与评论解析为按时间排序的事件列表。
325
+
326
+ Args:
327
+ labels: ``(标签名, 时间)`` 序列。标签本身不携带时间,
328
+ 调用方应传入可获得的最近时间(如 PR 更新时间)。
329
+ comments: ``(正文, 作者, 时间)`` 序列。
330
+ """
331
+ events: list[ParsedEvent] = []
332
+
333
+ for name, occurred_at in labels:
334
+ event = parse_label(name, actor_login="", occurred_at=occurred_at)
335
+ if event is not None:
336
+ events.append(event)
337
+
338
+ for body, author, occurred_at in comments:
339
+ if is_bot(author):
340
+ # 机器人会复述用户指令(如 "LGTM" 回执),不能当作人类评审动作,
341
+ # 否则评审工作量会被系统性高估
342
+ continue
343
+ event = parse_comment_command(body, actor_login=author, occurred_at=occurred_at)
344
+ if event is not None:
345
+ events.append(event)
346
+
347
+ events.sort(key=lambda e: e.occurred_at)
348
+ return events
349
+
350
+
351
+ def derive_gate(
352
+ label_names: Sequence[str],
353
+ *,
354
+ is_draft: bool = False,
355
+ state: str = "open",
356
+ ) -> MergeGate:
357
+ """从标签列表推导当前门禁状态。
358
+
359
+ 这是"当前状态"的唯一判定入口;历史事件由 :func:`parse_events` 提供。
360
+ """
361
+ gate = MergeGate(
362
+ is_draft=is_draft,
363
+ is_merged=state == "merged",
364
+ is_closed=state == "closed",
365
+ )
366
+
367
+ for name in label_names:
368
+ if name == NEEDS_ISSUE_LABEL:
369
+ gate.needs_issue = True
370
+ continue
371
+
372
+ for pattern, kind in LABEL_RULES:
373
+ match = pattern.match(name)
374
+ if not match:
375
+ continue
376
+ if kind is EventType.LGTM:
377
+ actor = match.groupdict().get("user")
378
+ if actor:
379
+ gate.lgtm_actors.add(actor)
380
+ else:
381
+ gate.has_plain_lgtm = True
382
+ elif kind is EventType.APPROVE:
383
+ gate.approved = True
384
+ elif kind is EventType.REJECT:
385
+ gate.rejected = True
386
+ elif kind is EventType.CLA_SIGNED:
387
+ gate.cla_signed = True
388
+ elif kind is EventType.CLA_DENIED:
389
+ gate.cla_denied = True
390
+ elif kind is EventType.CI_PASS:
391
+ gate.ci_passed = True
392
+ elif kind is EventType.CI_FAIL:
393
+ gate.ci_failed = True
394
+ elif kind is EventType.CI_RUNNING:
395
+ gate.ci_running = True
396
+ break
397
+
398
+ return gate
399
+
400
+
401
+ def unrecognized_labels(label_names: Iterable[str]) -> list[str]:
402
+ """返回既未匹配评审规则、也不属于已知无关类别的标签。
403
+
404
+ 用于诊断:当社区新增标签时,能第一时间发现解析规则未覆盖,
405
+ 而不是让评审状态静默出错。
406
+ """
407
+ result: list[str] = []
408
+ for name in label_names:
409
+ if name == NEEDS_ISSUE_LABEL:
410
+ continue
411
+ if any(pattern.match(name) for pattern, _ in LABEL_RULES):
412
+ continue
413
+ if name.startswith(_IGNORED_LABEL_PREFIXES):
414
+ continue
415
+ result.append(name)
416
+ return result
@@ -0,0 +1,178 @@
1
+ """SIG 名单与模块归属的解析。
2
+
3
+ 来源是 ``openeuler/community`` 仓库的两份 Markdown:
4
+
5
+ - ``sig/Kernel/README.md`` —— Maintainer 列表
6
+ - ``sig/Kernel/committers.md`` —— 按模块划分的 Committer 表
7
+
8
+ 两份都是人维护的文档,格式会变。这里同样按"尽力而为"处理:解析不出来的行
9
+ 跳过,不让一处格式变化导致整份名单同步失败。
10
+
11
+ 一个容易被忽略的细节:表格里 ``[@handle](url)`` 的 handle 与 URL 末段的 id
12
+ **经常不同**(形如 ``[@display-name](https://atomgit.com/account-id)``)。
13
+ handle 是人写的展示名,URL 里才是平台账号。两者都保留 —— 用 handle 匹配
14
+ PR 作者会漏,用 URL id 展示给人看又会认不出来是谁。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+
22
+ # | kernel | kernel/\* | [@display-name](https://atomgit.com/account-id) |
23
+ _TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
24
+ _SEPARATOR_ROW = re.compile(r"^\|[\s:|-]+\|\s*$")
25
+ _SECTION = re.compile(r"^##\s+(.+?)\s*$")
26
+
27
+ # [@handle](https://atomgit.com/real_id) / [@handle](https://gitee.com/real_id)
28
+ _ACCOUNT = re.compile(r"\[@?([^\]\s]+)\]\(\s*(https?://[^)\s]+)\s*\)")
29
+
30
+ # Markdown 里混进来的排版残留:<img width=40/>、<br/>、转义的 \*
31
+ _HTML_TAG = re.compile(r"<[^>]+>")
32
+
33
+ # README 的 Maintainer 行:- 显示名 [@handle](https://gitee.com/account-id)
34
+ _MAINTAINER = re.compile(r"^[-*]\s*([^\[]+?)\s*\[@?([^\]]+)\]\(\s*(https?://[^)\s]+)\s*\)")
35
+
36
+ _PLATFORM_HOSTS = {
37
+ "atomgit.com": "atomgit",
38
+ "gitee.com": "gitee",
39
+ "github.com": "github",
40
+ }
41
+
42
+
43
+ @dataclass
44
+ class AccountRef:
45
+ """一个平台账号。"""
46
+
47
+ handle: str
48
+ """文档里显示的 handle。用于展示。"""
49
+
50
+ platform: str
51
+ """atomgit / gitee / github / unknown。"""
52
+
53
+ account_id: str
54
+ """URL 末段的账号 id。用于匹配数据。"""
55
+
56
+ url: str
57
+
58
+
59
+ @dataclass
60
+ class SubsystemRow:
61
+ module: str
62
+ section: str
63
+ paths: list[str] = field(default_factory=list)
64
+ committers: list[AccountRef] = field(default_factory=list)
65
+
66
+
67
+ @dataclass
68
+ class MemberEntry:
69
+ name: str
70
+ role: str
71
+ accounts: list[AccountRef] = field(default_factory=list)
72
+ email: str | None = None
73
+
74
+
75
+ def parse_accounts(cell: str) -> list[AccountRef]:
76
+ """从表格单元格里抽出全部账号引用。"""
77
+ accounts: list[AccountRef] = []
78
+ for handle, url in _ACCOUNT.findall(cell):
79
+ host = re.sub(r"^https?://(?:www\.)?", "", url).split("/")[0].lower()
80
+ platform = _PLATFORM_HOSTS.get(host, "unknown")
81
+ tail = [part for part in url.rstrip("/").split("/") if part]
82
+ accounts.append(
83
+ AccountRef(
84
+ handle=handle.strip(),
85
+ platform=platform,
86
+ account_id=tail[-1] if tail else handle,
87
+ url=url,
88
+ )
89
+ )
90
+ return accounts
91
+
92
+
93
+ def _cell_to_paths(cell: str) -> list[str]:
94
+ """把「文件」列拆成路径前缀列表。
95
+
96
+ 上游用 ``<br/>`` 分隔多个路径,用 ``\\*`` 表示前缀通配,还会混进
97
+ ``<img>`` 这类排版残留。这些都是展示层的写法,落到数据上只需要
98
+ 干净的路径前缀。
99
+ """
100
+ # <br/> 与 <img .../> 一并换成换行/空白,剩下的才是路径
101
+ text = _HTML_TAG.sub("\n", cell)
102
+ paths: list[str] = []
103
+ for chunk in re.split(r"[\n,]+", text):
104
+ # 去掉通配符后可能出现 `arch//include/...` 这样的空目录段 —— 通配符
105
+ # 本身代表一层目录,删掉它就留下了一个空段
106
+ path = chunk.strip().replace("\\*", "").replace("*", "").strip().strip("/")
107
+ path = re.sub(r"/{2,}", "/", path)
108
+ if not path or path.startswith("文件"):
109
+ continue
110
+ paths.append(f"{path}/")
111
+ return paths
112
+
113
+
114
+ def parse_committers(text: str) -> list[SubsystemRow]:
115
+ """解析 committers.md 的模块表。"""
116
+ rows: list[SubsystemRow] = []
117
+ section = ""
118
+
119
+ for line in text.splitlines():
120
+ if match := _SECTION.match(line):
121
+ section = match.group(1).strip()
122
+ continue
123
+ if not _TABLE_ROW.match(line) or _SEPARATOR_ROW.match(line):
124
+ continue
125
+
126
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
127
+ if len(cells) < 3:
128
+ continue
129
+ module, files, committers = cells[0], cells[1], "|".join(cells[2:])
130
+
131
+ # 表头行。列名里带 <img> 排版标签,所以用包含匹配而不是相等。
132
+ if "模块" in module or module.lower() == "module":
133
+ continue
134
+
135
+ accounts = parse_accounts(committers)
136
+ if not module or not accounts:
137
+ # 没有 Committer 的行是说明行,不是归属关系
138
+ continue
139
+
140
+ rows.append(
141
+ SubsystemRow(
142
+ module=module,
143
+ section=section,
144
+ paths=_cell_to_paths(files),
145
+ committers=accounts,
146
+ )
147
+ )
148
+ return rows
149
+
150
+
151
+ def parse_maintainers(text: str) -> list[MemberEntry]:
152
+ """解析 README 的 Maintainer 列表。
153
+
154
+ 只认 ``### Maintainer列表`` 之后的那一段,避免把正文里出现的
155
+ 其他 ``[@who](url)`` 当成名单。
156
+ """
157
+ entries: list[MemberEntry] = []
158
+ in_section = False
159
+
160
+ for line in text.splitlines():
161
+ if line.startswith("#"):
162
+ heading = line.lstrip("#").strip()
163
+ in_section = "maintainer" in heading.lower() or "maintainer" in heading
164
+ continue
165
+ if not in_section:
166
+ continue
167
+ match = _MAINTAINER.match(line.strip())
168
+ if match is None:
169
+ continue
170
+ name, handle, url = match.groups()
171
+ entries.append(
172
+ MemberEntry(
173
+ name=name.strip(),
174
+ role="maintainer",
175
+ accounts=parse_accounts(f"[@{handle}]({url})"),
176
+ )
177
+ )
178
+ return entries