@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""评审情况汇总。
|
|
2
|
+
|
|
3
|
+
这里钉的是几条**判断**,不是实现细节:
|
|
4
|
+
|
|
5
|
+
1. 作者自己的回复不算「提了意见」—— 否则作者几乎总排第一,评审名单被淹。
|
|
6
|
+
2. 整条评论只有一个评审指令(``/lgtm``)不算意见 —— 实测这类评论占评审
|
|
7
|
+
评论的多数,混进来会把真正写了话的人挤下去。
|
|
8
|
+
3. 名册里查不到的人照样出现在支持名单里。外部贡献者也能给 LGTM,
|
|
9
|
+
不能因为查不到身份就把人抹掉;只是标不出 Committer 而已。
|
|
10
|
+
4. 「还需评审」是子系统负责人减去已表态的人,不是全体 Committer ——
|
|
11
|
+
125 个人的名单没有信息量。
|
|
12
|
+
|
|
13
|
+
用一个假 session 喂数据:这几条规则的输入是四张表的内容,与 SQL 怎么写无关。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from app.domain.review import EventType
|
|
22
|
+
from app.models.classification import Classification, SubjectType
|
|
23
|
+
from app.models.pull_request import PRComment, PRState, PullRequest, ReviewEvent
|
|
24
|
+
from app.models.sig import SIGMember, SubsystemOwner
|
|
25
|
+
from app.services.review_service import build_review_summary
|
|
26
|
+
|
|
27
|
+
T0 = datetime(2026, 9, 1, 10, 0, tzinfo=UTC)
|
|
28
|
+
REPO_ID = uuid.uuid4()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _FakeResult:
|
|
32
|
+
def __init__(self, rows: list) -> None:
|
|
33
|
+
self._rows = rows
|
|
34
|
+
|
|
35
|
+
def __iter__(self):
|
|
36
|
+
return iter(self._rows)
|
|
37
|
+
|
|
38
|
+
def all(self) -> list:
|
|
39
|
+
return self._rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _FakeSession:
|
|
43
|
+
"""按 ORM 实体返回预置行。
|
|
44
|
+
|
|
45
|
+
真实查询带 where 子句,但被测逻辑不依赖过滤 —— 过滤是数据库的事,
|
|
46
|
+
这里关心的是拿到行之后怎么聚合。
|
|
47
|
+
|
|
48
|
+
键必须是**类对象本身**,不能用字符串:调用方拿到的
|
|
49
|
+
``stmt.column_descriptions[0]["entity"]`` 是类,字符串键永远查不中,
|
|
50
|
+
而查不中的表现是"名册为空",测试会以错误的原因失败。
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, by_entity: dict[type, list] | None = None) -> None:
|
|
54
|
+
self._by_entity = by_entity or {}
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _entity(stmt):
|
|
58
|
+
return stmt.column_descriptions[0]["entity"]
|
|
59
|
+
|
|
60
|
+
async def scalars(self, stmt):
|
|
61
|
+
return _FakeResult(list(self._by_entity.get(self._entity(stmt), [])))
|
|
62
|
+
|
|
63
|
+
async def scalar(self, stmt):
|
|
64
|
+
rows = self._by_entity.get(self._entity(stmt), [])
|
|
65
|
+
return rows[0] if rows else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pull(author: str = "alice") -> PullRequest:
|
|
69
|
+
return PullRequest(
|
|
70
|
+
id=uuid.uuid4(),
|
|
71
|
+
repository_id=REPO_ID,
|
|
72
|
+
number=27766,
|
|
73
|
+
title="gpio: add support",
|
|
74
|
+
body=None,
|
|
75
|
+
state=PRState.OPEN,
|
|
76
|
+
author_login=author,
|
|
77
|
+
target_branch="OLK-6.6",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _event(actor: str, kind: EventType = EventType.LGTM, minutes: int = 0) -> ReviewEvent:
|
|
82
|
+
return ReviewEvent(
|
|
83
|
+
pull_request_id=uuid.uuid4(),
|
|
84
|
+
event_type=kind,
|
|
85
|
+
actor_login=actor,
|
|
86
|
+
source="comment",
|
|
87
|
+
occurred_at=T0 + timedelta(minutes=minutes),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _comment(actor: str, body: str, minutes: int = 0) -> PRComment:
|
|
92
|
+
return PRComment(
|
|
93
|
+
pull_request_id=uuid.uuid4(),
|
|
94
|
+
comment_id=str(uuid.uuid4()),
|
|
95
|
+
author_login=actor,
|
|
96
|
+
body=body,
|
|
97
|
+
atomgit_created_at=T0 + timedelta(minutes=minutes),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _member(login: str, role: str = "committer") -> SIGMember:
|
|
102
|
+
return SIGMember(name=login, atomgit_id=login, role=role, roles=[role])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def _summarise(pull, events, comments, members=(), owners=(), classification=None):
|
|
106
|
+
session = _FakeSession(
|
|
107
|
+
{
|
|
108
|
+
SIGMember: list(members),
|
|
109
|
+
SubsystemOwner: list(owners),
|
|
110
|
+
Classification: [classification] if classification else [],
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return await build_review_summary(session, pull, events, comments, ["mm/foo.c"])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.asyncio
|
|
117
|
+
async def test_author_replies_are_not_opinions():
|
|
118
|
+
pull = _pull(author="alice")
|
|
119
|
+
summary = await _summarise(
|
|
120
|
+
pull,
|
|
121
|
+
[],
|
|
122
|
+
[_comment("alice", "谢谢,已改"), _comment("bob", "这里有个并发问题")],
|
|
123
|
+
)
|
|
124
|
+
assert [c.login for c in summary.commenters] == ["bob"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_command_only_comment_is_not_an_opinion():
|
|
129
|
+
"""`/lgtm /approve` 是给机器看的指令,不是意见。"""
|
|
130
|
+
pull = _pull()
|
|
131
|
+
summary = await _summarise(
|
|
132
|
+
pull,
|
|
133
|
+
[],
|
|
134
|
+
[_comment("bob", "/lgtm /approve"), _comment("carol", "mmap 路径漏了加锁")],
|
|
135
|
+
)
|
|
136
|
+
assert [c.login for c in summary.commenters] == ["carol"]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_command_with_trailing_words_still_counts():
|
|
141
|
+
"""带说明的同一个指令算意见 —— 判据是整条评论只有指令。"""
|
|
142
|
+
pull = _pull()
|
|
143
|
+
summary = await _summarise(pull, [], [_comment("bob", "/lgtm 但请补一下 commit log")])
|
|
144
|
+
assert [c.login for c in summary.commenters] == ["bob"]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_unknown_reviewer_is_kept_without_committer_flag():
|
|
149
|
+
pull = _pull()
|
|
150
|
+
summary = await _summarise(
|
|
151
|
+
pull,
|
|
152
|
+
[_event("outsider")],
|
|
153
|
+
[],
|
|
154
|
+
members=[_member("insider")],
|
|
155
|
+
)
|
|
156
|
+
assert [a.login for a in summary.supported] == ["outsider"]
|
|
157
|
+
assert summary.supported[0].is_committer is False
|
|
158
|
+
assert summary.committer_support == 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@pytest.mark.asyncio
|
|
162
|
+
async def test_committer_support_is_counted():
|
|
163
|
+
pull = _pull()
|
|
164
|
+
summary = await _summarise(
|
|
165
|
+
pull,
|
|
166
|
+
[_event("insider"), _event("outsider")],
|
|
167
|
+
[],
|
|
168
|
+
members=[_member("insider")],
|
|
169
|
+
)
|
|
170
|
+
assert summary.committer_support == 1
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_pending_excludes_those_who_already_spoke():
|
|
175
|
+
"""还需评审 = 该子系统的 Committer 减去已表态的人。"""
|
|
176
|
+
pull = _pull()
|
|
177
|
+
owner = SubsystemOwner(
|
|
178
|
+
module="mm",
|
|
179
|
+
section="kernel core",
|
|
180
|
+
paths=["mm/"],
|
|
181
|
+
committer_logins=["has_said", "has_not"],
|
|
182
|
+
sequence=0,
|
|
183
|
+
)
|
|
184
|
+
summary = await _summarise(
|
|
185
|
+
pull,
|
|
186
|
+
[_event("has_said")],
|
|
187
|
+
[],
|
|
188
|
+
members=[_member("has_said"), _member("has_not")],
|
|
189
|
+
owners=[owner],
|
|
190
|
+
)
|
|
191
|
+
assert summary.subsystems == ["mm"]
|
|
192
|
+
assert [a.login for a in summary.pending] == ["has_not"]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_pending_matches_by_changed_file_path():
|
|
197
|
+
"""分类没跑过时,用变更文件的路径前缀也能定位子系统。"""
|
|
198
|
+
pull = _pull()
|
|
199
|
+
owner = SubsystemOwner(
|
|
200
|
+
module="mm",
|
|
201
|
+
section="kernel core",
|
|
202
|
+
paths=["mm/"],
|
|
203
|
+
committer_logins=["reviewer"],
|
|
204
|
+
sequence=0,
|
|
205
|
+
)
|
|
206
|
+
summary = await _summarise(pull, [], [], members=[_member("reviewer")], owners=[owner])
|
|
207
|
+
assert summary.subsystems == ["mm"]
|
|
208
|
+
assert [a.login for a in summary.pending] == ["reviewer"]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.asyncio
|
|
212
|
+
async def test_latest_comment_wins_the_excerpt():
|
|
213
|
+
pull = _pull()
|
|
214
|
+
summary = await _summarise(
|
|
215
|
+
pull,
|
|
216
|
+
[],
|
|
217
|
+
[_comment("bob", "第一条意见", minutes=0), _comment("bob", "第二条意见", minutes=10)],
|
|
218
|
+
)
|
|
219
|
+
assert summary.commenters[0].comments == 2
|
|
220
|
+
assert summary.commenters[0].latest_excerpt == "第二条意见"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_classification_subsystem_also_matches():
|
|
225
|
+
"""分类给出的 subsystem 名与路径匹配是两条并行的路,任一命中即可。"""
|
|
226
|
+
pull = _pull()
|
|
227
|
+
owner = SubsystemOwner(
|
|
228
|
+
module="ub",
|
|
229
|
+
section="kernel core",
|
|
230
|
+
paths=["drivers/ub/"],
|
|
231
|
+
committer_logins=["ub_owner"],
|
|
232
|
+
sequence=0,
|
|
233
|
+
)
|
|
234
|
+
classification = Classification(
|
|
235
|
+
subject_type=SubjectType.PULL_REQUEST,
|
|
236
|
+
subject_id=pull.id,
|
|
237
|
+
subsystem="ub",
|
|
238
|
+
source="rule",
|
|
239
|
+
confidence=1.0,
|
|
240
|
+
)
|
|
241
|
+
summary = await _summarise(
|
|
242
|
+
pull, [], [], members=[_member("ub_owner")], owners=[owner], classification=classification
|
|
243
|
+
)
|
|
244
|
+
assert summary.subsystems == ["ub"]
|
|
@@ -62,10 +62,50 @@ export interface PRCommit {
|
|
|
62
62
|
sha: string
|
|
63
63
|
sequence: number
|
|
64
64
|
subject: string
|
|
65
|
+
/** 完整提交信息。评审要看正文里的 bugzilla / CVE / Reference 这些行。 */
|
|
66
|
+
message: string
|
|
65
67
|
author_name: string | null
|
|
66
68
|
author_email: string | null
|
|
67
69
|
committed_at: string | null
|
|
68
70
|
signed_off_bys: string[] | null
|
|
71
|
+
/** null 表示没取到,0 表示确实没改 —— 含义不同,不能混用。 */
|
|
72
|
+
additions: number | null
|
|
73
|
+
deletions: number | null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ReviewActor {
|
|
77
|
+
login: string
|
|
78
|
+
events: number
|
|
79
|
+
last_at: string | null
|
|
80
|
+
role: string | null
|
|
81
|
+
is_committer: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ReviewCommenter {
|
|
85
|
+
login: string
|
|
86
|
+
comments: number
|
|
87
|
+
last_at: string | null
|
|
88
|
+
latest_excerpt: string | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ReviewSummary {
|
|
92
|
+
supported: ReviewActor[]
|
|
93
|
+
commenters: ReviewCommenter[]
|
|
94
|
+
pending: ReviewActor[]
|
|
95
|
+
/** 命中的子系统模块名,pending 就是从它们推出来的 */
|
|
96
|
+
subsystems: string[]
|
|
97
|
+
/** 已有几个 Committer 表过态 */
|
|
98
|
+
committer_support: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LinkedIssue {
|
|
102
|
+
number: number
|
|
103
|
+
title: string | null
|
|
104
|
+
issue_state: string | null
|
|
105
|
+
issue_type: string | null
|
|
106
|
+
html_url: string | null
|
|
107
|
+
/** atomgit_link(上游显式登记)/ pr_body(正文里写的链接) */
|
|
108
|
+
source: string
|
|
69
109
|
}
|
|
70
110
|
|
|
71
111
|
export interface PRFile {
|
|
@@ -122,6 +162,8 @@ export interface PullDetail extends PullSummary {
|
|
|
122
162
|
ready_to_merge: boolean
|
|
123
163
|
lgtm_actors: string[]
|
|
124
164
|
labels: PRLabel[]
|
|
165
|
+
linked_issues: LinkedIssue[]
|
|
166
|
+
review_summary: ReviewSummary | null
|
|
125
167
|
commits: PRCommit[]
|
|
126
168
|
files: PRFile[]
|
|
127
169
|
review_events: ReviewEvent[]
|
|
@@ -70,6 +70,9 @@ export const repositoriesApi = {
|
|
|
70
70
|
api_base_url?: string
|
|
71
71
|
}) => api.post<Repository>('/repositories', payload),
|
|
72
72
|
|
|
73
|
+
update: (repositoryId: string, payload: { credential_id?: string }) =>
|
|
74
|
+
api.patch<Repository>(`/repositories/${repositoryId}`, payload),
|
|
75
|
+
|
|
73
76
|
sync: (repositoryId: string, detailLimit = 50) =>
|
|
74
77
|
api.post<SyncRun[]>(
|
|
75
78
|
`/repositories/${repositoryId}/sync?detail_limit=${detailLimit}`,
|