@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,105 @@
|
|
|
1
|
+
"""凭据服务。
|
|
2
|
+
|
|
3
|
+
职责:加密存储、按需解密、验证有效性。
|
|
4
|
+
|
|
5
|
+
安全约束:
|
|
6
|
+
- 明文永不回显,接口只暴露指纹
|
|
7
|
+
- 解密仅在真正需要调用外部服务时发生,且不写入日志
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from app.core.config import Settings
|
|
18
|
+
from app.core.crypto import CredentialCipher, fingerprint
|
|
19
|
+
from app.core.exceptions import ConflictError, NotFoundError
|
|
20
|
+
from app.models.credential import Credential, CredentialKind
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cipher(settings: Settings) -> CredentialCipher:
|
|
26
|
+
return CredentialCipher(secret_key=settings.secret_key)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def list_credentials(
|
|
30
|
+
session: AsyncSession, *, kind: CredentialKind | None = None
|
|
31
|
+
) -> list[Credential]:
|
|
32
|
+
stmt = select(Credential).order_by(Credential.created_at.desc())
|
|
33
|
+
if kind is not None:
|
|
34
|
+
stmt = stmt.where(Credential.kind == kind)
|
|
35
|
+
return list((await session.scalars(stmt)).all())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def get_credential(session: AsyncSession, credential_id: uuid.UUID) -> Credential | None:
|
|
39
|
+
return await session.get(Credential, credential_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def create_credential(
|
|
43
|
+
session: AsyncSession,
|
|
44
|
+
settings: Settings,
|
|
45
|
+
*,
|
|
46
|
+
name: str,
|
|
47
|
+
kind: CredentialKind,
|
|
48
|
+
plaintext: str,
|
|
49
|
+
meta: dict | None = None,
|
|
50
|
+
created_by: uuid.UUID | None = None,
|
|
51
|
+
) -> Credential:
|
|
52
|
+
existing = await session.scalar(select(Credential).where(Credential.name == name))
|
|
53
|
+
if existing is not None:
|
|
54
|
+
raise ConflictError(f"凭据名称 {name} 已存在")
|
|
55
|
+
|
|
56
|
+
cipher = _cipher(settings)
|
|
57
|
+
credential = Credential(
|
|
58
|
+
name=name,
|
|
59
|
+
kind=kind,
|
|
60
|
+
encrypted_value=cipher.encrypt(plaintext).encode("utf-8"),
|
|
61
|
+
fingerprint=fingerprint(plaintext),
|
|
62
|
+
meta=meta,
|
|
63
|
+
created_by=created_by,
|
|
64
|
+
)
|
|
65
|
+
session.add(credential)
|
|
66
|
+
await session.flush()
|
|
67
|
+
return credential
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def update_credential_value(
|
|
71
|
+
session: AsyncSession, settings: Settings, credential: Credential, plaintext: str
|
|
72
|
+
) -> Credential:
|
|
73
|
+
"""轮换凭据明文。指纹随之更新。"""
|
|
74
|
+
cipher = _cipher(settings)
|
|
75
|
+
credential.encrypted_value = cipher.encrypt(plaintext).encode("utf-8")
|
|
76
|
+
credential.fingerprint = fingerprint(plaintext)
|
|
77
|
+
credential.last_verified_at = None
|
|
78
|
+
credential.last_verify_error = None
|
|
79
|
+
await session.flush()
|
|
80
|
+
return credential
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reveal(settings: Settings, credential: Credential) -> str:
|
|
84
|
+
"""解密凭据明文。
|
|
85
|
+
|
|
86
|
+
仅限在真正需要调用外部服务的路径上使用。返回值不得进入日志、
|
|
87
|
+
异常信息或任何面向用户的响应。
|
|
88
|
+
"""
|
|
89
|
+
return _cipher(settings).decrypt(credential.encrypted_value.decode("utf-8"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def mark_verified(credential: Credential, *, error: str | None = None) -> None:
|
|
93
|
+
credential.last_verified_at = datetime.now(UTC) if error is None else None
|
|
94
|
+
credential.last_verify_error = error
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def delete_credential(session: AsyncSession, credential: Credential) -> None:
|
|
98
|
+
await session.delete(credential)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def require_credential(session: AsyncSession, credential_id: uuid.UUID) -> Credential:
|
|
102
|
+
credential = await get_credential(session, credential_id)
|
|
103
|
+
if credential is None:
|
|
104
|
+
raise NotFoundError("凭据不存在", resource="credential", identifier=str(credential_id))
|
|
105
|
+
return credential
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""PR 列表的筛选与分面计数。
|
|
2
|
+
|
|
3
|
+
筛选条件只有一份定义,列表接口与分面接口共用 —— 两处各写一份必然漂移,
|
|
4
|
+
表现为"筛选出 12 条,但分面里那一项的计数写着 15",而这种偏差很难被发现。
|
|
5
|
+
|
|
6
|
+
分面计数的关键在于**算某一维度的计数时要把该维度自己的筛选去掉**:
|
|
7
|
+
否则勾上"CVE"之后,类别那一栏只剩下 CVE 一项非零,使用者除了全部清空
|
|
8
|
+
没有别的办法把范围放宽。这是分面浏览能不能用的分水岭。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
|
|
17
|
+
from sqlalchemy import Select, String, cast, exists, func, or_, select
|
|
18
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
19
|
+
|
|
20
|
+
from app.domain.release import classify_branch
|
|
21
|
+
from app.domain.review import is_bot
|
|
22
|
+
from app.models.attention import AttentionItem
|
|
23
|
+
from app.models.classification import Classification, PRKind, SubjectType
|
|
24
|
+
from app.models.pull_request import PullRequest
|
|
25
|
+
|
|
26
|
+
# 分面维度。顺序即展示顺序。
|
|
27
|
+
DIMENSIONS: tuple[str, ...] = ("stage", "kind", "branch", "author", "attention")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Selection:
|
|
32
|
+
"""一组筛选条件。多维之间取交,同维之内取并。"""
|
|
33
|
+
|
|
34
|
+
state: str | None = None
|
|
35
|
+
stage: list[str] = field(default_factory=list)
|
|
36
|
+
kind: list[str] = field(default_factory=list)
|
|
37
|
+
branch: list[str] = field(default_factory=list)
|
|
38
|
+
author: list[str] = field(default_factory=list)
|
|
39
|
+
attention: list[str] = field(default_factory=list)
|
|
40
|
+
draft: bool | None = None
|
|
41
|
+
conflict: bool | None = None
|
|
42
|
+
search: str | None = None
|
|
43
|
+
|
|
44
|
+
waiting_days_gte: int | None = None
|
|
45
|
+
"""已等待天数下限。首页「超 30 天未评审」这类指标要能点进来,
|
|
46
|
+
否则那只是一个无法追问的数字。"""
|
|
47
|
+
|
|
48
|
+
def without(self, dimension: str) -> Selection:
|
|
49
|
+
"""去掉某一维度后的条件。分面计数用。"""
|
|
50
|
+
clone = Selection(
|
|
51
|
+
state=self.state,
|
|
52
|
+
stage=list(self.stage),
|
|
53
|
+
kind=list(self.kind),
|
|
54
|
+
branch=list(self.branch),
|
|
55
|
+
author=list(self.author),
|
|
56
|
+
attention=list(self.attention),
|
|
57
|
+
draft=self.draft,
|
|
58
|
+
conflict=self.conflict,
|
|
59
|
+
search=self.search,
|
|
60
|
+
waiting_days_gte=self.waiting_days_gte,
|
|
61
|
+
)
|
|
62
|
+
setattr(clone, dimension, [] if dimension != "draft" else None)
|
|
63
|
+
return clone
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class FacetValue:
|
|
68
|
+
value: str
|
|
69
|
+
label: str
|
|
70
|
+
count: int
|
|
71
|
+
selected: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Facet:
|
|
76
|
+
key: str
|
|
77
|
+
label: str
|
|
78
|
+
values: list[FacetValue]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_filters(
|
|
82
|
+
stmt: Select,
|
|
83
|
+
repository_id: uuid.UUID,
|
|
84
|
+
selection: Selection,
|
|
85
|
+
*,
|
|
86
|
+
branch_alias: dict[str, list[str]] | None = None,
|
|
87
|
+
) -> Select:
|
|
88
|
+
"""把筛选条件套到 PR 查询上。列表与分面共用。
|
|
89
|
+
|
|
90
|
+
``branch_alias`` 是「规范化分支名 → 库中实际写法」的映射。筛选值用的是
|
|
91
|
+
规范化名(分面里显示的就是它),而 ``target_branch`` 存的是上游原始写法,
|
|
92
|
+
不展开就会筛不到任何东西 —— 而"勾了却空结果"比报错更难排查。
|
|
93
|
+
"""
|
|
94
|
+
stmt = stmt.where(PullRequest.repository_id == repository_id)
|
|
95
|
+
|
|
96
|
+
if selection.state and selection.state != "all":
|
|
97
|
+
stmt = stmt.where(PullRequest.state == selection.state)
|
|
98
|
+
if selection.stage:
|
|
99
|
+
stmt = stmt.where(PullRequest.gate_stage.in_(selection.stage))
|
|
100
|
+
if selection.branch:
|
|
101
|
+
raw = [
|
|
102
|
+
name
|
|
103
|
+
for canonical in selection.branch
|
|
104
|
+
for name in (branch_alias or {}).get(canonical, [canonical])
|
|
105
|
+
]
|
|
106
|
+
stmt = stmt.where(PullRequest.target_branch.in_(raw))
|
|
107
|
+
if selection.author:
|
|
108
|
+
stmt = stmt.where(PullRequest.author_login.in_(selection.author))
|
|
109
|
+
if selection.draft is not None:
|
|
110
|
+
stmt = stmt.where(PullRequest.draft.is_(selection.draft))
|
|
111
|
+
if selection.conflict is not None:
|
|
112
|
+
stmt = stmt.where(PullRequest.merge_conflict.is_(selection.conflict))
|
|
113
|
+
if selection.search:
|
|
114
|
+
pattern = f"%{selection.search}%"
|
|
115
|
+
stmt = stmt.where(
|
|
116
|
+
or_(
|
|
117
|
+
PullRequest.title.ilike(pattern),
|
|
118
|
+
cast(PullRequest.number, String).ilike(pattern),
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if selection.kind:
|
|
123
|
+
stmt = stmt.where(
|
|
124
|
+
PullRequest.id.in_(
|
|
125
|
+
select(Classification.subject_id).where(
|
|
126
|
+
Classification.subject_type == SubjectType.PULL_REQUEST,
|
|
127
|
+
Classification.kind.in_([PRKind(value) for value in selection.kind]),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if selection.waiting_days_gte is not None:
|
|
132
|
+
# 等待时长以创建时间为起点。与 analytics 的 waiting_time_stats
|
|
133
|
+
# 用同一个口径,否则首页数字与点进来的列表对不上。
|
|
134
|
+
cutoff = datetime.now(UTC) - timedelta(days=selection.waiting_days_gte)
|
|
135
|
+
stmt = stmt.where(
|
|
136
|
+
PullRequest.atomgit_created_at.is_not(None),
|
|
137
|
+
PullRequest.atomgit_created_at < cutoff,
|
|
138
|
+
)
|
|
139
|
+
if selection.attention:
|
|
140
|
+
stmt = stmt.where(
|
|
141
|
+
exists().where(
|
|
142
|
+
AttentionItem.subject_id == PullRequest.id,
|
|
143
|
+
AttentionItem.resolved_at.is_(None),
|
|
144
|
+
AttentionItem.rule.in_(selection.attention),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return stmt
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _counts(
|
|
151
|
+
session: AsyncSession,
|
|
152
|
+
repository_id: uuid.UUID,
|
|
153
|
+
selection: Selection,
|
|
154
|
+
dimension: str,
|
|
155
|
+
*,
|
|
156
|
+
branch_alias: dict[str, list[str]] | None = None,
|
|
157
|
+
) -> dict[str, int]:
|
|
158
|
+
"""某一维度下的取值分布。"""
|
|
159
|
+
scoped = selection.without(dimension)
|
|
160
|
+
|
|
161
|
+
if dimension == "stage":
|
|
162
|
+
column = PullRequest.gate_stage
|
|
163
|
+
stmt = select(column, func.count()).group_by(column)
|
|
164
|
+
elif dimension == "branch":
|
|
165
|
+
column = PullRequest.target_branch
|
|
166
|
+
stmt = select(column, func.count()).group_by(column)
|
|
167
|
+
elif dimension == "author":
|
|
168
|
+
column = PullRequest.author_login
|
|
169
|
+
stmt = select(column, func.count()).where(column.is_not(None)).group_by(column)
|
|
170
|
+
elif dimension == "kind":
|
|
171
|
+
stmt = (
|
|
172
|
+
select(Classification.kind, func.count())
|
|
173
|
+
.select_from(PullRequest)
|
|
174
|
+
.join(
|
|
175
|
+
Classification,
|
|
176
|
+
(Classification.subject_id == PullRequest.id)
|
|
177
|
+
& (Classification.subject_type == SubjectType.PULL_REQUEST),
|
|
178
|
+
)
|
|
179
|
+
.where(PullRequest.repository_id == repository_id)
|
|
180
|
+
.group_by(Classification.kind)
|
|
181
|
+
)
|
|
182
|
+
elif dimension == "attention":
|
|
183
|
+
stmt = (
|
|
184
|
+
select(AttentionItem.rule, func.count(func.distinct(PullRequest.id)))
|
|
185
|
+
.select_from(PullRequest)
|
|
186
|
+
.join(AttentionItem, AttentionItem.subject_id == PullRequest.id)
|
|
187
|
+
.where(
|
|
188
|
+
PullRequest.repository_id == repository_id,
|
|
189
|
+
AttentionItem.resolved_at.is_(None),
|
|
190
|
+
)
|
|
191
|
+
.group_by(AttentionItem.rule)
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
return {}
|
|
195
|
+
|
|
196
|
+
stmt = apply_filters(stmt, repository_id, scoped, branch_alias=branch_alias)
|
|
197
|
+
raw = {
|
|
198
|
+
str(row[0].value if hasattr(row[0], "value") else row[0]): row[1]
|
|
199
|
+
for row in (await session.execute(stmt)).all()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if dimension == "branch":
|
|
203
|
+
# 同一交付的多种写法在这里合并,否则分面里会出现两行同一个版本
|
|
204
|
+
merged: dict[str, int] = {}
|
|
205
|
+
for raw_name, count in raw.items():
|
|
206
|
+
merged[classify_branch(raw_name).name] = (
|
|
207
|
+
merged.get(classify_branch(raw_name).name, 0) + count
|
|
208
|
+
)
|
|
209
|
+
return merged
|
|
210
|
+
return raw
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# 每个维度最多列多少项。列表太长会让"找不到"取代"选得快"。
|
|
214
|
+
DIMENSION_LIMIT = 14
|
|
215
|
+
|
|
216
|
+
# 作者维度里机器人不参与 —— 它们不是人在选的维度
|
|
217
|
+
_AUTHOR_LIMIT = 10
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def build_facets(
|
|
221
|
+
session: AsyncSession,
|
|
222
|
+
repository_id: uuid.UUID,
|
|
223
|
+
selection: Selection,
|
|
224
|
+
*,
|
|
225
|
+
branch_alias: dict[str, list[str]] | None = None,
|
|
226
|
+
) -> list[Facet]:
|
|
227
|
+
"""构造各维度的分面。
|
|
228
|
+
|
|
229
|
+
取值按计数降序,零计数的取值**保留**(调用方把它们置灰):
|
|
230
|
+
一个勾选框就重排一次的列表是点不准的。已被选中但本轮计数为零的取值
|
|
231
|
+
同样保留,否则使用者只能靠手改地址栏才能取消它。
|
|
232
|
+
"""
|
|
233
|
+
facets: list[Facet] = []
|
|
234
|
+
for dimension in DIMENSIONS:
|
|
235
|
+
counts = await _counts(
|
|
236
|
+
session, repository_id, selection, dimension, branch_alias=branch_alias
|
|
237
|
+
)
|
|
238
|
+
chosen = set(getattr(selection, dimension) or [])
|
|
239
|
+
|
|
240
|
+
if dimension == "author":
|
|
241
|
+
counts = {name: count for name, count in counts.items() if not is_bot(name)}
|
|
242
|
+
|
|
243
|
+
items = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
|
244
|
+
limit = _AUTHOR_LIMIT if dimension == "author" else DIMENSION_LIMIT
|
|
245
|
+
items = items[:limit]
|
|
246
|
+
|
|
247
|
+
# 选中的取值即使掉出前 N 也要保留
|
|
248
|
+
for value in chosen:
|
|
249
|
+
if value not in counts and value not in {item[0] for item in items}:
|
|
250
|
+
items.append((value, 0))
|
|
251
|
+
|
|
252
|
+
# label 只放原始值:门禁阶段与内容类别的中文名在前端已有单一来源
|
|
253
|
+
# (GATE_STAGE_META / KIND_META),后端再维护一份必然漂移。
|
|
254
|
+
facets.append(
|
|
255
|
+
Facet(
|
|
256
|
+
key=dimension,
|
|
257
|
+
label=DIMENSION_LABELS[dimension],
|
|
258
|
+
values=[
|
|
259
|
+
FacetValue(value=value, label=value, count=count, selected=value in chosen)
|
|
260
|
+
for value, count in items
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return facets
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
DIMENSION_LABELS: dict[str, str] = {
|
|
268
|
+
"stage": "门禁阶段",
|
|
269
|
+
"kind": "内容类别",
|
|
270
|
+
"branch": "目标分支",
|
|
271
|
+
"author": "作者",
|
|
272
|
+
"attention": "关注规则",
|
|
273
|
+
}
|