@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,446 @@
1
+ """版本与分支的统计。
2
+
3
+ 两件事:
4
+
5
+ 1. **把同名分支合并统计。** 库里的 ``target_branch`` 存的是上游原始名,
6
+ 而同一交付有 ``openEuler-24.03-LTS_SP1`` 与 ``-SP1`` 两种写法。
7
+ 统计前必须归一,否则同一个版本会裂成两行,每行的合入数都偏小。
8
+ 2. **按双周/月汇总合入情况。** 例会本身就是双周的,所以双周是天然的统计
9
+ 周期;月汇总用于看趋势。两者都从 PR 的 ``merged_at`` 现算,
10
+ 不落库 —— 落库就要考虑失效,而这类聚合每天都会变。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, date, datetime, timedelta
19
+
20
+ from sqlalchemy import select
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+
23
+ from app.domain.release import BranchKind, classify_branch
24
+ from app.domain.review import is_bot
25
+ from app.models.classification import Classification, PRKind, SubjectType
26
+ from app.models.meeting import Meeting, ReleaseReport
27
+ from app.models.pull_request import PullRequest
28
+ from app.models.sig import ReleaseBranch
29
+
30
+ # 一天。双周与月汇总的边界计算都以它为粒度。
31
+ DAY = timedelta(days=1)
32
+
33
+
34
+ @dataclass
35
+ class BranchStats:
36
+ """一个分支的概览。"""
37
+
38
+ name: str
39
+ """规范化后的名字。"""
40
+
41
+ raw_names: list[str]
42
+ """库中实际出现过的原始写法。查询该分支的 PR 要用这些值。"""
43
+
44
+ kind: str
45
+ series: str | None = None
46
+ sp: int | None = None
47
+ upstream_base: str | None = None
48
+ state: str = "active"
49
+ note: str | None = None
50
+ keepers: list[str] = field(default_factory=list)
51
+ """分支负责人账号,来自 sig-info.yaml。"""
52
+
53
+ open_count: int = 0
54
+ merged_count: int = 0
55
+ merged_recent: int = 0
56
+ """近 30 天合入数。"""
57
+
58
+ last_merged_at: datetime | None = None
59
+ latest_tag: str | None = None
60
+ """最近一次例会上报告的分支 tag。"""
61
+
62
+ last_report_on: date | None = None
63
+
64
+
65
+ async def _raw_branch_map(session: AsyncSession, repository_id: uuid.UUID) -> dict[str, list[str]]:
66
+ """规范化分支名 → 库中实际出现过的原始名列表。"""
67
+ stmt = (
68
+ select(PullRequest.target_branch)
69
+ .where(
70
+ PullRequest.repository_id == repository_id,
71
+ PullRequest.target_branch.is_not(None),
72
+ )
73
+ .group_by(PullRequest.target_branch)
74
+ )
75
+ mapping: dict[str, list[str]] = defaultdict(list)
76
+ for raw in (await session.scalars(stmt)).all():
77
+ mapping[classify_branch(raw).name].append(raw)
78
+ return dict(mapping)
79
+
80
+
81
+ async def list_branches(
82
+ session: AsyncSession, repository_id: uuid.UUID, *, recent_days: int = 30
83
+ ) -> list[BranchStats]:
84
+ """全部分支及其合入统计。
85
+
86
+ 一次查完所有分支的计数再在内存里归并,而不是按分支逐个查 ——
87
+ 分支数在十位量级,但逐个查会变成十几次往返。
88
+ """
89
+ raw_map = await _raw_branch_map(session, repository_id)
90
+ if not raw_map:
91
+ return []
92
+
93
+ canonical = {raw: name for name, raws in raw_map.items() for raw in raws}
94
+
95
+ # 一次性取回全部 PR 的分支与状态,在内存里归并。分支数是十位量级,
96
+ # 按分支逐个查会变成十几次往返,而全量取回的列很窄。
97
+ stmt = select(PullRequest.target_branch, PullRequest.state, PullRequest.merged_at).where(
98
+ PullRequest.repository_id == repository_id
99
+ )
100
+
101
+ cutoff = datetime.now(UTC) - timedelta(days=recent_days)
102
+ stats: dict[str, BranchStats] = {}
103
+
104
+ for raw, state, merged_at in (await session.execute(stmt)).all():
105
+ name = canonical.get(raw)
106
+ if name is None:
107
+ continue
108
+ spec = classify_branch(raw)
109
+ item = stats.setdefault(
110
+ name,
111
+ BranchStats(
112
+ name=name,
113
+ raw_names=sorted(raw_map.get(name, [raw])),
114
+ kind=spec.kind.value,
115
+ series=spec.series,
116
+ sp=spec.sp,
117
+ upstream_base=spec.upstream_base,
118
+ ),
119
+ )
120
+ if state == "open":
121
+ item.open_count += 1
122
+ elif state == "merged":
123
+ item.merged_count += 1
124
+ if merged_at is not None:
125
+ if merged_at > cutoff:
126
+ item.merged_recent += 1
127
+ if item.last_merged_at is None or merged_at > item.last_merged_at:
128
+ item.last_merged_at = merged_at
129
+
130
+ # 登记表里的补充事实(生命周期、维护说明、别名)覆盖推导结果
131
+ #
132
+ # 只取本仓库的分支:这张表里每条都带着所属仓库(来自 sig-info.yaml 的
133
+ # repo_branch),不按它过滤的话,另一个仓库的分支会被当成"登记了但还没有
134
+ # PR 的分支"凭空出现 —— OLK-6.6 会出现在 src-openeuler/kernel 的分支页上。
135
+ registry = {
136
+ row.name: row
137
+ for row in (
138
+ await session.scalars(
139
+ select(ReleaseBranch).where(ReleaseBranch.repository_id == repository_id)
140
+ )
141
+ ).all()
142
+ }
143
+ for name, row in registry.items():
144
+ item = stats.get(name)
145
+ if item is None:
146
+ # 登记了但还没有 PR 的分支(如刚宣布、尚未启用)也要出现
147
+ spec = classify_branch(name)
148
+ item = stats[name] = BranchStats(
149
+ name=name,
150
+ raw_names=sorted(set(raw_map.get(name, []) + list(row.aliases or []))) or [name],
151
+ kind=row.kind or spec.kind.value,
152
+ series=row.series or spec.series,
153
+ sp=row.sp if row.sp is not None else spec.sp,
154
+ upstream_base=row.upstream_base or spec.upstream_base,
155
+ )
156
+ item.state = row.state
157
+ item.note = row.note
158
+ item.keepers = list(row.keepers or [])
159
+ if row.aliases:
160
+ item.raw_names = sorted(set(item.raw_names) | set(row.aliases))
161
+
162
+ # 最近一次例会上报告的 tag
163
+ latest = (
164
+ select(ReleaseReport.branch, ReleaseReport.to_tag, Meeting.held_on)
165
+ .join(Meeting, Meeting.id == ReleaseReport.meeting_id)
166
+ .order_by(Meeting.held_on.desc())
167
+ )
168
+ for branch, to_tag, held_on in (await session.execute(latest)).all():
169
+ item = stats.get(branch)
170
+ if item is not None and item.latest_tag is None:
171
+ item.latest_tag = to_tag
172
+ item.last_report_on = held_on
173
+
174
+ return sorted(stats.values(), key=_branch_sort_key)
175
+
176
+
177
+ _KIND_ORDER = {
178
+ BranchKind.OLK.value: 0,
179
+ BranchKind.LTS.value: 1,
180
+ BranchKind.INNOVATION.value: 2,
181
+ BranchKind.OTHER.value: 3,
182
+ }
183
+
184
+
185
+ def _branch_sort_key(item: BranchStats) -> tuple:
186
+ """产品线优先,同线内按 SP 倒序(新版本在前),再按名字。
187
+
188
+ OLK 与 LTS 的版本号方向相反:OLK-6.6 比 OLK-5.10 新,而 24.03-LTS 比
189
+ 22.03-LTS 新,但 SP 越大越新。用 SP 倒序并把版本串倒序排,
190
+ 两条线都能得到"新的在前"。
191
+ """
192
+ return (
193
+ _KIND_ORDER.get(item.kind, 9),
194
+ -(item.sp or 0),
195
+ _negate_version(item.series or item.name),
196
+ item.name,
197
+ )
198
+
199
+
200
+ def _negate_version(text: str) -> tuple:
201
+ """把版本号转成可倒序排序的键。非数字段按 0 处理。"""
202
+ parts = []
203
+ for chunk in text.replace("-", ".").replace("_", ".").split("."):
204
+ parts.append(-int(chunk) if chunk.isdigit() else 0)
205
+ return tuple(parts)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # 合入趋势与分期汇总
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ @dataclass
214
+ class MergeBucket:
215
+ """一个统计周期内的合入情况。"""
216
+
217
+ label: str
218
+ start: date
219
+ end: date
220
+ total: int = 0
221
+ by_kind: dict[str, int] = field(default_factory=dict)
222
+
223
+
224
+ async def merge_timeline(
225
+ session: AsyncSession,
226
+ repository_id: uuid.UUID,
227
+ *,
228
+ raw_branches: list[str],
229
+ buckets: int = 12,
230
+ granularity: str = "month",
231
+ ) -> list[MergeBucket]:
232
+ """按周期统计合入量。
233
+
234
+ 周期边界从**今天**往回推,而不是从第一条数据开始 —— 从数据开始推会让
235
+ 最近一期只有半天,看起来像合入量骤降。
236
+ """
237
+ if not raw_branches:
238
+ return []
239
+
240
+ stmt = (
241
+ select(PullRequest.merged_at, Classification.kind)
242
+ .outerjoin(
243
+ Classification,
244
+ (Classification.subject_id == PullRequest.id)
245
+ & (Classification.subject_type == SubjectType.PULL_REQUEST),
246
+ )
247
+ .where(
248
+ PullRequest.repository_id == repository_id,
249
+ PullRequest.target_branch.in_(raw_branches),
250
+ PullRequest.merged_at.is_not(None),
251
+ )
252
+ )
253
+ rows = (await session.execute(stmt)).all()
254
+ if not rows:
255
+ return []
256
+
257
+ windows = _bucket_windows(date.today(), buckets, granularity)
258
+ results = [MergeBucket(label=label, start=start, end=end) for label, start, end in windows]
259
+
260
+ for merged_at, kind in rows:
261
+ merged_on = merged_at.date()
262
+ for bucket in results:
263
+ if bucket.start <= merged_on < bucket.end:
264
+ bucket.total += 1
265
+ key = kind.value if kind is not None else "unknown"
266
+ bucket.by_kind[key] = bucket.by_kind.get(key, 0) + 1
267
+ break
268
+
269
+ return results
270
+
271
+
272
+ def recent_windows(
273
+ count: int, granularity: str, today: date | None = None
274
+ ) -> list[tuple[str, date, date]]:
275
+ """最近 N 个统计周期的边界,按时间正序。"""
276
+ return _bucket_windows(today or date.today(), count, granularity)
277
+
278
+
279
+ def _bucket_windows(today: date, count: int, granularity: str) -> list[tuple[str, date, date]]:
280
+ """从今天往回生成 N 个周期的边界。"""
281
+ windows: list[tuple[str, date, date]] = []
282
+ if granularity == "month":
283
+ cursor = today.replace(day=1)
284
+ for _ in range(count):
285
+ nxt = _add_month(cursor)
286
+ windows.append((cursor.strftime("%Y-%m"), cursor, nxt))
287
+ cursor = _add_month(cursor, -1)
288
+ else:
289
+ # 双周:以 ISO 周的周一对齐,每次回退 14 天
290
+ cursor = today - timedelta(days=today.weekday())
291
+ for _ in range(count):
292
+ windows.append(
293
+ (
294
+ f"{cursor.isoformat()}~{(cursor + 13 * DAY).isoformat()}",
295
+ cursor,
296
+ cursor + 14 * DAY,
297
+ )
298
+ )
299
+ cursor -= 14 * DAY
300
+ windows.reverse()
301
+ return windows
302
+
303
+
304
+ def _add_month(day: date, delta: int = 1) -> date:
305
+ month = day.month - 1 + delta
306
+ year = day.year + month // 12
307
+ month = month % 12 + 1
308
+ return date(year, month, 1)
309
+
310
+
311
+ @dataclass
312
+ class PeriodSummary:
313
+ """一期(双周或月)的合入总结。"""
314
+
315
+ label: str
316
+ start: date
317
+ end: date
318
+ total: int = 0
319
+ by_branch: dict[str, int] = field(default_factory=dict)
320
+ by_kind: dict[str, int] = field(default_factory=dict)
321
+ cve_count: int = 0
322
+ top_contributors: list[tuple[str, int]] = field(default_factory=list)
323
+
324
+
325
+ async def period_summary(
326
+ session: AsyncSession,
327
+ repository_id: uuid.UUID,
328
+ *,
329
+ start: date,
330
+ end: date,
331
+ label: str,
332
+ raw_branches: list[str] | None = None,
333
+ ) -> PeriodSummary:
334
+ """一期内按分支、类别、作者的合入汇总。
335
+
336
+ ``raw_branches`` 传入时只统计这些分支 —— 版本页要的是"这个版本这一期
337
+ 合入了什么",而不是全仓合入了什么。不传则汇总全部。
338
+ """
339
+ stmt = (
340
+ select(PullRequest.target_branch, PullRequest.author_login, Classification.kind)
341
+ .outerjoin(
342
+ Classification,
343
+ (Classification.subject_id == PullRequest.id)
344
+ & (Classification.subject_type == SubjectType.PULL_REQUEST),
345
+ )
346
+ .where(
347
+ PullRequest.repository_id == repository_id,
348
+ PullRequest.merged_at.is_not(None),
349
+ PullRequest.merged_at >= datetime.combine(start, datetime.min.time(), tzinfo=UTC),
350
+ PullRequest.merged_at < datetime.combine(end, datetime.min.time(), tzinfo=UTC),
351
+ )
352
+ )
353
+ if raw_branches:
354
+ stmt = stmt.where(PullRequest.target_branch.in_(raw_branches))
355
+ summary = PeriodSummary(label=label, start=start, end=end)
356
+ authors: dict[str, int] = defaultdict(int)
357
+
358
+ for raw_branch, author, kind in (await session.execute(stmt)).all():
359
+ summary.total += 1
360
+ branch = classify_branch(raw_branch).name
361
+ summary.by_branch[branch] = summary.by_branch.get(branch, 0) + 1
362
+ key = kind.value if kind is not None else "unknown"
363
+ summary.by_kind[key] = summary.by_kind.get(key, 0) + 1
364
+ if key == PRKind.CVE.value:
365
+ summary.cve_count += 1
366
+ # 机器人要排除在贡献者之外,否则榜单前几名永远是 CI 与门禁机器人。
367
+ # 实测某月合入 362 个 PR,其中机器人提了 115 个。
368
+ if author and not is_bot(author):
369
+ authors[author] += 1
370
+
371
+ summary.top_contributors = sorted(authors.items(), key=lambda item: -item[1])[:10]
372
+ return summary
373
+
374
+
375
+ async def period_summaries(
376
+ session: AsyncSession,
377
+ repository_id: uuid.UUID,
378
+ *,
379
+ windows: list[tuple[str, date, date]],
380
+ raw_branches: list[str] | None = None,
381
+ ) -> list[PeriodSummary]:
382
+ """多个连续周期的合入汇总,一次查询完。
383
+
384
+ 逐期调用 period_summary 会退化成 N 次扫描,而版本页要的正是
385
+ "最近半年的双周总结"这种连续多期 —— 那样的实现读起来直观,
386
+ 代价是在几万行的表上跑 N 遍。
387
+ """
388
+ if not windows:
389
+ return []
390
+
391
+ stmt = (
392
+ select(
393
+ PullRequest.target_branch,
394
+ PullRequest.author_login,
395
+ Classification.kind,
396
+ PullRequest.merged_at,
397
+ )
398
+ .outerjoin(
399
+ Classification,
400
+ (Classification.subject_id == PullRequest.id)
401
+ & (Classification.subject_type == SubjectType.PULL_REQUEST),
402
+ )
403
+ .where(
404
+ PullRequest.repository_id == repository_id,
405
+ PullRequest.merged_at.is_not(None),
406
+ PullRequest.merged_at
407
+ >= datetime.combine(windows[0][1], datetime.min.time(), tzinfo=UTC),
408
+ PullRequest.merged_at
409
+ < datetime.combine(windows[-1][2], datetime.min.time(), tzinfo=UTC),
410
+ )
411
+ )
412
+ if raw_branches:
413
+ stmt = stmt.where(PullRequest.target_branch.in_(raw_branches))
414
+
415
+ summaries = [PeriodSummary(label=label, start=start, end=end) for label, start, end in windows]
416
+ bounds = [
417
+ (
418
+ datetime.combine(start, datetime.min.time(), tzinfo=UTC),
419
+ datetime.combine(end, datetime.min.time(), tzinfo=UTC),
420
+ )
421
+ for _, start, end in windows
422
+ ]
423
+ authors: list[dict[str, int]] = [defaultdict(int) for _ in windows]
424
+
425
+ for raw_branch, author, kind, merged_at in (await session.execute(stmt)).all():
426
+ index = next(
427
+ (i for i, (lo, hi) in enumerate(bounds) if lo <= merged_at < hi),
428
+ None,
429
+ )
430
+ if index is None:
431
+ continue
432
+ summary = summaries[index]
433
+ summary.total += 1
434
+ branch = classify_branch(raw_branch).name
435
+ summary.by_branch[branch] = summary.by_branch.get(branch, 0) + 1
436
+ key = kind.value if kind is not None else "unknown"
437
+ summary.by_kind[key] = summary.by_kind.get(key, 0) + 1
438
+ if key == PRKind.CVE.value:
439
+ summary.cve_count += 1
440
+ # 机器人要排除在贡献者之外,否则榜单前几名永远是 CI 与门禁机器人
441
+ if author and not is_bot(author):
442
+ authors[index][author] += 1
443
+
444
+ for summary, counts in zip(summaries, authors, strict=True):
445
+ summary.top_contributors = sorted(counts.items(), key=lambda item: -item[1])[:10]
446
+ return summaries
@@ -0,0 +1,132 @@
1
+ """仓库纳管服务。"""
2
+
3
+ import secrets
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+
7
+ import structlog
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.core.config import Settings
12
+ from app.core.exceptions import ConflictError, ExternalServiceError, NotFoundError
13
+ from app.integrations.atomgit.client import (
14
+ AtomGitAuthError,
15
+ AtomGitClient,
16
+ AtomGitError,
17
+ )
18
+ from app.models.repository import DEFAULT_API_BASE_URL, Repository
19
+ from app.services import credential_service
20
+
21
+ logger = structlog.get_logger(__name__)
22
+
23
+ WEBHOOK_SECRET_BYTES = 24
24
+
25
+
26
+ async def list_repositories(session: AsyncSession) -> list[Repository]:
27
+ stmt = select(Repository).order_by(Repository.owner, Repository.name)
28
+ return list((await session.scalars(stmt)).all())
29
+
30
+
31
+ async def get_repository(session: AsyncSession, repository_id: uuid.UUID) -> Repository | None:
32
+ return await session.get(Repository, repository_id)
33
+
34
+
35
+ async def require_repository(session: AsyncSession, repository_id: uuid.UUID) -> Repository:
36
+ repository = await get_repository(session, repository_id)
37
+ if repository is None:
38
+ raise NotFoundError("仓库不存在", resource="repository", identifier=str(repository_id))
39
+ return repository
40
+
41
+
42
+ async def get_by_full_name(session: AsyncSession, owner: str, name: str) -> Repository | None:
43
+ stmt = select(Repository).where(Repository.owner == owner, Repository.name == name)
44
+ return await session.scalar(stmt)
45
+
46
+
47
+ def build_client(settings: Settings, repository: Repository, credential) -> AtomGitClient:
48
+ """按仓库配置构造客户端。base_url 取自仓库,支持 AtomGit/GitCode 切换。"""
49
+ token = credential_service.reveal(settings, credential)
50
+ return AtomGitClient(token, base_url=repository.api_base_url or DEFAULT_API_BASE_URL)
51
+
52
+
53
+ async def create_repository(
54
+ session: AsyncSession,
55
+ settings: Settings,
56
+ *,
57
+ owner: str,
58
+ name: str,
59
+ credential_id: uuid.UUID,
60
+ display_name: str | None = None,
61
+ api_base_url: str = DEFAULT_API_BASE_URL,
62
+ tracked_branches: list[str] | None = None,
63
+ backfill_days: int = 180,
64
+ ) -> Repository:
65
+ """纳管一个仓库。
66
+
67
+ **先验证凭据再落库**:提交前用 /user 确认 token 有效,
68
+ 避免把不可用的配置写进数据库、直到第一次同步才暴露问题。
69
+ """
70
+ if await get_by_full_name(session, owner, name) is not None:
71
+ raise ConflictError(f"仓库 {owner}/{name} 已被纳管")
72
+
73
+ credential = await credential_service.require_credential(session, credential_id)
74
+
75
+ client = AtomGitClient(
76
+ credential_service.reveal(settings, credential),
77
+ base_url=api_base_url,
78
+ )
79
+ try:
80
+ async with client:
81
+ repo_info = await client.get_repository(owner, name)
82
+ except AtomGitAuthError as exc:
83
+ credential_service.mark_verified(credential, error=str(exc))
84
+ await session.flush()
85
+ raise ExternalServiceError(f"凭据校验失败:{exc}") from exc
86
+ except AtomGitError as exc:
87
+ raise ExternalServiceError(f"无法访问 {owner}/{name}:{exc}") from exc
88
+
89
+ credential_service.mark_verified(credential)
90
+
91
+ repository = Repository(
92
+ owner=owner,
93
+ name=name,
94
+ display_name=display_name or repo_info.human_name or f"{owner}/{name}",
95
+ description=repo_info.description,
96
+ api_base_url=api_base_url,
97
+ credential_id=credential_id,
98
+ default_branch=repo_info.default_branch,
99
+ tracked_branches=tracked_branches
100
+ or ([repo_info.default_branch] if repo_info.default_branch else None),
101
+ webhook_secret=secrets.token_urlsafe(WEBHOOK_SECRET_BYTES),
102
+ backfill_days=backfill_days,
103
+ )
104
+ session.add(repository)
105
+ await session.flush()
106
+ logger.info("repository_created", full_name=repository.full_name)
107
+ return repository
108
+
109
+
110
+ async def update_repository(session: AsyncSession, repository: Repository, **fields) -> Repository:
111
+ for key, value in fields.items():
112
+ if value is not None and hasattr(repository, key):
113
+ setattr(repository, key, value)
114
+ await session.flush()
115
+ return repository
116
+
117
+
118
+ async def rotate_webhook_secret(session: AsyncSession, repository: Repository) -> str:
119
+ """轮换 webhook 路径密钥。旧密钥立即失效。"""
120
+ repository.webhook_secret = secrets.token_urlsafe(WEBHOOK_SECRET_BYTES)
121
+ await session.flush()
122
+ return repository.webhook_secret
123
+
124
+
125
+ async def get_by_webhook_secret(session: AsyncSession, secret: str) -> Repository | None:
126
+ stmt = select(Repository).where(Repository.webhook_secret == secret)
127
+ return await session.scalar(stmt)
128
+
129
+
130
+ def mark_synced(repository: Repository, *, error: str | None = None) -> None:
131
+ repository.last_sync_at = datetime.now(UTC)
132
+ repository.last_sync_error = error