@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,408 @@
1
+ """SIG 例会纪要解析。
2
+
3
+ 数据源是 Etherpad 上的活文档 ``etherpad.openeuler.org/p/Kernel-meetings``,
4
+ 历次会议按时间倒序累积在同一页。它是人写的自由文本,所以这里的解析器
5
+ 按"尽力而为"设计:能认出来的字段就取,认不出来就跳过,绝不抛异常。
6
+
7
+ 理由很直接 —— 下一次有人换个格式写纪要时,抛异常会让整场会议的数据
8
+ 一起消失;而少解析一个字段只是少一条信息。上游是外部输入,不能要求它
9
+ 稳定。
10
+
11
+ 纪要里最有价值的部分是「进展update」议题,它其实是**结构化的版本合入报告**:
12
+
13
+ 【OLK-6.6】
14
+ OLK-6.6开发分支tag更新到6.6.0-170.0.0,期间合入补丁590个
15
+ git log 6.6.0-167.0.0..6.6.0-170.0.0 --oneline --no-merges | wc -l
16
+ ISO镜像和update rpm包获取链接:
17
+ https://repo.openeuler.org/openEuler-24.03-LTS-SP1
18
+ CVE: (124)
19
+ CVE-2026-74739,CVE-2026-74544,...
20
+ Bugfix:
21
+ nfs: use nfsi->rwsem to protect traversal of the file lock list
22
+ https://atomgit.com/openeuler/kernel/pull/26235
23
+ Feature:
24
+ ...
25
+
26
+ 这一段既是双周合入总结,也是各版本的 release notes:tag 区间、补丁数、
27
+ CVE 清单、按类别归好的补丁列表。解析出来就能直接把 PR 号链回本地 PR 表。
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from dataclasses import dataclass, field
34
+ from datetime import date
35
+ from itertools import pairwise
36
+
37
+ from app.domain.release import normalize_branch_name
38
+
39
+ # 【2026/9/4 14:00 Kernel SIG 双周例会】;早期会议没写时间
40
+ _MEETING_HEADER = re.compile(
41
+ r"^【\s*(\d{4})/(\d{1,2})/(\d{1,2})(?:\s+(\d{1,2}:\d{2}))?\s*([^】]+?)\s*】\s*$"
42
+ )
43
+
44
+ # 【OLK-6.6】—— 必须是整行只有标签,否则是「【OLK-6.6】xxx」这种带前缀的议题标题
45
+ _PROGRESS_HEADER = re.compile(r"^【\s*([^】]+?)\s*】\s*$")
46
+
47
+ # 一、上期遗留问题跟踪 / 二、议题列表
48
+ _SECTION = re.compile(r"^([一二三四五六七八九十]+)、\s*(.+)$")
49
+
50
+ # 议题一:进展update(吴腾达);编号有重复也有占位的 X
51
+ _AGENDA = re.compile(r"^议题\s*([一二三四五六七八九十\dXx]+)\s*[::]\s*(.*)$")
52
+
53
+ # 轮值主持:关文涛【轮值主持顺序:曾昭荣->桑力鹏->...】
54
+ _HOST = re.compile(r"^轮值主持\s*[::]\s*([^【((]+)")
55
+ _HOST_ORDER = re.compile(r"【[^】]*顺序\s*[::]\s*([^】]+)】")
56
+ _NEXT_HOST = re.compile(r"^下次轮值主持\s*[::]\s*(.+?)\s*$")
57
+ _MEETING_URL = re.compile(r"^会议链接\s*[::]\s*(\S+)\s*$")
58
+ _MINUTES_URL = re.compile(r"^会议纪要\s*[::]\s*(\S+)\s*$")
59
+
60
+ # 分支 tag 更新:OLK-6.6开发分支tag更新到6.6.0-170.0.0,期间合入补丁590个
61
+ _TAG_UPDATE = re.compile(r"更新到\s*([\w.\-]+)\s*[,,]\s*期间合入补丁\s*(\d+)\s*个")
62
+ # git log 6.6.0-167.0.0..6.6.0-170.0.0 --oneline --no-merges | wc -l
63
+ _GIT_RANGE = re.compile(r"git log\s+([\w.\-]+)\.\.([\w.\-]+)")
64
+ _ISO_SECTION = re.compile(r"^ISO\s*镜像|^ISO镜像|获取链接\s*[::]")
65
+ _CVE_SECTION = re.compile(r"^CVE\s*[::]\s*[((]?\s*(\d*)\s*[))]?\s*$")
66
+ _CVE_ID = re.compile(r"CVE-\d{4}-\d{4,7}")
67
+
68
+ # 分组标题:短的、以冒号结尾、可带 (N) 计数。Bugfix: / LTS:/ 灵衢: (13)
69
+ _PROGRESS_GROUP = re.compile(r"^([^::\s][^::]{0,23}?)\s*[::]\s*(?:[((]\s*(\d+)\s*[))])?\s*$")
70
+
71
+ # 缩进的 URL 行(补丁链接、镜像链接)
72
+ _INDENTED_URL = re.compile(r"^\s+(\S+://\S+)\s*$")
73
+
74
+ # PR / Issue 链接。atomgit 与 gitcode 都在用,都要认。
75
+ _LINK = re.compile(
76
+ r"https?://(?:www\.)?(?:atomgit\.com|gitcode\.com)/[\w.\-]+/[\w.\-]+/"
77
+ r"(pull|merge_requests|issues?)/(\d+)",
78
+ re.IGNORECASE,
79
+ )
80
+
81
+ # 被截断的链接,末尾没有编号(实测存在,如 ".../pull/")
82
+ _TRUNCATED_LINK = re.compile(
83
+ r"https?://(?:www\.)?(?:atomgit\.com|gitcode\.com)/[\w.\-]+/[\w.\-]+/(?:pull|issues?)/\s*$",
84
+ re.IGNORECASE,
85
+ )
86
+
87
+ # 分组标题里出现这些词说明它不是补丁分组,是别的段落标题
88
+ _NOT_A_GROUP = ("ISO", "获取链接", "git log", "镜像")
89
+
90
+ # 纪要里留着给人填写的模板行,不是真议题。
91
+ # 当成议题收录会让每场会议都凭空多出一个汇报人是"汇报人"的条目。
92
+ _TEMPLATE_MARKERS = ("其他议题在这里补充", "议题在这里补充", "格式:议题名称")
93
+
94
+
95
+ @dataclass
96
+ class ProgressItem:
97
+ """进展报告里的一条补丁。"""
98
+
99
+ title: str
100
+ pull_number: int | None = None
101
+ url: str | None = None
102
+
103
+
104
+ @dataclass
105
+ class ProgressGroup:
106
+ """补丁分组。``count`` 是作者自己写的条数,可能与实际条数不一致。"""
107
+
108
+ name: str
109
+ count: int | None = None
110
+ items: list[ProgressItem] = field(default_factory=list)
111
+
112
+
113
+ @dataclass
114
+ class BranchProgress:
115
+ """一个分支在一期内的合入情况。"""
116
+
117
+ branch: str
118
+ from_tag: str | None = None
119
+ to_tag: str | None = None
120
+ patch_count: int | None = None
121
+ """作者自报的合入补丁数。"""
122
+
123
+ iso_urls: list[str] = field(default_factory=list)
124
+ cve_ids: list[str] = field(default_factory=list)
125
+ groups: list[ProgressGroup] = field(default_factory=list)
126
+
127
+ @property
128
+ def listed_patch_count(self) -> int:
129
+ """分组里实际列出的补丁条数。与 ``patch_count`` 不必然相等。"""
130
+ return sum(len(group.items) for group in self.groups)
131
+
132
+
133
+ @dataclass
134
+ class AgendaDraft:
135
+ num: str
136
+ title: str
137
+ owner: str | None
138
+ body: str
139
+ pull_numbers: list[int] = field(default_factory=list)
140
+ issue_numbers: list[int] = field(default_factory=list)
141
+ progress: list[BranchProgress] = field(default_factory=list)
142
+
143
+
144
+ @dataclass
145
+ class MeetingDraft:
146
+ date: date
147
+ time: str | None
148
+ title: str
149
+ host: str | None = None
150
+ host_order: list[str] = field(default_factory=list)
151
+ next_host: str | None = None
152
+ meeting_url: str | None = None
153
+ minutes_url: str | None = None
154
+ legacy: str = ""
155
+ agendas: list[AgendaDraft] = field(default_factory=list)
156
+
157
+ @property
158
+ def pull_numbers(self) -> list[int]:
159
+ seen: dict[int, None] = {}
160
+ for agenda in self.agendas:
161
+ for number in agenda.pull_numbers:
162
+ seen.setdefault(number, None)
163
+ return list(seen)
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # 会议纪要
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ def parse_pad(text: str) -> list[MeetingDraft]:
172
+ """把整页 Etherpad 文本切成按时间倒序的会议列表。
173
+
174
+ 切分依据是会议标题行,而不是分隔线 —— 分隔线是人工画的,
175
+ 有的会议没画(实测 17 场里就有),靠它切会漏。
176
+ """
177
+ lines = text.splitlines()
178
+ starts = [index for index, line in enumerate(lines) if _MEETING_HEADER.match(line)]
179
+ if not starts:
180
+ return []
181
+
182
+ # 末尾补一行长度作为最后一场的右界
183
+ bounds = [*starts, len(lines)]
184
+ meetings: list[MeetingDraft] = []
185
+ for begin, end in pairwise(bounds):
186
+ draft = _parse_meeting(lines[begin:end])
187
+ if draft is not None:
188
+ meetings.append(draft)
189
+ return meetings
190
+
191
+
192
+ def _parse_meeting(block: list[str]) -> MeetingDraft | None:
193
+ header = _MEETING_HEADER.match(block[0])
194
+ if header is None:
195
+ return None
196
+
197
+ year, month, day, time_text, title = header.groups()
198
+ try:
199
+ meeting_date = date(int(year), int(month), int(day))
200
+ except ValueError:
201
+ # 手写日期会有 2/30 这种不存在的值。丢掉一场,不要丢掉整页。
202
+ return None
203
+
204
+ draft = MeetingDraft(date=meeting_date, time=time_text, title=title.strip())
205
+
206
+ section = ""
207
+ agenda: AgendaDraft | None = None
208
+ legacy_lines: list[str] = []
209
+ body: list[str] = []
210
+
211
+ def commit(current: AgendaDraft | None, lines: list[str]) -> None:
212
+ if current is None:
213
+ return
214
+ _finish_agenda(current, lines)
215
+ if not _is_template(current):
216
+ draft.agendas.append(current)
217
+
218
+ for line in block[1:]:
219
+ if draft.host is None and (match := _HOST.match(line)):
220
+ draft.host = match.group(1).strip()
221
+ if order := _HOST_ORDER.search(line):
222
+ draft.host_order = [
223
+ part.strip() for part in re.split(r"->|→", order.group(1)) if part.strip()
224
+ ]
225
+ continue
226
+ if draft.next_host is None and (match := _NEXT_HOST.match(line)):
227
+ draft.next_host = match.group(1).strip()
228
+ continue
229
+ if draft.meeting_url is None and (match := _MEETING_URL.match(line)):
230
+ draft.meeting_url = match.group(1)
231
+ continue
232
+ if draft.minutes_url is None and (match := _MINUTES_URL.match(line)):
233
+ draft.minutes_url = match.group(1)
234
+ continue
235
+
236
+ if match := _SECTION.match(line):
237
+ commit(agenda, body)
238
+ agenda, body = None, []
239
+ section = match.group(2).strip()
240
+ continue
241
+
242
+ if match := _AGENDA.match(line):
243
+ commit(agenda, body)
244
+ agenda = _split_agenda(match.group(1), match.group(2))
245
+ body = []
246
+ continue
247
+
248
+ if agenda is not None:
249
+ body.append(line)
250
+ elif "遗留" in section:
251
+ legacy_lines.append(line)
252
+
253
+ commit(agenda, body)
254
+ draft.legacy = "\n".join(legacy_lines).strip()
255
+ return draft
256
+
257
+
258
+ def _split_agenda(num: str, rest: str) -> AgendaDraft:
259
+ """拆出「标题(汇报人)」。中英文括号与全角空格都出现过。"""
260
+ rest = rest.strip()
261
+ owner = None
262
+ match = re.search(r"[((]\s*([^(())]+?)\s*[))]\s*$", rest)
263
+ if match:
264
+ owner = match.group(1).strip()
265
+ rest = rest[: match.start()].strip()
266
+ return AgendaDraft(num=num.strip(), title=rest, owner=owner, body="")
267
+
268
+
269
+ def _is_template(agenda: AgendaDraft) -> bool:
270
+ """模板占位议题。"""
271
+ return any(marker in agenda.title for marker in _TEMPLATE_MARKERS)
272
+
273
+
274
+ def _finish_agenda(agenda: AgendaDraft, body: list[str]) -> None:
275
+ agenda.body = "\n".join(body).strip()
276
+ for match in _LINK.finditer(agenda.body):
277
+ kind, number = match.group(1).lower(), int(match.group(2))
278
+ if kind.startswith("issue"):
279
+ agenda.issue_numbers.append(number)
280
+ else:
281
+ agenda.pull_numbers.append(number)
282
+
283
+ # 只有「进展update」这类议题才带版本报告
284
+ if "进展" in agenda.title or "update" in agenda.title.lower():
285
+ agenda.progress = parse_progress_report(agenda.body)
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # 版本进展报告
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def parse_progress_report(text: str) -> list[BranchProgress]:
294
+ """从议题正文里抽出各分支的合入报告。
295
+
296
+ 用一个显式状态机而不是正则一把梭:正文里 ``xxx:`` 形态的行太多,
297
+ 补丁标题本身常含冒号(``nfs: use ...``),靠单条正则区分分组标题和
298
+ 补丁标题必然互相误伤。
299
+ """
300
+ blocks: list[BranchProgress] = []
301
+ current: BranchProgress | None = None
302
+ group: ProgressGroup | None = None
303
+ mode = "" # "" | "iso" | "cve"
304
+
305
+ pending_title: str | None = None
306
+
307
+ def flush_item(url: str | None = None) -> None:
308
+ nonlocal pending_title
309
+ if pending_title is None:
310
+ return
311
+ if current is not None and group is not None:
312
+ group.items.append(
313
+ ProgressItem(
314
+ title=pending_title,
315
+ pull_number=_pull_number(url) if url else None,
316
+ url=url,
317
+ )
318
+ )
319
+ pending_title = None
320
+
321
+ for raw in text.splitlines():
322
+ line = raw.rstrip()
323
+ stripped = line.strip()
324
+
325
+ if match := _PROGRESS_HEADER.match(line):
326
+ flush_item()
327
+ current = BranchProgress(branch=normalize_branch_name(match.group(1)))
328
+ blocks.append(current)
329
+ group = None
330
+ mode = ""
331
+ continue
332
+
333
+ if current is None:
334
+ continue
335
+
336
+ if not stripped:
337
+ continue
338
+
339
+ # 补丁标题的下一行是缩进 URL
340
+ if (match := _INDENTED_URL.match(line)) and pending_title is not None:
341
+ flush_item(match.group(1))
342
+ continue
343
+ if _TRUNCATED_LINK.search(stripped):
344
+ # 编号被截断的链接:保留标题,但不猜编号
345
+ flush_item(None)
346
+ continue
347
+
348
+ flush_item()
349
+
350
+ if match := _TAG_UPDATE.search(stripped):
351
+ current.to_tag = match.group(1)
352
+ current.patch_count = int(match.group(2))
353
+ mode = ""
354
+ continue
355
+ if match := _GIT_RANGE.search(stripped):
356
+ current.from_tag = match.group(1)
357
+ current.to_tag = match.group(2)
358
+ mode = ""
359
+ continue
360
+ if _ISO_SECTION.match(stripped):
361
+ mode = "iso"
362
+ group = None
363
+ continue
364
+ if match := _CVE_SECTION.match(stripped):
365
+ mode = "cve"
366
+ group = None
367
+ continue
368
+ if stripped in ("CVE", "CVE:"):
369
+ mode = "cve"
370
+ group = None
371
+ continue
372
+
373
+ if mode == "iso":
374
+ # 镜像链接有时不带缩进
375
+ if "://" in stripped:
376
+ current.iso_urls.append(stripped)
377
+ continue
378
+ mode = ""
379
+
380
+ if mode == "cve":
381
+ found = _CVE_ID.findall(stripped)
382
+ if found:
383
+ current.cve_ids.extend(found)
384
+ # CVE 清单可能跨多行
385
+ continue
386
+ mode = ""
387
+
388
+ if match := _PROGRESS_GROUP.match(stripped):
389
+ name, count = match.group(1).strip(), match.group(2)
390
+ if not any(token in name for token in _NOT_A_GROUP) and "://" not in name:
391
+ group = ProgressGroup(name=name, count=int(count) if count else None)
392
+ current.groups.append(group)
393
+ continue
394
+
395
+ pending_title = stripped
396
+
397
+ flush_item()
398
+ return blocks
399
+
400
+
401
+ def _pull_number(url: str | None) -> int | None:
402
+ if not url:
403
+ return None
404
+ match = _LINK.search(url)
405
+ if match is None:
406
+ return None
407
+ kind, number = match.group(1).lower(), int(match.group(2))
408
+ return number if not kind.startswith("issue") else None
@@ -0,0 +1,163 @@
1
+ """版本与分支的归类。
2
+
3
+ openEuler 内核分两条线(见 sig/Kernel/README.md):创新版本追求新特性验证,
4
+ 稳定版本(LTS / OLK)质量优先、维护期长。两条线的评审标准不同,
5
+ 混在一个列表里维护者没法按线分流,所以这里先把分支名解释成结构化事实。
6
+
7
+ 命名从名称能推出来的部分由代码推,推不出来的(生命周期、上游基线)留给
8
+ ``ksp_release_branch`` 登记表由人填 —— 猜测版本基线只会造出一个看起来
9
+ 权威、实际是编造的字段。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+ from enum import StrEnum
17
+
18
+
19
+ class BranchKind(StrEnum):
20
+ """分支所属的产品线。"""
21
+
22
+ OLK = "olk"
23
+ LTS = "lts"
24
+ INNOVATION = "innovation"
25
+ OTHER = "other"
26
+
27
+
28
+ # OLK-6.6 / OLK-5.10
29
+ _OLK = re.compile(r"^OLK-(\d+)\.(\d+)$", re.IGNORECASE)
30
+ # openEuler-24.03-LTS、openEuler-22.03-LTS-SP4、openEuler-1.0-LTS
31
+ # 版本号不限定两位:1.0-LTS 是真实存在的分支,而且 PR 量最大的一条
32
+ _LTS = re.compile(r"^openEuler-(\d+\.\d+)-LTS(?:-SP(\d+))?$", re.IGNORECASE)
33
+ # openEuler-26.09 这类 yy.mm 且非 LTS 的即创新版本
34
+ _INNOVATION = re.compile(r"^openEuler-(\d{2})\.(\d{2})$", re.IGNORECASE)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BranchSpec:
39
+ """分支名的结构化解释。"""
40
+
41
+ name: str
42
+ """规范化后的名字。用于分组与展示。"""
43
+
44
+ kind: BranchKind
45
+ raw: str = ""
46
+ """上游原始名字。用于回查数据 —— 库里的 ``target_branch`` 存的就是它。"""
47
+
48
+ series: str | None = None
49
+ """发行版系列,如 ``24.03-LTS``。OLK 分支没有系列。"""
50
+
51
+ sp: int | None = None
52
+ """Service Pack 序号。"""
53
+
54
+ upstream_base: str | None = None
55
+ """上游内核基线,仅 OLK 分支可从名字推得。"""
56
+
57
+
58
+ def normalize_branch_name(name: str) -> str:
59
+ """归一化分支名,只处理能确定等价的差异。
60
+
61
+ 实测数据里同一个交付有四种写法混用:
62
+
63
+ openEuler-24.03-LTS-SP1 openEuler-24.03-LTS_SP1
64
+ openEuler-24.03-LTS openEuler-24.03_LTS
65
+
66
+ 不归一化会让同一个版本在统计里裂成两行,每个版本页的合入数都偏小。
67
+
68
+ 只统一 ``LTS`` 与 ``SP\\d+`` 前后的分隔符与大小写。其余部分原样保留:
69
+ 分支名大小写敏感,改错了会指向不存在的引用,因此不做泛化的小写化。
70
+ """
71
+ if not name:
72
+ return name
73
+ normalized = name.strip()
74
+ normalized = re.sub(r"[-_](lts)(?=[-_]|$)", "-LTS", normalized, flags=re.IGNORECASE)
75
+ normalized = re.sub(r"[-_](sp)(\d+)", r"-SP\2", normalized, flags=re.IGNORECASE)
76
+ return normalized
77
+
78
+
79
+ def classify_branch(name: str) -> BranchSpec:
80
+ """把分支名解释成产品线、系列与 SP 序号。
81
+
82
+ 认不出来的分支不报错:openEuler 会开临时分支,且分支名是外部输入。
83
+ 归到 ``OTHER`` 后仍会出现在分支页,只是不带产品线标签。
84
+ """
85
+ raw = name.strip() if name else name
86
+ normalized = normalize_branch_name(name)
87
+
88
+ if match := _OLK.match(normalized):
89
+ version = f"{match.group(1)}.{match.group(2)}"
90
+ return BranchSpec(
91
+ name=normalized,
92
+ kind=BranchKind.OLK,
93
+ raw=raw,
94
+ upstream_base=version,
95
+ )
96
+
97
+ if match := _LTS.match(normalized):
98
+ version, sp = match.groups()
99
+ return BranchSpec(
100
+ name=normalized,
101
+ kind=BranchKind.LTS,
102
+ raw=raw,
103
+ series=f"{version}-LTS",
104
+ sp=int(sp) if sp else None,
105
+ )
106
+
107
+ if match := _INNOVATION.match(normalized):
108
+ return BranchSpec(
109
+ name=normalized,
110
+ kind=BranchKind.INNOVATION,
111
+ raw=raw,
112
+ series=match.group(0).removeprefix("openEuler-"),
113
+ )
114
+
115
+ return BranchSpec(name=normalized, kind=BranchKind.OTHER, raw=raw)
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # 内核 tag
120
+ # ---------------------------------------------------------------------------
121
+
122
+ # 6.6.0-170.0.0 / 5.10.0-331.0.0 / 6.6.0-165
123
+ _KERNEL_TAG = re.compile(r"^(\d+\.\d+)\.(\d+)-(\d+)(?:\.\d+)*$")
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class KernelTag:
128
+ base: str
129
+ """上游基线,如 ``6.6``。"""
130
+
131
+ build: int
132
+ """构建号,如 170。同一基线下单调递增。"""
133
+
134
+ raw: str
135
+
136
+
137
+ def parse_kernel_tag(tag: str) -> KernelTag | None:
138
+ """解析 ``6.6.0-170.0.0`` 形态的 tag。
139
+
140
+ 构建号是判断"两个 tag 之间合入了多少"的锚点:会议纪要里
141
+ ``6.6.0-167.0.0..6.6.0-170.0.0`` 就是靠它界定的区间。
142
+ """
143
+ match = _KERNEL_TAG.match(tag.strip())
144
+ if not match:
145
+ return None
146
+ return KernelTag(base=match.group(1), build=int(match.group(3)), raw=tag.strip())
147
+
148
+
149
+ def compare_tags(left: str, right: str) -> int | None:
150
+ """按 (基线, 构建号) 比较两个 tag。无法解析时返回 None。
151
+
152
+ 不能按字符串比:``6.6.0-99.0.0`` 字典序小于 ``6.6.0-170.0.0``,
153
+ 但 9 与 170 的大小关系取决于先比构建号还是先比文本 —— 只有解析成
154
+ 整数才不会在构建号跨过位数时翻车。
155
+ """
156
+ left_tag, right_tag = parse_kernel_tag(left), parse_kernel_tag(right)
157
+ if left_tag is None or right_tag is None:
158
+ return None
159
+ if left_tag.base != right_tag.base:
160
+ return None
161
+ if left_tag.build == right_tag.build:
162
+ return 0
163
+ return 1 if left_tag.build > right_tag.build else -1