@kernel-sig/console 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,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