@spaceflow/review 0.76.0 → 0.77.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 +12 -0
- package/dist/index.js +2654 -1872
- package/package.json +2 -2
- package/src/deletion-impact.service.ts +4 -2
- package/src/index.ts +34 -2
- package/src/locales/en/review.json +2 -1
- package/src/locales/zh-cn/review.json +2 -1
- package/src/pull-request-model.ts +236 -0
- package/src/review-context.ts +409 -0
- package/src/review-includes-filter.spec.ts +248 -0
- package/src/review-includes-filter.ts +144 -0
- package/src/review-issue-filter.ts +523 -0
- package/src/review-llm.ts +634 -0
- package/src/review-pr-comment-utils.ts +186 -0
- package/src/review-result-model.spec.ts +657 -0
- package/src/review-result-model.ts +1024 -0
- package/src/review-spec/types.ts +2 -0
- package/src/review.config.ts +9 -0
- package/src/review.service.spec.ts +93 -1626
- package/src/review.service.ts +531 -2765
- package/src/types/review-llm.ts +19 -0
- package/src/utils/review-llm.ts +32 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { ReviewIssue, ReviewResult, ReviewStats } from "./review-spec";
|
|
2
|
+
import { PullRequestModel } from "./pull-request-model";
|
|
3
|
+
|
|
4
|
+
export const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
|
|
5
|
+
export const REVIEW_LINE_COMMENTS_MARKER = "<!-- spaceflow-review-lines -->";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 从评论 body 中提取 issue key(AI 行级评论末尾的 HTML 注释标记)
|
|
9
|
+
* 格式:`<!-- issue-key: file:line:ruleId -->`
|
|
10
|
+
* 返回 null 表示非 AI 评论(即用户真实回复)
|
|
11
|
+
*/
|
|
12
|
+
export function extractIssueKeyFromBody(body: string): string | null {
|
|
13
|
+
const match = body.match(/<!-- issue-key: (.+?) -->/);
|
|
14
|
+
return match ? match[1] : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 判断评论是否为 AI 生成的评论(非用户真实回复)
|
|
19
|
+
* 除 issue-key 标记外,还通过结构化格式特征识别
|
|
20
|
+
*/
|
|
21
|
+
export function isAiGeneratedComment(body: string): boolean {
|
|
22
|
+
if (!body) return false;
|
|
23
|
+
// 含 issue-key 标记
|
|
24
|
+
if (body.includes("<!-- issue-key:")) return true;
|
|
25
|
+
// 含 AI 评论的结构化格式特征(同时包含「规则」和「文件」字段)
|
|
26
|
+
if (body.includes("- **规则**:") && body.includes("- **文件**:")) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function generateIssueKey(issue: ReviewIssue): string {
|
|
31
|
+
return `${issue.file}:${issue.line}:${issue.ruleId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 同步评论回复到对应的 issues
|
|
36
|
+
* review 评论回复是通过同一个 review 下的后续评论实现的
|
|
37
|
+
*
|
|
38
|
+
* 通过 AI 评论 body 中嵌入的 issue key 精确匹配 issue:
|
|
39
|
+
* - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
|
|
40
|
+
* - 不含 issue key 但匹配 AI 格式特征的评论也视为 AI 评论,过滤掉
|
|
41
|
+
* - 其余评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
|
|
42
|
+
*/
|
|
43
|
+
export async function syncRepliesToIssues(
|
|
44
|
+
reviewComments: {
|
|
45
|
+
id?: number;
|
|
46
|
+
path?: string;
|
|
47
|
+
position?: number;
|
|
48
|
+
body?: string;
|
|
49
|
+
user?: { id?: number; login?: string };
|
|
50
|
+
created_at?: string;
|
|
51
|
+
}[],
|
|
52
|
+
result: ReviewResult,
|
|
53
|
+
lineMatchesPosition: (issueLine: string, position?: number) => boolean,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
// 构建 issue key → issue 的映射,用于快速查找
|
|
57
|
+
const issueByKey = new Map<string, ReviewResult["issues"][0]>();
|
|
58
|
+
for (const issue of result.issues) {
|
|
59
|
+
issueByKey.set(generateIssueKey(issue), issue);
|
|
60
|
+
}
|
|
61
|
+
// 按文件路径和行号分组评论
|
|
62
|
+
const commentsByLocation = new Map<string, typeof reviewComments>();
|
|
63
|
+
for (const comment of reviewComments) {
|
|
64
|
+
if (!comment.path || !comment.position) continue;
|
|
65
|
+
const key = `${comment.path}:${comment.position}`;
|
|
66
|
+
const comments = commentsByLocation.get(key) || [];
|
|
67
|
+
comments.push(comment);
|
|
68
|
+
commentsByLocation.set(key, comments);
|
|
69
|
+
}
|
|
70
|
+
// 遍历每个位置的评论
|
|
71
|
+
for (const [, comments] of commentsByLocation) {
|
|
72
|
+
if (comments.length <= 1) continue;
|
|
73
|
+
// 按创建时间排序
|
|
74
|
+
comments.sort((a, b) => {
|
|
75
|
+
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
|
76
|
+
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
|
77
|
+
return timeA - timeB;
|
|
78
|
+
});
|
|
79
|
+
// 遍历评论,用 issue key 精确匹配
|
|
80
|
+
let lastIssueKey: string | null = null;
|
|
81
|
+
for (const comment of comments) {
|
|
82
|
+
const commentBody = comment.body || "";
|
|
83
|
+
const issueKey = extractIssueKeyFromBody(commentBody);
|
|
84
|
+
if (issueKey) {
|
|
85
|
+
// AI 自身评论(含 issue-key),记录 issue key 但不作为回复
|
|
86
|
+
lastIssueKey = issueKey;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// 跳过不含 issue-key 但匹配 AI 格式特征的评论(如其他轮次的 bot 评论)
|
|
90
|
+
if (isAiGeneratedComment(commentBody)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// 用户真实回复,通过前面最近的 AI 评论的 issue key 精确匹配
|
|
94
|
+
let matchedIssue = lastIssueKey ? (issueByKey.get(lastIssueKey) ?? null) : null;
|
|
95
|
+
// 回退:如果 issue key 匹配失败,使用 path:position 匹配
|
|
96
|
+
if (!matchedIssue) {
|
|
97
|
+
matchedIssue =
|
|
98
|
+
result.issues.find(
|
|
99
|
+
(issue) =>
|
|
100
|
+
issue.file === comment.path && lineMatchesPosition(issue.line, comment.position),
|
|
101
|
+
) ?? null;
|
|
102
|
+
}
|
|
103
|
+
if (!matchedIssue) continue;
|
|
104
|
+
// 追加回复(而非覆盖,同一 issue 可能有多条用户回复)
|
|
105
|
+
if (!matchedIssue.replies) {
|
|
106
|
+
matchedIssue.replies = [];
|
|
107
|
+
}
|
|
108
|
+
matchedIssue.replies.push({
|
|
109
|
+
user: {
|
|
110
|
+
id: comment.user?.id?.toString(),
|
|
111
|
+
login: comment.user?.login || "unknown",
|
|
112
|
+
},
|
|
113
|
+
body: comment.body || "",
|
|
114
|
+
createdAt: comment.created_at || "",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.warn("⚠️ 同步评论回复失败:", error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 删除已有的 AI review(通过 marker 识别)
|
|
125
|
+
* - 删除行级评论的 PR Review(带 REVIEW_LINE_COMMENTS_MARKER)
|
|
126
|
+
* - 删除主评论的 Issue Comment(带 REVIEW_COMMENT_MARKER)
|
|
127
|
+
*/
|
|
128
|
+
export async function deleteExistingAiReviews(pr: PullRequestModel): Promise<void> {
|
|
129
|
+
let deletedCount = 0;
|
|
130
|
+
// 删除行级评论的 PR Review
|
|
131
|
+
try {
|
|
132
|
+
const reviews = await pr.getReviews();
|
|
133
|
+
const aiReviews = reviews.filter(
|
|
134
|
+
(r) =>
|
|
135
|
+
r.body?.includes(REVIEW_LINE_COMMENTS_MARKER) || r.body?.includes(REVIEW_COMMENT_MARKER),
|
|
136
|
+
);
|
|
137
|
+
for (const review of aiReviews) {
|
|
138
|
+
if (review.id) {
|
|
139
|
+
try {
|
|
140
|
+
await pr.deleteReview(review.id);
|
|
141
|
+
deletedCount++;
|
|
142
|
+
} catch {
|
|
143
|
+
// 已提交的 review 无法删除,忽略
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.warn("⚠️ 列出 PR reviews 失败:", error);
|
|
149
|
+
}
|
|
150
|
+
// 删除主评论的 Issue Comment
|
|
151
|
+
try {
|
|
152
|
+
const comments = await pr.getComments();
|
|
153
|
+
const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
154
|
+
for (const comment of aiComments) {
|
|
155
|
+
if (comment.id) {
|
|
156
|
+
try {
|
|
157
|
+
await pr.deleteComment(comment.id);
|
|
158
|
+
deletedCount++;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.warn(`⚠️ 删除评论 ${comment.id} 失败:`, error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.warn("⚠️ 列出 issue comments 失败:", error);
|
|
166
|
+
}
|
|
167
|
+
if (deletedCount > 0) {
|
|
168
|
+
console.log(`🗑️ 已删除 ${deletedCount} 个旧的 AI review`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 计算问题状态统计
|
|
174
|
+
*/
|
|
175
|
+
export function calculateIssueStats(issues: ReviewIssue[]): ReviewStats {
|
|
176
|
+
const total = issues.length;
|
|
177
|
+
const validIssue = issues.filter((i) => i.valid !== "false");
|
|
178
|
+
const validTotal = validIssue.length;
|
|
179
|
+
const fixed = validIssue.filter((i) => i.fixed).length;
|
|
180
|
+
const resolved = validIssue.filter((i) => i.resolved && !i.fixed).length;
|
|
181
|
+
const invalid = total - validTotal;
|
|
182
|
+
const pending = validTotal - fixed - resolved;
|
|
183
|
+
const fixRate = validTotal > 0 ? Math.round((fixed / validTotal) * 100 * 10) / 10 : 0;
|
|
184
|
+
const resolveRate = validTotal > 0 ? Math.round((resolved / validTotal) * 100 * 10) / 10 : 0;
|
|
185
|
+
return { total, validTotal, fixed, resolved, invalid, pending, fixRate, resolveRate };
|
|
186
|
+
}
|