@spaceflow/review 0.77.0 → 0.79.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 +67 -0
- package/dist/index.js +1095 -500
- 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 +53 -15
- 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 +75 -3
- package/src/review.service.ts +120 -13
- 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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { PromptFn } from "./types";
|
|
2
|
+
import { validateArray } from "./types";
|
|
3
|
+
import type { PullRequestCommit, ChangedFile } from "@spaceflow/core";
|
|
4
|
+
import type { FileContentsMap } from "../review-spec";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 内存使用限制常量
|
|
8
|
+
*/
|
|
9
|
+
const MEMORY_LIMITS = {
|
|
10
|
+
MAX_TOTAL_LENGTH: 8000, // 代码变更内容最大总长度
|
|
11
|
+
MAX_FILES: 30, // 最大文件数量
|
|
12
|
+
MAX_SNIPPET_LENGTH: 50, // 每个文件最大代码行数
|
|
13
|
+
MAX_COMMITS: 10, // 最大 commit 数量(用于标题生成)
|
|
14
|
+
MAX_FILES_FOR_TITLE: 20, // 标题生成时最大文件数量
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* PR 描述生成提示词
|
|
19
|
+
*/
|
|
20
|
+
export interface PrDescriptionContext {
|
|
21
|
+
commits: PullRequestCommit[];
|
|
22
|
+
changedFiles: ChangedFile[];
|
|
23
|
+
fileContents?: FileContentsMap;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const buildPrDescriptionPrompt: PromptFn<PrDescriptionContext> = (ctx) => {
|
|
28
|
+
// 验证必需的输入参数
|
|
29
|
+
validateArray(ctx.commits, "commits");
|
|
30
|
+
validateArray(ctx.changedFiles, "changedFiles");
|
|
31
|
+
|
|
32
|
+
const commitMessages = ctx.commits
|
|
33
|
+
.map((c) => `- ${c.sha?.slice(0, 7)}: ${c.commit?.message?.split("\n")[0]}`)
|
|
34
|
+
.join("\n");
|
|
35
|
+
const fileChanges = ctx.changedFiles
|
|
36
|
+
.slice(0, MEMORY_LIMITS.MAX_FILES)
|
|
37
|
+
.map((f) => `- ${f.filename} (${f.status})`)
|
|
38
|
+
.join("\n");
|
|
39
|
+
|
|
40
|
+
// 构建代码变更内容(只包含变更行,优化内存使用)
|
|
41
|
+
let codeChangesSection = "";
|
|
42
|
+
if (ctx.fileContents && ctx.fileContents.size > 0) {
|
|
43
|
+
const codeSnippets: string[] = [];
|
|
44
|
+
let totalLength = 0;
|
|
45
|
+
|
|
46
|
+
// 使用 Map.entries() 进行更高效的迭代
|
|
47
|
+
for (const [filename, lines] of ctx.fileContents) {
|
|
48
|
+
if (totalLength >= MEMORY_LIMITS.MAX_TOTAL_LENGTH) break;
|
|
49
|
+
|
|
50
|
+
// 只提取有变更的行(commitHash 不是 "-------")
|
|
51
|
+
const changedLines = lines
|
|
52
|
+
.map(([hash, code], idx) => (hash !== "-------" ? `${idx + 1}: ${code}` : null))
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
|
|
55
|
+
if (changedLines.length > 0) {
|
|
56
|
+
// 限制每个文件的代码行数,避免单个文件占用过多内存
|
|
57
|
+
const limitedLines = changedLines.slice(0, MEMORY_LIMITS.MAX_SNIPPET_LENGTH);
|
|
58
|
+
const snippet = `### ${filename}\n\`\`\`\n${limitedLines.join("\n")}\n\`\`\``;
|
|
59
|
+
|
|
60
|
+
// 检查添加此片段是否会超过内存限制
|
|
61
|
+
if (totalLength + snippet.length <= MEMORY_LIMITS.MAX_TOTAL_LENGTH) {
|
|
62
|
+
codeSnippets.push(snippet);
|
|
63
|
+
totalLength += snippet.length;
|
|
64
|
+
} else {
|
|
65
|
+
// 如果添加当前片段会超过限制,尝试截断它
|
|
66
|
+
const remainingLength = MEMORY_LIMITS.MAX_TOTAL_LENGTH - totalLength;
|
|
67
|
+
if (remainingLength > 100) {
|
|
68
|
+
// 至少保留 100 字符的片段
|
|
69
|
+
// snippet 格式为 "### filename\n```\ncode\n```"
|
|
70
|
+
// 截断时去掉结尾的 ``` 再追加,避免双重代码块
|
|
71
|
+
const closingTag = "\n```";
|
|
72
|
+
const contentEnd = snippet.lastIndexOf(closingTag);
|
|
73
|
+
const truncateAt = Math.max(
|
|
74
|
+
0,
|
|
75
|
+
contentEnd > 0 ? Math.min(remainingLength - 20, contentEnd) : remainingLength - 20,
|
|
76
|
+
);
|
|
77
|
+
const truncatedSnippet = snippet.substring(0, truncateAt) + "\n..." + closingTag;
|
|
78
|
+
codeSnippets.push(truncatedSnippet);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (codeSnippets.length > 0) {
|
|
87
|
+
codeChangesSection = `\n\n## 代码变更内容\n${codeSnippets.join("\n\n")}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
systemPrompt: "",
|
|
93
|
+
userPrompt: `请根据以下 PR 的 commit 记录、文件变更和代码内容,用简洁的中文总结这个 PR 实现了什么功能。
|
|
94
|
+
要求:
|
|
95
|
+
1. 第一行输出 PR 标题,格式必须是: Feat xxx 或 Fix xxx 或 Refactor xxx(根据变更类型选择,整体不超过 50 个字符)
|
|
96
|
+
2. 空一行后输出详细描述
|
|
97
|
+
3. 描述应该简明扼要,突出核心功能点
|
|
98
|
+
4. 使用 Markdown 格式
|
|
99
|
+
5. 不要逐条列出 commit,而是归纳总结
|
|
100
|
+
6. 重点分析代码变更的实际功能
|
|
101
|
+
|
|
102
|
+
## Commit 记录 (${ctx.commits.length} 个)
|
|
103
|
+
${commitMessages || "无"}
|
|
104
|
+
|
|
105
|
+
## 文件变更 (${ctx.changedFiles.length} 个文件)
|
|
106
|
+
${fileChanges || "无"}${ctx.changedFiles.length > MEMORY_LIMITS.MAX_FILES ? `\n... 等 ${ctx.changedFiles.length - MEMORY_LIMITS.MAX_FILES} 个文件` : ""}${codeChangesSection}`,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* PR 标题生成提示词
|
|
112
|
+
*/
|
|
113
|
+
export interface PrTitleContext {
|
|
114
|
+
commits: PullRequestCommit[];
|
|
115
|
+
changedFiles: ChangedFile[];
|
|
116
|
+
[key: string]: unknown;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const buildPrTitlePrompt: PromptFn<PrTitleContext> = (ctx) => {
|
|
120
|
+
// 验证必需的输入参数
|
|
121
|
+
validateArray(ctx.commits, "commits");
|
|
122
|
+
validateArray(ctx.changedFiles, "changedFiles");
|
|
123
|
+
|
|
124
|
+
const commitMessages = ctx.commits
|
|
125
|
+
.slice(0, MEMORY_LIMITS.MAX_COMMITS)
|
|
126
|
+
.map((c) => c.commit?.message?.split("\n")[0])
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join("\n");
|
|
129
|
+
const fileChanges = ctx.changedFiles
|
|
130
|
+
.slice(0, MEMORY_LIMITS.MAX_FILES_FOR_TITLE)
|
|
131
|
+
.map((f) => `${f.filename} (${f.status})`)
|
|
132
|
+
.join("\n");
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
systemPrompt: "",
|
|
136
|
+
userPrompt: `请根据以下 commit 记录和文件变更,生成一个简短的 PR 标题。
|
|
137
|
+
要求:
|
|
138
|
+
1. 格式必须是: Feat: xxx 或 Fix: xxx 或 Refactor: xxx
|
|
139
|
+
2. 根据变更内容选择合适的前缀(新功能用 Feat,修复用 Fix,重构用 Refactor)
|
|
140
|
+
3. xxx 部分用简短的中文描述(整体不超过 50 个字符)
|
|
141
|
+
4. 只输出标题,不要加任何解释
|
|
142
|
+
|
|
143
|
+
Commit 记录:
|
|
144
|
+
${commitMessages || "无"}
|
|
145
|
+
|
|
146
|
+
文件变更:
|
|
147
|
+
${fileChanges || "无"}`,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { LlmJsonPutSchema } from "@spaceflow/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 代码审查结果 JSON Schema
|
|
5
|
+
*/
|
|
6
|
+
export const REVIEW_SCHEMA: LlmJsonPutSchema = {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
issues: {
|
|
10
|
+
type: "array",
|
|
11
|
+
items: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
file: { type: "string", description: "发生问题的文件路径" },
|
|
15
|
+
line: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description:
|
|
18
|
+
"问题所在的行号,只支持单行或多行 (如 123 或 123-125),不允许使用 `,` 分隔多个行号",
|
|
19
|
+
},
|
|
20
|
+
ruleId: { type: "string", description: "违反的规则 ID(如 JsTs.FileName.UpperCamel)" },
|
|
21
|
+
specFile: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "规则来源的规范文件名(如 js&ts.file-name.md)",
|
|
24
|
+
},
|
|
25
|
+
reason: { type: "string", description: "问题的简要概括" },
|
|
26
|
+
suggestion: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description:
|
|
29
|
+
"修改后的完整代码片段。要求以代码为主体,并在代码中使用详细的中文注释解释逻辑改进点。不要包含 Markdown 反引号。",
|
|
30
|
+
},
|
|
31
|
+
commit: { type: "string", description: "相关的 7 位 commit SHA" },
|
|
32
|
+
severity: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "问题严重程度,根据规则文档中的 severity 标记确定",
|
|
35
|
+
enum: ["error", "warn"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["file", "line", "ruleId", "specFile", "reason"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
summary: { type: "string", description: "本次代码审查的整体总结" },
|
|
43
|
+
},
|
|
44
|
+
required: ["issues", "summary"],
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 删除影响分析结果 JSON Schema
|
|
50
|
+
*/
|
|
51
|
+
export const DELETION_IMPACT_SCHEMA: LlmJsonPutSchema = {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: {
|
|
54
|
+
impacts: {
|
|
55
|
+
type: "array",
|
|
56
|
+
items: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
file: { type: "string", description: "被删除代码所在的文件路径" },
|
|
60
|
+
deletedCode: { type: "string", description: "被删除的代码片段摘要(前50字符)" },
|
|
61
|
+
riskLevel: {
|
|
62
|
+
type: "string",
|
|
63
|
+
enum: ["high", "medium", "low", "none"],
|
|
64
|
+
description:
|
|
65
|
+
"风险等级:high=可能导致功能异常,medium=可能影响部分功能,low=影响较小,none=无影响",
|
|
66
|
+
},
|
|
67
|
+
affectedFiles: {
|
|
68
|
+
type: "array",
|
|
69
|
+
items: { type: "string" },
|
|
70
|
+
description: "可能受影响的文件列表",
|
|
71
|
+
},
|
|
72
|
+
reason: { type: "string", description: "影响分析的详细说明" },
|
|
73
|
+
suggestion: { type: "string", description: "建议的处理方式" },
|
|
74
|
+
},
|
|
75
|
+
required: ["file", "deletedCode", "riskLevel", "affectedFiles", "reason"],
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
summary: { type: "string", description: "删除代码影响的整体总结" },
|
|
80
|
+
},
|
|
81
|
+
required: ["impacts", "summary"],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 问题验证结果 JSON Schema
|
|
87
|
+
*/
|
|
88
|
+
export const VERIFY_SCHEMA: LlmJsonPutSchema = {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {
|
|
91
|
+
fixed: {
|
|
92
|
+
type: "boolean",
|
|
93
|
+
description: "问题是否已被修复",
|
|
94
|
+
},
|
|
95
|
+
valid: {
|
|
96
|
+
type: "boolean",
|
|
97
|
+
description: "问题是否有效,有效的条件就是你需要看看代码是否符合规范",
|
|
98
|
+
},
|
|
99
|
+
reason: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "判断依据,说明为什么认为问题已修复或仍存在",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ["fixed", "valid", "reason"],
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提示词回调函数类型定义
|
|
3
|
+
*/
|
|
4
|
+
export interface PromptContext {
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PromptResult {
|
|
9
|
+
systemPrompt: string;
|
|
10
|
+
userPrompt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 输入验证错误类
|
|
15
|
+
*/
|
|
16
|
+
export class PromptValidationError extends Error {
|
|
17
|
+
constructor(message: string) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "PromptValidationError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 输入验证工具函数
|
|
25
|
+
*/
|
|
26
|
+
export function validateRequired<T>(value: T | undefined | null, fieldName: string): T {
|
|
27
|
+
if (value === undefined || value === null) {
|
|
28
|
+
throw new PromptValidationError(`${fieldName} is required but was ${value}`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateNonEmptyString(
|
|
34
|
+
value: string | undefined | null,
|
|
35
|
+
fieldName: string,
|
|
36
|
+
): string {
|
|
37
|
+
if (value === undefined || value === null || value.trim() === "") {
|
|
38
|
+
throw new PromptValidationError(`${fieldName} is required and cannot be empty`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateArray<T>(value: T[] | undefined | null, fieldName: string): T[] {
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
throw new PromptValidationError(`${fieldName} must be an array but was ${typeof value}`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 提示词函数类型 - 接收上下文,返回 systemPrompt 和 userPrompt
|
|
52
|
+
*/
|
|
53
|
+
export type PromptFn<T extends PromptContext = PromptContext> = (ctx: T) => PromptResult;
|
package/src/review-context.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { parseTitleOptions } from "./parse-title-options";
|
|
|
15
15
|
import { type ReviewIssue, type UserInfo } from "./review-spec";
|
|
16
16
|
import { readFile } from "fs/promises";
|
|
17
17
|
import { join } from "path";
|
|
18
|
-
import { isAbsolute, relative } from "path";
|
|
18
|
+
import { isAbsolute, normalize, relative } from "path";
|
|
19
19
|
import { homedir } from "os";
|
|
20
20
|
import type { ReportFormat } from "./review-report";
|
|
21
21
|
|
|
@@ -28,6 +28,7 @@ export interface ReviewContext extends ReviewOptions {
|
|
|
28
28
|
specSources: string[];
|
|
29
29
|
verbose?: VerboseLevel;
|
|
30
30
|
includes?: string[];
|
|
31
|
+
whenModifiedCode?: string[];
|
|
31
32
|
files?: string[];
|
|
32
33
|
commits?: string[];
|
|
33
34
|
concurrency?: number;
|
|
@@ -64,6 +65,9 @@ export class ReviewContextBuilder {
|
|
|
64
65
|
|
|
65
66
|
async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
|
|
66
67
|
const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
|
|
68
|
+
if (shouldLog(options.verbose, 2)) {
|
|
69
|
+
console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
|
|
70
|
+
}
|
|
67
71
|
const ciConf = this.config.get<CiConfig>("ci");
|
|
68
72
|
const repository = ciConf?.repository;
|
|
69
73
|
|
|
@@ -133,11 +137,15 @@ export class ReviewContextBuilder {
|
|
|
133
137
|
specSources.push(...reviewConf.references);
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
const normalizedFiles = this.normalizeFilePaths(options.files);
|
|
141
|
+
|
|
136
142
|
// 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
|
|
143
|
+
// 当显式指定 files 时,强制走“按文件审查模式”,不进入本地未提交模式
|
|
137
144
|
const localMode = this.resolveLocalMode(options, {
|
|
138
145
|
ci: options.ci,
|
|
139
146
|
hasPrNumber: !!prNumber,
|
|
140
147
|
hasBaseHead: !!(options.base || options.head),
|
|
148
|
+
hasFiles: !!normalizedFiles?.length,
|
|
141
149
|
});
|
|
142
150
|
|
|
143
151
|
// 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
|
|
@@ -152,6 +160,12 @@ export class ReviewContextBuilder {
|
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
// 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
|
|
163
|
+
const ctxIncludes = options.includes ?? titleOptions.includes ?? reviewConf.includes;
|
|
164
|
+
if (shouldLog(options.verbose, 2)) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[getContextFromEnv] includes: commandLine=${JSON.stringify(options.includes)}, title=${JSON.stringify(titleOptions.includes)}, config=${JSON.stringify(reviewConf.includes)}, final=${JSON.stringify(ctxIncludes)}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
155
169
|
return {
|
|
156
170
|
owner,
|
|
157
171
|
repo,
|
|
@@ -162,9 +176,10 @@ export class ReviewContextBuilder {
|
|
|
162
176
|
dryRun: options.dryRun || titleOptions.dryRun || false,
|
|
163
177
|
ci: options.ci ?? false,
|
|
164
178
|
verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
|
|
165
|
-
includes:
|
|
179
|
+
includes: ctxIncludes,
|
|
180
|
+
whenModifiedCode: options.whenModifiedCode ?? reviewConf.whenModifiedCode,
|
|
166
181
|
llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
|
|
167
|
-
files:
|
|
182
|
+
files: normalizedFiles,
|
|
168
183
|
commits: options.commits,
|
|
169
184
|
verifyFixes:
|
|
170
185
|
options.verifyFixes ?? titleOptions.verifyFixes ?? reviewConf.verifyFixes ?? true,
|
|
@@ -193,9 +208,10 @@ export class ReviewContextBuilder {
|
|
|
193
208
|
flush: options.flush ?? false,
|
|
194
209
|
eventAction: options.eventAction,
|
|
195
210
|
localMode,
|
|
196
|
-
|
|
197
|
-
options.
|
|
211
|
+
duplicateWorkflowResolved:
|
|
212
|
+
options.duplicateWorkflowResolved ?? reviewConf.duplicateWorkflowResolved ?? "delete",
|
|
198
213
|
autoApprove: options.autoApprove ?? reviewConf.autoApprove ?? false,
|
|
214
|
+
systemRules: options.systemRules ?? reviewConf.systemRules,
|
|
199
215
|
};
|
|
200
216
|
}
|
|
201
217
|
|
|
@@ -207,8 +223,12 @@ export class ReviewContextBuilder {
|
|
|
207
223
|
*/
|
|
208
224
|
resolveLocalMode(
|
|
209
225
|
options: ReviewOptions,
|
|
210
|
-
env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean },
|
|
226
|
+
env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean; hasFiles: boolean },
|
|
211
227
|
): "uncommitted" | "staged" | false {
|
|
228
|
+
// 显式指定了 files,优先进入按文件审查模式
|
|
229
|
+
if (env.hasFiles) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
212
232
|
// 显式指定了 --no-local
|
|
213
233
|
if (options.local === false) {
|
|
214
234
|
return false;
|
|
@@ -237,13 +257,19 @@ export class ReviewContextBuilder {
|
|
|
237
257
|
if (!files || files.length === 0) return files;
|
|
238
258
|
|
|
239
259
|
const cwd = process.cwd();
|
|
240
|
-
return files.map((file) =>
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
return files.map((file) => this.normalizeSingleFilePath(file, cwd));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 规范化单个文件路径为仓库相对路径:
|
|
265
|
+
* - 绝对路径转相对路径
|
|
266
|
+
* - 统一分隔符为 /
|
|
267
|
+
* - 移除前导 ./
|
|
268
|
+
*/
|
|
269
|
+
private normalizeSingleFilePath(file: string, cwd: string): string {
|
|
270
|
+
const normalizedInput = normalize(file);
|
|
271
|
+
const relativePath = isAbsolute(normalizedInput) ? relative(cwd, normalizedInput) : normalizedInput;
|
|
272
|
+
return relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
247
273
|
}
|
|
248
274
|
|
|
249
275
|
/**
|
|
@@ -390,14 +416,26 @@ export class ReviewContextBuilder {
|
|
|
390
416
|
// 为每个 issue 填充 author
|
|
391
417
|
return issues.map((issue) => {
|
|
392
418
|
if (issue.author) {
|
|
419
|
+
const shortHash = issue.commit?.slice(0, 7);
|
|
420
|
+
if (shortHash?.includes("---")) {
|
|
421
|
+
return { ...issue, commit: undefined, valid: "false" };
|
|
422
|
+
}
|
|
393
423
|
if (shouldLog(verbose, 2)) {
|
|
394
424
|
console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
|
|
395
425
|
}
|
|
396
426
|
return issue;
|
|
397
427
|
}
|
|
398
428
|
const shortHash = issue.commit?.slice(0, 7);
|
|
399
|
-
const
|
|
400
|
-
|
|
429
|
+
const isValidHash = Boolean(shortHash && !shortHash.includes("---"));
|
|
430
|
+
if (!isValidHash) {
|
|
431
|
+
if (shouldLog(verbose, 2)) {
|
|
432
|
+
console.log(
|
|
433
|
+
`[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit} is invalid hash, marking as invalid`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return { ...issue, commit: undefined, valid: "false" };
|
|
437
|
+
}
|
|
438
|
+
const author = commitAuthorMap.get(shortHash!);
|
|
401
439
|
if (shouldLog(verbose, 2)) {
|
|
402
440
|
console.log(
|
|
403
441
|
`[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
parseIncludePattern,
|
|
4
4
|
filterFilesByIncludes,
|
|
5
5
|
extractGlobsFromIncludes,
|
|
6
|
+
extractCodeBlockTypes,
|
|
6
7
|
} from "./review-includes-filter";
|
|
7
8
|
|
|
8
9
|
describe("review-includes-filter", () => {
|
|
@@ -216,6 +217,15 @@ describe("review-includes-filter", () => {
|
|
|
216
217
|
const glFiles = [{ filename: "src/old.ts", status: "deleted" }];
|
|
217
218
|
expect(filterFilesByIncludes(glFiles, [`deleted|${glob}`])).toHaveLength(1);
|
|
218
219
|
});
|
|
220
|
+
|
|
221
|
+
it("全量 diff 语义:文件在当前分支首次引入后被多次修改,status 仍为 added,added| 始终匹配", () => {
|
|
222
|
+
// 场景:a.ts 在 commit1 创建(status=added),在 commit2 修改
|
|
223
|
+
// 全量 diff(当前分支 vs base)时,compare API 返回的 status 仍为 added
|
|
224
|
+
// 因此 added|*.ts 在后续每次 review 中都能匹配到该文件
|
|
225
|
+
const diffFiles = [{ filename: "src/a.ts", status: "added" }];
|
|
226
|
+
const result = filterFilesByIncludes(diffFiles, [`added|${glob}`]);
|
|
227
|
+
expect(result.map((f) => f.filename)).toEqual(["src/a.ts"]);
|
|
228
|
+
});
|
|
219
229
|
});
|
|
220
230
|
|
|
221
231
|
describe("extractGlobsFromIncludes", () => {
|
|
@@ -245,4 +255,30 @@ describe("review-includes-filter", () => {
|
|
|
245
255
|
expect(extractGlobsFromIncludes([])).toEqual([]);
|
|
246
256
|
});
|
|
247
257
|
});
|
|
258
|
+
|
|
259
|
+
describe("extractCodeBlockTypes", () => {
|
|
260
|
+
it("提取纯类型名", () => {
|
|
261
|
+
const result = extractCodeBlockTypes(["function", "class"]);
|
|
262
|
+
expect(result).toEqual(["function", "class"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("status|code-* 语法不被识别,返回空", () => {
|
|
266
|
+
const result = extractCodeBlockTypes(["added|code-function", "added|code-class"]);
|
|
267
|
+
expect(result).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("混合:纯类型名保留,status 前缀语法忽略", () => {
|
|
271
|
+
const result = extractCodeBlockTypes(["added|code-function", "class"]);
|
|
272
|
+
expect(result).toEqual(["class"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("去重:同一类型出现多次只返回一次", () => {
|
|
276
|
+
const result = extractCodeBlockTypes(["function", "function"]);
|
|
277
|
+
expect(result).toEqual(["function"]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("空数组返回空数组", () => {
|
|
281
|
+
expect(extractCodeBlockTypes([])).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
248
284
|
});
|
|
@@ -4,13 +4,36 @@ import micromatch from "micromatch";
|
|
|
4
4
|
* includes 模式中的变更类型前缀
|
|
5
5
|
*
|
|
6
6
|
* 语法:`<status>|<glob>`,例如:
|
|
7
|
-
* - `added|*\/**\/*.ts`
|
|
8
|
-
* - `modified|*\/**\/*.ts`
|
|
9
|
-
* - `deleted|*\/**\/*.ts`
|
|
10
|
-
* - `*\/**\/*.ts`
|
|
7
|
+
* - `added|*\/**\/*.ts` → 仅匹配新增文件
|
|
8
|
+
* - `modified|*\/**\/*.ts` → 仅匹配修改文件
|
|
9
|
+
* - `deleted|*\/**\/*.ts` → 仅匹配删除文件
|
|
10
|
+
* - `*\/**\/*.ts` → 不限变更类型(原有行为)
|
|
11
|
+
*
|
|
12
|
+
* ## 全量 diff 语义说明
|
|
13
|
+
*
|
|
14
|
+
* 每次 review 是对当前分支与 base 分支(develop/master)的**全量 diff**,
|
|
15
|
+
* 文件的 status 由平台 compare API 给出,表示该文件**相对 base 分支**的变更类型。
|
|
16
|
+
*
|
|
17
|
+
* 因此,若 `a.ts` 是在当前分支上首次引入的(相对 base 不存在),
|
|
18
|
+
* 无论后续经过多少次 commit 修改,其 status **始终为 `added`**。
|
|
19
|
+
* 这意味着 `added|*.ts` 在后续每次 review 中都会继续匹配该文件,
|
|
20
|
+
* 这是符合预期的行为——只要文件相对 base 是"新建"的,`added|` 就应该始终生效。
|
|
11
21
|
*/
|
|
12
22
|
export type IncludeStatusPrefix = "added" | "modified" | "deleted";
|
|
13
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 代码结构类型,用于 whenModifiedCode 配置
|
|
26
|
+
*/
|
|
27
|
+
export type CodeBlockType = "function" | "class" | "interface" | "type" | "method";
|
|
28
|
+
|
|
29
|
+
export const CODE_BLOCK_TYPES: CodeBlockType[] = [
|
|
30
|
+
"function",
|
|
31
|
+
"class",
|
|
32
|
+
"interface",
|
|
33
|
+
"type",
|
|
34
|
+
"method",
|
|
35
|
+
];
|
|
36
|
+
|
|
14
37
|
/** status 值到前缀的映射(兼容 GitHub/GitLab/Gitea 各平台) */
|
|
15
38
|
const STATUS_ALIAS: Record<string, IncludeStatusPrefix> = {
|
|
16
39
|
added: "added",
|
|
@@ -51,6 +74,7 @@ export function parseIncludePattern(pattern: string): ParsedIncludePattern {
|
|
|
51
74
|
// 前缀无法识别(如 extglob 中的 `|`),当作普通 glob 处理
|
|
52
75
|
return { status: undefined, glob: pattern };
|
|
53
76
|
}
|
|
77
|
+
|
|
54
78
|
return { status, glob };
|
|
55
79
|
}
|
|
56
80
|
|
|
@@ -78,6 +102,7 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
|
|
|
78
102
|
if (!includes || includes.length === 0) return files;
|
|
79
103
|
|
|
80
104
|
const parsed = includes.map(parseIncludePattern);
|
|
105
|
+
console.log(`[filterFilesByIncludes] parsed patterns=${JSON.stringify(parsed)}`);
|
|
81
106
|
|
|
82
107
|
// 排除模式(以 ! 开头),用于最终全局过滤
|
|
83
108
|
const negativeGlobs = parsed
|
|
@@ -90,8 +115,13 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
|
|
|
90
115
|
// 有 status 前缀的 patterns
|
|
91
116
|
const statusPatterns = parsed.filter((p) => p.status !== undefined);
|
|
92
117
|
|
|
118
|
+
console.log(
|
|
119
|
+
`[filterFilesByIncludes] negativeGlobs=${JSON.stringify(negativeGlobs)}, plainGlobs=${JSON.stringify(plainGlobs)}, statusPatterns=${JSON.stringify(statusPatterns)}`,
|
|
120
|
+
);
|
|
121
|
+
|
|
93
122
|
return files.filter((file) => {
|
|
94
123
|
const filename = file.filename ?? "";
|
|
124
|
+
const fileStatus = STATUS_ALIAS[file.status?.toLowerCase() ?? ""] ?? "modified";
|
|
95
125
|
if (!filename) return false;
|
|
96
126
|
|
|
97
127
|
// 最终排除:命中排除模式的文件直接过滤掉
|
|
@@ -99,18 +129,19 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
|
|
|
99
129
|
negativeGlobs.length > 0 &&
|
|
100
130
|
micromatch.isMatch(filename, negativeGlobs, { matchBase: true })
|
|
101
131
|
) {
|
|
132
|
+
console.log(`[filterFilesByIncludes] ${filename} excluded by negativeGlobs`);
|
|
102
133
|
return false;
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
// 正向匹配:无前缀 glob
|
|
106
137
|
if (plainGlobs.length > 0 && micromatch.isMatch(filename, plainGlobs, { matchBase: true })) {
|
|
138
|
+
console.log(`[filterFilesByIncludes] ${filename} matched plainGlobs`);
|
|
107
139
|
return true;
|
|
108
140
|
}
|
|
109
141
|
|
|
110
142
|
// 正向匹配:有 status 前缀的 glob,按文件实际 status 过滤
|
|
111
143
|
// glob 可以带 ! 前缀表示在该 status 范围内排除,如 added|!**/*.spec.ts
|
|
112
144
|
if (statusPatterns.length > 0) {
|
|
113
|
-
const fileStatus = STATUS_ALIAS[file.status?.toLowerCase() ?? ""] ?? "modified";
|
|
114
145
|
// 按 status 分组,每组内正向 glob + 排除 glob 合并后批量匹配
|
|
115
146
|
const matchingStatusGlobs = statusPatterns
|
|
116
147
|
.filter(({ status }) => status === fileStatus)
|
|
@@ -126,11 +157,17 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
|
|
|
126
157
|
const matchesNegative =
|
|
127
158
|
negativeStatusGlobs.length > 0 &&
|
|
128
159
|
micromatch.isMatch(filename, negativeStatusGlobs, { matchBase: true });
|
|
129
|
-
if (matchesPositive && !matchesNegative)
|
|
160
|
+
if (matchesPositive && !matchesNegative) {
|
|
161
|
+
console.log(
|
|
162
|
+
`[filterFilesByIncludes] ${filename} (status=${fileStatus}) matched statusPatterns`,
|
|
163
|
+
);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
130
166
|
}
|
|
131
167
|
}
|
|
132
168
|
}
|
|
133
169
|
|
|
170
|
+
console.log(`[filterFilesByIncludes] ${filename} (status=${fileStatus}) NOT matched`);
|
|
134
171
|
return false;
|
|
135
172
|
});
|
|
136
173
|
}
|
|
@@ -140,5 +177,20 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
|
|
|
140
177
|
* 带 status 前缀的模式会去掉前缀,仅保留 glob 部分。
|
|
141
178
|
*/
|
|
142
179
|
export function extractGlobsFromIncludes(includes: string[]): string[] {
|
|
143
|
-
return includes.map((p) => parseIncludePattern(p).glob);
|
|
180
|
+
return includes.map((p) => parseIncludePattern(p).glob).filter((g) => g.length > 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 从 whenModifiedCode 配置中解析代码结构过滤类型。
|
|
185
|
+
* 只接受简单的类型名称,如 "function"、"class"、"interface"、"type"、"method"
|
|
186
|
+
*/
|
|
187
|
+
export function extractCodeBlockTypes(whenModifiedCode: string[]): CodeBlockType[] {
|
|
188
|
+
const types = new Set<CodeBlockType>();
|
|
189
|
+
for (const entry of whenModifiedCode) {
|
|
190
|
+
const trimmed = entry.trim();
|
|
191
|
+
if ((CODE_BLOCK_TYPES as string[]).includes(trimmed)) {
|
|
192
|
+
types.add(trimmed as CodeBlockType);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...types];
|
|
144
196
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
FileContentLine,
|
|
21
21
|
} from "./review-spec";
|
|
22
22
|
import { IssueVerifyService } from "./issue-verify.service";
|
|
23
|
-
import { generateIssueKey } from "./review-pr-comment
|
|
23
|
+
import { generateIssueKey } from "./utils/review-pr-comment";
|
|
24
24
|
import type { ReviewContext } from "./review-context";
|
|
25
25
|
|
|
26
26
|
export class ReviewIssueFilter {
|