@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,565 @@
1
+ """版本/分支、例会、成员的 API。
2
+
3
+ 三组资源的共同点是:数据来自平台之外(上游文档与纪要),平台只做结构化与
4
+ 统计,因此都是只读端点 —— 写入口是定时同步,不是人工编辑。
5
+ """
6
+
7
+ import uuid
8
+ from typing import Annotated, Literal
9
+
10
+ import structlog
11
+ from fastapi import APIRouter, Depends, Query
12
+ from sqlalchemy import func, select
13
+ from sqlalchemy.orm import selectinload
14
+
15
+ from app.api.deps import CurrentUserDep, SessionDep, require_permission
16
+ from app.core.exceptions import NotFoundError
17
+ from app.core.permissions import Permission
18
+ from app.domain.attention import RULE_INFO_BY_KEY
19
+ from app.domain.review import is_bot
20
+ from app.models.classification import Classification, SubjectType
21
+ from app.models.meeting import Meeting, MeetingAgenda, ReleaseReport, ReleaseReportPatch
22
+ from app.models.pull_request import PullRequest, ReviewEvent
23
+ from app.models.sig import SIGMember, SubsystemOwner
24
+ from app.schemas.common import MessageResponse
25
+ from app.schemas.sig import (
26
+ AttentionGroupRead,
27
+ AttentionSampleRead,
28
+ AttentionSettingRead,
29
+ AttentionSettingUpdate,
30
+ AttentionSummaryRead,
31
+ AttentionThemeRead,
32
+ BranchSummary,
33
+ MeetingAgendaRead,
34
+ MeetingDetail,
35
+ MeetingSummary,
36
+ MemberRead,
37
+ MemberStats,
38
+ MergeBucketRead,
39
+ PeriodSummaryRead,
40
+ PullRef,
41
+ ReleaseReportRead,
42
+ SubsystemOwnerRead,
43
+ )
44
+ from app.services import attention_queue as attention_queue_service
45
+ from app.services import release_service, repository_service
46
+
47
+ logger = structlog.get_logger(__name__)
48
+
49
+ router = APIRouter(tags=["sig"])
50
+
51
+ Manage = Annotated[None, Depends(require_permission(Permission.MANAGE_REPOSITORY))]
52
+
53
+
54
+ async def _repository_id(session: SessionDep, repository_id: uuid.UUID) -> uuid.UUID:
55
+ await repository_service.require_repository(session, repository_id)
56
+ return repository_id
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # 版本与分支
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @router.get("/repositories/{repository_id}/branches", response_model=list[BranchSummary])
65
+ async def list_branches(
66
+ repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
67
+ ) -> list[BranchSummary]:
68
+ """全部版本/分支及其合入概览。
69
+
70
+ 分支名做了归一化:``openEuler-24.03-LTS_SP1`` 与 ``-SP1`` 是同一个交付,
71
+ 不合并会让同一个版本的合入数被拆到两行。
72
+ """
73
+ await _repository_id(session, repository_id)
74
+ rows = await release_service.list_branches(session, repository_id)
75
+ return [BranchSummary(**vars(row)) for row in rows]
76
+
77
+
78
+ @router.get(
79
+ "/repositories/{repository_id}/branches/{branch}/timeline",
80
+ response_model=list[MergeBucketRead],
81
+ )
82
+ async def branch_timeline(
83
+ repository_id: uuid.UUID,
84
+ branch: str,
85
+ session: SessionDep,
86
+ _: CurrentUserDep,
87
+ granularity: Annotated[Literal["month", "biweek"], Query()] = "month",
88
+ buckets: Annotated[int, Query(ge=2, le=36)] = 12,
89
+ ) -> list[MergeBucketRead]:
90
+ """该分支的合入趋势。双周粒度与例会节奏对齐。"""
91
+ await _repository_id(session, repository_id)
92
+ raw = await _raw_names_for(session, repository_id, branch)
93
+ rows = await release_service.merge_timeline(
94
+ session, repository_id, raw_branches=raw, buckets=buckets, granularity=granularity
95
+ )
96
+ return [MergeBucketRead(**vars(row)) for row in rows]
97
+
98
+
99
+ @router.get(
100
+ "/repositories/{repository_id}/branches/{branch}/periods",
101
+ response_model=list[PeriodSummaryRead],
102
+ )
103
+ async def branch_periods(
104
+ repository_id: uuid.UUID,
105
+ branch: str,
106
+ session: SessionDep,
107
+ _: CurrentUserDep,
108
+ granularity: Annotated[Literal["month", "biweek"], Query()] = "month",
109
+ count: Annotated[int, Query(ge=1, le=26)] = 6,
110
+ ) -> list[PeriodSummaryRead]:
111
+ """该版本最近若干期的合入总结,新的在前。
112
+
113
+ 只取**已结束**的周期:当前周期还没过完,拿它做总结会得到一个
114
+ 每刷新一次就变大、且永远与上一期不可比的数字。
115
+ 一次返回多期而不是只给上一期 —— "这个版本最近几期稳步在合什么"
116
+ 才是维护者要看的形状,单期看不出趋势。
117
+ """
118
+ await _repository_id(session, repository_id)
119
+ raw = await _raw_names_for(session, repository_id, branch)
120
+ # 末位是当前周期,去掉;剩下的按新到旧返回
121
+ windows = release_service.recent_windows(count + 1, granularity)[:-1]
122
+ summaries = await release_service.period_summaries(
123
+ session, repository_id, windows=windows, raw_branches=raw
124
+ )
125
+ return [PeriodSummaryRead(**vars(summary)) for summary in reversed(summaries)]
126
+
127
+
128
+ @router.get(
129
+ "/repositories/{repository_id}/releases/notes",
130
+ response_model=list[ReleaseReportRead],
131
+ )
132
+ async def release_notes(
133
+ repository_id: uuid.UUID,
134
+ session: SessionDep,
135
+ _: CurrentUserDep,
136
+ branch: str | None = None,
137
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
138
+ ) -> list[ReleaseReportRead]:
139
+ """各版本的 release notes。
140
+
141
+ 来源是例会纪要里的版本进展报告:tag 区间、合入补丁数、CVE 清单、
142
+ 按类别归好的补丁列表。这是维护者本来就在写的版本说明,
143
+ 平台只是把它结构化了。
144
+ """
145
+ stmt = (
146
+ select(ReleaseReport, Meeting.held_on)
147
+ .join(Meeting, Meeting.id == ReleaseReport.meeting_id)
148
+ .order_by(Meeting.held_on.desc(), ReleaseReport.branch)
149
+ .limit(limit)
150
+ )
151
+ if branch:
152
+ stmt = stmt.where(ReleaseReport.branch == branch)
153
+
154
+ rows = (await session.execute(stmt)).all()
155
+ return [
156
+ ReleaseReportRead(
157
+ id=report.id,
158
+ branch=report.branch,
159
+ from_tag=report.from_tag,
160
+ to_tag=report.to_tag,
161
+ patch_count=report.patch_count,
162
+ listed_count=report.listed_count,
163
+ iso_urls=report.iso_urls,
164
+ cve_ids=report.cve_ids,
165
+ groups=report.groups,
166
+ meeting_held_on=held_on,
167
+ )
168
+ for report, held_on in rows
169
+ ]
170
+
171
+
172
+ @router.get(
173
+ "/releases/reports/{report_id}/patches",
174
+ response_model=list[dict],
175
+ )
176
+ async def report_patches(
177
+ report_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
178
+ ) -> list[dict]:
179
+ """一份进展报告里列出的补丁条目。"""
180
+ stmt = (
181
+ select(ReleaseReportPatch)
182
+ .where(ReleaseReportPatch.report_id == report_id)
183
+ .order_by(ReleaseReportPatch.sequence)
184
+ )
185
+ rows = (await session.scalars(stmt)).all()
186
+ return [
187
+ {
188
+ "sequence": row.sequence,
189
+ "group_name": row.group_name,
190
+ "title": row.title,
191
+ "pull_number": row.pull_number,
192
+ "url": row.url,
193
+ }
194
+ for row in rows
195
+ ]
196
+
197
+
198
+ async def _raw_names_for(session: SessionDep, repository_id: uuid.UUID, branch: str) -> list[str]:
199
+ """把一个规范化分支名展开成库里实际存在的全部写法。"""
200
+ rows = await release_service.list_branches(session, repository_id)
201
+ for item in rows:
202
+ if item.name == branch:
203
+ return item.raw_names
204
+ return [branch]
205
+
206
+
207
+ async def _resolve_pull_refs(session: SessionDep, numbers: set[int]) -> dict[int, PullRef]:
208
+ """把 PR 编号批量解析成可跳转的引用。
209
+
210
+ 按编号全局解析而不限定仓库:纪要里的编号指的是 ``openeuler/kernel``,
211
+ 而平台当前只纳管这一个仓库。若将来纳管多个仓库且编号相撞,
212
+ 这里会记录告警并保留先出现的一条 —— 静默取一条会让人以为链接是对的。
213
+ """
214
+ if not numbers:
215
+ return {}
216
+ stmt = (
217
+ select(
218
+ PullRequest.repository_id,
219
+ PullRequest.number,
220
+ PullRequest.id,
221
+ PullRequest.title,
222
+ PullRequest.state,
223
+ Classification.kind,
224
+ )
225
+ .outerjoin(
226
+ Classification,
227
+ (Classification.subject_id == PullRequest.id)
228
+ & (Classification.subject_type == SubjectType.PULL_REQUEST),
229
+ )
230
+ .where(PullRequest.number.in_(numbers))
231
+ )
232
+ refs: dict[int, PullRef] = {}
233
+ collisions: set[int] = set()
234
+ for row in (await session.execute(stmt)).all():
235
+ if row.number in refs:
236
+ collisions.add(row.number)
237
+ continue
238
+ refs[row.number] = PullRef(
239
+ number=row.number, id=row.id, title=row.title, state=row.state, kind=row.kind
240
+ )
241
+ if collisions:
242
+ logger.warning("pull_number_collision_across_repositories", numbers=sorted(collisions))
243
+ return refs
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # 例会
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ @router.get("/meetings", response_model=list[MeetingSummary])
252
+ async def list_meetings(
253
+ session: SessionDep,
254
+ _: CurrentUserDep,
255
+ status: str | None = None,
256
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
257
+ ) -> list[MeetingSummary]:
258
+ """SIG 例会列表,最近的在最前。"""
259
+ stmt = select(Meeting).order_by(Meeting.held_on.desc()).limit(limit)
260
+ if status:
261
+ stmt = stmt.where(Meeting.status == status)
262
+ meetings = list((await session.scalars(stmt)).all())
263
+ if not meetings:
264
+ return []
265
+
266
+ ids = [row.id for row in meetings]
267
+ agenda_counts = dict(
268
+ (
269
+ await session.execute(
270
+ select(MeetingAgenda.meeting_id, func.count())
271
+ .where(MeetingAgenda.meeting_id.in_(ids))
272
+ .group_by(MeetingAgenda.meeting_id)
273
+ )
274
+ ).all()
275
+ )
276
+ report_counts = dict(
277
+ (
278
+ await session.execute(
279
+ select(ReleaseReport.meeting_id, func.count())
280
+ .where(ReleaseReport.meeting_id.in_(ids))
281
+ .group_by(ReleaseReport.meeting_id)
282
+ )
283
+ ).all()
284
+ )
285
+ return [
286
+ _meeting_summary(row, agenda_counts.get(row.id, 0), report_counts.get(row.id, 0))
287
+ for row in meetings
288
+ ]
289
+
290
+
291
+ @router.get("/meetings/{meeting_id}", response_model=MeetingDetail)
292
+ async def get_meeting(
293
+ meeting_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
294
+ ) -> MeetingDetail:
295
+ stmt = (
296
+ select(Meeting)
297
+ .where(Meeting.id == meeting_id)
298
+ .options(selectinload(Meeting.agendas), selectinload(Meeting.reports))
299
+ )
300
+ meeting = await session.scalar(stmt)
301
+ if meeting is None:
302
+ raise NotFoundError("例会不存在", resource="meeting", identifier=str(meeting_id))
303
+
304
+ numbers = {n for row in meeting.agendas for n in (row.pull_numbers or [])}
305
+ refs = await _resolve_pull_refs(session, numbers)
306
+
307
+ base = _meeting_summary(meeting, len(meeting.agendas), len(meeting.reports))
308
+ agendas = []
309
+ for row in meeting.agendas:
310
+ agenda = MeetingAgendaRead.model_validate(row)
311
+ agenda.pull_refs = [refs[n] for n in (row.pull_numbers or []) if n in refs]
312
+ agendas.append(agenda)
313
+ return MeetingDetail(
314
+ **base.model_dump(),
315
+ legacy=meeting.legacy,
316
+ agendas=agendas,
317
+ reports=[
318
+ ReleaseReportRead(
319
+ id=report.id,
320
+ branch=report.branch,
321
+ from_tag=report.from_tag,
322
+ to_tag=report.to_tag,
323
+ patch_count=report.patch_count,
324
+ listed_count=report.listed_count,
325
+ iso_urls=report.iso_urls,
326
+ cve_ids=report.cve_ids,
327
+ groups=report.groups,
328
+ meeting_held_on=meeting.held_on,
329
+ )
330
+ for report in meeting.reports
331
+ ],
332
+ )
333
+
334
+
335
+ def _meeting_summary(row: Meeting, agendas: int, reports: int) -> MeetingSummary:
336
+ return MeetingSummary(
337
+ id=row.id,
338
+ held_on=row.held_on,
339
+ start_time=row.start_time,
340
+ title=row.title,
341
+ host=row.host,
342
+ next_host=row.next_host,
343
+ host_order=row.host_order,
344
+ meeting_url=row.meeting_url,
345
+ minutes_url=row.minutes_url,
346
+ status=row.status,
347
+ agenda_count=agendas,
348
+ report_count=reports,
349
+ )
350
+
351
+
352
+ @router.post("/meetings/sync", response_model=MessageResponse)
353
+ async def sync_meetings_now(session: SessionDep, _: Manage) -> MessageResponse:
354
+ """立即同步一次纪要。手动入口,定时任务在 worker 里。"""
355
+ from app.services import sig_service
356
+
357
+ text = await sig_service.fetch_etherpad()
358
+ result = await sig_service.sync_meetings(session, text)
359
+ await session.commit()
360
+ return MessageResponse(
361
+ message=(
362
+ f"解析 {result['meetings']} 场:新增 {result['created']},"
363
+ f"更新 {result['updated']},未变 {result['unchanged']}"
364
+ )
365
+ )
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # 成员与模块归属
370
+ # ---------------------------------------------------------------------------
371
+
372
+
373
+ @router.get("/members", response_model=list[MemberRead])
374
+ async def list_members(
375
+ session: SessionDep, _: CurrentUserDep, role: str | None = None
376
+ ) -> list[MemberRead]:
377
+ """SIG 成员名单。权威来源是 community 仓库的 SIG 文档,不由平台推断。"""
378
+ stmt = select(SIGMember).order_by(SIGMember.role, SIGMember.name)
379
+ if role:
380
+ stmt = stmt.where(SIGMember.role == role)
381
+ return [MemberRead.model_validate(row) for row in (await session.scalars(stmt)).all()]
382
+
383
+
384
+ @router.get("/members/stats", response_model=list[MemberStats])
385
+ async def member_stats(
386
+ session: SessionDep,
387
+ _: CurrentUserDep,
388
+ repository_id: uuid.UUID | None = None,
389
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
390
+ ) -> list[MemberStats]:
391
+ """成员贡献统计。
392
+
393
+ 现算而非落库:这个数字每天都在变,存下来就得维护失效,
394
+ 而名单本身是低频变化的,两者寿命不同。
395
+ """
396
+ authored_stmt = (
397
+ select(
398
+ PullRequest.author_login,
399
+ func.count(),
400
+ func.count().filter(PullRequest.state == "merged"),
401
+ )
402
+ .where(PullRequest.author_login.is_not(None))
403
+ .group_by(PullRequest.author_login)
404
+ )
405
+ review_stmt = select(ReviewEvent.actor_login, func.count()).group_by(ReviewEvent.actor_login)
406
+ if repository_id is not None:
407
+ authored_stmt = authored_stmt.where(PullRequest.repository_id == repository_id)
408
+
409
+ authored = {row[0]: (row[1], row[2]) for row in (await session.execute(authored_stmt)).all()}
410
+ reviewed = {row[0]: row[1] for row in (await session.execute(review_stmt)).all()}
411
+
412
+ members = {
413
+ row.atomgit_id or row.gitee_id: row
414
+ for row in (await session.scalars(select(SIGMember))).all()
415
+ }
416
+ owners = (await session.scalars(select(SubsystemOwner))).all()
417
+ modules: dict[str, list[str]] = {}
418
+ for row in owners:
419
+ for login in row.committer_logins or []:
420
+ modules.setdefault(login, []).append(row.module)
421
+
422
+ logins = set(authored) | set(reviewed) | set(members)
423
+ stats: list[MemberStats] = []
424
+ for login in logins:
425
+ if not login or is_bot(login):
426
+ continue
427
+ member = members.get(login)
428
+ counts = authored.get(login, (0, 0))
429
+ stats.append(
430
+ MemberStats(
431
+ login=login,
432
+ name=member.name if member else None,
433
+ role=member.role if member else None,
434
+ authored=counts[0],
435
+ merged=counts[1],
436
+ reviews=reviewed.get(login, 0),
437
+ subsystems=sorted(set(modules.get(login, [])))[:12],
438
+ )
439
+ )
440
+
441
+ stats.sort(key=lambda item: (-item.merged, -item.reviews, item.login))
442
+ return stats[:limit]
443
+
444
+
445
+ @router.get("/subsystem-owners", response_model=list[SubsystemOwnerRead])
446
+ async def list_subsystem_owners(session: SessionDep, _: CurrentUserDep) -> list[SubsystemOwnerRead]:
447
+ """模块 → Committer 的归属表,来自 committers.md。"""
448
+ stmt = select(SubsystemOwner).order_by(SubsystemOwner.sequence)
449
+ return [SubsystemOwnerRead.model_validate(row) for row in (await session.scalars(stmt)).all()]
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # 关注队列的订阅设置与分组
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ @router.get(
458
+ "/repositories/{repository_id}/attention/queue",
459
+ response_model=list[AttentionGroupRead],
460
+ )
461
+ async def attention_queue(
462
+ repository_id: uuid.UUID,
463
+ session: SessionDep,
464
+ _: CurrentUserDep,
465
+ scope: Annotated[Literal["pull_request", "issue"] | None, Query()] = None,
466
+ ) -> list[AttentionGroupRead]:
467
+ """按订阅设置分组的关注队列。
468
+
469
+ 返回的是**分组**而非逐条:一条规则一行,给出条数、最久等待与少量样本。
470
+ 实测未收敛时有 4780 条,其中 63% 是陈旧 Issue —— 逐条罗列等于
471
+ 把"需要关注"变成"全部清单",翻两页就会被放弃。
472
+ """
473
+ await _repository_id(session, repository_id)
474
+ groups = await attention_queue_service.build_queue(session, repository_id, scope=scope)
475
+ return [_group_read(group) for group in groups]
476
+
477
+
478
+ @router.get(
479
+ "/repositories/{repository_id}/attention/summary",
480
+ response_model=AttentionSummaryRead,
481
+ )
482
+ async def attention_summary(
483
+ repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
484
+ ) -> AttentionSummaryRead:
485
+ """首页摘要。
486
+
487
+ 与队列同源,不是另算一遍:分别统计的话,关掉一条规则后首页与队列页
488
+ 会显示两个不同的数字,而这种不一致从任一页面上都看不出来。
489
+ """
490
+ await _repository_id(session, repository_id)
491
+ summary = await attention_queue_service.queue_summary(session, repository_id)
492
+ return AttentionSummaryRead(
493
+ total=summary.total,
494
+ group_count=summary.group_count,
495
+ by_severity=summary.by_severity,
496
+ blocking=summary.blocking,
497
+ top=[_group_read(group) for group in summary.top],
498
+ )
499
+
500
+
501
+ @router.get("/attention/rule-settings", response_model=list[AttentionSettingRead])
502
+ async def list_rule_settings(session: SessionDep, _: CurrentUserDep) -> list[AttentionSettingRead]:
503
+ """各关注规则的订阅设置。
504
+
505
+ 带上当前未解决的条数:关掉一条规则之前,应该先看到它拦下了多少东西。
506
+ """
507
+ views = await attention_queue_service.load_settings(session)
508
+ return [AttentionSettingRead(**vars(view)) for view in views]
509
+
510
+
511
+ @router.put("/attention/rule-settings/{rule}", response_model=AttentionSettingRead)
512
+ async def update_rule_setting(
513
+ rule: str,
514
+ payload: AttentionSettingUpdate,
515
+ session: SessionDep,
516
+ user: CurrentUserDep,
517
+ _: Manage,
518
+ ) -> AttentionSettingRead:
519
+ """调整一条规则的订阅:开关、阈值、适用对象。
520
+
521
+ 阈值写死在代码里的结果是没人用这个功能 —— 第一条不符合自己节奏的
522
+ 提醒就会让人关掉整个队列。
523
+ """
524
+ if rule not in RULE_INFO_BY_KEY:
525
+ raise NotFoundError("关注规则不存在", resource="attention_rule", identifier=rule)
526
+
527
+ await attention_queue_service.save_setting(
528
+ session,
529
+ rule=rule,
530
+ enabled=payload.enabled,
531
+ days=payload.days,
532
+ subject_scope=payload.subject_scope,
533
+ user_id=user.id,
534
+ )
535
+ await session.commit()
536
+
537
+ views = await attention_queue_service.load_settings(session)
538
+ view = next((item for item in views if item.rule == rule), None)
539
+ if view is None:
540
+ raise NotFoundError("关注规则不存在", resource="attention_rule", identifier=rule)
541
+ return AttentionSettingRead(**vars(view))
542
+
543
+
544
+ def _group_read(group) -> AttentionGroupRead:
545
+ return AttentionGroupRead(
546
+ rule=group.rule,
547
+ label=group.label,
548
+ meaning=group.meaning,
549
+ action=group.action,
550
+ subject=group.subject,
551
+ count=group.count,
552
+ severity=group.severity,
553
+ oldest_days=group.oldest_days,
554
+ high_volume=group.high_volume,
555
+ acknowledged_count=group.acknowledged_count,
556
+ samples=[AttentionSampleRead(**vars(item)) for item in group.samples],
557
+ themes=[
558
+ AttentionThemeRead(
559
+ name=theme.name,
560
+ count=theme.count,
561
+ samples=[AttentionSampleRead(**vars(item)) for item in theme.samples],
562
+ )
563
+ for theme in group.themes
564
+ ],
565
+ )
@@ -0,0 +1,87 @@
1
+ """用户管理端点。"""
2
+
3
+ import uuid
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, Query, status
7
+
8
+ from app.api.deps import SessionDep, require_permission
9
+ from app.core.exceptions import ConflictError, NotFoundError, UnauthorizedError
10
+ from app.core.permissions import Permission
11
+ from app.core.security import verify_password
12
+ from app.models.user import User
13
+ from app.schemas.common import Page
14
+ from app.schemas.user import PasswordChange, UserCreate, UserRead, UserUpdate
15
+ from app.services import user_service
16
+
17
+ router = APIRouter(prefix="/users", tags=["users"])
18
+
19
+ AdminDep = Annotated[User, Depends(require_permission(Permission.MANAGE_USERS))]
20
+ CurrentUserDep = Annotated[User, Depends(require_permission(Permission.VIEW))]
21
+
22
+
23
+ @router.get("", response_model=Page[UserRead])
24
+ async def list_users(
25
+ session: SessionDep,
26
+ _: AdminDep,
27
+ page: Annotated[int, Query(ge=1)] = 1,
28
+ per_page: Annotated[int, Query(ge=1, le=200)] = 50,
29
+ ) -> Page[UserRead]:
30
+ users, total = await user_service.list_users(
31
+ session, offset=(page - 1) * per_page, limit=per_page
32
+ )
33
+ return Page.create([UserRead.model_validate(u) for u in users], total, page, per_page)
34
+
35
+
36
+ @router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
37
+ async def create_user(
38
+ payload: UserCreate,
39
+ session: SessionDep,
40
+ _: AdminDep,
41
+ ) -> UserRead:
42
+ try:
43
+ user = await user_service.create_user(
44
+ session,
45
+ username=payload.username,
46
+ display_name=payload.display_name,
47
+ email=payload.email,
48
+ password=payload.password,
49
+ role=payload.role,
50
+ atomgit_login=payload.atomgit_login,
51
+ )
52
+ except ValueError as exc:
53
+ raise ConflictError(str(exc)) from exc
54
+ await session.commit()
55
+ return UserRead.model_validate(user)
56
+
57
+
58
+ @router.get("/{user_id}", response_model=UserRead)
59
+ async def get_user(user_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -> UserRead:
60
+ target = await user_service.get_user(session, user_id)
61
+ if target is None:
62
+ raise NotFoundError("用户不存在", resource="user", identifier=str(user_id))
63
+ return UserRead.model_validate(target)
64
+
65
+
66
+ @router.patch("/{user_id}", response_model=UserRead)
67
+ async def update_user(
68
+ user_id: uuid.UUID, payload: UserUpdate, session: SessionDep, _: AdminDep
69
+ ) -> UserRead:
70
+ target = await user_service.get_user(session, user_id)
71
+ if target is None:
72
+ raise NotFoundError("用户不存在", resource="user", identifier=str(user_id))
73
+
74
+ for field, value in payload.model_dump(exclude_unset=True).items():
75
+ setattr(target, field, value)
76
+ await session.commit()
77
+ return UserRead.model_validate(target)
78
+
79
+
80
+ @router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
81
+ async def change_own_password(
82
+ payload: PasswordChange, session: SessionDep, user: CurrentUserDep
83
+ ) -> None:
84
+ if not verify_password(payload.current_password, user.password_hash):
85
+ raise UnauthorizedError("当前密码错误")
86
+ await user_service.set_password(session, user, payload.new_password)
87
+ await session.commit()