@kernel-sig/console 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,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)