@spaceflow/review 0.77.0 → 0.79.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/CHANGELOG.md +67 -0
- package/dist/index.js +1095 -500
- package/package.json +1 -1
- package/src/deletion-impact.service.ts +13 -128
- package/src/issue-verify.service.ts +18 -82
- package/src/mcp/index.ts +4 -1
- package/src/prompt/code-review.ts +95 -0
- package/src/prompt/deletion-impact.ts +105 -0
- package/src/prompt/index.ts +37 -0
- package/src/prompt/issue-verify.ts +86 -0
- package/src/prompt/pr-description.ts +149 -0
- package/src/prompt/schemas.ts +106 -0
- package/src/prompt/types.ts +53 -0
- package/src/review-context.ts +53 -15
- package/src/review-includes-filter.spec.ts +36 -0
- package/src/review-includes-filter.ts +59 -7
- package/src/review-issue-filter.ts +1 -1
- package/src/review-llm.ts +116 -207
- package/src/review-result-model.ts +28 -6
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review.config.ts +31 -5
- package/src/review.service.spec.ts +75 -3
- package/src/review.service.ts +120 -13
- package/src/system-rules/index.ts +48 -0
- package/src/system-rules/max-lines-per-file.ts +57 -0
- package/src/types/review-llm.ts +2 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +152 -7
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/{review-pr-comment-utils.ts → utils/review-pr-comment.ts} +2 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_COMMENT_MARKER,
|
|
4
|
+
REVIEW_LINE_COMMENTS_MARKER,
|
|
5
|
+
extractIssueKeyFromBody,
|
|
6
|
+
isAiGeneratedComment,
|
|
7
|
+
generateIssueKey,
|
|
8
|
+
syncRepliesToIssues,
|
|
9
|
+
deleteExistingAiReviews,
|
|
10
|
+
calculateIssueStats,
|
|
11
|
+
} from "./review-pr-comment";
|
|
12
|
+
|
|
13
|
+
describe("utils/review-pr-comment", () => {
|
|
14
|
+
// ─── 常量 ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("markers", () => {
|
|
17
|
+
it("REVIEW_COMMENT_MARKER 包含预期字符串", () => {
|
|
18
|
+
expect(REVIEW_COMMENT_MARKER).toBe("<!-- spaceflow-review -->");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("REVIEW_LINE_COMMENTS_MARKER 包含预期字符串", () => {
|
|
22
|
+
expect(REVIEW_LINE_COMMENTS_MARKER).toBe("<!-- spaceflow-review-lines -->");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ─── extractIssueKeyFromBody ─────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("extractIssueKeyFromBody", () => {
|
|
29
|
+
it("提取标准格式的 issue key", () => {
|
|
30
|
+
expect(extractIssueKeyFromBody("<!-- issue-key: src/a.ts:10:R1 -->")).toBe(
|
|
31
|
+
"src/a.ts:10:R1",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("body 不含 issue-key 时返回 null", () => {
|
|
36
|
+
expect(extractIssueKeyFromBody("普通评论内容")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("issue key 含空格时正确提取(trim)", () => {
|
|
40
|
+
expect(extractIssueKeyFromBody("<!-- issue-key: a.ts:1:R1 -->")).toBe("a.ts:1:R1");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── isAiGeneratedComment ────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("isAiGeneratedComment", () => {
|
|
47
|
+
it("含 issue-key 标记时返回 true", () => {
|
|
48
|
+
expect(isAiGeneratedComment("some body <!-- issue-key: a.ts:1:R1 --> end")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("同时含规则和文件字段时返回 true", () => {
|
|
52
|
+
expect(isAiGeneratedComment("- **规则**: R1\n- **文件**: a.ts:1")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("普通用户评论返回 false", () => {
|
|
56
|
+
expect(isAiGeneratedComment("LGTM")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("空字符串返回 false", () => {
|
|
60
|
+
expect(isAiGeneratedComment("")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("只含规则字段(不含文件字段)返回 false", () => {
|
|
64
|
+
expect(isAiGeneratedComment("- **规则**: R1")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── generateIssueKey ───────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("generateIssueKey", () => {
|
|
71
|
+
it("拼接 file:line:ruleId", () => {
|
|
72
|
+
expect(
|
|
73
|
+
generateIssueKey({ file: "src/a.ts", line: "10", ruleId: "R1" } as any),
|
|
74
|
+
).toBe("src/a.ts:10:R1");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── calculateIssueStats ────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("calculateIssueStats", () => {
|
|
81
|
+
it("空数组返回全零统计", () => {
|
|
82
|
+
const stats = calculateIssueStats([]);
|
|
83
|
+
expect(stats).toEqual({
|
|
84
|
+
total: 0,
|
|
85
|
+
validTotal: 0,
|
|
86
|
+
fixed: 0,
|
|
87
|
+
resolved: 0,
|
|
88
|
+
invalid: 0,
|
|
89
|
+
pending: 0,
|
|
90
|
+
fixRate: 0,
|
|
91
|
+
resolveRate: 0,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("全部待处理问题", () => {
|
|
96
|
+
const issues = [
|
|
97
|
+
{ file: "a.ts", line: "1", ruleId: "R1" },
|
|
98
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
99
|
+
] as any[];
|
|
100
|
+
const stats = calculateIssueStats(issues);
|
|
101
|
+
expect(stats.total).toBe(2);
|
|
102
|
+
expect(stats.validTotal).toBe(2);
|
|
103
|
+
expect(stats.pending).toBe(2);
|
|
104
|
+
expect(stats.fixed).toBe(0);
|
|
105
|
+
expect(stats.resolved).toBe(0);
|
|
106
|
+
expect(stats.invalid).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("valid=false 的问题计入 invalid,不计入 pending", () => {
|
|
110
|
+
const issues = [
|
|
111
|
+
{ file: "a.ts", line: "1", ruleId: "R1", valid: "false" },
|
|
112
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
113
|
+
] as any[];
|
|
114
|
+
const stats = calculateIssueStats(issues);
|
|
115
|
+
expect(stats.total).toBe(2);
|
|
116
|
+
expect(stats.invalid).toBe(1);
|
|
117
|
+
expect(stats.validTotal).toBe(1);
|
|
118
|
+
expect(stats.pending).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("fixed 问题计入 fixed,不计入 pending", () => {
|
|
122
|
+
const issues = [
|
|
123
|
+
{ file: "a.ts", line: "1", ruleId: "R1", fixed: "2024-01-01" },
|
|
124
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
125
|
+
] as any[];
|
|
126
|
+
const stats = calculateIssueStats(issues);
|
|
127
|
+
expect(stats.fixed).toBe(1);
|
|
128
|
+
expect(stats.pending).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("resolved(非 fixed)计入 resolved,不计入 pending", () => {
|
|
132
|
+
const issues = [
|
|
133
|
+
{ file: "a.ts", line: "1", ruleId: "R1", resolved: "2024-01-01" },
|
|
134
|
+
] as any[];
|
|
135
|
+
const stats = calculateIssueStats(issues);
|
|
136
|
+
expect(stats.resolved).toBe(1);
|
|
137
|
+
expect(stats.pending).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("fixed 同时有 resolved 时只计入 fixed", () => {
|
|
141
|
+
const issues = [
|
|
142
|
+
{ file: "a.ts", line: "1", ruleId: "R1", fixed: "2024-01-01", resolved: "2024-01-01" },
|
|
143
|
+
] as any[];
|
|
144
|
+
const stats = calculateIssueStats(issues);
|
|
145
|
+
expect(stats.fixed).toBe(1);
|
|
146
|
+
expect(stats.resolved).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fixRate 计算正确(2/4 = 50%)", () => {
|
|
150
|
+
const issues = Array.from({ length: 4 }, (_, i) => ({
|
|
151
|
+
file: "a.ts",
|
|
152
|
+
line: String(i + 1),
|
|
153
|
+
ruleId: "R1",
|
|
154
|
+
...(i < 2 ? { fixed: "2024-01-01" } : {}),
|
|
155
|
+
})) as any[];
|
|
156
|
+
const stats = calculateIssueStats(issues);
|
|
157
|
+
expect(stats.fixRate).toBe(50);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── syncRepliesToIssues ─────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("syncRepliesToIssues", () => {
|
|
164
|
+
const lineMatchesPosition = (line: string, pos?: number) =>
|
|
165
|
+
pos !== undefined && String(pos) === line;
|
|
166
|
+
|
|
167
|
+
it("单条评论(无回复)不产生 replies", async () => {
|
|
168
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
169
|
+
const result = { issues: [issue] } as any;
|
|
170
|
+
const comments = [
|
|
171
|
+
{
|
|
172
|
+
id: 1,
|
|
173
|
+
path: "a.ts",
|
|
174
|
+
position: 1,
|
|
175
|
+
body: `🔴 **问题**\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
176
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
180
|
+
expect(issue.replies).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("用户回复通过 issue-key 精确匹配后加入 replies", async () => {
|
|
184
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
185
|
+
const result = { issues: [issue] } as any;
|
|
186
|
+
const comments = [
|
|
187
|
+
{
|
|
188
|
+
id: 1,
|
|
189
|
+
path: "a.ts",
|
|
190
|
+
position: 1,
|
|
191
|
+
body: `🔴 问题\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
192
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 2,
|
|
196
|
+
path: "a.ts",
|
|
197
|
+
position: 1,
|
|
198
|
+
body: "已修复",
|
|
199
|
+
user: { id: 42, login: "dev1" },
|
|
200
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
204
|
+
expect(issue.replies).toHaveLength(1);
|
|
205
|
+
expect(issue.replies[0].body).toBe("已修复");
|
|
206
|
+
expect(issue.replies[0].user.login).toBe("dev1");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("AI 格式特征的评论不被计为用户回复", async () => {
|
|
210
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
211
|
+
const result = { issues: [issue] } as any;
|
|
212
|
+
const comments = [
|
|
213
|
+
{
|
|
214
|
+
id: 1,
|
|
215
|
+
path: "a.ts",
|
|
216
|
+
position: 1,
|
|
217
|
+
body: `🔴 问题\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
218
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 2,
|
|
222
|
+
path: "a.ts",
|
|
223
|
+
position: 1,
|
|
224
|
+
body: "- **规则**: R2\n- **文件**: b.ts:2",
|
|
225
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
229
|
+
expect(issue.replies).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("同一 issue 多条用户回复全部追加", async () => {
|
|
233
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
234
|
+
const result = { issues: [issue] } as any;
|
|
235
|
+
const comments = [
|
|
236
|
+
{
|
|
237
|
+
id: 1,
|
|
238
|
+
path: "a.ts",
|
|
239
|
+
position: 1,
|
|
240
|
+
body: `<!-- issue-key: a.ts:1:R1 -->`,
|
|
241
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 2,
|
|
245
|
+
path: "a.ts",
|
|
246
|
+
position: 1,
|
|
247
|
+
body: "回复1",
|
|
248
|
+
user: { login: "u1" },
|
|
249
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 3,
|
|
253
|
+
path: "a.ts",
|
|
254
|
+
position: 1,
|
|
255
|
+
body: "回复2",
|
|
256
|
+
user: { login: "u2" },
|
|
257
|
+
created_at: "2024-01-01T02:00:00Z",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
261
|
+
expect(issue.replies).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── deleteExistingAiReviews ─────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe("deleteExistingAiReviews", () => {
|
|
268
|
+
let pr: any;
|
|
269
|
+
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
pr = {
|
|
272
|
+
getReviews: vi.fn(),
|
|
273
|
+
deleteReview: vi.fn(),
|
|
274
|
+
getComments: vi.fn(),
|
|
275
|
+
deleteComment: vi.fn(),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("删除含 REVIEW_LINE_COMMENTS_MARKER 的 PR Review", async () => {
|
|
280
|
+
pr.getReviews.mockResolvedValue([
|
|
281
|
+
{ id: 1, body: `${REVIEW_LINE_COMMENTS_MARKER} Round 1` },
|
|
282
|
+
{ id: 2, body: "普通 review" },
|
|
283
|
+
]);
|
|
284
|
+
pr.deleteReview.mockResolvedValue(undefined);
|
|
285
|
+
pr.getComments.mockResolvedValue([]);
|
|
286
|
+
|
|
287
|
+
await deleteExistingAiReviews(pr);
|
|
288
|
+
|
|
289
|
+
expect(pr.deleteReview).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(pr.deleteReview).toHaveBeenCalledWith(1);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("删除含 REVIEW_COMMENT_MARKER 的 Issue Comment", async () => {
|
|
294
|
+
pr.getReviews.mockResolvedValue([]);
|
|
295
|
+
pr.getComments.mockResolvedValue([
|
|
296
|
+
{ id: 10, body: `${REVIEW_COMMENT_MARKER} 报告内容` },
|
|
297
|
+
{ id: 11, body: "普通评论" },
|
|
298
|
+
]);
|
|
299
|
+
pr.deleteComment.mockResolvedValue(undefined);
|
|
300
|
+
|
|
301
|
+
await deleteExistingAiReviews(pr);
|
|
302
|
+
|
|
303
|
+
expect(pr.deleteComment).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(pr.deleteComment).toHaveBeenCalledWith(10);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("deleteReview 失败时静默忽略,继续处理其他", async () => {
|
|
308
|
+
pr.getReviews.mockResolvedValue([
|
|
309
|
+
{ id: 1, body: REVIEW_LINE_COMMENTS_MARKER },
|
|
310
|
+
{ id: 2, body: REVIEW_LINE_COMMENTS_MARKER },
|
|
311
|
+
]);
|
|
312
|
+
pr.deleteReview
|
|
313
|
+
.mockRejectedValueOnce(new Error("submitted review cannot be deleted"))
|
|
314
|
+
.mockResolvedValueOnce(undefined);
|
|
315
|
+
pr.getComments.mockResolvedValue([]);
|
|
316
|
+
|
|
317
|
+
await expect(deleteExistingAiReviews(pr)).resolves.not.toThrow();
|
|
318
|
+
expect(pr.deleteReview).toHaveBeenCalledTimes(2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("getReviews 失败时静默忽略,继续处理 comments", async () => {
|
|
322
|
+
pr.getReviews.mockRejectedValue(new Error("API error"));
|
|
323
|
+
pr.getComments.mockResolvedValue([{ id: 10, body: REVIEW_COMMENT_MARKER }]);
|
|
324
|
+
pr.deleteComment.mockResolvedValue(undefined);
|
|
325
|
+
|
|
326
|
+
await expect(deleteExistingAiReviews(pr)).resolves.not.toThrow();
|
|
327
|
+
expect(pr.deleteComment).toHaveBeenCalledWith(10);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("无 AI 评论时不调用任何 delete", async () => {
|
|
331
|
+
pr.getReviews.mockResolvedValue([{ id: 1, body: "普通 review" }]);
|
|
332
|
+
pr.getComments.mockResolvedValue([{ id: 10, body: "普通评论" }]);
|
|
333
|
+
|
|
334
|
+
await deleteExistingAiReviews(pr);
|
|
335
|
+
|
|
336
|
+
expect(pr.deleteReview).not.toHaveBeenCalled();
|
|
337
|
+
expect(pr.deleteComment).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ReviewIssue, ReviewResult, ReviewStats } from "
|
|
2
|
-
import { PullRequestModel } from "
|
|
1
|
+
import { ReviewIssue, ReviewResult, ReviewStats } from "../review-spec";
|
|
2
|
+
import { PullRequestModel } from "../pull-request-model";
|
|
3
3
|
|
|
4
4
|
export const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
|
|
5
5
|
export const REVIEW_LINE_COMMENTS_MARKER = "<!-- spaceflow-review-lines -->";
|