@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,154 @@
1
+ """分类 / 关注项 / AI 三组接口的契约测试。
2
+
3
+ 这些接口大多需要真实数据库,因此这里只断言**不依赖数据的前置条件**:
4
+ 路由已注册、未登录一律 401、参数校验在进业务逻辑前拦下。
5
+ 认证与校验是最容易在重构中悄悄失守的部分,而它们失守时不会有任何报错。
6
+ """
7
+
8
+ import re
9
+ import uuid
10
+
11
+ import pytest
12
+ from fastapi.testclient import TestClient
13
+
14
+ from app.core.config import get_settings
15
+
16
+ SECRET = "0123456789abcdef0123456789abcdef"
17
+ SOME_UUID = str(uuid.UUID("00000000-0000-0000-0000-000000000001"))
18
+
19
+ # (方法, 路径, 请求体) —— 覆盖三组新增接口的每一个入口
20
+ ENDPOINTS: list[tuple[str, str, dict | None]] = [
21
+ ("GET", f"/api/v1/pulls/{SOME_UUID}/classification", None),
22
+ ("GET", f"/api/v1/pulls/{SOME_UUID}/attention", None),
23
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/classifications", None),
24
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/classifications/kinds", None),
25
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/classifications/subsystems", None),
26
+ ("POST", f"/api/v1/repositories/{SOME_UUID}/classifications/recompute", None),
27
+ ("PUT", f"/api/v1/classifications/{SOME_UUID}", {"kind": "cve"}),
28
+ ("DELETE", f"/api/v1/classifications/{SOME_UUID}/override", None),
29
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/attention", None),
30
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/attention/counts", None),
31
+ ("GET", "/api/v1/attention/rules", None),
32
+ ("POST", f"/api/v1/attention/{SOME_UUID}/acknowledge", None),
33
+ ("DELETE", f"/api/v1/attention/{SOME_UUID}/acknowledge", None),
34
+ ("POST", f"/api/v1/repositories/{SOME_UUID}/attention/recompute", None),
35
+ # 关注队列的订阅与分组:队列返回分组而非逐条,设置决定哪些规则进队列
36
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/attention/queue", None),
37
+ ("GET", f"/api/v1/repositories/{SOME_UUID}/attention/summary", None),
38
+ ("GET", "/api/v1/attention/rule-settings", None),
39
+ ("PUT", "/api/v1/attention/rule-settings/stale", {"enabled": True, "days": 30}),
40
+ ("GET", "/api/v1/ai/providers", None),
41
+ ("POST", "/api/v1/ai/providers", {"name": "x", "base_url": "http://x/v1", "model": "m"}),
42
+ ("PATCH", f"/api/v1/ai/providers/{SOME_UUID}", {"model": "m2"}),
43
+ ("DELETE", f"/api/v1/ai/providers/{SOME_UUID}", None),
44
+ ("POST", f"/api/v1/ai/providers/{SOME_UUID}/test", None),
45
+ ("GET", "/api/v1/ai/routing", None),
46
+ ("PUT", "/api/v1/ai/routing/classify", {"provider_id": SOME_UUID}),
47
+ ("DELETE", "/api/v1/ai/routing/classify", None),
48
+ ("GET", f"/api/v1/ai/analyses/pull_request/{SOME_UUID}", None),
49
+ ("GET", "/api/v1/ai/usage", None),
50
+ ("GET", "/api/v1/ai/tasks", None),
51
+ ("POST", f"/api/v1/pulls/{SOME_UUID}/ai/analyze", None),
52
+ ("POST", f"/api/v1/repositories/{SOME_UUID}/ai/classify-missing", None),
53
+ ]
54
+
55
+
56
+ @pytest.fixture
57
+ def client(monkeypatch):
58
+ monkeypatch.setenv("KSC_SECRET_KEY", SECRET)
59
+ get_settings.cache_clear()
60
+ from app.main import create_app
61
+
62
+ with TestClient(create_app()) as c:
63
+ yield c
64
+ get_settings.cache_clear()
65
+
66
+
67
+ @pytest.mark.parametrize(("method", "path", "payload"), ENDPOINTS)
68
+ def test_endpoint_is_registered(client, method, path, payload):
69
+ """路由存在。写错路径会退化成 404,与 401 很容易混淆,因此单独断言。"""
70
+ resp = client.request(method, path, json=payload)
71
+ assert resp.status_code != 404, f"{method} {path} 未注册"
72
+
73
+
74
+ @pytest.mark.parametrize(("method", "path", "payload"), ENDPOINTS)
75
+ def test_endpoint_requires_authentication(client, method, path, payload):
76
+ """未登录一律 401,且是 Problem Details。
77
+
78
+ 这里刻意不排除任何端点:AI 端点的 GET 会暴露平台接了哪些模型与用量,
79
+ 同样不该对匿名用户开放。
80
+ """
81
+ resp = client.request(method, path, json=payload)
82
+ assert resp.status_code == 401, f"{method} {path} 返回 {resp.status_code}"
83
+ assert resp.json()["code"] == "unauthorized"
84
+
85
+
86
+ def _template_to_regex(path: str) -> re.Pattern[str]:
87
+ """把 OpenAPI 的路径模板转成正则,用于匹配带具体取值的测试路径。"""
88
+ parts = re.split(r"\{[^}]+\}", path)
89
+ return re.compile("^" + "[^/]+".join(re.escape(part) for part in parts) + "$")
90
+
91
+
92
+ def test_all_new_endpoints_are_covered():
93
+ """新增接口必须被上面的清单覆盖到。
94
+
95
+ 漏掉一个入口意味着它既没有认证测试、也没有注册测试,这种"没人看着"
96
+ 的接口是权限漏洞最常出现的地方。反向也查:清单里不能有已删掉的路由,
97
+ 否则认证测试会变成对 404 的断言,看着是绿的其实什么都没测到。
98
+ """
99
+ get_settings.cache_clear()
100
+ import os
101
+
102
+ os.environ.setdefault("KSC_SECRET_KEY", SECRET)
103
+ from app.main import create_app
104
+
105
+ paths = create_app().openapi()["paths"]
106
+ get_settings.cache_clear()
107
+
108
+ registered = [
109
+ (method.upper(), _template_to_regex(path))
110
+ for path, operations in paths.items()
111
+ for method in operations
112
+ if "/ai/" in path or "attention" in path or "classification" in path
113
+ ]
114
+
115
+ uncovered = [
116
+ (method, path)
117
+ for method, path, _ in ENDPOINTS
118
+ if not any(
119
+ method == reg_method and pattern.match(path) for reg_method, pattern in registered
120
+ )
121
+ ]
122
+ assert not uncovered, f"以下接口未被认证测试覆盖:{uncovered}"
123
+
124
+ unmatched = [
125
+ (reg_method, pattern.pattern)
126
+ for reg_method, pattern in registered
127
+ if not any(method == reg_method and pattern.match(path) for method, path, _ in ENDPOINTS)
128
+ ]
129
+ assert not unmatched, f"以下已注册接口不在测试清单中:{unmatched}"
130
+
131
+
132
+ def test_task_routing_rejects_unknown_task(client):
133
+ """任务名是枚举,拼错的取值必须在进入业务逻辑前被拦下。"""
134
+ resp = client.put("/api/v1/ai/routing/not-a-task", json={"provider_id": SOME_UUID})
135
+ # 未登录时认证先于校验,两者都会拦下,关键是不能是 404
136
+ assert resp.status_code in (401, 422)
137
+
138
+
139
+ def test_classification_override_rejects_unknown_kind(client):
140
+ resp = client.put(f"/api/v1/classifications/{SOME_UUID}", json={"kind": "not-a-kind"})
141
+ assert resp.status_code in (401, 422)
142
+
143
+
144
+ def test_attention_list_rejects_unknown_severity(client):
145
+ resp = client.get(f"/api/v1/repositories/{SOME_UUID}/attention", params={"severity": "nope"})
146
+ assert resp.status_code in (401, 422)
147
+
148
+
149
+ def test_ai_analyze_accepts_task_and_force_params(client):
150
+ """参数形状不对会在 422 暴露出来,与认证失败区分开。"""
151
+ resp = client.post(
152
+ f"/api/v1/pulls/{SOME_UUID}/ai/analyze", params={"task": "bogus", "force": "true"}
153
+ )
154
+ assert resp.status_code in (401, 422)
@@ -0,0 +1,360 @@
1
+ """AtomGit 客户端行为测试。
2
+
3
+ 用 httpx.MockTransport 拦截请求,断言客户端发出的**实际**请求
4
+ (请求头、查询参数、重试次数),而非仅仅断言返回值。
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from app.integrations.atomgit.client import (
14
+ AtomGitAuthError,
15
+ AtomGitClient,
16
+ AtomGitNotFoundError,
17
+ AtomGitRateLimitError,
18
+ AtomGitUpstreamError,
19
+ )
20
+
21
+ FIXTURES = Path(__file__).parent / "fixtures" / "atomgit"
22
+ TOKEN = "test-token-abcdef"
23
+
24
+
25
+ def load(name: str):
26
+ return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
27
+
28
+
29
+ @pytest.fixture(autouse=True)
30
+ def _no_sleep(monkeypatch):
31
+ """重试退避在测试中不应真的等待。"""
32
+
33
+ async def _instant(*args, **kwargs):
34
+ return None
35
+
36
+ monkeypatch.setattr(AtomGitClient, "_sleep_backoff", staticmethod(_instant))
37
+
38
+
39
+ def make_client(handler, **kwargs) -> AtomGitClient:
40
+ return AtomGitClient(TOKEN, transport=httpx.MockTransport(handler), **kwargs)
41
+
42
+
43
+ # --- 认证 -------------------------------------------------------------
44
+
45
+
46
+ async def test_uses_private_token_header():
47
+ """关键:AtomGit 要求 private-token 头。
48
+
49
+ 官方文档所载的 Authorization: token 写法在 atomgit.com 上返回 400,
50
+ 此处断言客户端发出的确实是 private-token。
51
+ """
52
+ seen: dict[str, str] = {}
53
+
54
+ def handler(request: httpx.Request) -> httpx.Response:
55
+ seen.update(dict(request.headers))
56
+ return httpx.Response(200, json=load("repo.json"))
57
+
58
+ async with make_client(handler) as client:
59
+ await client.get_repository("openeuler", "kernel")
60
+
61
+ assert seen.get("private-token") == TOKEN
62
+ assert "authorization" not in seen
63
+
64
+
65
+ async def test_empty_token_is_rejected():
66
+ with pytest.raises(ValueError, match="token"):
67
+ AtomGitClient("")
68
+
69
+
70
+ async def test_401_raises_auth_error_with_actionable_message():
71
+ def handler(request: httpx.Request) -> httpx.Response:
72
+ return httpx.Response(401, json={"message": "Bad credentials"})
73
+
74
+ async with make_client(handler) as client:
75
+ with pytest.raises(AtomGitAuthError, match="凭据无效"):
76
+ await client.get_repository("openeuler", "kernel")
77
+
78
+
79
+ async def test_403_permission_raises_auth_error():
80
+ def handler(request: httpx.Request) -> httpx.Response:
81
+ return httpx.Response(403, json={"message": "Forbidden"})
82
+
83
+ async with make_client(handler) as client:
84
+ with pytest.raises(AtomGitAuthError, match="无权访问"):
85
+ await client.create_issue_comment("o", "r", 1, "hi")
86
+
87
+
88
+ async def test_403_rate_limit_raises_rate_limit_error():
89
+ """AtomGit 用 403 同时表达权限不足与限流,需按文案区分。"""
90
+
91
+ def handler(request: httpx.Request) -> httpx.Response:
92
+ return httpx.Response(403, json={"message": "Rate limit exceeded"})
93
+
94
+ async with make_client(handler) as client:
95
+ with pytest.raises(AtomGitRateLimitError):
96
+ await client.get_repository("o", "r")
97
+
98
+
99
+ async def test_404_raises_not_found():
100
+ def handler(request: httpx.Request) -> httpx.Response:
101
+ return httpx.Response(404, json={"message": "Not Found"})
102
+
103
+ async with make_client(handler) as client:
104
+ with pytest.raises(AtomGitNotFoundError):
105
+ await client.get_pull("o", "r", 999999)
106
+
107
+
108
+ async def test_429_raises_rate_limit():
109
+ def handler(request: httpx.Request) -> httpx.Response:
110
+ return httpx.Response(429, json={"message": "slow down"})
111
+
112
+ async with make_client(handler) as client:
113
+ with pytest.raises(AtomGitRateLimitError):
114
+ await client.get_repository("o", "r")
115
+
116
+
117
+ # --- 重试 -------------------------------------------------------------
118
+
119
+
120
+ async def test_retries_on_500_then_succeeds():
121
+ calls = {"n": 0}
122
+
123
+ def handler(request: httpx.Request) -> httpx.Response:
124
+ calls["n"] += 1
125
+ if calls["n"] < 3:
126
+ return httpx.Response(500, json={"message": "boom"})
127
+ return httpx.Response(200, json=load("repo.json"))
128
+
129
+ async with make_client(handler) as client:
130
+ repo = await client.get_repository("openeuler", "kernel")
131
+
132
+ assert calls["n"] == 3
133
+ assert repo.full_name == "openeuler/kernel"
134
+ assert client.stats.retries == 2
135
+
136
+
137
+ async def test_gives_up_after_max_attempts():
138
+ calls = {"n": 0}
139
+
140
+ def handler(request: httpx.Request) -> httpx.Response:
141
+ calls["n"] += 1
142
+ return httpx.Response(503, json={"message": "unavailable"})
143
+
144
+ async with make_client(handler) as client:
145
+ with pytest.raises(AtomGitUpstreamError, match="重试"):
146
+ await client.get_repository("o", "r")
147
+
148
+ assert calls["n"] == 4
149
+
150
+
151
+ async def test_does_not_retry_on_404():
152
+ calls = {"n": 0}
153
+
154
+ def handler(request: httpx.Request) -> httpx.Response:
155
+ calls["n"] += 1
156
+ return httpx.Response(404, json={"message": "Not Found"})
157
+
158
+ async with make_client(handler) as client:
159
+ with pytest.raises(AtomGitNotFoundError):
160
+ await client.get_pull("o", "r", 1)
161
+
162
+ assert calls["n"] == 1
163
+
164
+
165
+ async def test_network_error_is_retried_then_wrapped():
166
+ calls = {"n": 0}
167
+
168
+ def handler(request: httpx.Request) -> httpx.Response:
169
+ calls["n"] += 1
170
+ raise httpx.ConnectTimeout("timed out")
171
+
172
+ async with make_client(handler) as client:
173
+ with pytest.raises(AtomGitUpstreamError):
174
+ await client.get_repository("o", "r")
175
+
176
+ assert calls["n"] == 4
177
+
178
+
179
+ # --- 分页 -------------------------------------------------------------
180
+
181
+
182
+ async def test_pagination_reads_total_count_from_headers():
183
+ """实测总数在响应头 total_count / total_page,不在响应体。"""
184
+
185
+ def handler(request: httpx.Request) -> httpx.Response:
186
+ return httpx.Response(
187
+ 200,
188
+ json=load("pulls_page1.json"),
189
+ headers={"total_count": "1124", "total_page": "12"},
190
+ )
191
+
192
+ async with make_client(handler) as client:
193
+ page = await client.list_pulls("openeuler", "kernel", state="open")
194
+
195
+ assert page.total_count == 1124
196
+ assert page.total_page == 12
197
+ assert page.has_more is True
198
+ assert len(page.items) == 5
199
+
200
+
201
+ async def test_has_more_false_on_last_page():
202
+ def handler(request: httpx.Request) -> httpx.Response:
203
+ return httpx.Response(200, json=load("pulls_page1.json"), headers={"total_page": "1"})
204
+
205
+ async with make_client(handler) as client:
206
+ page = await client.list_pulls("o", "r")
207
+
208
+ assert page.has_more is False
209
+
210
+
211
+ async def test_per_page_is_clamped_to_maximum():
212
+ seen: dict[str, str] = {}
213
+
214
+ def handler(request: httpx.Request) -> httpx.Response:
215
+ seen.update(dict(request.url.params))
216
+ return httpx.Response(200, json=[])
217
+
218
+ async with make_client(handler) as client:
219
+ await client.list_pulls("o", "r", per_page=5000)
220
+
221
+ assert seen["per_page"] == "100"
222
+
223
+
224
+ async def test_state_and_page_params_are_sent():
225
+ seen: dict[str, str] = {}
226
+
227
+ def handler(request: httpx.Request) -> httpx.Response:
228
+ seen.update(dict(request.url.params))
229
+ return httpx.Response(200, json=[])
230
+
231
+ async with make_client(handler) as client:
232
+ await client.list_pulls("o", "r", state="merged", page=3, per_page=50)
233
+
234
+ assert seen == {"state": "merged", "page": "3", "per_page": "50"}
235
+
236
+
237
+ async def test_iter_pulls_stops_on_last_page():
238
+ pages = {"n": 0}
239
+
240
+ def handler(request: httpx.Request) -> httpx.Response:
241
+ pages["n"] += 1
242
+ return httpx.Response(
243
+ 200,
244
+ json=load("pulls_page1.json"),
245
+ headers={"total_page": "2"},
246
+ )
247
+
248
+ async with make_client(handler) as client:
249
+ collected = [p async for p in client.iter_pulls("o", "r", per_page=5)]
250
+
251
+ assert len(collected) == 2
252
+ assert pages["n"] == 2
253
+
254
+
255
+ # --- 解析错误可诊断 ---------------------------------------------------
256
+
257
+
258
+ async def test_schema_mismatch_reports_model_and_field():
259
+ """上游字段变化时必须明确指出是哪个模型、哪个字段,而非静默丢弃。"""
260
+
261
+ def handler(request: httpx.Request) -> httpx.Response:
262
+ return httpx.Response(200, json=[{"number": "not-a-number", "title": "x", "state": "open"}])
263
+
264
+ async with make_client(handler) as client:
265
+ with pytest.raises(AtomGitUpstreamError, match="AtomGitPullRequest"):
266
+ await client.list_pulls("o", "r")
267
+
268
+
269
+ async def test_non_list_response_is_reported():
270
+ def handler(request: httpx.Request) -> httpx.Response:
271
+ return httpx.Response(200, json={"unexpected": "object"})
272
+
273
+ async with make_client(handler) as client:
274
+ with pytest.raises(AtomGitUpstreamError, match="期望返回列表"):
275
+ await client.list_pulls("o", "r")
276
+
277
+
278
+ # --- 各资源端点 -------------------------------------------------------
279
+
280
+
281
+ async def test_get_pull_files_parses_real_fixture():
282
+ def handler(request: httpx.Request) -> httpx.Response:
283
+ assert request.url.path.endswith("/pulls/27650/files")
284
+ return httpx.Response(200, json=load("pull_files.json"))
285
+
286
+ async with make_client(handler) as client:
287
+ files = await client.get_pull_files("openeuler", "kernel", 27650)
288
+
289
+ assert files and files[0].filename
290
+ assert files[0].diff_text
291
+
292
+
293
+ async def test_get_pull_comments_uses_dedicated_endpoint():
294
+ def handler(request: httpx.Request) -> httpx.Response:
295
+ assert request.url.path.endswith("/pulls/27650/comments")
296
+ return httpx.Response(200, json=load("pull_comments.json"))
297
+
298
+ async with make_client(handler) as client:
299
+ comments = await client.get_pull_comments("openeuler", "kernel", 27650)
300
+
301
+ assert comments
302
+
303
+
304
+ async def test_create_issue_comment_posts_body():
305
+ captured: dict = {}
306
+
307
+ def handler(request: httpx.Request) -> httpx.Response:
308
+ assert "issues/123/comments" in str(request.url)
309
+ captured.update(json.loads(request.content))
310
+ return httpx.Response(201, json={"id": 1, "body": "hello", "user": {"login": "me"}})
311
+
312
+ async with make_client(handler) as client:
313
+ await client.create_issue_comment("o", "r", 123, "hello")
314
+
315
+ assert captured == {"body": "hello"}
316
+
317
+
318
+ async def test_issue_number_accepts_string():
319
+ """Issue 编号可能是字符串,客户端需接受。"""
320
+
321
+ def handler(request: httpx.Request) -> httpx.Response:
322
+ assert request.url.path.endswith("/issues/9980")
323
+ return httpx.Response(200, json=load("issue_detail.json"))
324
+
325
+ async with make_client(handler) as client:
326
+ issue = await client.get_issue("openeuler", "kernel", "9980")
327
+
328
+ assert issue.number == 9980
329
+
330
+
331
+ async def test_stats_track_endpoint_usage():
332
+ def handler(request: httpx.Request) -> httpx.Response:
333
+ return httpx.Response(200, json=[])
334
+
335
+ async with make_client(handler) as client:
336
+ await client.list_pulls("o", "r")
337
+ await client.list_pulls("o", "r")
338
+ await client.list_issues("o", "r")
339
+
340
+ assert client.stats.requests == 3
341
+ assert client.stats.by_endpoint["/repos/o/r/pulls"] == 2
342
+ assert client.stats.by_endpoint["/repos/o/r/issues"] == 1
343
+
344
+
345
+ async def test_base_url_is_configurable_for_gitcode():
346
+ """AtomGit 与 GitCode 为同一后端,token 通用,base_url 可切换。"""
347
+ seen: dict[str, str] = {}
348
+
349
+ def handler(request: httpx.Request) -> httpx.Response:
350
+ seen["host"] = request.url.host
351
+ return httpx.Response(200, json=load("repo.json"))
352
+
353
+ async with AtomGitClient(
354
+ TOKEN,
355
+ base_url="https://api.gitcode.com/api/v5",
356
+ transport=httpx.MockTransport(handler),
357
+ ) as client:
358
+ await client.get_repository("openeuler", "kernel")
359
+
360
+ assert seen["host"] == "api.gitcode.com"