@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spaceflow/review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.0",
|
|
4
4
|
"description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lydanne",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@spaceflow/cli": "0.41.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@spaceflow/core": "0.
|
|
31
|
+
"@spaceflow/core": "0.31.0"
|
|
32
32
|
},
|
|
33
33
|
"spaceflow": {
|
|
34
34
|
"type": "flow",
|
package/src/README.md
CHANGED
|
@@ -129,7 +129,6 @@ interface ReviewIssue {
|
|
|
129
129
|
| `callLLM()` | 并行审查多个文件 |
|
|
130
130
|
| `reviewSingleFile()` | 审查单个文件 |
|
|
131
131
|
| `getFileContents()` | 获取文件内容并构建行号映射 |
|
|
132
|
-
| `buildLineCommitMap()` | 构建行号到 commit 的映射 |
|
|
133
132
|
| `filterDuplicateIssues()` | 过滤重复问题 |
|
|
134
133
|
| `postOrUpdateReviewComment()` | 发布/更新 PR 评论 |
|
|
135
134
|
| `generatePrDescription()` | AI 生成 PR 功能描述 |
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ChangedFile } from "@spaceflow/core";
|
|
2
|
+
import { extname } from "path";
|
|
3
|
+
import type { FileStatusCount } from "./types/changed-file-collection";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 变更文件集合,封装 ChangedFile[] 并提供常用访问器。
|
|
7
|
+
*/
|
|
8
|
+
export class ChangedFileCollection implements Iterable<ChangedFile> {
|
|
9
|
+
private readonly _files: ChangedFile[];
|
|
10
|
+
|
|
11
|
+
constructor(files: ChangedFile[]) {
|
|
12
|
+
this._files = files;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static from(files: ChangedFile[]): ChangedFileCollection {
|
|
16
|
+
return new ChangedFileCollection(files);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static empty(): ChangedFileCollection {
|
|
20
|
+
return new ChangedFileCollection([]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get length(): number {
|
|
24
|
+
return this._files.length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toArray(): ChangedFile[] {
|
|
28
|
+
return [...this._files];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
[Symbol.iterator](): Iterator<ChangedFile> {
|
|
32
|
+
return this._files[Symbol.iterator]();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
filenames(): string[] {
|
|
36
|
+
return this._files.map((f) => f.filename ?? "").filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
extensions(): Set<string> {
|
|
40
|
+
const exts = new Set<string>();
|
|
41
|
+
for (const f of this._files) {
|
|
42
|
+
if (f.filename) {
|
|
43
|
+
const ext = extname(f.filename).replace(/^\./, "").toLowerCase();
|
|
44
|
+
if (ext) exts.add(ext);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return exts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
has(filename: string): boolean {
|
|
51
|
+
return this._files.some((f) => f.filename === filename);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
filter(predicate: (file: ChangedFile) => boolean): ChangedFileCollection {
|
|
55
|
+
return new ChangedFileCollection(this._files.filter(predicate));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
map<T>(fn: (file: ChangedFile) => T): T[] {
|
|
59
|
+
return this._files.map(fn);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
countByStatus(): FileStatusCount {
|
|
63
|
+
let added = 0,
|
|
64
|
+
modified = 0,
|
|
65
|
+
deleted = 0;
|
|
66
|
+
for (const f of this._files) {
|
|
67
|
+
if (f.status === "added") added++;
|
|
68
|
+
else if (f.status === "modified") modified++;
|
|
69
|
+
else if (f.status === "deleted") deleted++;
|
|
70
|
+
}
|
|
71
|
+
return { added, modified, deleted };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
nonDeletedFiles(): ChangedFileCollection {
|
|
75
|
+
return this.filter((f) => f.status !== "deleted" && !!f.filename);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
filterByFilenames(names: Iterable<string>): ChangedFileCollection {
|
|
79
|
+
const nameSet = new Set(names);
|
|
80
|
+
return this.filter((f) => !!f.filename && nameSet.has(f.filename));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
filterByCommitFiles(commitFilenames: Iterable<string>): ChangedFileCollection {
|
|
84
|
+
const nameSet = new Set(commitFilenames);
|
|
85
|
+
return this.filter((f) => !!f.filename && nameSet.has(f.filename));
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { t, z, type SpaceflowContext, type GitProviderService } from "@spaceflow/core";
|
|
2
2
|
import { ReviewSpecService } from "../review-spec";
|
|
3
|
+
import { ChangedFileCollection } from "../changed-file-collection";
|
|
3
4
|
import type { ReviewConfig } from "../review.config";
|
|
4
5
|
import { extractGlobsFromIncludes } from "../review-includes-filter";
|
|
5
6
|
import { join } from "path";
|
|
@@ -110,7 +111,10 @@ export const tools = [
|
|
|
110
111
|
const workDir = ctx.cwd;
|
|
111
112
|
const allSpecs = await loadAllSpecs(workDir, ctx);
|
|
112
113
|
const specService = new ReviewSpecService();
|
|
113
|
-
const applicableSpecs = specService.filterApplicableSpecs(
|
|
114
|
+
const applicableSpecs = specService.filterApplicableSpecs(
|
|
115
|
+
allSpecs,
|
|
116
|
+
ChangedFileCollection.from([{ filename: filePath }]),
|
|
117
|
+
);
|
|
114
118
|
const micromatchModule = await import("micromatch");
|
|
115
119
|
const micromatch = micromatchModule.default || micromatchModule;
|
|
116
120
|
const rules = applicableSpecs.flatMap((spec) =>
|
|
@@ -62,6 +62,10 @@ export const buildIssueVerifyPrompt: PromptFn<IssueVerifyContext> = (ctx) => {
|
|
|
62
62
|
ruleSection = `### ${spec.filename} (${spec.type})\n\n${spec.content.slice(0, 200)}...\n\n#### 规则\n- ${rule.id}: ${rule.title}\n ${rule.description}`;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const originalCodeSection = ctx.issue.code
|
|
66
|
+
? `\n## 问题发现时的原始代码(用于对比)\n\n\`\`\`\n${ctx.issue.code}\n\`\`\`\n`
|
|
67
|
+
: "";
|
|
68
|
+
|
|
65
69
|
const userPrompt = `## 规则定义
|
|
66
70
|
|
|
67
71
|
${ruleSection}
|
|
@@ -69,18 +73,19 @@ ${ruleSection}
|
|
|
69
73
|
## 之前发现的问题
|
|
70
74
|
|
|
71
75
|
- **文件**: ${ctx.issue.file}
|
|
72
|
-
- **行号**: ${ctx.issue.line}
|
|
76
|
+
- **行号**: ${ctx.issue.line}(问题发现时的行号,可能因代码变更而偏移)
|
|
73
77
|
- **规则**: ${ctx.issue.ruleId} (来自 ${ctx.issue.specFile})
|
|
74
78
|
- **问题描述**: ${ctx.issue.reason}
|
|
75
79
|
${ctx.issue.suggestion ? `- **原建议**: ${ctx.issue.suggestion}` : ""}
|
|
76
|
-
|
|
80
|
+
${originalCodeSection}
|
|
77
81
|
## 当前文件内容
|
|
78
82
|
|
|
79
83
|
\`\`\`
|
|
80
84
|
${linesWithNumbers}
|
|
81
85
|
\`\`\`
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
请判断这个问题是否有效,以及是否已经被修复。
|
|
88
|
+
**注意**:如果提供了"问题发现时的原始代码",请优先通过搜索该代码片段来定位问题位置,而不是仅依赖行号(行号可能因代码变更已经偏移)。`;
|
|
84
89
|
|
|
85
90
|
return { systemPrompt, userPrompt };
|
|
86
91
|
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { vi, type Mock } from "vitest";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { ReviewContextBuilder } from "./review-context";
|
|
4
|
+
|
|
5
|
+
vi.mock("fs/promises");
|
|
6
|
+
|
|
7
|
+
describe("ReviewContextBuilder", () => {
|
|
8
|
+
let builder: ReviewContextBuilder;
|
|
9
|
+
let gitProvider: any;
|
|
10
|
+
let configService: any;
|
|
11
|
+
let mockGitSdkService: any;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
gitProvider = {
|
|
16
|
+
searchUsers: vi.fn().mockResolvedValue([]),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
configService = {
|
|
20
|
+
get: vi.fn(),
|
|
21
|
+
getPluginConfig: vi.fn().mockReturnValue({}),
|
|
22
|
+
registerSchema: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
mockGitSdkService = {
|
|
26
|
+
getRemoteUrl: vi.fn().mockReturnValue(null),
|
|
27
|
+
parseRepositoryFromRemoteUrl: vi.fn().mockReturnValue(null),
|
|
28
|
+
getCurrentBranch: vi.fn().mockReturnValue("main"),
|
|
29
|
+
getDefaultBranch: vi.fn().mockReturnValue("main"),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
builder = new ReviewContextBuilder(
|
|
33
|
+
gitProvider as any,
|
|
34
|
+
configService as any,
|
|
35
|
+
mockGitSdkService as any,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("getPrNumberFromEvent", () => {
|
|
44
|
+
const originalEnv = process.env;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
process.env = { ...originalEnv };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
process.env = originalEnv;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should return undefined if GITHUB_EVENT_PATH and GITEA_EVENT_PATH are not set", async () => {
|
|
55
|
+
delete process.env.GITHUB_EVENT_PATH;
|
|
56
|
+
delete process.env.GITEA_EVENT_PATH;
|
|
57
|
+
const prNumber = await builder.getPrNumberFromEvent();
|
|
58
|
+
expect(prNumber).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should parse prNumber from GITHUB_EVENT_PATH", async () => {
|
|
62
|
+
const mockEventPath = "/tmp/event.json";
|
|
63
|
+
process.env.GITHUB_EVENT_PATH = mockEventPath;
|
|
64
|
+
const mockEventContent = JSON.stringify({ pull_request: { number: 456 } });
|
|
65
|
+
|
|
66
|
+
(readFile as Mock).mockResolvedValue(mockEventContent);
|
|
67
|
+
|
|
68
|
+
const prNumber = await builder.getPrNumberFromEvent();
|
|
69
|
+
expect(prNumber).toBe(456);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should parse prNumber from GITEA_EVENT_PATH when GITHUB_EVENT_PATH is not set", async () => {
|
|
73
|
+
delete process.env.GITHUB_EVENT_PATH;
|
|
74
|
+
const mockEventPath = "/tmp/gitea-event.json";
|
|
75
|
+
process.env.GITEA_EVENT_PATH = mockEventPath;
|
|
76
|
+
const mockEventContent = JSON.stringify({ pull_request: { number: 789 } });
|
|
77
|
+
|
|
78
|
+
(readFile as Mock).mockResolvedValue(mockEventContent);
|
|
79
|
+
|
|
80
|
+
const prNumber = await builder.getPrNumberFromEvent();
|
|
81
|
+
expect(prNumber).toBe(789);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("resolveAnalyzeDeletions", () => {
|
|
86
|
+
it("should return boolean directly", () => {
|
|
87
|
+
expect(builder.resolveAnalyzeDeletions(true, { ci: false, hasPrNumber: false })).toBe(true);
|
|
88
|
+
expect(builder.resolveAnalyzeDeletions(false, { ci: true, hasPrNumber: true })).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should resolve 'ci' mode", () => {
|
|
92
|
+
expect(builder.resolveAnalyzeDeletions("ci", { ci: true, hasPrNumber: false })).toBe(true);
|
|
93
|
+
expect(builder.resolveAnalyzeDeletions("ci", { ci: false, hasPrNumber: false })).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should resolve 'pr' mode", () => {
|
|
97
|
+
expect(builder.resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: true })).toBe(true);
|
|
98
|
+
expect(builder.resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: false })).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should resolve 'terminal' mode", () => {
|
|
102
|
+
expect(builder.resolveAnalyzeDeletions("terminal", { ci: false, hasPrNumber: false })).toBe(
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
expect(builder.resolveAnalyzeDeletions("terminal", { ci: true, hasPrNumber: false })).toBe(
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should return false for unknown mode", () => {
|
|
111
|
+
expect(
|
|
112
|
+
builder.resolveAnalyzeDeletions("unknown" as any, { ci: false, hasPrNumber: false }),
|
|
113
|
+
).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("normalizeFilePaths", () => {
|
|
118
|
+
it("should return undefined for empty array", () => {
|
|
119
|
+
expect(builder.normalizeFilePaths([])).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should return undefined for undefined input", () => {
|
|
123
|
+
expect(builder.normalizeFilePaths(undefined)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should keep relative paths as-is", () => {
|
|
127
|
+
const result = builder.normalizeFilePaths(["src/app.ts", "lib/util.ts"]);
|
|
128
|
+
expect(result).toEqual(["src/app.ts", "lib/util.ts"]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("fillIssueAuthors", () => {
|
|
133
|
+
it("should fill author from commit with platform user", async () => {
|
|
134
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
135
|
+
const commits = [
|
|
136
|
+
{
|
|
137
|
+
sha: "abc1234567890",
|
|
138
|
+
author: { id: 1, login: "dev1" },
|
|
139
|
+
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
143
|
+
expect(result[0].author.login).toBe("dev1");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should use default author when commit not matched", async () => {
|
|
147
|
+
const issues = [{ file: "test.ts", line: "1", commit: "zzz9999" }];
|
|
148
|
+
const commits = [
|
|
149
|
+
{
|
|
150
|
+
sha: "abc1234567890",
|
|
151
|
+
author: { id: 1, login: "dev1" },
|
|
152
|
+
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
156
|
+
expect(result[0].author.login).toBe("dev1");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should keep existing author", async () => {
|
|
160
|
+
const issues = [{ file: "test.ts", line: "1", author: { id: "99", login: "existing" } }];
|
|
161
|
+
const commits = [{ sha: "abc1234567890", author: { id: 1, login: "dev1" } }];
|
|
162
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
163
|
+
expect(result[0].author.login).toBe("existing");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("should use git author name when no platform user", async () => {
|
|
167
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
168
|
+
const commits = [
|
|
169
|
+
{
|
|
170
|
+
sha: "abc1234567890",
|
|
171
|
+
author: null,
|
|
172
|
+
committer: null,
|
|
173
|
+
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
177
|
+
expect(result[0].author.login).toBe("GitUser");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should mark invalid when existing author but ------- commit hash", async () => {
|
|
181
|
+
const issues = [
|
|
182
|
+
{ file: "test.ts", line: "1", commit: "-------", author: { id: "1", login: "dev1" } },
|
|
183
|
+
];
|
|
184
|
+
const result = await builder.fillIssueAuthors(issues as any, [], "o", "r");
|
|
185
|
+
expect(result[0].commit).toBeUndefined();
|
|
186
|
+
expect(result[0].valid).toBe("false");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should handle issues with ------- commit hash", async () => {
|
|
190
|
+
const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
|
|
191
|
+
const commits = [
|
|
192
|
+
{ sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
|
|
193
|
+
];
|
|
194
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
195
|
+
expect(result[0].commit).toBeUndefined();
|
|
196
|
+
expect(result[0].valid).toBe("false");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should use searchUsers result for git-only authors", async () => {
|
|
200
|
+
gitProvider.searchUsers.mockResolvedValue([{ id: 42, login: "found-user" }] as any);
|
|
201
|
+
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
202
|
+
const commits = [
|
|
203
|
+
{
|
|
204
|
+
sha: "abc1234567890",
|
|
205
|
+
author: null,
|
|
206
|
+
committer: null,
|
|
207
|
+
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const result = await builder.fillIssueAuthors(issues as any, commits, "o", "r");
|
|
211
|
+
expect(result[0].author.login).toBe("found-user");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|