@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,386 @@
1
+ """AI 配置与分析 API。
2
+
3
+ 模型端点是全局配置(一个部署通常接一两家中转),因此不挂在仓库路径下;
4
+ 分析结果则按对象查询,因为维护者是"看着某个 PR 想要它的摘要"。
5
+ """
6
+
7
+ import uuid
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, Query, status
11
+
12
+ from app.api.deps import CurrentUserDep, SessionDep, SettingsDep, require_permission
13
+ from app.core.exceptions import ConflictError, NotFoundError
14
+ from app.core.permissions import Permission
15
+ from app.models.ai import AITask, AnalysisStatus, LLMProvider, TaskRouting
16
+ from app.models.classification import SubjectType
17
+ from app.models.credential import CredentialKind
18
+ from app.models.pull_request import PullRequest
19
+ from app.schemas.ai import (
20
+ AIAnalysisRead,
21
+ AIUsage,
22
+ LLMProviderCreate,
23
+ LLMProviderRead,
24
+ LLMProviderUpdate,
25
+ ProviderTestResult,
26
+ TaskRoutingRead,
27
+ TaskRoutingUpsert,
28
+ )
29
+ from app.schemas.common import MessageResponse
30
+ from app.services import ai_service, credential_service, repository_service
31
+
32
+ router = APIRouter(tags=["ai"])
33
+
34
+ ManageCredentials = Annotated[object, Depends(require_permission(Permission.MANAGE_CREDENTIALS))]
35
+ ManagePrompts = Annotated[object, Depends(require_permission(Permission.MANAGE_PROMPTS))]
36
+ Trigger = Annotated[object, Depends(require_permission(Permission.TRIGGER_ANALYSIS))]
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # 模型端点
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @router.get("/ai/providers", response_model=list[LLMProviderRead])
45
+ async def list_providers(session: SessionDep, _: ManageCredentials) -> list[LLMProviderRead]:
46
+ """列出已配置的模型端点。
47
+
48
+ 模型端点配置属于管理操作(涉及 API Key 与费用),因此与凭据同一权限;
49
+ 普通使用者只看得到分析结果,看不到平台接了哪些模型。
50
+ 响应里不含任何密钥材料,只有指向凭据表的 id。
51
+ """
52
+ providers = await ai_service.list_providers(session)
53
+ return [LLMProviderRead.model_validate(provider) for provider in providers]
54
+
55
+
56
+ @router.post("/ai/providers", response_model=LLMProviderRead, status_code=status.HTTP_201_CREATED)
57
+ async def create_provider(
58
+ payload: LLMProviderCreate,
59
+ session: SessionDep,
60
+ settings: SettingsDep,
61
+ user: CurrentUserDep,
62
+ _: ManageCredentials,
63
+ ) -> LLMProviderRead:
64
+ """新增模型端点。
65
+
66
+ API Key 走与 AtomGit token 相同的凭据表加密存储 ——
67
+ 明文永不回显,接口只暴露指纹。
68
+ """
69
+ from sqlalchemy import select
70
+
71
+ existing = await session.scalar(select(LLMProvider).where(LLMProvider.name == payload.name))
72
+ if existing is not None:
73
+ raise ConflictError(f"模型端点 {payload.name} 已存在")
74
+
75
+ credential_id = None
76
+ if payload.api_key:
77
+ credential = await credential_service.create_credential(
78
+ session,
79
+ settings,
80
+ name=f"llm:{payload.name}",
81
+ kind=CredentialKind.LLM_API_KEY,
82
+ plaintext=payload.api_key,
83
+ meta={"provider": payload.name, "base_url": payload.base_url},
84
+ created_by=user.id,
85
+ )
86
+ credential_id = credential.id
87
+
88
+ provider = LLMProvider(
89
+ name=payload.name,
90
+ base_url=payload.base_url,
91
+ model=payload.model,
92
+ credential_id=credential_id,
93
+ temperature=payload.temperature,
94
+ max_tokens=payload.max_tokens,
95
+ timeout_seconds=payload.timeout_seconds,
96
+ is_default=payload.is_default,
97
+ )
98
+ session.add(provider)
99
+ await session.flush()
100
+
101
+ if payload.is_default:
102
+ await _clear_other_defaults(session, provider)
103
+
104
+ await session.commit()
105
+ return LLMProviderRead.model_validate(provider)
106
+
107
+
108
+ async def _clear_other_defaults(session: SessionDep, keep: LLMProvider) -> None:
109
+ """默认端点只能有一个:多默认等于没有默认。"""
110
+ from sqlalchemy import update
111
+
112
+ await session.execute(
113
+ update(LLMProvider)
114
+ .where(LLMProvider.id != keep.id, LLMProvider.is_default.is_(True))
115
+ .values(is_default=False)
116
+ )
117
+ keep.is_default = True
118
+
119
+
120
+ async def _require_provider(session: SessionDep, provider_id: uuid.UUID) -> LLMProvider:
121
+ provider = await ai_service.get_provider(session, provider_id)
122
+ if provider is None:
123
+ raise NotFoundError("模型端点不存在", resource="llm_provider", identifier=str(provider_id))
124
+ return provider
125
+
126
+
127
+ @router.patch("/ai/providers/{provider_id}", response_model=LLMProviderRead)
128
+ async def update_provider(
129
+ provider_id: uuid.UUID,
130
+ payload: LLMProviderUpdate,
131
+ session: SessionDep,
132
+ settings: SettingsDep,
133
+ _: ManageCredentials,
134
+ ) -> LLMProviderRead:
135
+ provider = await _require_provider(session, provider_id)
136
+
137
+ if payload.api_key is not None:
138
+ if provider.credential_id:
139
+ credential = await credential_service.require_credential(
140
+ session, provider.credential_id
141
+ )
142
+ await credential_service.update_credential_value(
143
+ session, settings, credential, payload.api_key
144
+ )
145
+ else:
146
+ credential = await credential_service.create_credential(
147
+ session,
148
+ settings,
149
+ name=f"llm:{provider.name}",
150
+ kind=CredentialKind.LLM_API_KEY,
151
+ plaintext=payload.api_key,
152
+ meta={"provider": provider.name, "base_url": provider.base_url},
153
+ )
154
+ provider.credential_id = credential.id
155
+
156
+ for field, value in payload.model_dump(exclude_unset=True, exclude={"api_key"}).items():
157
+ setattr(provider, field, value)
158
+
159
+ if payload.is_default:
160
+ await _clear_other_defaults(session, provider)
161
+
162
+ await session.commit()
163
+ return LLMProviderRead.model_validate(provider)
164
+
165
+
166
+ @router.post("/ai/providers/{provider_id}/test", response_model=ProviderTestResult)
167
+ async def test_provider(
168
+ provider_id: uuid.UUID,
169
+ session: SessionDep,
170
+ settings: SettingsDep,
171
+ _: ManageCredentials,
172
+ ) -> ProviderTestResult:
173
+ """实测端点连通性与凭据有效性。
174
+
175
+ 配置模型时最容易错的是 base_url 少一段 /v1、模型名拼错、
176
+ 以及 Key 与端点不配套 —— 只有真发一次请求才能确认。
177
+ """
178
+ provider = await _require_provider(session, provider_id)
179
+ result = await ai_service.test_provider(session, settings, provider)
180
+ await session.commit()
181
+ return ProviderTestResult(**result)
182
+
183
+
184
+ @router.delete("/ai/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
185
+ async def delete_provider(
186
+ provider_id: uuid.UUID, session: SessionDep, _: ManageCredentials
187
+ ) -> None:
188
+ from sqlalchemy import func, select
189
+
190
+ provider = await _require_provider(session, provider_id)
191
+ in_use = await session.scalar(
192
+ select(func.count()).select_from(TaskRouting).where(TaskRouting.provider_id == provider_id)
193
+ )
194
+ if in_use:
195
+ raise ConflictError(f"该端点仍被 {in_use} 条任务路由引用,请先改路由")
196
+
197
+ credential_id = provider.credential_id
198
+ await session.delete(provider)
199
+ await session.flush()
200
+ if credential_id:
201
+ credential = await credential_service.get_credential(session, credential_id)
202
+ if credential is not None:
203
+ await credential_service.delete_credential(session, credential)
204
+ await session.commit()
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # 任务路由
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ @router.get("/ai/routing", response_model=list[TaskRoutingRead])
213
+ async def list_routing(session: SessionDep, _: CurrentUserDep) -> list[TaskRoutingRead]:
214
+ rows = await ai_service.list_routing(session)
215
+ return [TaskRoutingRead.model_validate(row) for row in rows]
216
+
217
+
218
+ @router.put("/ai/routing/{task}", response_model=TaskRoutingRead)
219
+ async def upsert_routing(
220
+ task: AITask,
221
+ payload: TaskRoutingUpsert,
222
+ session: SessionDep,
223
+ _: ManagePrompts,
224
+ ) -> TaskRoutingRead:
225
+ """为某类任务指定模型。
226
+
227
+ 按任务分开配置是刻意的:分类这类轻任务用便宜模型就够,
228
+ 代码风险审查才值得用强模型。全部走大模型会让成本失控。
229
+ """
230
+ from sqlalchemy import select
231
+
232
+ await _require_provider(session, payload.provider_id)
233
+ if payload.fallback_provider_id:
234
+ await _require_provider(session, payload.fallback_provider_id)
235
+
236
+ routing = await session.scalar(select(TaskRouting).where(TaskRouting.task == task))
237
+ if routing is None:
238
+ routing = TaskRouting(task=task, provider_id=payload.provider_id)
239
+ session.add(routing)
240
+
241
+ routing.provider_id = payload.provider_id
242
+ routing.fallback_provider_id = payload.fallback_provider_id
243
+ routing.system_prompt = payload.system_prompt
244
+ routing.enabled = payload.enabled
245
+ await session.flush()
246
+ await session.commit()
247
+ return TaskRoutingRead.model_validate(routing)
248
+
249
+
250
+ @router.delete("/ai/routing/{task}", status_code=status.HTTP_204_NO_CONTENT)
251
+ async def delete_routing(task: AITask, session: SessionDep, _: ManagePrompts) -> None:
252
+ """删除路由后该任务回落到默认端点。"""
253
+ from sqlalchemy import delete, select
254
+
255
+ routing = await session.scalar(select(TaskRouting).where(TaskRouting.task == task))
256
+ if routing is None:
257
+ raise NotFoundError("该任务未配置路由", resource="task_routing", identifier=task.value)
258
+ await session.execute(delete(TaskRouting).where(TaskRouting.task == task))
259
+ await session.commit()
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # 分析
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ @router.get("/ai/analyses/{subject_type}/{subject_id}", response_model=list[AIAnalysisRead])
268
+ async def list_analyses(
269
+ subject_type: SubjectType,
270
+ subject_id: uuid.UUID,
271
+ session: SessionDep,
272
+ _: CurrentUserDep,
273
+ ) -> list[AIAnalysisRead]:
274
+ """某个 PR/Issue 的全部分析结果。
275
+
276
+ raw_response 不在此返回 —— 它是排查用的原始文本,体积大且对使用者无意义,
277
+ 需要时直接查库。
278
+ """
279
+ rows = await ai_service.list_analyses(session, subject_type, subject_id)
280
+ return [AIAnalysisRead.model_validate(row) for row in rows]
281
+
282
+
283
+ @router.post("/pulls/{pull_id}/ai/analyze", response_model=AIAnalysisRead)
284
+ async def analyze_pull(
285
+ pull_id: uuid.UUID,
286
+ session: SessionDep,
287
+ settings: SettingsDep,
288
+ user: CurrentUserDep,
289
+ _: Trigger,
290
+ task: Annotated[AITask, Query()] = AITask.SUMMARIZE,
291
+ force: Annotated[bool, Query(description="绕过幂等检查,重新调用模型")] = False,
292
+ ) -> AIAnalysisRead:
293
+ """对单个 PR 触发一次分析。
294
+
295
+ 同步执行而非入队:这是用户点了按钮在等结果的场景,
296
+ 排队到 worker 反而让等待时间不可预期。单条分析通常几秒到几十秒。
297
+ """
298
+ pull = await session.get(PullRequest, pull_id)
299
+ if pull is None:
300
+ raise NotFoundError("PR 不存在", resource="pull_request", identifier=str(pull_id))
301
+
302
+ repository = await repository_service.require_repository(session, pull.repository_id)
303
+ target = await ai_service.build_target(session, task=task, pull=pull)
304
+ analysis = await ai_service.execute(
305
+ session, settings, repository, target, triggered_by=user.id, force=force
306
+ )
307
+
308
+ # 分类任务的结果要写回分类表,否则"分析了但没生效"
309
+ if task is AITask.CLASSIFY and analysis.result:
310
+ from app.services import classification_service
311
+
312
+ await classification_service.apply_ai_result(
313
+ session,
314
+ repository_id=repository.id,
315
+ subject_type=SubjectType.PULL_REQUEST,
316
+ subject_id=pull.id,
317
+ kind=str(analysis.result.get("kind", "unknown")),
318
+ subsystem=analysis.result.get("subsystem"),
319
+ confidence=float(analysis.result.get("confidence") or 0.0),
320
+ reason=analysis.result.get("reason"),
321
+ )
322
+
323
+ await session.commit()
324
+ return AIAnalysisRead.model_validate(analysis)
325
+
326
+
327
+ @router.get("/ai/usage", response_model=AIUsage)
328
+ async def usage(
329
+ session: SessionDep,
330
+ _: CurrentUserDep,
331
+ repository_id: uuid.UUID | None = None,
332
+ days: Annotated[int, Query(ge=1, le=365)] = 30,
333
+ ) -> AIUsage:
334
+ """模型用量。这是唯一按次计费的开销,必须能被直接看到。"""
335
+ return AIUsage(
336
+ **await ai_service.usage_stats(session, repository_id=repository_id, since_days=days)
337
+ )
338
+
339
+
340
+ @router.get("/ai/tasks", response_model=list[str])
341
+ async def task_catalog(_: CurrentUserDep) -> list[str]:
342
+ """可选任务清单。让前端不必硬编码任务名。"""
343
+ return [task.value for task in AITask]
344
+
345
+
346
+ @router.post("/repositories/{repository_id}/ai/classify-missing")
347
+ async def classify_missing(
348
+ repository_id: uuid.UUID,
349
+ session: SessionDep,
350
+ settings: SettingsDep,
351
+ _: Trigger,
352
+ limit: Annotated[int, Query(ge=1, le=50)] = 5,
353
+ ) -> MessageResponse:
354
+ """用模型补判规则判不出来的分类。
355
+
356
+ 限流是刻意的:这是按次计费的操作,一次请求批量跑几十条会让
357
+ 一次误点产生难以预期的账单。
358
+ """
359
+ from app.services import classification_service
360
+
361
+ repository = await repository_service.require_repository(session, repository_id)
362
+
363
+ # 先确认有可用模型再挑目标:否则会挑出一批、逐个失败,
364
+ # 返回一句"已补判 0/5 条",使用者从中看不出是没配模型还是模型出错
365
+ await ai_service.resolve_provider(session, AITask.CLASSIFY)
366
+
367
+ targets = await classification_service.pulls_missing_classification(
368
+ session, repository.id, limit=limit
369
+ )
370
+ if not targets:
371
+ return MessageResponse(message="没有需要模型补判的条目")
372
+
373
+ succeeded = 0
374
+ failures: list[str] = []
375
+ for pull in targets:
376
+ analysis = await ai_service.classify_subject(session, settings, repository, pull=pull)
377
+ if analysis.status is AnalysisStatus.SUCCEEDED:
378
+ succeeded += 1
379
+ elif analysis.error:
380
+ failures.append(f"#{pull.number}: {analysis.error}")
381
+
382
+ await session.commit()
383
+ message = f"已补判 {succeeded}/{len(targets)} 条"
384
+ if failures:
385
+ message += f";失败原因示例:{failures[0]}"
386
+ return MessageResponse(message=message)
@@ -0,0 +1,48 @@
1
+ """统计分析 API。"""
2
+
3
+ import uuid
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Query
7
+
8
+ from app.api.deps import CurrentUserDep, SessionDep
9
+ from app.services import analytics_service, repository_service
10
+
11
+ router = APIRouter(tags=["analytics"])
12
+
13
+
14
+ @router.get("/repositories/{repository_id}/analytics/summary")
15
+ async def repository_summary(
16
+ repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
17
+ ) -> dict:
18
+ """一次性返回仪表盘所需的全部指标。
19
+
20
+ 刻意做成单个端点而非多个细粒度端点:仪表盘首屏需要全部数据,
21
+ 拆成 8 个请求会让首屏从 1 次往返变成 8 次。
22
+ """
23
+ repository = await repository_service.require_repository(session, repository_id)
24
+ return await analytics_service.repository_summary(session, repository)
25
+
26
+
27
+ @router.get("/repositories/{repository_id}/analytics/reviewers")
28
+ async def reviewer_workload(
29
+ repository_id: uuid.UUID,
30
+ session: SessionDep,
31
+ _: CurrentUserDep,
32
+ days: Annotated[int, Query(ge=1, le=365)] = 90,
33
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
34
+ ) -> list[dict]:
35
+ """评审人工作量排行。
36
+
37
+ 数据来自评论派生的评审事件 —— 评审人身份只存在于评论中,
38
+ 标签给出的是汇总结果。
39
+ """
40
+ await repository_service.require_repository(session, repository_id)
41
+ return await analytics_service.reviewer_workload(session, repository_id, days=days, limit=limit)
42
+
43
+
44
+ @router.get("/repositories/{repository_id}/analytics/waiting")
45
+ async def waiting_stats(repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -> dict:
46
+ """待评审 PR 的等待时长分布,含 30 天以上积压量。"""
47
+ await repository_service.require_repository(session, repository_id)
48
+ return await analytics_service.waiting_time_stats(session, repository_id)
@@ -0,0 +1,142 @@
1
+ """关注项 API。
2
+
3
+ 维护者日常的入口就是这个队列:几百条待办按严重程度排好,
4
+ 点开能看到"为什么被判定为超期",认领后不再重复提示。
5
+ """
6
+
7
+ import uuid
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, Query
11
+
12
+ from app.api.deps import CurrentUserDep, SessionDep, require_permission
13
+ from app.core.exceptions import NotFoundError
14
+ from app.core.permissions import Permission
15
+ from app.domain.attention import RULE_DESCRIPTIONS, Rule
16
+ from app.models.attention import AttentionItem, AttentionRule, AttentionSeverity
17
+ from app.models.classification import SubjectType
18
+ from app.schemas.attention import AttentionCounts, AttentionItemRead, RuleDescription
19
+ from app.schemas.common import Page
20
+ from app.services import attention_service, repository_service
21
+
22
+ router = APIRouter(tags=["attention"])
23
+
24
+ Classify = Annotated[object, Depends(require_permission(Permission.CLASSIFY))]
25
+
26
+
27
+ @router.get("/repositories/{repository_id}/attention", response_model=Page[AttentionItemRead])
28
+ async def list_attention(
29
+ repository_id: uuid.UUID,
30
+ session: SessionDep,
31
+ _: CurrentUserDep,
32
+ severity: Annotated[list[AttentionSeverity] | None, Query()] = None,
33
+ rule: Annotated[list[AttentionRule] | None, Query()] = None,
34
+ subject_type: SubjectType | None = None,
35
+ include_resolved: bool = False,
36
+ unacknowledged_only: bool = False,
37
+ page: Annotated[int, Query(ge=1)] = 1,
38
+ per_page: Annotated[int, Query(ge=1, le=200)] = 50,
39
+ ) -> Page[AttentionItemRead]:
40
+ await repository_service.require_repository(session, repository_id)
41
+ rows, total = await attention_service.list_items(
42
+ session,
43
+ repository_id=repository_id,
44
+ severities=severity or (),
45
+ rules=rule or (),
46
+ subject_type=subject_type,
47
+ include_resolved=include_resolved,
48
+ unacknowledged_only=unacknowledged_only,
49
+ limit=per_page,
50
+ offset=(page - 1) * per_page,
51
+ )
52
+ return Page.create(
53
+ [AttentionItemRead.model_validate(row) for row in rows], total, page, per_page
54
+ )
55
+
56
+
57
+ @router.get("/repositories/{repository_id}/attention/counts", response_model=AttentionCounts)
58
+ async def attention_counts(
59
+ repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
60
+ ) -> AttentionCounts:
61
+ """未解决项的分布。顶部筛选栏与仪表盘共用这一个端点。"""
62
+ await repository_service.require_repository(session, repository_id)
63
+ by_severity = await attention_service.severity_counts(session, repository_id)
64
+ by_rule = await attention_service.rule_counts(session, repository_id)
65
+ return AttentionCounts(
66
+ by_severity=by_severity,
67
+ by_rule=by_rule,
68
+ open_total=sum(by_severity.values()),
69
+ )
70
+
71
+
72
+ @router.get("/pulls/{pull_id}/attention", response_model=list[AttentionItemRead])
73
+ async def pull_attention(
74
+ pull_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
75
+ ) -> list[AttentionItemRead]:
76
+ """该 PR 当前命中的关注项。
77
+
78
+ 详情页需要知道"这个 PR 为什么在待办队列里",而不是让维护者
79
+ 自己回队列页翻找。只返回未解决的 —— 已解决的对决策没有意义。
80
+ """
81
+ from app.models.pull_request import PullRequest
82
+
83
+ if await session.get(PullRequest, pull_id) is None:
84
+ raise NotFoundError("PR 不存在", resource="pull_request", identifier=str(pull_id))
85
+
86
+ rows = await attention_service.items_for_subject(session, SubjectType.PULL_REQUEST, pull_id)
87
+ return [AttentionItemRead.model_validate(row) for row in rows]
88
+
89
+
90
+ @router.get("/attention/rules", response_model=list[RuleDescription])
91
+ async def rule_catalog(_: CurrentUserDep) -> list[RuleDescription]:
92
+ """规则清单与含义。界面上"这条规则是什么意思"的答案来自这里。
93
+
94
+ 规则说明只在领域层维护一份,不在前端复刻 —— 两处各写一份必然漂移。
95
+ """
96
+ return [
97
+ RuleDescription(rule=AttentionRule(rule.value), description=RULE_DESCRIPTIONS[rule])
98
+ for rule in Rule
99
+ ]
100
+
101
+
102
+ async def _require_item(session: SessionDep, item_id: uuid.UUID) -> AttentionItem:
103
+ item = await attention_service.get_item(session, item_id)
104
+ if item is None:
105
+ raise NotFoundError("关注项不存在", resource="attention_item", identifier=str(item_id))
106
+ return item
107
+
108
+
109
+ @router.post("/attention/{item_id}/acknowledge", response_model=AttentionItemRead)
110
+ async def acknowledge(
111
+ item_id: uuid.UUID, session: SessionDep, user: CurrentUserDep
112
+ ) -> AttentionItemRead:
113
+ """认领一条待办。
114
+
115
+ 不需要额外权限:认领只是"我要处理这条",任何登录用户都可以。
116
+ 已被他人认领时不覆盖 —— 两个人同时在看同一件事时,
117
+ 后者应该看到前者已经接手,而不是把它悄悄变成自己的。
118
+ """
119
+ item = await _require_item(session, item_id)
120
+ await attention_service.acknowledge(session, item, user.id)
121
+ await session.commit()
122
+ return AttentionItemRead.model_validate(item)
123
+
124
+
125
+ @router.delete("/attention/{item_id}/acknowledge", response_model=AttentionItemRead)
126
+ async def release(item_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -> AttentionItemRead:
127
+ item = await _require_item(session, item_id)
128
+ await attention_service.release(session, item)
129
+ await session.commit()
130
+ return AttentionItemRead.model_validate(item)
131
+
132
+
133
+ @router.post("/repositories/{repository_id}/attention/recompute")
134
+ async def recompute_attention(repository_id: uuid.UUID, session: SessionDep, _: Classify) -> dict:
135
+ """立即重算关注项。
136
+
137
+ 定时任务每 15 分钟跑一次;手动入口是为"刚同步完想立刻看到结果"准备的。
138
+ """
139
+ repository = await repository_service.require_repository(session, repository_id)
140
+ stats = await attention_service.compute_for_repository(session, repository)
141
+ await session.commit()
142
+ return stats
@@ -0,0 +1,106 @@
1
+ """认证端点。"""
2
+
3
+ from fastapi import APIRouter, Request, Response, status
4
+
5
+ from app.api.deps import ACCESS_COOKIE, REFRESH_COOKIE, CurrentUserDep, SessionDep, SettingsDep
6
+ from app.core.config import Settings
7
+ from app.core.exceptions import ForbiddenError, UnauthorizedError
8
+ from app.core.permissions import ROLE_PERMISSIONS, Role
9
+ from app.schemas.auth import LoginRequest, SetupRequest, SetupStatus, TokenResponse
10
+ from app.schemas.user import CurrentUser, UserRead
11
+ from app.services import auth_service, user_service
12
+
13
+ router = APIRouter(prefix="/auth", tags=["auth"])
14
+
15
+
16
+ def _set_auth_cookies(response: Response, access: str, refresh: str, settings: Settings) -> None:
17
+ common = {
18
+ "httponly": True,
19
+ "samesite": "lax",
20
+ "secure": settings.cookie_secure,
21
+ "domain": settings.cookie_domain,
22
+ }
23
+ response.set_cookie(
24
+ ACCESS_COOKIE,
25
+ access,
26
+ max_age=settings.access_token_ttl_minutes * 60,
27
+ path="/",
28
+ **common,
29
+ )
30
+ # refresh cookie 只发给刷新端点,缩小暴露面
31
+ response.set_cookie(
32
+ REFRESH_COOKIE,
33
+ refresh,
34
+ max_age=settings.refresh_token_ttl_days * 86400,
35
+ path="/api/v1/auth",
36
+ **common,
37
+ )
38
+
39
+
40
+ @router.get("/setup-status", response_model=SetupStatus)
41
+ async def setup_status(session: SessionDep) -> SetupStatus:
42
+ """是否需要初始化(系统中无任何用户)。"""
43
+ return SetupStatus(needs_setup=await user_service.count_users(session) == 0)
44
+
45
+
46
+ @router.post("/setup", response_model=UserRead, status_code=status.HTTP_201_CREATED)
47
+ async def setup(payload: SetupRequest, session: SessionDep) -> UserRead:
48
+ """创建首个管理员。仅在系统无用户时可用。"""
49
+ if await user_service.count_users(session) > 0:
50
+ raise ForbiddenError("系统已完成初始化")
51
+
52
+ user = await user_service.create_user(
53
+ session,
54
+ username=payload.username,
55
+ display_name=payload.display_name,
56
+ email=payload.email,
57
+ password=payload.password,
58
+ role=Role.ADMIN,
59
+ )
60
+ await session.commit()
61
+ return UserRead.model_validate(user)
62
+
63
+
64
+ @router.post("/login", response_model=TokenResponse)
65
+ async def login(
66
+ payload: LoginRequest,
67
+ session: SessionDep,
68
+ settings: SettingsDep,
69
+ response: Response,
70
+ ) -> TokenResponse:
71
+ user = await auth_service.authenticate(session, payload.username, payload.password)
72
+ access, refresh = auth_service.issue_tokens(user, settings)
73
+ _set_auth_cookies(response, access, refresh, settings)
74
+ await session.commit()
75
+ return TokenResponse(access_token=access)
76
+
77
+
78
+ @router.post("/refresh", response_model=TokenResponse)
79
+ async def refresh(
80
+ request: Request,
81
+ session: SessionDep,
82
+ settings: SettingsDep,
83
+ response: Response,
84
+ ) -> TokenResponse:
85
+ token = request.cookies.get(REFRESH_COOKIE)
86
+ if not token:
87
+ raise UnauthorizedError("缺少刷新令牌")
88
+ user = await auth_service.resolve_refresh_token(session, token, settings)
89
+ access, new_refresh = auth_service.issue_tokens(user, settings)
90
+ _set_auth_cookies(response, access, new_refresh, settings)
91
+ return TokenResponse(access_token=access)
92
+
93
+
94
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
95
+ async def logout(response: Response) -> None:
96
+ response.delete_cookie(ACCESS_COOKIE, path="/")
97
+ response.delete_cookie(REFRESH_COOKIE, path="/api/v1/auth")
98
+
99
+
100
+ @router.get("/me", response_model=CurrentUser)
101
+ async def me(user: CurrentUserDep) -> CurrentUser:
102
+ base = UserRead.model_validate(user)
103
+ return CurrentUser(
104
+ **base.model_dump(),
105
+ permissions=sorted(ROLE_PERMISSIONS[user.role], key=lambda p: p.value),
106
+ )