@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,385 @@
1
+ """SIG 例会、成员与模块归属的采集。
2
+
3
+ 两个外部数据源,都是"整份文档同步"而非增量:
4
+
5
+ - **Etherpad 纪要**(``etherpad.openeuler.org/p/Kernel-meetings``):一个持续追加的
6
+ 活文档,历次会议倒序累积。同步方式是整页拉取、逐场解析、按会议日期 upsert。
7
+ 会议纪要会被事后修改(补链接、改主持人),所以 upsert 而不是只插新的。
8
+ - **community 仓库的 SIG 文档**:``sig/Kernel/README.md``(Maintainer)
9
+ 与 ``sig/Kernel/committers.md``(模块归属)。低频变化,每次全量替换。
10
+
11
+ 成员与模块归属采用全量替换而非增量合并:它们是人维护的权威名单,
12
+ 有人被移除时必须消失。增量合并会让"已退出的人"永久留在库里。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import hashlib
19
+ from datetime import UTC, datetime
20
+
21
+ import httpx
22
+ import structlog
23
+ from sqlalchemy import delete, select
24
+ from sqlalchemy.ext.asyncio import AsyncSession
25
+
26
+ from app.domain import meeting as meeting_domain
27
+ from app.domain import sig as sig_domain
28
+ from app.domain import sig_info as sig_info_domain
29
+ from app.domain.release import classify_branch, normalize_branch_name
30
+ from app.models.meeting import (
31
+ Meeting,
32
+ MeetingAgenda,
33
+ MeetingStatus,
34
+ ReleaseReport,
35
+ ReleaseReportPatch,
36
+ )
37
+ from app.models.repository import Repository
38
+ from app.models.sig import MemberSource, ReleaseBranch, SIGMember, SubsystemOwner
39
+
40
+ logger = structlog.get_logger(__name__)
41
+
42
+ ETHER_PAD_URL = "https://etherpad.openeuler.org/p/Kernel-meetings"
43
+ ETHER_PAD_EXPORT = f"{ETHER_PAD_URL}/export/txt"
44
+ SIG_REPO_OWNER = "openeuler"
45
+ SIG_REPO_NAME = "community"
46
+ SIG_README_PATH = "sig/Kernel/README.md"
47
+ SIG_COMMITTERS_PATH = "sig/Kernel/committers.md"
48
+ SIG_SIG_INFO_PATH = "sig/Kernel/sig-info.yaml"
49
+
50
+ # 会议的"已结束"判定:会议日期早于今天即为已开过。
51
+ # 不用纪要里有没有内容判断 —— 会前一天主持人就会把模板填好。
52
+
53
+
54
+ async def fetch_etherpad(client: httpx.AsyncClient | None = None) -> str:
55
+ """拉取纪要全文。
56
+
57
+ 单独抽出来是为了能在测试里喂本地样本 —— 解析逻辑才是需要覆盖的部分,
58
+ 而每次跑测试都去打上游会让测试依赖网络。
59
+ """
60
+ owns_client = client is None
61
+ client = client or httpx.AsyncClient(timeout=30.0, follow_redirects=True)
62
+ try:
63
+ response = await client.get(ETHER_PAD_EXPORT)
64
+ response.raise_for_status()
65
+ return response.text
66
+ finally:
67
+ if owns_client:
68
+ await client.aclose()
69
+
70
+
71
+ def _section_hash(block: dict) -> str:
72
+ """一节纪要的内容指纹。
73
+
74
+ Etherpad 整页同步,但只有少数几节会变。按节算指纹,未变的跳过重写 ——
75
+ 重写会级联删除并重建议题与报告,让它们的 id 每次都变,而前端可能已经
76
+ 在引用这些 id。
77
+ """
78
+ payload = repr(sorted(block.items(), key=lambda item: item[0]))
79
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
80
+
81
+
82
+ async def sync_meetings(session: AsyncSession, text: str) -> dict[str, int]:
83
+ """把一份纪要全文同步进库。返回统计。
84
+
85
+ 整页同步是**幂等**的:重复执行结果相同。按会议日期 upsert,
86
+ 内容有变化时重建该场会议的议题与报告。
87
+ """
88
+ drafts = meeting_domain.parse_pad(text)
89
+ if not drafts:
90
+ logger.warning("meeting_parse_empty", url=ETHER_PAD_URL)
91
+ return {"meetings": 0, "created": 0, "updated": 0, "unchanged": 0}
92
+
93
+ dates = [draft.date for draft in drafts]
94
+ existing = {
95
+ row.held_on: row
96
+ for row in (await session.scalars(select(Meeting).where(Meeting.held_on.in_(dates)))).all()
97
+ }
98
+
99
+ today = datetime.now(UTC).date()
100
+ created = updated = unchanged = 0
101
+
102
+ for draft in drafts:
103
+ # 会议日期早于今天即已开过。不用"纪要里有没有内容"判断:
104
+ # 会前一天主持人就会把模板填好,那会显示成已结束。
105
+ status = MeetingStatus.DONE.value if draft.date < today else MeetingStatus.SCHEDULED.value
106
+ row = existing.get(draft.date)
107
+
108
+ if row is not None and row.source_hash == _section_hash(_draft_fingerprint(draft)):
109
+ unchanged += 1
110
+ continue
111
+
112
+ if row is None:
113
+ row = Meeting(held_on=draft.date, source_url=ETHER_PAD_URL)
114
+ session.add(row)
115
+ created += 1
116
+ else:
117
+ updated += 1
118
+
119
+ row.title = draft.title
120
+ row.start_time = draft.time
121
+ row.host = draft.host
122
+ row.next_host = draft.next_host
123
+ row.host_order = draft.host_order or None
124
+ row.meeting_url = draft.meeting_url
125
+ row.minutes_url = draft.minutes_url
126
+ row.legacy = draft.legacy or None
127
+ row.status = status
128
+ row.source_hash = _section_hash(_draft_fingerprint(draft))
129
+ row.last_synced_at = datetime.now(UTC)
130
+ await session.flush()
131
+
132
+ # 议题与报告整场重建:议题编号会变(有人插了一条),
133
+ # 逐条 diff 得不偿失,而一场会议的数据量很小。
134
+ await session.execute(delete(MeetingAgenda).where(MeetingAgenda.meeting_id == row.id))
135
+ await session.execute(delete(ReleaseReport).where(ReleaseReport.meeting_id == row.id))
136
+
137
+ for index, agenda in enumerate(draft.agendas):
138
+ session.add(
139
+ MeetingAgenda(
140
+ meeting_id=row.id,
141
+ sequence=index,
142
+ num=agenda.num,
143
+ title=agenda.title,
144
+ owner=agenda.owner,
145
+ body=agenda.body or None,
146
+ pull_numbers=sorted(set(agenda.pull_numbers)) or None,
147
+ issue_numbers=sorted(set(agenda.issue_numbers)) or None,
148
+ )
149
+ )
150
+
151
+ for progress in _collect_progress(draft):
152
+ report = ReleaseReport(
153
+ meeting_id=row.id,
154
+ branch=progress.branch,
155
+ from_tag=progress.from_tag,
156
+ to_tag=progress.to_tag,
157
+ patch_count=progress.patch_count,
158
+ listed_count=progress.listed_patch_count,
159
+ iso_urls=progress.iso_urls or None,
160
+ cve_ids=sorted(set(progress.cve_ids)) or None,
161
+ groups=[{"name": group.name, "count": group.count} for group in progress.groups]
162
+ or None,
163
+ )
164
+ session.add(report)
165
+ await session.flush()
166
+
167
+ sequence = 0
168
+ for group in progress.groups:
169
+ for item in group.items:
170
+ session.add(
171
+ ReleaseReportPatch(
172
+ report_id=report.id,
173
+ sequence=sequence,
174
+ group_name=group.name,
175
+ title=item.title[:2000],
176
+ pull_number=item.pull_number,
177
+ url=item.url,
178
+ )
179
+ )
180
+ sequence += 1
181
+
182
+ await session.flush()
183
+ logger.info(
184
+ "meetings_synced",
185
+ parsed=len(drafts),
186
+ created=created,
187
+ updated=updated,
188
+ unchanged=unchanged,
189
+ )
190
+ return {
191
+ "meetings": len(drafts),
192
+ "created": created,
193
+ "updated": updated,
194
+ "unchanged": unchanged,
195
+ }
196
+
197
+
198
+ def _draft_fingerprint(draft: meeting_domain.MeetingDraft) -> dict:
199
+ """转成可哈希的字典。dataclass 里含 date 与嵌套列表,不能直接 repr 排序。"""
200
+ return {
201
+ "date": draft.date.isoformat(),
202
+ "time": draft.time,
203
+ "host": draft.host,
204
+ "next_host": draft.next_host,
205
+ "meeting_url": draft.meeting_url,
206
+ "title": draft.title,
207
+ "legacy": draft.legacy,
208
+ "agendas": [
209
+ (agenda.num, agenda.title, agenda.owner, len(agenda.body)) for agenda in draft.agendas
210
+ ],
211
+ "reports": [
212
+ (p.branch, p.from_tag, p.to_tag, p.patch_count, p.listed_patch_count)
213
+ for p in _collect_progress(draft)
214
+ ],
215
+ }
216
+
217
+
218
+ def _collect_progress(draft: meeting_domain.MeetingDraft) -> list[meeting_domain.BranchProgress]:
219
+ """一场会议里所有分支的进展报告。
220
+
221
+ 同一分支在同一场会议里可能出现两次(不同议题各报告一次),
222
+ 以先出现的为准 —— 后一份通常是补记,字段更少。
223
+ """
224
+ merged: dict[str, meeting_domain.BranchProgress] = {}
225
+ for agenda in draft.agendas:
226
+ for progress in agenda.progress:
227
+ merged.setdefault(progress.branch, progress)
228
+ return list(merged.values())
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # SIG 名单
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ async def sync_roster(
237
+ session: AsyncSession,
238
+ *,
239
+ sig_info_yaml: str | None,
240
+ committers_md: str | None = None,
241
+ source_ref: str = "master",
242
+ ) -> dict[str, int]:
243
+ """同步成员名单、分支负责人与模块归属。
244
+
245
+ 成员名单以 ``sig-info.yaml`` 为准 —— 它是 openEuler 的机器可读 SIG 元数据,
246
+ 字段完整(姓名、邮箱、组织、atomgit 与 gitee 账号)。此前退而用 README 的
247
+ 维护者列表加 committers.md 的模块表,只有 101 人且缺邮箱与组织账号。
248
+
249
+ 全量替换而非增量合并:这是一份权威名单,有人退出时必须消失。
250
+ 也刻意不用某列做冲突键 —— 名单里唯一可用的键是平台账号,而账号是可空的
251
+ (133 人中 16 人只有 gitee),以它建唯一索引会让这些人无法入库。
252
+ """
253
+ if sig_info_yaml is None:
254
+ logger.warning("sig_info_unavailable")
255
+ return {"members": 0, "branches": 0, "subsystems": 0, "skipped": 1}
256
+
257
+ info = sig_info_domain.parse_sig_info(sig_info_yaml)
258
+ if info is None or not info.roster:
259
+ logger.warning("sig_info_parse_failed")
260
+ return {"members": 0, "branches": 0, "subsystems": 0, "skipped": 1}
261
+
262
+ now = datetime.now(UTC)
263
+ source_url = (
264
+ f"https://atomgit.com/{SIG_REPO_OWNER}/{SIG_REPO_NAME}"
265
+ f"/blob/{source_ref}/{SIG_SIG_INFO_PATH}"
266
+ )
267
+
268
+ await session.execute(delete(SIGMember))
269
+ for entry in info.roster:
270
+ session.add(
271
+ SIGMember(
272
+ name=entry.name[:128],
273
+ role=entry.role,
274
+ roles=entry.roles or None,
275
+ atomgit_id=entry.atomgit_id,
276
+ gitee_id=entry.gitee_id,
277
+ email=entry.email,
278
+ org=entry.organization,
279
+ repositories=entry.repositories or None,
280
+ admin_repositories=entry.admin_repositories or None,
281
+ kept_branches=entry.kept_branches or None,
282
+ is_active=True,
283
+ source=MemberSource.SIG_INFO.value,
284
+ source_url=source_url,
285
+ last_synced_at=now,
286
+ )
287
+ )
288
+
289
+ # 分支负责人写进登记表:分支页据此显示"该分支谁负责"。
290
+ # 只在登记表里补 keepers,不覆盖人工填的生命周期与说明。
291
+ #
292
+ # 键是 (仓库, 分支名) 而不是分支名:上游文档里每条 repo_branch 都写着
293
+ # 这个分支属于哪个仓库,而 master、openEuler-24.03-LTS-SP4 这类名字
294
+ # 两个仓库都有 —— 只按名字存,两个仓库就会互相看到对方的分支。
295
+ repositories = {
296
+ f"{row.owner}/{row.name}": row.id
297
+ for row in (await session.scalars(select(Repository))).all()
298
+ }
299
+ keepers_by_branch: dict[tuple[str, str], list[str]] = {}
300
+ for branch in info.branch_keepers:
301
+ key = (branch.repo, normalize_branch_name(branch.branch))
302
+ keepers_by_branch.setdefault(key, [])
303
+ for login in branch.keepers:
304
+ if login not in keepers_by_branch[key]:
305
+ keepers_by_branch[key].append(login)
306
+
307
+ for (repo, name), keepers in keepers_by_branch.items():
308
+ repository_id = repositories.get(repo)
309
+ row = await session.scalar(
310
+ select(ReleaseBranch).where(
311
+ ReleaseBranch.name == name,
312
+ ReleaseBranch.repository_id == repository_id,
313
+ )
314
+ )
315
+ spec = classify_branch(name)
316
+ if row is None:
317
+ row = ReleaseBranch(
318
+ repository_id=repository_id,
319
+ name=name,
320
+ kind=spec.kind.value,
321
+ series=spec.series,
322
+ sp=spec.sp,
323
+ upstream_base=spec.upstream_base,
324
+ )
325
+ session.add(row)
326
+ row.keepers = keepers or None
327
+ await session.flush()
328
+
329
+ subsystems = 0
330
+ if committers_md is not None:
331
+ rows = sig_domain.parse_committers(committers_md)
332
+ await session.execute(delete(SubsystemOwner))
333
+ for index, row in enumerate(rows):
334
+ session.add(
335
+ SubsystemOwner(
336
+ module=row.module[:64],
337
+ section=row.section or None,
338
+ paths=row.paths or None,
339
+ committer_logins=sorted({a.account_id for a in row.committers}) or None,
340
+ sequence=index,
341
+ source_url=(
342
+ f"https://atomgit.com/{SIG_REPO_OWNER}/{SIG_REPO_NAME}"
343
+ f"/blob/{source_ref}/{SIG_COMMITTERS_PATH}"
344
+ ),
345
+ last_synced_at=now,
346
+ )
347
+ )
348
+ subsystems += 1
349
+
350
+ await session.flush()
351
+ logger.info(
352
+ "sig_roster_synced",
353
+ members=len(info.roster),
354
+ branches=len(keepers_by_branch),
355
+ subsystems=subsystems,
356
+ )
357
+ return {
358
+ "members": len(info.roster),
359
+ "branches": len(keepers_by_branch),
360
+ "subsystems": subsystems,
361
+ "skipped": 0,
362
+ }
363
+
364
+
365
+ async def fetch_sig_documents(client) -> tuple[str | None, str | None, str | None]:
366
+ """从 AtomGit 取 SIG 的三份文档:sig-info.yaml、committers.md、README.md。
367
+
368
+ 取不到的那份返回 None —— 其中某一份改路径或临时不可读,不该让整轮同步失败。
369
+ """
370
+ results = await asyncio.gather(
371
+ client.get_file_content(SIG_REPO_OWNER, SIG_REPO_NAME, SIG_SIG_INFO_PATH),
372
+ client.get_file_content(SIG_REPO_OWNER, SIG_REPO_NAME, SIG_COMMITTERS_PATH),
373
+ client.get_file_content(SIG_REPO_OWNER, SIG_REPO_NAME, SIG_README_PATH),
374
+ return_exceptions=True,
375
+ )
376
+ return tuple(item if isinstance(item, str) else None for item in results) # type: ignore[return-value]
377
+
378
+
379
+ __all__ = [
380
+ "ETHER_PAD_URL",
381
+ "fetch_etherpad",
382
+ "fetch_sig_documents",
383
+ "sync_meetings",
384
+ "sync_roster",
385
+ ]