@spaceflow/review 0.77.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/index.js +1060 -481
- package/package.json +1 -1
- package/src/deletion-impact.service.ts +13 -128
- package/src/issue-verify.service.ts +18 -82
- package/src/mcp/index.ts +4 -1
- package/src/prompt/code-review.ts +95 -0
- package/src/prompt/deletion-impact.ts +105 -0
- package/src/prompt/index.ts +37 -0
- package/src/prompt/issue-verify.ts +86 -0
- package/src/prompt/pr-description.ts +149 -0
- package/src/prompt/schemas.ts +106 -0
- package/src/prompt/types.ts +53 -0
- package/src/review-context.ts +29 -5
- package/src/review-includes-filter.spec.ts +36 -0
- package/src/review-includes-filter.ts +59 -7
- package/src/review-issue-filter.ts +1 -1
- package/src/review-llm.ts +116 -207
- package/src/review-result-model.ts +28 -6
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review.config.ts +31 -5
- package/src/review.service.spec.ts +11 -1
- package/src/review.service.ts +105 -5
- package/src/system-rules/index.ts +48 -0
- package/src/system-rules/max-lines-per-file.ts +57 -0
- package/src/types/review-llm.ts +2 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +152 -7
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/{review-pr-comment-utils.ts → utils/review-pr-comment.ts} +2 -2
package/src/utils/review-llm.ts
CHANGED
|
@@ -1,13 +1,158 @@
|
|
|
1
1
|
import type { PullRequestCommit } from "@spaceflow/core";
|
|
2
|
+
import type { CodeBlockType } from "../review-includes-filter";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* 构建带行号的文件内容字符串。
|
|
6
|
+
*
|
|
7
|
+
* @param contentLines [hash, code] 行数组
|
|
8
|
+
* @param visibleRanges 可选,指定需要输出的行号区间 [startLine, endLine](不含则输出全文)
|
|
9
|
+
* 被跳过的连续行用 `...... ..| ignore {start}-{end} code` 占位
|
|
10
|
+
*/
|
|
11
|
+
export function buildLinesWithNumbers(
|
|
12
|
+
contentLines: [string, string][],
|
|
13
|
+
visibleRanges?: [number, number][],
|
|
14
|
+
): string {
|
|
4
15
|
const padWidth = String(contentLines.length).length;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
|
|
17
|
+
if (!visibleRanges || visibleRanges.length === 0) {
|
|
18
|
+
return contentLines
|
|
19
|
+
.map(([hash, line], index) => {
|
|
20
|
+
const lineNum = index + 1;
|
|
21
|
+
return `${hash} ${String(lineNum).padStart(padWidth)}| ${line}`;
|
|
22
|
+
})
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 将 ranges 按起始行号排序并合并重叠区间
|
|
27
|
+
const sorted = [...visibleRanges].sort((a, b) => a[0] - b[0]);
|
|
28
|
+
const merged: [number, number][] = [];
|
|
29
|
+
for (const range of sorted) {
|
|
30
|
+
if (merged.length > 0 && range[0] <= merged[merged.length - 1][1] + 1) {
|
|
31
|
+
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], range[1]);
|
|
32
|
+
} else {
|
|
33
|
+
merged.push([...range]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const output: string[] = [];
|
|
38
|
+
let prevEnd = 0;
|
|
39
|
+
|
|
40
|
+
for (const [start, end] of merged) {
|
|
41
|
+
const clampedStart = Math.max(1, start);
|
|
42
|
+
const clampedEnd = Math.min(contentLines.length, end);
|
|
43
|
+
|
|
44
|
+
// 被忽略的前缀区间
|
|
45
|
+
if (clampedStart > prevEnd + 1) {
|
|
46
|
+
output.push(`....... ignore ${prevEnd + 1}-${clampedStart - 1} line .......`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 输出可见行
|
|
50
|
+
for (let i = clampedStart - 1; i < clampedEnd; i++) {
|
|
51
|
+
const [hash, line] = contentLines[i];
|
|
52
|
+
const lineNum = i + 1;
|
|
53
|
+
output.push(`${hash} ${String(lineNum).padStart(padWidth)}| ${line}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
prevEnd = clampedEnd;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 被忽略的末尾区间
|
|
60
|
+
if (prevEnd < contentLines.length) {
|
|
61
|
+
output.push(`....... ignore ${prevEnd + 1}-${contentLines.length} line .......`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return output.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 从 contentLines 中提取新增代码里的指定结构类型的行号范围。
|
|
69
|
+
*
|
|
70
|
+
* 逻辑:
|
|
71
|
+
* 1. 只考虑 hash !== "-------" 的新增行
|
|
72
|
+
* 2. 用各类型的正则匹配结构开头行,再用层级计数找到结尾行
|
|
73
|
+
* 3. 返回行号范围列表 [startLine, endLine](从 1 计)
|
|
74
|
+
*
|
|
75
|
+
* @param contentLines 文件的 [hash, code] 行列表
|
|
76
|
+
* @param types 要提取的结构类型
|
|
77
|
+
*/
|
|
78
|
+
export function extractCodeBlocks(
|
|
79
|
+
contentLines: [string, string][],
|
|
80
|
+
types: CodeBlockType[],
|
|
81
|
+
): [number, number][] {
|
|
82
|
+
if (types.length === 0) return [];
|
|
83
|
+
|
|
84
|
+
const ranges: [number, number][] = [];
|
|
85
|
+
|
|
86
|
+
// 将所有行的实际代码组成文本(用于层级计数)
|
|
87
|
+
const fullLines = contentLines.map(([, code]) => code);
|
|
88
|
+
|
|
89
|
+
// 各类型的开头識别正则(匹配行首)
|
|
90
|
+
const PATTERNS: Record<CodeBlockType, RegExp> = {
|
|
91
|
+
function: /^\s*(?:export\s+)?(?:async\s+)?function\s+\w+/,
|
|
92
|
+
class: /^\s*(?:export\s+)?(?:abstract\s+)?class\s+\w+/,
|
|
93
|
+
interface: /^\s*(?:export\s+)?interface\s+\w+/,
|
|
94
|
+
type: /^\s*(?:export\s+)?type\s+\w+\s*[=<]/,
|
|
95
|
+
method:
|
|
96
|
+
/^\s*(?:(?:public|protected|private|static|async|override|readonly|abstract)\s+)*(?!(?:if|for|while|switch|return|const|let|var|throw|new)\b)(\w+)\s*[(<]/,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < fullLines.length; i++) {
|
|
100
|
+
const lineNum = i + 1;
|
|
101
|
+
const isAdded = contentLines[i][0] !== "-------";
|
|
102
|
+
if (!isAdded) continue;
|
|
103
|
+
|
|
104
|
+
for (const type of types) {
|
|
105
|
+
const pattern = PATTERNS[type];
|
|
106
|
+
if (!pattern.test(fullLines[i])) continue;
|
|
107
|
+
|
|
108
|
+
// 找到结构开头,用层级计数找封闭括号结尾
|
|
109
|
+
const endLine = findBlockEnd(fullLines, i);
|
|
110
|
+
ranges.push([lineNum, endLine]);
|
|
111
|
+
break; // 同一行只匹配一种类型
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return mergeRanges(ranges);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 从开始行向下层级计数,找到匹配的封闭括号位置(行号从 1 计)。
|
|
120
|
+
* 如果没有找到匹配括号,返回开始行到文件末尾。
|
|
121
|
+
*/
|
|
122
|
+
function findBlockEnd(lines: string[], startIndex: number): number {
|
|
123
|
+
let depth = 0;
|
|
124
|
+
let foundOpen = false;
|
|
125
|
+
|
|
126
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
127
|
+
for (const ch of lines[i]) {
|
|
128
|
+
if (ch === "{") {
|
|
129
|
+
depth++;
|
|
130
|
+
foundOpen = true;
|
|
131
|
+
} else if (ch === "}") {
|
|
132
|
+
depth--;
|
|
133
|
+
if (foundOpen && depth === 0) {
|
|
134
|
+
return i + 1; // 行号从 1 计
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.length; // 没有找到匹配括号,返回文件末尾
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function mergeRanges(ranges: [number, number][]): [number, number][] {
|
|
144
|
+
if (ranges.length === 0) return [];
|
|
145
|
+
const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
|
|
146
|
+
const merged: [number, number][] = [sorted[0]];
|
|
147
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
148
|
+
const last = merged[merged.length - 1];
|
|
149
|
+
if (sorted[i][0] <= last[1] + 1) {
|
|
150
|
+
last[1] = Math.max(last[1], sorted[i][1]);
|
|
151
|
+
} else {
|
|
152
|
+
merged.push([...sorted[i]]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return merged;
|
|
11
156
|
}
|
|
12
157
|
|
|
13
158
|
export function buildCommitsSection(
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_COMMENT_MARKER,
|
|
4
|
+
REVIEW_LINE_COMMENTS_MARKER,
|
|
5
|
+
extractIssueKeyFromBody,
|
|
6
|
+
isAiGeneratedComment,
|
|
7
|
+
generateIssueKey,
|
|
8
|
+
syncRepliesToIssues,
|
|
9
|
+
deleteExistingAiReviews,
|
|
10
|
+
calculateIssueStats,
|
|
11
|
+
} from "./review-pr-comment";
|
|
12
|
+
|
|
13
|
+
describe("utils/review-pr-comment", () => {
|
|
14
|
+
// ─── 常量 ────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe("markers", () => {
|
|
17
|
+
it("REVIEW_COMMENT_MARKER 包含预期字符串", () => {
|
|
18
|
+
expect(REVIEW_COMMENT_MARKER).toBe("<!-- spaceflow-review -->");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("REVIEW_LINE_COMMENTS_MARKER 包含预期字符串", () => {
|
|
22
|
+
expect(REVIEW_LINE_COMMENTS_MARKER).toBe("<!-- spaceflow-review-lines -->");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ─── extractIssueKeyFromBody ─────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("extractIssueKeyFromBody", () => {
|
|
29
|
+
it("提取标准格式的 issue key", () => {
|
|
30
|
+
expect(extractIssueKeyFromBody("<!-- issue-key: src/a.ts:10:R1 -->")).toBe(
|
|
31
|
+
"src/a.ts:10:R1",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("body 不含 issue-key 时返回 null", () => {
|
|
36
|
+
expect(extractIssueKeyFromBody("普通评论内容")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("issue key 含空格时正确提取(trim)", () => {
|
|
40
|
+
expect(extractIssueKeyFromBody("<!-- issue-key: a.ts:1:R1 -->")).toBe("a.ts:1:R1");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ─── isAiGeneratedComment ────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("isAiGeneratedComment", () => {
|
|
47
|
+
it("含 issue-key 标记时返回 true", () => {
|
|
48
|
+
expect(isAiGeneratedComment("some body <!-- issue-key: a.ts:1:R1 --> end")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("同时含规则和文件字段时返回 true", () => {
|
|
52
|
+
expect(isAiGeneratedComment("- **规则**: R1\n- **文件**: a.ts:1")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("普通用户评论返回 false", () => {
|
|
56
|
+
expect(isAiGeneratedComment("LGTM")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("空字符串返回 false", () => {
|
|
60
|
+
expect(isAiGeneratedComment("")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("只含规则字段(不含文件字段)返回 false", () => {
|
|
64
|
+
expect(isAiGeneratedComment("- **规则**: R1")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── generateIssueKey ───────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe("generateIssueKey", () => {
|
|
71
|
+
it("拼接 file:line:ruleId", () => {
|
|
72
|
+
expect(
|
|
73
|
+
generateIssueKey({ file: "src/a.ts", line: "10", ruleId: "R1" } as any),
|
|
74
|
+
).toBe("src/a.ts:10:R1");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── calculateIssueStats ────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("calculateIssueStats", () => {
|
|
81
|
+
it("空数组返回全零统计", () => {
|
|
82
|
+
const stats = calculateIssueStats([]);
|
|
83
|
+
expect(stats).toEqual({
|
|
84
|
+
total: 0,
|
|
85
|
+
validTotal: 0,
|
|
86
|
+
fixed: 0,
|
|
87
|
+
resolved: 0,
|
|
88
|
+
invalid: 0,
|
|
89
|
+
pending: 0,
|
|
90
|
+
fixRate: 0,
|
|
91
|
+
resolveRate: 0,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("全部待处理问题", () => {
|
|
96
|
+
const issues = [
|
|
97
|
+
{ file: "a.ts", line: "1", ruleId: "R1" },
|
|
98
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
99
|
+
] as any[];
|
|
100
|
+
const stats = calculateIssueStats(issues);
|
|
101
|
+
expect(stats.total).toBe(2);
|
|
102
|
+
expect(stats.validTotal).toBe(2);
|
|
103
|
+
expect(stats.pending).toBe(2);
|
|
104
|
+
expect(stats.fixed).toBe(0);
|
|
105
|
+
expect(stats.resolved).toBe(0);
|
|
106
|
+
expect(stats.invalid).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("valid=false 的问题计入 invalid,不计入 pending", () => {
|
|
110
|
+
const issues = [
|
|
111
|
+
{ file: "a.ts", line: "1", ruleId: "R1", valid: "false" },
|
|
112
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
113
|
+
] as any[];
|
|
114
|
+
const stats = calculateIssueStats(issues);
|
|
115
|
+
expect(stats.total).toBe(2);
|
|
116
|
+
expect(stats.invalid).toBe(1);
|
|
117
|
+
expect(stats.validTotal).toBe(1);
|
|
118
|
+
expect(stats.pending).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("fixed 问题计入 fixed,不计入 pending", () => {
|
|
122
|
+
const issues = [
|
|
123
|
+
{ file: "a.ts", line: "1", ruleId: "R1", fixed: "2024-01-01" },
|
|
124
|
+
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
125
|
+
] as any[];
|
|
126
|
+
const stats = calculateIssueStats(issues);
|
|
127
|
+
expect(stats.fixed).toBe(1);
|
|
128
|
+
expect(stats.pending).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("resolved(非 fixed)计入 resolved,不计入 pending", () => {
|
|
132
|
+
const issues = [
|
|
133
|
+
{ file: "a.ts", line: "1", ruleId: "R1", resolved: "2024-01-01" },
|
|
134
|
+
] as any[];
|
|
135
|
+
const stats = calculateIssueStats(issues);
|
|
136
|
+
expect(stats.resolved).toBe(1);
|
|
137
|
+
expect(stats.pending).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("fixed 同时有 resolved 时只计入 fixed", () => {
|
|
141
|
+
const issues = [
|
|
142
|
+
{ file: "a.ts", line: "1", ruleId: "R1", fixed: "2024-01-01", resolved: "2024-01-01" },
|
|
143
|
+
] as any[];
|
|
144
|
+
const stats = calculateIssueStats(issues);
|
|
145
|
+
expect(stats.fixed).toBe(1);
|
|
146
|
+
expect(stats.resolved).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fixRate 计算正确(2/4 = 50%)", () => {
|
|
150
|
+
const issues = Array.from({ length: 4 }, (_, i) => ({
|
|
151
|
+
file: "a.ts",
|
|
152
|
+
line: String(i + 1),
|
|
153
|
+
ruleId: "R1",
|
|
154
|
+
...(i < 2 ? { fixed: "2024-01-01" } : {}),
|
|
155
|
+
})) as any[];
|
|
156
|
+
const stats = calculateIssueStats(issues);
|
|
157
|
+
expect(stats.fixRate).toBe(50);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── syncRepliesToIssues ─────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe("syncRepliesToIssues", () => {
|
|
164
|
+
const lineMatchesPosition = (line: string, pos?: number) =>
|
|
165
|
+
pos !== undefined && String(pos) === line;
|
|
166
|
+
|
|
167
|
+
it("单条评论(无回复)不产生 replies", async () => {
|
|
168
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
169
|
+
const result = { issues: [issue] } as any;
|
|
170
|
+
const comments = [
|
|
171
|
+
{
|
|
172
|
+
id: 1,
|
|
173
|
+
path: "a.ts",
|
|
174
|
+
position: 1,
|
|
175
|
+
body: `🔴 **问题**\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
176
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
180
|
+
expect(issue.replies).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("用户回复通过 issue-key 精确匹配后加入 replies", async () => {
|
|
184
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
185
|
+
const result = { issues: [issue] } as any;
|
|
186
|
+
const comments = [
|
|
187
|
+
{
|
|
188
|
+
id: 1,
|
|
189
|
+
path: "a.ts",
|
|
190
|
+
position: 1,
|
|
191
|
+
body: `🔴 问题\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
192
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 2,
|
|
196
|
+
path: "a.ts",
|
|
197
|
+
position: 1,
|
|
198
|
+
body: "已修复",
|
|
199
|
+
user: { id: 42, login: "dev1" },
|
|
200
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
204
|
+
expect(issue.replies).toHaveLength(1);
|
|
205
|
+
expect(issue.replies[0].body).toBe("已修复");
|
|
206
|
+
expect(issue.replies[0].user.login).toBe("dev1");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("AI 格式特征的评论不被计为用户回复", async () => {
|
|
210
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
211
|
+
const result = { issues: [issue] } as any;
|
|
212
|
+
const comments = [
|
|
213
|
+
{
|
|
214
|
+
id: 1,
|
|
215
|
+
path: "a.ts",
|
|
216
|
+
position: 1,
|
|
217
|
+
body: `🔴 问题\n<!-- issue-key: a.ts:1:R1 -->`,
|
|
218
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 2,
|
|
222
|
+
path: "a.ts",
|
|
223
|
+
position: 1,
|
|
224
|
+
body: "- **规则**: R2\n- **文件**: b.ts:2",
|
|
225
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
229
|
+
expect(issue.replies).toBeUndefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("同一 issue 多条用户回复全部追加", async () => {
|
|
233
|
+
const issue = { file: "a.ts", line: "1", ruleId: "R1" } as any;
|
|
234
|
+
const result = { issues: [issue] } as any;
|
|
235
|
+
const comments = [
|
|
236
|
+
{
|
|
237
|
+
id: 1,
|
|
238
|
+
path: "a.ts",
|
|
239
|
+
position: 1,
|
|
240
|
+
body: `<!-- issue-key: a.ts:1:R1 -->`,
|
|
241
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 2,
|
|
245
|
+
path: "a.ts",
|
|
246
|
+
position: 1,
|
|
247
|
+
body: "回复1",
|
|
248
|
+
user: { login: "u1" },
|
|
249
|
+
created_at: "2024-01-01T01:00:00Z",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 3,
|
|
253
|
+
path: "a.ts",
|
|
254
|
+
position: 1,
|
|
255
|
+
body: "回复2",
|
|
256
|
+
user: { login: "u2" },
|
|
257
|
+
created_at: "2024-01-01T02:00:00Z",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
await syncRepliesToIssues(comments, result, lineMatchesPosition);
|
|
261
|
+
expect(issue.replies).toHaveLength(2);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── deleteExistingAiReviews ─────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe("deleteExistingAiReviews", () => {
|
|
268
|
+
let pr: any;
|
|
269
|
+
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
pr = {
|
|
272
|
+
getReviews: vi.fn(),
|
|
273
|
+
deleteReview: vi.fn(),
|
|
274
|
+
getComments: vi.fn(),
|
|
275
|
+
deleteComment: vi.fn(),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("删除含 REVIEW_LINE_COMMENTS_MARKER 的 PR Review", async () => {
|
|
280
|
+
pr.getReviews.mockResolvedValue([
|
|
281
|
+
{ id: 1, body: `${REVIEW_LINE_COMMENTS_MARKER} Round 1` },
|
|
282
|
+
{ id: 2, body: "普通 review" },
|
|
283
|
+
]);
|
|
284
|
+
pr.deleteReview.mockResolvedValue(undefined);
|
|
285
|
+
pr.getComments.mockResolvedValue([]);
|
|
286
|
+
|
|
287
|
+
await deleteExistingAiReviews(pr);
|
|
288
|
+
|
|
289
|
+
expect(pr.deleteReview).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(pr.deleteReview).toHaveBeenCalledWith(1);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("删除含 REVIEW_COMMENT_MARKER 的 Issue Comment", async () => {
|
|
294
|
+
pr.getReviews.mockResolvedValue([]);
|
|
295
|
+
pr.getComments.mockResolvedValue([
|
|
296
|
+
{ id: 10, body: `${REVIEW_COMMENT_MARKER} 报告内容` },
|
|
297
|
+
{ id: 11, body: "普通评论" },
|
|
298
|
+
]);
|
|
299
|
+
pr.deleteComment.mockResolvedValue(undefined);
|
|
300
|
+
|
|
301
|
+
await deleteExistingAiReviews(pr);
|
|
302
|
+
|
|
303
|
+
expect(pr.deleteComment).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(pr.deleteComment).toHaveBeenCalledWith(10);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("deleteReview 失败时静默忽略,继续处理其他", async () => {
|
|
308
|
+
pr.getReviews.mockResolvedValue([
|
|
309
|
+
{ id: 1, body: REVIEW_LINE_COMMENTS_MARKER },
|
|
310
|
+
{ id: 2, body: REVIEW_LINE_COMMENTS_MARKER },
|
|
311
|
+
]);
|
|
312
|
+
pr.deleteReview
|
|
313
|
+
.mockRejectedValueOnce(new Error("submitted review cannot be deleted"))
|
|
314
|
+
.mockResolvedValueOnce(undefined);
|
|
315
|
+
pr.getComments.mockResolvedValue([]);
|
|
316
|
+
|
|
317
|
+
await expect(deleteExistingAiReviews(pr)).resolves.not.toThrow();
|
|
318
|
+
expect(pr.deleteReview).toHaveBeenCalledTimes(2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("getReviews 失败时静默忽略,继续处理 comments", async () => {
|
|
322
|
+
pr.getReviews.mockRejectedValue(new Error("API error"));
|
|
323
|
+
pr.getComments.mockResolvedValue([{ id: 10, body: REVIEW_COMMENT_MARKER }]);
|
|
324
|
+
pr.deleteComment.mockResolvedValue(undefined);
|
|
325
|
+
|
|
326
|
+
await expect(deleteExistingAiReviews(pr)).resolves.not.toThrow();
|
|
327
|
+
expect(pr.deleteComment).toHaveBeenCalledWith(10);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("无 AI 评论时不调用任何 delete", async () => {
|
|
331
|
+
pr.getReviews.mockResolvedValue([{ id: 1, body: "普通 review" }]);
|
|
332
|
+
pr.getComments.mockResolvedValue([{ id: 10, body: "普通评论" }]);
|
|
333
|
+
|
|
334
|
+
await deleteExistingAiReviews(pr);
|
|
335
|
+
|
|
336
|
+
expect(pr.deleteReview).not.toHaveBeenCalled();
|
|
337
|
+
expect(pr.deleteComment).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ReviewIssue, ReviewResult, ReviewStats } from "
|
|
2
|
-
import { PullRequestModel } from "
|
|
1
|
+
import { ReviewIssue, ReviewResult, ReviewStats } from "../review-spec";
|
|
2
|
+
import { PullRequestModel } from "../pull-request-model";
|
|
3
3
|
|
|
4
4
|
export const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
|
|
5
5
|
export const REVIEW_LINE_COMMENTS_MARKER = "<!-- spaceflow-review-lines -->";
|