@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,192 @@
|
|
|
1
|
+
"""分类 API。
|
|
2
|
+
|
|
3
|
+
分类结果对 PR/Issue 是同一件事,因此接口按 subject_type 区分而非拆两套。
|
|
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.models.classification import Classification, ClassificationSource, PRKind, SubjectType
|
|
16
|
+
from app.models.pull_request import PullRequest
|
|
17
|
+
from app.schemas.classification import (
|
|
18
|
+
ClassificationOverride,
|
|
19
|
+
ClassificationRead,
|
|
20
|
+
KindCount,
|
|
21
|
+
SubsystemCount,
|
|
22
|
+
)
|
|
23
|
+
from app.schemas.common import MessageResponse, Page
|
|
24
|
+
from app.services import classification_service, repository_service
|
|
25
|
+
|
|
26
|
+
router = APIRouter(tags=["classification"])
|
|
27
|
+
|
|
28
|
+
Classify = Annotated[object, Depends(require_permission(Permission.CLASSIFY))]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get(
|
|
32
|
+
"/repositories/{repository_id}/classifications", response_model=Page[ClassificationRead]
|
|
33
|
+
)
|
|
34
|
+
async def list_classifications(
|
|
35
|
+
repository_id: uuid.UUID,
|
|
36
|
+
session: SessionDep,
|
|
37
|
+
_: CurrentUserDep,
|
|
38
|
+
subject_type: SubjectType | None = None,
|
|
39
|
+
kind: PRKind | None = None,
|
|
40
|
+
subsystem: str | None = None,
|
|
41
|
+
source: ClassificationSource | None = None,
|
|
42
|
+
override_only: bool = False,
|
|
43
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
44
|
+
per_page: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
45
|
+
) -> Page[ClassificationRead]:
|
|
46
|
+
await repository_service.require_repository(session, repository_id)
|
|
47
|
+
rows, total = await classification_service.list_classifications(
|
|
48
|
+
session,
|
|
49
|
+
repository_id=repository_id,
|
|
50
|
+
subject_type=subject_type,
|
|
51
|
+
kind=kind,
|
|
52
|
+
subsystem=subsystem,
|
|
53
|
+
source=source,
|
|
54
|
+
override_only=override_only,
|
|
55
|
+
limit=per_page,
|
|
56
|
+
offset=(page - 1) * per_page,
|
|
57
|
+
)
|
|
58
|
+
return Page.create(
|
|
59
|
+
[ClassificationRead.model_validate(row) for row in rows], total, page, per_page
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/repositories/{repository_id}/classifications/kinds", response_model=list[KindCount])
|
|
64
|
+
async def kind_distribution(
|
|
65
|
+
repository_id: uuid.UUID,
|
|
66
|
+
session: SessionDep,
|
|
67
|
+
_: CurrentUserDep,
|
|
68
|
+
subject_type: SubjectType = SubjectType.PULL_REQUEST,
|
|
69
|
+
) -> list[KindCount]:
|
|
70
|
+
"""按类型计数,供筛选栏与统计图直接渲染。"""
|
|
71
|
+
await repository_service.require_repository(session, repository_id)
|
|
72
|
+
rows = await classification_service.kind_distribution(
|
|
73
|
+
session, repository_id, subject_type=subject_type
|
|
74
|
+
)
|
|
75
|
+
return [KindCount(**row) for row in rows]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get(
|
|
79
|
+
"/repositories/{repository_id}/classifications/subsystems",
|
|
80
|
+
response_model=list[SubsystemCount],
|
|
81
|
+
)
|
|
82
|
+
async def subsystem_distribution(
|
|
83
|
+
repository_id: uuid.UUID,
|
|
84
|
+
session: SessionDep,
|
|
85
|
+
_: CurrentUserDep,
|
|
86
|
+
limit: Annotated[int, Query(ge=1, le=60)] = 15,
|
|
87
|
+
) -> list[SubsystemCount]:
|
|
88
|
+
"""按子系统计数。未归类的单列会让这张表失去意义,故不计入。"""
|
|
89
|
+
await repository_service.require_repository(session, repository_id)
|
|
90
|
+
rows = await classification_service.subsystem_distribution(session, repository_id, limit=limit)
|
|
91
|
+
return [SubsystemCount(**row) for row in rows]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.get("/pulls/{pull_id}/classification", response_model=ClassificationRead | None)
|
|
95
|
+
async def pull_classification(
|
|
96
|
+
pull_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
|
|
97
|
+
) -> ClassificationRead | None:
|
|
98
|
+
"""单个 PR 的分类结果。
|
|
99
|
+
|
|
100
|
+
详情页拿到的只有 PR id,没有仓库 id,而按仓库列出全部分类再筛
|
|
101
|
+
既浪费又要在前端拼分页。详情页需要的就是"这一条是什么"。
|
|
102
|
+
未分类时返回 null 而非 404 —— "尚未分类"是正常状态,不是错误。
|
|
103
|
+
"""
|
|
104
|
+
pull = await session.get(PullRequest, pull_id)
|
|
105
|
+
if pull is None:
|
|
106
|
+
raise NotFoundError("PR 不存在", resource="pull_request", identifier=str(pull_id))
|
|
107
|
+
|
|
108
|
+
item = await classification_service.get_classification(
|
|
109
|
+
session, SubjectType.PULL_REQUEST, pull_id
|
|
110
|
+
)
|
|
111
|
+
return ClassificationRead.model_validate(item) if item is not None else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.post("/repositories/{repository_id}/classifications/recompute")
|
|
115
|
+
async def recompute_classifications(
|
|
116
|
+
repository_id: uuid.UUID,
|
|
117
|
+
session: SessionDep,
|
|
118
|
+
_: Classify,
|
|
119
|
+
) -> MessageResponse:
|
|
120
|
+
"""按规则重算全部活动 PR/Issue 的分类。
|
|
121
|
+
|
|
122
|
+
只影响规则与 AI 的判定结果,人工覆盖过的记录原样保留。
|
|
123
|
+
"""
|
|
124
|
+
from sqlalchemy import select as sa_select
|
|
125
|
+
|
|
126
|
+
from app.models.issue import Issue
|
|
127
|
+
|
|
128
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
129
|
+
# 覆盖全部状态:合入/关闭的 PR 也参与版本合入统计,
|
|
130
|
+
# 只按 open 分类会让月度汇总的类别分布几乎全是 unknown。
|
|
131
|
+
pulls = list(
|
|
132
|
+
(
|
|
133
|
+
await session.scalars(
|
|
134
|
+
sa_select(PullRequest).where(PullRequest.repository_id == repository.id)
|
|
135
|
+
)
|
|
136
|
+
).all()
|
|
137
|
+
)
|
|
138
|
+
issues = list(
|
|
139
|
+
(
|
|
140
|
+
await session.scalars(
|
|
141
|
+
sa_select(Issue).where(Issue.repository_id == repository.id)
|
|
142
|
+
)
|
|
143
|
+
).all()
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
by_kind = await classification_service.classify_pulls(session, repository, pulls)
|
|
147
|
+
by_kind += await classification_service.classify_issues(session, repository, issues)
|
|
148
|
+
await session.commit()
|
|
149
|
+
return MessageResponse(message=f"已重算 {by_kind} 条分类(人工覆盖的未被改动)")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _require_classification(session: SessionDep, classification_id: uuid.UUID):
|
|
153
|
+
item = await session.get(Classification, classification_id)
|
|
154
|
+
if item is None:
|
|
155
|
+
raise NotFoundError(
|
|
156
|
+
"分类记录不存在", resource="classification", identifier=str(classification_id)
|
|
157
|
+
)
|
|
158
|
+
return item
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.put("/classifications/{classification_id}", response_model=ClassificationRead)
|
|
162
|
+
async def override_classification(
|
|
163
|
+
classification_id: uuid.UUID,
|
|
164
|
+
payload: ClassificationOverride,
|
|
165
|
+
session: SessionDep,
|
|
166
|
+
user: CurrentUserDep,
|
|
167
|
+
_: Classify,
|
|
168
|
+
) -> ClassificationRead:
|
|
169
|
+
"""人工指定分类。
|
|
170
|
+
|
|
171
|
+
这是唯一能让某条记录"定格"的操作:此后规则重算与 AI 补判都不会改写它。
|
|
172
|
+
没有这条路径,维护者纠正过一次又被改回去,就不会再信任自动分类。
|
|
173
|
+
"""
|
|
174
|
+
item = await _require_classification(session, classification_id)
|
|
175
|
+
await classification_service.override(
|
|
176
|
+
session, item, kind=payload.kind, subsystem=payload.subsystem, user_id=user.id
|
|
177
|
+
)
|
|
178
|
+
await session.commit()
|
|
179
|
+
return ClassificationRead.model_validate(item)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.delete("/classifications/{classification_id}/override", response_model=ClassificationRead)
|
|
183
|
+
async def clear_classification_override(
|
|
184
|
+
classification_id: uuid.UUID,
|
|
185
|
+
session: SessionDep,
|
|
186
|
+
_: Classify,
|
|
187
|
+
) -> ClassificationRead:
|
|
188
|
+
"""撤销人工覆盖,交还给自动分类。下次重算会重新判定。"""
|
|
189
|
+
item = await _require_classification(session, classification_id)
|
|
190
|
+
await classification_service.clear_override(session, item)
|
|
191
|
+
await session.commit()
|
|
192
|
+
return ClassificationRead.model_validate(item)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""健康检查。"""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Response, status
|
|
4
|
+
from sqlalchemy import text
|
|
5
|
+
|
|
6
|
+
from app.api.deps import SessionDep
|
|
7
|
+
|
|
8
|
+
router = APIRouter(tags=["health"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/healthz", summary="存活检查")
|
|
12
|
+
async def healthz() -> dict[str, str]:
|
|
13
|
+
return {"status": "ok"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/readyz", summary="就绪检查(含依赖连通性)")
|
|
17
|
+
async def readyz(session: SessionDep, response: Response) -> dict[str, object]:
|
|
18
|
+
checks: dict[str, str] = {}
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
await session.execute(text("SELECT 1"))
|
|
22
|
+
checks["database"] = "ok"
|
|
23
|
+
except Exception as exc:
|
|
24
|
+
checks["database"] = f"error: {exc}"
|
|
25
|
+
|
|
26
|
+
healthy = all(v == "ok" for v in checks.values())
|
|
27
|
+
if not healthy:
|
|
28
|
+
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
|
29
|
+
return {"status": "ok" if healthy else "degraded", "checks": checks}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Issue API。"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Annotated, Literal
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Query
|
|
7
|
+
from sqlalchemy import Select, String, cast, func, or_, select
|
|
8
|
+
|
|
9
|
+
from app.api.deps import CurrentUserDep, SessionDep
|
|
10
|
+
from app.core.exceptions import NotFoundError
|
|
11
|
+
from app.models.classification import Classification, PRKind, SubjectType
|
|
12
|
+
from app.models.issue import Issue
|
|
13
|
+
from app.schemas.common import Page
|
|
14
|
+
from app.schemas.pull_request import IssueDetail, IssueListItem, IssueSummary
|
|
15
|
+
from app.services import classification_service, repository_service
|
|
16
|
+
|
|
17
|
+
router = APIRouter(tags=["issues"])
|
|
18
|
+
|
|
19
|
+
IssueSort = Literal["updated", "created", "comments"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _base_query(repository_id: uuid.UUID) -> Select:
|
|
23
|
+
return select(Issue).where(Issue.repository_id == repository_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("/repositories/{repository_id}/issues", response_model=Page[IssueListItem])
|
|
27
|
+
async def list_issues(
|
|
28
|
+
repository_id: uuid.UUID,
|
|
29
|
+
session: SessionDep,
|
|
30
|
+
_: CurrentUserDep,
|
|
31
|
+
state: Annotated[str | None, Query(pattern="^(open|closed|all)$")] = "open",
|
|
32
|
+
issue_type: str | None = None,
|
|
33
|
+
kind: Annotated[list[PRKind] | None, Query()] = None,
|
|
34
|
+
priority: str | None = None,
|
|
35
|
+
author: str | None = None,
|
|
36
|
+
assignee: str | None = None,
|
|
37
|
+
label: str | None = None,
|
|
38
|
+
q: str | None = None,
|
|
39
|
+
sort: IssueSort = "updated",
|
|
40
|
+
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
|
|
41
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
42
|
+
per_page: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
43
|
+
) -> Page[IssueListItem]:
|
|
44
|
+
await repository_service.require_repository(session, repository_id)
|
|
45
|
+
|
|
46
|
+
stmt = _base_query(repository_id)
|
|
47
|
+
if state and state != "all":
|
|
48
|
+
stmt = stmt.where(Issue.state == state)
|
|
49
|
+
if issue_type:
|
|
50
|
+
stmt = stmt.where(Issue.issue_type == issue_type)
|
|
51
|
+
if kind:
|
|
52
|
+
# 半连接:Issue 与分类一对一,JOIN 不改变行数却要引入列名限定
|
|
53
|
+
stmt = stmt.where(
|
|
54
|
+
Issue.id.in_(
|
|
55
|
+
select(Classification.subject_id).where(
|
|
56
|
+
Classification.subject_type == SubjectType.ISSUE,
|
|
57
|
+
Classification.kind.in_(kind),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
if priority:
|
|
62
|
+
stmt = stmt.where(Issue.priority_label == priority)
|
|
63
|
+
if author:
|
|
64
|
+
stmt = stmt.where(Issue.author_login == author)
|
|
65
|
+
if assignee:
|
|
66
|
+
stmt = stmt.where(Issue.assignee_login == assignee)
|
|
67
|
+
if label:
|
|
68
|
+
stmt = stmt.where(Issue.label_names.contains([label]))
|
|
69
|
+
if q:
|
|
70
|
+
pattern = f"%{q}%"
|
|
71
|
+
stmt = stmt.where(
|
|
72
|
+
or_(
|
|
73
|
+
Issue.title.ilike(pattern),
|
|
74
|
+
cast(Issue.number, String).ilike(pattern),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
total = await session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
|
79
|
+
|
|
80
|
+
columns = {
|
|
81
|
+
"updated": Issue.atomgit_updated_at,
|
|
82
|
+
"created": Issue.atomgit_created_at,
|
|
83
|
+
"comments": Issue.comments_count,
|
|
84
|
+
}
|
|
85
|
+
column = columns[sort]
|
|
86
|
+
stmt = (
|
|
87
|
+
stmt.order_by(column.asc() if order == "asc" else column.desc(), Issue.number.desc())
|
|
88
|
+
.offset((page - 1) * per_page)
|
|
89
|
+
.limit(per_page)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
rows = (await session.scalars(stmt)).all()
|
|
93
|
+
rows = list(rows)
|
|
94
|
+
# 一次性取回本页分类:Issue 有 2000+ 条,逐条查会拖垮列表
|
|
95
|
+
classified = await classification_service.classifications_for(
|
|
96
|
+
session, SubjectType.ISSUE, [row.id for row in rows]
|
|
97
|
+
)
|
|
98
|
+
items = [
|
|
99
|
+
IssueListItem(
|
|
100
|
+
**IssueSummary.model_validate(row).model_dump(),
|
|
101
|
+
kind=classified[row.id].kind if row.id in classified else None,
|
|
102
|
+
)
|
|
103
|
+
for row in rows
|
|
104
|
+
]
|
|
105
|
+
return Page.create(items, total, page, per_page)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/issues/{issue_id}", response_model=IssueDetail)
|
|
109
|
+
async def get_issue(issue_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -> IssueDetail:
|
|
110
|
+
issue = await session.get(Issue, issue_id)
|
|
111
|
+
if issue is None:
|
|
112
|
+
raise NotFoundError("Issue 不存在", resource="issue", identifier=str(issue_id))
|
|
113
|
+
detail = IssueDetail.model_validate(issue)
|
|
114
|
+
detail.body = issue.body
|
|
115
|
+
detail.finished_at = issue.finished_at
|
|
116
|
+
return detail
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.get("/repositories/{repository_id}/issues/facets")
|
|
120
|
+
async def issue_facets(
|
|
121
|
+
repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
|
|
122
|
+
) -> dict[str, list[dict]]:
|
|
123
|
+
"""筛选项取值分布,供前端渲染下拉与计数。
|
|
124
|
+
|
|
125
|
+
使用 AtomGit 原生的 issue_type / priority 字段,
|
|
126
|
+
而非从标签猜测 —— 原生字段比标签更可靠。
|
|
127
|
+
"""
|
|
128
|
+
await repository_service.require_repository(session, repository_id)
|
|
129
|
+
|
|
130
|
+
async def group_by(column) -> list[dict]:
|
|
131
|
+
stmt = (
|
|
132
|
+
select(column, func.count())
|
|
133
|
+
.where(Issue.repository_id == repository_id, column.is_not(None))
|
|
134
|
+
.group_by(column)
|
|
135
|
+
.order_by(func.count().desc())
|
|
136
|
+
)
|
|
137
|
+
return [{"value": row[0], "count": row[1]} for row in (await session.execute(stmt)).all()]
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"types": await group_by(Issue.issue_type),
|
|
141
|
+
"priorities": await group_by(Issue.priority_label),
|
|
142
|
+
"states": await group_by(Issue.issue_state),
|
|
143
|
+
}
|