@kernel-sig/console 0.1.0 → 0.1.1
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 +5 -3
- package/backend/alembic/versions/fbcd9026dc97_commit_additions_deletions.py +36 -0
- package/backend/app/api/v1/pulls.py +41 -0
- package/backend/app/integrations/atomgit/client.py +9 -0
- package/backend/app/integrations/atomgit/models.py +14 -0
- package/backend/app/integrations/llm/prompts.py +11 -1
- package/backend/app/models/pull_request.py +5 -0
- package/backend/app/schemas/pull_request.py +63 -0
- package/backend/app/services/review_service.py +202 -0
- package/backend/app/services/sync_service.py +96 -8
- package/backend/tests/test_review_summary.py +244 -0
- package/frontend/src/api/pulls.ts +42 -0
- package/frontend/src/api/repositories.ts +3 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +290 -85
- package/frontend/src/pages/settings/SettingsPage.tsx +46 -1
- package/package.json +2 -2
- package/scripts/e2e-verify.py +12 -0
package/.env.example
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
KSC_SECRET_KEY=please-change-me-to-a-random-32-plus-char-string
|
|
11
11
|
|
|
12
12
|
# ---- 镜像源 ----
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# 直连 Docker Hub、pypi.org 与 npm 官方源在部分网络环境下极慢或不可达,三者
|
|
14
|
+
# 默认都走国内镜像。在其他网络环境可分别改回:
|
|
15
|
+
# docker.io / https://pypi.org/simple / https://registry.npmjs.org
|
|
16
|
+
# 改完重跑 `npx @kernel-sig/console`;只有受影响的构建层会重建。
|
|
15
17
|
DOCKER_REGISTRY=docker.m.daocloud.io
|
|
16
18
|
PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
|
17
|
-
NPM_REGISTRY=https://registry.
|
|
19
|
+
NPM_REGISTRY=https://registry.npmmirror.com
|
|
18
20
|
|
|
19
21
|
# ---- 端口 ----
|
|
20
22
|
# 默认避开本机常见的 80/443/3000/8000/6379,避免与既有服务冲突
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""提交的增删行数
|
|
2
|
+
|
|
3
|
+
Revision ID: fbcd9026dc97
|
|
4
|
+
Revises: e5b27c9d3a41
|
|
5
|
+
|
|
6
|
+
界面上每个补丁要显示它改了多少行,而 ``/pulls/{n}/commits`` 不返回这个数
|
|
7
|
+
据,只有 ``/commits/{sha}`` 有。于是同步时按提交单独取一次,落在新增的两列。
|
|
8
|
+
|
|
9
|
+
两列都可空,且刻意不给服务端默认值:取不到(上游返回 404、网络抖动)时留
|
|
10
|
+
NULL,界面按"未取到"渲染。用 0 冒充会把"确实没改任何行"和"没取到"混成
|
|
11
|
+
一回事 —— 前者是有效信息,后者不是。
|
|
12
|
+
|
|
13
|
+
存量记录的这两列同样是 NULL,不做事后回填:回填要为每个提交打一次接口,
|
|
14
|
+
而这两列只影响一个展示字段,不值得在迁移里发几千个请求。下一次该 PR 内容
|
|
15
|
+
变化触发详情同步时,值会自然补上。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
import sqlalchemy as sa
|
|
21
|
+
from alembic import op
|
|
22
|
+
|
|
23
|
+
revision: str = "fbcd9026dc97"
|
|
24
|
+
down_revision: str | None = "e5b27c9d3a41"
|
|
25
|
+
branch_labels: str | Sequence[str] | None = None
|
|
26
|
+
depends_on: str | Sequence[str] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def upgrade() -> None:
|
|
30
|
+
op.add_column("ksp_pr_commit", sa.Column("additions", sa.Integer(), nullable=True))
|
|
31
|
+
op.add_column("ksp_pr_commit", sa.Column("deletions", sa.Integer(), nullable=True))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def downgrade() -> None:
|
|
35
|
+
op.drop_column("ksp_pr_commit", "deletions")
|
|
36
|
+
op.drop_column("ksp_pr_commit", "additions")
|
|
@@ -16,12 +16,14 @@ from app.core.exceptions import NotFoundError
|
|
|
16
16
|
from app.core.permissions import Permission
|
|
17
17
|
from app.domain.review import derive_gate, is_bot
|
|
18
18
|
from app.models.classification import PRKind, SubjectType
|
|
19
|
+
from app.models.issue import Issue, PRIssueLink
|
|
19
20
|
from app.models.pull_request import PRComment, PRCommit, PRFile, PullRequest, ReviewEvent
|
|
20
21
|
from app.schemas.common import MessageResponse, Page
|
|
21
22
|
from app.schemas.pull_request import (
|
|
22
23
|
FacetRead,
|
|
23
24
|
FacetResponse,
|
|
24
25
|
FacetValueRead,
|
|
26
|
+
LinkedIssueRead,
|
|
25
27
|
PRCommentRead,
|
|
26
28
|
PRCommitRead,
|
|
27
29
|
PRFileRead,
|
|
@@ -36,6 +38,7 @@ from app.services import (
|
|
|
36
38
|
pull_query,
|
|
37
39
|
release_service,
|
|
38
40
|
repository_service,
|
|
41
|
+
review_service,
|
|
39
42
|
)
|
|
40
43
|
|
|
41
44
|
router = APIRouter(tags=["pulls"])
|
|
@@ -339,9 +342,47 @@ async def get_pull(pull_id: uuid.UUID, session: SessionDep, _: CurrentUserDep) -
|
|
|
339
342
|
)
|
|
340
343
|
for comment in comments
|
|
341
344
|
]
|
|
345
|
+
detail.linked_issues = await _linked_issues(session, pull)
|
|
346
|
+
detail.review_summary = await review_service.build_review_summary(
|
|
347
|
+
session, pull, list(events), list(comments), [file.filename for file in files]
|
|
348
|
+
)
|
|
342
349
|
return detail
|
|
343
350
|
|
|
344
351
|
|
|
352
|
+
async def _linked_issues(session: SessionDep, pull: PullRequest) -> list[LinkedIssueRead]:
|
|
353
|
+
"""把关联表里的编号补成完整信息。
|
|
354
|
+
|
|
355
|
+
关联表只存编号(外加来源),标题与状态在 Issue 表里 —— 那张表未必
|
|
356
|
+
有对应的行(Issue 还没同步到),所以是左连接而不是内连接。
|
|
357
|
+
"""
|
|
358
|
+
rows = (
|
|
359
|
+
await session.execute(
|
|
360
|
+
select(PRIssueLink, Issue)
|
|
361
|
+
.outerjoin(
|
|
362
|
+
Issue,
|
|
363
|
+
(Issue.repository_id == PRIssueLink.repository_id)
|
|
364
|
+
& (Issue.number == PRIssueLink.issue_number),
|
|
365
|
+
)
|
|
366
|
+
.where(PRIssueLink.pull_request_id == pull.id)
|
|
367
|
+
.order_by(PRIssueLink.issue_number)
|
|
368
|
+
)
|
|
369
|
+
).all()
|
|
370
|
+
|
|
371
|
+
return [
|
|
372
|
+
LinkedIssueRead(
|
|
373
|
+
number=link.issue_number,
|
|
374
|
+
title=issue.title if issue else None,
|
|
375
|
+
issue_state=issue.issue_state if issue else None,
|
|
376
|
+
issue_type=issue.issue_type if issue else None,
|
|
377
|
+
html_url=issue.html_url if issue else None,
|
|
378
|
+
source=(
|
|
379
|
+
link.source.value if hasattr(link.source, "value") else str(link.source)
|
|
380
|
+
),
|
|
381
|
+
)
|
|
382
|
+
for link, issue in rows
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
|
|
345
386
|
@router.post("/pulls/{pull_id}/recompute-gate", response_model=MessageResponse)
|
|
346
387
|
async def recompute_gate(
|
|
347
388
|
pull_id: uuid.UUID,
|
|
@@ -372,6 +372,15 @@ class AtomGitClient:
|
|
|
372
372
|
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/commits")
|
|
373
373
|
return self._parse_list(AtomGitCommit, response.json(), "/pulls/{n}/commits")
|
|
374
374
|
|
|
375
|
+
async def get_commit(self, owner: str, repo: str, sha: str) -> AtomGitCommit:
|
|
376
|
+
"""单个提交。
|
|
377
|
+
|
|
378
|
+
比列表接口多一个 ``stats``(增删行数)—— 那个数据列表接口不给,
|
|
379
|
+
所以想显示每个补丁改了多少行,只能一个提交问一次。
|
|
380
|
+
"""
|
|
381
|
+
response = await self._request("GET", f"/repos/{owner}/{repo}/commits/{sha}")
|
|
382
|
+
return self._parse(AtomGitCommit, response.json(), "/commits/{sha}")
|
|
383
|
+
|
|
375
384
|
async def get_pull_comments(self, owner: str, repo: str, number: int) -> list[AtomGitComment]:
|
|
376
385
|
"""PR 评论。评审意见的主要来源。"""
|
|
377
386
|
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}/comments")
|
|
@@ -271,6 +271,18 @@ class AtomGitCommitInner(AtomGitBase):
|
|
|
271
271
|
committer: AtomGitCommitAuthor | None = None
|
|
272
272
|
|
|
273
273
|
|
|
274
|
+
class AtomGitCommitStats(AtomGitBase):
|
|
275
|
+
"""单个提交的增删行数。
|
|
276
|
+
|
|
277
|
+
只有 ``GET /repos/{o}/{r}/commits/{sha}`` 返回这一项,
|
|
278
|
+
``/pulls/{n}/commits`` 不带 —— 所以它要按提交单独请求一次。
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
additions: int = 0
|
|
282
|
+
deletions: int = 0
|
|
283
|
+
total: int = 0
|
|
284
|
+
|
|
285
|
+
|
|
274
286
|
class AtomGitCommit(AtomGitBase):
|
|
275
287
|
sha: str
|
|
276
288
|
commit: AtomGitCommitInner = Field(default_factory=AtomGitCommitInner)
|
|
@@ -278,6 +290,8 @@ class AtomGitCommit(AtomGitBase):
|
|
|
278
290
|
committer: AtomGitCommitAuthor | None = None
|
|
279
291
|
parents: Any = None
|
|
280
292
|
html_url: str | None = None
|
|
293
|
+
# 列表接口不返回,单提交接口才有
|
|
294
|
+
stats: AtomGitCommitStats | None = None
|
|
281
295
|
|
|
282
296
|
@property
|
|
283
297
|
def short_sha(self) -> str:
|
|
@@ -188,9 +188,19 @@ CLASSIFY_SYSTEM = """\
|
|
|
188
188
|
"kind": "上面十二选一",
|
|
189
189
|
"subsystem": "所属子系统,如 net / mm / fs / arm64 / drm",
|
|
190
190
|
"confidence": 0.0 到 1.0,
|
|
191
|
-
"reason": "
|
|
191
|
+
"reason": "判断依据"
|
|
192
192
|
}}
|
|
193
193
|
|
|
194
|
+
reason 会直接显示在界面上给维护者看,只写你实际看到的证据:
|
|
195
|
+
|
|
196
|
+
- 写证据,不要复述类别。不写「符合 backport 类别定义」这类话 ——
|
|
197
|
+
类别已经在另一个字段里了
|
|
198
|
+
- 不写评语。「明确指出」「属于典型的」「充分说明」这类修饰一律去掉
|
|
199
|
+
- 能引原文就引原文,让维护者一眼能核对
|
|
200
|
+
|
|
201
|
+
好:提交信息写着 `category: bugfix`,标题 `net: fix use-after-free in foo()`
|
|
202
|
+
坏:标题明确指出存在并发缺陷,属于典型的功能缺陷修复,符合 bugfix 类别定义
|
|
203
|
+
|
|
194
204
|
只输出 JSON。"""
|
|
195
205
|
|
|
196
206
|
CLASSIFY_USER = """\
|
|
@@ -139,6 +139,11 @@ class PRCommit(Base):
|
|
|
139
139
|
author_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
140
140
|
committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
141
141
|
|
|
142
|
+
# 单个提交的增删行数。只有 GET /commits/{sha} 返回,所以是同步时
|
|
143
|
+
# 按提交单独取的(见 sync_pull_detail);为 NULL 表示还没取到。
|
|
144
|
+
additions: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
145
|
+
deletions: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
146
|
+
|
|
142
147
|
# 补丁格式审查的落点:openEuler 要求 Signed-off-by 链完整
|
|
143
148
|
signed_off_bys: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
|
144
149
|
|
|
@@ -56,10 +56,16 @@ class PRCommitRead(BaseModel):
|
|
|
56
56
|
sha: str
|
|
57
57
|
sequence: int
|
|
58
58
|
subject: str
|
|
59
|
+
# 完整提交信息:评审时要看正文里的 bugzilla / CVE / Reference 这些行,
|
|
60
|
+
# 只给标题等于把评审人赶回 AtomGit 页面
|
|
61
|
+
message: str
|
|
59
62
|
author_name: str | None
|
|
60
63
|
author_email: str | None
|
|
61
64
|
committed_at: datetime | None
|
|
62
65
|
signed_off_bys: list[str] | None
|
|
66
|
+
# NULL 表示没取到(见 PRCommit 模型的注释),0 表示确实没改
|
|
67
|
+
additions: int | None = None
|
|
68
|
+
deletions: int | None = None
|
|
63
69
|
|
|
64
70
|
|
|
65
71
|
class PRFileRead(BaseModel):
|
|
@@ -118,12 +124,69 @@ class PRCommentRead(BaseModel):
|
|
|
118
124
|
is_bot: bool = False
|
|
119
125
|
|
|
120
126
|
|
|
127
|
+
class ReviewActor(BaseModel):
|
|
128
|
+
"""评审里的一个人。
|
|
129
|
+
|
|
130
|
+
``role`` 来自 SIG 名册;名册里没有这个人时为 None —— 外部贡献者也能
|
|
131
|
+
给 LGTM,不能因为查不到身份就把人从名单里抹掉。
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
login: str
|
|
135
|
+
events: int = 0
|
|
136
|
+
last_at: datetime | None = None
|
|
137
|
+
role: str | None = None
|
|
138
|
+
is_committer: bool = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ReviewCommenter(BaseModel):
|
|
142
|
+
login: str
|
|
143
|
+
comments: int
|
|
144
|
+
last_at: datetime | None = None
|
|
145
|
+
latest_excerpt: str | None = None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ReviewSummary(BaseModel):
|
|
149
|
+
"""评审情况总览。
|
|
150
|
+
|
|
151
|
+
刻意不下"是否满足门禁"的结论 —— 那是 gate 的职责,这里只把人和意见
|
|
152
|
+
摆出来。两处各判一次,迟早会判出两个答案。
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
supported: list[ReviewActor] = []
|
|
156
|
+
commenters: list[ReviewCommenter] = []
|
|
157
|
+
pending: list[ReviewActor] = []
|
|
158
|
+
# 命中的子系统模块名(来自 committers.md),pending 就是从它们推出来的
|
|
159
|
+
subsystems: list[str] = []
|
|
160
|
+
# 已有几个 committer 表过态。openEuler 要求至少一个 committer 的 LGTM,
|
|
161
|
+
# 但那是门禁的判定,这里只报数。
|
|
162
|
+
committer_support: int = 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class LinkedIssueRead(BaseModel):
|
|
166
|
+
"""PR 关联的 Issue。
|
|
167
|
+
|
|
168
|
+
标题与状态取自本地已同步的 Issue 表。表里没有那个 Issue 时留 None,
|
|
169
|
+
界面只显示编号 —— 关联存在不等于我们已经拉过它。
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
number: int
|
|
173
|
+
title: str | None = None
|
|
174
|
+
issue_state: str | None = None
|
|
175
|
+
issue_type: str | None = None
|
|
176
|
+
html_url: str | None = None
|
|
177
|
+
# atomgit_link(上游显式登记)/ pr_body(正文里写的链接)
|
|
178
|
+
source: str
|
|
179
|
+
|
|
180
|
+
|
|
121
181
|
class PullDetail(PullSummary):
|
|
122
182
|
body: str | None
|
|
123
183
|
source_branch: str | None
|
|
124
184
|
mergeable: bool | None
|
|
125
185
|
mergeable_detail: dict | None = None
|
|
126
186
|
|
|
187
|
+
linked_issues: list[LinkedIssueRead] = []
|
|
188
|
+
review_summary: ReviewSummary | None = None
|
|
189
|
+
|
|
127
190
|
# 门禁推导结果:让前端无需复刻后端规则
|
|
128
191
|
blocking_reasons: list[str] = []
|
|
129
192
|
ready_to_merge: bool = False
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""PR 评审情况的聚合。
|
|
2
|
+
|
|
3
|
+
界面上要一眼看清"谁加了分、谁提了意见、还该找谁",这三件事跨三张表:
|
|
4
|
+
评审事件(ksp_review_event)、评论(ksp_pr_comment)、SIG 名册
|
|
5
|
+
(ksp_sig_member 与 ksp_subsystem_owner)。
|
|
6
|
+
|
|
7
|
+
放在服务层而不是接口层:判断该找谁评审,要拿变更文件去比子系统负责人表
|
|
8
|
+
里的路径前缀,再回名册查身份,接口函数只该负责编排。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
from sqlalchemy import select
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
from app.domain.review import EventType, is_bot
|
|
23
|
+
from app.models.classification import Classification, SubjectType
|
|
24
|
+
from app.models.pull_request import PRComment, PullRequest, ReviewEvent
|
|
25
|
+
from app.models.sig import SIGMember, SubsystemOwner
|
|
26
|
+
from app.schemas.pull_request import ReviewActor, ReviewCommenter, ReviewSummary
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# 整条评论就是一个或多个评审指令,如 `/lgtm`、`/approve`、`/retest`。
|
|
31
|
+
# 实测这类评论占评审评论的多数(一条 `/lgtm /approve` 同时产生一个
|
|
32
|
+
# approve 事件),把它们算作"提了意见"会把真写了话的人挤下去。
|
|
33
|
+
_COMMAND_ONLY = re.compile(r"^\s*(/[a-zA-Z_]+[\s.]*)+$")
|
|
34
|
+
|
|
35
|
+
# 算作"支持合入"的事件。ACK 不算加分,REJECT 更不是;CI_PASS 是机器人
|
|
36
|
+
# 给的,会被 is_bot 挡掉。
|
|
37
|
+
_SUPPORT_EVENTS = frozenset({EventType.LGTM, EventType.APPROVE})
|
|
38
|
+
|
|
39
|
+
# 评论摘要的长度。评审意见的完整正文在「讨论」页签里,这里只给个引子,
|
|
40
|
+
# 让人知道"他提了意见",要看细节再点过去。
|
|
41
|
+
_EXCERPT_CHARS = 160
|
|
42
|
+
|
|
43
|
+
_COMMITTER_ROLES = frozenset({"committer", "maintainer"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _excerpt(text: str | None) -> str | None:
|
|
47
|
+
if not text:
|
|
48
|
+
return None
|
|
49
|
+
flat = " ".join(text.split())
|
|
50
|
+
if len(flat) <= _EXCERPT_CHARS:
|
|
51
|
+
return flat
|
|
52
|
+
return flat[:_EXCERPT_CHARS] + "…"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _later(current: datetime | None, candidate: datetime | None) -> datetime | None:
|
|
56
|
+
if candidate is None:
|
|
57
|
+
return current
|
|
58
|
+
if current is None or candidate > current:
|
|
59
|
+
return candidate
|
|
60
|
+
return current
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _members_by_login(session: AsyncSession) -> dict[str, SIGMember]:
|
|
64
|
+
"""登录名 → 名册条目。
|
|
65
|
+
|
|
66
|
+
一个人可能在三个平台各留一个名字,任一命中即认。名册里查不到不算
|
|
67
|
+
异常:外部贡献者本来就不在上面。
|
|
68
|
+
"""
|
|
69
|
+
index: dict[str, SIGMember] = {}
|
|
70
|
+
for member in await session.scalars(select(SIGMember)):
|
|
71
|
+
for login in (member.atomgit_id, member.gitee_id, member.github_id):
|
|
72
|
+
if login:
|
|
73
|
+
index.setdefault(login, member)
|
|
74
|
+
return index
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _identity(member: SIGMember | None) -> tuple[str | None, bool]:
|
|
78
|
+
if member is None:
|
|
79
|
+
return None, False
|
|
80
|
+
roles = {role.lower() for role in (member.roles or [member.role])}
|
|
81
|
+
return member.role, bool(roles & _COMMITTER_ROLES)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _matching_owners(
|
|
85
|
+
session: AsyncSession, pull: PullRequest, filenames: Iterable[str]
|
|
86
|
+
) -> list[SubsystemOwner]:
|
|
87
|
+
"""这个 PR 落在哪些子系统上。
|
|
88
|
+
|
|
89
|
+
两条路并用:分类给的 subsystem 名(粗粒度、由维护者确认过),以及
|
|
90
|
+
变更文件的路径前缀(更细,且不依赖分类是否跑过)。任一命中即算。
|
|
91
|
+
"""
|
|
92
|
+
classification = await session.scalar(
|
|
93
|
+
select(Classification).where(
|
|
94
|
+
Classification.subject_type == SubjectType.PULL_REQUEST,
|
|
95
|
+
Classification.subject_id == pull.id,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
named: set[str] = set()
|
|
99
|
+
if classification is not None:
|
|
100
|
+
if classification.subsystem:
|
|
101
|
+
named.add(classification.subsystem)
|
|
102
|
+
named.update(classification.related_subsystems or [])
|
|
103
|
+
|
|
104
|
+
files = list(filenames)
|
|
105
|
+
matched: list[SubsystemOwner] = []
|
|
106
|
+
for owner in await session.scalars(select(SubsystemOwner).order_by(SubsystemOwner.sequence)):
|
|
107
|
+
if owner.module in named:
|
|
108
|
+
matched.append(owner)
|
|
109
|
+
continue
|
|
110
|
+
# committers.md 里的路径以 `/` 结尾表示目录,直接当前缀比即可
|
|
111
|
+
if any(name.startswith(path) for path in (owner.paths or []) for name in files):
|
|
112
|
+
matched.append(owner)
|
|
113
|
+
return matched
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def build_review_summary(
|
|
117
|
+
session: AsyncSession,
|
|
118
|
+
pull: PullRequest,
|
|
119
|
+
events: list[ReviewEvent],
|
|
120
|
+
comments: list[PRComment],
|
|
121
|
+
filenames: Iterable[str],
|
|
122
|
+
) -> ReviewSummary:
|
|
123
|
+
"""汇总一个 PR 的评审情况。
|
|
124
|
+
|
|
125
|
+
事件与评论由调用方传入:详情接口本来就要把它们取出来下发,这里再查
|
|
126
|
+
一遍纯属多余。
|
|
127
|
+
"""
|
|
128
|
+
members = await _members_by_login(session)
|
|
129
|
+
|
|
130
|
+
support_count: dict[str, int] = defaultdict(int)
|
|
131
|
+
support_last: dict[str, datetime | None] = defaultdict(lambda: None)
|
|
132
|
+
for event in events:
|
|
133
|
+
if event.event_type not in _SUPPORT_EVENTS or is_bot(event.actor_login):
|
|
134
|
+
continue
|
|
135
|
+
support_count[event.actor_login] += 1
|
|
136
|
+
support_last[event.actor_login] = _later(
|
|
137
|
+
support_last[event.actor_login], event.occurred_at
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
supported: list[ReviewActor] = []
|
|
141
|
+
committer_support = 0
|
|
142
|
+
for login in sorted(support_count, key=lambda name: (-support_count[name], name)):
|
|
143
|
+
role, is_committer = _identity(members.get(login))
|
|
144
|
+
committer_support += int(is_committer)
|
|
145
|
+
supported.append(
|
|
146
|
+
ReviewActor(
|
|
147
|
+
login=login,
|
|
148
|
+
events=support_count[login],
|
|
149
|
+
last_at=support_last[login],
|
|
150
|
+
role=role,
|
|
151
|
+
is_committer=is_committer,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
comment_count: dict[str, int] = defaultdict(int)
|
|
156
|
+
comment_last: dict[str, datetime | None] = defaultdict(lambda: None)
|
|
157
|
+
comment_excerpt: dict[str, str | None] = {}
|
|
158
|
+
author = pull.author_login
|
|
159
|
+
for comment in comments:
|
|
160
|
+
login = comment.author_login
|
|
161
|
+
# 作者自己的回复不是"意见"。留着他只会把评审名单淹掉 ——
|
|
162
|
+
# 作者在讨论里发言的频率本来就最高。
|
|
163
|
+
if not login or login == author or is_bot(login):
|
|
164
|
+
continue
|
|
165
|
+
if _COMMAND_ONLY.match(comment.body or ""):
|
|
166
|
+
continue
|
|
167
|
+
comment_count[login] += 1
|
|
168
|
+
at = comment.atomgit_created_at
|
|
169
|
+
# 按时间取最新一条做摘要。时间缺失时退化为"后出现的覆盖先出现的",
|
|
170
|
+
# 总比丢掉这条评论强。
|
|
171
|
+
if at is None or comment_last[login] is None or at >= comment_last[login]:
|
|
172
|
+
comment_last[login] = _later(comment_last[login], at)
|
|
173
|
+
comment_excerpt[login] = _excerpt(comment.body)
|
|
174
|
+
|
|
175
|
+
commenters = [
|
|
176
|
+
ReviewCommenter(
|
|
177
|
+
login=login,
|
|
178
|
+
comments=comment_count[login],
|
|
179
|
+
last_at=comment_last[login],
|
|
180
|
+
latest_excerpt=comment_excerpt.get(login),
|
|
181
|
+
)
|
|
182
|
+
for login in sorted(comment_count, key=lambda name: (-comment_count[name], name))
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
owners = await _matching_owners(session, pull, filenames)
|
|
186
|
+
pending: list[ReviewActor] = []
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
for owner in owners:
|
|
189
|
+
for login in owner.committer_logins or []:
|
|
190
|
+
if login in support_count or login in seen:
|
|
191
|
+
continue
|
|
192
|
+
seen.add(login)
|
|
193
|
+
role, is_committer = _identity(members.get(login))
|
|
194
|
+
pending.append(ReviewActor(login=login, role=role, is_committer=is_committer))
|
|
195
|
+
|
|
196
|
+
return ReviewSummary(
|
|
197
|
+
supported=supported,
|
|
198
|
+
commenters=commenters,
|
|
199
|
+
pending=pending,
|
|
200
|
+
subsystems=[owner.module for owner in owners],
|
|
201
|
+
committer_support=committer_support,
|
|
202
|
+
)
|
|
@@ -283,10 +283,77 @@ async def upsert_issues(
|
|
|
283
283
|
# ---------------------------------------------------------------------------
|
|
284
284
|
|
|
285
285
|
|
|
286
|
+
async def _fetch_commit_stats(
|
|
287
|
+
client: AtomGitClient, owner: str, name: str, sha: str
|
|
288
|
+
) -> tuple[int | None, int | None]:
|
|
289
|
+
"""取单个提交的增删行数。
|
|
290
|
+
|
|
291
|
+
拿不到就返回 ``(None, None)`` 而不是 ``(0, 0)``:前者是"没取到",
|
|
292
|
+
后者是"确实一行没改",界面要区别对待,不能用一个数字冒充另一个。
|
|
293
|
+
|
|
294
|
+
上游失败不该拖垮整个 PR 的详情同步 —— 这个数据只影响一个展示字段。
|
|
295
|
+
"""
|
|
296
|
+
try:
|
|
297
|
+
detail = await client.get_commit(owner, name, sha)
|
|
298
|
+
except AtomGitError as exc:
|
|
299
|
+
logger.debug("commit_stats_unavailable", sha=sha, error=str(exc)[:200])
|
|
300
|
+
return None, None
|
|
301
|
+
if detail.stats is None:
|
|
302
|
+
return None, None
|
|
303
|
+
return detail.stats.additions, detail.stats.deletions
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def sync_linked_issues(
|
|
307
|
+
session: AsyncSession,
|
|
308
|
+
client: AtomGitClient,
|
|
309
|
+
repository: Repository,
|
|
310
|
+
pull: PullRequest,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""同步 PR ↔ Issue 关联。
|
|
313
|
+
|
|
314
|
+
两个来源并用:AtomGit 的显式关联(``/pulls/{n}/issues``)可信度最高,
|
|
315
|
+
但实测很多 PR 没有登记,只在正文里写了 ``bugzilla: .../issues/1234``;
|
|
316
|
+
于是正文正则作补充。两者冲突时以显式关联为准。
|
|
317
|
+
|
|
318
|
+
这与分类器提取的 ``linked_issues`` 不是一回事:那个是分类的依据之一,
|
|
319
|
+
存的是编号数组;这里是独立的关联表,供界面展示与反向查询。
|
|
320
|
+
"""
|
|
321
|
+
from app.domain.classification import extract_linked_issues
|
|
322
|
+
from app.models.issue import IssueLinkSource, PRIssueLink
|
|
323
|
+
|
|
324
|
+
owner, name = repository.owner, repository.name
|
|
325
|
+
|
|
326
|
+
links: dict[int, IssueLinkSource] = {}
|
|
327
|
+
try:
|
|
328
|
+
for issue in await client.get_pull_linked_issues(owner, name, pull.number):
|
|
329
|
+
links[issue.number] = IssueLinkSource.ATOMGIT_LINK
|
|
330
|
+
except AtomGitError as exc:
|
|
331
|
+
logger.debug("pull_linked_issues_unavailable", number=pull.number, error=str(exc)[:200])
|
|
332
|
+
|
|
333
|
+
for number in extract_linked_issues(pull.body or ""):
|
|
334
|
+
links.setdefault(number, IssueLinkSource.PR_BODY)
|
|
335
|
+
|
|
336
|
+
await session.execute(
|
|
337
|
+
PRIssueLink.__table__.delete().where(PRIssueLink.pull_request_id == pull.id)
|
|
338
|
+
)
|
|
339
|
+
session.add_all(
|
|
340
|
+
[
|
|
341
|
+
PRIssueLink(
|
|
342
|
+
pull_request_id=pull.id,
|
|
343
|
+
repository_id=repository.id,
|
|
344
|
+
issue_number=number,
|
|
345
|
+
source=source,
|
|
346
|
+
confidence=1.0 if source is IssueLinkSource.ATOMGIT_LINK else 0.7,
|
|
347
|
+
)
|
|
348
|
+
for number, source in sorted(links.items())
|
|
349
|
+
]
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
286
353
|
async def sync_pull_detail(
|
|
287
354
|
session: AsyncSession, client: AtomGitClient, repository: Repository, pull: PullRequest
|
|
288
355
|
) -> None:
|
|
289
|
-
"""拉取单个 PR
|
|
356
|
+
"""拉取单个 PR 的提交、变更文件、关联 Issue,并解析评审事件。
|
|
290
357
|
|
|
291
358
|
评审事件由标签与评论共同推导 —— 评审人身份只存在于评论中,
|
|
292
359
|
标签仅给出汇总结果。
|
|
@@ -300,10 +367,25 @@ async def sync_pull_detail(
|
|
|
300
367
|
commits = _dedupe_by(commits, key=lambda commit: commit.sha)
|
|
301
368
|
files = _dedupe_by(files, key=lambda file: file.filename)
|
|
302
369
|
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
370
|
+
# 提交是整批替换的,替换前先把已取到的增删行数收起来。那个数据只能按
|
|
371
|
+
# 提交单独问一次接口,重复问纯属浪费 —— 只有新出现的提交才值得去取。
|
|
372
|
+
known_stats = {
|
|
373
|
+
row.sha: (row.additions, row.deletions)
|
|
374
|
+
for row in (
|
|
375
|
+
await session.execute(
|
|
376
|
+
select(PRCommit.sha, PRCommit.additions, PRCommit.deletions).where(
|
|
377
|
+
PRCommit.pull_request_id == pull.id
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
).all()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
rows: list[PRCommit] = []
|
|
384
|
+
for index, commit in enumerate(commits, start=1):
|
|
385
|
+
additions, deletions = known_stats.get(commit.sha, (None, None))
|
|
386
|
+
if additions is None and deletions is None:
|
|
387
|
+
additions, deletions = await _fetch_commit_stats(client, owner, name, commit.sha)
|
|
388
|
+
rows.append(
|
|
307
389
|
PRCommit(
|
|
308
390
|
pull_request_id=pull.id,
|
|
309
391
|
sha=commit.sha,
|
|
@@ -313,11 +395,17 @@ async def sync_pull_detail(
|
|
|
313
395
|
author_name=(commit.commit.author.name if commit.commit.author else None),
|
|
314
396
|
author_email=commit.author_email,
|
|
315
397
|
committed_at=commit.commit.author.date if commit.commit.author else None,
|
|
398
|
+
additions=additions,
|
|
399
|
+
deletions=deletions,
|
|
316
400
|
signed_off_bys=_extract_signed_off_bys(commit.message),
|
|
317
401
|
)
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# --- 提交:整批替换,避免处理"已从 PR 中移除的提交"这类脏数据 ---
|
|
405
|
+
await session.execute(PRCommit.__table__.delete().where(PRCommit.pull_request_id == pull.id))
|
|
406
|
+
session.add_all(rows)
|
|
407
|
+
|
|
408
|
+
await sync_linked_issues(session, client, repository, pull)
|
|
321
409
|
|
|
322
410
|
# --- 变更文件:同样整批替换 ---
|
|
323
411
|
await session.execute(PRFile.__table__.delete().where(PRFile.pull_request_id == pull.id))
|