@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,505 @@
|
|
|
1
|
+
"""AtomGit / GitCode REST API 客户端。
|
|
2
|
+
|
|
3
|
+
实现依据为**实测行为**而非官方文档:
|
|
4
|
+
|
|
5
|
+
- 认证必须使用 ``private-token`` 请求头。官方文档所载的
|
|
6
|
+
``Authorization: token <TOKEN>`` 在 atomgit.com 上返回 400。
|
|
7
|
+
- 分页总数放在响应头 ``total_count`` / ``total_page``,不在响应体。
|
|
8
|
+
- 文档中描述的若干端点(operate-logs、files-json、issues/{n}/pull-requests 等)
|
|
9
|
+
在 atomgit.com 实例上并不存在,客户端不依赖它们。
|
|
10
|
+
|
|
11
|
+
设计约束:客户端不依赖任何内部模块(models/services),
|
|
12
|
+
只做 HTTP 与数据解析,以便被 service、worker 与测试独立复用。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
import random
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, TypeVar
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import structlog
|
|
25
|
+
from pydantic import BaseModel, ValidationError
|
|
26
|
+
|
|
27
|
+
from app.integrations.atomgit.models import (
|
|
28
|
+
AtomGitComment,
|
|
29
|
+
AtomGitCommit,
|
|
30
|
+
AtomGitFile,
|
|
31
|
+
AtomGitIssue,
|
|
32
|
+
AtomGitLabel,
|
|
33
|
+
AtomGitPullRequest,
|
|
34
|
+
AtomGitRepository,
|
|
35
|
+
AtomGitUser,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = structlog.get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
DEFAULT_BASE_URL = "https://api.atomgit.com/api/v5"
|
|
41
|
+
DEFAULT_TIMEOUT = 30.0
|
|
42
|
+
MAX_PER_PAGE = 100
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T", bound=BaseModel)
|
|
45
|
+
|
|
46
|
+
# 重试策略:仅对可恢复的错误重试
|
|
47
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
48
|
+
MAX_ATTEMPTS = 4
|
|
49
|
+
BASE_BACKOFF_SECONDS = 1.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AtomGitError(Exception):
|
|
53
|
+
"""AtomGit 客户端异常基类。"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AtomGitAuthError(AtomGitError):
|
|
57
|
+
"""凭据无效或权限不足。"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AtomGitNotFoundError(AtomGitError):
|
|
61
|
+
"""资源不存在。"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AtomGitRateLimitError(AtomGitError):
|
|
65
|
+
"""触发上游限流。"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AtomGitUpstreamError(AtomGitError):
|
|
69
|
+
"""上游服务错误(5xx 或网络故障,重试后仍失败)。"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Page[T: BaseModel]:
|
|
74
|
+
"""一页结果,附带分页元信息。"""
|
|
75
|
+
|
|
76
|
+
items: list[T]
|
|
77
|
+
page: int
|
|
78
|
+
per_page: int
|
|
79
|
+
total_count: int | None = None
|
|
80
|
+
total_page: int | None = None
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def has_more(self) -> bool:
|
|
84
|
+
if self.total_page is not None:
|
|
85
|
+
return self.page < self.total_page
|
|
86
|
+
return len(self.items) == self.per_page
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class RequestStats:
|
|
91
|
+
"""累计请求统计,用于同步任务的可观测性。"""
|
|
92
|
+
|
|
93
|
+
requests: int = 0
|
|
94
|
+
retries: int = 0
|
|
95
|
+
errors: int = 0
|
|
96
|
+
by_endpoint: dict[str, int] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AtomGitClient:
|
|
100
|
+
"""AtomGit API 异步客户端。
|
|
101
|
+
|
|
102
|
+
通过 ``base_url`` 参数支持切换到 GitCode 等兼容实例
|
|
103
|
+
(``https://api.gitcode.com/api/v5``);两者 token 通用。
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
token: str,
|
|
109
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
110
|
+
*,
|
|
111
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
112
|
+
user_agent: str = "kernel-sig-console/0.1",
|
|
113
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
114
|
+
max_concurrency: int = 5,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not token:
|
|
117
|
+
raise ValueError("必须提供 AtomGit token")
|
|
118
|
+
self._base_url = base_url.rstrip("/")
|
|
119
|
+
self._client = httpx.AsyncClient(
|
|
120
|
+
base_url=self._base_url,
|
|
121
|
+
timeout=timeout,
|
|
122
|
+
headers={
|
|
123
|
+
"private-token": token,
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
"User-Agent": user_agent,
|
|
126
|
+
},
|
|
127
|
+
transport=transport,
|
|
128
|
+
follow_redirects=True,
|
|
129
|
+
)
|
|
130
|
+
# 并发上限:避免瞬时打爆上游触发限流
|
|
131
|
+
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
132
|
+
self.stats = RequestStats()
|
|
133
|
+
|
|
134
|
+
async def __aenter__(self) -> AtomGitClient:
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
138
|
+
await self.aclose()
|
|
139
|
+
|
|
140
|
+
async def aclose(self) -> None:
|
|
141
|
+
await self._client.aclose()
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# 底层请求
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def _request(
|
|
148
|
+
self,
|
|
149
|
+
method: str,
|
|
150
|
+
path: str,
|
|
151
|
+
*,
|
|
152
|
+
params: dict[str, Any] | None = None,
|
|
153
|
+
json: dict[str, Any] | None = None,
|
|
154
|
+
) -> httpx.Response:
|
|
155
|
+
endpoint = path.split("?")[0]
|
|
156
|
+
self.stats.by_endpoint[endpoint] = self.stats.by_endpoint.get(endpoint, 0) + 1
|
|
157
|
+
|
|
158
|
+
# 保留最后一次失败的具体错误类型。限流(429)也属于可重试状态,
|
|
159
|
+
# 但重试耗尽后应仍然告诉调用方"这是限流",而不是丢失语义变成通用上游错误。
|
|
160
|
+
last_error: AtomGitError | None = None
|
|
161
|
+
|
|
162
|
+
async with self._semaphore:
|
|
163
|
+
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
164
|
+
self.stats.requests += 1
|
|
165
|
+
try:
|
|
166
|
+
response = await self._client.request(method, path, params=params, json=json)
|
|
167
|
+
except httpx.HTTPError as exc:
|
|
168
|
+
last_error = AtomGitUpstreamError(f"网络请求失败:{exc}")
|
|
169
|
+
if attempt == MAX_ATTEMPTS:
|
|
170
|
+
break
|
|
171
|
+
await self._sleep_backoff(attempt)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if response.status_code in RETRYABLE_STATUS:
|
|
175
|
+
detail = self._error_detail(response)
|
|
176
|
+
if response.status_code == 429:
|
|
177
|
+
last_error = AtomGitRateLimitError(f"触发 AtomGit 限流:{detail}")
|
|
178
|
+
else:
|
|
179
|
+
last_error = AtomGitUpstreamError(
|
|
180
|
+
f"{method} {path} 返回 {response.status_code}:{detail}"
|
|
181
|
+
)
|
|
182
|
+
if attempt == MAX_ATTEMPTS:
|
|
183
|
+
break
|
|
184
|
+
self.stats.retries += 1
|
|
185
|
+
await self._sleep_backoff(attempt, response=response)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
self._raise_for_status(response, method, path)
|
|
189
|
+
return response
|
|
190
|
+
|
|
191
|
+
self.stats.errors += 1
|
|
192
|
+
assert last_error is not None # 循环必然赋值后才可能走到这里
|
|
193
|
+
# 保持原异常类型,只在文案上补充重试次数,便于排查上游持续性故障
|
|
194
|
+
raise type(last_error)(f"{last_error}(已重试 {MAX_ATTEMPTS} 次)") from last_error
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _raise_for_status(response: httpx.Response, method: str, path: str) -> None:
|
|
198
|
+
code = response.status_code
|
|
199
|
+
if code < 400:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
detail = AtomGitClient._error_detail(response)
|
|
203
|
+
|
|
204
|
+
if code == 401:
|
|
205
|
+
raise AtomGitAuthError("AtomGit 凭据无效或已过期,请在「设置 → 凭据」中更新 token")
|
|
206
|
+
if code == 403:
|
|
207
|
+
# AtomGit 用 403 同时表达"权限不足"与"触发限流"
|
|
208
|
+
if "rate limit" in detail.lower() or "too many" in detail.lower():
|
|
209
|
+
raise AtomGitRateLimitError(f"触发 AtomGit 限流:{detail}")
|
|
210
|
+
raise AtomGitAuthError(f"当前 token 无权访问 {method} {path}:{detail}")
|
|
211
|
+
if code == 404:
|
|
212
|
+
raise AtomGitNotFoundError(f"资源不存在:{method} {path}")
|
|
213
|
+
if code == 429:
|
|
214
|
+
raise AtomGitRateLimitError(f"触发 AtomGit 限流:{detail}")
|
|
215
|
+
|
|
216
|
+
raise AtomGitUpstreamError(f"{method} {path} 返回 {code}:{detail}")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
220
|
+
try:
|
|
221
|
+
payload = response.json()
|
|
222
|
+
except ValueError:
|
|
223
|
+
return response.text[:200]
|
|
224
|
+
if isinstance(payload, dict):
|
|
225
|
+
for key in ("message", "error_message", "error", "detail"):
|
|
226
|
+
value = payload.get(key)
|
|
227
|
+
if isinstance(value, str) and value:
|
|
228
|
+
return value
|
|
229
|
+
return response.text[:200]
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
async def _sleep_backoff(attempt: int, response: httpx.Response | None = None) -> None:
|
|
233
|
+
"""指数退避 + 抖动。若上游给出 Retry-After 则优先遵守。"""
|
|
234
|
+
delay = BASE_BACKOFF_SECONDS * (2 ** (attempt - 1))
|
|
235
|
+
if response is not None:
|
|
236
|
+
retry_after = response.headers.get("Retry-After")
|
|
237
|
+
if retry_after and retry_after.isdigit():
|
|
238
|
+
delay = max(delay, float(retry_after))
|
|
239
|
+
delay += random.uniform(0, delay * 0.25)
|
|
240
|
+
await asyncio.sleep(min(delay, 30.0))
|
|
241
|
+
|
|
242
|
+
async def _get_page(
|
|
243
|
+
self,
|
|
244
|
+
path: str,
|
|
245
|
+
model: type[T],
|
|
246
|
+
*,
|
|
247
|
+
params: dict[str, Any] | None = None,
|
|
248
|
+
page: int = 1,
|
|
249
|
+
per_page: int = 20,
|
|
250
|
+
) -> Page[T]:
|
|
251
|
+
query = dict(params or {})
|
|
252
|
+
query.update({"page": page, "per_page": min(per_page, MAX_PER_PAGE)})
|
|
253
|
+
response = await self._request("GET", path, params=query)
|
|
254
|
+
|
|
255
|
+
raw = response.json()
|
|
256
|
+
if not isinstance(raw, list):
|
|
257
|
+
raise AtomGitUpstreamError(f"{path} 期望返回列表,实际为 {type(raw).__name__}")
|
|
258
|
+
|
|
259
|
+
items = [self._parse(model, entry, path) for entry in raw]
|
|
260
|
+
|
|
261
|
+
return Page(
|
|
262
|
+
items=items,
|
|
263
|
+
page=page,
|
|
264
|
+
per_page=query["per_page"],
|
|
265
|
+
total_count=self._header_int(response, "total_count"),
|
|
266
|
+
total_page=self._header_int(response, "total_page"),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _header_int(response: httpx.Response, name: str) -> int | None:
|
|
271
|
+
value = response.headers.get(name) or response.headers.get(name.replace("_", "-"))
|
|
272
|
+
if value is None:
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
return int(value)
|
|
276
|
+
except ValueError:
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _parse(model: type[T], payload: Any, path: str) -> T:
|
|
281
|
+
try:
|
|
282
|
+
return model.model_validate(payload)
|
|
283
|
+
except ValidationError as exc:
|
|
284
|
+
# 上游字段变化时,明确报出是哪个模型、哪个字段,而不是静默丢弃
|
|
285
|
+
raise AtomGitUpstreamError(
|
|
286
|
+
f"解析 {path} 的 {model.__name__} 失败:{exc.error_count()} 处字段不匹配。"
|
|
287
|
+
f"首个错误:{exc.errors()[0].get('loc')} {exc.errors()[0].get('msg')}"
|
|
288
|
+
) from exc
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _parse_list(model: type[T], payload: Any, path: str) -> list[T]:
|
|
292
|
+
if not isinstance(payload, list):
|
|
293
|
+
raise AtomGitUpstreamError(f"{path} 期望返回列表,实际为 {type(payload).__name__}")
|
|
294
|
+
return [AtomGitClient._parse(model, entry, path) for entry in payload]
|
|
295
|
+
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
# 账号与仓库
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
async def get_authenticated_user(self) -> AtomGitUser:
|
|
301
|
+
"""用于在配置凭据时验证 token 有效性并回显账号名。"""
|
|
302
|
+
response = await self._request("GET", "/user")
|
|
303
|
+
return self._parse(AtomGitUser, response.json(), "/user")
|
|
304
|
+
|
|
305
|
+
async def get_repository(self, owner: str, repo: str) -> AtomGitRepository:
|
|
306
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}")
|
|
307
|
+
return self._parse(AtomGitRepository, response.json(), "/repos")
|
|
308
|
+
|
|
309
|
+
async def get_file_content(
|
|
310
|
+
self, owner: str, repo: str, path: str, *, ref: str = "master"
|
|
311
|
+
) -> str | None:
|
|
312
|
+
"""读取仓库里的一个文本文件。文件不存在时返回 None。
|
|
313
|
+
|
|
314
|
+
SIG 名单(``openeuler/community`` 下的 ``sig/Kernel/*.md``)只能这样取:
|
|
315
|
+
网页端会重定向到前端 SPA,匿名 raw 链接拿回来的是 HTML 外壳,
|
|
316
|
+
带 token 的 API 是唯一可靠路径。
|
|
317
|
+
|
|
318
|
+
不存在的文件返回 None 而不是抛错:名单文件的位置与名字由上游决定,
|
|
319
|
+
某天改了文件名不该让整轮同步失败。
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
response = await self._request(
|
|
323
|
+
"GET", f"/repos/{owner}/{repo}/contents/{path}", params={"ref": ref}
|
|
324
|
+
)
|
|
325
|
+
except AtomGitNotFoundError:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
payload = response.json()
|
|
329
|
+
content = payload.get("content")
|
|
330
|
+
if not isinstance(content, str):
|
|
331
|
+
return None
|
|
332
|
+
# 上游返回 base64;encoding 字段实测可能是 "base64" 也可能缺失
|
|
333
|
+
return base64.b64decode(content).decode("utf-8", "replace")
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
# Pull Request
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
async def list_pulls(
|
|
340
|
+
self,
|
|
341
|
+
owner: str,
|
|
342
|
+
repo: str,
|
|
343
|
+
*,
|
|
344
|
+
state: str = "open",
|
|
345
|
+
page: int = 1,
|
|
346
|
+
per_page: int = 50,
|
|
347
|
+
sort: str | None = None,
|
|
348
|
+
direction: str | None = None,
|
|
349
|
+
) -> Page[AtomGitPullRequest]:
|
|
350
|
+
params: dict[str, Any] = {"state": state}
|
|
351
|
+
if sort:
|
|
352
|
+
params["sort"] = sort
|
|
353
|
+
if direction:
|
|
354
|
+
params["direction"] = direction
|
|
355
|
+
return await self._get_page(
|
|
356
|
+
f"/repos/{owner}/{repo}/pulls",
|
|
357
|
+
AtomGitPullRequest,
|
|
358
|
+
params=params,
|
|
359
|
+
page=page,
|
|
360
|
+
per_page=per_page,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def get_pull(self, owner: str, repo: str, number: int) -> AtomGitPullRequest:
|
|
364
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
|
|
365
|
+
return self._parse(AtomGitPullRequest, response.json(), "/pulls/{n}")
|
|
366
|
+
|
|
367
|
+
async def get_pull_files(self, owner: str, repo: str, number: int) -> list[AtomGitFile]:
|
|
368
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/files")
|
|
369
|
+
return self._parse_list(AtomGitFile, response.json(), "/pulls/{n}/files")
|
|
370
|
+
|
|
371
|
+
async def get_pull_commits(self, owner: str, repo: str, number: int) -> list[AtomGitCommit]:
|
|
372
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/commits")
|
|
373
|
+
return self._parse_list(AtomGitCommit, response.json(), "/pulls/{n}/commits")
|
|
374
|
+
|
|
375
|
+
async def get_pull_comments(self, owner: str, repo: str, number: int) -> list[AtomGitComment]:
|
|
376
|
+
"""PR 评论。评审意见的主要来源。"""
|
|
377
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/comments")
|
|
378
|
+
return self._parse_list(AtomGitComment, response.json(), "/pulls/{n}/comments")
|
|
379
|
+
|
|
380
|
+
async def get_pull_labels(self, owner: str, repo: str, number: int) -> list[AtomGitLabel]:
|
|
381
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/labels")
|
|
382
|
+
return self._parse_list(AtomGitLabel, response.json(), "/pulls/{n}/labels")
|
|
383
|
+
|
|
384
|
+
async def get_pull_linked_issues(
|
|
385
|
+
self, owner: str, repo: str, number: int
|
|
386
|
+
) -> list[AtomGitIssue]:
|
|
387
|
+
"""PR 显式关联的 Issue。优于从正文正则提取。"""
|
|
388
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/issues")
|
|
389
|
+
return self._parse_list(AtomGitIssue, response.json(), "/pulls/{n}/issues")
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------
|
|
392
|
+
# Issue
|
|
393
|
+
# ------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
async def list_issues(
|
|
396
|
+
self,
|
|
397
|
+
owner: str,
|
|
398
|
+
repo: str,
|
|
399
|
+
*,
|
|
400
|
+
state: str = "open",
|
|
401
|
+
page: int = 1,
|
|
402
|
+
per_page: int = 50,
|
|
403
|
+
labels: str | None = None,
|
|
404
|
+
since: str | None = None,
|
|
405
|
+
) -> Page[AtomGitIssue]:
|
|
406
|
+
params: dict[str, Any] = {"state": state}
|
|
407
|
+
if labels:
|
|
408
|
+
params["labels"] = labels
|
|
409
|
+
if since:
|
|
410
|
+
params["since"] = since
|
|
411
|
+
return await self._get_page(
|
|
412
|
+
f"/repos/{owner}/{repo}/issues",
|
|
413
|
+
AtomGitIssue,
|
|
414
|
+
params=params,
|
|
415
|
+
page=page,
|
|
416
|
+
per_page=per_page,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
async def get_issue(self, owner: str, repo: str, number: int | str) -> AtomGitIssue:
|
|
420
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}")
|
|
421
|
+
return self._parse(AtomGitIssue, response.json(), "/issues/{n}")
|
|
422
|
+
|
|
423
|
+
async def get_issue_comments(
|
|
424
|
+
self, owner: str, repo: str, number: int | str
|
|
425
|
+
) -> list[AtomGitComment]:
|
|
426
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/issues/{number}/comments")
|
|
427
|
+
return self._parse_list(AtomGitComment, response.json(), "/issues/{n}/comments")
|
|
428
|
+
|
|
429
|
+
async def create_issue_comment(
|
|
430
|
+
self, owner: str, repo: str, number: int | str, body: str
|
|
431
|
+
) -> AtomGitComment:
|
|
432
|
+
"""发表评论。
|
|
433
|
+
|
|
434
|
+
``/issues/{n}/comments`` 同时适用于 PR 与 Issue ——
|
|
435
|
+
在 AtomGit 的数据模型中两者共用编号空间。
|
|
436
|
+
"""
|
|
437
|
+
response = await self._request(
|
|
438
|
+
"POST",
|
|
439
|
+
f"/repos/{owner}/{repo}/issues/{number}/comments",
|
|
440
|
+
json={"body": body},
|
|
441
|
+
)
|
|
442
|
+
return self._parse(AtomGitComment, response.json(), "/issues/{n}/comments")
|
|
443
|
+
|
|
444
|
+
# ------------------------------------------------------------------
|
|
445
|
+
# 标签与 Webhook
|
|
446
|
+
# ------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async def list_labels(
|
|
449
|
+
self, owner: str, repo: str, *, page: int = 1, per_page: int = 100
|
|
450
|
+
) -> Page[AtomGitLabel]:
|
|
451
|
+
return await self._get_page(
|
|
452
|
+
f"/repos/{owner}/{repo}/labels",
|
|
453
|
+
AtomGitLabel,
|
|
454
|
+
page=page,
|
|
455
|
+
per_page=per_page,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
async def list_webhooks(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
|
459
|
+
"""读取已注册的 webhook。用于诊断事件投递配置。"""
|
|
460
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/hooks")
|
|
461
|
+
payload = response.json()
|
|
462
|
+
return payload if isinstance(payload, list) else []
|
|
463
|
+
|
|
464
|
+
# ------------------------------------------------------------------
|
|
465
|
+
# 便捷方法
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
async def iter_pulls(
|
|
469
|
+
self,
|
|
470
|
+
owner: str,
|
|
471
|
+
repo: str,
|
|
472
|
+
*,
|
|
473
|
+
state: str = "open",
|
|
474
|
+
per_page: int = 100,
|
|
475
|
+
max_pages: int = 100,
|
|
476
|
+
):
|
|
477
|
+
"""按页迭代 PR,避免一次性把 1000+ 条读进内存。"""
|
|
478
|
+
page = 1
|
|
479
|
+
while page <= max_pages:
|
|
480
|
+
result = await self.list_pulls(owner, repo, state=state, page=page, per_page=per_page)
|
|
481
|
+
if not result.items:
|
|
482
|
+
return
|
|
483
|
+
yield result
|
|
484
|
+
if not result.has_more:
|
|
485
|
+
return
|
|
486
|
+
page += 1
|
|
487
|
+
|
|
488
|
+
async def iter_issues(
|
|
489
|
+
self,
|
|
490
|
+
owner: str,
|
|
491
|
+
repo: str,
|
|
492
|
+
*,
|
|
493
|
+
state: str = "open",
|
|
494
|
+
per_page: int = 100,
|
|
495
|
+
max_pages: int = 100,
|
|
496
|
+
):
|
|
497
|
+
page = 1
|
|
498
|
+
while page <= max_pages:
|
|
499
|
+
result = await self.list_issues(owner, repo, state=state, page=page, per_page=per_page)
|
|
500
|
+
if not result.items:
|
|
501
|
+
return
|
|
502
|
+
yield result
|
|
503
|
+
if not result.has_more:
|
|
504
|
+
return
|
|
505
|
+
page += 1
|