@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,68 @@
1
+ """用户领域服务。"""
2
+
3
+ import uuid
4
+
5
+ from sqlalchemy import func, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.core.permissions import Role
9
+ from app.core.security import hash_password
10
+ from app.models.user import User
11
+
12
+
13
+ async def get_user_by_username(session: AsyncSession, username: str) -> User | None:
14
+ """按用户名查找(不区分大小写)。"""
15
+ stmt = select(User).where(func.lower(User.username) == username.lower())
16
+ return await session.scalar(stmt)
17
+
18
+
19
+ async def get_user(session: AsyncSession, user_id: uuid.UUID | str) -> User | None:
20
+ return await session.get(User, user_id)
21
+
22
+
23
+ async def list_users(
24
+ session: AsyncSession, *, offset: int = 0, limit: int = 50
25
+ ) -> tuple[list[User], int]:
26
+ total = await session.scalar(select(func.count()).select_from(User)) or 0
27
+ stmt = select(User).order_by(User.created_at.desc()).offset(offset).limit(limit)
28
+ users = list((await session.scalars(stmt)).all())
29
+ return users, total
30
+
31
+
32
+ async def create_user(
33
+ session: AsyncSession,
34
+ *,
35
+ username: str,
36
+ display_name: str,
37
+ email: str,
38
+ password: str,
39
+ role: Role = Role.VIEWER,
40
+ atomgit_login: str | None = None,
41
+ ) -> User:
42
+ if await get_user_by_username(session, username) is not None:
43
+ raise ValueError(f"用户名 {username} 已存在")
44
+
45
+ existing_email = await session.scalar(select(User).where(User.email == email))
46
+ if existing_email is not None:
47
+ raise ValueError(f"邮箱 {email} 已存在")
48
+
49
+ user = User(
50
+ username=username,
51
+ display_name=display_name,
52
+ email=email,
53
+ password_hash=hash_password(password),
54
+ role=role,
55
+ atomgit_login=atomgit_login,
56
+ )
57
+ session.add(user)
58
+ await session.flush()
59
+ return user
60
+
61
+
62
+ async def set_password(session: AsyncSession, user: User, new_password: str) -> None:
63
+ user.password_hash = hash_password(new_password)
64
+ await session.flush()
65
+
66
+
67
+ async def count_users(session: AsyncSession) -> int:
68
+ return await session.scalar(select(func.count()).select_from(User)) or 0
@@ -0,0 +1,389 @@
1
+ """后台 worker:定时轮询与异步分析。
2
+
3
+ 启动方式:``arq app.worker.WorkerSettings``(见 compose.yaml 的 worker 服务)。
4
+
5
+ 任务分四类,各自的节奏差异很大,**不合并**成一个"同步一切"的任务:
6
+ - 列表同步快且必须及时(10 分钟一次),否则界面上的状态是过期的
7
+ - 详情同步逐步推进(5 分钟一批),失败不该拖累列表
8
+ - 分类与关注项求值是本地计算,零外部开销,跟在下游跑即可
9
+ - AI 分析按次计费,必须限流并单独控制
10
+
11
+ **并发防护**:ARQ 的 cron 默认 unique=True,同一任务不会并发执行。
12
+ 手动触发与定时任务之间可能重叠,这一点由 sync_service 的乐观并发
13
+ (仅当远端 updated_at 不早于本地才覆盖)兜底,不会写坏数据。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import ClassVar
19
+
20
+ import httpx
21
+ import structlog
22
+ from arq import cron
23
+ from sqlalchemy.exc import SQLAlchemyError
24
+
25
+ from app.core.config import get_settings
26
+ from app.core.db import dispose_engine, init_engine, session_scope
27
+ from app.core.logging import configure_logging
28
+ from app.core.queue import redis_settings_from
29
+ from app.integrations.atomgit.client import AtomGitError
30
+
31
+ logger = structlog.get_logger(__name__)
32
+
33
+ # 每轮详情同步处理的 PR 数。单次请求量大时上游会限流,
34
+ # 小批量高频比大批量低频更稳。
35
+ DETAIL_BATCH = 50
36
+
37
+ # 每轮 AI 分析处理的目标数。这是成本闸门 —— 调大意味着账单同步变大。
38
+ AI_BATCH = 5
39
+
40
+
41
+ def _every(minutes: int, offset: int = 0) -> set[int]:
42
+ """每 N 分钟触发一次的分钟集合。
43
+
44
+ 刻意错开分钟点:所有任务都压在整点会让上游在瞬间收到成倍请求。
45
+ """
46
+ return set(range(offset % minutes, 60, minutes))
47
+
48
+
49
+ async def _repository_ids(repository_id: str | None) -> list[str]:
50
+ """解析本次任务的目标仓库。
51
+
52
+ 只返回 id,不返回 ORM 对象 —— 后续每个仓库在独立会话里重新加载,
53
+ 避免跨会话持有已 detached 的实例。
54
+ """
55
+ from app.services import repository_service
56
+
57
+ async with session_scope() as session:
58
+ if repository_id:
59
+ repository = await repository_service.require_repository(session, repository_id)
60
+ return [str(repository.id)]
61
+ repositories = await repository_service.list_repositories(session)
62
+ return [str(repository.id) for repository in repositories]
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # 同步
67
+ # ---------------------------------------------------------------------------
68
+
69
+ # 没配凭据的仓库会被同步层跳过(返回 None,见 sync_service._skip_without_credential)。
70
+ # 这不是失败,所以如实报成 "skipped" 而不是 fail。
71
+ _NO_CREDENTIAL = "skipped:未配置凭据"
72
+
73
+
74
+ async def sync_pulls(ctx: dict, repository_id: str | None = None) -> dict:
75
+ """同步 PR 列表。只更新轻量字段,不拉详情。"""
76
+ from app.models.repository import SyncTrigger
77
+ from app.services import repository_service, sync_service
78
+
79
+ settings = get_settings()
80
+ results: dict[str, str] = {}
81
+ for repo_id in await _repository_ids(repository_id):
82
+ async with session_scope() as session:
83
+ repository = await repository_service.require_repository(session, repo_id)
84
+ run = await sync_service.sync_pulls(
85
+ session, settings, repository, trigger=SyncTrigger.SCHEDULED
86
+ )
87
+ results[repository.full_name] = (
88
+ f"{run.status.value}:{run.items_fetched}" if run else _NO_CREDENTIAL
89
+ )
90
+ return {"repositories": results}
91
+
92
+
93
+ async def sync_issues(ctx: dict, repository_id: str | None = None) -> dict:
94
+ from app.models.repository import SyncTrigger
95
+ from app.services import repository_service, sync_service
96
+
97
+ settings = get_settings()
98
+ results: dict[str, str] = {}
99
+ for repo_id in await _repository_ids(repository_id):
100
+ async with session_scope() as session:
101
+ repository = await repository_service.require_repository(session, repo_id)
102
+ run = await sync_service.sync_issues(
103
+ session, settings, repository, trigger=SyncTrigger.SCHEDULED
104
+ )
105
+ results[repository.full_name] = (
106
+ f"{run.status.value}:{run.items_fetched}" if run else _NO_CREDENTIAL
107
+ )
108
+ return {"repositories": results}
109
+
110
+
111
+ async def sync_pull_details(ctx: dict, repository_id: str | None = None, limit: int = DETAIL_BATCH):
112
+ """为内容有变化的 PR 补齐提交、文件与评审事件。"""
113
+ from app.models.repository import SyncTrigger
114
+ from app.services import repository_service, sync_service
115
+
116
+ settings = get_settings()
117
+ results: dict[str, str] = {}
118
+ for repo_id in await _repository_ids(repository_id):
119
+ async with session_scope() as session:
120
+ repository = await repository_service.require_repository(session, repo_id)
121
+ run = await sync_service.sync_pull_details(
122
+ session, settings, repository, limit=limit, trigger=SyncTrigger.SCHEDULED
123
+ )
124
+ results[repository.full_name] = (
125
+ f"{run.status.value}:{run.items_updated}/{run.items_fetched}"
126
+ if run
127
+ else _NO_CREDENTIAL
128
+ )
129
+ return {"repositories": results}
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # 分类与关注项
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ async def classify_repository(ctx: dict, repository_id: str | None = None) -> dict:
138
+ """对活动 PR/Issue 跑规则分类。
139
+
140
+ 只处理 open 的:已合并/已关闭的分类结果不再产生任何可执行动作,
141
+ 为它们消耗计算没有收益。
142
+ """
143
+ from sqlalchemy import select
144
+
145
+ from app.models.issue import Issue
146
+ from app.models.pull_request import PullRequest
147
+ from app.services import classification_service, repository_service
148
+
149
+ results: dict[str, str] = {}
150
+ for repo_id in await _repository_ids(repository_id):
151
+ async with session_scope() as session:
152
+ repository = await repository_service.require_repository(session, repo_id)
153
+ # 规则分类覆盖全部状态,不只 open。合入的 PR 同样需要类别 ——
154
+ # 版本与分支页的"这一期合入了多少 CVE"就依赖它,而合入之后
155
+ # 状态变成 merged,再只按 open 筛选就永远统计不到。
156
+ # 规则是纯计算、无外部调用,多算的代价可以忽略。
157
+ pulls = list(
158
+ (
159
+ await session.scalars(
160
+ select(PullRequest).where(PullRequest.repository_id == repository.id)
161
+ )
162
+ ).all()
163
+ )
164
+ issues = list(
165
+ (
166
+ await session.scalars(
167
+ select(Issue).where(
168
+ Issue.repository_id == repository.id, Issue.state == "open"
169
+ )
170
+ )
171
+ ).all()
172
+ )
173
+ pulls_written = await classification_service.classify_pulls(session, repository, pulls)
174
+ issues_written = await classification_service.classify_issues(
175
+ session, repository, issues
176
+ )
177
+ results[repository.full_name] = f"pulls={pulls_written} issues={issues_written}"
178
+ return {"repositories": results}
179
+
180
+
181
+ async def sync_sig_info(ctx: dict, repository_id: str | None = None) -> dict:
182
+ """同步 SIG 例会纪要与成员名单。
183
+
184
+ 两者都与具体仓库无关(会议是全 SIG 的,名单来自 community 仓库),
185
+ 但仍挂在仓库任务体系里:复用同一套 session 与凭据解析,
186
+ 为此单开一条调度链不值得。
187
+
188
+ 两件事都容错 —— 数据源都在平台之外,网络波动不该让定时任务报红:
189
+ 纪要拉不到就跳过,名单拉不到就保留旧数据(``sync_roster``
190
+ 在拿不到文档时不动现有数据)。
191
+ """
192
+ from app.services import credential_service, repository_service, sig_service
193
+
194
+ settings = get_settings()
195
+ result: dict[str, object] = {}
196
+
197
+ try:
198
+ text = await sig_service.fetch_etherpad()
199
+ async with session_scope() as session:
200
+ result["meetings"] = await sig_service.sync_meetings(session, text)
201
+ except httpx.HTTPError as exc:
202
+ logger.warning("etherpad_unavailable", error=str(exc)[:200])
203
+ result["meetings"] = "unavailable"
204
+
205
+ roster: dict[str, object] = {}
206
+ for repo_id in await _repository_ids(repository_id):
207
+ async with session_scope() as session:
208
+ repository = await repository_service.require_repository(session, repo_id)
209
+ credential = await credential_service.get_credential(session, repository.credential_id)
210
+ if credential is None:
211
+ roster[repository.full_name] = "no_credential"
212
+ continue
213
+ async with repository_service.build_client(settings, repository, credential) as client:
214
+ sig_yaml, committers, _readme = await sig_service.fetch_sig_documents(client)
215
+ try:
216
+ roster[repository.full_name] = await sig_service.sync_roster(
217
+ session, sig_info_yaml=sig_yaml, committers_md=committers
218
+ )
219
+ except (AtomGitError, SQLAlchemyError) as exc:
220
+ logger.warning("sig_roster_failed", error=str(exc)[:200])
221
+ roster[repository.full_name] = "failed"
222
+ result["roster"] = roster
223
+ return result
224
+
225
+
226
+ async def compute_attention(ctx: dict, repository_id: str | None = None) -> dict:
227
+ """重算关注项队列。
228
+
229
+ 必须在列表同步之后执行 —— 规则读的是 gate_stage、merge_conflict
230
+ 这些派生列,列表同步没跑完时算出来的是上一轮的状态。
231
+ """
232
+ from app.services import attention_service, repository_service
233
+
234
+ results: dict[str, dict] = {}
235
+ for repo_id in await _repository_ids(repository_id):
236
+ async with session_scope() as session:
237
+ repository = await repository_service.require_repository(session, repo_id)
238
+ results[repository.full_name] = await attention_service.compute_for_repository(
239
+ session, repository
240
+ )
241
+ return {"repositories": results}
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # AI 分析
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ async def run_ai_analysis(ctx: dict, repository_id: str | None = None, limit: int = AI_BATCH):
250
+ """对规则无法判定、或从未分析过的 PR 调用模型。
251
+
252
+ 当前只做分类补判 —— 这是成本最低、且规则明确接不住的一块。
253
+ 摘要与风险审查按需触发(analyze_pull_task),不做定时批量:
254
+ 它们的单价高得多,无人阅读的结果等于纯支出。
255
+ """
256
+ from app.core.exceptions import ValidationError
257
+ from app.models.ai import AITask, AnalysisStatus
258
+ from app.services import ai_service, classification_service, repository_service
259
+
260
+ settings = get_settings()
261
+ results: dict[str, str] = {}
262
+
263
+ for repo_id in await _repository_ids(repository_id):
264
+ async with session_scope() as session:
265
+ repository = await repository_service.require_repository(session, repo_id)
266
+
267
+ # 先确认有可用模型再挑目标并动手。否则会挑出一批、逐个失败,
268
+ # 结果只能报一句 "classified=0/5",看不出是没配模型还是模型出错。
269
+ try:
270
+ await ai_service.resolve_provider(session, AITask.CLASSIFY)
271
+ except ValidationError:
272
+ logger.info("ai_analysis_skipped_no_provider", repository=repository.full_name)
273
+ results[repository.full_name] = "skipped:no_provider"
274
+ continue
275
+
276
+ targets = await classification_service.pulls_missing_classification(
277
+ session, repository.id, limit=limit
278
+ )
279
+ # Issue 也补判。数量是 PR 的两倍且未判定率更高(46%,因为
280
+ # Issue 没有变更文件可供推断),因此单独限流:每轮只取一小批,
281
+ # 慢慢补完即可 —— 一次性判完既烧钱也没有必要。
282
+ issues = await classification_service.issues_missing_classification(
283
+ session, repository.id, limit=max(5, limit // 2)
284
+ )
285
+ if not targets and not issues:
286
+ continue
287
+
288
+ classified = 0
289
+ for pull in targets:
290
+ analysis = await ai_service.classify_subject(
291
+ session, settings, repository, pull=pull
292
+ )
293
+ if analysis.status is AnalysisStatus.SUCCEEDED:
294
+ classified += 1
295
+
296
+ issue_classified = 0
297
+ for issue in issues:
298
+ analysis = await ai_service.classify_subject(
299
+ session, settings, repository, issue=issue
300
+ )
301
+ if analysis.status is AnalysisStatus.SUCCEEDED:
302
+ issue_classified += 1
303
+
304
+ results[repository.full_name] = (
305
+ f"pr={classified}/{len(targets)} issue={issue_classified}/{len(issues)}"
306
+ )
307
+
308
+ return {"repositories": results}
309
+
310
+
311
+ async def analyze_pull_task(ctx: dict, pull_id: str, task: str, force: bool = False) -> dict:
312
+ """按需触发的单条分析。供 API 入队使用。"""
313
+ from app.models.ai import AITask
314
+ from app.models.pull_request import PullRequest
315
+ from app.services import ai_service, repository_service
316
+
317
+ settings = get_settings()
318
+ async with session_scope() as session:
319
+ pull = await session.get(PullRequest, pull_id)
320
+ if pull is None:
321
+ return {"status": "not_found", "pull_id": pull_id}
322
+ repository = await repository_service.require_repository(session, pull.repository_id)
323
+ resolved = AITask(task)
324
+ target = await ai_service.build_target(session, task=resolved, pull=pull)
325
+ analysis = await ai_service.execute(session, settings, repository, target, force=force)
326
+ return {
327
+ "status": analysis.status.value,
328
+ "pull": pull.number,
329
+ "task": task,
330
+ "error": analysis.error,
331
+ }
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # worker 装配
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ async def on_startup(ctx: dict) -> None:
340
+ settings = get_settings()
341
+ configure_logging(level=settings.log_level, json_output=settings.is_production)
342
+ init_engine(settings)
343
+ logger.info("worker_started", environment=settings.environment)
344
+
345
+
346
+ async def on_shutdown(ctx: dict) -> None:
347
+ await dispose_engine()
348
+ logger.info("worker_stopped")
349
+
350
+
351
+ class WorkerSettings:
352
+ # ClassVar 注解不改变运行期行为:ARQ 通过 __dict__ 读取这些属性,
353
+ # 注解只是告诉类型检查器它们是类级别的常量容器
354
+ functions: ClassVar[list] = [
355
+ sync_pulls,
356
+ sync_issues,
357
+ sync_pull_details,
358
+ classify_repository,
359
+ compute_attention,
360
+ run_ai_analysis,
361
+ analyze_pull_task,
362
+ ]
363
+
364
+ # 分钟点刻意错开:全部压在整点会让上游在瞬间收到成倍请求,
365
+ # 自己的数据库也会同时承受多个全量重算。
366
+ cron_jobs: ClassVar[list] = [
367
+ cron(sync_pulls, minute=_every(10, offset=1), timeout=600),
368
+ cron(sync_issues, minute=_every(30, offset=4), timeout=600),
369
+ cron(sync_pull_details, minute=_every(5, offset=3), timeout=900),
370
+ # 跟在列表同步之后:规则读的是列表同步写下的派生列
371
+ cron(classify_repository, minute=_every(10, offset=6), timeout=600),
372
+ cron(compute_attention, minute=_every(15, offset=7), timeout=600),
373
+ cron(run_ai_analysis, minute=_every(30, offset=9), timeout=1800),
374
+ # 例会纪要每天变一次就够了;成员名单更低频,但共用一条链成本很低
375
+ cron(sync_sig_info, hour={2, 14}, minute=_every(60, offset=13), timeout=900),
376
+ ]
377
+
378
+ on_startup = on_startup
379
+ on_shutdown = on_shutdown
380
+
381
+ # 并发度:同步任务是 IO 密集且受上游限流约束,4 路已足够;
382
+ # 再高只会更快撞上 429。
383
+ max_jobs = 4
384
+ job_timeout = 1800
385
+ keep_result = 3600
386
+ max_tries = 2
387
+
388
+ # ARQ 通过 __dict__ 读取本类属性,因此这里必须是实例而非可调用对象
389
+ redis_settings = redis_settings_from(get_settings())
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # 容器入口:先跑迁移再起服务。
3
+ # alembic upgrade head 是幂等的,重复启动不会重复应用。
4
+ set -e
5
+
6
+ echo "[entrypoint] 执行数据库迁移..."
7
+ alembic upgrade head
8
+
9
+ echo "[entrypoint] 启动 API..."
10
+ exec uvicorn app.main:app --host 0.0.0.0 --port 8000 "$@"
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "kernel-sig-console"
3
+ version = "0.1.0"
4
+ description = "openEuler Kernel SIG 协作平台后端"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "fastapi>=0.115.6",
8
+ "uvicorn[standard]>=0.34.0",
9
+ "sqlalchemy[asyncio]>=2.0.36",
10
+ "asyncpg>=0.30.0",
11
+ "psycopg[binary]>=3.2.3",
12
+ "alembic>=1.14.0",
13
+ "pydantic>=2.10.4",
14
+ "pydantic-settings>=2.7.0",
15
+ "structlog>=24.4.0",
16
+ "argon2-cffi>=23.1.0",
17
+ "pyjwt>=2.10.1",
18
+ "cryptography>=44.0.0",
19
+ "httpx>=0.28.1",
20
+ "arq>=0.26.3",
21
+ "python-multipart>=0.0.20",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.3.4",
27
+ "pytest-asyncio>=0.25.0",
28
+ "pytest-cov>=6.0.0",
29
+ "ruff>=0.8.4",
30
+ "mypy>=1.14.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=75"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [tool.setuptools.packages.find]
38
+ include = ["app*"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py312"
43
+ exclude = ["alembic/versions"]
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "N", "UP", "B", "C4", "SIM", "RUF"]
47
+ ignore = [
48
+ "E501", # 行长由 formatter 决定
49
+ "RUF001", # 全角标点:本项目注释与文档有意使用中文,属误报
50
+ "RUF002",
51
+ "RUF003",
52
+ ]
53
+
54
+ [tool.ruff.lint.isort]
55
+ known-first-party = ["app"]
56
+
57
+ [tool.mypy]
58
+ python_version = "3.12"
59
+ plugins = ["pydantic.mypy"]
60
+ ignore_missing_imports = true
61
+ warn_unused_ignores = true
62
+
63
+ [tool.pytest.ini_options]
64
+ asyncio_mode = "auto"
65
+ asyncio_default_fixture_loop_scope = "session"
66
+ testpaths = ["tests"]
67
+ addopts = "-ra --strict-markers"
68
+ filterwarnings = ["ignore::DeprecationWarning"]