@spaceflow/review 0.29.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/CHANGELOG.md +533 -0
- package/README.md +124 -0
- package/dist/551.js +9 -0
- package/dist/index.js +5704 -0
- package/package.json +50 -0
- package/src/README.md +364 -0
- package/src/__mocks__/@anthropic-ai/claude-agent-sdk.js +3 -0
- package/src/__mocks__/json-stringify-pretty-compact.ts +4 -0
- package/src/deletion-impact.service.spec.ts +974 -0
- package/src/deletion-impact.service.ts +879 -0
- package/src/dto/mcp.dto.ts +42 -0
- package/src/index.ts +32 -0
- package/src/issue-verify.service.spec.ts +460 -0
- package/src/issue-verify.service.ts +309 -0
- package/src/locales/en/review.json +31 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/review.json +31 -0
- package/src/parse-title-options.spec.ts +251 -0
- package/src/parse-title-options.ts +185 -0
- package/src/review-report/formatters/deletion-impact.formatter.ts +144 -0
- package/src/review-report/formatters/index.ts +4 -0
- package/src/review-report/formatters/json.formatter.ts +8 -0
- package/src/review-report/formatters/markdown.formatter.ts +291 -0
- package/src/review-report/formatters/terminal.formatter.ts +130 -0
- package/src/review-report/index.ts +4 -0
- package/src/review-report/review-report.module.ts +8 -0
- package/src/review-report/review-report.service.ts +58 -0
- package/src/review-report/types.ts +26 -0
- package/src/review-spec/index.ts +3 -0
- package/src/review-spec/review-spec.module.ts +10 -0
- package/src/review-spec/review-spec.service.spec.ts +1543 -0
- package/src/review-spec/review-spec.service.ts +902 -0
- package/src/review-spec/types.ts +143 -0
- package/src/review.command.ts +244 -0
- package/src/review.config.ts +58 -0
- package/src/review.mcp.ts +184 -0
- package/src/review.module.ts +52 -0
- package/src/review.service.spec.ts +3007 -0
- package/src/review.service.ts +2603 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,3007 @@
|
|
|
1
|
+
import { vi, type Mocked, type Mock } from "vitest";
|
|
2
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
3
|
+
import {
|
|
4
|
+
ConfigService,
|
|
5
|
+
ConfigReaderService,
|
|
6
|
+
GitProviderService,
|
|
7
|
+
ClaudeSetupService,
|
|
8
|
+
LlmProxyService,
|
|
9
|
+
GitSdkService,
|
|
10
|
+
parseChangedLinesFromPatch,
|
|
11
|
+
} from "@spaceflow/core";
|
|
12
|
+
import { ReviewSpecService } from "./review-spec";
|
|
13
|
+
import { ReviewReportService } from "./review-report";
|
|
14
|
+
import { readFile } from "fs/promises";
|
|
15
|
+
import { ReviewService, ReviewContext, ReviewPrompt } from "./review.service";
|
|
16
|
+
import { IssueVerifyService } from "./issue-verify.service";
|
|
17
|
+
import { DeletionImpactService } from "./deletion-impact.service";
|
|
18
|
+
import type { ReviewOptions } from "./review.command";
|
|
19
|
+
|
|
20
|
+
vi.mock("c12");
|
|
21
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
22
|
+
query: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("fs/promises");
|
|
25
|
+
vi.mock("child_process");
|
|
26
|
+
vi.mock("@opencode-ai/sdk", () => ({
|
|
27
|
+
createOpencodeClient: vi.fn().mockReturnValue({
|
|
28
|
+
session: {
|
|
29
|
+
create: vi.fn(),
|
|
30
|
+
prompt: vi.fn(),
|
|
31
|
+
delete: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("openai", () => {
|
|
36
|
+
const mCreate = vi.fn();
|
|
37
|
+
class MockOpenAI {
|
|
38
|
+
chat = {
|
|
39
|
+
completions: {
|
|
40
|
+
create: mCreate,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
static APIError = class extends Error {
|
|
44
|
+
status: number;
|
|
45
|
+
constructor(status: number, message: string) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.status = status;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
__esModule: true,
|
|
53
|
+
default: MockOpenAI,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ReviewService", () => {
|
|
58
|
+
let service: ReviewService;
|
|
59
|
+
let gitProvider: Mocked<GitProviderService>;
|
|
60
|
+
let configService: Mocked<ConfigService>;
|
|
61
|
+
let mockReviewSpecService: any;
|
|
62
|
+
let mockDeletionImpactService: any;
|
|
63
|
+
let mockGitSdkService: any;
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
const mockGitProvider = {
|
|
67
|
+
validateConfig: vi.fn(),
|
|
68
|
+
getPullRequest: vi.fn(),
|
|
69
|
+
getCommit: vi.fn(),
|
|
70
|
+
getPullRequestCommits: vi.fn(),
|
|
71
|
+
getPullRequestFiles: vi.fn(),
|
|
72
|
+
getFileContent: vi.fn(),
|
|
73
|
+
listPullReviews: vi.fn(),
|
|
74
|
+
createPullReview: vi.fn(),
|
|
75
|
+
deletePullReview: vi.fn(),
|
|
76
|
+
editPullRequest: vi.fn(),
|
|
77
|
+
getCommitDiff: vi.fn(),
|
|
78
|
+
listPullReviewComments: vi.fn(),
|
|
79
|
+
searchUsers: vi.fn().mockResolvedValue([]),
|
|
80
|
+
getIssueCommentReactions: vi.fn().mockResolvedValue([]),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const mockConfigService = {
|
|
84
|
+
get: vi.fn(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const mockClaudeSetupService = {
|
|
88
|
+
configure: vi.fn(),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
mockReviewSpecService = {
|
|
92
|
+
resolveSpecSources: vi.fn().mockResolvedValue(["/mock/spec/dir"]),
|
|
93
|
+
loadReviewSpecs: vi.fn().mockResolvedValue([
|
|
94
|
+
{
|
|
95
|
+
filename: "ts.base.md",
|
|
96
|
+
extensions: ["ts"],
|
|
97
|
+
rules: [{ id: "R1", title: "Rule 1", description: "D1", examples: [], overrides: [] }],
|
|
98
|
+
overrides: [],
|
|
99
|
+
includes: [],
|
|
100
|
+
},
|
|
101
|
+
]),
|
|
102
|
+
applyOverrides: vi.fn().mockImplementation((specs) => specs),
|
|
103
|
+
filterApplicableSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
104
|
+
filterIssuesByIncludes: vi.fn().mockImplementation((issues) => issues),
|
|
105
|
+
filterIssuesByOverrides: vi.fn().mockImplementation((issues) => issues),
|
|
106
|
+
filterIssuesByCommits: vi.fn().mockImplementation((issues) => issues),
|
|
107
|
+
formatIssues: vi.fn().mockImplementation((issues) => issues),
|
|
108
|
+
buildSpecsSection: vi.fn().mockReturnValue("mock specs section"),
|
|
109
|
+
filterIssuesByRuleExistence: vi.fn().mockImplementation((issues) => issues),
|
|
110
|
+
deduplicateSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const mockReviewReportService = {
|
|
114
|
+
formatMarkdown: vi.fn().mockReturnValue("AI 代码审查报告"),
|
|
115
|
+
parseMarkdown: vi.fn().mockReturnValue({ issues: [] }),
|
|
116
|
+
format: vi.fn().mockReturnValue("AI 代码审查报告"),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const mockIssueVerifyService = {
|
|
120
|
+
verifyIssueFixes: vi.fn().mockImplementation((issues) => Promise.resolve(issues)),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
mockDeletionImpactService = {
|
|
124
|
+
analyzeDeletionImpact: vi.fn().mockResolvedValue({ issues: [], summary: "" }),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
mockGitSdkService = {
|
|
128
|
+
parseChangedLinesFromPatch: vi.fn().mockReturnValue(new Set()),
|
|
129
|
+
parseDiffText: vi.fn().mockReturnValue([]),
|
|
130
|
+
getRemoteUrl: vi.fn().mockReturnValue(null),
|
|
131
|
+
parseRepositoryFromRemoteUrl: vi.fn().mockReturnValue(null),
|
|
132
|
+
getChangedFilesBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
133
|
+
getCommitsBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
134
|
+
getDiffBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
135
|
+
getFileContent: vi.fn().mockResolvedValue(""),
|
|
136
|
+
getFilesForCommit: vi.fn().mockResolvedValue([]),
|
|
137
|
+
getCurrentBranch: vi.fn().mockReturnValue("main"),
|
|
138
|
+
getDefaultBranch: vi.fn().mockReturnValue("main"),
|
|
139
|
+
getCommitDiff: vi.fn().mockReturnValue([]),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
143
|
+
providers: [
|
|
144
|
+
ReviewService,
|
|
145
|
+
{
|
|
146
|
+
provide: GitProviderService,
|
|
147
|
+
useValue: mockGitProvider,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
provide: ConfigService,
|
|
151
|
+
useValue: mockConfigService,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
provide: ClaudeSetupService,
|
|
155
|
+
useValue: mockClaudeSetupService,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
provide: ReviewSpecService,
|
|
159
|
+
useValue: mockReviewSpecService,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
provide: LlmProxyService,
|
|
163
|
+
useValue: {
|
|
164
|
+
chat: vi.fn(),
|
|
165
|
+
chatStream: vi.fn(),
|
|
166
|
+
createSession: vi.fn(),
|
|
167
|
+
getAvailableAdapters: vi.fn().mockReturnValue(["claude-code", "openai"]),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
provide: ReviewReportService,
|
|
172
|
+
useValue: mockReviewReportService,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
provide: IssueVerifyService,
|
|
176
|
+
useValue: mockIssueVerifyService,
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
provide: DeletionImpactService,
|
|
180
|
+
useValue: mockDeletionImpactService,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
provide: GitSdkService,
|
|
184
|
+
useValue: mockGitSdkService,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
provide: ConfigReaderService,
|
|
188
|
+
useValue: {
|
|
189
|
+
getPluginConfig: vi.fn().mockReturnValue({}),
|
|
190
|
+
getSystemConfig: vi.fn().mockReturnValue({}),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
}).compile();
|
|
195
|
+
|
|
196
|
+
service = module.get<ReviewService>(ReviewService);
|
|
197
|
+
gitProvider = module.get(GitProviderService) as Mocked<GitProviderService>;
|
|
198
|
+
configService = module.get(ConfigService) as Mocked<ConfigService>;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
vi.clearAllMocks();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("ReviewService.getContextFromEnv", () => {
|
|
206
|
+
it("should return context with owner and repo from config", async () => {
|
|
207
|
+
const options: ReviewOptions = {
|
|
208
|
+
dryRun: false,
|
|
209
|
+
ci: false,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
configService.get.mockReturnValue({
|
|
213
|
+
repository: "owner/repo",
|
|
214
|
+
refName: "main",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const context = await service.getContextFromEnv(options);
|
|
218
|
+
|
|
219
|
+
expect(context.owner).toBe("owner");
|
|
220
|
+
expect(context.repo).toBe("repo");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("should throw error when repository is missing", async () => {
|
|
224
|
+
const options: ReviewOptions = {
|
|
225
|
+
dryRun: false,
|
|
226
|
+
ci: false,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
configService.get.mockReturnValue({
|
|
230
|
+
repository: "",
|
|
231
|
+
refName: "main",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await expect(service.getContextFromEnv(options)).rejects.toThrow("缺少配置 ci.repository");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should use provided prNumber from options", async () => {
|
|
238
|
+
const options: ReviewOptions = {
|
|
239
|
+
dryRun: false,
|
|
240
|
+
ci: false,
|
|
241
|
+
prNumber: 123,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
configService.get.mockReturnValue({
|
|
245
|
+
repository: "owner/repo",
|
|
246
|
+
refName: "main",
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const context = await service.getContextFromEnv(options);
|
|
250
|
+
|
|
251
|
+
expect(context.prNumber).toBe(123);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("ReviewService.execute", () => {
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
|
|
258
|
+
success: true,
|
|
259
|
+
issues: [],
|
|
260
|
+
summary: [],
|
|
261
|
+
});
|
|
262
|
+
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
263
|
+
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
264
|
+
vi.spyOn(service as any, "getExistingReviewResult").mockResolvedValue(null);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should execute review for PR successfully", async () => {
|
|
268
|
+
const context: ReviewContext = {
|
|
269
|
+
owner: "owner",
|
|
270
|
+
repo: "repo",
|
|
271
|
+
prNumber: 123,
|
|
272
|
+
specSources: ["/spec/dir"],
|
|
273
|
+
dryRun: true,
|
|
274
|
+
ci: false,
|
|
275
|
+
llmMode: "claude-code" as const,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const mockPR = {
|
|
279
|
+
title: "Test PR",
|
|
280
|
+
head: { sha: "abc123" },
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const mockCommits = [
|
|
284
|
+
{
|
|
285
|
+
sha: "abc123",
|
|
286
|
+
commit: { message: "Test commit" },
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
const mockFiles = [
|
|
291
|
+
{
|
|
292
|
+
filename: "test.ts",
|
|
293
|
+
status: "modified",
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
gitProvider.getPullRequest.mockResolvedValue(mockPR);
|
|
298
|
+
gitProvider.getPullRequestCommits.mockResolvedValue(mockCommits);
|
|
299
|
+
gitProvider.getPullRequestFiles.mockResolvedValue(mockFiles);
|
|
300
|
+
|
|
301
|
+
const result = await service.execute(context);
|
|
302
|
+
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
expect(result.issues).toHaveLength(0);
|
|
305
|
+
expect(gitProvider.getPullRequest).toHaveBeenCalledWith("owner", "repo", 123);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should return early when no applicable specs or files", async () => {
|
|
309
|
+
const context = {
|
|
310
|
+
owner: "owner",
|
|
311
|
+
repo: "repo",
|
|
312
|
+
prNumber: 123,
|
|
313
|
+
specSources: ["/spec/dir"],
|
|
314
|
+
dryRun: false,
|
|
315
|
+
ci: false,
|
|
316
|
+
} as ReviewContext;
|
|
317
|
+
|
|
318
|
+
const mockPR = {
|
|
319
|
+
title: "Test PR",
|
|
320
|
+
head: { sha: "abc123" },
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
gitProvider.getPullRequest.mockResolvedValue(mockPR);
|
|
324
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([]);
|
|
325
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([]);
|
|
326
|
+
|
|
327
|
+
const result = await service.execute(context);
|
|
328
|
+
|
|
329
|
+
expect(result.success).toBe(true);
|
|
330
|
+
expect(result.summary).toEqual([]);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("should post comment to PR in CI mode", async () => {
|
|
334
|
+
const context = {
|
|
335
|
+
owner: "owner",
|
|
336
|
+
repo: "repo",
|
|
337
|
+
prNumber: 123,
|
|
338
|
+
specSources: ["/spec/dir"],
|
|
339
|
+
dryRun: false,
|
|
340
|
+
ci: true,
|
|
341
|
+
llmMode: "claude-code" as const,
|
|
342
|
+
} as ReviewContext;
|
|
343
|
+
|
|
344
|
+
const mockPR = {
|
|
345
|
+
title: "Test PR",
|
|
346
|
+
head: { sha: "abc123" },
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const mockCommits = [
|
|
350
|
+
{
|
|
351
|
+
sha: "abc123",
|
|
352
|
+
commit: { message: "Test commit" },
|
|
353
|
+
},
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
const mockFiles = [
|
|
357
|
+
{
|
|
358
|
+
filename: "test.ts",
|
|
359
|
+
status: "modified",
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
gitProvider.getPullRequest.mockResolvedValue(mockPR);
|
|
364
|
+
gitProvider.getPullRequestCommits.mockResolvedValue(mockCommits);
|
|
365
|
+
gitProvider.getPullRequestFiles.mockResolvedValue(mockFiles);
|
|
366
|
+
gitProvider.getFileContent.mockResolvedValue("const test = 1;");
|
|
367
|
+
gitProvider.listPullReviews.mockResolvedValue([]);
|
|
368
|
+
gitProvider.createPullReview.mockResolvedValue({});
|
|
369
|
+
|
|
370
|
+
await service.execute(context);
|
|
371
|
+
|
|
372
|
+
expect(gitProvider.createPullReview).toHaveBeenCalledWith(
|
|
373
|
+
"owner",
|
|
374
|
+
"repo",
|
|
375
|
+
123,
|
|
376
|
+
expect.objectContaining({
|
|
377
|
+
body: expect.stringContaining("AI 代码审查报告"),
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("should handle error during review", async () => {
|
|
383
|
+
const context: ReviewContext = {
|
|
384
|
+
owner: "owner",
|
|
385
|
+
repo: "repo",
|
|
386
|
+
prNumber: 123,
|
|
387
|
+
specSources: ["/spec/dir"],
|
|
388
|
+
dryRun: false,
|
|
389
|
+
ci: false,
|
|
390
|
+
llmMode: "claude-code",
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
gitProvider.getPullRequest.mockRejectedValue(new Error("Gitea API Error"));
|
|
394
|
+
|
|
395
|
+
await expect(service.execute(context)).rejects.toThrow("Gitea API Error");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should delegate to executeCollectOnly on closed event", async () => {
|
|
399
|
+
const context: ReviewContext = {
|
|
400
|
+
owner: "owner",
|
|
401
|
+
repo: "repo",
|
|
402
|
+
prNumber: 123,
|
|
403
|
+
specSources: ["/spec/dir"],
|
|
404
|
+
dryRun: false,
|
|
405
|
+
ci: true,
|
|
406
|
+
eventAction: "closed",
|
|
407
|
+
};
|
|
408
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
409
|
+
const result = await service.execute(context);
|
|
410
|
+
expect(result.success).toBe(true);
|
|
411
|
+
expect(result.round).toBe(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("should throw when no prNumber and no baseRef/headRef", async () => {
|
|
415
|
+
const context: ReviewContext = {
|
|
416
|
+
owner: "owner",
|
|
417
|
+
repo: "repo",
|
|
418
|
+
specSources: ["/spec/dir"],
|
|
419
|
+
dryRun: false,
|
|
420
|
+
ci: false,
|
|
421
|
+
llmMode: "openai",
|
|
422
|
+
};
|
|
423
|
+
await expect(service.execute(context)).rejects.toThrow("必须指定 PR 编号或者 base/head 分支");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should execute review with baseRef/headRef", async () => {
|
|
427
|
+
const context: ReviewContext = {
|
|
428
|
+
owner: "owner",
|
|
429
|
+
repo: "repo",
|
|
430
|
+
baseRef: "main",
|
|
431
|
+
headRef: "feature",
|
|
432
|
+
specSources: ["/spec/dir"],
|
|
433
|
+
dryRun: true,
|
|
434
|
+
ci: false,
|
|
435
|
+
llmMode: "openai",
|
|
436
|
+
};
|
|
437
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
|
|
438
|
+
{ filename: "test.ts", patch: "@@ -1,1 +1,2 @@\n line1\n+new", status: "modified" },
|
|
439
|
+
]);
|
|
440
|
+
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
441
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
442
|
+
]);
|
|
443
|
+
const result = await service.execute(context);
|
|
444
|
+
expect(result.success).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("should execute direct file mode when base equals head", async () => {
|
|
448
|
+
const context: ReviewContext = {
|
|
449
|
+
owner: "owner",
|
|
450
|
+
repo: "repo",
|
|
451
|
+
baseRef: "main",
|
|
452
|
+
headRef: "main",
|
|
453
|
+
files: ["src/app.ts"],
|
|
454
|
+
specSources: ["/spec/dir"],
|
|
455
|
+
dryRun: true,
|
|
456
|
+
ci: false,
|
|
457
|
+
llmMode: "openai",
|
|
458
|
+
showAll: true,
|
|
459
|
+
};
|
|
460
|
+
const result = await service.execute(context);
|
|
461
|
+
expect(result.success).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("should filter files by includes pattern", async () => {
|
|
465
|
+
const context: ReviewContext = {
|
|
466
|
+
owner: "owner",
|
|
467
|
+
repo: "repo",
|
|
468
|
+
prNumber: 123,
|
|
469
|
+
specSources: ["/spec/dir"],
|
|
470
|
+
dryRun: true,
|
|
471
|
+
ci: false,
|
|
472
|
+
llmMode: "openai",
|
|
473
|
+
includes: ["*.ts"],
|
|
474
|
+
};
|
|
475
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } });
|
|
476
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
477
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
478
|
+
]);
|
|
479
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
480
|
+
{ filename: "test.ts", status: "modified" },
|
|
481
|
+
{ filename: "readme.md", status: "modified" },
|
|
482
|
+
]);
|
|
483
|
+
gitProvider.getCommit.mockResolvedValue({
|
|
484
|
+
files: [{ filename: "test.ts" }],
|
|
485
|
+
} as any);
|
|
486
|
+
const result = await service.execute(context);
|
|
487
|
+
expect(result.success).toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should filter merge commits", async () => {
|
|
491
|
+
const context: ReviewContext = {
|
|
492
|
+
owner: "owner",
|
|
493
|
+
repo: "repo",
|
|
494
|
+
prNumber: 123,
|
|
495
|
+
specSources: ["/spec/dir"],
|
|
496
|
+
dryRun: true,
|
|
497
|
+
ci: false,
|
|
498
|
+
llmMode: "openai",
|
|
499
|
+
};
|
|
500
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } });
|
|
501
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
502
|
+
{ sha: "abc123", commit: { message: "Merge branch 'main' into feature" } },
|
|
503
|
+
{ sha: "def456", commit: { message: "fix: real commit" } },
|
|
504
|
+
]);
|
|
505
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
506
|
+
{ filename: "test.ts", status: "modified" },
|
|
507
|
+
]);
|
|
508
|
+
const result = await service.execute(context);
|
|
509
|
+
expect(result.success).toBe(true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("should run deletion analysis when analyzeDeletions is true", async () => {
|
|
513
|
+
const context: ReviewContext = {
|
|
514
|
+
owner: "owner",
|
|
515
|
+
repo: "repo",
|
|
516
|
+
prNumber: 123,
|
|
517
|
+
specSources: ["/spec/dir"],
|
|
518
|
+
dryRun: false,
|
|
519
|
+
ci: false,
|
|
520
|
+
llmMode: "openai",
|
|
521
|
+
analyzeDeletions: true,
|
|
522
|
+
deletionAnalysisMode: "openai",
|
|
523
|
+
};
|
|
524
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } });
|
|
525
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
526
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
527
|
+
]);
|
|
528
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
529
|
+
{ filename: "test.ts", status: "modified" },
|
|
530
|
+
]);
|
|
531
|
+
const result = await service.execute(context);
|
|
532
|
+
expect(result.success).toBe(true);
|
|
533
|
+
expect(mockDeletionImpactService.analyzeDeletionImpact).toHaveBeenCalled();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("should execute deletionOnly review successfully", async () => {
|
|
537
|
+
const context: ReviewContext = {
|
|
538
|
+
owner: "owner",
|
|
539
|
+
repo: "repo",
|
|
540
|
+
prNumber: 123,
|
|
541
|
+
specSources: ["/spec/dir"],
|
|
542
|
+
dryRun: false,
|
|
543
|
+
ci: true,
|
|
544
|
+
llmMode: "openai",
|
|
545
|
+
deletionOnly: true,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const mockPR = { title: "Test PR", head: { sha: "abc123" } };
|
|
549
|
+
gitProvider.getPullRequest.mockResolvedValue(mockPR as any);
|
|
550
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([]);
|
|
551
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([]);
|
|
552
|
+
gitProvider.listPullReviews.mockResolvedValue([]);
|
|
553
|
+
gitProvider.createPullReview.mockResolvedValue({});
|
|
554
|
+
|
|
555
|
+
const result = await service.execute(context);
|
|
556
|
+
|
|
557
|
+
expect(result.success).toBe(true);
|
|
558
|
+
expect(mockDeletionImpactService.analyzeDeletionImpact).toHaveBeenCalled();
|
|
559
|
+
expect(gitProvider.createPullReview).toHaveBeenCalled();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe("ReviewService.getPrNumberFromEvent", () => {
|
|
564
|
+
const originalEnv = process.env;
|
|
565
|
+
|
|
566
|
+
beforeEach(() => {
|
|
567
|
+
process.env = { ...originalEnv };
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
afterEach(() => {
|
|
571
|
+
process.env = originalEnv;
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("should return undefined if GITHUB_EVENT_PATH is not set", async () => {
|
|
575
|
+
delete process.env.GITHUB_EVENT_PATH;
|
|
576
|
+
const prNumber = await (service as any).getPrNumberFromEvent();
|
|
577
|
+
expect(prNumber).toBeUndefined();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("should parse prNumber from event file", async () => {
|
|
581
|
+
const mockEventPath = "/tmp/event.json";
|
|
582
|
+
process.env.GITHUB_EVENT_PATH = mockEventPath;
|
|
583
|
+
const mockEventContent = JSON.stringify({ pull_request: { number: 456 } });
|
|
584
|
+
|
|
585
|
+
(readFile as Mock).mockResolvedValue(mockEventContent);
|
|
586
|
+
|
|
587
|
+
const prNumber = await (service as any).getPrNumberFromEvent();
|
|
588
|
+
expect(prNumber).toBe(456);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
describe("ReviewService.runLLMReview", () => {
|
|
593
|
+
it("should call callLLM when llmMode is claude", async () => {
|
|
594
|
+
const callLLMSpy = vi
|
|
595
|
+
.spyOn(service as any, "callLLM")
|
|
596
|
+
.mockResolvedValue({ issues: [], summary: [] });
|
|
597
|
+
|
|
598
|
+
const mockPrompt: ReviewPrompt = {
|
|
599
|
+
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
await (service as any).runLLMReview("claude-code", mockPrompt);
|
|
603
|
+
|
|
604
|
+
expect(callLLMSpy).toHaveBeenCalledWith("claude-code", mockPrompt, {});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should call callLLM when llmMode is openai", async () => {
|
|
608
|
+
const callLLMSpy = vi
|
|
609
|
+
.spyOn(service as any, "callLLM")
|
|
610
|
+
.mockResolvedValue({ issues: [], summary: [] });
|
|
611
|
+
|
|
612
|
+
const mockPrompt: ReviewPrompt = {
|
|
613
|
+
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
await (service as any).runLLMReview("openai", mockPrompt);
|
|
617
|
+
|
|
618
|
+
expect(callLLMSpy).toHaveBeenCalledWith("openai", mockPrompt, {});
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe("ReviewService Logic", () => {
|
|
623
|
+
it("normalizeIssues should split comma separated lines", () => {
|
|
624
|
+
const issues = [
|
|
625
|
+
{
|
|
626
|
+
file: "test.ts",
|
|
627
|
+
line: "10, 12",
|
|
628
|
+
ruleId: "R1",
|
|
629
|
+
specFile: "s1.md",
|
|
630
|
+
reason: "r1",
|
|
631
|
+
suggestion: "fix",
|
|
632
|
+
} as any,
|
|
633
|
+
];
|
|
634
|
+
const normalized = (service as any).normalizeIssues(issues);
|
|
635
|
+
expect(normalized).toHaveLength(2);
|
|
636
|
+
expect(normalized[0].line).toBe("10");
|
|
637
|
+
expect(normalized[1].line).toBe("12");
|
|
638
|
+
expect(normalized[1].suggestion).toContain("参考 test.ts:10");
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("parseChangedLinesFromPatch should correctly parse additions", () => {
|
|
642
|
+
const patch = `@@ -1,3 +1,4 @@
|
|
643
|
+
line1
|
|
644
|
+
+line2
|
|
645
|
+
line3
|
|
646
|
+
line4`;
|
|
647
|
+
const changedLines = parseChangedLinesFromPatch(patch);
|
|
648
|
+
expect(changedLines.has(2)).toBe(true);
|
|
649
|
+
expect(changedLines.size).toBe(1);
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
describe("ReviewService.updateIssueLineNumbers", () => {
|
|
654
|
+
beforeEach(() => {
|
|
655
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
656
|
+
const lines: number[] = [];
|
|
657
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
658
|
+
if (rangeMatch) {
|
|
659
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
660
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
661
|
+
for (let i = start; i <= end; i++) {
|
|
662
|
+
lines.push(i);
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
const line = parseInt(lineStr, 10);
|
|
666
|
+
if (!isNaN(line)) {
|
|
667
|
+
lines.push(line);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return lines;
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("should update issue line numbers when code is inserted before", () => {
|
|
675
|
+
// 在第1行插入2行,原第5行变成第7行
|
|
676
|
+
const issues = [
|
|
677
|
+
{
|
|
678
|
+
file: "test.ts",
|
|
679
|
+
line: "5",
|
|
680
|
+
ruleId: "R1",
|
|
681
|
+
specFile: "s1.md",
|
|
682
|
+
reason: "test issue",
|
|
683
|
+
severity: "error",
|
|
684
|
+
code: "",
|
|
685
|
+
round: 1,
|
|
686
|
+
} as any,
|
|
687
|
+
];
|
|
688
|
+
const filePatchMap = new Map<string, string>([
|
|
689
|
+
[
|
|
690
|
+
"test.ts",
|
|
691
|
+
`@@ -1,3 +1,5 @@
|
|
692
|
+
line1
|
|
693
|
+
+new line 1
|
|
694
|
+
+new line 2
|
|
695
|
+
line2
|
|
696
|
+
line3`,
|
|
697
|
+
],
|
|
698
|
+
]);
|
|
699
|
+
|
|
700
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
701
|
+
|
|
702
|
+
expect(result[0].line).toBe("7");
|
|
703
|
+
expect(result[0].originalLine).toBe("5");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("should update issue line numbers when code is deleted before", () => {
|
|
707
|
+
// 删除第1-2行,原第5行变成第3行
|
|
708
|
+
const issues = [
|
|
709
|
+
{
|
|
710
|
+
file: "test.ts",
|
|
711
|
+
line: "5",
|
|
712
|
+
ruleId: "R1",
|
|
713
|
+
specFile: "s1.md",
|
|
714
|
+
reason: "test issue",
|
|
715
|
+
severity: "error",
|
|
716
|
+
code: "",
|
|
717
|
+
round: 1,
|
|
718
|
+
} as any,
|
|
719
|
+
];
|
|
720
|
+
const filePatchMap = new Map<string, string>([
|
|
721
|
+
[
|
|
722
|
+
"test.ts",
|
|
723
|
+
`@@ -1,4 +1,2 @@
|
|
724
|
+
-line1
|
|
725
|
+
-line2
|
|
726
|
+
line3
|
|
727
|
+
line4`,
|
|
728
|
+
],
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
732
|
+
|
|
733
|
+
expect(result[0].line).toBe("3");
|
|
734
|
+
expect(result[0].originalLine).toBe("5");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("should mark issue as invalid when the line is deleted", () => {
|
|
738
|
+
// 删除第5行
|
|
739
|
+
const issues = [
|
|
740
|
+
{
|
|
741
|
+
file: "test.ts",
|
|
742
|
+
line: "5",
|
|
743
|
+
ruleId: "R1",
|
|
744
|
+
specFile: "s1.md",
|
|
745
|
+
reason: "test issue",
|
|
746
|
+
severity: "error",
|
|
747
|
+
code: "",
|
|
748
|
+
round: 1,
|
|
749
|
+
} as any,
|
|
750
|
+
];
|
|
751
|
+
const filePatchMap = new Map<string, string>([
|
|
752
|
+
[
|
|
753
|
+
"test.ts",
|
|
754
|
+
`@@ -5,1 +5,0 @@
|
|
755
|
+
-deleted line`,
|
|
756
|
+
],
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
760
|
+
|
|
761
|
+
expect(result[0].valid).toBe("false");
|
|
762
|
+
expect(result[0].originalLine).toBe("5");
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("should not update issue when file has no changes", () => {
|
|
766
|
+
const issues = [
|
|
767
|
+
{
|
|
768
|
+
file: "test.ts",
|
|
769
|
+
line: "5",
|
|
770
|
+
ruleId: "R1",
|
|
771
|
+
specFile: "s1.md",
|
|
772
|
+
reason: "test issue",
|
|
773
|
+
severity: "error",
|
|
774
|
+
code: "",
|
|
775
|
+
round: 1,
|
|
776
|
+
} as any,
|
|
777
|
+
];
|
|
778
|
+
const filePatchMap = new Map<string, string>([
|
|
779
|
+
[
|
|
780
|
+
"other.ts",
|
|
781
|
+
`@@ -1,1 +1,2 @@
|
|
782
|
+
line1
|
|
783
|
+
+new line`,
|
|
784
|
+
],
|
|
785
|
+
]);
|
|
786
|
+
|
|
787
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
788
|
+
|
|
789
|
+
expect(result[0].line).toBe("5");
|
|
790
|
+
expect(result[0].originalLine).toBeUndefined();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("should not update already fixed issues", () => {
|
|
794
|
+
const issues = [
|
|
795
|
+
{
|
|
796
|
+
file: "test.ts",
|
|
797
|
+
line: "5",
|
|
798
|
+
ruleId: "R1",
|
|
799
|
+
specFile: "s1.md",
|
|
800
|
+
reason: "test issue",
|
|
801
|
+
severity: "error",
|
|
802
|
+
code: "",
|
|
803
|
+
round: 1,
|
|
804
|
+
fixed: "2024-01-01T00:00:00Z",
|
|
805
|
+
} as any,
|
|
806
|
+
];
|
|
807
|
+
const filePatchMap = new Map<string, string>([
|
|
808
|
+
[
|
|
809
|
+
"test.ts",
|
|
810
|
+
`@@ -1,1 +1,3 @@
|
|
811
|
+
line1
|
|
812
|
+
+new line 1
|
|
813
|
+
+new line 2`,
|
|
814
|
+
],
|
|
815
|
+
]);
|
|
816
|
+
|
|
817
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
818
|
+
|
|
819
|
+
expect(result[0].line).toBe("5");
|
|
820
|
+
expect(result[0].originalLine).toBeUndefined();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("should not update invalid issues", () => {
|
|
824
|
+
const issues = [
|
|
825
|
+
{
|
|
826
|
+
file: "test.ts",
|
|
827
|
+
line: "5",
|
|
828
|
+
ruleId: "R1",
|
|
829
|
+
specFile: "s1.md",
|
|
830
|
+
reason: "test issue",
|
|
831
|
+
severity: "error",
|
|
832
|
+
code: "",
|
|
833
|
+
round: 1,
|
|
834
|
+
valid: "false",
|
|
835
|
+
} as any,
|
|
836
|
+
];
|
|
837
|
+
const filePatchMap = new Map<string, string>([
|
|
838
|
+
[
|
|
839
|
+
"test.ts",
|
|
840
|
+
`@@ -1,1 +1,3 @@
|
|
841
|
+
line1
|
|
842
|
+
+new line 1
|
|
843
|
+
+new line 2`,
|
|
844
|
+
],
|
|
845
|
+
]);
|
|
846
|
+
|
|
847
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
848
|
+
|
|
849
|
+
expect(result[0].line).toBe("5");
|
|
850
|
+
expect(result[0].originalLine).toBeUndefined();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("should handle range line numbers", () => {
|
|
854
|
+
// 在第1行插入2行,原第5-7行变成第7-9行
|
|
855
|
+
const issues = [
|
|
856
|
+
{
|
|
857
|
+
file: "test.ts",
|
|
858
|
+
line: "5-7",
|
|
859
|
+
ruleId: "R1",
|
|
860
|
+
specFile: "s1.md",
|
|
861
|
+
reason: "test issue",
|
|
862
|
+
severity: "error",
|
|
863
|
+
code: "",
|
|
864
|
+
round: 1,
|
|
865
|
+
} as any,
|
|
866
|
+
];
|
|
867
|
+
const filePatchMap = new Map<string, string>([
|
|
868
|
+
[
|
|
869
|
+
"test.ts",
|
|
870
|
+
`@@ -1,3 +1,5 @@
|
|
871
|
+
line1
|
|
872
|
+
+new line 1
|
|
873
|
+
+new line 2
|
|
874
|
+
line2
|
|
875
|
+
line3`,
|
|
876
|
+
],
|
|
877
|
+
]);
|
|
878
|
+
|
|
879
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
880
|
+
|
|
881
|
+
expect(result[0].line).toBe("7-9");
|
|
882
|
+
expect(result[0].originalLine).toBe("5-7");
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
it("should preserve originalLine if already set", () => {
|
|
886
|
+
const issues = [
|
|
887
|
+
{
|
|
888
|
+
file: "test.ts",
|
|
889
|
+
line: "7",
|
|
890
|
+
originalLine: "3",
|
|
891
|
+
ruleId: "R1",
|
|
892
|
+
specFile: "s1.md",
|
|
893
|
+
reason: "test issue",
|
|
894
|
+
severity: "error",
|
|
895
|
+
code: "",
|
|
896
|
+
round: 1,
|
|
897
|
+
} as any,
|
|
898
|
+
];
|
|
899
|
+
const filePatchMap = new Map<string, string>([
|
|
900
|
+
[
|
|
901
|
+
"test.ts",
|
|
902
|
+
`@@ -1,1 +1,3 @@
|
|
903
|
+
line1
|
|
904
|
+
+new line 1
|
|
905
|
+
+new line 2`,
|
|
906
|
+
],
|
|
907
|
+
]);
|
|
908
|
+
|
|
909
|
+
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
910
|
+
|
|
911
|
+
expect(result[0].line).toBe("9");
|
|
912
|
+
expect(result[0].originalLine).toBe("3");
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
describe("ReviewService.getFileContents", () => {
|
|
917
|
+
beforeEach(() => {
|
|
918
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
919
|
+
const lines: number[] = [];
|
|
920
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
921
|
+
if (rangeMatch) {
|
|
922
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
923
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
924
|
+
for (let i = start; i <= end; i++) {
|
|
925
|
+
lines.push(i);
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
const line = parseInt(lineStr, 10);
|
|
929
|
+
if (!isNaN(line)) {
|
|
930
|
+
lines.push(line);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return lines;
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it("should mark changed lines with commit hash from PR patch", async () => {
|
|
938
|
+
const changedFiles = [
|
|
939
|
+
{
|
|
940
|
+
filename: "test.ts",
|
|
941
|
+
status: "modified",
|
|
942
|
+
patch: `@@ -1,3 +1,5 @@
|
|
943
|
+
line1
|
|
944
|
+
+new line 1
|
|
945
|
+
+new line 2
|
|
946
|
+
line2
|
|
947
|
+
line3`,
|
|
948
|
+
},
|
|
949
|
+
];
|
|
950
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
951
|
+
|
|
952
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nnew line 1\nnew line 2\nline2\nline3");
|
|
953
|
+
|
|
954
|
+
const result = await (service as any).getFileContents(
|
|
955
|
+
"owner",
|
|
956
|
+
"repo",
|
|
957
|
+
changedFiles,
|
|
958
|
+
commits,
|
|
959
|
+
"abc1234",
|
|
960
|
+
123,
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
expect(result.size).toBe(1);
|
|
964
|
+
const fileContent = result.get("test.ts");
|
|
965
|
+
expect(fileContent).toBeDefined();
|
|
966
|
+
// 第 1 行未变更
|
|
967
|
+
expect(fileContent[0][0]).toBe("-------");
|
|
968
|
+
// 第 2、3 行是新增的
|
|
969
|
+
expect(fileContent[1][0]).toBe("abc1234");
|
|
970
|
+
expect(fileContent[2][0]).toBe("abc1234");
|
|
971
|
+
// 第 4、5 行未变更
|
|
972
|
+
expect(fileContent[3][0]).toBe("-------");
|
|
973
|
+
expect(fileContent[4][0]).toBe("-------");
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it("should handle files without patch (all lines unmarked)", async () => {
|
|
977
|
+
const changedFiles = [
|
|
978
|
+
{
|
|
979
|
+
filename: "test.ts",
|
|
980
|
+
status: "modified",
|
|
981
|
+
// 没有 patch 字段
|
|
982
|
+
},
|
|
983
|
+
];
|
|
984
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
985
|
+
|
|
986
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
987
|
+
|
|
988
|
+
const result = await (service as any).getFileContents(
|
|
989
|
+
"owner",
|
|
990
|
+
"repo",
|
|
991
|
+
changedFiles,
|
|
992
|
+
commits,
|
|
993
|
+
"abc1234",
|
|
994
|
+
123,
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
const fileContent = result.get("test.ts");
|
|
998
|
+
expect(fileContent).toBeDefined();
|
|
999
|
+
// 所有行都未标记变更
|
|
1000
|
+
expect(fileContent[0][0]).toBe("-------");
|
|
1001
|
+
expect(fileContent[1][0]).toBe("-------");
|
|
1002
|
+
expect(fileContent[2][0]).toBe("-------");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("should mark all lines as changed for added files without patch", async () => {
|
|
1006
|
+
const changedFiles = [
|
|
1007
|
+
{
|
|
1008
|
+
filename: "new-file.ts",
|
|
1009
|
+
status: "added",
|
|
1010
|
+
additions: 3,
|
|
1011
|
+
deletions: 0,
|
|
1012
|
+
// 没有 patch 字段(Gitea API 可能不返回新增文件的完整 patch)
|
|
1013
|
+
},
|
|
1014
|
+
];
|
|
1015
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1016
|
+
|
|
1017
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
1018
|
+
|
|
1019
|
+
const result = await (service as any).getFileContents(
|
|
1020
|
+
"owner",
|
|
1021
|
+
"repo",
|
|
1022
|
+
changedFiles,
|
|
1023
|
+
commits,
|
|
1024
|
+
"abc1234",
|
|
1025
|
+
123,
|
|
1026
|
+
);
|
|
1027
|
+
|
|
1028
|
+
const fileContent = result.get("new-file.ts");
|
|
1029
|
+
expect(fileContent).toBeDefined();
|
|
1030
|
+
// 新增文件的所有行都应该标记为变更
|
|
1031
|
+
expect(fileContent[0][0]).toBe("abc1234");
|
|
1032
|
+
expect(fileContent[1][0]).toBe("abc1234");
|
|
1033
|
+
expect(fileContent[2][0]).toBe("abc1234");
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("should skip deleted files", async () => {
|
|
1037
|
+
const changedFiles = [
|
|
1038
|
+
{
|
|
1039
|
+
filename: "deleted.ts",
|
|
1040
|
+
status: "deleted",
|
|
1041
|
+
},
|
|
1042
|
+
];
|
|
1043
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1044
|
+
|
|
1045
|
+
const result = await (service as any).getFileContents(
|
|
1046
|
+
"owner",
|
|
1047
|
+
"repo",
|
|
1048
|
+
changedFiles,
|
|
1049
|
+
commits,
|
|
1050
|
+
"abc1234",
|
|
1051
|
+
123,
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
expect(result.size).toBe(0);
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
describe("ReviewService.getChangedFilesBetweenRefs", () => {
|
|
1059
|
+
it("should return files with patch from getDiffBetweenRefs", async () => {
|
|
1060
|
+
const diffFiles = [
|
|
1061
|
+
{
|
|
1062
|
+
filename: "test.ts",
|
|
1063
|
+
patch: "@@ -1,3 +1,5 @@\n line1\n+new line",
|
|
1064
|
+
},
|
|
1065
|
+
];
|
|
1066
|
+
const statusFiles = [
|
|
1067
|
+
{
|
|
1068
|
+
filename: "test.ts",
|
|
1069
|
+
status: "modified",
|
|
1070
|
+
},
|
|
1071
|
+
];
|
|
1072
|
+
|
|
1073
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
1074
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
1075
|
+
|
|
1076
|
+
const result = await (service as any).getChangedFilesBetweenRefs(
|
|
1077
|
+
"owner",
|
|
1078
|
+
"repo",
|
|
1079
|
+
"main",
|
|
1080
|
+
"feature",
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
expect(result).toHaveLength(1);
|
|
1084
|
+
expect(result[0].filename).toBe("test.ts");
|
|
1085
|
+
expect(result[0].status).toBe("modified");
|
|
1086
|
+
expect(result[0].patch).toBe("@@ -1,3 +1,5 @@\n line1\n+new line");
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it("should use default status when file not in statusFiles", async () => {
|
|
1090
|
+
const diffFiles = [
|
|
1091
|
+
{
|
|
1092
|
+
filename: "new.ts",
|
|
1093
|
+
patch: "@@ -0,0 +1,3 @@\n+line1",
|
|
1094
|
+
},
|
|
1095
|
+
];
|
|
1096
|
+
const statusFiles: any[] = [];
|
|
1097
|
+
|
|
1098
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
1099
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
1100
|
+
|
|
1101
|
+
const result = await (service as any).getChangedFilesBetweenRefs(
|
|
1102
|
+
"owner",
|
|
1103
|
+
"repo",
|
|
1104
|
+
"main",
|
|
1105
|
+
"feature",
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
expect(result).toHaveLength(1);
|
|
1109
|
+
expect(result[0].filename).toBe("new.ts");
|
|
1110
|
+
expect(result[0].status).toBe("modified");
|
|
1111
|
+
});
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
describe("ReviewService.filterIssuesByValidCommits", () => {
|
|
1115
|
+
beforeEach(() => {
|
|
1116
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
1117
|
+
const lines: number[] = [];
|
|
1118
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
1119
|
+
if (rangeMatch) {
|
|
1120
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
1121
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
1122
|
+
for (let i = start; i <= end; i++) {
|
|
1123
|
+
lines.push(i);
|
|
1124
|
+
}
|
|
1125
|
+
} else {
|
|
1126
|
+
const line = parseInt(lineStr, 10);
|
|
1127
|
+
if (!isNaN(line)) {
|
|
1128
|
+
lines.push(line);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return lines;
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it("should filter out issues on non-changed lines", () => {
|
|
1136
|
+
const issues = [
|
|
1137
|
+
{
|
|
1138
|
+
file: "test.ts",
|
|
1139
|
+
line: "1",
|
|
1140
|
+
ruleId: "R1",
|
|
1141
|
+
specFile: "s1.md",
|
|
1142
|
+
reason: "issue on unchanged line",
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
file: "test.ts",
|
|
1146
|
+
line: "2",
|
|
1147
|
+
ruleId: "R2",
|
|
1148
|
+
specFile: "s1.md",
|
|
1149
|
+
reason: "issue on changed line",
|
|
1150
|
+
},
|
|
1151
|
+
];
|
|
1152
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1153
|
+
const fileContents = new Map([
|
|
1154
|
+
[
|
|
1155
|
+
"test.ts",
|
|
1156
|
+
[
|
|
1157
|
+
["-------", "line1"],
|
|
1158
|
+
["abc1234", "new line"],
|
|
1159
|
+
["-------", "line2"],
|
|
1160
|
+
],
|
|
1161
|
+
],
|
|
1162
|
+
]);
|
|
1163
|
+
|
|
1164
|
+
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
1165
|
+
|
|
1166
|
+
expect(result).toHaveLength(1);
|
|
1167
|
+
expect(result[0].line).toBe("2");
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("should keep issues when file not in fileContents", () => {
|
|
1171
|
+
const issues = [
|
|
1172
|
+
{ file: "unknown.ts", line: "1", ruleId: "R1", specFile: "s1.md", reason: "issue" },
|
|
1173
|
+
];
|
|
1174
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1175
|
+
const fileContents = new Map();
|
|
1176
|
+
|
|
1177
|
+
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
1178
|
+
|
|
1179
|
+
expect(result).toHaveLength(1);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("should keep issues with range if any line is changed", () => {
|
|
1183
|
+
const issues = [
|
|
1184
|
+
{ file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
|
|
1185
|
+
];
|
|
1186
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1187
|
+
const fileContents = new Map([
|
|
1188
|
+
[
|
|
1189
|
+
"test.ts",
|
|
1190
|
+
[
|
|
1191
|
+
["-------", "line1"],
|
|
1192
|
+
["abc1234", "new line"],
|
|
1193
|
+
["-------", "line3"],
|
|
1194
|
+
],
|
|
1195
|
+
],
|
|
1196
|
+
]);
|
|
1197
|
+
|
|
1198
|
+
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
1199
|
+
|
|
1200
|
+
expect(result).toHaveLength(1);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it("should filter out issues with range if no line is changed", () => {
|
|
1204
|
+
const issues = [
|
|
1205
|
+
{ file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
|
|
1206
|
+
];
|
|
1207
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
1208
|
+
const fileContents = new Map([
|
|
1209
|
+
[
|
|
1210
|
+
"test.ts",
|
|
1211
|
+
[
|
|
1212
|
+
["-------", "line1"],
|
|
1213
|
+
["-------", "line2"],
|
|
1214
|
+
["-------", "line3"],
|
|
1215
|
+
],
|
|
1216
|
+
],
|
|
1217
|
+
]);
|
|
1218
|
+
|
|
1219
|
+
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
1220
|
+
|
|
1221
|
+
expect(result).toHaveLength(0);
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
describe("ReviewService.resolveAnalyzeDeletions", () => {
|
|
1226
|
+
it("should return boolean directly", () => {
|
|
1227
|
+
expect(
|
|
1228
|
+
(service as any).resolveAnalyzeDeletions(true, { ci: false, hasPrNumber: false }),
|
|
1229
|
+
).toBe(true);
|
|
1230
|
+
expect((service as any).resolveAnalyzeDeletions(false, { ci: true, hasPrNumber: true })).toBe(
|
|
1231
|
+
false,
|
|
1232
|
+
);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("should resolve 'ci' mode", () => {
|
|
1236
|
+
expect((service as any).resolveAnalyzeDeletions("ci", { ci: true, hasPrNumber: false })).toBe(
|
|
1237
|
+
true,
|
|
1238
|
+
);
|
|
1239
|
+
expect(
|
|
1240
|
+
(service as any).resolveAnalyzeDeletions("ci", { ci: false, hasPrNumber: false }),
|
|
1241
|
+
).toBe(false);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
it("should resolve 'pr' mode", () => {
|
|
1245
|
+
expect((service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: true })).toBe(
|
|
1246
|
+
true,
|
|
1247
|
+
);
|
|
1248
|
+
expect(
|
|
1249
|
+
(service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: false }),
|
|
1250
|
+
).toBe(false);
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
it("should resolve 'terminal' mode", () => {
|
|
1254
|
+
expect(
|
|
1255
|
+
(service as any).resolveAnalyzeDeletions("terminal", { ci: false, hasPrNumber: false }),
|
|
1256
|
+
).toBe(true);
|
|
1257
|
+
expect(
|
|
1258
|
+
(service as any).resolveAnalyzeDeletions("terminal", { ci: true, hasPrNumber: false }),
|
|
1259
|
+
).toBe(false);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("should return false for unknown mode", () => {
|
|
1263
|
+
expect(
|
|
1264
|
+
(service as any).resolveAnalyzeDeletions("unknown", { ci: false, hasPrNumber: false }),
|
|
1265
|
+
).toBe(false);
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
describe("ReviewService.calculateIssueStats", () => {
|
|
1270
|
+
it("should calculate stats for empty array", () => {
|
|
1271
|
+
const stats = (service as any).calculateIssueStats([]);
|
|
1272
|
+
expect(stats).toEqual({ total: 0, fixed: 0, invalid: 0, pending: 0, fixRate: 0 });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("should calculate stats correctly", () => {
|
|
1276
|
+
const issues = [{ fixed: "2024-01-01" }, { fixed: "2024-01-02" }, { valid: "false" }, {}, {}];
|
|
1277
|
+
const stats = (service as any).calculateIssueStats(issues);
|
|
1278
|
+
expect(stats.total).toBe(5);
|
|
1279
|
+
expect(stats.fixed).toBe(2);
|
|
1280
|
+
expect(stats.invalid).toBe(1);
|
|
1281
|
+
expect(stats.pending).toBe(2);
|
|
1282
|
+
expect(stats.fixRate).toBe(40);
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
describe("ReviewService.filterSpecsForFile", () => {
|
|
1287
|
+
it("should return empty for files without extension", () => {
|
|
1288
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
1289
|
+
expect((service as any).filterSpecsForFile(specs, "Makefile")).toEqual([]);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it("should filter by extension", () => {
|
|
1293
|
+
const specs = [
|
|
1294
|
+
{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] },
|
|
1295
|
+
{ extensions: ["py"], includes: [], rules: [{ id: "R2" }] },
|
|
1296
|
+
];
|
|
1297
|
+
const result = (service as any).filterSpecsForFile(specs, "src/app.ts");
|
|
1298
|
+
expect(result).toHaveLength(1);
|
|
1299
|
+
expect(result[0].rules[0].id).toBe("R1");
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it("should filter by includes pattern when present", () => {
|
|
1303
|
+
const specs = [{ extensions: ["ts"], includes: ["**/*.spec.ts"], rules: [{ id: "R1" }] }];
|
|
1304
|
+
expect((service as any).filterSpecsForFile(specs, "src/app.spec.ts")).toHaveLength(1);
|
|
1305
|
+
expect((service as any).filterSpecsForFile(specs, "src/app.ts")).toHaveLength(0);
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
describe("ReviewService.buildSystemPrompt", () => {
|
|
1310
|
+
it("should include specs section in prompt", () => {
|
|
1311
|
+
const result = (service as any).buildSystemPrompt("## 规则内容");
|
|
1312
|
+
expect(result).toContain("## 规则内容");
|
|
1313
|
+
expect(result).toContain("代码审查专家");
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
describe("ReviewService.formatReviewComment", () => {
|
|
1318
|
+
it("should use markdown format in CI with PR", () => {
|
|
1319
|
+
const result = { issues: [], summary: [] };
|
|
1320
|
+
(service as any).formatReviewComment(result, { ci: true, prNumber: 1 });
|
|
1321
|
+
expect((service as any).reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it("should use terminal format by default", () => {
|
|
1325
|
+
const result = { issues: [], summary: [] };
|
|
1326
|
+
(service as any).formatReviewComment(result, {});
|
|
1327
|
+
expect((service as any).reviewReportService.format).toHaveBeenCalledWith(result, "terminal");
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it("should use specified outputFormat", () => {
|
|
1331
|
+
const result = { issues: [], summary: [] };
|
|
1332
|
+
(service as any).formatReviewComment(result, { outputFormat: "markdown" });
|
|
1333
|
+
expect((service as any).reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
describe("ReviewService.lineMatchesPosition", () => {
|
|
1338
|
+
it("should return false when no position", () => {
|
|
1339
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1340
|
+
expect((service as any).lineMatchesPosition("10", undefined)).toBe(false);
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
it("should return true when position is within range", () => {
|
|
1344
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10, 11, 12]);
|
|
1345
|
+
expect((service as any).lineMatchesPosition("10-12", 11)).toBe(true);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
it("should return false when position is outside range", () => {
|
|
1349
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10, 11, 12]);
|
|
1350
|
+
expect((service as any).lineMatchesPosition("10-12", 15)).toBe(false);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it("should return false for empty line range", () => {
|
|
1354
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([]);
|
|
1355
|
+
expect((service as any).lineMatchesPosition("", 10)).toBe(false);
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
describe("ReviewService.issueToReviewComment", () => {
|
|
1360
|
+
it("should return null for invalid line", () => {
|
|
1361
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([]);
|
|
1362
|
+
const issue = { file: "test.ts", line: "abc", ruleId: "R1", specFile: "s1.md", reason: "r" };
|
|
1363
|
+
expect((service as any).issueToReviewComment(issue)).toBeNull();
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
it("should convert issue to review comment", () => {
|
|
1367
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1368
|
+
const issue = {
|
|
1369
|
+
file: "test.ts",
|
|
1370
|
+
line: "10",
|
|
1371
|
+
ruleId: "R1",
|
|
1372
|
+
specFile: "s1.md",
|
|
1373
|
+
reason: "问题描述",
|
|
1374
|
+
severity: "error",
|
|
1375
|
+
author: { login: "dev1" },
|
|
1376
|
+
suggestion: "fix code",
|
|
1377
|
+
};
|
|
1378
|
+
const result = (service as any).issueToReviewComment(issue);
|
|
1379
|
+
expect(result).not.toBeNull();
|
|
1380
|
+
expect(result.path).toBe("test.ts");
|
|
1381
|
+
expect(result.new_position).toBe(10);
|
|
1382
|
+
expect(result.body).toContain("🔴");
|
|
1383
|
+
expect(result.body).toContain("@dev1");
|
|
1384
|
+
expect(result.body).toContain("fix code");
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it("should handle warn severity", () => {
|
|
1388
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1389
|
+
const issue = {
|
|
1390
|
+
file: "test.ts",
|
|
1391
|
+
line: "5",
|
|
1392
|
+
ruleId: "R1",
|
|
1393
|
+
specFile: "s1.md",
|
|
1394
|
+
reason: "r",
|
|
1395
|
+
severity: "warn",
|
|
1396
|
+
};
|
|
1397
|
+
const result = (service as any).issueToReviewComment(issue);
|
|
1398
|
+
expect(result.body).toContain("🟡");
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("should handle issue without author", () => {
|
|
1402
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1403
|
+
const issue = {
|
|
1404
|
+
file: "test.ts",
|
|
1405
|
+
line: "5",
|
|
1406
|
+
ruleId: "R1",
|
|
1407
|
+
specFile: "s1.md",
|
|
1408
|
+
reason: "r",
|
|
1409
|
+
severity: "info",
|
|
1410
|
+
};
|
|
1411
|
+
const result = (service as any).issueToReviewComment(issue);
|
|
1412
|
+
expect(result.body).toContain("未知");
|
|
1413
|
+
expect(result.body).toContain("⚪");
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it("should include commit info when present", () => {
|
|
1417
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1418
|
+
const issue = {
|
|
1419
|
+
file: "test.ts",
|
|
1420
|
+
line: "5",
|
|
1421
|
+
ruleId: "R1",
|
|
1422
|
+
specFile: "s1.md",
|
|
1423
|
+
reason: "r",
|
|
1424
|
+
commit: "abc1234",
|
|
1425
|
+
};
|
|
1426
|
+
const result = (service as any).issueToReviewComment(issue);
|
|
1427
|
+
expect(result.body).toContain("abc1234");
|
|
1428
|
+
});
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
describe("ReviewService.generateIssueKey", () => {
|
|
1432
|
+
it("should generate key from file, line, and ruleId", () => {
|
|
1433
|
+
const issue = { file: "test.ts", line: "10", ruleId: "R1" };
|
|
1434
|
+
expect((service as any).generateIssueKey(issue)).toBe("test.ts:10:R1");
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
describe("ReviewService.parseExistingReviewResult", () => {
|
|
1439
|
+
it("should return null when parseMarkdown returns null", () => {
|
|
1440
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1441
|
+
mockReviewReportService.parseMarkdown.mockReturnValue(null);
|
|
1442
|
+
expect((service as any).parseExistingReviewResult("body")).toBeNull();
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
it("should return result from parsed markdown", () => {
|
|
1446
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1447
|
+
const mockResult = { issues: [{ id: 1 }], summary: [] };
|
|
1448
|
+
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
1449
|
+
expect((service as any).parseExistingReviewResult("body")).toEqual(mockResult);
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
describe("ReviewService.filterDuplicateIssues", () => {
|
|
1454
|
+
it("should filter issues that exist in valid existing issues", () => {
|
|
1455
|
+
const newIssues = [
|
|
1456
|
+
{ file: "a.ts", line: "1", ruleId: "R1" },
|
|
1457
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
1458
|
+
];
|
|
1459
|
+
const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "true" }];
|
|
1460
|
+
const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
|
|
1461
|
+
expect(result.filteredIssues).toHaveLength(1);
|
|
1462
|
+
expect(result.filteredIssues[0].file).toBe("b.ts");
|
|
1463
|
+
expect(result.skippedCount).toBe(1);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it("should not filter if existing issue is not valid", () => {
|
|
1467
|
+
const newIssues = [{ file: "a.ts", line: "1", ruleId: "R1" }];
|
|
1468
|
+
const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "false" }];
|
|
1469
|
+
const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
|
|
1470
|
+
expect(result.filteredIssues).toHaveLength(1);
|
|
1471
|
+
expect(result.skippedCount).toBe(0);
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
describe("ReviewService.getFallbackTitle", () => {
|
|
1476
|
+
it("should return first commit message", () => {
|
|
1477
|
+
const commits = [{ commit: { message: "feat: add feature\n\ndetails" } }];
|
|
1478
|
+
expect((service as any).getFallbackTitle(commits)).toBe("feat: add feature");
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it("should return default when no commits", () => {
|
|
1482
|
+
expect((service as any).getFallbackTitle([])).toBe("PR 更新");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("should truncate long titles", () => {
|
|
1486
|
+
const commits = [{ commit: { message: "a".repeat(100) } }];
|
|
1487
|
+
expect((service as any).getFallbackTitle(commits).length).toBeLessThanOrEqual(50);
|
|
1488
|
+
});
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
describe("ReviewService.normalizeFilePaths", () => {
|
|
1492
|
+
it("should return undefined for empty array", () => {
|
|
1493
|
+
expect((service as any).normalizeFilePaths([])).toEqual([]);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it("should return undefined for undefined input", () => {
|
|
1497
|
+
expect((service as any).normalizeFilePaths(undefined)).toBeUndefined();
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
it("should keep relative paths as-is", () => {
|
|
1501
|
+
const result = (service as any).normalizeFilePaths(["src/app.ts", "lib/util.ts"]);
|
|
1502
|
+
expect(result).toEqual(["src/app.ts", "lib/util.ts"]);
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
describe("ReviewService.getExistingReviewResult", () => {
|
|
1507
|
+
it("should return null when no AI review exists", async () => {
|
|
1508
|
+
gitProvider.listPullReviews.mockResolvedValue([{ body: "normal review" }] as any);
|
|
1509
|
+
const result = await (service as any).getExistingReviewResult("o", "r", 1);
|
|
1510
|
+
expect(result).toBeNull();
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it("should return parsed result when AI review exists", async () => {
|
|
1514
|
+
const mockResult = { issues: [], summary: [] };
|
|
1515
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1516
|
+
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
1517
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
1518
|
+
{ body: "<!-- spaceflow-review --> review content" },
|
|
1519
|
+
] as any);
|
|
1520
|
+
const result = await (service as any).getExistingReviewResult("o", "r", 1);
|
|
1521
|
+
expect(result).toEqual(mockResult);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it("should return null on error", async () => {
|
|
1525
|
+
gitProvider.listPullReviews.mockRejectedValue(new Error("API error"));
|
|
1526
|
+
const result = await (service as any).getExistingReviewResult("o", "r", 1);
|
|
1527
|
+
expect(result).toBeNull();
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
describe("ReviewService.deleteExistingAiReviews", () => {
|
|
1532
|
+
it("should delete AI reviews", async () => {
|
|
1533
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
1534
|
+
{ id: 1, body: "<!-- spaceflow-review --> old review" },
|
|
1535
|
+
{ id: 2, body: "normal review" },
|
|
1536
|
+
] as any);
|
|
1537
|
+
gitProvider.deletePullReview.mockResolvedValue(undefined as any);
|
|
1538
|
+
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1539
|
+
expect(gitProvider.deletePullReview).toHaveBeenCalledWith("o", "r", 1, 1);
|
|
1540
|
+
expect(gitProvider.deletePullReview).toHaveBeenCalledTimes(1);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it("should handle error gracefully", async () => {
|
|
1544
|
+
gitProvider.listPullReviews.mockRejectedValue(new Error("fail"));
|
|
1545
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1546
|
+
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1547
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1548
|
+
consoleSpy.mockRestore();
|
|
1549
|
+
});
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
describe("ReviewService.invalidateIssuesForChangedFiles", () => {
|
|
1553
|
+
it("should return issues unchanged when no headSha", async () => {
|
|
1554
|
+
const issues = [{ file: "a.ts", line: "1" }];
|
|
1555
|
+
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1556
|
+
issues,
|
|
1557
|
+
undefined,
|
|
1558
|
+
"o",
|
|
1559
|
+
"r",
|
|
1560
|
+
);
|
|
1561
|
+
expect(result).toBe(issues);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
it("should invalidate issues for changed files", async () => {
|
|
1565
|
+
gitProvider.getCommitDiff = vi
|
|
1566
|
+
.fn()
|
|
1567
|
+
.mockResolvedValue(
|
|
1568
|
+
"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",
|
|
1569
|
+
) as any;
|
|
1570
|
+
const issues = [
|
|
1571
|
+
{ file: "changed.ts", line: "1", ruleId: "R1" },
|
|
1572
|
+
{ file: "unchanged.ts", line: "2", ruleId: "R2" },
|
|
1573
|
+
{ file: "changed.ts", line: "3", ruleId: "R3", fixed: "2024-01-01" },
|
|
1574
|
+
];
|
|
1575
|
+
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1576
|
+
issues,
|
|
1577
|
+
"abc123",
|
|
1578
|
+
"o",
|
|
1579
|
+
"r",
|
|
1580
|
+
);
|
|
1581
|
+
expect(result).toHaveLength(3);
|
|
1582
|
+
expect(result[0].valid).toBe("false");
|
|
1583
|
+
expect(result[1].valid).toBeUndefined();
|
|
1584
|
+
expect(result[2].fixed).toBe("2024-01-01");
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it("should return issues unchanged when no diff files", async () => {
|
|
1588
|
+
gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
|
|
1589
|
+
const issues = [{ file: "a.ts", line: "1" }];
|
|
1590
|
+
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1591
|
+
issues,
|
|
1592
|
+
"abc123",
|
|
1593
|
+
"o",
|
|
1594
|
+
"r",
|
|
1595
|
+
);
|
|
1596
|
+
expect(result).toBe(issues);
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it("should handle API error gracefully", async () => {
|
|
1600
|
+
gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
|
|
1601
|
+
const issues = [{ file: "a.ts", line: "1" }];
|
|
1602
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1603
|
+
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1604
|
+
issues,
|
|
1605
|
+
"abc123",
|
|
1606
|
+
"o",
|
|
1607
|
+
"r",
|
|
1608
|
+
);
|
|
1609
|
+
expect(result).toBe(issues);
|
|
1610
|
+
consoleSpy.mockRestore();
|
|
1611
|
+
});
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
describe("ReviewService.fillIssueCode", () => {
|
|
1615
|
+
it("should fill code from file contents", async () => {
|
|
1616
|
+
const issues = [{ file: "test.ts", line: "2" }];
|
|
1617
|
+
const fileContents = new Map([
|
|
1618
|
+
[
|
|
1619
|
+
"test.ts",
|
|
1620
|
+
[
|
|
1621
|
+
["-------", "line1"],
|
|
1622
|
+
["abc1234", "line2"],
|
|
1623
|
+
["-------", "line3"],
|
|
1624
|
+
],
|
|
1625
|
+
],
|
|
1626
|
+
]);
|
|
1627
|
+
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1628
|
+
expect(result[0].code).toBe("line2");
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
it("should handle range lines", async () => {
|
|
1632
|
+
const issues = [{ file: "test.ts", line: "1-2" }];
|
|
1633
|
+
const fileContents = new Map([
|
|
1634
|
+
[
|
|
1635
|
+
"test.ts",
|
|
1636
|
+
[
|
|
1637
|
+
["-------", "line1"],
|
|
1638
|
+
["abc1234", "line2"],
|
|
1639
|
+
["-------", "line3"],
|
|
1640
|
+
],
|
|
1641
|
+
],
|
|
1642
|
+
]);
|
|
1643
|
+
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1644
|
+
expect(result[0].code).toBe("line1\nline2");
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
it("should return issue unchanged if file not found", async () => {
|
|
1648
|
+
const issues = [{ file: "missing.ts", line: "1" }];
|
|
1649
|
+
const result = await (service as any).fillIssueCode(issues, new Map());
|
|
1650
|
+
expect(result[0].code).toBeUndefined();
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
it("should return issue unchanged if line out of range", async () => {
|
|
1654
|
+
const issues = [{ file: "test.ts", line: "999" }];
|
|
1655
|
+
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
1656
|
+
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1657
|
+
expect(result[0].code).toBeUndefined();
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("should return issue unchanged if line is NaN", async () => {
|
|
1661
|
+
const issues = [{ file: "test.ts", line: "abc" }];
|
|
1662
|
+
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
1663
|
+
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1664
|
+
expect(result[0].code).toBeUndefined();
|
|
1665
|
+
});
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
describe("ReviewService.fillIssueAuthors", () => {
|
|
1669
|
+
it("should fill author from commit with platform user", async () => {
|
|
1670
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
1671
|
+
const commits = [
|
|
1672
|
+
{
|
|
1673
|
+
sha: "abc1234567890",
|
|
1674
|
+
author: { id: 1, login: "dev1" },
|
|
1675
|
+
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
1676
|
+
},
|
|
1677
|
+
];
|
|
1678
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1679
|
+
expect(result[0].author.login).toBe("dev1");
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it("should use default author when commit not matched", async () => {
|
|
1683
|
+
const issues = [{ file: "test.ts", line: "1", commit: "zzz9999" }];
|
|
1684
|
+
const commits = [
|
|
1685
|
+
{
|
|
1686
|
+
sha: "abc1234567890",
|
|
1687
|
+
author: { id: 1, login: "dev1" },
|
|
1688
|
+
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
1689
|
+
},
|
|
1690
|
+
];
|
|
1691
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1692
|
+
expect(result[0].author.login).toBe("dev1");
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
it("should keep existing author", async () => {
|
|
1696
|
+
const issues = [{ file: "test.ts", line: "1", author: { id: "99", login: "existing" } }];
|
|
1697
|
+
const commits = [{ sha: "abc1234567890", author: { id: 1, login: "dev1" } }];
|
|
1698
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1699
|
+
expect(result[0].author.login).toBe("existing");
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
it("should use git author name when no platform user", async () => {
|
|
1703
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
1704
|
+
const commits = [
|
|
1705
|
+
{
|
|
1706
|
+
sha: "abc1234567890",
|
|
1707
|
+
author: null,
|
|
1708
|
+
committer: null,
|
|
1709
|
+
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
1710
|
+
},
|
|
1711
|
+
];
|
|
1712
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1713
|
+
expect(result[0].author.login).toBe("GitUser");
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
it("should handle issues with ------- commit hash", async () => {
|
|
1717
|
+
const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
|
|
1718
|
+
const commits = [
|
|
1719
|
+
{ sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
|
|
1720
|
+
];
|
|
1721
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1722
|
+
expect(result[0].author.login).toBe("dev1");
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
describe("ReviewService.reviewSingleFile", () => {
|
|
1727
|
+
it("should return issues from LLM stream", async () => {
|
|
1728
|
+
const llmProxy = (service as any).llmProxyService;
|
|
1729
|
+
const mockStream = (async function* () {
|
|
1730
|
+
yield {
|
|
1731
|
+
type: "result",
|
|
1732
|
+
response: {
|
|
1733
|
+
structuredOutput: {
|
|
1734
|
+
issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad" }],
|
|
1735
|
+
summary: "found issues",
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
};
|
|
1739
|
+
})();
|
|
1740
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1741
|
+
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1742
|
+
const result = await (service as any).reviewSingleFile("openai", filePrompt, 2);
|
|
1743
|
+
expect(result.issues).toHaveLength(1);
|
|
1744
|
+
expect(result.summary.file).toBe("test.ts");
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
it("should throw on error event", async () => {
|
|
1748
|
+
const llmProxy = (service as any).llmProxyService;
|
|
1749
|
+
const mockStream = (async function* () {
|
|
1750
|
+
yield { type: "error", message: "LLM failed" };
|
|
1751
|
+
})();
|
|
1752
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1753
|
+
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1754
|
+
await expect((service as any).reviewSingleFile("openai", filePrompt)).rejects.toThrow(
|
|
1755
|
+
"LLM failed",
|
|
1756
|
+
);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
it("should return empty issues when no structured output", async () => {
|
|
1760
|
+
const llmProxy = (service as any).llmProxyService;
|
|
1761
|
+
const mockStream = (async function* () {
|
|
1762
|
+
yield { type: "result", response: {} };
|
|
1763
|
+
})();
|
|
1764
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1765
|
+
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1766
|
+
const result = await (service as any).reviewSingleFile("openai", filePrompt);
|
|
1767
|
+
expect(result.issues).toHaveLength(0);
|
|
1768
|
+
expect(result.summary.summary).toBe("");
|
|
1769
|
+
});
|
|
1770
|
+
});
|
|
1771
|
+
|
|
1772
|
+
describe("ReviewService.postOrUpdateReviewComment", () => {
|
|
1773
|
+
it("should post review comment", async () => {
|
|
1774
|
+
const configReader = (service as any).configReader;
|
|
1775
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
1776
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1777
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1778
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1779
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
1780
|
+
const result = { issues: [], summary: [], round: 1 };
|
|
1781
|
+
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1782
|
+
expect(gitProvider.createPullReview).toHaveBeenCalled();
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it("should update PR title when autoUpdatePrTitle enabled", async () => {
|
|
1786
|
+
const configReader = (service as any).configReader;
|
|
1787
|
+
configReader.getPluginConfig.mockReturnValue({ autoUpdatePrTitle: true });
|
|
1788
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1789
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1790
|
+
gitProvider.editPullRequest.mockResolvedValue({} as any);
|
|
1791
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1792
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
1793
|
+
const result = { issues: [], summary: [], round: 1, title: "New Title" };
|
|
1794
|
+
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1795
|
+
expect(gitProvider.editPullRequest).toHaveBeenCalledWith("o", "r", 1, { title: "New Title" });
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("should handle createPullReview error gracefully", async () => {
|
|
1799
|
+
const configReader = (service as any).configReader;
|
|
1800
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
1801
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1802
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1803
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1804
|
+
gitProvider.createPullReview.mockRejectedValue(new Error("fail") as any);
|
|
1805
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1806
|
+
const result = { issues: [], summary: [], round: 1 };
|
|
1807
|
+
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1808
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1809
|
+
consoleSpy.mockRestore();
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it("should include line comments when configured", async () => {
|
|
1813
|
+
const configReader = (service as any).configReader;
|
|
1814
|
+
configReader.getPluginConfig.mockReturnValue({ lineComments: true });
|
|
1815
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1816
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1817
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1818
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1819
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
1820
|
+
const result = {
|
|
1821
|
+
issues: [
|
|
1822
|
+
{
|
|
1823
|
+
file: "test.ts",
|
|
1824
|
+
line: "10",
|
|
1825
|
+
ruleId: "R1",
|
|
1826
|
+
specFile: "s.md",
|
|
1827
|
+
reason: "r",
|
|
1828
|
+
severity: "error",
|
|
1829
|
+
},
|
|
1830
|
+
],
|
|
1831
|
+
summary: [],
|
|
1832
|
+
round: 1,
|
|
1833
|
+
};
|
|
1834
|
+
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1835
|
+
const callArgs = gitProvider.createPullReview.mock.calls[0];
|
|
1836
|
+
expect(callArgs[3].comments.length).toBeGreaterThan(0);
|
|
1837
|
+
});
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
describe("ReviewService.syncResolvedComments", () => {
|
|
1841
|
+
it("should mark matched issues as fixed", async () => {
|
|
1842
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1843
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
1844
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
1845
|
+
] as any);
|
|
1846
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
1847
|
+
{ path: "test.ts", position: 10, resolver: { login: "user1" } },
|
|
1848
|
+
] as any);
|
|
1849
|
+
const result = { issues: [{ file: "test.ts", line: "10" }] };
|
|
1850
|
+
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1851
|
+
expect((result.issues[0] as any).fixed).toBeDefined();
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
it("should skip resolved comments with no resolver", async () => {
|
|
1855
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1856
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
1857
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
1858
|
+
] as any);
|
|
1859
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
1860
|
+
{ path: "test.ts", position: 10, resolver: null },
|
|
1861
|
+
] as any);
|
|
1862
|
+
const result = { issues: [{ file: "test.ts", line: "10" }] };
|
|
1863
|
+
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1864
|
+
expect((result.issues[0] as any).fixed).toBeUndefined();
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it("should skip when no AI review found", async () => {
|
|
1868
|
+
gitProvider.listPullReviews.mockResolvedValue([{ id: 1, body: "normal review" }] as any);
|
|
1869
|
+
const result = { issues: [{ file: "test.ts", line: "10", fixed: false }] };
|
|
1870
|
+
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1871
|
+
expect(result.issues[0].fixed).toBe(false);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
it("should handle error gracefully", async () => {
|
|
1875
|
+
gitProvider.listPullReviews.mockRejectedValue(new Error("fail"));
|
|
1876
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1877
|
+
const result = { issues: [] };
|
|
1878
|
+
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1879
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
1880
|
+
consoleSpy.mockRestore();
|
|
1881
|
+
});
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
describe("ReviewService.callLLM", () => {
|
|
1885
|
+
it("should aggregate results from multiple files", async () => {
|
|
1886
|
+
const llmProxy = (service as any).llmProxyService;
|
|
1887
|
+
const mockStream = (async function* () {
|
|
1888
|
+
yield {
|
|
1889
|
+
type: "result",
|
|
1890
|
+
response: {
|
|
1891
|
+
structuredOutput: {
|
|
1892
|
+
issues: [{ file: "a.ts", line: "1", ruleId: "R1", reason: "bad" }],
|
|
1893
|
+
summary: "ok",
|
|
1894
|
+
},
|
|
1895
|
+
},
|
|
1896
|
+
};
|
|
1897
|
+
})();
|
|
1898
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1899
|
+
const reviewPrompt = {
|
|
1900
|
+
filePrompts: [{ filename: "a.ts", systemPrompt: "sys", userPrompt: "user" }],
|
|
1901
|
+
};
|
|
1902
|
+
const result = await (service as any).callLLM("openai", reviewPrompt);
|
|
1903
|
+
expect(result.issues).toHaveLength(1);
|
|
1904
|
+
expect(result.summary).toHaveLength(1);
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
it("should handle failed file review", async () => {
|
|
1908
|
+
const llmProxy = (service as any).llmProxyService;
|
|
1909
|
+
const mockStream = (async function* () {
|
|
1910
|
+
yield { type: "error", message: "LLM failed" };
|
|
1911
|
+
})();
|
|
1912
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1913
|
+
const reviewPrompt = {
|
|
1914
|
+
filePrompts: [{ filename: "a.ts", systemPrompt: "sys", userPrompt: "user" }],
|
|
1915
|
+
};
|
|
1916
|
+
const result = await (service as any).callLLM("openai", reviewPrompt);
|
|
1917
|
+
expect(result.summary[0].summary).toContain("审查失败");
|
|
1918
|
+
});
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
describe("ReviewService.executeCollectOnly", () => {
|
|
1922
|
+
it("should return empty result when no existing review", async () => {
|
|
1923
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1924
|
+
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1925
|
+
const result = await (service as any).executeCollectOnly(context);
|
|
1926
|
+
expect(result.success).toBe(true);
|
|
1927
|
+
expect(result.issues).toEqual([]);
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
it("should throw when no prNumber", async () => {
|
|
1931
|
+
const context = { owner: "o", repo: "r", ci: false, dryRun: false };
|
|
1932
|
+
await expect((service as any).executeCollectOnly(context)).rejects.toThrow(
|
|
1933
|
+
"collectOnly 模式必须指定 PR 编号",
|
|
1934
|
+
);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it("should collect and return existing review result", async () => {
|
|
1938
|
+
const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
1939
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1940
|
+
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
1941
|
+
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
1942
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
1943
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
1944
|
+
] as any);
|
|
1945
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1946
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
1947
|
+
gitProvider.getPullRequest.mockResolvedValue({} as any);
|
|
1948
|
+
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1949
|
+
const result = await (service as any).executeCollectOnly(context);
|
|
1950
|
+
expect(result.issues).toHaveLength(1);
|
|
1951
|
+
expect(result.stats).toBeDefined();
|
|
1952
|
+
});
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
describe("ReviewService.executeDeletionOnly", () => {
|
|
1956
|
+
it("should throw when no llmMode", async () => {
|
|
1957
|
+
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1958
|
+
await expect((service as any).executeDeletionOnly(context)).rejects.toThrow(
|
|
1959
|
+
"必须指定 LLM 类型",
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
it("should execute deletion analysis with PR", async () => {
|
|
1964
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1965
|
+
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
1966
|
+
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1967
|
+
issues: [],
|
|
1968
|
+
summary: "ok",
|
|
1969
|
+
});
|
|
1970
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
1971
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
1972
|
+
] as any);
|
|
1973
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
1974
|
+
{ filename: "a.ts", status: "modified" },
|
|
1975
|
+
] as any);
|
|
1976
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1977
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1978
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1979
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
1980
|
+
const configReader = (service as any).configReader;
|
|
1981
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
1982
|
+
const context = {
|
|
1983
|
+
owner: "o",
|
|
1984
|
+
repo: "r",
|
|
1985
|
+
prNumber: 1,
|
|
1986
|
+
ci: false,
|
|
1987
|
+
dryRun: false,
|
|
1988
|
+
llmMode: "openai",
|
|
1989
|
+
deletionAnalysisMode: "openai",
|
|
1990
|
+
verbose: 1,
|
|
1991
|
+
};
|
|
1992
|
+
const result = await (service as any).executeDeletionOnly(context);
|
|
1993
|
+
expect(result.success).toBe(true);
|
|
1994
|
+
expect(result.deletionImpact).toBeDefined();
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
it("should post comment in CI mode for deletionOnly", async () => {
|
|
1998
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
1999
|
+
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
2000
|
+
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
2001
|
+
issues: [],
|
|
2002
|
+
summary: "ok",
|
|
2003
|
+
});
|
|
2004
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2005
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
2006
|
+
] as any);
|
|
2007
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2008
|
+
{ filename: "a.ts", status: "modified" },
|
|
2009
|
+
] as any);
|
|
2010
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
2011
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2012
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
2013
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
2014
|
+
const configReader = (service as any).configReader;
|
|
2015
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
2016
|
+
const context = {
|
|
2017
|
+
owner: "o",
|
|
2018
|
+
repo: "r",
|
|
2019
|
+
prNumber: 1,
|
|
2020
|
+
ci: true,
|
|
2021
|
+
dryRun: false,
|
|
2022
|
+
llmMode: "openai",
|
|
2023
|
+
deletionAnalysisMode: "openai",
|
|
2024
|
+
verbose: 1,
|
|
2025
|
+
};
|
|
2026
|
+
const result = await (service as any).executeDeletionOnly(context);
|
|
2027
|
+
expect(result.success).toBe(true);
|
|
2028
|
+
expect(gitProvider.createPullReview).toHaveBeenCalled();
|
|
2029
|
+
});
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
describe("ReviewService.getContextFromEnv - more branches", () => {
|
|
2033
|
+
it("should get repo from git remote when no ci.repository", async () => {
|
|
2034
|
+
configService.get.mockReturnValue({ repository: "", refName: "main" });
|
|
2035
|
+
mockGitSdkService.getRemoteUrl.mockReturnValue("https://github.com/owner/repo.git");
|
|
2036
|
+
mockGitSdkService.parseRepositoryFromRemoteUrl.mockReturnValue({
|
|
2037
|
+
owner: "owner",
|
|
2038
|
+
repo: "repo",
|
|
2039
|
+
});
|
|
2040
|
+
const options = { dryRun: false, ci: false };
|
|
2041
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2042
|
+
expect(context.owner).toBe("owner");
|
|
2043
|
+
expect(context.repo).toBe("repo");
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
it("should throw when remote parse returns null", async () => {
|
|
2047
|
+
configService.get.mockReturnValue({ repository: "", refName: "main" });
|
|
2048
|
+
mockGitSdkService.getRemoteUrl.mockReturnValue("https://github.com/owner/repo.git");
|
|
2049
|
+
mockGitSdkService.parseRepositoryFromRemoteUrl.mockReturnValue(null);
|
|
2050
|
+
const options = { dryRun: false, ci: false };
|
|
2051
|
+
await expect(service.getContextFromEnv(options as any)).rejects.toThrow(
|
|
2052
|
+
"缺少配置 ci.repository",
|
|
2053
|
+
);
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
it("should auto-detect prNumber from event in CI mode", async () => {
|
|
2057
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2058
|
+
vi.spyOn(service as any, "getPrNumberFromEvent").mockResolvedValue(42);
|
|
2059
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
|
|
2060
|
+
const options = { dryRun: false, ci: true, verbose: 1 };
|
|
2061
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2062
|
+
expect(context.prNumber).toBe(42);
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
it("should handle getPullRequest failure when parsing title", async () => {
|
|
2066
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2067
|
+
gitProvider.getPullRequest.mockRejectedValue(new Error("API error"));
|
|
2068
|
+
const options = { dryRun: false, ci: true, prNumber: 1, verbose: 1 };
|
|
2069
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2070
|
+
expect(context.prNumber).toBe(1);
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
it("should parse title options with verbose logging", async () => {
|
|
2074
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2075
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2076
|
+
title: "feat: test [/review -d -v 2]",
|
|
2077
|
+
} as any);
|
|
2078
|
+
const options = { dryRun: false, ci: true, prNumber: 1, verbose: 1 };
|
|
2079
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2080
|
+
expect(context.dryRun).toBe(true);
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
it("should throw for invalid repository format", async () => {
|
|
2084
|
+
configService.get.mockReturnValue({ repository: "invalid", refName: "main" });
|
|
2085
|
+
const options = { dryRun: false, ci: false };
|
|
2086
|
+
await expect(service.getContextFromEnv(options as any)).rejects.toThrow(
|
|
2087
|
+
"ci.repository 格式不正确",
|
|
2088
|
+
);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
it("should normalize absolute file paths", async () => {
|
|
2092
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2093
|
+
const options = {
|
|
2094
|
+
dryRun: false,
|
|
2095
|
+
ci: false,
|
|
2096
|
+
files: ["/absolute/path/to/file.ts", "relative.ts"],
|
|
2097
|
+
};
|
|
2098
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2099
|
+
expect(context.files).toBeDefined();
|
|
2100
|
+
expect(context.files![1]).toBe("relative.ts");
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
it("should auto-detect base/head with verbose logging", async () => {
|
|
2104
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2105
|
+
mockGitSdkService.getCurrentBranch.mockReturnValue("feature");
|
|
2106
|
+
mockGitSdkService.getDefaultBranch.mockReturnValue("main");
|
|
2107
|
+
const options = { dryRun: false, ci: false, verbose: 1 };
|
|
2108
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2109
|
+
expect(context.headRef).toBe("feature");
|
|
2110
|
+
expect(context.baseRef).toBe("main");
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
it("should auto-detect base/head when no PR and no refs", async () => {
|
|
2114
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2115
|
+
mockGitSdkService.getCurrentBranch.mockReturnValue("feature");
|
|
2116
|
+
mockGitSdkService.getDefaultBranch.mockReturnValue("main");
|
|
2117
|
+
const options = { dryRun: false, ci: false };
|
|
2118
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2119
|
+
expect(context.headRef).toBe("feature");
|
|
2120
|
+
expect(context.baseRef).toBe("main");
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
it("should merge references from options and config", async () => {
|
|
2124
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2125
|
+
const configReader = (service as any).configReader;
|
|
2126
|
+
configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
|
|
2127
|
+
const options = { dryRun: false, ci: false, references: ["opt-ref"] };
|
|
2128
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2129
|
+
expect(context.specSources).toContain("opt-ref");
|
|
2130
|
+
expect(context.specSources).toContain("config-ref");
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
it("should parse title options in CI mode with PR", async () => {
|
|
2134
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2135
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test [/review -d]" } as any);
|
|
2136
|
+
const options = { dryRun: false, ci: true, prNumber: 1 };
|
|
2137
|
+
const context = await service.getContextFromEnv(options as any);
|
|
2138
|
+
expect(context.dryRun).toBe(true);
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
describe("ReviewService.ensureClaudeCli", () => {
|
|
2143
|
+
it("should not throw when claude is installed", async () => {
|
|
2144
|
+
vi.spyOn(require("child_process"), "execSync").mockImplementation(() => Buffer.from("1.0.0"));
|
|
2145
|
+
await expect((service as any).ensureClaudeCli()).resolves.toBeUndefined();
|
|
2146
|
+
});
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
describe("ReviewService.getFileDirectoryInfo", () => {
|
|
2150
|
+
it("should return root directory marker for root files", async () => {
|
|
2151
|
+
const result = await (service as any).getFileDirectoryInfo("file.ts");
|
|
2152
|
+
expect(result).toBe("(根目录)");
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
describe("ReviewService.getCommitsBetweenRefs", () => {
|
|
2157
|
+
it("should return commits from git sdk", async () => {
|
|
2158
|
+
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
2159
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
2160
|
+
]);
|
|
2161
|
+
const result = await (service as any).getCommitsBetweenRefs("main", "feature");
|
|
2162
|
+
expect(result).toHaveLength(1);
|
|
2163
|
+
});
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
describe("ReviewService.getFilesForCommit", () => {
|
|
2167
|
+
it("should return files from git sdk", async () => {
|
|
2168
|
+
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
|
2169
|
+
{ filename: "a.ts", status: "modified" },
|
|
2170
|
+
]);
|
|
2171
|
+
const result = await (service as any).getFilesForCommit("abc123");
|
|
2172
|
+
expect(result).toHaveLength(1);
|
|
2173
|
+
});
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
describe("ReviewService.syncReactionsToIssues", () => {
|
|
2177
|
+
it("should skip when no AI review found", async () => {
|
|
2178
|
+
gitProvider.listPullReviews.mockResolvedValue([{ body: "normal" }] as any);
|
|
2179
|
+
const result = { issues: [] };
|
|
2180
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2181
|
+
expect(result.issues).toEqual([]);
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
it("should handle error gracefully", async () => {
|
|
2185
|
+
gitProvider.listPullReviews.mockRejectedValue(new Error("fail"));
|
|
2186
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2187
|
+
const result = { issues: [] };
|
|
2188
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2189
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
2190
|
+
consoleSpy.mockRestore();
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
it("should mark issue as invalid on thumbs down from reviewer", async () => {
|
|
2194
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2195
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2196
|
+
{ id: 1, body: "<!-- spaceflow-review --> content", user: { login: "bot" } },
|
|
2197
|
+
{ id: 2, body: "LGTM", user: { login: "reviewer1" } },
|
|
2198
|
+
] as any);
|
|
2199
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2200
|
+
requested_reviewers: [],
|
|
2201
|
+
requested_reviewers_teams: [],
|
|
2202
|
+
} as any);
|
|
2203
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2204
|
+
{ id: 100, path: "test.ts", position: 10 },
|
|
2205
|
+
] as any);
|
|
2206
|
+
gitProvider.getIssueCommentReactions.mockResolvedValue([
|
|
2207
|
+
{ content: "-1", user: { login: "reviewer1" } },
|
|
2208
|
+
] as any);
|
|
2209
|
+
const result = { issues: [{ file: "test.ts", line: "10", valid: "true" }] };
|
|
2210
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2211
|
+
expect(result.issues[0].valid).toBe("false");
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
it("should add requested_reviewers to reviewers set", async () => {
|
|
2215
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2216
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2217
|
+
{ id: 1, body: "<!-- spaceflow-review --> content", user: { login: "bot" } },
|
|
2218
|
+
] as any);
|
|
2219
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2220
|
+
requested_reviewers: [{ login: "req-reviewer" }],
|
|
2221
|
+
requested_reviewers_teams: [],
|
|
2222
|
+
} as any);
|
|
2223
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2224
|
+
{ id: 100, path: "test.ts", position: 10 },
|
|
2225
|
+
] as any);
|
|
2226
|
+
gitProvider.getIssueCommentReactions.mockResolvedValue([
|
|
2227
|
+
{ content: "-1", user: { login: "req-reviewer" } },
|
|
2228
|
+
] as any);
|
|
2229
|
+
const result = { issues: [{ file: "test.ts", line: "10", valid: "true" }] };
|
|
2230
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2231
|
+
expect(result.issues[0].valid).toBe("false");
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
it("should skip comments without id", async () => {
|
|
2235
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2236
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2237
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
2238
|
+
] as any);
|
|
2239
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2240
|
+
requested_reviewers: [],
|
|
2241
|
+
requested_reviewers_teams: [],
|
|
2242
|
+
} as any);
|
|
2243
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2244
|
+
{ path: "test.ts", position: 10 },
|
|
2245
|
+
] as any);
|
|
2246
|
+
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2247
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2248
|
+
expect(result.issues[0].reactions).toHaveLength(0);
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
it("should skip when reactions are empty", async () => {
|
|
2252
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2253
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2254
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
2255
|
+
] as any);
|
|
2256
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2257
|
+
requested_reviewers: [],
|
|
2258
|
+
requested_reviewers_teams: [],
|
|
2259
|
+
} as any);
|
|
2260
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2261
|
+
{ id: 100, path: "test.ts", position: 10 },
|
|
2262
|
+
] as any);
|
|
2263
|
+
gitProvider.getIssueCommentReactions.mockResolvedValue([] as any);
|
|
2264
|
+
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2265
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2266
|
+
expect(result.issues[0].reactions).toHaveLength(0);
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
it("should store multiple reaction types", async () => {
|
|
2270
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2271
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2272
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
2273
|
+
] as any);
|
|
2274
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2275
|
+
requested_reviewers: [],
|
|
2276
|
+
requested_reviewers_teams: [],
|
|
2277
|
+
} as any);
|
|
2278
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2279
|
+
{ id: 100, path: "test.ts", position: 10 },
|
|
2280
|
+
] as any);
|
|
2281
|
+
gitProvider.getIssueCommentReactions.mockResolvedValue([
|
|
2282
|
+
{ content: "+1", user: { login: "user1" } },
|
|
2283
|
+
{ content: "+1", user: { login: "user2" } },
|
|
2284
|
+
{ content: "heart", user: { login: "user1" } },
|
|
2285
|
+
] as any);
|
|
2286
|
+
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2287
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2288
|
+
expect(result.issues[0].reactions).toHaveLength(2);
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
it("should not mark as invalid when thumbs down from non-reviewer", async () => {
|
|
2292
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2293
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2294
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
2295
|
+
] as any);
|
|
2296
|
+
gitProvider.getPullRequest.mockResolvedValue({
|
|
2297
|
+
requested_reviewers: [],
|
|
2298
|
+
requested_reviewers_teams: [],
|
|
2299
|
+
} as any);
|
|
2300
|
+
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2301
|
+
{ id: 100, path: "test.ts", position: 10 },
|
|
2302
|
+
] as any);
|
|
2303
|
+
gitProvider.getIssueCommentReactions.mockResolvedValue([
|
|
2304
|
+
{ content: "-1", user: { login: "random-user" } },
|
|
2305
|
+
] as any);
|
|
2306
|
+
const result = { issues: [{ file: "test.ts", line: "10", valid: "true", reactions: [] }] };
|
|
2307
|
+
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2308
|
+
expect(result.issues[0].valid).toBe("true");
|
|
2309
|
+
});
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
describe("ReviewService.buildReviewPrompt", () => {
|
|
2313
|
+
it("should build prompts for changed files", async () => {
|
|
2314
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
|
|
2315
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
2316
|
+
const fileContents = new Map([
|
|
2317
|
+
[
|
|
2318
|
+
"test.ts",
|
|
2319
|
+
[
|
|
2320
|
+
["abc1234", "const x = 1;"],
|
|
2321
|
+
["-------", "const y = 2;"],
|
|
2322
|
+
],
|
|
2323
|
+
],
|
|
2324
|
+
]);
|
|
2325
|
+
const commits = [{ sha: "abc1234567890", commit: { message: "fix" } }];
|
|
2326
|
+
const result = await (service as any).buildReviewPrompt(
|
|
2327
|
+
specs,
|
|
2328
|
+
changedFiles,
|
|
2329
|
+
fileContents,
|
|
2330
|
+
commits,
|
|
2331
|
+
);
|
|
2332
|
+
expect(result.filePrompts).toHaveLength(1);
|
|
2333
|
+
expect(result.filePrompts[0].filename).toBe("test.ts");
|
|
2334
|
+
expect(result.filePrompts[0].userPrompt).toContain("test.ts");
|
|
2335
|
+
expect(result.filePrompts[0].systemPrompt).toContain("代码审查专家");
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
it("should skip deleted files", async () => {
|
|
2339
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
2340
|
+
const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
|
|
2341
|
+
const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
|
|
2342
|
+
expect(result.filePrompts).toHaveLength(0);
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
it("should handle missing file contents", async () => {
|
|
2346
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
2347
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
2348
|
+
const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
|
|
2349
|
+
expect(result.filePrompts).toHaveLength(1);
|
|
2350
|
+
expect(result.filePrompts[0].userPrompt).toContain("无法获取内容");
|
|
2351
|
+
});
|
|
2352
|
+
|
|
2353
|
+
it("should include existing result in prompt", async () => {
|
|
2354
|
+
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
2355
|
+
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
2356
|
+
const fileContents = new Map([["test.ts", [["-------", "code"]]]]);
|
|
2357
|
+
const existingResult = {
|
|
2358
|
+
issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad code" }],
|
|
2359
|
+
summary: [{ file: "test.ts", summary: "has issues" }],
|
|
2360
|
+
};
|
|
2361
|
+
const result = await (service as any).buildReviewPrompt(
|
|
2362
|
+
specs,
|
|
2363
|
+
changedFiles,
|
|
2364
|
+
fileContents,
|
|
2365
|
+
[],
|
|
2366
|
+
existingResult,
|
|
2367
|
+
);
|
|
2368
|
+
expect(result.filePrompts[0].userPrompt).toContain("bad code");
|
|
2369
|
+
});
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
describe("ReviewService.generatePrDescription", () => {
|
|
2373
|
+
it("should generate description from LLM", async () => {
|
|
2374
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2375
|
+
const mockStream = (async function* () {
|
|
2376
|
+
yield { type: "text", content: "# Feat: 新功能\n\n详细描述" };
|
|
2377
|
+
})();
|
|
2378
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2379
|
+
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
2380
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
2381
|
+
const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
|
|
2382
|
+
expect(result.title).toBe("Feat: 新功能");
|
|
2383
|
+
expect(result.description).toContain("详细描述");
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
it("should fallback on LLM error", async () => {
|
|
2387
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2388
|
+
const mockStream = (async function* () {
|
|
2389
|
+
yield { type: "error", message: "fail" };
|
|
2390
|
+
})();
|
|
2391
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2392
|
+
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
2393
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
2394
|
+
const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
|
|
2395
|
+
expect(result.title).toBeDefined();
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
it("should include code changes section when fileContents provided", async () => {
|
|
2399
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2400
|
+
const mockStream = (async function* () {
|
|
2401
|
+
yield { type: "text", content: "Feat: test\n\ndesc" };
|
|
2402
|
+
})();
|
|
2403
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2404
|
+
const commits = [{ sha: "abc123", commit: { message: "feat" } }];
|
|
2405
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
2406
|
+
const fileContents = new Map([["a.ts", [["abc1234", "new code"]]]]);
|
|
2407
|
+
const result = await (service as any).generatePrDescription(
|
|
2408
|
+
commits,
|
|
2409
|
+
changedFiles,
|
|
2410
|
+
"openai",
|
|
2411
|
+
fileContents,
|
|
2412
|
+
);
|
|
2413
|
+
expect(result.title).toBeDefined();
|
|
2414
|
+
});
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
describe("ReviewService.generatePrTitle", () => {
|
|
2418
|
+
it("should generate title from LLM", async () => {
|
|
2419
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2420
|
+
const mockStream = (async function* () {
|
|
2421
|
+
yield { type: "text", content: "Feat: 新功能" };
|
|
2422
|
+
})();
|
|
2423
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2424
|
+
const commits = [{ sha: "abc", commit: { message: "feat: add" } }];
|
|
2425
|
+
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
2426
|
+
const result = await (service as any).generatePrTitle(commits, changedFiles);
|
|
2427
|
+
expect(result).toBe("Feat: 新功能");
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
it("should fallback on error", async () => {
|
|
2431
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2432
|
+
const mockStream = (async function* () {
|
|
2433
|
+
yield { type: "error", message: "fail" };
|
|
2434
|
+
})();
|
|
2435
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2436
|
+
const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
|
|
2437
|
+
const result = await (service as any).generatePrTitle(commits, []);
|
|
2438
|
+
expect(result).toBe("feat: add feature");
|
|
2439
|
+
});
|
|
2440
|
+
});
|
|
2441
|
+
|
|
2442
|
+
describe("ReviewService.syncRepliesToIssues", () => {
|
|
2443
|
+
it("should sync replies to matched issues", async () => {
|
|
2444
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2445
|
+
const reviewComments = [
|
|
2446
|
+
{
|
|
2447
|
+
id: 1,
|
|
2448
|
+
path: "test.ts",
|
|
2449
|
+
position: 10,
|
|
2450
|
+
body: "original",
|
|
2451
|
+
user: { id: 1, login: "bot" },
|
|
2452
|
+
created_at: "2024-01-01",
|
|
2453
|
+
},
|
|
2454
|
+
{
|
|
2455
|
+
id: 2,
|
|
2456
|
+
path: "test.ts",
|
|
2457
|
+
position: 10,
|
|
2458
|
+
body: "reply",
|
|
2459
|
+
user: { id: 2, login: "dev" },
|
|
2460
|
+
created_at: "2024-01-02",
|
|
2461
|
+
},
|
|
2462
|
+
];
|
|
2463
|
+
const result = { issues: [{ file: "test.ts", line: "10", replies: [] }] };
|
|
2464
|
+
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2465
|
+
expect(result.issues[0].replies).toHaveLength(1);
|
|
2466
|
+
expect(result.issues[0].replies[0].body).toBe("reply");
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
it("should skip comments without path or position", async () => {
|
|
2470
|
+
const reviewComments = [{ id: 1, body: "no path" }];
|
|
2471
|
+
const result = { issues: [] };
|
|
2472
|
+
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2473
|
+
expect(result.issues).toEqual([]);
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
it("should handle error gracefully", async () => {
|
|
2477
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation(() => {
|
|
2478
|
+
throw new Error("fail");
|
|
2479
|
+
});
|
|
2480
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2481
|
+
const reviewComments = [
|
|
2482
|
+
{ id: 1, path: "test.ts", position: 10, body: "a", created_at: "2024-01-01" },
|
|
2483
|
+
{ id: 2, path: "test.ts", position: 10, body: "b", created_at: "2024-01-02" },
|
|
2484
|
+
];
|
|
2485
|
+
const result = { issues: [{ file: "test.ts", line: "10", replies: [] }] };
|
|
2486
|
+
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2487
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
2488
|
+
consoleSpy.mockRestore();
|
|
2489
|
+
});
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
describe("ReviewService.execute - CI with existingResult", () => {
|
|
2493
|
+
beforeEach(() => {
|
|
2494
|
+
vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
|
|
2495
|
+
success: true,
|
|
2496
|
+
issues: [{ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" }],
|
|
2497
|
+
summary: [{ file: "test.ts", summary: "ok" }],
|
|
2498
|
+
});
|
|
2499
|
+
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
2500
|
+
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
it("should merge existing issues with new issues in CI mode", async () => {
|
|
2504
|
+
vi.spyOn(service as any, "getExistingReviewResult").mockResolvedValue({
|
|
2505
|
+
issues: [{ file: "old.ts", line: "1", ruleId: "R2", reason: "old issue", valid: "true" }],
|
|
2506
|
+
summary: [],
|
|
2507
|
+
round: 1,
|
|
2508
|
+
});
|
|
2509
|
+
const configReader = (service as any).configReader;
|
|
2510
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
2511
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2512
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2513
|
+
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
2514
|
+
] as any);
|
|
2515
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2516
|
+
{ filename: "test.ts", status: "modified" },
|
|
2517
|
+
] as any);
|
|
2518
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
2519
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2520
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
2521
|
+
const context: ReviewContext = {
|
|
2522
|
+
owner: "o",
|
|
2523
|
+
repo: "r",
|
|
2524
|
+
prNumber: 1,
|
|
2525
|
+
specSources: ["/spec"],
|
|
2526
|
+
dryRun: false,
|
|
2527
|
+
ci: true,
|
|
2528
|
+
llmMode: "openai",
|
|
2529
|
+
verifyFixes: false,
|
|
2530
|
+
verbose: 1,
|
|
2531
|
+
};
|
|
2532
|
+
const result = await service.execute(context);
|
|
2533
|
+
expect(result.success).toBe(true);
|
|
2534
|
+
expect(result.round).toBe(2);
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
it("should verify fixes when verifyFixes is true", async () => {
|
|
2538
|
+
vi.spyOn(service as any, "getExistingReviewResult").mockResolvedValue({
|
|
2539
|
+
issues: [{ file: "old.ts", line: "1", ruleId: "R2", reason: "old", valid: "true" }],
|
|
2540
|
+
summary: [],
|
|
2541
|
+
round: 1,
|
|
2542
|
+
});
|
|
2543
|
+
const configReader = (service as any).configReader;
|
|
2544
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
2545
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2546
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2547
|
+
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
2548
|
+
] as any);
|
|
2549
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2550
|
+
{ filename: "test.ts", status: "modified" },
|
|
2551
|
+
] as any);
|
|
2552
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
2553
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2554
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
2555
|
+
const context: ReviewContext = {
|
|
2556
|
+
owner: "o",
|
|
2557
|
+
repo: "r",
|
|
2558
|
+
prNumber: 1,
|
|
2559
|
+
specSources: ["/spec"],
|
|
2560
|
+
dryRun: false,
|
|
2561
|
+
ci: true,
|
|
2562
|
+
llmMode: "openai",
|
|
2563
|
+
verifyFixes: true,
|
|
2564
|
+
verifyConcurrency: 5,
|
|
2565
|
+
verbose: 1,
|
|
2566
|
+
};
|
|
2567
|
+
const result = await service.execute(context);
|
|
2568
|
+
expect(result.success).toBe(true);
|
|
2569
|
+
});
|
|
2570
|
+
});
|
|
2571
|
+
|
|
2572
|
+
describe("ReviewService.execute - filterCommits branch", () => {
|
|
2573
|
+
beforeEach(() => {
|
|
2574
|
+
vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
|
|
2575
|
+
success: true,
|
|
2576
|
+
issues: [],
|
|
2577
|
+
summary: [],
|
|
2578
|
+
});
|
|
2579
|
+
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
2580
|
+
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
2581
|
+
vi.spyOn(service as any, "getExistingReviewResult").mockResolvedValue(null);
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
it("should filter by specified commits", async () => {
|
|
2585
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2586
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2587
|
+
{ sha: "abc123", commit: { message: "fix a" } },
|
|
2588
|
+
{ sha: "def456", commit: { message: "fix b" } },
|
|
2589
|
+
] as any);
|
|
2590
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2591
|
+
{ filename: "a.ts", status: "modified" },
|
|
2592
|
+
{ filename: "b.ts", status: "modified" },
|
|
2593
|
+
] as any);
|
|
2594
|
+
gitProvider.getCommit.mockResolvedValue({ files: [{ filename: "a.ts" }] } as any);
|
|
2595
|
+
const context: ReviewContext = {
|
|
2596
|
+
owner: "o",
|
|
2597
|
+
repo: "r",
|
|
2598
|
+
prNumber: 1,
|
|
2599
|
+
specSources: ["/spec"],
|
|
2600
|
+
dryRun: true,
|
|
2601
|
+
ci: false,
|
|
2602
|
+
llmMode: "openai",
|
|
2603
|
+
commits: ["abc123"],
|
|
2604
|
+
};
|
|
2605
|
+
const result = await service.execute(context);
|
|
2606
|
+
expect(result.success).toBe(true);
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
it("should execute with verbose logging", async () => {
|
|
2610
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2611
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2612
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
2613
|
+
] as any);
|
|
2614
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2615
|
+
{ filename: "test.ts", status: "modified" },
|
|
2616
|
+
] as any);
|
|
2617
|
+
const context: ReviewContext = {
|
|
2618
|
+
owner: "o",
|
|
2619
|
+
repo: "r",
|
|
2620
|
+
prNumber: 1,
|
|
2621
|
+
specSources: ["/spec"],
|
|
2622
|
+
dryRun: true,
|
|
2623
|
+
ci: false,
|
|
2624
|
+
llmMode: "openai",
|
|
2625
|
+
verbose: 1,
|
|
2626
|
+
};
|
|
2627
|
+
const result = await service.execute(context);
|
|
2628
|
+
expect(result.success).toBe(true);
|
|
2629
|
+
});
|
|
2630
|
+
|
|
2631
|
+
it("should execute with verbose logging level 2", async () => {
|
|
2632
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2633
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2634
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
2635
|
+
] as any);
|
|
2636
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2637
|
+
{ filename: "test.ts", status: "modified" },
|
|
2638
|
+
] as any);
|
|
2639
|
+
const context: ReviewContext = {
|
|
2640
|
+
owner: "o",
|
|
2641
|
+
repo: "r",
|
|
2642
|
+
prNumber: 1,
|
|
2643
|
+
specSources: ["/spec"],
|
|
2644
|
+
dryRun: true,
|
|
2645
|
+
ci: false,
|
|
2646
|
+
llmMode: "openai",
|
|
2647
|
+
verbose: 2,
|
|
2648
|
+
showAll: true,
|
|
2649
|
+
};
|
|
2650
|
+
const result = await service.execute(context);
|
|
2651
|
+
expect(result.success).toBe(true);
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
it("should execute with files filter", async () => {
|
|
2655
|
+
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
2656
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2657
|
+
{ sha: "abc123", commit: { message: "fix" } },
|
|
2658
|
+
] as any);
|
|
2659
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2660
|
+
{ filename: "a.ts", status: "modified" },
|
|
2661
|
+
{ filename: "b.ts", status: "modified" },
|
|
2662
|
+
] as any);
|
|
2663
|
+
const context: ReviewContext = {
|
|
2664
|
+
owner: "o",
|
|
2665
|
+
repo: "r",
|
|
2666
|
+
prNumber: 1,
|
|
2667
|
+
specSources: ["/spec"],
|
|
2668
|
+
dryRun: true,
|
|
2669
|
+
ci: false,
|
|
2670
|
+
llmMode: "openai",
|
|
2671
|
+
files: ["a.ts"],
|
|
2672
|
+
};
|
|
2673
|
+
const result = await service.execute(context);
|
|
2674
|
+
expect(result.success).toBe(true);
|
|
2675
|
+
});
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
describe("ReviewService.fillIssueAuthors - searchUsers success", () => {
|
|
2679
|
+
it("should use searchUsers result for git-only authors", async () => {
|
|
2680
|
+
gitProvider.searchUsers.mockResolvedValue([{ id: 42, login: "found-user" }] as any);
|
|
2681
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
2682
|
+
const commits = [
|
|
2683
|
+
{
|
|
2684
|
+
sha: "abc1234567890",
|
|
2685
|
+
author: null,
|
|
2686
|
+
committer: null,
|
|
2687
|
+
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
2688
|
+
},
|
|
2689
|
+
];
|
|
2690
|
+
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
2691
|
+
expect(result[0].author.login).toBe("found-user");
|
|
2692
|
+
});
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
describe("ReviewService.executeDeletionOnly - baseRef/headRef mode", () => {
|
|
2696
|
+
it("should execute with baseRef/headRef instead of PR", async () => {
|
|
2697
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
2698
|
+
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
2699
|
+
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
2700
|
+
issues: [],
|
|
2701
|
+
summary: "ok",
|
|
2702
|
+
});
|
|
2703
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
|
|
2704
|
+
{ filename: "a.ts", patch: "", status: "modified" },
|
|
2705
|
+
]);
|
|
2706
|
+
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
2707
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
2708
|
+
]);
|
|
2709
|
+
const context = {
|
|
2710
|
+
owner: "o",
|
|
2711
|
+
repo: "r",
|
|
2712
|
+
baseRef: "main",
|
|
2713
|
+
headRef: "feature",
|
|
2714
|
+
ci: false,
|
|
2715
|
+
dryRun: true,
|
|
2716
|
+
llmMode: "openai",
|
|
2717
|
+
deletionAnalysisMode: "openai",
|
|
2718
|
+
};
|
|
2719
|
+
const result = await (service as any).executeDeletionOnly(context);
|
|
2720
|
+
expect(result.success).toBe(true);
|
|
2721
|
+
});
|
|
2722
|
+
|
|
2723
|
+
it("should filter files by includes in deletionOnly", async () => {
|
|
2724
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
2725
|
+
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
2726
|
+
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
2727
|
+
issues: [],
|
|
2728
|
+
summary: "ok",
|
|
2729
|
+
});
|
|
2730
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
2731
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
2732
|
+
] as any);
|
|
2733
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
2734
|
+
{ filename: "a.ts", status: "modified" },
|
|
2735
|
+
{ filename: "b.md", status: "modified" },
|
|
2736
|
+
] as any);
|
|
2737
|
+
const context = {
|
|
2738
|
+
owner: "o",
|
|
2739
|
+
repo: "r",
|
|
2740
|
+
prNumber: 1,
|
|
2741
|
+
ci: false,
|
|
2742
|
+
dryRun: true,
|
|
2743
|
+
llmMode: "openai",
|
|
2744
|
+
deletionAnalysisMode: "openai",
|
|
2745
|
+
includes: ["*.ts"],
|
|
2746
|
+
};
|
|
2747
|
+
const result = await (service as any).executeDeletionOnly(context);
|
|
2748
|
+
expect(result.success).toBe(true);
|
|
2749
|
+
});
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
describe("ReviewService.getContextFromEnv - CI validation", () => {
|
|
2753
|
+
it("should call validateConfig in CI mode", async () => {
|
|
2754
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2755
|
+
const options = { dryRun: false, ci: true, prNumber: 1 };
|
|
2756
|
+
await service.getContextFromEnv(options as any);
|
|
2757
|
+
expect(gitProvider.validateConfig).toHaveBeenCalled();
|
|
2758
|
+
});
|
|
2759
|
+
});
|
|
2760
|
+
|
|
2761
|
+
describe("ReviewService.executeCollectOnly - CI post comment", () => {
|
|
2762
|
+
it("should post comment in CI mode", async () => {
|
|
2763
|
+
const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
2764
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
2765
|
+
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
2766
|
+
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
2767
|
+
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
2768
|
+
gitProvider.listPullReviews.mockResolvedValue([
|
|
2769
|
+
{ id: 1, body: "<!-- spaceflow-review --> content" },
|
|
2770
|
+
] as any);
|
|
2771
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2772
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
2773
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
2774
|
+
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
2775
|
+
const configReader = (service as any).configReader;
|
|
2776
|
+
configReader.getPluginConfig.mockReturnValue({});
|
|
2777
|
+
const context = { owner: "o", repo: "r", prNumber: 1, ci: true, dryRun: false, verbose: 1 };
|
|
2778
|
+
const result = await (service as any).executeCollectOnly(context);
|
|
2779
|
+
expect(result.issues).toHaveLength(1);
|
|
2780
|
+
expect(gitProvider.createPullReview).toHaveBeenCalled();
|
|
2781
|
+
});
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
describe("ReviewService.getFileContents", () => {
|
|
2785
|
+
it("should get file contents with PR mode", async () => {
|
|
2786
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3" as any);
|
|
2787
|
+
const changedFiles = [
|
|
2788
|
+
{
|
|
2789
|
+
filename: "test.ts",
|
|
2790
|
+
status: "modified",
|
|
2791
|
+
patch: "@@ -1,2 +1,3 @@\n line1\n+line2\n line3",
|
|
2792
|
+
},
|
|
2793
|
+
];
|
|
2794
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
2795
|
+
const result = await (service as any).getFileContents(
|
|
2796
|
+
"o",
|
|
2797
|
+
"r",
|
|
2798
|
+
changedFiles,
|
|
2799
|
+
commits,
|
|
2800
|
+
"abc",
|
|
2801
|
+
1,
|
|
2802
|
+
);
|
|
2803
|
+
expect(result.has("test.ts")).toBe(true);
|
|
2804
|
+
expect(result.get("test.ts")).toHaveLength(3);
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
it("should get file contents with git sdk mode (no PR)", async () => {
|
|
2808
|
+
mockGitSdkService.getFileContent.mockResolvedValue("line1\nline2");
|
|
2809
|
+
const changedFiles = [
|
|
2810
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
2811
|
+
];
|
|
2812
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
2813
|
+
const result = await (service as any).getFileContents(
|
|
2814
|
+
"o",
|
|
2815
|
+
"r",
|
|
2816
|
+
changedFiles,
|
|
2817
|
+
commits,
|
|
2818
|
+
"HEAD",
|
|
2819
|
+
);
|
|
2820
|
+
expect(result.has("test.ts")).toBe(true);
|
|
2821
|
+
});
|
|
2822
|
+
|
|
2823
|
+
it("should skip deleted files", async () => {
|
|
2824
|
+
const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
|
|
2825
|
+
const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
|
|
2826
|
+
expect(result.size).toBe(0);
|
|
2827
|
+
});
|
|
2828
|
+
|
|
2829
|
+
it("should handle file content fetch error", async () => {
|
|
2830
|
+
gitProvider.getFileContent.mockRejectedValue(new Error("not found") as any);
|
|
2831
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2832
|
+
const changedFiles = [{ filename: "missing.ts", status: "modified" }];
|
|
2833
|
+
const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
|
|
2834
|
+
expect(result.size).toBe(0);
|
|
2835
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
2836
|
+
consoleSpy.mockRestore();
|
|
2837
|
+
});
|
|
2838
|
+
|
|
2839
|
+
it("should get file contents with verbose=3 logging", async () => {
|
|
2840
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
2841
|
+
const changedFiles = [
|
|
2842
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
2843
|
+
];
|
|
2844
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
2845
|
+
const result = await (service as any).getFileContents(
|
|
2846
|
+
"o",
|
|
2847
|
+
"r",
|
|
2848
|
+
changedFiles,
|
|
2849
|
+
commits,
|
|
2850
|
+
"abc",
|
|
2851
|
+
1,
|
|
2852
|
+
3,
|
|
2853
|
+
);
|
|
2854
|
+
expect(result.has("test.ts")).toBe(true);
|
|
2855
|
+
});
|
|
2856
|
+
|
|
2857
|
+
it("should mark all lines as changed for new files without patch", async () => {
|
|
2858
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
2859
|
+
const changedFiles = [{ filename: "new.ts", status: "added", additions: 2, deletions: 0 }];
|
|
2860
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
2861
|
+
const result = await (service as any).getFileContents(
|
|
2862
|
+
"o",
|
|
2863
|
+
"r",
|
|
2864
|
+
changedFiles,
|
|
2865
|
+
commits,
|
|
2866
|
+
"abc",
|
|
2867
|
+
1,
|
|
2868
|
+
);
|
|
2869
|
+
expect(result.has("new.ts")).toBe(true);
|
|
2870
|
+
const lines = result.get("new.ts");
|
|
2871
|
+
expect(lines[0][0]).toBe("abc1234");
|
|
2872
|
+
expect(lines[1][0]).toBe("abc1234");
|
|
2873
|
+
});
|
|
2874
|
+
});
|
|
2875
|
+
|
|
2876
|
+
describe("ReviewService.getChangedFilesBetweenRefs", () => {
|
|
2877
|
+
it("should merge diff and status info", async () => {
|
|
2878
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
|
|
2879
|
+
{ filename: "a.ts", patch: "diff content" },
|
|
2880
|
+
]);
|
|
2881
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue([
|
|
2882
|
+
{ filename: "a.ts", status: "added" },
|
|
2883
|
+
]);
|
|
2884
|
+
const result = await (service as any).getChangedFilesBetweenRefs("o", "r", "main", "feature");
|
|
2885
|
+
expect(result).toHaveLength(1);
|
|
2886
|
+
expect(result[0].status).toBe("added");
|
|
2887
|
+
expect(result[0].patch).toBe("diff content");
|
|
2888
|
+
});
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
describe("ReviewService.buildFallbackDescription", () => {
|
|
2892
|
+
it("should build description from commits and files", async () => {
|
|
2893
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2894
|
+
const mockStream = (async function* () {
|
|
2895
|
+
yield { type: "text", content: "Feat: test" };
|
|
2896
|
+
})();
|
|
2897
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2898
|
+
const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
|
|
2899
|
+
const changedFiles = [
|
|
2900
|
+
{ filename: "a.ts", status: "added" },
|
|
2901
|
+
{ filename: "b.ts", status: "modified" },
|
|
2902
|
+
{ filename: "c.ts", status: "deleted" },
|
|
2903
|
+
];
|
|
2904
|
+
const result = await (service as any).buildFallbackDescription(commits, changedFiles);
|
|
2905
|
+
expect(result.description).toContain("提交记录");
|
|
2906
|
+
expect(result.description).toContain("文件变更");
|
|
2907
|
+
expect(result.description).toContain("新增 1");
|
|
2908
|
+
expect(result.description).toContain("修改 1");
|
|
2909
|
+
expect(result.description).toContain("删除 1");
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
it("should handle empty commits", async () => {
|
|
2913
|
+
const llmProxy = (service as any).llmProxyService;
|
|
2914
|
+
const mockStream = (async function* () {
|
|
2915
|
+
yield { type: "text", content: "Feat: empty" };
|
|
2916
|
+
})();
|
|
2917
|
+
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2918
|
+
const result = await (service as any).buildFallbackDescription([], []);
|
|
2919
|
+
expect(result.title).toBeDefined();
|
|
2920
|
+
});
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2923
|
+
describe("ReviewService.normalizeIssues - comma separated", () => {
|
|
2924
|
+
it("should split comma separated lines into multiple issues", () => {
|
|
2925
|
+
const issues = [
|
|
2926
|
+
{ file: "test.ts", line: "10, 20", ruleId: "R1", reason: "bad", suggestion: "fix it" },
|
|
2927
|
+
];
|
|
2928
|
+
const result = (service as any).normalizeIssues(issues);
|
|
2929
|
+
expect(result).toHaveLength(2);
|
|
2930
|
+
expect(result[0].line).toBe("10");
|
|
2931
|
+
expect(result[0].suggestion).toBe("fix it");
|
|
2932
|
+
expect(result[1].line).toBe("20");
|
|
2933
|
+
expect(result[1].suggestion).toContain("参考");
|
|
2934
|
+
});
|
|
2935
|
+
});
|
|
2936
|
+
|
|
2937
|
+
describe("ReviewService.formatReviewComment - terminal format", () => {
|
|
2938
|
+
it("should use terminal format when not CI", () => {
|
|
2939
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
2940
|
+
mockReviewReportService.format.mockReturnValue("terminal output");
|
|
2941
|
+
const result = (service as any).formatReviewComment(
|
|
2942
|
+
{ issues: [], summary: [] },
|
|
2943
|
+
{ ci: false },
|
|
2944
|
+
);
|
|
2945
|
+
expect(result).toBe("terminal output");
|
|
2946
|
+
});
|
|
2947
|
+
|
|
2948
|
+
it("should use specified outputFormat", () => {
|
|
2949
|
+
const mockReviewReportService = (service as any).reviewReportService;
|
|
2950
|
+
mockReviewReportService.format.mockReturnValue("terminal output");
|
|
2951
|
+
const result = (service as any).formatReviewComment(
|
|
2952
|
+
{ issues: [], summary: [] },
|
|
2953
|
+
{ outputFormat: "terminal" },
|
|
2954
|
+
);
|
|
2955
|
+
expect(result).toBe("terminal output");
|
|
2956
|
+
});
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
describe("ReviewService.getFilesForCommit - no PR", () => {
|
|
2960
|
+
it("should use git sdk when no prNumber", async () => {
|
|
2961
|
+
mockGitSdkService.getFilesForCommit.mockResolvedValue(["a.ts", "b.ts"]);
|
|
2962
|
+
const result = await (service as any).getFilesForCommit("o", "r", "abc123");
|
|
2963
|
+
expect(result).toEqual(["a.ts", "b.ts"]);
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
it("should use git provider when prNumber provided", async () => {
|
|
2967
|
+
gitProvider.getCommit.mockResolvedValue({ files: [{ filename: "a.ts" }] } as any);
|
|
2968
|
+
const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
|
|
2969
|
+
expect(result).toEqual(["a.ts"]);
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
it("should handle null files from getCommit", async () => {
|
|
2973
|
+
gitProvider.getCommit.mockResolvedValue({ files: null } as any);
|
|
2974
|
+
const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
|
|
2975
|
+
expect(result).toEqual([]);
|
|
2976
|
+
});
|
|
2977
|
+
});
|
|
2978
|
+
|
|
2979
|
+
describe("ReviewService.buildLineCommitMap", () => {
|
|
2980
|
+
it("should build line commit map from commits", async () => {
|
|
2981
|
+
gitProvider.getCommitDiff = vi
|
|
2982
|
+
.fn()
|
|
2983
|
+
.mockResolvedValue(
|
|
2984
|
+
"diff --git a/file.ts b/file.ts\n--- a/file.ts\n+++ b/file.ts\n@@ -1,2 +1,3 @@\n line1\n+new line\n line2",
|
|
2985
|
+
) as any;
|
|
2986
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
2987
|
+
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
2988
|
+
expect(result.has("file.ts")).toBe(true);
|
|
2989
|
+
expect(result.get("file.ts").get(2)).toBe("abc1234");
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
it("should skip commits without sha", async () => {
|
|
2993
|
+
const commits = [{ sha: undefined }];
|
|
2994
|
+
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
2995
|
+
expect(result.size).toBe(0);
|
|
2996
|
+
});
|
|
2997
|
+
|
|
2998
|
+
it("should fallback to git sdk on API error", async () => {
|
|
2999
|
+
gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
|
|
3000
|
+
mockGitSdkService.getCommitDiff = vi.fn().mockReturnValue([]);
|
|
3001
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
3002
|
+
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
3003
|
+
expect(mockGitSdkService.getCommitDiff).toHaveBeenCalledWith("abc1234567890");
|
|
3004
|
+
expect(result.size).toBe(0);
|
|
3005
|
+
});
|
|
3006
|
+
});
|
|
3007
|
+
});
|