@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.
@@ -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}`,