@spaceflow/review 0.81.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.js +798 -717
- package/package.json +2 -2
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +20 -279
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +5 -4
- package/src/review-spec/review-spec.service.ts +5 -15
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
package/src/review-llm.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PullRequestCommit,
|
|
3
|
-
ChangedFile,
|
|
4
3
|
type LLMMode,
|
|
5
4
|
LlmProxyService,
|
|
6
5
|
logStreamEvent,
|
|
@@ -23,6 +22,7 @@ import { dirname, extname } from "path";
|
|
|
23
22
|
import micromatch from "micromatch";
|
|
24
23
|
import type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
|
|
25
24
|
import { buildLinesWithNumbers, buildCommitsSection, extractCodeBlocks } from "./utils/review-llm";
|
|
25
|
+
import { ChangedFileCollection } from "./changed-file-collection";
|
|
26
26
|
import { extractCodeBlockTypes, extractGlobsFromIncludes } from "./review-includes-filter";
|
|
27
27
|
import {
|
|
28
28
|
REVIEW_SCHEMA,
|
|
@@ -89,7 +89,7 @@ export class ReviewLlmProcessor {
|
|
|
89
89
|
|
|
90
90
|
async buildReviewPrompt(
|
|
91
91
|
specs: ReviewSpec[],
|
|
92
|
-
changedFiles:
|
|
92
|
+
changedFiles: ChangedFileCollection,
|
|
93
93
|
fileContents: FileContentsMap,
|
|
94
94
|
commits: PullRequestCommit[],
|
|
95
95
|
existingResult?: ReviewResult | null,
|
|
@@ -99,25 +99,23 @@ export class ReviewLlmProcessor {
|
|
|
99
99
|
): Promise<ReviewPrompt> {
|
|
100
100
|
const round = (existingResult?.round ?? 0) + 1;
|
|
101
101
|
const { staticIssues, skippedFiles } = applyStaticRules(
|
|
102
|
-
changedFiles,
|
|
102
|
+
changedFiles.toArray(),
|
|
103
103
|
fileContents,
|
|
104
104
|
systemRules,
|
|
105
105
|
round,
|
|
106
106
|
verbose,
|
|
107
107
|
);
|
|
108
108
|
|
|
109
|
-
const fileDataList = changedFiles
|
|
110
|
-
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return { filename, file, contentLines, commitsSection };
|
|
120
|
-
});
|
|
109
|
+
const fileDataList = changedFiles.nonDeletedFiles().map((file) => {
|
|
110
|
+
const filename = file.filename!;
|
|
111
|
+
if (skippedFiles.has(filename)) return null;
|
|
112
|
+
const contentLines = fileContents.get(filename);
|
|
113
|
+
if (!contentLines) {
|
|
114
|
+
return { filename, file, contentLines: null, commitsSection: "- 无相关 commits" };
|
|
115
|
+
}
|
|
116
|
+
const commitsSection = buildCommitsSection(contentLines, commits);
|
|
117
|
+
return { filename, file, contentLines, commitsSection };
|
|
118
|
+
});
|
|
121
119
|
|
|
122
120
|
const filePrompts: (FileReviewPrompt | null)[] = await Promise.all(
|
|
123
121
|
fileDataList
|
|
@@ -438,14 +436,14 @@ export class ReviewLlmProcessor {
|
|
|
438
436
|
*/
|
|
439
437
|
async generatePrDescription(
|
|
440
438
|
commits: PullRequestCommit[],
|
|
441
|
-
changedFiles:
|
|
439
|
+
changedFiles: ChangedFileCollection,
|
|
442
440
|
llmMode: LLMMode,
|
|
443
441
|
fileContents?: FileContentsMap,
|
|
444
442
|
verbose?: VerboseLevel,
|
|
445
443
|
): Promise<{ title: string; description: string }> {
|
|
446
444
|
const { userPrompt } = buildPrDescriptionPrompt({
|
|
447
445
|
commits,
|
|
448
|
-
changedFiles,
|
|
446
|
+
changedFiles: changedFiles.toArray(),
|
|
449
447
|
fileContents,
|
|
450
448
|
});
|
|
451
449
|
|
|
@@ -479,9 +477,9 @@ export class ReviewLlmProcessor {
|
|
|
479
477
|
*/
|
|
480
478
|
async generatePrTitle(
|
|
481
479
|
commits: PullRequestCommit[],
|
|
482
|
-
changedFiles:
|
|
480
|
+
changedFiles: ChangedFileCollection,
|
|
483
481
|
): Promise<string> {
|
|
484
|
-
const { userPrompt } = buildPrTitlePrompt({ commits, changedFiles });
|
|
482
|
+
const { userPrompt } = buildPrTitlePrompt({ commits, changedFiles: changedFiles.toArray() });
|
|
485
483
|
|
|
486
484
|
try {
|
|
487
485
|
const stream = this.llmProxyService.chatStream([{ role: "user", content: userPrompt }], {
|
|
@@ -514,7 +512,7 @@ export class ReviewLlmProcessor {
|
|
|
514
512
|
*/
|
|
515
513
|
async buildBasicDescription(
|
|
516
514
|
commits: PullRequestCommit[],
|
|
517
|
-
changedFiles:
|
|
515
|
+
changedFiles: ChangedFileCollection,
|
|
518
516
|
): Promise<{ title: string; description: string }> {
|
|
519
517
|
const parts: string[] = [];
|
|
520
518
|
// 使用 LLM 生成标题
|
|
@@ -529,9 +527,7 @@ export class ReviewLlmProcessor {
|
|
|
529
527
|
}
|
|
530
528
|
}
|
|
531
529
|
if (changedFiles.length > 0) {
|
|
532
|
-
const added = changedFiles.
|
|
533
|
-
const modified = changedFiles.filter((f) => f.status === "modified").length;
|
|
534
|
-
const deleted = changedFiles.filter((f) => f.status === "deleted").length;
|
|
530
|
+
const { added, modified, deleted } = changedFiles.countByStatus();
|
|
535
531
|
const stats: string[] = [];
|
|
536
532
|
if (added > 0) stats.push(`新增 ${added}`);
|
|
537
533
|
if (modified > 0) stats.push(`修改 ${modified}`);
|
|
@@ -348,13 +348,12 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
|
|
|
348
348
|
formatStats(stats: ReviewStats, prNumber?: number): string {
|
|
349
349
|
const title = prNumber ? `PR #${prNumber} Review 状态统计` : "Review 状态统计";
|
|
350
350
|
const lines = [`## 📊 ${title}\n`, `| 指标 | 数量 |`, `|------|------|`];
|
|
351
|
-
lines.push(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
lines.push(`| ❌
|
|
355
|
-
lines.push(`|
|
|
356
|
-
lines.push(`|
|
|
357
|
-
lines.push(`| 解决率 | ${stats.resolveRate}% |`);
|
|
351
|
+
lines.push(
|
|
352
|
+
`| 有效问题 | ${stats.validTotal} (🟢已验收 ${stats.fixed}, ⚪已解决 ${stats.resolved}, ⚠️待处理 ${stats.pending}) |`,
|
|
353
|
+
);
|
|
354
|
+
lines.push(`| ❌ 无效问题 | ${stats.invalid} |`);
|
|
355
|
+
lines.push(`| 验收率 | ${stats.fixRate}% (${stats.fixed}/${stats.validTotal}) |`);
|
|
356
|
+
lines.push(`| 解决率 | ${stats.resolveRate}% (${stats.resolved}/${stats.validTotal}) |`);
|
|
358
357
|
return lines.join("\n");
|
|
359
358
|
}
|
|
360
359
|
}
|
|
@@ -391,7 +391,7 @@ describe("ReviewResultModel", () => {
|
|
|
391
391
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
392
392
|
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
393
393
|
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
394
|
-
await model.invalidateChangedFiles(undefined, 1);
|
|
394
|
+
await model.invalidateChangedFiles(undefined, undefined, 1);
|
|
395
395
|
expect(issues[0].valid).toBeUndefined();
|
|
396
396
|
expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 无法获取 PR head SHA,跳过变更文件检查");
|
|
397
397
|
consoleSpy.mockRestore();
|
|
@@ -402,7 +402,7 @@ describe("ReviewResultModel", () => {
|
|
|
402
402
|
gitProvider.getCommitDiff.mockResolvedValue("");
|
|
403
403
|
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
404
404
|
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
405
|
-
await model.invalidateChangedFiles("abc123", 1);
|
|
405
|
+
await model.invalidateChangedFiles("abc123", undefined, 1);
|
|
406
406
|
expect(issues[0].valid).toBeUndefined();
|
|
407
407
|
consoleSpy.mockRestore();
|
|
408
408
|
});
|
|
@@ -417,7 +417,7 @@ describe("ReviewResultModel", () => {
|
|
|
417
417
|
{ file: "unchanged.ts", line: "2", ruleId: "R2" },
|
|
418
418
|
] as any[];
|
|
419
419
|
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
420
|
-
await model.invalidateChangedFiles("abc123", 1);
|
|
420
|
+
await model.invalidateChangedFiles("abc123", undefined, 1);
|
|
421
421
|
expect(model.issues[0].valid).toBe("false");
|
|
422
422
|
expect(model.issues[1].valid).toBeUndefined();
|
|
423
423
|
consoleSpy.mockRestore();
|
|
@@ -445,10 +445,41 @@ describe("ReviewResultModel", () => {
|
|
|
445
445
|
gitProvider.getCommitDiff.mockRejectedValue(new Error("diff fail"));
|
|
446
446
|
const issues = [{ file: "a.ts", line: "1" }] as any[];
|
|
447
447
|
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
448
|
-
await model.invalidateChangedFiles("abc123", 1);
|
|
448
|
+
await model.invalidateChangedFiles("abc123", undefined, 1);
|
|
449
449
|
expect(consoleSpy).toHaveBeenCalled();
|
|
450
450
|
consoleSpy.mockRestore();
|
|
451
451
|
});
|
|
452
|
+
|
|
453
|
+
it("should preserve resolved issue when current code differs from issue.code", async () => {
|
|
454
|
+
gitProvider.getCommitDiff.mockResolvedValue(
|
|
455
|
+
"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",
|
|
456
|
+
);
|
|
457
|
+
const issues = [
|
|
458
|
+
{ file: "changed.ts", line: "1", ruleId: "R1", resolved: "2024-01-01", code: "old code" },
|
|
459
|
+
] as any[];
|
|
460
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
461
|
+
const fileContents: Map<string, [string, string][]> = new Map([
|
|
462
|
+
["changed.ts", [["abc1234", "new code"]]],
|
|
463
|
+
]);
|
|
464
|
+
await model.invalidateChangedFiles("abc123", fileContents);
|
|
465
|
+
expect(model.issues[0].valid).toBeUndefined();
|
|
466
|
+
expect(model.issues[0].resolved).toBe("2024-01-01");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("should invalidate resolved issue when current code matches issue.code", async () => {
|
|
470
|
+
gitProvider.getCommitDiff.mockResolvedValue(
|
|
471
|
+
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n old code\n+new",
|
|
472
|
+
);
|
|
473
|
+
const issues = [
|
|
474
|
+
{ file: "changed.ts", line: "1", ruleId: "R1", resolved: "2024-01-01", code: "old code" },
|
|
475
|
+
] as any[];
|
|
476
|
+
const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
|
|
477
|
+
const fileContents: Map<string, [string, string][]> = new Map([
|
|
478
|
+
["changed.ts", [["abc1234", "old code"]]],
|
|
479
|
+
]);
|
|
480
|
+
await model.invalidateChangedFiles("abc123", fileContents);
|
|
481
|
+
expect(model.issues[0].valid).toBe("false");
|
|
482
|
+
});
|
|
452
483
|
});
|
|
453
484
|
|
|
454
485
|
// ─── deleteOldReviews ─────────────────────────────────
|
|
@@ -9,7 +9,13 @@ import {
|
|
|
9
9
|
import type { IConfigReader } from "@spaceflow/core";
|
|
10
10
|
import { PullRequestModel } from "./pull-request-model";
|
|
11
11
|
import { type ReviewConfig } from "./review.config";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ReviewSpecService,
|
|
14
|
+
ReviewIssue,
|
|
15
|
+
ReviewResult,
|
|
16
|
+
ReviewStats,
|
|
17
|
+
FileContentsMap,
|
|
18
|
+
} from "./review-spec";
|
|
13
19
|
import { ReviewReportService, type ReportFormat } from "./review-report";
|
|
14
20
|
import { extname } from "path";
|
|
15
21
|
import {
|
|
@@ -143,11 +149,16 @@ export class ReviewResultModel {
|
|
|
143
149
|
* - 合并历史 issues(this.issues)+ newIssues
|
|
144
150
|
* - 复制 newResult 的元信息(title/description/deletionImpact 等)
|
|
145
151
|
*
|
|
146
|
-
* 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes
|
|
152
|
+
* 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes 等)。
|
|
147
153
|
*/
|
|
148
154
|
nextRound(newResult: ReviewResult): ReviewResultModel {
|
|
149
155
|
const nextRoundNum = this._result.round + 1;
|
|
150
|
-
|
|
156
|
+
|
|
157
|
+
// 去重:过滤掉已存在于历史 issues 中的新问题(含 valid:false 的都参与去重)
|
|
158
|
+
const existingKeys = new Set(this._result.issues.map((i) => generateIssueKey(i)));
|
|
159
|
+
const dedupedNewIssues = newResult.issues.filter((i) => !existingKeys.has(generateIssueKey(i)));
|
|
160
|
+
|
|
161
|
+
const taggedNewIssues = dedupedNewIssues.map((issue) => ({ ...issue, round: nextRoundNum }));
|
|
151
162
|
const mergedResult: ReviewResult = {
|
|
152
163
|
...newResult,
|
|
153
164
|
round: nextRoundNum,
|
|
@@ -415,9 +426,14 @@ export class ReviewResultModel {
|
|
|
415
426
|
|
|
416
427
|
/**
|
|
417
428
|
* 将有变更文件的历史 issue 标记为无效。
|
|
418
|
-
*
|
|
429
|
+
* 策略:如果文件在最新 commit 中有变更,则将该文件的历史问题标记为无效,但以下情况保留:
|
|
430
|
+
* - issue 已被用户手动 resolved 且当前代码行内容与 issue.code 不同(说明用户 resolve 后代码已变,应保留其 resolve 状态)
|
|
419
431
|
*/
|
|
420
|
-
async invalidateChangedFiles(
|
|
432
|
+
async invalidateChangedFiles(
|
|
433
|
+
headSha: string | undefined,
|
|
434
|
+
fileContents?: FileContentsMap,
|
|
435
|
+
verbose?: VerboseLevel,
|
|
436
|
+
): Promise<void> {
|
|
421
437
|
if (!headSha) {
|
|
422
438
|
if (shouldLog(verbose, 1)) {
|
|
423
439
|
console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
|
|
@@ -449,14 +465,43 @@ export class ReviewResultModel {
|
|
|
449
465
|
|
|
450
466
|
// 将变更文件的历史 issue 标记为无效
|
|
451
467
|
let invalidatedCount = 0;
|
|
468
|
+
let preservedCount = 0;
|
|
452
469
|
this._result.issues = this._result.issues.map((issue) => {
|
|
453
|
-
// 如果 issue
|
|
454
|
-
if (issue.fixed || issue.
|
|
470
|
+
// 如果 issue 已修复或已无效,不需要处理
|
|
471
|
+
if (issue.fixed || issue.valid === "false") {
|
|
455
472
|
return issue;
|
|
456
473
|
}
|
|
457
474
|
|
|
458
|
-
// 如果 issue
|
|
475
|
+
// 如果 issue 所在文件有变更
|
|
459
476
|
if (changedFileSet.has(issue.file)) {
|
|
477
|
+
// 已 resolved 的 issue:检查当前代码行是否与 issue.code 不同
|
|
478
|
+
// 不同说明用户 resolve 后代码确实变了,保留其 resolve 状态
|
|
479
|
+
if (issue.resolved && issue.code && fileContents) {
|
|
480
|
+
const contentLines = fileContents.get(issue.file);
|
|
481
|
+
if (contentLines) {
|
|
482
|
+
const lineNums = issue.line
|
|
483
|
+
.split("-")
|
|
484
|
+
.map(Number)
|
|
485
|
+
.filter((n) => !isNaN(n));
|
|
486
|
+
const startLine = lineNums[0];
|
|
487
|
+
const endLine = lineNums[lineNums.length - 1];
|
|
488
|
+
const currentCode = contentLines
|
|
489
|
+
.slice(startLine - 1, endLine)
|
|
490
|
+
.map(([, line]) => line)
|
|
491
|
+
.join("\n")
|
|
492
|
+
.trim();
|
|
493
|
+
if (currentCode !== issue.code) {
|
|
494
|
+
preservedCount++;
|
|
495
|
+
if (shouldLog(verbose, 1)) {
|
|
496
|
+
console.log(
|
|
497
|
+
` ✅ Issue ${issue.file}:${issue.line} 已 resolved 且代码已变更,保留`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return issue;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
460
505
|
invalidatedCount++;
|
|
461
506
|
if (shouldLog(verbose, 1)) {
|
|
462
507
|
console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
|
|
@@ -467,8 +512,11 @@ export class ReviewResultModel {
|
|
|
467
512
|
return issue;
|
|
468
513
|
});
|
|
469
514
|
|
|
470
|
-
if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
|
|
471
|
-
|
|
515
|
+
if ((invalidatedCount > 0 || preservedCount > 0) && shouldLog(verbose, 1)) {
|
|
516
|
+
const parts: string[] = [];
|
|
517
|
+
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
518
|
+
if (preservedCount > 0) parts.push(`保留 ${preservedCount} 个已 resolved`);
|
|
519
|
+
console.log(` 📊 Issue 处理: ${parts.join(",")}`);
|
|
472
520
|
}
|
|
473
521
|
} catch (error) {
|
|
474
522
|
if (shouldLog(verbose, 1)) {
|