@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,388 @@
|
|
|
1
|
+
"""Pull Request API。
|
|
2
|
+
|
|
3
|
+
列表支持组合筛选 —— 这是维护者日常使用的主入口:
|
|
4
|
+
1124+ 个 open PR,没有强筛选等于没有列表。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, Query
|
|
12
|
+
from sqlalchemy import func, select
|
|
13
|
+
|
|
14
|
+
from app.api.deps import CurrentUserDep, SessionDep, require_permission
|
|
15
|
+
from app.core.exceptions import NotFoundError
|
|
16
|
+
from app.core.permissions import Permission
|
|
17
|
+
from app.domain.review import derive_gate, is_bot
|
|
18
|
+
from app.models.classification import PRKind, SubjectType
|
|
19
|
+
from app.models.pull_request import PRComment, PRCommit, PRFile, PullRequest, ReviewEvent
|
|
20
|
+
from app.schemas.common import MessageResponse, Page
|
|
21
|
+
from app.schemas.pull_request import (
|
|
22
|
+
FacetRead,
|
|
23
|
+
FacetResponse,
|
|
24
|
+
FacetValueRead,
|
|
25
|
+
PRCommentRead,
|
|
26
|
+
PRCommitRead,
|
|
27
|
+
PRFileRead,
|
|
28
|
+
PullDetail,
|
|
29
|
+
PullListItem,
|
|
30
|
+
PullSummary,
|
|
31
|
+
ReviewEventRead,
|
|
32
|
+
StageCount,
|
|
33
|
+
)
|
|
34
|
+
from app.services import (
|
|
35
|
+
classification_service,
|
|
36
|
+
pull_query,
|
|
37
|
+
release_service,
|
|
38
|
+
repository_service,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
router = APIRouter(tags=["pulls"])
|
|
42
|
+
|
|
43
|
+
SortField = Literal["updated", "created", "waiting", "lines", "comments"]
|
|
44
|
+
|
|
45
|
+
SORT_COLUMNS = {
|
|
46
|
+
"updated": PullRequest.atomgit_updated_at,
|
|
47
|
+
"created": PullRequest.atomgit_created_at,
|
|
48
|
+
# 等待时长:创建越早、仍处于待评审,等待越久
|
|
49
|
+
"waiting": PullRequest.atomgit_created_at,
|
|
50
|
+
"lines": (PullRequest.added_lines + PullRequest.removed_lines),
|
|
51
|
+
"comments": PullRequest.comments_count,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _branch_alias(session: SessionDep, repository_id: uuid.UUID) -> dict[str, list[str]]:
|
|
56
|
+
"""规范化分支名 → 库中实际写法的映射。
|
|
57
|
+
|
|
58
|
+
分面里显示的是规范化名(同一个交付的不同写法已合并),而筛选时要展开成
|
|
59
|
+
库里的原始写法 —— 不展开就会"勾了却空结果"。
|
|
60
|
+
"""
|
|
61
|
+
rows = await release_service.list_branches(session, repository_id)
|
|
62
|
+
return {row.name: row.raw_names for row in rows}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _selection(
|
|
66
|
+
*,
|
|
67
|
+
state: str | None,
|
|
68
|
+
stage: list[str] | None,
|
|
69
|
+
kind: list[str] | None,
|
|
70
|
+
branch: list[str] | None,
|
|
71
|
+
author: list[str] | None,
|
|
72
|
+
attention: list[str] | None,
|
|
73
|
+
draft: bool | None,
|
|
74
|
+
conflict: bool | None,
|
|
75
|
+
q: str | None,
|
|
76
|
+
waiting_days_gte: int | None,
|
|
77
|
+
) -> pull_query.Selection:
|
|
78
|
+
return pull_query.Selection(
|
|
79
|
+
state=None if state == "all" else state,
|
|
80
|
+
stage=list(stage or []),
|
|
81
|
+
kind=list(kind or []),
|
|
82
|
+
branch=list(branch or []),
|
|
83
|
+
author=list(author or []),
|
|
84
|
+
attention=list(attention or []),
|
|
85
|
+
draft=draft,
|
|
86
|
+
conflict=conflict,
|
|
87
|
+
search=q,
|
|
88
|
+
waiting_days_gte=waiting_days_gte,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.get("/repositories/{repository_id}/pulls", response_model=Page[PullListItem])
|
|
93
|
+
async def list_pulls(
|
|
94
|
+
repository_id: uuid.UUID,
|
|
95
|
+
session: SessionDep,
|
|
96
|
+
_: CurrentUserDep,
|
|
97
|
+
state: Annotated[str | None, Query(pattern="^(open|closed|merged|all)$")] = "open",
|
|
98
|
+
stage: Annotated[list[str] | None, Query()] = None,
|
|
99
|
+
kind: Annotated[list[PRKind] | None, Query()] = None,
|
|
100
|
+
branch: Annotated[list[str] | None, Query()] = None,
|
|
101
|
+
author: Annotated[list[str] | None, Query()] = None,
|
|
102
|
+
attention: Annotated[list[str] | None, Query()] = None,
|
|
103
|
+
draft: bool | None = None,
|
|
104
|
+
has_conflict: bool | None = None,
|
|
105
|
+
q: str | None = None,
|
|
106
|
+
waiting_days_gte: int | None = None,
|
|
107
|
+
sort: SortField = "updated",
|
|
108
|
+
order: Annotated[Literal["asc", "desc"], Query()] = "desc",
|
|
109
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
110
|
+
per_page: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
111
|
+
) -> Page[PullListItem]:
|
|
112
|
+
await repository_service.require_repository(session, repository_id)
|
|
113
|
+
alias = await _branch_alias(session, repository_id)
|
|
114
|
+
|
|
115
|
+
# 筛选逻辑与分面共用同一份定义(pull_query),否则会出现
|
|
116
|
+
# "列表 12 条、分面计数 15" 这种没人能一眼看出的偏差
|
|
117
|
+
selection = _selection(
|
|
118
|
+
state=state,
|
|
119
|
+
stage=stage,
|
|
120
|
+
kind=[item.value for item in kind] if kind else None,
|
|
121
|
+
branch=branch,
|
|
122
|
+
author=author,
|
|
123
|
+
attention=attention,
|
|
124
|
+
draft=draft,
|
|
125
|
+
conflict=has_conflict,
|
|
126
|
+
q=q,
|
|
127
|
+
waiting_days_gte=waiting_days_gte,
|
|
128
|
+
)
|
|
129
|
+
base = pull_query.apply_filters(
|
|
130
|
+
select(PullRequest), repository_id, selection, branch_alias=alias
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
total = await session.scalar(select(func.count()).select_from(base.subquery())) or 0
|
|
134
|
+
|
|
135
|
+
column = SORT_COLUMNS[sort]
|
|
136
|
+
ordered = base.order_by(
|
|
137
|
+
column.asc() if order == "asc" else column.desc(), PullRequest.number.desc()
|
|
138
|
+
)
|
|
139
|
+
pulls = list(
|
|
140
|
+
(await session.scalars(ordered.offset((page - 1) * per_page).limit(per_page))).all()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 一次性取回本页的分类,避免逐条查询。列表页看不到类别的话,
|
|
144
|
+
# 分类就只能在详情页一条条点开看 —— 那等于没做分流。
|
|
145
|
+
classified = await classification_service.classifications_for(
|
|
146
|
+
session, SubjectType.PULL_REQUEST, [pull.id for pull in pulls]
|
|
147
|
+
)
|
|
148
|
+
items = [
|
|
149
|
+
PullListItem(
|
|
150
|
+
**PullSummary.model_validate(pull).model_dump(),
|
|
151
|
+
kind=classified[pull.id].kind if pull.id in classified else None,
|
|
152
|
+
)
|
|
153
|
+
for pull in pulls
|
|
154
|
+
]
|
|
155
|
+
return Page.create(items, total, page, per_page)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/repositories/{repository_id}/pulls/facets", response_model=FacetResponse)
|
|
159
|
+
async def pull_facets(
|
|
160
|
+
repository_id: uuid.UUID,
|
|
161
|
+
session: SessionDep,
|
|
162
|
+
_: CurrentUserDep,
|
|
163
|
+
state: Annotated[str | None, Query(pattern="^(open|closed|merged|all)$")] = "open",
|
|
164
|
+
stage: Annotated[list[str] | None, Query()] = None,
|
|
165
|
+
kind: Annotated[list[PRKind] | None, Query()] = None,
|
|
166
|
+
branch: Annotated[list[str] | None, Query()] = None,
|
|
167
|
+
author: Annotated[list[str] | None, Query()] = None,
|
|
168
|
+
attention: Annotated[list[str] | None, Query()] = None,
|
|
169
|
+
draft: bool | None = None,
|
|
170
|
+
has_conflict: bool | None = None,
|
|
171
|
+
q: str | None = None,
|
|
172
|
+
waiting_days_gte: int | None = None,
|
|
173
|
+
) -> FacetResponse:
|
|
174
|
+
"""各筛选维度的取值与计数。
|
|
175
|
+
|
|
176
|
+
某一维度的计数**不含该维度自身的筛选**:勾上 CVE 之后,分支一栏仍然
|
|
177
|
+
给出各分支的真实数量,使用者才能在此基础上继续放宽或收窄。
|
|
178
|
+
少了这一条,分面就退化成只能全部清空的单选列表。
|
|
179
|
+
"""
|
|
180
|
+
await repository_service.require_repository(session, repository_id)
|
|
181
|
+
alias = await _branch_alias(session, repository_id)
|
|
182
|
+
|
|
183
|
+
selection = _selection(
|
|
184
|
+
state=state,
|
|
185
|
+
stage=stage,
|
|
186
|
+
kind=[item.value for item in kind] if kind else None,
|
|
187
|
+
branch=branch,
|
|
188
|
+
author=author,
|
|
189
|
+
attention=attention,
|
|
190
|
+
draft=draft,
|
|
191
|
+
conflict=has_conflict,
|
|
192
|
+
q=q,
|
|
193
|
+
waiting_days_gte=waiting_days_gte,
|
|
194
|
+
)
|
|
195
|
+
facets = await pull_query.build_facets(session, repository_id, selection, branch_alias=alias)
|
|
196
|
+
matched = await session.scalar(
|
|
197
|
+
select(func.count()).select_from(
|
|
198
|
+
pull_query.apply_filters(
|
|
199
|
+
select(PullRequest), repository_id, selection, branch_alias=alias
|
|
200
|
+
).subquery()
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return FacetResponse(
|
|
204
|
+
total=matched or 0,
|
|
205
|
+
facets=[
|
|
206
|
+
FacetRead(
|
|
207
|
+
key=facet.key,
|
|
208
|
+
label=facet.label,
|
|
209
|
+
values=[
|
|
210
|
+
FacetValueRead(
|
|
211
|
+
value=item.value,
|
|
212
|
+
label=item.label,
|
|
213
|
+
count=item.count,
|
|
214
|
+
selected=item.selected,
|
|
215
|
+
)
|
|
216
|
+
for item in facet.values
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
for facet in facets
|
|
220
|
+
],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@router.get("/repositories/{repository_id}/pulls/stage-counts", response_model=list[StageCount])
|
|
225
|
+
async def stage_counts(
|
|
226
|
+
repository_id: uuid.UUID,
|
|
227
|
+
session: SessionDep,
|
|
228
|
+
_: CurrentUserDep,
|
|
229
|
+
state: str | None = "open",
|
|
230
|
+
) -> list[StageCount]:
|
|
231
|
+
"""各门禁阶段的 PR 数量,用于列表页的筛选标签。
|
|
232
|
+
|
|
233
|
+
只统计指定状态的 PR:把已合并的数量混进来会淹没待办。
|
|
234
|
+
"""
|
|
235
|
+
await repository_service.require_repository(session, repository_id)
|
|
236
|
+
stmt = select(PullRequest.gate_stage, func.count()).where(
|
|
237
|
+
PullRequest.repository_id == repository_id
|
|
238
|
+
)
|
|
239
|
+
if state:
|
|
240
|
+
stmt = stmt.where(PullRequest.state == state)
|
|
241
|
+
rows = (await session.execute(stmt.group_by(PullRequest.gate_stage))).all()
|
|
242
|
+
return [StageCount(stage=row[0], count=row[1]) for row in rows]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.get("/repositories/{repository_id}/pulls/authors", response_model=list[str])
|
|
246
|
+
async def pull_authors(
|
|
247
|
+
repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
"""出现过的作者列表,供筛选下拉使用。"""
|
|
250
|
+
stmt = (
|
|
251
|
+
select(PullRequest.author_login)
|
|
252
|
+
.where(
|
|
253
|
+
PullRequest.repository_id == repository_id,
|
|
254
|
+
PullRequest.author_login.is_not(None),
|
|
255
|
+
)
|
|
256
|
+
.group_by(PullRequest.author_login)
|
|
257
|
+
.order_by(func.count().desc())
|
|
258
|
+
.limit(100)
|
|
259
|
+
)
|
|
260
|
+
return [row for row in (await session.scalars(stmt)).all() if row]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.get("/repositories/{repository_id}/pulls/branches", response_model=list[str])
|
|
264
|
+
async def pull_branches(
|
|
265
|
+
repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
stmt = (
|
|
268
|
+
select(PullRequest.target_branch)
|
|
269
|
+
.where(
|
|
270
|
+
PullRequest.repository_id == repository_id,
|
|
271
|
+
PullRequest.target_branch.is_not(None),
|
|
272
|
+
)
|
|
273
|
+
.group_by(PullRequest.target_branch)
|
|
274
|
+
.order_by(func.count().desc())
|
|
275
|
+
)
|
|
276
|
+
return [row for row in (await session.scalars(stmt)).all() if row]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def _require_pull(session: SessionDep, pull_id: uuid.UUID) -> PullRequest:
|
|
280
|
+
pull = await session.get(PullRequest, pull_id)
|
|
281
|
+
if pull is None:
|
|
282
|
+
raise NotFoundError("PR 不存在", resource="pull_request", identifier=str(pull_id))
|
|
283
|
+
return pull
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.get("/pulls/{pull_id}", response_model=PullDetail)
|
|
287
|
+
async def get_pull(pull_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -> PullDetail:
|
|
288
|
+
pull = await _require_pull(session, pull_id)
|
|
289
|
+
|
|
290
|
+
commits = (
|
|
291
|
+
await session.scalars(
|
|
292
|
+
select(PRCommit).where(PRCommit.pull_request_id == pull_id).order_by(PRCommit.sequence)
|
|
293
|
+
)
|
|
294
|
+
).all()
|
|
295
|
+
files = (
|
|
296
|
+
await session.scalars(
|
|
297
|
+
select(PRFile).where(PRFile.pull_request_id == pull_id).order_by(PRFile.filename)
|
|
298
|
+
)
|
|
299
|
+
).all()
|
|
300
|
+
events = (
|
|
301
|
+
await session.scalars(
|
|
302
|
+
select(ReviewEvent)
|
|
303
|
+
.where(ReviewEvent.pull_request_id == pull_id)
|
|
304
|
+
.order_by(ReviewEvent.occurred_at)
|
|
305
|
+
)
|
|
306
|
+
).all()
|
|
307
|
+
|
|
308
|
+
comments = (
|
|
309
|
+
await session.scalars(
|
|
310
|
+
select(PRComment)
|
|
311
|
+
.where(PRComment.pull_request_id == pull_id)
|
|
312
|
+
.order_by(PRComment.atomgit_created_at, PRComment.id)
|
|
313
|
+
)
|
|
314
|
+
).all()
|
|
315
|
+
|
|
316
|
+
# 门禁由后端推导后下发,前端无需复刻规则 —— 规则只有一份,不会前后端漂移
|
|
317
|
+
gate = derive_gate(pull.label_names or [], is_draft=pull.draft, state=pull.state)
|
|
318
|
+
|
|
319
|
+
detail = PullDetail.model_validate(pull)
|
|
320
|
+
detail.body = pull.body
|
|
321
|
+
detail.source_branch = pull.source_branch
|
|
322
|
+
detail.mergeable = pull.mergeable
|
|
323
|
+
detail.mergeable_detail = pull.merge_check_detail
|
|
324
|
+
detail.blocking_reasons = gate.blocking_reasons
|
|
325
|
+
detail.ready_to_merge = gate.ready_to_merge
|
|
326
|
+
detail.lgtm_actors = sorted(gate.lgtm_actors)
|
|
327
|
+
detail.commits = [PRCommitRead.model_validate(c) for c in commits]
|
|
328
|
+
detail.files = [PRFileRead.model_validate(f) for f in files]
|
|
329
|
+
detail.review_events = [ReviewEventRead.model_validate(e) for e in events]
|
|
330
|
+
detail.comments = [
|
|
331
|
+
PRCommentRead(
|
|
332
|
+
comment_id=comment.comment_id,
|
|
333
|
+
comment_type=comment.comment_type,
|
|
334
|
+
author_login=comment.author_login,
|
|
335
|
+
author_avatar_url=comment.author_avatar_url,
|
|
336
|
+
body=comment.body,
|
|
337
|
+
atomgit_created_at=comment.atomgit_created_at,
|
|
338
|
+
is_bot=is_bot(comment.author_login),
|
|
339
|
+
)
|
|
340
|
+
for comment in comments
|
|
341
|
+
]
|
|
342
|
+
return detail
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@router.post("/pulls/{pull_id}/recompute-gate", response_model=MessageResponse)
|
|
346
|
+
async def recompute_gate(
|
|
347
|
+
pull_id: uuid.UUID,
|
|
348
|
+
session: SessionDep,
|
|
349
|
+
_: Annotated[object, Depends(require_permission(Permission.CLASSIFY))],
|
|
350
|
+
) -> MessageResponse:
|
|
351
|
+
"""按当前标签重算门禁阶段。
|
|
352
|
+
|
|
353
|
+
标签在两次同步之间可能被上游改动,提供手动重算入口,
|
|
354
|
+
避免维护者只能等下一次同步。
|
|
355
|
+
"""
|
|
356
|
+
pull = await _require_pull(session, pull_id)
|
|
357
|
+
gate = derive_gate(pull.label_names or [], is_draft=pull.draft, state=pull.state)
|
|
358
|
+
previous, pull.gate_stage = pull.gate_stage, gate.stage.value
|
|
359
|
+
await session.commit()
|
|
360
|
+
return MessageResponse(
|
|
361
|
+
message=f"门禁阶段已从 {previous} 更新为 {pull.gate_stage}"
|
|
362
|
+
if previous != pull.gate_stage
|
|
363
|
+
else f"门禁阶段未变化({pull.gate_stage})"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@router.get("/repositories/{repository_id}/pulls/stale", response_model=list[PullSummary])
|
|
368
|
+
async def stale_pulls(
|
|
369
|
+
repository_id: uuid.UUID,
|
|
370
|
+
session: SessionDep,
|
|
371
|
+
_: CurrentUserDep,
|
|
372
|
+
days: Annotated[int, Query(ge=1, le=365)] = 30,
|
|
373
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
374
|
+
) -> list[PullSummary]:
|
|
375
|
+
"""长期无活动的 PR。关注队列的核心规则之一。"""
|
|
376
|
+
await repository_service.require_repository(session, repository_id)
|
|
377
|
+
cutoff_dt = datetime.now(UTC) - timedelta(days=days)
|
|
378
|
+
stmt = (
|
|
379
|
+
select(PullRequest)
|
|
380
|
+
.where(
|
|
381
|
+
PullRequest.repository_id == repository_id,
|
|
382
|
+
PullRequest.state == "open",
|
|
383
|
+
PullRequest.atomgit_updated_at < cutoff_dt,
|
|
384
|
+
)
|
|
385
|
+
.order_by(PullRequest.atomgit_updated_at.asc())
|
|
386
|
+
.limit(limit)
|
|
387
|
+
)
|
|
388
|
+
return [PullSummary.model_validate(row) for row in (await session.scalars(stmt)).all()]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""仓库、凭据与同步的 API。"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query, status
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
|
|
9
|
+
from app.api.deps import CurrentUserDep, SessionDep, SettingsDep, require_permission
|
|
10
|
+
from app.core.exceptions import ConflictError, ExternalServiceError, NotFoundError
|
|
11
|
+
from app.core.permissions import Permission
|
|
12
|
+
from app.integrations.atomgit.client import AtomGitAuthError, AtomGitClient
|
|
13
|
+
from app.models.credential import Credential, CredentialKind
|
|
14
|
+
from app.models.issue import Issue
|
|
15
|
+
from app.models.pull_request import PullRequest
|
|
16
|
+
from app.models.repository import Repository, SyncRun, SyncTrigger
|
|
17
|
+
from app.schemas.common import Page
|
|
18
|
+
from app.schemas.repository import (
|
|
19
|
+
CredentialCreate,
|
|
20
|
+
CredentialRead,
|
|
21
|
+
CredentialRotate,
|
|
22
|
+
RepositoryCreate,
|
|
23
|
+
RepositoryRead,
|
|
24
|
+
RepositoryUpdate,
|
|
25
|
+
SyncRunRead,
|
|
26
|
+
)
|
|
27
|
+
from app.services import credential_service, repository_service, sync_service
|
|
28
|
+
|
|
29
|
+
router = APIRouter(tags=["repositories"])
|
|
30
|
+
|
|
31
|
+
ManageRepo = Annotated[object, Depends(require_permission(Permission.MANAGE_REPOSITORY))]
|
|
32
|
+
ManageCred = Annotated[object, Depends(require_permission(Permission.MANAGE_CREDENTIALS))]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# 凭据
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get("/credentials", response_model=list[CredentialRead])
|
|
41
|
+
async def list_credentials(
|
|
42
|
+
session: SessionDep,
|
|
43
|
+
_: ManageCred,
|
|
44
|
+
kind: CredentialKind | None = None,
|
|
45
|
+
) -> list[CredentialRead]:
|
|
46
|
+
items = await credential_service.list_credentials(session, kind=kind)
|
|
47
|
+
return [CredentialRead.model_validate(item) for item in items]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/credentials", response_model=CredentialRead, status_code=status.HTTP_201_CREATED)
|
|
51
|
+
async def create_credential(
|
|
52
|
+
payload: CredentialCreate,
|
|
53
|
+
session: SessionDep,
|
|
54
|
+
settings: SettingsDep,
|
|
55
|
+
user: CurrentUserDep,
|
|
56
|
+
_: ManageCred,
|
|
57
|
+
) -> CredentialRead:
|
|
58
|
+
try:
|
|
59
|
+
credential = await credential_service.create_credential(
|
|
60
|
+
session,
|
|
61
|
+
settings,
|
|
62
|
+
name=payload.name,
|
|
63
|
+
kind=payload.kind,
|
|
64
|
+
plaintext=payload.value,
|
|
65
|
+
meta=payload.meta,
|
|
66
|
+
created_by=user.id,
|
|
67
|
+
)
|
|
68
|
+
except ConflictError:
|
|
69
|
+
raise
|
|
70
|
+
await session.commit()
|
|
71
|
+
return CredentialRead.model_validate(credential)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.post("/credentials/{credential_id}/verify", response_model=CredentialRead)
|
|
75
|
+
async def verify_credential(
|
|
76
|
+
credential_id: uuid.UUID, session: SessionDep, settings: SettingsDep, _: ManageCred
|
|
77
|
+
) -> CredentialRead:
|
|
78
|
+
"""用 /user 校验凭据并回显账号名,让配置者确认 token 配对无误。"""
|
|
79
|
+
credential = await credential_service.require_credential(session, credential_id)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
async with AtomGitClient(credential_service.reveal(settings, credential)) as client:
|
|
83
|
+
account = await client.get_authenticated_user()
|
|
84
|
+
except AtomGitAuthError as exc:
|
|
85
|
+
credential_service.mark_verified(credential, error=str(exc))
|
|
86
|
+
await session.commit()
|
|
87
|
+
raise ExternalServiceError(str(exc)) from exc
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
credential_service.mark_verified(credential, error=str(exc))
|
|
90
|
+
await session.commit()
|
|
91
|
+
raise ExternalServiceError(f"凭据校验失败:{exc}") from exc
|
|
92
|
+
|
|
93
|
+
credential_service.mark_verified(credential)
|
|
94
|
+
credential.meta = {**(credential.meta or {}), "login": account.login}
|
|
95
|
+
await session.commit()
|
|
96
|
+
return CredentialRead.model_validate(credential)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.post("/credentials/{credential_id}/rotate", response_model=CredentialRead)
|
|
100
|
+
async def rotate_credential(
|
|
101
|
+
credential_id: uuid.UUID,
|
|
102
|
+
payload: CredentialRotate,
|
|
103
|
+
session: SessionDep,
|
|
104
|
+
settings: SettingsDep,
|
|
105
|
+
_: ManageCred,
|
|
106
|
+
) -> CredentialRead:
|
|
107
|
+
credential = await credential_service.require_credential(session, credential_id)
|
|
108
|
+
await credential_service.update_credential_value(session, settings, credential, payload.value)
|
|
109
|
+
await session.commit()
|
|
110
|
+
return CredentialRead.model_validate(credential)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.delete("/credentials/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
114
|
+
async def delete_credential(credential_id: uuid.UUID, session: SessionDep, _: ManageCred) -> None:
|
|
115
|
+
credential = await credential_service.require_credential(session, credential_id)
|
|
116
|
+
in_use = await session.scalar(
|
|
117
|
+
select(func.count())
|
|
118
|
+
.select_from(Repository)
|
|
119
|
+
.where(Repository.credential_id == credential_id)
|
|
120
|
+
)
|
|
121
|
+
if in_use:
|
|
122
|
+
raise ConflictError(f"该凭据正被 {in_use} 个仓库使用,无法删除")
|
|
123
|
+
await credential_service.delete_credential(session, credential)
|
|
124
|
+
await session.commit()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# 仓库
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _decorate(session: SessionDep, repository: Repository) -> RepositoryRead:
|
|
133
|
+
data = RepositoryRead.model_validate(repository)
|
|
134
|
+
data.pull_count = await session.scalar(
|
|
135
|
+
select(func.count())
|
|
136
|
+
.select_from(PullRequest)
|
|
137
|
+
.where(PullRequest.repository_id == repository.id)
|
|
138
|
+
)
|
|
139
|
+
data.issue_count = await session.scalar(
|
|
140
|
+
select(func.count()).select_from(Issue).where(Issue.repository_id == repository.id)
|
|
141
|
+
)
|
|
142
|
+
if repository.webhook_secret:
|
|
143
|
+
data.webhook_path = f"/api/v1/webhooks/atomgit/{repository.webhook_secret}"
|
|
144
|
+
return data
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.get("/repositories", response_model=list[RepositoryRead])
|
|
148
|
+
async def list_repositories(session: SessionDep, _: CurrentUserDep) -> list[RepositoryRead]:
|
|
149
|
+
repositories = await repository_service.list_repositories(session)
|
|
150
|
+
return [await _decorate(session, repo) for repo in repositories]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.post("/repositories", response_model=RepositoryRead, status_code=status.HTTP_201_CREATED)
|
|
154
|
+
async def create_repository(
|
|
155
|
+
payload: RepositoryCreate,
|
|
156
|
+
session: SessionDep,
|
|
157
|
+
settings: SettingsDep,
|
|
158
|
+
_: ManageRepo,
|
|
159
|
+
) -> RepositoryRead:
|
|
160
|
+
repository = await repository_service.create_repository(
|
|
161
|
+
session,
|
|
162
|
+
settings,
|
|
163
|
+
owner=payload.owner,
|
|
164
|
+
name=payload.name,
|
|
165
|
+
credential_id=payload.credential_id,
|
|
166
|
+
display_name=payload.display_name,
|
|
167
|
+
api_base_url=payload.api_base_url,
|
|
168
|
+
tracked_branches=payload.tracked_branches,
|
|
169
|
+
backfill_days=payload.backfill_days,
|
|
170
|
+
)
|
|
171
|
+
await session.commit()
|
|
172
|
+
return await _decorate(session, repository)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.get("/repositories/{repository_id}", response_model=RepositoryRead)
|
|
176
|
+
async def get_repository(
|
|
177
|
+
repository_id: uuid.UUID, session: SessionDep, _: CurrentUserDep
|
|
178
|
+
) -> RepositoryRead:
|
|
179
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
180
|
+
return await _decorate(session, repository)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.patch("/repositories/{repository_id}", response_model=RepositoryRead)
|
|
184
|
+
async def update_repository(
|
|
185
|
+
repository_id: uuid.UUID,
|
|
186
|
+
payload: RepositoryUpdate,
|
|
187
|
+
session: SessionDep,
|
|
188
|
+
_: ManageRepo,
|
|
189
|
+
) -> RepositoryRead:
|
|
190
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
191
|
+
await repository_service.update_repository(
|
|
192
|
+
session, repository, **payload.model_dump(exclude_unset=True)
|
|
193
|
+
)
|
|
194
|
+
await session.commit()
|
|
195
|
+
return await _decorate(session, repository)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.post("/repositories/{repository_id}/rotate-webhook", response_model=RepositoryRead)
|
|
199
|
+
async def rotate_webhook(
|
|
200
|
+
repository_id: uuid.UUID, session: SessionDep, _: ManageRepo
|
|
201
|
+
) -> RepositoryRead:
|
|
202
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
203
|
+
await repository_service.rotate_webhook_secret(session, repository)
|
|
204
|
+
await session.commit()
|
|
205
|
+
return await _decorate(session, repository)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# 同步
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.post("/repositories/{repository_id}/sync", response_model=list[SyncRunRead])
|
|
214
|
+
async def trigger_sync(
|
|
215
|
+
repository_id: uuid.UUID,
|
|
216
|
+
session: SessionDep,
|
|
217
|
+
settings: SettingsDep,
|
|
218
|
+
_: ManageRepo,
|
|
219
|
+
detail_limit: Annotated[int, Query(ge=0, le=500)] = 50,
|
|
220
|
+
) -> list[SyncRunRead]:
|
|
221
|
+
"""手动触发一次完整同步。
|
|
222
|
+
|
|
223
|
+
同步在执行期间会持续占用连接,故此处同步等待完成再返回;
|
|
224
|
+
仓库规模较大时不适宜在请求内长时间阻塞,后续会迁到后台任务队列。
|
|
225
|
+
"""
|
|
226
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
227
|
+
runs = await sync_service.sync_repository(
|
|
228
|
+
session, settings, repository, trigger=SyncTrigger.MANUAL, detail_limit=detail_limit
|
|
229
|
+
)
|
|
230
|
+
return [SyncRunRead.model_validate(run) for run in runs]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@router.get("/repositories/{repository_id}/sync-runs", response_model=Page[SyncRunRead])
|
|
234
|
+
async def list_sync_runs(
|
|
235
|
+
repository_id: uuid.UUID,
|
|
236
|
+
session: SessionDep,
|
|
237
|
+
_: CurrentUserDep,
|
|
238
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
239
|
+
per_page: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
240
|
+
) -> Page[SyncRunRead]:
|
|
241
|
+
await repository_service.require_repository(session, repository_id)
|
|
242
|
+
total = await session.scalar(
|
|
243
|
+
select(func.count()).select_from(SyncRun).where(SyncRun.repository_id == repository_id)
|
|
244
|
+
)
|
|
245
|
+
stmt = (
|
|
246
|
+
select(SyncRun)
|
|
247
|
+
.where(SyncRun.repository_id == repository_id)
|
|
248
|
+
.order_by(SyncRun.started_at.desc())
|
|
249
|
+
.offset((page - 1) * per_page)
|
|
250
|
+
.limit(per_page)
|
|
251
|
+
)
|
|
252
|
+
runs = list((await session.scalars(stmt)).all())
|
|
253
|
+
return Page.create(
|
|
254
|
+
[SyncRunRead.model_validate(run) for run in runs], total or 0, page, per_page
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.get("/credentials/{credential_id}", response_model=CredentialRead)
|
|
259
|
+
async def get_credential(
|
|
260
|
+
credential_id: uuid.UUID, session: SessionDep, _: ManageCred
|
|
261
|
+
) -> CredentialRead:
|
|
262
|
+
credential: Credential = await credential_service.require_credential(session, credential_id)
|
|
263
|
+
return CredentialRead.model_validate(credential)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@router.get("/repositories/{repository_id}/webhook", response_model=RepositoryRead)
|
|
267
|
+
async def get_webhook_config(
|
|
268
|
+
repository_id: uuid.UUID, session: SessionDep, _: ManageRepo
|
|
269
|
+
) -> RepositoryRead:
|
|
270
|
+
repository = await repository_service.require_repository(session, repository_id)
|
|
271
|
+
if not repository.webhook_secret:
|
|
272
|
+
raise NotFoundError("该仓库尚未生成 webhook 密钥")
|
|
273
|
+
return await _decorate(session, repository)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""v1 路由汇总。"""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from app.api.v1 import (
|
|
6
|
+
ai,
|
|
7
|
+
analytics,
|
|
8
|
+
attention,
|
|
9
|
+
auth,
|
|
10
|
+
classification,
|
|
11
|
+
health,
|
|
12
|
+
issues,
|
|
13
|
+
pulls,
|
|
14
|
+
repositories,
|
|
15
|
+
sig,
|
|
16
|
+
users,
|
|
17
|
+
webhooks,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router = APIRouter()
|
|
21
|
+
router.include_router(health.router)
|
|
22
|
+
router.include_router(auth.router)
|
|
23
|
+
router.include_router(users.router)
|
|
24
|
+
router.include_router(repositories.router)
|
|
25
|
+
router.include_router(pulls.router)
|
|
26
|
+
router.include_router(issues.router)
|
|
27
|
+
router.include_router(classification.router)
|
|
28
|
+
router.include_router(sig.router)
|
|
29
|
+
router.include_router(attention.router)
|
|
30
|
+
router.include_router(ai.router)
|
|
31
|
+
router.include_router(analytics.router)
|
|
32
|
+
router.include_router(webhooks.router)
|