@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.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- 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
|