@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,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()
|