@spaceflow/review 0.76.0 → 0.78.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 +47 -0
- package/dist/index.js +3830 -2469
- package/package.json +2 -2
- package/src/deletion-impact.service.ts +17 -130
- package/src/index.ts +34 -2
- package/src/issue-verify.service.ts +18 -82
- package/src/locales/en/review.json +2 -1
- package/src/locales/zh-cn/review.json +2 -1
- 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/pull-request-model.ts +236 -0
- package/src/review-context.ts +433 -0
- package/src/review-includes-filter.spec.ts +284 -0
- package/src/review-includes-filter.ts +196 -0
- package/src/review-issue-filter.ts +523 -0
- package/src/review-llm.ts +543 -0
- package/src/review-result-model.spec.ts +657 -0
- package/src/review-result-model.ts +1046 -0
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review-spec/types.ts +2 -0
- package/src/review.config.ts +40 -5
- package/src/review.service.spec.ts +102 -1625
- package/src/review.service.ts +608 -2742
- 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 +21 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +177 -0
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/utils/review-pr-comment.ts +186 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
|
|
3
|
+
import { PullRequestModel } from "./pull-request-model";
|
|
4
|
+
import type { ReviewResult } from "./review-spec";
|
|
5
|
+
|
|
6
|
+
// ─── Mock 工具 ───────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function createMockGitProvider() {
|
|
9
|
+
return {
|
|
10
|
+
listIssueComments: vi.fn().mockResolvedValue([]),
|
|
11
|
+
createIssueComment: vi.fn().mockResolvedValue({}),
|
|
12
|
+
updateIssueComment: vi.fn().mockResolvedValue({}),
|
|
13
|
+
deleteIssueComment: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
listPullReviews: vi.fn().mockResolvedValue([]),
|
|
15
|
+
createPullReview: vi.fn().mockResolvedValue({}),
|
|
16
|
+
deletePullReview: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
deletePullReviewComment: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
listPullReviewComments: vi.fn().mockResolvedValue([]),
|
|
19
|
+
listResolvedThreads: vi.fn().mockResolvedValue([]),
|
|
20
|
+
getPullRequest: vi.fn().mockResolvedValue({}),
|
|
21
|
+
getCommitDiff: vi.fn().mockResolvedValue(""),
|
|
22
|
+
getIssueCommentReactions: vi.fn().mockResolvedValue([]),
|
|
23
|
+
getPullReviewCommentReactions: vi.fn().mockResolvedValue([]),
|
|
24
|
+
getTeamMembers: vi.fn().mockResolvedValue([]),
|
|
25
|
+
editPullRequest: vi.fn(),
|
|
26
|
+
searchUsers: vi.fn().mockResolvedValue([]),
|
|
27
|
+
} as any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createMockDeps(gitProvider: any): ReviewResultModelDeps {
|
|
31
|
+
return {
|
|
32
|
+
gitProvider,
|
|
33
|
+
config: {
|
|
34
|
+
getPluginConfig: vi.fn().mockReturnValue({}),
|
|
35
|
+
} as any,
|
|
36
|
+
reviewSpecService: {
|
|
37
|
+
parseLineRange: vi.fn().mockImplementation((lineStr: string) => {
|
|
38
|
+
const lines: number[] = [];
|
|
39
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
40
|
+
if (rangeMatch) {
|
|
41
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
42
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
43
|
+
for (let i = start; i <= end; i++) lines.push(i);
|
|
44
|
+
} else {
|
|
45
|
+
const n = parseInt(lineStr, 10);
|
|
46
|
+
if (!isNaN(n)) lines.push(n);
|
|
47
|
+
}
|
|
48
|
+
return lines;
|
|
49
|
+
}),
|
|
50
|
+
} as any,
|
|
51
|
+
reviewReportService: {
|
|
52
|
+
formatMarkdown: vi.fn().mockReturnValue("markdown"),
|
|
53
|
+
format: vi.fn().mockReturnValue("terminal"),
|
|
54
|
+
parseMarkdown: vi.fn().mockReturnValue(null),
|
|
55
|
+
} as any,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createPr(gitProvider: any, owner = "o", repo = "r", prNumber = 1) {
|
|
60
|
+
return new PullRequestModel(gitProvider, owner, repo, prNumber);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createResult(overrides: Partial<ReviewResult> = {}): ReviewResult {
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
description: "",
|
|
67
|
+
issues: [],
|
|
68
|
+
summary: [],
|
|
69
|
+
round: 1,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── 测试 ───────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("ReviewResultModel", () => {
|
|
77
|
+
let gitProvider: any;
|
|
78
|
+
let deps: ReviewResultModelDeps;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
gitProvider = createMockGitProvider();
|
|
83
|
+
deps = createMockDeps(gitProvider);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── 工厂方法 ─────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("loadFromPr", () => {
|
|
89
|
+
it("should return null when no AI comment exists", async () => {
|
|
90
|
+
gitProvider.listIssueComments.mockResolvedValue([]);
|
|
91
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
92
|
+
expect(model).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return null when comment has no marker", async () => {
|
|
96
|
+
gitProvider.listIssueComments.mockResolvedValue([{ body: "normal comment" }]);
|
|
97
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
98
|
+
expect(model).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should return model when AI comment with valid result exists", async () => {
|
|
102
|
+
const mockResult = createResult({
|
|
103
|
+
issues: [{ file: "a.ts", line: "1", ruleId: "R1" } as any],
|
|
104
|
+
});
|
|
105
|
+
gitProvider.listIssueComments.mockResolvedValue([
|
|
106
|
+
{ body: "<!-- spaceflow-review --> content" },
|
|
107
|
+
]);
|
|
108
|
+
(deps.reviewReportService.parseMarkdown as any).mockReturnValue({ result: mockResult });
|
|
109
|
+
|
|
110
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
111
|
+
expect(model).not.toBeNull();
|
|
112
|
+
expect(model!.issues).toHaveLength(1);
|
|
113
|
+
expect(model!.issues[0].file).toBe("a.ts");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should return null when parseMarkdown returns no result", async () => {
|
|
117
|
+
gitProvider.listIssueComments.mockResolvedValue([
|
|
118
|
+
{ body: "<!-- spaceflow-review --> content" },
|
|
119
|
+
]);
|
|
120
|
+
(deps.reviewReportService.parseMarkdown as any).mockReturnValue({ issues: [] });
|
|
121
|
+
|
|
122
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
123
|
+
expect(model).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should return null on API error", async () => {
|
|
127
|
+
gitProvider.listIssueComments.mockRejectedValue(new Error("API fail"));
|
|
128
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
129
|
+
|
|
130
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
131
|
+
expect(model).toBeNull();
|
|
132
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
133
|
+
consoleSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should use findLast to get the latest AI comment", async () => {
|
|
137
|
+
const result1 = createResult({
|
|
138
|
+
issues: [{ file: "old.ts", line: "1", ruleId: "R1" } as any],
|
|
139
|
+
});
|
|
140
|
+
const result2 = createResult({
|
|
141
|
+
issues: [{ file: "new.ts", line: "2", ruleId: "R2" } as any],
|
|
142
|
+
});
|
|
143
|
+
gitProvider.listIssueComments.mockResolvedValue([
|
|
144
|
+
{ body: "<!-- spaceflow-review --> old" },
|
|
145
|
+
{ body: "<!-- spaceflow-review --> new" },
|
|
146
|
+
]);
|
|
147
|
+
(deps.reviewReportService.parseMarkdown as any)
|
|
148
|
+
.mockReturnValueOnce({ result: result1 })
|
|
149
|
+
.mockReturnValueOnce({ result: result2 });
|
|
150
|
+
|
|
151
|
+
// parseMarkdown is called once with the LAST matching comment
|
|
152
|
+
const model = await ReviewResultModel.loadFromPr(createPr(gitProvider), deps);
|
|
153
|
+
expect(model).not.toBeNull();
|
|
154
|
+
// findLast returns the last one, so parseMarkdown is called with "new" body
|
|
155
|
+
expect(deps.reviewReportService.parseMarkdown).toHaveBeenCalledWith(
|
|
156
|
+
"<!-- spaceflow-review --> new",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("create / empty", () => {
|
|
162
|
+
it("should create model with provided result", () => {
|
|
163
|
+
const result = createResult({ round: 3, issues: [{ file: "a.ts" } as any] });
|
|
164
|
+
const model = ReviewResultModel.create(createPr(gitProvider), result, deps);
|
|
165
|
+
expect(model.round).toBe(3);
|
|
166
|
+
expect(model.issues).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("should create empty model", () => {
|
|
170
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
171
|
+
expect(model.round).toBe(0);
|
|
172
|
+
expect(model.issues).toHaveLength(0);
|
|
173
|
+
expect(model.result.success).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ─── 读取器 ───────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe("accessors", () => {
|
|
180
|
+
it("should get/set issues", () => {
|
|
181
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult(), deps);
|
|
182
|
+
expect(model.issues).toEqual([]);
|
|
183
|
+
model.issues = [{ file: "b.ts" } as any];
|
|
184
|
+
expect(model.issues).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should compute stats", () => {
|
|
188
|
+
const issues = [
|
|
189
|
+
{ file: "a.ts", line: "1", valid: "true" },
|
|
190
|
+
{ file: "b.ts", line: "2", fixed: "2024-01-01" },
|
|
191
|
+
{ file: "c.ts", line: "3", resolved: "2024-01-01" },
|
|
192
|
+
] as any[];
|
|
193
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
194
|
+
const stats = model.stats;
|
|
195
|
+
expect(stats.fixed).toBe(1);
|
|
196
|
+
expect(stats.resolved).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── 数据操作 ─────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("setResult / update / updateStats", () => {
|
|
203
|
+
it("should replace result entirely", () => {
|
|
204
|
+
const model = ReviewResultModel.create(
|
|
205
|
+
createPr(gitProvider),
|
|
206
|
+
createResult({ round: 1 }),
|
|
207
|
+
deps,
|
|
208
|
+
);
|
|
209
|
+
model.setResult(createResult({ round: 5 }));
|
|
210
|
+
expect(model.round).toBe(5);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should update partial fields", () => {
|
|
214
|
+
const model = ReviewResultModel.create(
|
|
215
|
+
createPr(gitProvider),
|
|
216
|
+
createResult({ round: 1 }),
|
|
217
|
+
deps,
|
|
218
|
+
);
|
|
219
|
+
model.update({ round: 3, description: "updated" });
|
|
220
|
+
expect(model.round).toBe(3);
|
|
221
|
+
expect(model.result.description).toBe("updated");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should update stats and store in result", () => {
|
|
225
|
+
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
226
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
227
|
+
const stats = model.updateStats();
|
|
228
|
+
expect(stats).toBeDefined();
|
|
229
|
+
expect(model.result.stats).toBe(stats);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ─── nextRound ─────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
describe("nextRound", () => {
|
|
236
|
+
it("should increment round number", () => {
|
|
237
|
+
const existing = ReviewResultModel.create(
|
|
238
|
+
createPr(gitProvider),
|
|
239
|
+
createResult({ round: 2, issues: [{ file: "old.ts", line: "1", round: 2 } as any] }),
|
|
240
|
+
deps,
|
|
241
|
+
);
|
|
242
|
+
const newResult = createResult({ issues: [{ file: "new.ts", line: "5" } as any] });
|
|
243
|
+
const next = existing.nextRound(newResult);
|
|
244
|
+
expect(next.round).toBe(3);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should tag new issues with next round number", () => {
|
|
248
|
+
const existing = ReviewResultModel.create(
|
|
249
|
+
createPr(gitProvider),
|
|
250
|
+
createResult({ round: 1, issues: [] }),
|
|
251
|
+
deps,
|
|
252
|
+
);
|
|
253
|
+
const newResult = createResult({
|
|
254
|
+
issues: [{ file: "a.ts", line: "1" } as any, { file: "b.ts", line: "2" } as any],
|
|
255
|
+
});
|
|
256
|
+
const next = existing.nextRound(newResult);
|
|
257
|
+
expect(next.issues[0].round).toBe(2);
|
|
258
|
+
expect(next.issues[1].round).toBe(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should merge existing issues with new issues", () => {
|
|
262
|
+
const existing = ReviewResultModel.create(
|
|
263
|
+
createPr(gitProvider),
|
|
264
|
+
createResult({
|
|
265
|
+
round: 1,
|
|
266
|
+
issues: [{ file: "old.ts", line: "1", round: 1, ruleId: "R1" } as any],
|
|
267
|
+
}),
|
|
268
|
+
deps,
|
|
269
|
+
);
|
|
270
|
+
const newResult = createResult({
|
|
271
|
+
issues: [{ file: "new.ts", line: "5", ruleId: "R2" } as any],
|
|
272
|
+
title: "New Title",
|
|
273
|
+
description: "New Desc",
|
|
274
|
+
});
|
|
275
|
+
const next = existing.nextRound(newResult);
|
|
276
|
+
expect(next.issues).toHaveLength(2);
|
|
277
|
+
expect(next.issues[0].file).toBe("old.ts");
|
|
278
|
+
expect(next.issues[0].round).toBe(1);
|
|
279
|
+
expect(next.issues[1].file).toBe("new.ts");
|
|
280
|
+
expect(next.issues[1].round).toBe(2);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("should copy metadata from newResult", () => {
|
|
284
|
+
const existing = ReviewResultModel.create(
|
|
285
|
+
createPr(gitProvider),
|
|
286
|
+
createResult({ round: 1, title: "Old", description: "Old desc" }),
|
|
287
|
+
deps,
|
|
288
|
+
);
|
|
289
|
+
const newResult = createResult({ title: "New Title", description: "New Desc" });
|
|
290
|
+
const next = existing.nextRound(newResult);
|
|
291
|
+
expect(next.result.title).toBe("New Title");
|
|
292
|
+
expect(next.result.description).toBe("New Desc");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should preserve the same pr reference", () => {
|
|
296
|
+
const pr = createPr(gitProvider);
|
|
297
|
+
const existing = ReviewResultModel.create(pr, createResult({ round: 1 }), deps);
|
|
298
|
+
const next = existing.nextRound(createResult());
|
|
299
|
+
expect(next.pr).toBe(pr);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ─── createLocal ──────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
describe("createLocal", () => {
|
|
306
|
+
it("should create model without requiring a real PR", () => {
|
|
307
|
+
const result = createResult({ round: 1, issues: [{ file: "a.ts" } as any] });
|
|
308
|
+
const model = ReviewResultModel.createLocal(result, deps);
|
|
309
|
+
expect(model.round).toBe(1);
|
|
310
|
+
expect(model.issues).toHaveLength(1);
|
|
311
|
+
expect(model.pr.number).toBe(0);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should support formatComment without real PR", () => {
|
|
315
|
+
const model = ReviewResultModel.createLocal(createResult(), deps);
|
|
316
|
+
const comment = model.formatComment({ outputFormat: "terminal" });
|
|
317
|
+
expect(comment).toBe("terminal");
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ─── syncResolved ─────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
describe("syncResolved", () => {
|
|
324
|
+
it("should do nothing when no resolved threads", async () => {
|
|
325
|
+
gitProvider.listResolvedThreads.mockResolvedValue([]);
|
|
326
|
+
const issues = [{ file: "a.ts", line: "10", ruleId: "R1" }] as any[];
|
|
327
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
328
|
+
await model.syncResolved();
|
|
329
|
+
expect(issues[0].resolved).toBeUndefined();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("should mark issue as resolved by path:line match", async () => {
|
|
333
|
+
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
334
|
+
{ path: "a.ts", line: 10, resolvedBy: { id: 1, login: "user1" } },
|
|
335
|
+
]);
|
|
336
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
337
|
+
const issues = [{ file: "a.ts", line: "10", ruleId: "R1" }] as any[];
|
|
338
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
339
|
+
await model.syncResolved();
|
|
340
|
+
expect(issues[0].resolved).toBeDefined();
|
|
341
|
+
expect(issues[0].resolvedBy?.login).toBe("user1");
|
|
342
|
+
consoleSpy.mockRestore();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should match by issue key from comment body", async () => {
|
|
346
|
+
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
347
|
+
{
|
|
348
|
+
path: "a.ts",
|
|
349
|
+
line: 10,
|
|
350
|
+
body: "<!-- issue-key: a.ts:10:R1 -->",
|
|
351
|
+
resolvedBy: { id: 2, login: "user2" },
|
|
352
|
+
},
|
|
353
|
+
]);
|
|
354
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
355
|
+
const issues = [{ file: "a.ts", line: "10", ruleId: "R1" }] as any[];
|
|
356
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
357
|
+
await model.syncResolved();
|
|
358
|
+
expect(issues[0].resolved).toBeDefined();
|
|
359
|
+
expect(issues[0].resolvedBy?.login).toBe("user2");
|
|
360
|
+
consoleSpy.mockRestore();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should not overwrite already resolved issues", async () => {
|
|
364
|
+
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
365
|
+
{ path: "a.ts", line: 10, resolvedBy: { id: 2, login: "user2" } },
|
|
366
|
+
]);
|
|
367
|
+
const issues = [{ file: "a.ts", line: "10", ruleId: "R1", resolved: "2024-01-01" }] as any[];
|
|
368
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
369
|
+
await model.syncResolved();
|
|
370
|
+
expect(issues[0].resolved).toBe("2024-01-01"); // unchanged
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should handle API error gracefully", async () => {
|
|
374
|
+
gitProvider.listResolvedThreads.mockRejectedValue(new Error("fail"));
|
|
375
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
376
|
+
const model = ReviewResultModel.create(
|
|
377
|
+
createPr(gitProvider),
|
|
378
|
+
createResult({ issues: [{ file: "a.ts", line: "1" } as any] }),
|
|
379
|
+
deps,
|
|
380
|
+
);
|
|
381
|
+
await model.syncResolved();
|
|
382
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
383
|
+
consoleSpy.mockRestore();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ─── invalidateChangedFiles ───────────────────────────
|
|
388
|
+
|
|
389
|
+
describe("invalidateChangedFiles", () => {
|
|
390
|
+
it("should skip when no headSha", async () => {
|
|
391
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
392
|
+
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
393
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
394
|
+
await model.invalidateChangedFiles(undefined, 1);
|
|
395
|
+
expect(issues[0].valid).toBeUndefined();
|
|
396
|
+
expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 无法获取 PR head SHA,跳过变更文件检查");
|
|
397
|
+
consoleSpy.mockRestore();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("should skip when diff is empty", async () => {
|
|
401
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
402
|
+
gitProvider.getCommitDiff.mockResolvedValue("");
|
|
403
|
+
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
404
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
405
|
+
await model.invalidateChangedFiles("abc123", 1);
|
|
406
|
+
expect(issues[0].valid).toBeUndefined();
|
|
407
|
+
consoleSpy.mockRestore();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("should mark issues as invalid when file changed", async () => {
|
|
411
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
412
|
+
gitProvider.getCommitDiff.mockResolvedValue(
|
|
413
|
+
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
|
|
414
|
+
);
|
|
415
|
+
const issues = [
|
|
416
|
+
{ file: "changed.ts", line: "1", ruleId: "R1" },
|
|
417
|
+
{ file: "unchanged.ts", line: "2", ruleId: "R2" },
|
|
418
|
+
] as any[];
|
|
419
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
420
|
+
await model.invalidateChangedFiles("abc123", 1);
|
|
421
|
+
expect(model.issues[0].valid).toBe("false");
|
|
422
|
+
expect(model.issues[1].valid).toBeUndefined();
|
|
423
|
+
consoleSpy.mockRestore();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should not invalidate already fixed/resolved/invalid issues", async () => {
|
|
427
|
+
gitProvider.getCommitDiff.mockResolvedValue(
|
|
428
|
+
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
|
|
429
|
+
);
|
|
430
|
+
const issues = [
|
|
431
|
+
{ file: "changed.ts", line: "1", ruleId: "R1", fixed: "2024-01-01" },
|
|
432
|
+
{ file: "changed.ts", line: "2", ruleId: "R2", resolved: "2024-01-01" },
|
|
433
|
+
{ file: "changed.ts", line: "3", ruleId: "R3", valid: "false" },
|
|
434
|
+
] as any[];
|
|
435
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
436
|
+
await model.invalidateChangedFiles("abc123");
|
|
437
|
+
// None should be further modified
|
|
438
|
+
expect(model.issues[0].fixed).toBe("2024-01-01");
|
|
439
|
+
expect(model.issues[1].resolved).toBe("2024-01-01");
|
|
440
|
+
expect(model.issues[2].valid).toBe("false");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("should handle API error gracefully", async () => {
|
|
444
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
445
|
+
gitProvider.getCommitDiff.mockRejectedValue(new Error("diff fail"));
|
|
446
|
+
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
447
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
448
|
+
await model.invalidateChangedFiles("abc123", 1);
|
|
449
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
450
|
+
consoleSpy.mockRestore();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ─── deleteOldReviews ─────────────────────────────────
|
|
455
|
+
|
|
456
|
+
describe("deleteOldReviews", () => {
|
|
457
|
+
it("should delete AI reviews and comments", async () => {
|
|
458
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
459
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
460
|
+
{ id: 1, body: "<!-- spaceflow-review --> old" },
|
|
461
|
+
{ id: 2, body: "normal review" },
|
|
462
|
+
]);
|
|
463
|
+
gitProvider.listIssueComments.mockResolvedValue([
|
|
464
|
+
{ id: 10, body: "<!-- spaceflow-review --> comment" },
|
|
465
|
+
]);
|
|
466
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
467
|
+
await model.deleteOldReviews();
|
|
468
|
+
expect(gitProvider.deletePullReview).toHaveBeenCalledWith("o", "r", 1, 1);
|
|
469
|
+
expect(gitProvider.deleteIssueComment).toHaveBeenCalledWith("o", "r", 10);
|
|
470
|
+
consoleSpy.mockRestore();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("should skip non-AI reviews", async () => {
|
|
474
|
+
gitProvider.listPullReviews.mockResolvedValue([{ id: 1, body: "human review" }]);
|
|
475
|
+
gitProvider.listIssueComments.mockResolvedValue([]);
|
|
476
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
477
|
+
await model.deleteOldReviews();
|
|
478
|
+
expect(gitProvider.deletePullReview).not.toHaveBeenCalled();
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// ─── formatComment ────────────────────────────────────
|
|
483
|
+
|
|
484
|
+
describe("formatComment", () => {
|
|
485
|
+
it("should use markdown format in CI with prNumber", () => {
|
|
486
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult(), deps);
|
|
487
|
+
model.formatComment({ ci: true, prNumber: 1 });
|
|
488
|
+
expect(deps.reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("should use terminal format by default", () => {
|
|
492
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult(), deps);
|
|
493
|
+
model.formatComment({});
|
|
494
|
+
expect(deps.reviewReportService.format).toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("should respect explicit outputFormat", () => {
|
|
498
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult(), deps);
|
|
499
|
+
model.formatComment({ outputFormat: "markdown", prNumber: 1 });
|
|
500
|
+
expect(deps.reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ─── lineMatchesPosition ──────────────────────────────
|
|
505
|
+
|
|
506
|
+
describe("lineMatchesPosition", () => {
|
|
507
|
+
it("should return false when no position", () => {
|
|
508
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
509
|
+
expect(model.lineMatchesPosition("10", undefined)).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("should return true when position is in range", () => {
|
|
513
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
514
|
+
expect(model.lineMatchesPosition("5-15", 10)).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("should return false when position is outside range", () => {
|
|
518
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
519
|
+
expect(model.lineMatchesPosition("5-15", 20)).toBe(false);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("should handle single line number", () => {
|
|
523
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
524
|
+
expect(model.lineMatchesPosition("10", 10)).toBe(true);
|
|
525
|
+
expect(model.lineMatchesPosition("10", 11)).toBe(false);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ─── issueToReviewComment ─────────────────────────────
|
|
530
|
+
|
|
531
|
+
describe("issueToReviewComment", () => {
|
|
532
|
+
it("should return null for unparseable line", () => {
|
|
533
|
+
(deps.reviewSpecService.parseLineRange as any).mockReturnValueOnce([]);
|
|
534
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
535
|
+
const result = model.issueToReviewComment({
|
|
536
|
+
file: "a.ts",
|
|
537
|
+
line: "abc",
|
|
538
|
+
ruleId: "R1",
|
|
539
|
+
specFile: "s.md",
|
|
540
|
+
reason: "bad",
|
|
541
|
+
} as any);
|
|
542
|
+
expect(result).toBeNull();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("should create review comment with correct fields", () => {
|
|
546
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
547
|
+
const comment = model.issueToReviewComment({
|
|
548
|
+
file: "test.ts",
|
|
549
|
+
line: "10",
|
|
550
|
+
ruleId: "R1",
|
|
551
|
+
specFile: "rule.md",
|
|
552
|
+
reason: "问题描述",
|
|
553
|
+
severity: "error",
|
|
554
|
+
} as any);
|
|
555
|
+
expect(comment).not.toBeNull();
|
|
556
|
+
expect(comment!.path).toBe("test.ts");
|
|
557
|
+
expect(comment!.new_position).toBe(10);
|
|
558
|
+
expect(comment!.body).toContain("🔴");
|
|
559
|
+
expect(comment!.body).toContain("问题描述");
|
|
560
|
+
expect(comment!.body).toContain("issue-key: test.ts:10:R1");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("should include suggestion block when present", () => {
|
|
564
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
565
|
+
const comment = model.issueToReviewComment({
|
|
566
|
+
file: "test.ts",
|
|
567
|
+
line: "10",
|
|
568
|
+
ruleId: "R1",
|
|
569
|
+
specFile: "rule.md",
|
|
570
|
+
reason: "bad",
|
|
571
|
+
severity: "warn",
|
|
572
|
+
suggestion: "const x = 1;",
|
|
573
|
+
} as any);
|
|
574
|
+
expect(comment!.body).toContain("建议");
|
|
575
|
+
expect(comment!.body).toContain("const x = 1;");
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ─── buildLineReviewBody ──────────────────────────────
|
|
580
|
+
|
|
581
|
+
describe("buildLineReviewBody", () => {
|
|
582
|
+
it("should show no issues message when empty", () => {
|
|
583
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
584
|
+
const body = model.buildLineReviewBody([], 1, []);
|
|
585
|
+
expect(body).toContain("✅ 未发现新问题");
|
|
586
|
+
expect(body).toContain("Round 1");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("should show issue count and file count", () => {
|
|
590
|
+
const issues = [
|
|
591
|
+
{ file: "a.ts", line: "1", severity: "error" },
|
|
592
|
+
{ file: "b.ts", line: "2", severity: "warn" },
|
|
593
|
+
] as any[];
|
|
594
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
595
|
+
const body = model.buildLineReviewBody(issues, 1, issues);
|
|
596
|
+
expect(body).toContain("2");
|
|
597
|
+
expect(body).toContain("**2** 个文件");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("should include previous round review for round > 1", () => {
|
|
601
|
+
const allIssues = [
|
|
602
|
+
{ file: "a.ts", line: "1", round: 1, fixed: "2024-01-01" },
|
|
603
|
+
{ file: "b.ts", line: "2", round: 2, severity: "error" },
|
|
604
|
+
] as any[];
|
|
605
|
+
const newIssues = allIssues.filter((i) => i.round === 2);
|
|
606
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
607
|
+
const body = model.buildLineReviewBody(newIssues, 2, allIssues);
|
|
608
|
+
expect(body).toContain("Round 1 回顾");
|
|
609
|
+
expect(body).toContain("已修复");
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// ─── parseFromComment ─────────────────────────────────
|
|
614
|
+
|
|
615
|
+
describe("parseFromComment", () => {
|
|
616
|
+
it("should return null when parseMarkdown returns null", () => {
|
|
617
|
+
(deps.reviewReportService.parseMarkdown as any).mockReturnValueOnce(null);
|
|
618
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
619
|
+
expect(model.parseFromComment("some body")).toBeNull();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("should return result when parseMarkdown succeeds", () => {
|
|
623
|
+
const result = createResult({ round: 2 });
|
|
624
|
+
(deps.reviewReportService.parseMarkdown as any).mockReturnValueOnce({ result });
|
|
625
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
626
|
+
const parsed = model.parseFromComment("some body");
|
|
627
|
+
expect(parsed).not.toBeNull();
|
|
628
|
+
expect(parsed!.round).toBe(2);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// ─── findExistingAiComments ───────────────────────────
|
|
633
|
+
|
|
634
|
+
describe("findExistingAiComments", () => {
|
|
635
|
+
it("should return AI comments with marker", async () => {
|
|
636
|
+
gitProvider.listIssueComments.mockResolvedValue([
|
|
637
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
638
|
+
{ id: 2, body: "normal" },
|
|
639
|
+
{ id: 3, body: "<!-- spaceflow-review --> more" },
|
|
640
|
+
]);
|
|
641
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
642
|
+
const comments = await model.findExistingAiComments();
|
|
643
|
+
expect(comments).toHaveLength(2);
|
|
644
|
+
expect(comments[0].id).toBe(1);
|
|
645
|
+
expect(comments[1].id).toBe(3);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("should return empty on API error", async () => {
|
|
649
|
+
gitProvider.listIssueComments.mockRejectedValue(new Error("fail"));
|
|
650
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
651
|
+
const model = ReviewResultModel.empty(createPr(gitProvider), deps);
|
|
652
|
+
const comments = await model.findExistingAiComments();
|
|
653
|
+
expect(comments).toEqual([]);
|
|
654
|
+
consoleSpy.mockRestore();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|