@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,82 @@
|
|
|
1
|
+
"""PR 详情 Schema 的序列化契约。
|
|
2
|
+
|
|
3
|
+
两处最容易漂移的地方,各钉一个测试:
|
|
4
|
+
|
|
5
|
+
1. ``labels`` 落库的是 AtomGit 原始 JSON(含 id / repository_id / 时间戳),
|
|
6
|
+
Schema 只取 name 与 color。上游加字段不应导致详情接口 500。
|
|
7
|
+
2. ``is_bot`` 必须由服务端给出。前端不重复实现机器人识别 ——
|
|
8
|
+
两处各写一份规则必然漂移。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from app.models.pull_request import PRState, PullRequest
|
|
14
|
+
from app.schemas.pull_request import PRCommentRead, PullDetail
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _make_pull(**overrides) -> PullRequest:
|
|
18
|
+
defaults = {
|
|
19
|
+
"id": uuid.uuid4(),
|
|
20
|
+
"repository_id": uuid.uuid4(),
|
|
21
|
+
"number": 19344,
|
|
22
|
+
"title": "arm64: mm: fix page table teardown",
|
|
23
|
+
"body": None,
|
|
24
|
+
"state": PRState.OPEN,
|
|
25
|
+
"draft": False,
|
|
26
|
+
"author_login": "alice",
|
|
27
|
+
"author_avatar_url": None,
|
|
28
|
+
"target_branch": "OLK-6.6",
|
|
29
|
+
"label_names": ["sig/Kernel"],
|
|
30
|
+
"added_lines": 12,
|
|
31
|
+
"removed_lines": 3,
|
|
32
|
+
"changed_files": 1,
|
|
33
|
+
"comments_count": 0,
|
|
34
|
+
"merge_conflict": False,
|
|
35
|
+
"gate_stage": "ready_to_merge",
|
|
36
|
+
"atomgit_created_at": None,
|
|
37
|
+
"atomgit_updated_at": None,
|
|
38
|
+
"merged_at": None,
|
|
39
|
+
"html_url": None,
|
|
40
|
+
"labels": [],
|
|
41
|
+
}
|
|
42
|
+
return PullRequest(**{**defaults, **overrides})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_labels_keep_name_and_color_and_ignore_upstream_extras():
|
|
46
|
+
pull = _make_pull(
|
|
47
|
+
labels=[
|
|
48
|
+
{
|
|
49
|
+
"id": 18027,
|
|
50
|
+
"name": "sig/Kernel",
|
|
51
|
+
"color": "#2865E0",
|
|
52
|
+
"repository_id": 8744898,
|
|
53
|
+
"created_at": "2025-11-08T15:25:03+08:00",
|
|
54
|
+
},
|
|
55
|
+
{"name": "no-color-label", "color": None},
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
detail = PullDetail.model_validate(pull)
|
|
60
|
+
|
|
61
|
+
assert [(label.name, label.color) for label in detail.labels] == [
|
|
62
|
+
("sig/Kernel", "#2865E0"),
|
|
63
|
+
("no-color-label", None),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_labels_absent_yields_empty_list_not_none():
|
|
68
|
+
"""详情页直接迭代 labels,None 会让前端多一个分支。"""
|
|
69
|
+
assert PullDetail.model_validate(_make_pull(labels=None)).labels == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_comment_is_bot_defaults_to_false():
|
|
73
|
+
"""未知作者一律按人处理:误折叠人写的评审意见,比多展开一条机器人评论代价大。"""
|
|
74
|
+
comment = PRCommentRead(
|
|
75
|
+
comment_id="12345",
|
|
76
|
+
comment_type="diff_comment",
|
|
77
|
+
author_login="someone",
|
|
78
|
+
author_avatar_url=None,
|
|
79
|
+
body="正文",
|
|
80
|
+
atomgit_created_at=None,
|
|
81
|
+
)
|
|
82
|
+
assert comment.is_bot is False
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""版本与分支归类。
|
|
2
|
+
|
|
3
|
+
分支名是外部输入,规范化的每一条规则都对应实测数据里真实存在的一种写法。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from app.domain.release import (
|
|
9
|
+
BranchKind,
|
|
10
|
+
classify_branch,
|
|
11
|
+
compare_tags,
|
|
12
|
+
normalize_branch_name,
|
|
13
|
+
parse_kernel_tag,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
("raw", "expected"),
|
|
19
|
+
[
|
|
20
|
+
# 实测同一个交付有四种写法混用,都要收敛成一个名字
|
|
21
|
+
("openEuler-24.03-LTS_SP1", "openEuler-24.03-LTS-SP1"),
|
|
22
|
+
("openEuler-24.03-LTS-SP1", "openEuler-24.03-LTS-SP1"),
|
|
23
|
+
("openEuler-24.03-LTS-SP3", "openEuler-24.03-LTS-SP3"),
|
|
24
|
+
("openEuler-24.03_LTS", "openEuler-24.03-LTS"),
|
|
25
|
+
("openEuler-20.03-lts-SP1", "openEuler-20.03-LTS-SP1"),
|
|
26
|
+
# LTS / SP 以外的部分原样保留:分支名大小写敏感
|
|
27
|
+
("topic/foo_bar", "topic/foo_bar"),
|
|
28
|
+
("OLK-6.6", "OLK-6.6"),
|
|
29
|
+
("", ""),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
def test_normalize_branch_name(raw, expected):
|
|
33
|
+
assert normalize_branch_name(raw) == expected
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
("raw", "kind", "series", "sp", "base"),
|
|
38
|
+
[
|
|
39
|
+
("OLK-6.6", BranchKind.OLK, None, None, "6.6"),
|
|
40
|
+
("OLK-5.10", BranchKind.OLK, None, None, "5.10"),
|
|
41
|
+
("openEuler-24.03-LTS", BranchKind.LTS, "24.03-LTS", None, None),
|
|
42
|
+
("openEuler-24.03-LTS-SP4", BranchKind.LTS, "24.03-LTS", 4, None),
|
|
43
|
+
("openEuler-1.0-LTS", BranchKind.LTS, "1.0-LTS", None, None),
|
|
44
|
+
("openEuler-26.09", BranchKind.INNOVATION, "26.09", None, None),
|
|
45
|
+
# 认不出来不报错:临时分支是常态
|
|
46
|
+
("topic/foo-bar", BranchKind.OTHER, None, None, None),
|
|
47
|
+
("master", BranchKind.OTHER, None, None, None),
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
def test_classify_branch(raw, kind, series, sp, base):
|
|
51
|
+
spec = classify_branch(raw)
|
|
52
|
+
assert (spec.kind, spec.series, spec.sp, spec.upstream_base) == (kind, series, sp, base)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_olk_base_is_derived_from_name_only():
|
|
56
|
+
"""非 OLK 分支不猜上游基线。猜出来的基线会看起来权威但其实是编的。"""
|
|
57
|
+
assert classify_branch("openEuler-24.03-LTS").upstream_base is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_kernel_tag():
|
|
61
|
+
tag = parse_kernel_tag("6.6.0-170.0.0")
|
|
62
|
+
assert tag is not None
|
|
63
|
+
assert (tag.base, tag.build) == ("6.6", 170)
|
|
64
|
+
assert parse_kernel_tag("v6.6") is None
|
|
65
|
+
assert parse_kernel_tag("") is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize(
|
|
69
|
+
("left", "right", "expected"),
|
|
70
|
+
[
|
|
71
|
+
("6.6.0-170.0.0", "6.6.0-167.0.0", 1),
|
|
72
|
+
("6.6.0-167.0.0", "6.6.0-170.0.0", -1),
|
|
73
|
+
("6.6.0-170.0.0", "6.6.0-170.0.0", 0),
|
|
74
|
+
# 位数变化时字符串比较会翻车,构建号必须是整数比较
|
|
75
|
+
("6.6.0-99.0.0", "6.6.0-170.0.0", -1),
|
|
76
|
+
# 跨基线不比
|
|
77
|
+
("6.6.0-170.0.0", "5.10.0-331.0.0", None),
|
|
78
|
+
("6.6.0-170.0.0", "not-a-tag", None),
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
def test_compare_tags(left, right, expected):
|
|
82
|
+
assert compare_tags(left, right) == expected
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""评审解析与门禁判定。
|
|
2
|
+
|
|
3
|
+
门禁链条与标签取值均来自对真实仓库的实测(含已合并 PR 的标签组合),
|
|
4
|
+
不依赖官方文档描述。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from app.domain.review import (
|
|
12
|
+
EventType,
|
|
13
|
+
GateStage,
|
|
14
|
+
derive_gate,
|
|
15
|
+
is_bot,
|
|
16
|
+
parse_comment_command,
|
|
17
|
+
parse_events,
|
|
18
|
+
parse_label,
|
|
19
|
+
unrecognized_labels,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
T0 = datetime(2026, 9, 1, 10, 0, tzinfo=UTC)
|
|
23
|
+
|
|
24
|
+
# 实测:已合并 PR #27570 的标签组合
|
|
25
|
+
MERGED_LABELS = ["openeuler-cla/yes", "sig/Kernel", "ci_successful", "lgtm", "approved"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- 标签解析 ---------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_plain_lgtm_label():
|
|
32
|
+
event = parse_label("lgtm", "", T0)
|
|
33
|
+
assert event is not None
|
|
34
|
+
assert event.event_type is EventType.LGTM
|
|
35
|
+
assert event.actor_login == ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_cla_label():
|
|
39
|
+
event = parse_label("openeuler-cla/yes", "", T0)
|
|
40
|
+
assert event is not None
|
|
41
|
+
assert event.event_type is EventType.CLA_SIGNED
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_ci_labels():
|
|
45
|
+
assert parse_label("ci_successful", "", T0).event_type is EventType.CI_PASS
|
|
46
|
+
assert parse_label("ci_must_go_failed", "", T0).event_type is EventType.CI_FAIL
|
|
47
|
+
assert parse_label("ci_block_force_merge", "", T0).event_type is EventType.CI_FAIL
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_approved_label():
|
|
51
|
+
assert parse_label("approved", "", T0).event_type is EventType.APPROVE
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
"name",
|
|
56
|
+
["sig/Kernel", "kind/abandoned", "kind/lgtm-for-ci", "kind/kabi-need-review", "ai-suc"],
|
|
57
|
+
)
|
|
58
|
+
def test_irrelevant_labels_are_not_review_events(name):
|
|
59
|
+
"""sig/、kind/、ai- 前缀是分类标签,与评审门禁无关。
|
|
60
|
+
|
|
61
|
+
注意 kind/lgtm-for-ci 虽然名字含 lgtm,但它是 kind/ 分类标签,
|
|
62
|
+
不代表评审通过,不能计入 LGTM。
|
|
63
|
+
"""
|
|
64
|
+
assert parse_label(name, "", T0) is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- 评论指令 ---------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_comment_command_lgtm():
|
|
71
|
+
event = parse_comment_command("/lgtm", "someuser", T0)
|
|
72
|
+
assert event is not None
|
|
73
|
+
assert event.event_type is EventType.LGTM
|
|
74
|
+
assert event.source == "comment"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_comment_command_approve():
|
|
78
|
+
assert parse_comment_command("/approve", "u", T0).event_type is EventType.APPROVE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_comment_command_tolerates_surrounding_whitespace():
|
|
82
|
+
assert parse_comment_command(" /lgtm ", "u", T0) is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_comment_command_inside_multiline_body():
|
|
86
|
+
body = "看起来没问题。\n\n/lgtm\n\n谢谢"
|
|
87
|
+
assert parse_comment_command(body, "u", T0) is not None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_plain_comment_is_not_a_command():
|
|
91
|
+
assert parse_comment_command("这个改动我看了,没问题", "u", T0) is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_prose_mentioning_command_is_not_a_command():
|
|
95
|
+
"""只有独占一行的指令才算数,避免误判讨论文本。"""
|
|
96
|
+
assert parse_comment_command("please run /lgtm later", "u", T0) is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- 机器人识别 -------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize(
|
|
103
|
+
"login",
|
|
104
|
+
[
|
|
105
|
+
"openeuler-ci-bot",
|
|
106
|
+
"some-bot",
|
|
107
|
+
"x_bot",
|
|
108
|
+
"ci-bot",
|
|
109
|
+
"kernel[bot]",
|
|
110
|
+
# 实测存在的命名:以 robot 而非 bot 结尾,仅匹配后缀会漏掉
|
|
111
|
+
"devstation-robot",
|
|
112
|
+
"ci-robot1",
|
|
113
|
+
"openeuler-infra-bot",
|
|
114
|
+
"bot.user",
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
def test_bot_detection(login):
|
|
118
|
+
assert is_bot(login)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_bot_comments_are_excluded_from_events():
|
|
122
|
+
"""机器人复述用户指令(如 LGTM 回执),若计入会系统性高估评审工作量。"""
|
|
123
|
+
labels = [("lgtm", T0)]
|
|
124
|
+
comments = [
|
|
125
|
+
("/lgtm", "openeuler-ci-bot", T0),
|
|
126
|
+
("/lgtm", "real-reviewer", T0),
|
|
127
|
+
]
|
|
128
|
+
events = parse_events(labels, comments)
|
|
129
|
+
actors = {e.actor_login for e in events}
|
|
130
|
+
assert "openeuler-ci-bot" not in actors
|
|
131
|
+
assert "real-reviewer" in actors
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_events_are_sorted_by_time():
|
|
135
|
+
labels = [("lgtm", T0 + timedelta(days=2))]
|
|
136
|
+
comments = [("/approve", "alice", T0)]
|
|
137
|
+
events = parse_events(labels, comments)
|
|
138
|
+
assert events[0].occurred_at < events[1].occurred_at
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- 门禁判定 ---------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_merged_pr_labels_satisfy_gate():
|
|
145
|
+
gate = derive_gate(MERGED_LABELS, state="merged")
|
|
146
|
+
assert gate.cla_signed
|
|
147
|
+
assert gate.ci_passed
|
|
148
|
+
assert gate.has_lgtm
|
|
149
|
+
assert gate.approved
|
|
150
|
+
assert gate.stage is GateStage.MERGED
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_empty_labels_block_at_cla():
|
|
154
|
+
gate = derive_gate([])
|
|
155
|
+
assert gate.stage is GateStage.CLA_PENDING
|
|
156
|
+
assert "CLA 未签署" in gate.blocking_reasons
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_ci_failure_blocks_even_with_lgtm_and_approve():
|
|
160
|
+
"""CI 失败必须阻塞 —— 即使评审已通过。"""
|
|
161
|
+
labels = ["openeuler-cla/yes", "ci_must_go_failed", "lgtm", "approved"]
|
|
162
|
+
gate = derive_gate(labels)
|
|
163
|
+
assert gate.ci_failed
|
|
164
|
+
assert not gate.ready_to_merge
|
|
165
|
+
assert gate.stage is GateStage.CI_FAILED
|
|
166
|
+
assert "CI 未通过" in gate.blocking_reasons
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_missing_lgtm_blocks_merge():
|
|
170
|
+
labels = ["openeuler-cla/yes", "ci_successful", "approved"]
|
|
171
|
+
gate = derive_gate(labels)
|
|
172
|
+
assert not gate.ready_to_merge
|
|
173
|
+
assert gate.stage is GateStage.REVIEW_PENDING
|
|
174
|
+
assert "缺少 LGTM" in gate.blocking_reasons
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_approved_label_is_not_a_precondition_for_merge():
|
|
178
|
+
"""approved 是合入时补记的标记,不应作为"可合入"的前置条件。
|
|
179
|
+
|
|
180
|
+
实测:200 个 open PR 中 0 个带 approved,而 200 个已合并 PR 中 199 个带。
|
|
181
|
+
若把 approved 设为前置条件,"可合入"在 open PR 上永不可能出现,
|
|
182
|
+
维护者最需要的待办队列会直接失效。
|
|
183
|
+
"""
|
|
184
|
+
labels = ["openeuler-cla/yes", "ci_successful", "lgtm"]
|
|
185
|
+
gate = derive_gate(labels)
|
|
186
|
+
assert not gate.is_approved
|
|
187
|
+
assert gate.ready_to_merge
|
|
188
|
+
assert gate.stage is GateStage.READY_TO_MERGE
|
|
189
|
+
assert "缺少 Approved" not in gate.blocking_reasons
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_approved_label_is_still_tracked():
|
|
193
|
+
"""不作为前置条件,但仍然要记录,供界面展示。"""
|
|
194
|
+
gate = derive_gate(["approved"])
|
|
195
|
+
assert gate.is_approved
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_full_gate_ready_to_merge():
|
|
199
|
+
gate = derive_gate(MERGED_LABELS, state="open")
|
|
200
|
+
assert gate.ready_to_merge
|
|
201
|
+
assert gate.stage is GateStage.READY_TO_MERGE
|
|
202
|
+
assert gate.blocking_reasons == []
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_draft_blocks_merge():
|
|
206
|
+
gate = derive_gate(MERGED_LABELS, is_draft=True)
|
|
207
|
+
assert not gate.ready_to_merge
|
|
208
|
+
assert gate.stage is GateStage.DRAFT
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_closed_pr_has_no_blocking_reasons():
|
|
212
|
+
"""已关闭的 PR 不需要"阻塞原因",展示它只会造成困惑。"""
|
|
213
|
+
gate = derive_gate(["openeuler-cla/yes"], state="closed")
|
|
214
|
+
assert gate.blocking_reasons == []
|
|
215
|
+
assert gate.stage is GateStage.CLOSED
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_blocking_reasons_follow_gate_order():
|
|
219
|
+
"""原因按门禁链条顺序排列,便于界面按序引导。"""
|
|
220
|
+
gate = derive_gate([])
|
|
221
|
+
reasons = gate.blocking_reasons
|
|
222
|
+
assert reasons.index("CLA 未签署") < reasons.index("CI 未完成")
|
|
223
|
+
assert reasons.index("CI 未完成") < reasons.index("缺少 LGTM")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- 诊断 -------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_unrecognized_labels_surface_unknown_ones():
|
|
230
|
+
"""社区新增标签时应能被发现,而不是让评审状态静默出错。"""
|
|
231
|
+
labels = ["openeuler-cla/yes", "lgtm", "sig/Kernel", "kind/abandoned", "brand-new-label"]
|
|
232
|
+
assert unrecognized_labels(labels) == ["brand-new-label"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_unrecognized_labels_empty_for_known_set():
|
|
236
|
+
assert unrecognized_labels(MERGED_LABELS) == []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- 对真实仓库数据的验证 ---------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _load_fixture(name: str):
|
|
243
|
+
import json
|
|
244
|
+
from pathlib import Path
|
|
245
|
+
|
|
246
|
+
path = Path(__file__).parent / "fixtures" / "atomgit" / name
|
|
247
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_every_label_actually_in_use_is_recognized():
|
|
251
|
+
"""所有**实际在用**的标签都必须被识别或明确归入已知无关类别。
|
|
252
|
+
|
|
253
|
+
注意断言对象是「用过」的标签而非标签库全集:标签库中存在
|
|
254
|
+
创建后从未使用的残留项(实测 invi / need / res / ci 在 700 个 PR 上
|
|
255
|
+
零出现),把它们纳入规则只会造成误导。
|
|
256
|
+
|
|
257
|
+
若有未识别项,说明社区新增了标签而解析规则未跟上 ——
|
|
258
|
+
此时评审状态会静默出错,必须尽早发现。
|
|
259
|
+
"""
|
|
260
|
+
usage = _load_fixture("observed_label_usage.json")
|
|
261
|
+
in_use = [name for name in usage["labels"] if not name.startswith("_")]
|
|
262
|
+
in_use += [name for name in usage["user_scoped"] if not name.startswith("_")]
|
|
263
|
+
|
|
264
|
+
unknown = unrecognized_labels(in_use)
|
|
265
|
+
assert unknown == [], f"存在未识别的在用标签:{unknown}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_unused_library_labels_are_surfaced_not_guessed():
|
|
269
|
+
"""标签库中的未使用残留项应被报告为未知,而不是被赋予臆测的语义。"""
|
|
270
|
+
labels = _load_fixture("labels.json")
|
|
271
|
+
names = [entry["name"] for entry in labels]
|
|
272
|
+
unknown = set(unrecognized_labels(names))
|
|
273
|
+
# 这些标签实测在 700 个 PR 上零出现,描述为空,语义不明。
|
|
274
|
+
# 宁可报告为未知,也不猜测其含义。
|
|
275
|
+
assert {"invi", "need", "res", "ci"} <= unknown
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_real_open_pulls_derive_a_gate_stage():
|
|
279
|
+
"""对真实 open PR 列表跑一遍门禁推导,确保任何标签组合都不抛异常。"""
|
|
280
|
+
pulls = _load_fixture("pulls_page1.json")
|
|
281
|
+
stages: dict[str, int] = {}
|
|
282
|
+
for pull in pulls:
|
|
283
|
+
names = [label["name"] for label in pull.get("labels") or []]
|
|
284
|
+
gate = derive_gate(names, is_draft=pull.get("draft", False), state=pull["state"])
|
|
285
|
+
stages[gate.stage.value] = stages.get(gate.stage.value, 0) + 1
|
|
286
|
+
# 未合并的 PR 必须能说清楚卡在哪里
|
|
287
|
+
if gate.stage not in (GateStage.MERGED, GateStage.CLOSED):
|
|
288
|
+
assert gate.blocking_reasons, f"PR #{pull['number']} 未给出阻塞原因"
|
|
289
|
+
assert stages, "应至少推导出一个阶段"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_real_merged_pulls_satisfy_gate():
|
|
293
|
+
"""真实已合并的 PR,其标签应能满足门禁 —— 否则说明规则与实际情况不符。"""
|
|
294
|
+
pulls = _load_fixture("pulls_merged.json")
|
|
295
|
+
for pull in pulls:
|
|
296
|
+
names = [label["name"] for label in pull.get("labels") or []]
|
|
297
|
+
gate = derive_gate(names, state=pull["state"])
|
|
298
|
+
if gate.stage is GateStage.MERGED:
|
|
299
|
+
assert gate.cla_signed, f"已合并 PR #{pull['number']} 缺少 CLA 标签"
|
|
300
|
+
assert gate.has_lgtm, f"已合并 PR #{pull['number']} 缺少 LGTM 标签"
|
|
301
|
+
assert gate.approved, f"已合并 PR #{pull['number']} 缺少 approved 标签"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""用户 Schema 的序列化契约。
|
|
2
|
+
|
|
3
|
+
角色对外必须是**名称字符串**而非整数 —— 前端按名称做类型与权限门控,
|
|
4
|
+
整数会随枚举顺序调整而破坏兼容性。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from app.core.permissions import Permission, Role
|
|
14
|
+
from app.models.user import User
|
|
15
|
+
from app.schemas.user import CurrentUser, UserCreate, UserRead, UserUpdate
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _make_user(role: Role = Role.ADMIN) -> User:
|
|
19
|
+
return User(
|
|
20
|
+
id=uuid.uuid4(),
|
|
21
|
+
username="alice",
|
|
22
|
+
display_name="Alice",
|
|
23
|
+
email="alice@example.com",
|
|
24
|
+
password_hash="$argon2id$fake",
|
|
25
|
+
role=role,
|
|
26
|
+
is_active=True,
|
|
27
|
+
created_at=datetime.now(UTC),
|
|
28
|
+
last_login_at=None,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_role_serializes_as_name_not_int():
|
|
33
|
+
payload = UserRead.model_validate(_make_user(Role.ADMIN)).model_dump()
|
|
34
|
+
assert payload["role"] == "ADMIN"
|
|
35
|
+
assert payload["role"] != 50
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_role_round_trips_through_name():
|
|
39
|
+
data = UserRead.model_validate(_make_user(Role.MAINTAINER)).model_dump()
|
|
40
|
+
again = UserRead.model_validate({**data, "id": uuid.uuid4()})
|
|
41
|
+
assert again.role is Role.MAINTAINER
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_current_user_carries_permissions_as_strings():
|
|
45
|
+
base = UserRead.model_validate(_make_user(Role.ADMIN)).model_dump()
|
|
46
|
+
current = CurrentUser(**base, permissions=sorted(Permission, key=lambda p: p.value))
|
|
47
|
+
dumped = current.model_dump()
|
|
48
|
+
assert "manage_users" in dumped["permissions"]
|
|
49
|
+
assert dumped["role"] == "ADMIN"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_user_create_accepts_role_by_name():
|
|
53
|
+
payload = {
|
|
54
|
+
"username": "bob",
|
|
55
|
+
"display_name": "Bob",
|
|
56
|
+
"email": "bob@example.com",
|
|
57
|
+
"password": "long-enough-password",
|
|
58
|
+
"role": "COMMITTER",
|
|
59
|
+
}
|
|
60
|
+
assert UserCreate(**payload).role is Role.COMMITTER
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_user_create_accepts_role_by_numeric_value():
|
|
64
|
+
payload = {
|
|
65
|
+
"username": "bob",
|
|
66
|
+
"display_name": "Bob",
|
|
67
|
+
"email": "bob@example.com",
|
|
68
|
+
"password": "long-enough-password",
|
|
69
|
+
"role": 30,
|
|
70
|
+
}
|
|
71
|
+
assert UserCreate(**payload).role is Role.COMMITTER
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_user_create_rejects_unknown_role():
|
|
75
|
+
with pytest.raises(ValidationError, match="未知角色"):
|
|
76
|
+
UserCreate(
|
|
77
|
+
username="bob",
|
|
78
|
+
display_name="Bob",
|
|
79
|
+
email="bob@example.com",
|
|
80
|
+
password="long-enough-password",
|
|
81
|
+
role="SUPERUSER",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_user_create_rejects_illegal_username():
|
|
86
|
+
with pytest.raises(ValidationError, match="用户名只能包含"):
|
|
87
|
+
UserCreate(
|
|
88
|
+
username="bob space",
|
|
89
|
+
display_name="Bob",
|
|
90
|
+
email="bob@example.com",
|
|
91
|
+
password="long-enough-password",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_user_update_role_is_optional():
|
|
96
|
+
assert UserUpdate().role is None
|
|
97
|
+
assert UserUpdate(role="REVIEWER").role is Role.REVIEWER
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from datetime import UTC, datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import jwt
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from app.core.security import (
|
|
7
|
+
TokenType,
|
|
8
|
+
create_token,
|
|
9
|
+
decode_token,
|
|
10
|
+
hash_password,
|
|
11
|
+
needs_rehash,
|
|
12
|
+
verify_password,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
SECRET = "0123456789abcdef0123456789abcdef"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_password_hash_roundtrip():
|
|
19
|
+
hashed = hash_password("correct horse battery staple")
|
|
20
|
+
assert hashed != "correct horse battery staple"
|
|
21
|
+
assert verify_password("correct horse battery staple", hashed)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_password_verify_rejects_wrong_password():
|
|
25
|
+
assert not verify_password("wrong", hash_password("right"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_password_hash_uses_argon2id():
|
|
29
|
+
assert hash_password("x").startswith("$argon2id$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_password_hash_is_salted():
|
|
33
|
+
assert hash_password("same") != hash_password("same")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_verify_password_tolerates_malformed_hash():
|
|
37
|
+
assert not verify_password("anything", "not-a-valid-hash")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_needs_rehash_is_false_for_current_params():
|
|
41
|
+
assert needs_rehash(hash_password("x")) is False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_needs_rehash_is_true_for_garbage():
|
|
45
|
+
assert needs_rehash("garbage") is True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_access_token_roundtrip():
|
|
49
|
+
token = create_token(subject="user-1", token_type=TokenType.ACCESS, secret=SECRET)
|
|
50
|
+
payload = decode_token(token, secret=SECRET, expected_type=TokenType.ACCESS)
|
|
51
|
+
assert payload["sub"] == "user-1"
|
|
52
|
+
assert payload["type"] == "access"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_extra_claims_are_preserved():
|
|
56
|
+
token = create_token(
|
|
57
|
+
subject="u",
|
|
58
|
+
token_type=TokenType.ACCESS,
|
|
59
|
+
secret=SECRET,
|
|
60
|
+
extra_claims={"role": "ADMIN"},
|
|
61
|
+
)
|
|
62
|
+
payload = decode_token(token, secret=SECRET, expected_type=TokenType.ACCESS)
|
|
63
|
+
assert payload["role"] == "ADMIN"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_decode_rejects_wrong_token_type():
|
|
67
|
+
token = create_token(subject="u", token_type=TokenType.ACCESS, secret=SECRET)
|
|
68
|
+
with pytest.raises(jwt.InvalidTokenError):
|
|
69
|
+
decode_token(token, secret=SECRET, expected_type=TokenType.REFRESH)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_decode_rejects_wrong_secret():
|
|
73
|
+
token = create_token(subject="u", token_type=TokenType.ACCESS, secret=SECRET)
|
|
74
|
+
with pytest.raises(jwt.InvalidTokenError):
|
|
75
|
+
decode_token(token, secret="x" * 32, expected_type=TokenType.ACCESS)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_decode_rejects_expired_token():
|
|
79
|
+
token = create_token(
|
|
80
|
+
subject="u",
|
|
81
|
+
token_type=TokenType.ACCESS,
|
|
82
|
+
secret=SECRET,
|
|
83
|
+
expires_delta=timedelta(seconds=-1),
|
|
84
|
+
)
|
|
85
|
+
with pytest.raises(jwt.ExpiredSignatureError):
|
|
86
|
+
decode_token(token, secret=SECRET, expected_type=TokenType.ACCESS)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_token_carries_future_expiry():
|
|
90
|
+
token = create_token(subject="u", token_type=TokenType.ACCESS, secret=SECRET)
|
|
91
|
+
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
|
|
92
|
+
assert datetime.fromtimestamp(payload["exp"], tz=UTC) > datetime.now(UTC)
|