@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""关注项服务。
|
|
2
|
+
|
|
3
|
+
把 domain.attention 的规则求值结果落库成一份可认领的待办队列。
|
|
4
|
+
|
|
5
|
+
生命周期与「每次重算覆盖」的直觉不同,这里刻意保留状态:
|
|
6
|
+
- 命中且此前已存在 → 刷新 severity/detail/evidence,**保留 first_detected_at**
|
|
7
|
+
- 命中且此前不存在 → 新建
|
|
8
|
+
- 不再命中 → 置 resolved_at,**不删除**
|
|
9
|
+
|
|
10
|
+
不删除是因为「这条挂了多久才被处理」本身就是维护者要看的信号。
|
|
11
|
+
若把 resolved 的行删掉,积压趋势就无从观察。
|
|
12
|
+
|
|
13
|
+
一次求值用同一个 ``now`` 同时写入 last_evaluated_at,再用
|
|
14
|
+
``last_evaluated_at < now`` 判定"本轮未再命中"。这样避免把上千个 id
|
|
15
|
+
塞进 NOT IN 子句,也让判断与写入处于同一时间基准。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
|
|
24
|
+
import structlog
|
|
25
|
+
from sqlalchemy import case, func, select, update
|
|
26
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
27
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
28
|
+
|
|
29
|
+
from app.domain.attention import (
|
|
30
|
+
AttentionHit,
|
|
31
|
+
IssueSnapshot,
|
|
32
|
+
PullSnapshot,
|
|
33
|
+
Thresholds,
|
|
34
|
+
evaluate,
|
|
35
|
+
evaluate_issue,
|
|
36
|
+
)
|
|
37
|
+
from app.models.attention import AttentionItem, AttentionRule, AttentionSeverity
|
|
38
|
+
from app.models.classification import Classification, SubjectType
|
|
39
|
+
from app.models.issue import Issue
|
|
40
|
+
from app.models.pull_request import PRCommit, PRFile, PullRequest
|
|
41
|
+
from app.models.repository import Repository
|
|
42
|
+
|
|
43
|
+
logger = structlog.get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# 快照构造
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _cve_ids_by_pull(
|
|
52
|
+
session: AsyncSession, pull_ids: Sequence[uuid.UUID]
|
|
53
|
+
) -> dict[uuid.UUID, list[str]]:
|
|
54
|
+
if not pull_ids:
|
|
55
|
+
return {}
|
|
56
|
+
stmt = select(Classification.subject_id, Classification.cve_ids).where(
|
|
57
|
+
Classification.subject_type == SubjectType.PULL_REQUEST,
|
|
58
|
+
Classification.subject_id.in_(list(pull_ids)),
|
|
59
|
+
Classification.cve_ids.is_not(None),
|
|
60
|
+
)
|
|
61
|
+
return {row[0]: list(row[1]) for row in (await session.execute(stmt)).all() if row[1]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _binary_files_by_pull(
|
|
65
|
+
session: AsyncSession, pull_ids: Sequence[uuid.UUID]
|
|
66
|
+
) -> dict[uuid.UUID, list[str]]:
|
|
67
|
+
"""只取二进制文件。内核仓库禁止提交,这条规则必须有据可依。"""
|
|
68
|
+
if not pull_ids:
|
|
69
|
+
return {}
|
|
70
|
+
stmt = select(PRFile.pull_request_id, PRFile.filename).where(
|
|
71
|
+
PRFile.pull_request_id.in_(list(pull_ids)), PRFile.is_binary.is_(True)
|
|
72
|
+
)
|
|
73
|
+
result: dict[uuid.UUID, list[str]] = {}
|
|
74
|
+
for pull_id, filename in (await session.execute(stmt)).all():
|
|
75
|
+
result.setdefault(pull_id, []).append(filename)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _commit_stats_by_pull(
|
|
80
|
+
session: AsyncSession, pull_ids: Sequence[uuid.UUID]
|
|
81
|
+
) -> dict[uuid.UUID, tuple[int, int]]:
|
|
82
|
+
"""返回 {pull_id: (提交总数, 缺 Signed-off-by 的提交数)}。
|
|
83
|
+
|
|
84
|
+
签名链只在补丁正文里,因此这里用 SQL 的 JSON 数组长度判断,
|
|
85
|
+
而不是把全部提交读进内存。
|
|
86
|
+
"""
|
|
87
|
+
if not pull_ids:
|
|
88
|
+
return {}
|
|
89
|
+
signed_off_len = func.coalesce(func.jsonb_array_length(PRCommit.signed_off_bys), 0)
|
|
90
|
+
stmt = (
|
|
91
|
+
select(
|
|
92
|
+
PRCommit.pull_request_id,
|
|
93
|
+
func.count(),
|
|
94
|
+
func.count().filter(signed_off_len == 0),
|
|
95
|
+
)
|
|
96
|
+
.where(PRCommit.pull_request_id.in_(list(pull_ids)))
|
|
97
|
+
.group_by(PRCommit.pull_request_id)
|
|
98
|
+
)
|
|
99
|
+
return {row[0]: (row[1], row[2]) for row in (await session.execute(stmt)).all()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _pull_snapshot(
|
|
103
|
+
pull: PullRequest,
|
|
104
|
+
*,
|
|
105
|
+
cve_ids: list[str],
|
|
106
|
+
binary_files: list[str],
|
|
107
|
+
commit_stats: tuple[int, int],
|
|
108
|
+
) -> PullSnapshot:
|
|
109
|
+
total_commits, missing_signoff = commit_stats
|
|
110
|
+
return PullSnapshot(
|
|
111
|
+
number=pull.number,
|
|
112
|
+
title=pull.title,
|
|
113
|
+
state=pull.state.value if hasattr(pull.state, "value") else str(pull.state),
|
|
114
|
+
gate_stage=pull.gate_stage,
|
|
115
|
+
created_at=pull.atomgit_created_at,
|
|
116
|
+
updated_at=pull.atomgit_updated_at,
|
|
117
|
+
is_draft=pull.draft,
|
|
118
|
+
merge_conflict=pull.merge_conflict,
|
|
119
|
+
label_names=pull.label_names or [],
|
|
120
|
+
cve_ids=cve_ids,
|
|
121
|
+
binary_files=binary_files,
|
|
122
|
+
commits_without_signed_off=missing_signoff,
|
|
123
|
+
total_commits=total_commits,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _issue_snapshot(issue: Issue) -> IssueSnapshot:
|
|
128
|
+
return IssueSnapshot(
|
|
129
|
+
number=issue.number,
|
|
130
|
+
title=issue.title,
|
|
131
|
+
state=issue.state,
|
|
132
|
+
created_at=issue.atomgit_created_at,
|
|
133
|
+
updated_at=issue.atomgit_updated_at,
|
|
134
|
+
issue_type=issue.issue_type,
|
|
135
|
+
priority=issue.priority_label,
|
|
136
|
+
assignee_login=issue.assignee_login,
|
|
137
|
+
label_names=issue.label_names or [],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# 求值与落库
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _row(
|
|
147
|
+
repository_id: uuid.UUID,
|
|
148
|
+
subject_type: SubjectType,
|
|
149
|
+
subject_id: uuid.UUID,
|
|
150
|
+
number: int,
|
|
151
|
+
title: str,
|
|
152
|
+
url: str | None,
|
|
153
|
+
hit: AttentionHit,
|
|
154
|
+
now: datetime,
|
|
155
|
+
) -> dict:
|
|
156
|
+
return {
|
|
157
|
+
"id": uuid.uuid4(),
|
|
158
|
+
"repository_id": repository_id,
|
|
159
|
+
"subject_type": subject_type.value,
|
|
160
|
+
"subject_id": subject_id,
|
|
161
|
+
"subject_number": number,
|
|
162
|
+
"subject_title": title,
|
|
163
|
+
"subject_url": url,
|
|
164
|
+
"rule": AttentionRule(hit.rule.value).value,
|
|
165
|
+
"severity": AttentionSeverity(hit.severity.value).value,
|
|
166
|
+
"title": hit.title,
|
|
167
|
+
"detail": hit.detail,
|
|
168
|
+
"evidence": hit.evidence,
|
|
169
|
+
"last_evaluated_at": now,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# 重算时刷新的列。first_detected_at / acknowledged_* 刻意不在其中:
|
|
174
|
+
# 前者是"这条挂了多久"的依据,后者是人的决定,都不该被自动任务抹掉。
|
|
175
|
+
_REFRESH_COLUMNS = (
|
|
176
|
+
"subject_number",
|
|
177
|
+
"subject_title",
|
|
178
|
+
"subject_url",
|
|
179
|
+
"severity",
|
|
180
|
+
"title",
|
|
181
|
+
"detail",
|
|
182
|
+
"evidence",
|
|
183
|
+
"last_evaluated_at",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _persist(
|
|
188
|
+
session: AsyncSession, rows: list[dict], *, repository_id: uuid.UUID, now: datetime
|
|
189
|
+
) -> None:
|
|
190
|
+
for start in range(0, len(rows), 500):
|
|
191
|
+
chunk = rows[start : start + 500]
|
|
192
|
+
stmt = pg_insert(AttentionItem).values(chunk)
|
|
193
|
+
stmt = stmt.on_conflict_do_update(
|
|
194
|
+
constraint="uq_ksp_attention_subject_rule",
|
|
195
|
+
set_={col: getattr(stmt.excluded, col) for col in _REFRESH_COLUMNS},
|
|
196
|
+
)
|
|
197
|
+
await session.execute(stmt)
|
|
198
|
+
|
|
199
|
+
# 本轮没有再命中的,视为已解决。用时间基准而非 id 列表 ——
|
|
200
|
+
# 一次全量重算涉及上千条,NOT IN 的 id 列表既慢又容易踩参数上限。
|
|
201
|
+
#
|
|
202
|
+
# **必须限定在本仓库内**:不加这个条件时,重算 A 仓库会把 B 仓库
|
|
203
|
+
# 尚未评估过的条目一并标记为已解决 —— B 的队列当场清空,而这一轮
|
|
204
|
+
# 根本没看过它的数据。只有一个仓库时看不出来,纳管第二个就会。
|
|
205
|
+
await session.execute(
|
|
206
|
+
update(AttentionItem)
|
|
207
|
+
.where(
|
|
208
|
+
AttentionItem.repository_id == repository_id,
|
|
209
|
+
AttentionItem.resolved_at.is_(None),
|
|
210
|
+
AttentionItem.last_evaluated_at < now,
|
|
211
|
+
)
|
|
212
|
+
.values(resolved_at=now)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def compute_for_repository(
|
|
217
|
+
session: AsyncSession,
|
|
218
|
+
repository: Repository,
|
|
219
|
+
*,
|
|
220
|
+
thresholds: Thresholds | None = None,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""重算一个仓库的全部关注项。返回本轮统计。"""
|
|
223
|
+
now = datetime.now(UTC)
|
|
224
|
+
thresholds = thresholds or Thresholds()
|
|
225
|
+
|
|
226
|
+
pulls = list(
|
|
227
|
+
(
|
|
228
|
+
await session.scalars(
|
|
229
|
+
select(PullRequest).where(
|
|
230
|
+
PullRequest.repository_id == repository.id,
|
|
231
|
+
PullRequest.state == "open",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
).all()
|
|
235
|
+
)
|
|
236
|
+
issues = list(
|
|
237
|
+
(
|
|
238
|
+
await session.scalars(
|
|
239
|
+
select(Issue).where(Issue.repository_id == repository.id, Issue.state == "open")
|
|
240
|
+
)
|
|
241
|
+
).all()
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
pull_ids = [pull.id for pull in pulls]
|
|
245
|
+
cves = await _cve_ids_by_pull(session, pull_ids)
|
|
246
|
+
binaries = await _binary_files_by_pull(session, pull_ids)
|
|
247
|
+
commits = await _commit_stats_by_pull(session, pull_ids)
|
|
248
|
+
|
|
249
|
+
rows: list[dict] = []
|
|
250
|
+
for pull in pulls:
|
|
251
|
+
snapshot = _pull_snapshot(
|
|
252
|
+
pull,
|
|
253
|
+
cve_ids=cves.get(pull.id, []),
|
|
254
|
+
binary_files=binaries.get(pull.id, []),
|
|
255
|
+
commit_stats=commits.get(pull.id, (0, 0)),
|
|
256
|
+
)
|
|
257
|
+
for hit in evaluate(snapshot, now=now, thresholds=thresholds):
|
|
258
|
+
rows.append(
|
|
259
|
+
_row(
|
|
260
|
+
repository.id,
|
|
261
|
+
SubjectType.PULL_REQUEST,
|
|
262
|
+
pull.id,
|
|
263
|
+
pull.number,
|
|
264
|
+
pull.title,
|
|
265
|
+
pull.html_url,
|
|
266
|
+
hit,
|
|
267
|
+
now,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
for issue in issues:
|
|
272
|
+
for hit in evaluate_issue(_issue_snapshot(issue), now=now, thresholds=thresholds):
|
|
273
|
+
rows.append(
|
|
274
|
+
_row(
|
|
275
|
+
repository.id,
|
|
276
|
+
SubjectType.ISSUE,
|
|
277
|
+
issue.id,
|
|
278
|
+
issue.number,
|
|
279
|
+
issue.title,
|
|
280
|
+
issue.html_url,
|
|
281
|
+
hit,
|
|
282
|
+
now,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
await _persist(session, rows, repository_id=repository.id, now=now)
|
|
287
|
+
|
|
288
|
+
open_count = await session.scalar(
|
|
289
|
+
select(func.count())
|
|
290
|
+
.select_from(AttentionItem)
|
|
291
|
+
.where(AttentionItem.repository_id == repository.id, AttentionItem.resolved_at.is_(None))
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
logger.info(
|
|
295
|
+
"attention_computed",
|
|
296
|
+
repository=repository.full_name,
|
|
297
|
+
hits=len(rows),
|
|
298
|
+
open=open_count,
|
|
299
|
+
)
|
|
300
|
+
return {
|
|
301
|
+
"evaluated_pulls": len(pulls),
|
|
302
|
+
"evaluated_issues": len(issues),
|
|
303
|
+
"open_items": open_count or 0,
|
|
304
|
+
"hits": len(rows),
|
|
305
|
+
"evaluated_at": now.isoformat(),
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# 查询与认领
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def list_items(
|
|
315
|
+
session: AsyncSession,
|
|
316
|
+
*,
|
|
317
|
+
repository_id: uuid.UUID | None = None,
|
|
318
|
+
severities: Sequence[AttentionSeverity] = (),
|
|
319
|
+
rules: Sequence[AttentionRule] = (),
|
|
320
|
+
subject_type: SubjectType | None = None,
|
|
321
|
+
include_resolved: bool = False,
|
|
322
|
+
unacknowledged_only: bool = False,
|
|
323
|
+
limit: int = 50,
|
|
324
|
+
offset: int = 0,
|
|
325
|
+
) -> tuple[list[AttentionItem], int]:
|
|
326
|
+
def apply_filters(stmt):
|
|
327
|
+
if repository_id is not None:
|
|
328
|
+
stmt = stmt.where(AttentionItem.repository_id == repository_id)
|
|
329
|
+
if severities:
|
|
330
|
+
stmt = stmt.where(AttentionItem.severity.in_(list(severities)))
|
|
331
|
+
if rules:
|
|
332
|
+
stmt = stmt.where(AttentionItem.rule.in_(list(rules)))
|
|
333
|
+
if subject_type is not None:
|
|
334
|
+
stmt = stmt.where(AttentionItem.subject_type == subject_type.value)
|
|
335
|
+
if not include_resolved:
|
|
336
|
+
stmt = stmt.where(AttentionItem.resolved_at.is_(None))
|
|
337
|
+
if unacknowledged_only:
|
|
338
|
+
stmt = stmt.where(AttentionItem.acknowledged_at.is_(None))
|
|
339
|
+
return stmt
|
|
340
|
+
|
|
341
|
+
# severity 是枚举,排序需按语义(blocker 最前)而非字母序,
|
|
342
|
+
# 因此用 CASE 映射成序号
|
|
343
|
+
severity_rank = _severity_rank()
|
|
344
|
+
stmt = (
|
|
345
|
+
apply_filters(select(AttentionItem))
|
|
346
|
+
.order_by(severity_rank, AttentionItem.first_detected_at)
|
|
347
|
+
.limit(limit)
|
|
348
|
+
.offset(offset)
|
|
349
|
+
)
|
|
350
|
+
count_stmt = apply_filters(select(func.count(AttentionItem.id)))
|
|
351
|
+
|
|
352
|
+
rows = list((await session.scalars(stmt)).all())
|
|
353
|
+
total = await session.scalar(count_stmt) or 0
|
|
354
|
+
return rows, total
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _severity_rank():
|
|
358
|
+
"""严重程度的排序表达式。blocker 最前,与 domain.attention.SEVERITY_ORDER 一致。"""
|
|
359
|
+
return case(
|
|
360
|
+
{
|
|
361
|
+
AttentionSeverity.BLOCKER: 0,
|
|
362
|
+
AttentionSeverity.CRITICAL: 1,
|
|
363
|
+
AttentionSeverity.WARNING: 2,
|
|
364
|
+
AttentionSeverity.INFO: 3,
|
|
365
|
+
},
|
|
366
|
+
value=AttentionItem.severity,
|
|
367
|
+
else_=9,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
async def severity_counts(session: AsyncSession, repository_id: uuid.UUID) -> dict[str, int]:
|
|
372
|
+
"""未解决项的严重程度分布。界面顶部的筛选栏直接用它渲染。"""
|
|
373
|
+
stmt = (
|
|
374
|
+
select(AttentionItem.severity, func.count())
|
|
375
|
+
.where(
|
|
376
|
+
AttentionItem.repository_id == repository_id,
|
|
377
|
+
AttentionItem.resolved_at.is_(None),
|
|
378
|
+
)
|
|
379
|
+
.group_by(AttentionItem.severity)
|
|
380
|
+
)
|
|
381
|
+
counts = {row[0].value: row[1] for row in (await session.execute(stmt)).all()}
|
|
382
|
+
return {severity.value: counts.get(severity.value, 0) for severity in AttentionSeverity}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def rule_counts(session: AsyncSession, repository_id: uuid.UUID) -> dict[str, int]:
|
|
386
|
+
stmt = (
|
|
387
|
+
select(AttentionItem.rule, func.count())
|
|
388
|
+
.where(
|
|
389
|
+
AttentionItem.repository_id == repository_id,
|
|
390
|
+
AttentionItem.resolved_at.is_(None),
|
|
391
|
+
)
|
|
392
|
+
.group_by(AttentionItem.rule)
|
|
393
|
+
)
|
|
394
|
+
counts = {row[0].value: row[1] for row in (await session.execute(stmt)).all()}
|
|
395
|
+
return {rule.value: counts.get(rule.value, 0) for rule in AttentionRule}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def get_item(session: AsyncSession, item_id: uuid.UUID) -> AttentionItem | None:
|
|
399
|
+
return await session.get(AttentionItem, item_id)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def acknowledge(
|
|
403
|
+
session: AsyncSession, item: AttentionItem, user_id: uuid.UUID
|
|
404
|
+
) -> AttentionItem:
|
|
405
|
+
"""认领。已被他人认领时不覆盖,避免两个人同时处理时互相踩掉记录。"""
|
|
406
|
+
if item.acknowledged_by is None:
|
|
407
|
+
item.acknowledged_by = user_id
|
|
408
|
+
item.acknowledged_at = datetime.now(UTC)
|
|
409
|
+
await session.flush()
|
|
410
|
+
return item
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def release(session: AsyncSession, item: AttentionItem) -> AttentionItem:
|
|
414
|
+
item.acknowledged_by = None
|
|
415
|
+
item.acknowledged_at = None
|
|
416
|
+
await session.flush()
|
|
417
|
+
return item
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
async def items_for_subject(
|
|
421
|
+
session: AsyncSession, subject_type: SubjectType, subject_id: uuid.UUID
|
|
422
|
+
) -> list[AttentionItem]:
|
|
423
|
+
stmt = (
|
|
424
|
+
select(AttentionItem)
|
|
425
|
+
.where(
|
|
426
|
+
AttentionItem.subject_type == subject_type.value,
|
|
427
|
+
AttentionItem.subject_id == subject_id,
|
|
428
|
+
AttentionItem.resolved_at.is_(None),
|
|
429
|
+
)
|
|
430
|
+
.order_by(_severity_rank(), AttentionItem.first_detected_at)
|
|
431
|
+
)
|
|
432
|
+
return list((await session.scalars(stmt)).all())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""认证服务:登录、令牌签发与解析。"""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.core.config import Settings
|
|
9
|
+
from app.core.exceptions import UnauthorizedError
|
|
10
|
+
from app.core.security import TokenType, create_token, decode_token, verify_password
|
|
11
|
+
from app.models.user import User
|
|
12
|
+
from app.services.user_service import get_user, get_user_by_username
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def authenticate(session: AsyncSession, username: str, password: str) -> User:
|
|
16
|
+
"""校验凭据。
|
|
17
|
+
|
|
18
|
+
用户不存在与密码错误返回同一个错误,避免用户名枚举。
|
|
19
|
+
"""
|
|
20
|
+
user = await get_user_by_username(session, username)
|
|
21
|
+
if user is None or not verify_password(password, user.password_hash):
|
|
22
|
+
raise UnauthorizedError("用户名或密码错误")
|
|
23
|
+
if not user.is_active:
|
|
24
|
+
raise UnauthorizedError("账号已被禁用")
|
|
25
|
+
|
|
26
|
+
user.last_login_at = datetime.now(UTC)
|
|
27
|
+
await session.flush()
|
|
28
|
+
return user
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def issue_tokens(user: User, settings: Settings) -> tuple[str, str]:
|
|
32
|
+
access = create_token(
|
|
33
|
+
subject=str(user.id),
|
|
34
|
+
token_type=TokenType.ACCESS,
|
|
35
|
+
secret=settings.secret_key,
|
|
36
|
+
expires_delta=timedelta(minutes=settings.access_token_ttl_minutes),
|
|
37
|
+
extra_claims={"username": user.username, "role": user.role.name},
|
|
38
|
+
)
|
|
39
|
+
refresh = create_token(
|
|
40
|
+
subject=str(user.id),
|
|
41
|
+
token_type=TokenType.REFRESH,
|
|
42
|
+
secret=settings.secret_key,
|
|
43
|
+
expires_delta=timedelta(days=settings.refresh_token_ttl_days),
|
|
44
|
+
)
|
|
45
|
+
return access, refresh
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def resolve_token(session: AsyncSession, token: str, settings: Settings) -> User:
|
|
49
|
+
"""从访问令牌解析出用户。"""
|
|
50
|
+
try:
|
|
51
|
+
payload = decode_token(token, settings.secret_key, TokenType.ACCESS)
|
|
52
|
+
except jwt.ExpiredSignatureError as exc:
|
|
53
|
+
raise UnauthorizedError("登录已过期,请重新登录") from exc
|
|
54
|
+
except jwt.InvalidTokenError as exc:
|
|
55
|
+
raise UnauthorizedError("无效的访问令牌") from exc
|
|
56
|
+
|
|
57
|
+
user = await get_user(session, payload["sub"])
|
|
58
|
+
if user is None or not user.is_active:
|
|
59
|
+
raise UnauthorizedError("账号不存在或已被禁用")
|
|
60
|
+
return user
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def resolve_refresh_token(session: AsyncSession, token: str, settings: Settings) -> User:
|
|
64
|
+
try:
|
|
65
|
+
payload = decode_token(token, settings.secret_key, TokenType.REFRESH)
|
|
66
|
+
except jwt.ExpiredSignatureError as exc:
|
|
67
|
+
raise UnauthorizedError("会话已过期,请重新登录") from exc
|
|
68
|
+
except jwt.InvalidTokenError as exc:
|
|
69
|
+
raise UnauthorizedError("无效的刷新令牌") from exc
|
|
70
|
+
|
|
71
|
+
user = await get_user(session, payload["sub"])
|
|
72
|
+
if user is None or not user.is_active:
|
|
73
|
+
raise UnauthorizedError("账号不存在或已被禁用")
|
|
74
|
+
return user
|