@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,498 @@
|
|
|
1
|
+
"""分类规则与子系统推导。
|
|
2
|
+
|
|
3
|
+
规则集基于 openEuler 与上游内核的实际提交惯例:
|
|
4
|
+
标题采用 "subsystem: description",正文用 "fixes #N" 关联 Issue。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from app.domain.classification import (
|
|
13
|
+
KIND_RULES,
|
|
14
|
+
TITLE_CONFIDENCE,
|
|
15
|
+
Kind,
|
|
16
|
+
_inclusion_kind,
|
|
17
|
+
classification_label,
|
|
18
|
+
classify_by_rules,
|
|
19
|
+
derive_subsystem,
|
|
20
|
+
extract_cve_ids,
|
|
21
|
+
extract_linked_issues,
|
|
22
|
+
extract_title_subsystem,
|
|
23
|
+
normalize_subsystem,
|
|
24
|
+
parse_commit_metadata,
|
|
25
|
+
subsystem_for_path,
|
|
26
|
+
subsystem_label,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "atomgit"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load(name: str):
|
|
33
|
+
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- CVE 提取 ---------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.parametrize(
|
|
40
|
+
("text", "expected"),
|
|
41
|
+
[
|
|
42
|
+
("Fix CVE-2026-89613 in netfilter", ["CVE-2026-89613"]),
|
|
43
|
+
("cve-2024-1234: fix", ["CVE-2024-1234"]),
|
|
44
|
+
("Multiple: CVE-2024-1234 and CVE-2025-5678", ["CVE-2024-1234", "CVE-2025-5678"]),
|
|
45
|
+
("no cve here", []),
|
|
46
|
+
# 序号部分不足 4 位不是合法 CVE 编号,不应被误当成一个
|
|
47
|
+
("CVE-2024-1 is malformed", []),
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
def test_extract_cve_ids(text, expected):
|
|
51
|
+
assert extract_cve_ids(text) == expected
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_extract_cve_deduplicates():
|
|
55
|
+
assert extract_cve_ids("CVE-2024-1234 and CVE-2024-1234") == ["CVE-2024-1234"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- 关联 Issue 提取 --------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize(
|
|
62
|
+
("body", "expected"),
|
|
63
|
+
[
|
|
64
|
+
("fixes #1234", [1234]),
|
|
65
|
+
("Fixes: #1234", [1234]),
|
|
66
|
+
("resolves #99", [99]),
|
|
67
|
+
("closes #7", [7]),
|
|
68
|
+
("fix #1 and fix #2", [1, 2]),
|
|
69
|
+
("no link", []),
|
|
70
|
+
],
|
|
71
|
+
)
|
|
72
|
+
def test_extract_linked_issues(body, expected):
|
|
73
|
+
assert extract_linked_issues(body) == expected
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_extract_linked_issues_deduplicates():
|
|
77
|
+
assert extract_linked_issues("fixes #5\nfixes #5") == [5]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- 标题子系统 -------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize(
|
|
84
|
+
("title", "expected"),
|
|
85
|
+
[
|
|
86
|
+
("net: fix use-after-free", "net"),
|
|
87
|
+
("mm/hugetlb: correct accounting", "mm/hugetlb"),
|
|
88
|
+
("KVM: arm64: fix uninitialised level", "KVM"),
|
|
89
|
+
("drivers/net/ethernet: add support", "drivers/net/ethernet"),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_extract_title_subsystem(title, expected):
|
|
93
|
+
assert extract_title_subsystem(title) == expected
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"title",
|
|
98
|
+
[
|
|
99
|
+
'Revert "net: something"',
|
|
100
|
+
"PATCH v2: fix",
|
|
101
|
+
"v3: update",
|
|
102
|
+
"no colon here",
|
|
103
|
+
"",
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
def test_non_subsystem_prefixes_are_rejected(title):
|
|
107
|
+
"""Revert / PATCH v2 / v3 这类前缀不是子系统,误判会污染统计。"""
|
|
108
|
+
assert extract_title_subsystem(title) is None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- 类型判定 ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_cve_wins_over_other_keywords():
|
|
115
|
+
"""标题同时含 'fix' 与 CVE 编号时,应判为 CVE —— 安全修复优先级更高。"""
|
|
116
|
+
result = classify_by_rules("net: fix CVE-2026-12345 use-after-free", "")
|
|
117
|
+
assert result.kind is Kind.CVE
|
|
118
|
+
assert result.confidence == 1.0
|
|
119
|
+
assert result.cve_ids == ["CVE-2026-12345"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_cve_detected_from_body_when_absent_in_title():
|
|
123
|
+
result = classify_by_rules("net: fix crash", "This addresses CVE-2026-9999.")
|
|
124
|
+
assert result.kind is Kind.CVE
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.parametrize(
|
|
128
|
+
("title", "expected"),
|
|
129
|
+
[
|
|
130
|
+
("net: fix use-after-free in xfrm", Kind.BUGFIX),
|
|
131
|
+
("mm: fix oob access", Kind.BUGFIX),
|
|
132
|
+
("backport upstream fix for page cache", Kind.BACKPORT),
|
|
133
|
+
("[OLK-6.6] cherry-pick fix for nvme", Kind.BACKPORT),
|
|
134
|
+
("net: add support for new NIC", Kind.FEATURE),
|
|
135
|
+
("fs: introduce new mount option", Kind.FEATURE),
|
|
136
|
+
("kernel: refactor scheduler init", Kind.REFACTOR),
|
|
137
|
+
("docs: update README", Kind.DOCS),
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
def test_kind_detection(title, expected):
|
|
141
|
+
assert classify_by_rules(title, "").kind is expected
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_unknown_when_nothing_matches():
|
|
145
|
+
result = classify_by_rules("net: adjust things", "some body text")
|
|
146
|
+
assert result.kind is Kind.UNKNOWN
|
|
147
|
+
assert result.confidence == 0.0
|
|
148
|
+
assert result.rule_name is None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_backport_has_higher_priority_than_bugfix():
|
|
152
|
+
"""'backport ... fix ...' 应判为 backport,否则 backport 统计会漏。"""
|
|
153
|
+
result = classify_by_rules("backport: fix memory leak", "")
|
|
154
|
+
assert result.kind is Kind.BACKPORT
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_rules_are_ordered_by_priority():
|
|
158
|
+
priorities = [r.priority for r in KIND_RULES]
|
|
159
|
+
assert priorities == sorted(priorities, reverse=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_result_carries_title_subsystem():
|
|
163
|
+
result = classify_by_rules("netfilter: fix leak", "")
|
|
164
|
+
assert result.title_subsystem == "netfilter"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- 子系统推导 -------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize(
|
|
171
|
+
("path", "expected"),
|
|
172
|
+
[
|
|
173
|
+
("net/core/sock.c", "net"),
|
|
174
|
+
("fs/ext4/inode.c", "fs"),
|
|
175
|
+
("mm/memory.c", "mm"),
|
|
176
|
+
("arch/arm64/kvm/hyp/pgtable.c", "arm64"),
|
|
177
|
+
("drivers/net/ethernet/hisilicon/hns3.c", "net-drivers"),
|
|
178
|
+
("kernel/sched/core.c", "kernel-core"),
|
|
179
|
+
("drivers/gpu/drm/amd/amdgpu.c", "drm"),
|
|
180
|
+
("unknown/file.c", None),
|
|
181
|
+
],
|
|
182
|
+
)
|
|
183
|
+
def test_subsystem_for_path(path, expected):
|
|
184
|
+
assert subsystem_for_path(path) == expected
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_longest_prefix_wins():
|
|
188
|
+
"""drivers/net 必须优先于 drivers/,否则网络驱动会被归入「其他驱动」。"""
|
|
189
|
+
assert subsystem_for_path("drivers/net/x.c") == "net-drivers"
|
|
190
|
+
assert subsystem_for_path("drivers/foo/x.c") == "drivers-other"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_derive_subsystem_picks_majority():
|
|
194
|
+
primary, related = derive_subsystem(["net/core/a.c", "net/core/b.c", "net/ipv4/c.c", "mm/d.c"])
|
|
195
|
+
assert primary == "net"
|
|
196
|
+
assert "mm" in related
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_derive_subsystem_keeps_related_for_context():
|
|
200
|
+
"""多数派不代表全部 —— 相关子系统一并返回,不做取舍。"""
|
|
201
|
+
primary, related = derive_subsystem(["drivers/a.c", "drivers/b.c", "mm/important.c"])
|
|
202
|
+
assert primary == "drivers-other"
|
|
203
|
+
assert "mm" in related
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_derive_subsystem_empty_input():
|
|
207
|
+
assert derive_subsystem([]) == (None, [])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_derive_subsystem_ignores_unknown_paths():
|
|
211
|
+
assert derive_subsystem(["unknown/a.c", "other/b.c"]) == (None, [])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_subsystem_label_is_localized():
|
|
215
|
+
assert subsystem_label("net") == "网络"
|
|
216
|
+
assert subsystem_label(None) == "未归类"
|
|
217
|
+
assert subsystem_label("some-unknown") == "some-unknown"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_classification_label_uses_kind_prefix():
|
|
221
|
+
"""与仓库中已有的 kind/abandoned 等标签保持同一命名习惯。"""
|
|
222
|
+
assert classification_label(Kind.CVE) == "kind/cve"
|
|
223
|
+
assert classification_label(Kind.BACKPORT) == "kind/backport"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --- 对真实标题的验证 -------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_real_open_pr_titles_are_classified():
|
|
230
|
+
"""对真实 PR 标题跑一遍,确认没有异常且分布合理。"""
|
|
231
|
+
pulls = _load("pulls_page1.json")
|
|
232
|
+
kinds: dict[str, int] = {}
|
|
233
|
+
for pull in pulls:
|
|
234
|
+
result = classify_by_rules(pull["title"], pull.get("body") or "")
|
|
235
|
+
kinds[result.kind.value] = kinds.get(result.kind.value, 0) + 1
|
|
236
|
+
assert kinds, "应至少归类出一条"
|
|
237
|
+
assert sum(kinds.values()) == len(pulls)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_real_pr_bodies_yield_linked_issues():
|
|
241
|
+
"""openEuler 的 PR 模板要求填 fixes #N,正文中应能提取到。"""
|
|
242
|
+
pulls = _load("pulls_page1.json")
|
|
243
|
+
total = sum(len(extract_linked_issues(pull.get("body") or "")) for pull in pulls)
|
|
244
|
+
assert total >= 0 # 部分 PR 未填,不应抛异常
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --- 标题子系统的归一化 -----------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.parametrize(
|
|
251
|
+
("declared", "expected"),
|
|
252
|
+
[
|
|
253
|
+
("net", "net"),
|
|
254
|
+
("net/sched", "net"),
|
|
255
|
+
("mm", "mm"),
|
|
256
|
+
("mm/hugetlb", "mm"),
|
|
257
|
+
("drivers/net/ethernet", "net-drivers"),
|
|
258
|
+
("arm64", "arm64"),
|
|
259
|
+
("arch/arm64", "arm64"),
|
|
260
|
+
("drm", "drm"),
|
|
261
|
+
("fs", "fs"),
|
|
262
|
+
("io_uring", "io_uring"),
|
|
263
|
+
("NET", "net"),
|
|
264
|
+
("/net/", "net"),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
def test_normalize_subsystem_maps_title_prefixes(declared, expected):
|
|
268
|
+
"""标题写法五花八门,但统计口径必须收敛到同一取值域。
|
|
269
|
+
|
|
270
|
+
否则按子系统统计时会出现 net / net/sched / net/core 三个近义项。
|
|
271
|
+
"""
|
|
272
|
+
assert normalize_subsystem(declared) == expected
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.mark.parametrize("declared", ["", None, " ", "randomsubsystem"])
|
|
276
|
+
def test_normalize_subsystem_rejects_unknown(declared):
|
|
277
|
+
"""不认识就返回 None —— 塞进一个假分类比留空更有害。"""
|
|
278
|
+
assert normalize_subsystem(declared) is None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- 缺陷形态的识别 ---------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.parametrize(
|
|
285
|
+
("title", "reason"),
|
|
286
|
+
[
|
|
287
|
+
("remoteproc: Prevent crash handling to race with rproc_del()", "崩溃与竞争"),
|
|
288
|
+
("net: fix use-after-free in xfrm_policy", "既有 fix 也有 UAF"),
|
|
289
|
+
("mm: add missing hugetlb_lock in __unmap_hugepage_range()", "缺锁=缺陷"),
|
|
290
|
+
("ipv4: validate IPV4_DEVCONF attributes properly", "原先校验不当"),
|
|
291
|
+
("xen: Don't track # of local child partitions", "否定句式"),
|
|
292
|
+
("HISI-CCA and HISI-CCA-DA bugfixs", "拼写不规范的 bugfix"),
|
|
293
|
+
("mm: fix memory leak on error path", "内存泄漏"),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
def test_defect_idioms_are_bugfixes(title, reason):
|
|
297
|
+
"""内核补丁标题描述"改了什么"而非"属于哪一类"。
|
|
298
|
+
|
|
299
|
+
只认 fix/bug 两个字会大量漏判(实测未命中样本里近半数属此类),
|
|
300
|
+
因此把缺陷的具体形态也纳入规则。
|
|
301
|
+
"""
|
|
302
|
+
assert classify_by_rules(title, "").kind is Kind.BUGFIX, reason
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize(
|
|
306
|
+
"title",
|
|
307
|
+
[
|
|
308
|
+
# \b 边界必须挡住 "trace" —— 它含 "race" 但无关缺陷
|
|
309
|
+
"ftrace: add trace event for sched_switch",
|
|
310
|
+
"net: improve xdp throughput",
|
|
311
|
+
"docs: document the new tracepoint",
|
|
312
|
+
# 同理 "debug" 含 "bug"
|
|
313
|
+
"rcu: add debug checks for expedited grace period",
|
|
314
|
+
],
|
|
315
|
+
)
|
|
316
|
+
def test_race_and_bug_patterns_respect_word_boundaries(title):
|
|
317
|
+
"""`\\brace\\b` / `\\bbug\\w*\\b` 不得命中 trace、debug。
|
|
318
|
+
|
|
319
|
+
这类子串误判会把成片的追踪、调试补丁误判为缺陷修复,
|
|
320
|
+
比漏判更糟 —— 误报会让维护者不再相信这套分类。
|
|
321
|
+
"""
|
|
322
|
+
assert classify_by_rules(title, "").kind is not Kind.BUGFIX
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@pytest.mark.parametrize(
|
|
326
|
+
"title",
|
|
327
|
+
[
|
|
328
|
+
"net: backport from stable commit abc123",
|
|
329
|
+
"mm: cherry-pick upstream fix",
|
|
330
|
+
"sched: 回合上游补丁",
|
|
331
|
+
"arm64: from linux-6.6 commit deadbeef",
|
|
332
|
+
],
|
|
333
|
+
)
|
|
334
|
+
def test_backport_annotations(title):
|
|
335
|
+
"""回合补丁需注明来源,这类标注比标题措辞更能说明性质。"""
|
|
336
|
+
assert classify_by_rules(title, "").kind is Kind.BACKPORT
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_merely_mentioning_upstream_is_not_a_backport():
|
|
340
|
+
"""标题提到 upstream 不等于回合 —— 陈述上游现状的补丁也是常见的。"""
|
|
341
|
+
result = classify_by_rules("net: align with upstream naming convention", "")
|
|
342
|
+
assert result.kind is not Kind.BACKPORT
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# --- 提交信息里的自声明元数据 -----------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@pytest.mark.parametrize(
|
|
349
|
+
("message", "expected"),
|
|
350
|
+
[
|
|
351
|
+
("openEuler inclusion\ncategory: feature\nbugzilla: ...", Kind.FEATURE),
|
|
352
|
+
("stable inclusion\nfrom stable-v6.6.64\ncategory: bugfix", Kind.BUGFIX),
|
|
353
|
+
("hulk inclusion\ncategory: cleanup", Kind.CLEANUP),
|
|
354
|
+
("category: performance", Kind.PERF),
|
|
355
|
+
# 实测存在拼写错误,容错而不是当成新类别
|
|
356
|
+
("category: featrue", Kind.FEATURE),
|
|
357
|
+
# 未收录的取值不应被臆测成某一类
|
|
358
|
+
("category: something-new", None),
|
|
359
|
+
],
|
|
360
|
+
)
|
|
361
|
+
def test_parse_commit_category(message, expected):
|
|
362
|
+
"""作者自填的 category 比标题关键词可靠:实测 1264 条提交里 97.7% 都带它。"""
|
|
363
|
+
declared, _ = parse_commit_metadata([message])
|
|
364
|
+
assert declared is expected
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@pytest.mark.parametrize(
|
|
368
|
+
("inclusion", "expected"),
|
|
369
|
+
[
|
|
370
|
+
("mainline", Kind.BACKPORT),
|
|
371
|
+
("stable", Kind.BACKPORT),
|
|
372
|
+
("driver", Kind.DRIVER_UPDATE),
|
|
373
|
+
("kunpeng", Kind.SOC_SUPPORT),
|
|
374
|
+
("hulk", Kind.OUT_OF_TREE),
|
|
375
|
+
("kylin", Kind.OUT_OF_TREE),
|
|
376
|
+
# 未收录的厂商名仍属非上游集成路径,归入自研而非猜测
|
|
377
|
+
("some-vendor", Kind.OUT_OF_TREE),
|
|
378
|
+
],
|
|
379
|
+
)
|
|
380
|
+
def test_inclusion_maps_to_kind(inclusion, expected):
|
|
381
|
+
_, parsed = parse_commit_metadata([f"{inclusion} inclusion\ncategory: feature"])
|
|
382
|
+
assert parsed == inclusion
|
|
383
|
+
assert _inclusion_kind(parsed) is expected
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_inclusion_beats_generic_title_rules():
|
|
387
|
+
"""来源标注要压过泛化性质。
|
|
388
|
+
|
|
389
|
+
"net: fix NULL deref" 按性质是 bugfix,但它标着 mainline inclusion ——
|
|
390
|
+
维护者需要知道的是"这是回合上游的补丁",两者评审方式不同。
|
|
391
|
+
"""
|
|
392
|
+
result = classify_by_rules("net: fix NULL deref in xfrm", "", ["mainline inclusion"])
|
|
393
|
+
assert result.kind is Kind.BACKPORT
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_specific_title_beats_inclusion():
|
|
397
|
+
"""具体形态要压过来源标注。
|
|
398
|
+
|
|
399
|
+
"Add Phytium i3c controller support" 标着 openEuler inclusion,
|
|
400
|
+
但"这是一条新增驱动"是更具体的判断。
|
|
401
|
+
"""
|
|
402
|
+
title = "[OLK-6.6] i3c: Add Phytium i3c controller support"
|
|
403
|
+
result = classify_by_rules(title, "", ["openEuler inclusion\ncategory: feature"])
|
|
404
|
+
assert result.kind is Kind.DRIVER_NEW
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_declared_category_is_the_last_resort():
|
|
408
|
+
"""标题判不出来时才用 category,且置信度仍然高于标题关键词匹配。"""
|
|
409
|
+
result = classify_by_rules("net: improve xdp throughput", "", ["category: feature"])
|
|
410
|
+
assert result.kind is Kind.FEATURE
|
|
411
|
+
assert result.confidence >= TITLE_CONFIDENCE
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# --- 内容类别 ---------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@pytest.mark.parametrize(
|
|
418
|
+
("title", "expected"),
|
|
419
|
+
[
|
|
420
|
+
("scsi: leapraid: update driver to v2.00.01.10", Kind.DRIVER_UPDATE),
|
|
421
|
+
("[OLK-6.6] Hygon: Add SM3/SM4 CIS driver", Kind.DRIVER_NEW),
|
|
422
|
+
("[OLK-6.6]Add Phytium NAND flash controller support", Kind.DRIVER_NEW),
|
|
423
|
+
("[OLK-5.10] x86/split_lock: Add support for Zhaoxin", Kind.SOC_SUPPORT),
|
|
424
|
+
("LoongArch: add TPM/SM3/SM4/EDAC support", Kind.SOC_SUPPORT),
|
|
425
|
+
("urma: fix session UAF and resource leak", Kind.OUT_OF_TREE),
|
|
426
|
+
("nfs/enfs: avoid resetting the fault state", Kind.OUT_OF_TREE),
|
|
427
|
+
],
|
|
428
|
+
)
|
|
429
|
+
def test_content_categories_from_title(title, expected):
|
|
430
|
+
"""标题层面的内容分类。用于详情尚未同步、拿不到提交信息时的降级判定。"""
|
|
431
|
+
assert classify_by_rules(title, "").kind is expected
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_soc_vendor_list_excludes_driver_namespaces():
|
|
435
|
+
"""hisi 是驱动命名空间而非处理器厂商,不能算作 SoC 支持。
|
|
436
|
+
|
|
437
|
+
实测 "HISI-CCA and HISI-CCA-DA bugfixs" 因此曾被错判成处理器支持。
|
|
438
|
+
"""
|
|
439
|
+
result = classify_by_rules("HISI-CCA and HISI-CCA-DA bugfixs", "")
|
|
440
|
+
assert result.kind is Kind.BUGFIX
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@pytest.mark.parametrize(
|
|
444
|
+
"title",
|
|
445
|
+
[
|
|
446
|
+
# 通用架构前缀是子系统而非厂商,不能算 SoC 支持
|
|
447
|
+
"arm64: Fix cpu usage error when force_wfi_trap set to 0",
|
|
448
|
+
"x86/mm: Move root_hpa validity checks",
|
|
449
|
+
"riscv: use pgd_offset for kernel mapping",
|
|
450
|
+
],
|
|
451
|
+
)
|
|
452
|
+
def test_generic_arch_prefix_is_not_soc_support(title):
|
|
453
|
+
assert classify_by_rules(title, "").kind is not Kind.SOC_SUPPORT
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_kind_enum_covers_model_enum():
|
|
457
|
+
"""领域层的 Kind 与数据库的 PRKind 必须一一对应。
|
|
458
|
+
|
|
459
|
+
少一个值的表现是"规则算出来了但写不进库",属于静默失败。
|
|
460
|
+
"""
|
|
461
|
+
from app.models.classification import PRKind
|
|
462
|
+
|
|
463
|
+
assert {k.value for k in Kind} == {k.value for k in PRKind}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_ai_kind_drops_cve_without_a_number():
|
|
467
|
+
"""模型不能判 cve。
|
|
468
|
+
|
|
469
|
+
规则层只要在正文里匹配到 CVE 编号就直接判 cve,所以走到 AI 补判这一步的
|
|
470
|
+
对象正文里本来就没有编号 —— 模型说 cve 一定是凭"这是安全修复"的印象,
|
|
471
|
+
而「CVE 数」是版本汇总里对外报的数字,混进未编号的加固会让那个数字失真。
|
|
472
|
+
"""
|
|
473
|
+
from app.models.classification import PRKind
|
|
474
|
+
from app.services.classification_service import normalize_ai_kind
|
|
475
|
+
|
|
476
|
+
kind, reason = normalize_ai_kind(PRKind.CVE, "标题提到权限检查")
|
|
477
|
+
assert kind is PRKind.BUGFIX
|
|
478
|
+
assert reason and "无 CVE 编号" in reason
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def test_ai_kind_passes_other_kinds_through_untouched():
|
|
482
|
+
from app.models.classification import PRKind
|
|
483
|
+
from app.services.classification_service import normalize_ai_kind
|
|
484
|
+
|
|
485
|
+
for kind in (PRKind.BUGFIX, PRKind.FEATURE, PRKind.BACKPORT, PRKind.UNKNOWN):
|
|
486
|
+
got, reason = normalize_ai_kind(kind, "原因")
|
|
487
|
+
assert got is kind
|
|
488
|
+
assert reason == "原因"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_ai_kind_handles_missing_reason():
|
|
492
|
+
"""模型没给理由时也要留下降级的痕迹,否则这条记录看起来毫无异常。"""
|
|
493
|
+
from app.models.classification import PRKind
|
|
494
|
+
from app.services.classification_service import normalize_ai_kind
|
|
495
|
+
|
|
496
|
+
kind, reason = normalize_ai_kind(PRKind.CVE, None)
|
|
497
|
+
assert kind is PRKind.BUGFIX
|
|
498
|
+
assert reason and "无 CVE 编号" in reason
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
|
|
4
|
+
from app.core.config import Settings, get_settings
|
|
5
|
+
|
|
6
|
+
VALID_SECRET = "0123456789abcdef0123456789abcdef"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_settings_loads_defaults(monkeypatch):
|
|
10
|
+
monkeypatch.setenv("KSC_SECRET_KEY", VALID_SECRET)
|
|
11
|
+
settings = Settings(_env_file=None)
|
|
12
|
+
assert settings.api_prefix == "/api/v1"
|
|
13
|
+
assert settings.log_level == "INFO"
|
|
14
|
+
assert settings.access_token_ttl_minutes == 30
|
|
15
|
+
assert settings.refresh_token_ttl_days == 7
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_secret_key_must_be_at_least_32_chars(monkeypatch):
|
|
19
|
+
monkeypatch.setenv("KSC_SECRET_KEY", "tooshort")
|
|
20
|
+
with pytest.raises(ValidationError):
|
|
21
|
+
Settings(_env_file=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_sync_database_url_uses_psycopg(monkeypatch):
|
|
25
|
+
monkeypatch.setenv("KSC_SECRET_KEY", VALID_SECRET)
|
|
26
|
+
monkeypatch.setenv("KSC_DATABASE_URL", "postgresql+asyncpg://u:p@h:5432/db")
|
|
27
|
+
settings = Settings(_env_file=None)
|
|
28
|
+
assert settings.sync_database_url == "postgresql+psycopg://u:p@h:5432/db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_settings_is_cached(monkeypatch):
|
|
32
|
+
monkeypatch.setenv("KSC_SECRET_KEY", VALID_SECRET)
|
|
33
|
+
get_settings.cache_clear()
|
|
34
|
+
assert get_settings() is get_settings()
|
|
35
|
+
get_settings.cache_clear()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_is_production_flag(monkeypatch):
|
|
39
|
+
monkeypatch.setenv("KSC_SECRET_KEY", VALID_SECRET)
|
|
40
|
+
monkeypatch.setenv("KSC_ENVIRONMENT", "production")
|
|
41
|
+
assert Settings(_env_file=None).is_production is True
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from app.core.crypto import CredentialCipher, fingerprint
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def cipher():
|
|
8
|
+
return CredentialCipher(secret_key="0123456789abcdef0123456789abcdef")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_roundtrip(cipher):
|
|
12
|
+
secret = "atomgit-token-abcdef123456"
|
|
13
|
+
assert cipher.decrypt(cipher.encrypt(secret)) == secret
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_handles_unicode_payload(cipher):
|
|
17
|
+
secret = "令牌-🔑-ümlaut"
|
|
18
|
+
assert cipher.decrypt(cipher.encrypt(secret)) == secret
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_ciphertext_differs_each_time(cipher):
|
|
22
|
+
"""相同明文两次加密应产生不同密文(随机 nonce)。"""
|
|
23
|
+
assert cipher.encrypt("same") != cipher.encrypt("same")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_ciphertext_does_not_contain_plaintext(cipher):
|
|
27
|
+
assert "super-secret-value" not in cipher.encrypt("super-secret-value")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_ciphertext_has_version_prefix(cipher):
|
|
31
|
+
assert cipher.encrypt("x").startswith("v1:")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_tampered_ciphertext_is_rejected(cipher):
|
|
35
|
+
version, nonce, payload = cipher.encrypt("original").split(":")
|
|
36
|
+
tampered = f"{version}:{nonce}:{payload[:-4]}AAAA"
|
|
37
|
+
with pytest.raises(ValueError, match="认证标签"):
|
|
38
|
+
cipher.decrypt(tampered)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_malformed_ciphertext_is_rejected(cipher):
|
|
42
|
+
with pytest.raises(ValueError, match="密文格式非法"):
|
|
43
|
+
cipher.decrypt("not-a-valid-token")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_unknown_version_is_rejected(cipher):
|
|
47
|
+
_, nonce, payload = cipher.encrypt("x").split(":")
|
|
48
|
+
with pytest.raises(ValueError, match="不支持的密文版本"):
|
|
49
|
+
cipher.decrypt(f"v9:{nonce}:{payload}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_different_key_cannot_decrypt():
|
|
53
|
+
a = CredentialCipher(secret_key="0" * 32)
|
|
54
|
+
b = CredentialCipher(secret_key="1" * 32)
|
|
55
|
+
with pytest.raises(ValueError):
|
|
56
|
+
b.decrypt(a.encrypt("secret"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_short_key_is_rejected():
|
|
60
|
+
with pytest.raises(ValueError, match=">= 32"):
|
|
61
|
+
CredentialCipher(secret_key="short")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_fingerprint_is_stable_and_short():
|
|
65
|
+
fp = fingerprint("token-abc")
|
|
66
|
+
assert fp == fingerprint("token-abc")
|
|
67
|
+
assert len(fp) == 16
|
|
68
|
+
assert fingerprint("token-xyz") != fp
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from app.core.exceptions import (
|
|
4
|
+
ConflictError,
|
|
5
|
+
ExternalServiceError,
|
|
6
|
+
ForbiddenError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
PlatformError,
|
|
9
|
+
RateLimitedError,
|
|
10
|
+
UnauthorizedError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_platform_error_carries_status_code_and_context():
|
|
16
|
+
err = NotFoundError("PR 不存在", resource="pull_request", identifier="123")
|
|
17
|
+
assert err.status_code == 404
|
|
18
|
+
assert err.code == "not_found"
|
|
19
|
+
assert "PR 不存在" in err.detail
|
|
20
|
+
assert err.context == {"resource": "pull_request", "identifier": "123"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize(
|
|
24
|
+
("exc_class", "expected_status", "expected_code"),
|
|
25
|
+
[
|
|
26
|
+
(ValidationError, 422, "validation_error"),
|
|
27
|
+
(UnauthorizedError, 401, "unauthorized"),
|
|
28
|
+
(ForbiddenError, 403, "forbidden"),
|
|
29
|
+
(NotFoundError, 404, "not_found"),
|
|
30
|
+
(ConflictError, 409, "conflict"),
|
|
31
|
+
(RateLimitedError, 429, "rate_limited"),
|
|
32
|
+
(ExternalServiceError, 502, "external_service_error"),
|
|
33
|
+
],
|
|
34
|
+
)
|
|
35
|
+
def test_error_status_and_code(exc_class, expected_status, expected_code):
|
|
36
|
+
err = exc_class("boom")
|
|
37
|
+
assert err.status_code == expected_status
|
|
38
|
+
assert err.code == expected_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_base_error_defaults():
|
|
42
|
+
err = PlatformError("generic")
|
|
43
|
+
assert err.status_code == 500
|
|
44
|
+
assert err.code == "internal_error"
|
|
45
|
+
assert err.title == "Internal Server Error"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_error_codes_are_distinct():
|
|
49
|
+
codes = {
|
|
50
|
+
ValidationError("x").code,
|
|
51
|
+
UnauthorizedError("x").code,
|
|
52
|
+
ForbiddenError("x").code,
|
|
53
|
+
NotFoundError("x").code,
|
|
54
|
+
ConflictError("x").code,
|
|
55
|
+
RateLimitedError("x").code,
|
|
56
|
+
ExternalServiceError("x").code,
|
|
57
|
+
}
|
|
58
|
+
assert len(codes) == 7
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_context_defaults_to_empty():
|
|
62
|
+
assert PlatformError("x").context == {}
|