@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,63 @@
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+ from app.core.config import get_settings
5
+
6
+ SECRET = "0123456789abcdef0123456789abcdef"
7
+
8
+
9
+ @pytest.fixture
10
+ def client(monkeypatch):
11
+ monkeypatch.setenv("KSC_SECRET_KEY", SECRET)
12
+ get_settings.cache_clear()
13
+ from app.main import create_app
14
+
15
+ with TestClient(create_app()) as c:
16
+ yield c
17
+ get_settings.cache_clear()
18
+
19
+
20
+ def test_healthz_returns_ok(client):
21
+ resp = client.get("/api/v1/healthz")
22
+ assert resp.status_code == 200
23
+ assert resp.json() == {"status": "ok"}
24
+
25
+
26
+ def test_healthz_sets_request_id_header(client):
27
+ resp = client.get("/api/v1/healthz")
28
+ assert len(resp.headers["x-request-id"]) == 32
29
+
30
+
31
+ def test_inbound_request_id_is_echoed(client):
32
+ resp = client.get("/api/v1/healthz", headers={"X-Request-ID": "abc123"})
33
+ assert resp.headers["x-request-id"] == "abc123"
34
+
35
+
36
+ def test_response_time_header_present(client):
37
+ resp = client.get("/api/v1/healthz")
38
+ assert float(resp.headers["x-response-time-ms"]) >= 0
39
+
40
+
41
+ def test_unauthenticated_request_returns_problem_details(client):
42
+ resp = client.get("/api/v1/auth/me")
43
+ assert resp.status_code == 401
44
+ body = resp.json()
45
+ assert body["code"] == "unauthorized"
46
+ assert body["status"] == 401
47
+ assert body["instance"] == "/api/v1/auth/me"
48
+ assert "type" in body
49
+
50
+
51
+ def test_validation_error_returns_problem_details(client):
52
+ """登录请求缺少 password 字段 → 422 且符合 Problem Details 格式。"""
53
+ resp = client.post("/api/v1/auth/login", json={"username": "x"})
54
+ assert resp.status_code == 422
55
+ body = resp.json()
56
+ assert body["code"] == "validation_error"
57
+ assert body["context"]["errors"][0]["field"] == "password"
58
+
59
+
60
+ def test_openapi_schema_is_available(client):
61
+ resp = client.get("/api/openapi.json")
62
+ assert resp.status_code == 200
63
+ assert "paths" in resp.json()
@@ -0,0 +1,320 @@
1
+ """LLM 网关行为测试。
2
+
3
+ 用 MockTransport 断言**实际发出的请求**(鉴权头、payload、重试次数),
4
+ 以及对各种畸形响应的处理 —— 模型返回不合规格式是常态,不是异常。
5
+ """
6
+
7
+ import json
8
+
9
+ import httpx
10
+ import pytest
11
+
12
+ from app.integrations.llm.provider import (
13
+ ChatMessage,
14
+ CompletionRequest,
15
+ LLMAuthError,
16
+ LLMRateLimitError,
17
+ LLMResponseError,
18
+ LLMUpstreamError,
19
+ OpenAICompatibleProvider,
20
+ extract_json,
21
+ )
22
+
23
+ BASE = "https://api.example.com/v1"
24
+
25
+
26
+ @pytest.fixture(autouse=True)
27
+ def _no_sleep(monkeypatch):
28
+ async def _instant(*args, **kwargs):
29
+ return None
30
+
31
+ monkeypatch.setattr(OpenAICompatibleProvider, "_backoff", staticmethod(_instant))
32
+
33
+
34
+ def make_provider(handler, **kwargs) -> OpenAICompatibleProvider:
35
+ return OpenAICompatibleProvider(
36
+ name="test",
37
+ base_url=BASE,
38
+ api_key="sk-test",
39
+ default_model="test-model",
40
+ transport=httpx.MockTransport(handler),
41
+ **kwargs,
42
+ )
43
+
44
+
45
+ def _ok(content: str, **usage) -> httpx.Response:
46
+ return httpx.Response(
47
+ 200,
48
+ json={
49
+ "model": "test-model",
50
+ "choices": [{"message": {"content": content}, "finish_reason": "stop"}],
51
+ "usage": {"prompt_tokens": 10, "completion_tokens": 20, **usage},
52
+ },
53
+ )
54
+
55
+
56
+ def _request(**overrides) -> CompletionRequest:
57
+ base: dict = {"messages": [ChatMessage(role="user", content="hi")], "model": "test-model"}
58
+ base.update(overrides)
59
+ return CompletionRequest(**base)
60
+
61
+
62
+ # --- 请求构造 ---------------------------------------------------------
63
+
64
+
65
+ async def test_sends_bearer_auth_header():
66
+ seen: dict[str, str] = {}
67
+
68
+ def handler(request: httpx.Request) -> httpx.Response:
69
+ seen.update(dict(request.headers))
70
+ return _ok("hello")
71
+
72
+ async with make_provider(handler) as provider:
73
+ await provider.complete(_request())
74
+
75
+ assert seen.get("authorization") == "Bearer sk-test"
76
+
77
+
78
+ async def test_omits_auth_header_when_no_key():
79
+ """本地 vLLM / Ollama 通常不校验鉴权,无 key 时不应发空头。"""
80
+ seen: dict[str, str] = {}
81
+
82
+ def handler(request: httpx.Request) -> httpx.Response:
83
+ seen.update(dict(request.headers))
84
+ return _ok("hi")
85
+
86
+ provider = OpenAICompatibleProvider(
87
+ name="local",
88
+ base_url="http://localhost:11434/v1",
89
+ api_key="",
90
+ transport=httpx.MockTransport(handler),
91
+ )
92
+ async with provider:
93
+ await provider.complete(_request())
94
+ assert "authorization" not in seen
95
+
96
+
97
+ async def test_posts_to_chat_completions():
98
+ captured: dict = {}
99
+
100
+ def handler(request: httpx.Request) -> httpx.Response:
101
+ captured["path"] = request.url.path
102
+ captured["body"] = json.loads(request.content)
103
+ return _ok("hi")
104
+
105
+ async with make_provider(handler) as provider:
106
+ await provider.complete(_request(temperature=0.3, max_tokens=100))
107
+
108
+ assert captured["path"] == "/v1/chat/completions"
109
+ body = captured["body"]
110
+ assert body["model"] == "test-model"
111
+ assert body["temperature"] == 0.3
112
+ assert body["max_tokens"] == 100
113
+ assert body["messages"] == [{"role": "user", "content": "hi"}]
114
+ assert body["stream"] is False
115
+
116
+
117
+ async def test_json_mode_sets_response_format():
118
+ captured: dict = {}
119
+
120
+ def handler(request: httpx.Request) -> httpx.Response:
121
+ captured.update(json.loads(request.content))
122
+ return _ok("{}")
123
+
124
+ async with make_provider(handler) as provider:
125
+ await provider.complete(_request(json_mode=True))
126
+
127
+ assert captured["response_format"] == {"type": "json_object"}
128
+
129
+
130
+ async def test_falls_back_to_default_model():
131
+ captured: dict = {}
132
+
133
+ def handler(request: httpx.Request) -> httpx.Response:
134
+ captured.update(json.loads(request.content))
135
+ return _ok("hi")
136
+
137
+ async with make_provider(handler) as provider:
138
+ await provider.complete(_request(model=""))
139
+
140
+ assert captured["model"] == "test-model"
141
+
142
+
143
+ async def test_empty_base_url_rejected():
144
+ with pytest.raises(ValueError, match="base_url"):
145
+ OpenAICompatibleProvider(name="x", base_url="", api_key="k")
146
+
147
+
148
+ # --- 响应解析 ---------------------------------------------------------
149
+
150
+
151
+ async def test_returns_content_and_usage():
152
+ def handler(request: httpx.Request) -> httpx.Response:
153
+ return _ok("结果", prompt_tokens=7, completion_tokens=3)
154
+
155
+ async with make_provider(handler) as provider:
156
+ result = await provider.complete(_request())
157
+
158
+ assert result.content == "结果"
159
+ assert result.prompt_tokens == 7
160
+ assert result.completion_tokens == 3
161
+ assert result.total_tokens == 10
162
+
163
+
164
+ async def test_tolerates_missing_usage():
165
+ def handler(request: httpx.Request) -> httpx.Response:
166
+ return httpx.Response(200, json={"choices": [{"message": {"content": "x"}}], "model": "m"})
167
+
168
+ async with make_provider(handler) as provider:
169
+ result = await provider.complete(_request())
170
+ assert result.total_tokens == 0
171
+
172
+
173
+ async def test_malformed_response_raises():
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ return httpx.Response(200, json={"unexpected": "shape"})
176
+
177
+ async with make_provider(handler) as provider:
178
+ with pytest.raises(LLMResponseError, match="不符合预期"):
179
+ await provider.complete(_request())
180
+
181
+
182
+ # --- 错误映射 ---------------------------------------------------------
183
+
184
+
185
+ async def test_401_raises_auth_error():
186
+ def handler(request: httpx.Request) -> httpx.Response:
187
+ return httpx.Response(401, json={"error": {"message": "invalid key"}})
188
+
189
+ async with make_provider(handler) as provider:
190
+ with pytest.raises(LLMAuthError, match="凭据无效"):
191
+ await provider.complete(_request())
192
+
193
+
194
+ async def test_429_preserves_rate_limit_semantics():
195
+ calls = {"n": 0}
196
+
197
+ def handler(request: httpx.Request) -> httpx.Response:
198
+ calls["n"] += 1
199
+ return httpx.Response(429, json={"error": {"message": "slow down"}})
200
+
201
+ async with make_provider(handler) as provider:
202
+ with pytest.raises(LLMRateLimitError):
203
+ await provider.complete(_request())
204
+ assert calls["n"] == 3
205
+
206
+
207
+ async def test_retries_then_succeeds():
208
+ calls = {"n": 0}
209
+
210
+ def handler(request: httpx.Request) -> httpx.Response:
211
+ calls["n"] += 1
212
+ return (
213
+ httpx.Response(500, json={"error": {"message": "boom"}})
214
+ if calls["n"] < 3
215
+ else _ok("ok")
216
+ )
217
+
218
+ async with make_provider(handler) as provider:
219
+ result = await provider.complete(_request())
220
+
221
+ assert calls["n"] == 3
222
+ assert result.content == "ok"
223
+ assert provider.retries == 2
224
+
225
+
226
+ async def test_network_error_wrapped():
227
+ def handler(request: httpx.Request) -> httpx.Response:
228
+ raise httpx.ConnectTimeout("timeout")
229
+
230
+ async with make_provider(handler) as provider:
231
+ with pytest.raises(LLMUpstreamError, match="网络请求失败"):
232
+ await provider.complete(_request())
233
+
234
+
235
+ async def test_error_message_is_surfaced():
236
+ """上游的错误文案要透传,否则排查时只剩一个状态码。"""
237
+
238
+ def handler(request: httpx.Request) -> httpx.Response:
239
+ return httpx.Response(400, json={"error": {"message": "model not found: gpt-x"}})
240
+
241
+ async with make_provider(handler) as provider:
242
+ with pytest.raises(LLMUpstreamError, match="model not found"):
243
+ await provider.complete(_request())
244
+
245
+
246
+ # --- 流式输出 ---------------------------------------------------------
247
+
248
+
249
+ async def test_stream_yields_content_chunks():
250
+ def handler(request: httpx.Request) -> httpx.Response:
251
+ lines = [
252
+ 'data: {"choices":[{"delta":{"content":"你"}}]}',
253
+ 'data: {"choices":[{"delta":{"content":"好"}}]}',
254
+ "data: [DONE]",
255
+ ]
256
+ return httpx.Response(200, text="\n".join(lines) + "\n")
257
+
258
+ async with make_provider(handler) as provider:
259
+ chunks = [chunk async for chunk in provider.stream(_request())]
260
+
261
+ assert chunks == ["你", "好"]
262
+
263
+
264
+ async def test_stream_ignores_non_data_lines():
265
+ def handler(request: httpx.Request) -> httpx.Response:
266
+ text = ': keep-alive\n\ndata: {"choices":[{"delta":{"content":"x"}}]}\ndata: [DONE]\n'
267
+ return httpx.Response(200, text=text)
268
+
269
+ async with make_provider(handler) as provider:
270
+ chunks = [chunk async for chunk in provider.stream(_request())]
271
+ assert chunks == ["x"]
272
+
273
+
274
+ # --- 结构化输出解析 ---------------------------------------------------
275
+
276
+
277
+ def test_extract_json_from_clean_output():
278
+ assert extract_json('{"kind": "bugfix"}')["kind"] == "bugfix"
279
+
280
+
281
+ def test_extract_json_from_fenced_output():
282
+ """模型常不顾"只输出 JSON"的指示,加上 ```json 围栏。"""
283
+ assert extract_json('```json\n{"kind": "cve"}\n```')["kind"] == "cve"
284
+
285
+
286
+ def test_extract_json_from_prose_wrapped_output():
287
+ text = '好的,我的判断如下:\n{"kind": "backport", "confidence": 0.9}\n希望有帮助。'
288
+ assert extract_json(text)["kind"] == "backport"
289
+
290
+
291
+ def test_extract_json_with_nested_braces():
292
+ text = '{"risks": [{"file": "net/x.c", "meta": {"line": 10}}]}'
293
+ assert extract_json(text)["risks"][0]["file"] == "net/x.c"
294
+
295
+
296
+ @pytest.mark.parametrize("text", ["", " ", "完全没有 JSON"])
297
+ def test_extract_json_fails_loudly_on_garbage(text):
298
+ """解析失败必须显式报错 —— 把散文塞进结构化字段比失败更糟。"""
299
+ with pytest.raises(LLMResponseError):
300
+ extract_json(text)
301
+
302
+
303
+ def test_extract_json_rejects_non_object():
304
+ with pytest.raises(LLMResponseError, match="期望 JSON 对象"):
305
+ extract_json("[1, 2, 3]")
306
+
307
+
308
+ def test_stats_tracked():
309
+ def handler(request: httpx.Request) -> httpx.Response:
310
+ return _ok("x")
311
+
312
+ import asyncio
313
+
314
+ async def run():
315
+ async with make_provider(handler) as provider:
316
+ await provider.complete(_request())
317
+ await provider.complete(_request())
318
+ return provider.requests
319
+
320
+ assert asyncio.run(run()) == 2
@@ -0,0 +1,169 @@
1
+ """SIG 例会纪要解析。
2
+
3
+ 样本取自 ``etherpad.openeuler.org/p/Kernel-meetings`` 的真实片段,包括
4
+ 几处刻意保留的原样错误(截断的链接、重复的议题编号、模板占位行)——
5
+ 解析器就是为容忍它们才这么写的。
6
+ """
7
+
8
+ from datetime import date
9
+
10
+ from app.domain.meeting import parse_pad, parse_progress_report
11
+
12
+ MEETING = """\
13
+ 【2026/9/4 14:00 Kernel SIG 双周例会】
14
+ 轮值主持:关文涛【轮值主持顺序:曾昭荣->桑力鹏->廖涛->吴腾达->陈玮->关文涛】
15
+ 下次轮值主持:曾昭荣
16
+ 会议链接:https://meeting.huaweicloud.com:36443/#/j/989418007
17
+ 会议纪要:https://etherpad.openeuler.org/p/Kernel-meetings
18
+
19
+ 一、上期遗留问题跟踪
20
+ 二、议题列表
21
+ 议题一:进展update(吴腾达)
22
+ 【OLK-6.6】
23
+ OLK-6.6开发分支tag更新到6.6.0-170.0.0,期间合入补丁590个
24
+ git log 6.6.0-167.0.0..6.6.0-170.0.0 --oneline --no-merges | wc -l
25
+ ISO镜像和update rpm包获取链接:
26
+ https://repo.openeuler.org/openEuler-24.03-LTS-SP1
27
+ https://repo.openeuler.org/openEuler-24.03-LTS-SP3
28
+ CVE: (124)
29
+ CVE-2026-74739,CVE-2026-74544,CVE-2026-74439
30
+ Bugfix:
31
+ nfs: use nfsi->rwsem to protect traversal of the file lock list
32
+ https://atomgit.com/openeuler/kernel/pull/26235
33
+ USB: serial: io_ti: fix heap overflow in get_manuf_info()
34
+ https://atomgit.com/openeuler/kernel/pull/
35
+ Feature:
36
+ KVM: arm64:Add pvqspinlock
37
+ https://gitcode.com/openeuler/kernel/pull/26044
38
+ LTS:
39
+ [OLK-6.6][linux-6.6.y sync] Backport 6.6.151-6.6.152 LTS Patches
40
+ https://atomgit.com/openeuler/kernel/pull/26808
41
+
42
+ 议题二:fs/exfat模块committer申报(池志凌)
43
+ 上游fs/exfat的贡献者
44
+ atomgit id:chizl
45
+
46
+ 议题X:其他议题在这里补充,格式:议题名称(汇报人)
47
+
48
+ 三、遗留问题
49
+ 某个待跟踪的问题
50
+ """
51
+
52
+
53
+ NESTED = """\
54
+ 【2026/9/4 14:00 Kernel SIG 双周例会】
55
+ 轮值主持:甲
56
+ 下次轮值主持:乙
57
+ 议题一:进展update(甲)
58
+ 【OLK-5.10】
59
+ OLK-5.10开发分支tag更新到5.10.0-331.0.0,期间合入补丁123个
60
+ 【OLK-6.6】
61
+ OLK-6.6开发分支tag更新到6.6.0-165.0.0,期间合入补丁1712个
62
+ 【2026/8/21 Kernel SIG 双周例会】
63
+ 轮值主持:丙
64
+ 议题一:某议题(丁)
65
+ """
66
+
67
+
68
+ def test_parses_header_fields():
69
+ (meeting,) = parse_pad(MEETING)
70
+ assert meeting.date == date(2026, 9, 4)
71
+ assert meeting.time == "14:00"
72
+ assert meeting.title == "Kernel SIG 双周例会"
73
+ assert meeting.host == "关文涛"
74
+ assert meeting.next_host == "曾昭荣"
75
+ assert meeting.meeting_url == "https://meeting.huaweicloud.com:36443/#/j/989418007"
76
+ assert meeting.minutes_url == "https://etherpad.openeuler.org/p/Kernel-meetings"
77
+ assert meeting.host_order[0] == "曾昭荣"
78
+ assert len(meeting.host_order) == 6
79
+
80
+
81
+ def test_template_agenda_is_dropped():
82
+ """「其他议题在这里补充」是给人填的模板,收录它会凭空造出一个议题。"""
83
+ (meeting,) = parse_pad(MEETING)
84
+ titles = [agenda.title for agenda in meeting.agendas]
85
+ assert titles == ["进展update", "fs/exfat模块committer申报"]
86
+ assert all("在这里补充" not in title for title in titles)
87
+
88
+
89
+ def test_agenda_owner_in_fullwidth_and_halfwidth_parens():
90
+ (meeting,) = parse_pad(MEETING)
91
+ assert meeting.agendas[0].owner == "吴腾达"
92
+ assert meeting.agendas[1].owner == "池志凌"
93
+
94
+
95
+ def test_legacy_section_captured():
96
+ (meeting,) = parse_pad(MEETING)
97
+ assert meeting.legacy == "某个待跟踪的问题"
98
+
99
+
100
+ def test_meeting_without_time_still_parses():
101
+ text = "【2026/7/10 Kernel SIG 双周例会】\n轮值主持:桑力鹏\n"
102
+ (meeting,) = parse_pad(text)
103
+ assert (meeting.date, meeting.time, meeting.host) == (date(2026, 7, 10), None, "桑力鹏")
104
+
105
+
106
+ def test_progress_block_fields():
107
+ (meeting,) = parse_pad(MEETING)
108
+ (progress,) = meeting.agendas[0].progress
109
+ assert progress.branch == "OLK-6.6"
110
+ assert progress.from_tag == "6.6.0-167.0.0"
111
+ assert progress.to_tag == "6.6.0-170.0.0"
112
+ assert progress.patch_count == 590
113
+ assert len(progress.iso_urls) == 2
114
+ assert progress.cve_ids == ["CVE-2026-74739", "CVE-2026-74544", "CVE-2026-74439"]
115
+
116
+
117
+ def test_progress_groups_and_items():
118
+ (meeting,) = parse_pad(MEETING)
119
+ (progress,) = meeting.agendas[0].progress
120
+ assert [group.name for group in progress.groups] == ["Bugfix", "Feature", "LTS"]
121
+
122
+ bugfix = progress.groups[0]
123
+ assert [item.pull_number for item in bugfix.items] == [26235, None]
124
+ assert bugfix.items[0].title.startswith("nfs: use nfsi->rwsem")
125
+
126
+ # gitcode 与 atomgit 两个域名都在用,都要认出编号
127
+ assert progress.groups[1].items[0].pull_number == 26044
128
+ assert progress.groups[2].items[0].pull_number == 26808
129
+
130
+
131
+ def test_truncated_url_keeps_title_but_not_a_guessed_number():
132
+ """实测存在 ".../pull/" 这种被截断的链接。丢掉整条比猜个编号诚实。"""
133
+ (meeting,) = parse_pad(MEETING)
134
+ item = meeting.agendas[0].progress[0].groups[0].items[1]
135
+ assert item.pull_number is None
136
+ assert "io_ti" in item.title
137
+
138
+
139
+ def test_next_meeting_header_terminates_the_block():
140
+ """新会议标题必须截断上一场,否则一场会议会把之后所有会议吞掉。"""
141
+ meetings = parse_pad(NESTED)
142
+ assert [m.date for m in meetings] == [date(2026, 9, 4), date(2026, 8, 21)]
143
+ assert [p.branch for p in meetings[0].agendas[0].progress] == ["OLK-5.10", "OLK-6.6"]
144
+ assert meetings[0].agendas[0].progress[1].patch_count == 1712
145
+ assert meetings[1].agendas[0].title == "某议题"
146
+
147
+
148
+ def test_bracketed_prefix_is_a_patch_title_not_a_progress_block():
149
+ """「【OLK-6.6】drm: add ...」是带分支前缀的补丁标题,不是版本报告。"""
150
+ blocks = parse_progress_report("【OLK-6.6】drm: add display driver for EG210\n")
151
+ assert blocks == []
152
+
153
+
154
+ def test_iso_label_is_not_taken_as_a_group():
155
+ (meeting,) = parse_pad(MEETING)
156
+ names = [group.name for group in meeting.agendas[0].progress[0].groups]
157
+ assert not any("ISO" in name or "获取链接" in name for name in names)
158
+
159
+
160
+ def test_empty_and_garbage_input_is_survivable():
161
+ """上游是外部输入。解析失败只该少一条信息,不该让整页消失。"""
162
+ assert parse_pad("") == []
163
+ assert parse_pad("这里没有任何会议标题\n随便写点什么\n") == []
164
+
165
+
166
+ def test_invalid_date_drops_only_that_meeting():
167
+ text = "【2026/2/30 坏日期】\n轮值主持:甲\n【2026/3/6 Kernel SIG 双周例会】\n轮值主持:乙\n"
168
+ meetings = parse_pad(text)
169
+ assert [m.date for m in meetings] == [date(2026, 3, 6)]
@@ -0,0 +1,69 @@
1
+ import pytest
2
+
3
+ from app.core.permissions import (
4
+ ROLE_PERMISSIONS,
5
+ Permission,
6
+ Role,
7
+ role_has_permission,
8
+ )
9
+
10
+
11
+ def test_role_ordering_is_hierarchical():
12
+ assert Role.VIEWER < Role.REVIEWER < Role.COMMITTER < Role.MAINTAINER < Role.ADMIN
13
+
14
+
15
+ def test_viewer_can_only_read():
16
+ assert role_has_permission(Role.VIEWER, Permission.VIEW)
17
+ assert not role_has_permission(Role.VIEWER, Permission.TRIGGER_ANALYSIS)
18
+
19
+
20
+ def test_reviewer_can_trigger_analysis_but_not_write_back():
21
+ assert role_has_permission(Role.REVIEWER, Permission.TRIGGER_ANALYSIS)
22
+ assert not role_has_permission(Role.REVIEWER, Permission.WRITE_BACK)
23
+
24
+
25
+ def test_committer_can_classify_and_write_back():
26
+ assert role_has_permission(Role.COMMITTER, Permission.CLASSIFY)
27
+ assert role_has_permission(Role.COMMITTER, Permission.WRITE_BACK)
28
+ assert not role_has_permission(Role.COMMITTER, Permission.MANAGE_USERS)
29
+
30
+
31
+ def test_maintainer_can_manage_repository_not_users():
32
+ assert role_has_permission(Role.MAINTAINER, Permission.MANAGE_REPOSITORY)
33
+ assert not role_has_permission(Role.MAINTAINER, Permission.MANAGE_USERS)
34
+
35
+
36
+ def test_admin_has_every_permission():
37
+ for permission in Permission:
38
+ assert role_has_permission(Role.ADMIN, permission)
39
+
40
+
41
+ def test_higher_role_inherits_lower_role_permissions():
42
+ """层级继承:committer 拥有 reviewer 与 viewer 的全部权限。"""
43
+ assert ROLE_PERMISSIONS[Role.REVIEWER] <= ROLE_PERMISSIONS[Role.COMMITTER]
44
+ assert ROLE_PERMISSIONS[Role.COMMITTER] <= ROLE_PERMISSIONS[Role.MAINTAINER]
45
+ assert ROLE_PERMISSIONS[Role.MAINTAINER] <= ROLE_PERMISSIONS[Role.ADMIN]
46
+
47
+
48
+ def test_no_role_granted_permissions_outside_enum():
49
+ for role in Role:
50
+ assert ROLE_PERMISSIONS[role] <= set(Permission)
51
+
52
+
53
+ def test_role_accepts_string_name():
54
+ assert role_has_permission("ADMIN", Permission.MANAGE_USERS)
55
+ assert not role_has_permission("VIEWER", Permission.MANAGE_USERS)
56
+
57
+
58
+ def test_unknown_role_has_no_permission():
59
+ assert not role_has_permission("nonsense", Permission.VIEW)
60
+
61
+
62
+ def test_role_labels_are_localized():
63
+ assert Role.ADMIN.label == "管理员"
64
+ assert Role.MAINTAINER.label == "维护者"
65
+
66
+
67
+ @pytest.mark.parametrize("role", list(Role))
68
+ def test_every_role_can_view(role):
69
+ assert role_has_permission(role, Permission.VIEW)
@@ -0,0 +1,66 @@
1
+ """列表与分面接口的参数装配。
2
+
3
+ 这个测试的存在理由很具体:给 ``Selection`` 加了一个筛选维度后,只改了
4
+ ``_selection`` 的签名与其中一处调用点,另一处漏传。类型检查发现不了
5
+ (缺的是运行时才绑定的关键字参数),既有的单元测试也没覆盖到处理函数,
6
+ 最后是浏览器端 e2e 才抓出来 —— 表现是"整个列表页空掉",
7
+ 而报错信息指向框架内部。这本可以在提交前几秒内发现。
8
+
9
+ 所以这里用 ast 精确解析每个调用点传了哪些关键字,逐一比对。
10
+ 不用正则:正则版本我写错过一次,测试比被测代码还脆是没有意义的。
11
+ """
12
+
13
+ import ast
14
+ import inspect
15
+ from pathlib import Path
16
+
17
+ from app.api.v1 import pulls
18
+ from app.services import pull_query
19
+
20
+ SOURCE = Path(inspect.getfile(pulls))
21
+
22
+
23
+ def _call_sites() -> list[set[str]]:
24
+ """源码里所有 ``_selection(...)`` 调用所传的关键字集合。"""
25
+ tree = ast.parse(SOURCE.read_text(encoding="utf-8"))
26
+ sites: list[set[str]] = []
27
+ for node in ast.walk(tree):
28
+ if not isinstance(node, ast.Call):
29
+ continue
30
+ func = node.func
31
+ name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None)
32
+ if name == "_selection":
33
+ sites.append({kw.arg for kw in node.keywords if kw.arg})
34
+ return sites
35
+
36
+
37
+ def test_selection_covers_every_filter_dimension():
38
+ """Selection 的每个筛选维度都要能经由 _selection 传入。
39
+
40
+ ``q`` 与 ``search`` 是同一件事的两个名字(前者是查询参数惯例,
41
+ 后者是领域模型的措辞),因此单独映射,不做强行改名。
42
+ """
43
+ parameters = set(inspect.signature(pulls._selection).parameters) - {"return"}
44
+ alias = {"q": "search"}
45
+
46
+ uncovered = {
47
+ field
48
+ for field in pull_query.Selection.__dataclass_fields__
49
+ if field not in {alias.get(name, name) for name in parameters}
50
+ }
51
+ assert not uncovered, f"_selection 未覆盖这些筛选维度:{sorted(uncovered)}"
52
+
53
+
54
+ def test_all_call_sites_pass_the_same_parameters():
55
+ """两处调用(列表与分面)必须传同一组参数。
56
+
57
+ 只要有一处漏传,FastAPI 就会把 TypeError 变成 500,
58
+ 而使用者看到的是"列表空了"。
59
+ """
60
+ sites = _call_sites()
61
+ assert len(sites) >= 2, "预期至少两处调用(列表与分面)"
62
+
63
+ reference = max(sites, key=len)
64
+ for index, site in enumerate(sites):
65
+ missing = reference - site
66
+ assert not missing, f"第 {index + 1} 处 _selection 调用漏传:{sorted(missing)}"