@spaceflow/review 0.81.0 → 0.83.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.81.0",
3
+ "version": "0.83.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.30.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(allSpecs, [{ filename: filePath }]);
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
+ });