@spaceflow/review 0.63.0 → 0.65.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.64.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.63.0...@spaceflow/review@0.64.0) (2026-03-03)
4
+
5
+ ### 测试用例
6
+
7
+ * **review:** 新增 deleteExistingAiReviews、buildLineReviewBody、findExistingAiComments、syncReactionsToIssues 和 filterIssuesByValidCommits 方法的详细日志测试 ([0619e9c](https://github.com/Lydanne/spaceflow/commit/0619e9cc1e5a81d24703e2e574026a249d87f236))
8
+ * **review:** 新增 invalidateIssuesForChangedFiles、updateIssueLineNumbers、filterIssuesByValidCommits 和 ensureClaudeCli 方法的单元测试 ([876f827](https://github.com/Lydanne/spaceflow/commit/876f82746331cf2e353b0a537b8691395e2a1084))
9
+
10
+ ### 其他修改
11
+
12
+ * **review-summary:** released version 0.31.0 [no ci] ([9b028ce](https://github.com/Lydanne/spaceflow/commit/9b028cedd03d8c60f8c1db58b189cd022ea562cf))
13
+
14
+ ## [0.63.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.62.0...@spaceflow/review@0.63.0) (2026-03-03)
15
+
16
+ ### 代码重构
17
+
18
+ * **review:** 为文件总结标题添加 💡 图标,增强视觉识别度 ([69cecf0](https://github.com/Lydanne/spaceflow/commit/69cecf0a6deadf1935db060800f8f110ae4b9889))
19
+
20
+ ### 其他修改
21
+
22
+ * **review-summary:** released version 0.30.0 [no ci] ([3902c7b](https://github.com/Lydanne/spaceflow/commit/3902c7be16ceb6ab7ff2abe52e434634de12a664))
23
+
3
24
  ## [0.62.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.61.0...@spaceflow/review@0.62.0) (2026-03-03)
4
25
 
5
26
  ### 代码重构
package/dist/index.js CHANGED
@@ -150,9 +150,9 @@ __webpack_require__.d(__webpack_exports__, {
150
150
  ;// CONCATENATED MODULE: external "@spaceflow/core"
151
151
 
152
152
  ;// CONCATENATED MODULE: ./src/locales/zh-cn/review.json
153
- var review_namespaceObject = JSON.parse('{"description":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","options.dryRun":"仅打印将要执行的操作,不实际提交评论","options.prNumber":"PR 编号,如果不指定则从环境变量获取","options.base":"基准分支/tag,用于比较差异","options.head":"目标分支/tag,用于比较差异","options.includes":"要审查的文件 glob 模式,如 *.ts *.js(可多次指定)","options.llmMode":"使用的 LLM 模式: claude-code, openai, gemini","options.files":"仅审查指定的文件(空格分隔)","options.commits":"仅审查指定的 commits(空格分隔)","options.verifyFixes":"是否验证历史问题是否已修复(默认从配置文件读取)","options.noVerifyFixes":"禁用历史问题验证","options.verifyConcurrency":"验证历史问题的并发数(默认 10)","options.analyzeDeletions":"分析删除代码可能带来的影响 (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"删除代码分析模式: openai (标准模式) 或 claude-code (Agent 模式,可使用工具)","options.deletionOnly":"仅执行删除代码分析,跳过常规代码审查","options.outputFormat":"输出格式: markdown, terminal, json。不指定则智能选择(PR 用 markdown,终端用 terminal)","options.generateDescription":"使用 AI 生成 PR 功能描述","options.showAll":"显示所有发现的问题,不过滤非变更行的问题","options.flush":"仅刷新状态(同步 reactions、resolved conversations、replies),不执行 LLM 审查","options.eventAction":"PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查","extensionDescription":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","mcp.serverDescription":"代码审查规则查询服务","mcp.listRules":"获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息","mcp.getRulesForFile":"获取某个文件应该使用的代码审查规则,根据文件扩展名和 includes 配置过滤","mcp.getRuleDetail":"获取某个规则的完整详情,包括描述、示例代码等","mcp.ruleNotFound":"规则 {{ruleId}} 不存在","mcp.dto.cwd":"项目根目录路径,默认为当前工作目录","mcp.dto.filePath":"文件路径,可以是相对路径或绝对路径","mcp.dto.includeExamples":"是否包含规则示例代码,默认 false","mcp.dto.ruleId":"规则 ID,如 JsTs.Naming.FileName","mcp.getRulesFromDir":"从指定目录加载代码审查规则。读取目录下所有 .md 文件,解析为规则并按规则 ID 去重(后加载的覆盖先加载的)","mcp.dto.dirPath":"包含规则 .md 文件的目录路径,可以是相对路径或绝对路径"}')
153
+ var review_namespaceObject = JSON.parse('{"description":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","options.dryRun":"仅打印将要执行的操作,不实际提交评论","options.prNumber":"PR 编号,如果不指定则从环境变量获取","options.base":"基准分支/tag,用于比较差异","options.head":"目标分支/tag,用于比较差异","options.includes":"要审查的文件 glob 模式,如 *.ts *.js(可多次指定)","options.llmMode":"使用的 LLM 模式: claude-code, openai, gemini","options.files":"仅审查指定的文件(空格分隔)","options.commits":"仅审查指定的 commits(空格分隔)","options.verifyFixes":"是否验证历史问题是否已修复(默认从配置文件读取)","options.noVerifyFixes":"禁用历史问题验证","options.verifyConcurrency":"验证历史问题的并发数(默认 10)","options.analyzeDeletions":"分析删除代码可能带来的影响 (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"删除代码分析模式: openai (标准模式) 或 claude-code (Agent 模式,可使用工具)","options.deletionOnly":"仅执行删除代码分析,跳过常规代码审查","options.outputFormat":"输出格式: markdown, terminal, json。不指定则智能选择(PR 用 markdown,终端用 terminal)","options.generateDescription":"使用 AI 生成 PR 功能描述","options.showAll":"显示所有发现的问题,不过滤非变更行的问题","options.flush":"仅刷新状态(同步 reactions、resolved conversations、replies),不执行 LLM 审查","options.eventAction":"PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查","options.local":"审查本地未提交的代码。模式: \'uncommitted\'(默认,暂存区+工作区)或 \'staged\'(仅暂存区)","options.noLocal":"禁用本地模式,使用分支比较","extensionDescription":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","mcp.serverDescription":"代码审查规则查询服务","mcp.listRules":"获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息","mcp.getRulesForFile":"获取某个文件应该使用的代码审查规则,根据文件扩展名和 includes 配置过滤","mcp.getRuleDetail":"获取某个规则的完整详情,包括描述、示例代码等","mcp.ruleNotFound":"规则 {{ruleId}} 不存在","mcp.dto.cwd":"项目根目录路径,默认为当前工作目录","mcp.dto.filePath":"文件路径,可以是相对路径或绝对路径","mcp.dto.includeExamples":"是否包含规则示例代码,默认 false","mcp.dto.ruleId":"规则 ID,如 JsTs.Naming.FileName","mcp.getRulesFromDir":"从指定目录加载代码审查规则。读取目录下所有 .md 文件,解析为规则并按规则 ID 去重(后加载的覆盖先加载的)","mcp.dto.dirPath":"包含规则 .md 文件的目录路径,可以是相对路径或绝对路径"}')
154
154
  ;// CONCATENATED MODULE: ./src/locales/en/review.json
155
- var en_review_namespaceObject = JSON.parse('{"description":"Code review command using LLM for automated PR review","options.dryRun":"Only print actions without posting comments","options.prNumber":"PR number, auto-detected from env if not specified","options.base":"Base branch/tag for diff comparison","options.head":"Head branch/tag for diff comparison","options.includes":"File glob patterns to review, e.g. *.ts *.js (can be specified multiple times)","options.llmMode":"LLM mode: claude-code, openai, gemini","options.files":"Only review specified files (space-separated)","options.commits":"Only review specified commits (space-separated)","options.verifyFixes":"Verify if historical issues are fixed (default from config)","options.noVerifyFixes":"Disable historical issue verification","options.verifyConcurrency":"Concurrency for verifying historical issues (default 10)","options.analyzeDeletions":"Analyze impact of deleted code (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"Deletion analysis mode: openai (standard) or claude-code (Agent mode with tools)","options.deletionOnly":"Only run deletion analysis, skip regular code review","options.outputFormat":"Output format: markdown, terminal, json. Auto-selected if not specified (markdown for PR, terminal for CLI)","options.generateDescription":"Generate PR description using AI","options.showAll":"Show all issues found, including those on unchanged lines","options.flush":"Only sync status (reactions, resolved conversations, replies), skip LLM review","options.eventAction":"PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review","extensionDescription":"Code review command using LLM for automated PR review","mcp.serverDescription":"Code review rules query service","mcp.listRules":"List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.","mcp.getRulesForFile":"Get applicable code review rules for a specific file, filtered by file extension and includes configuration","mcp.getRuleDetail":"Get full details of a specific rule, including description, example code, etc.","mcp.ruleNotFound":"Rule {{ruleId}} not found","mcp.dto.cwd":"Project root directory path, defaults to current working directory","mcp.dto.filePath":"File path, can be relative or absolute","mcp.dto.includeExamples":"Whether to include rule example code, defaults to false","mcp.dto.ruleId":"Rule ID, e.g. JsTs.Naming.FileName","mcp.getRulesFromDir":"Load code review rules from a specific directory. Reads all .md files in the directory, parses them into rules, and deduplicates by rule ID (later rules override earlier ones)","mcp.dto.dirPath":"Directory path containing rule .md files, can be relative or absolute"}')
155
+ var en_review_namespaceObject = JSON.parse('{"description":"Code review command using LLM for automated PR review","options.dryRun":"Only print actions without posting comments","options.prNumber":"PR number, auto-detected from env if not specified","options.base":"Base branch/tag for diff comparison","options.head":"Head branch/tag for diff comparison","options.includes":"File glob patterns to review, e.g. *.ts *.js (can be specified multiple times)","options.llmMode":"LLM mode: claude-code, openai, gemini","options.files":"Only review specified files (space-separated)","options.commits":"Only review specified commits (space-separated)","options.verifyFixes":"Verify if historical issues are fixed (default from config)","options.noVerifyFixes":"Disable historical issue verification","options.verifyConcurrency":"Concurrency for verifying historical issues (default 10)","options.analyzeDeletions":"Analyze impact of deleted code (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"Deletion analysis mode: openai (standard) or claude-code (Agent mode with tools)","options.deletionOnly":"Only run deletion analysis, skip regular code review","options.outputFormat":"Output format: markdown, terminal, json. Auto-selected if not specified (markdown for PR, terminal for CLI)","options.generateDescription":"Generate PR description using AI","options.showAll":"Show all issues found, including those on unchanged lines","options.flush":"Only sync status (reactions, resolved conversations, replies), skip LLM review","options.eventAction":"PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review","options.local":"Review local uncommitted code. Mode: \'uncommitted\' (default, staged + working) or \'staged\' (only staged)","options.noLocal":"Disable local mode, use branch comparison instead","extensionDescription":"Code review command using LLM for automated PR review","mcp.serverDescription":"Code review rules query service","mcp.listRules":"List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.","mcp.getRulesForFile":"Get applicable code review rules for a specific file, filtered by file extension and includes configuration","mcp.getRuleDetail":"Get full details of a specific rule, including description, example code, etc.","mcp.ruleNotFound":"Rule {{ruleId}} not found","mcp.dto.cwd":"Project root directory path, defaults to current working directory","mcp.dto.filePath":"File path, can be relative or absolute","mcp.dto.includeExamples":"Whether to include rule example code, defaults to false","mcp.dto.ruleId":"Rule ID, e.g. JsTs.Naming.FileName","mcp.getRulesFromDir":"Load code review rules from a specific directory. Reads all .md files in the directory, parses them into rules, and deduplicates by rule ID (later rules override earlier ones)","mcp.dto.dirPath":"Directory path containing rule .md files, can be relative or absolute"}')
156
156
  ;// CONCATENATED MODULE: ./src/locales/index.ts
157
157
 
158
158
 
@@ -1967,10 +1967,16 @@ class ReviewService {
1967
1967
  if (reviewConf.references?.length) {
1968
1968
  specSources.push(...reviewConf.references);
1969
1969
  }
1970
- // 当没有 PR 且没有指定 base/head 时,自动获取默认值
1970
+ // 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
1971
+ const localMode = this.resolveLocalMode(options, {
1972
+ ci: options.ci,
1973
+ hasPrNumber: !!prNumber,
1974
+ hasBaseHead: !!(options.base || options.head)
1975
+ });
1976
+ // 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
1971
1977
  let baseRef = options.base;
1972
1978
  let headRef = options.head;
1973
- if (!prNumber && !baseRef && !headRef) {
1979
+ if (!prNumber && !baseRef && !headRef && !localMode) {
1974
1980
  headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
1975
1981
  baseRef = this.gitSdk.getDefaultBranch();
1976
1982
  if (shouldLog(options.verbose, 1)) {
@@ -2007,10 +2013,36 @@ class ReviewService {
2007
2013
  generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
2008
2014
  showAll: options.showAll ?? false,
2009
2015
  flush: options.flush ?? false,
2010
- eventAction: options.eventAction
2016
+ eventAction: options.eventAction,
2017
+ localMode
2011
2018
  };
2012
2019
  }
2013
2020
  /**
2021
+ * 解析本地代码审查模式
2022
+ * - 显式指定 --local [mode] 时使用指定值
2023
+ * - 显式指定 --no-local 时禁用
2024
+ * - 非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
2025
+ */ resolveLocalMode(options, env) {
2026
+ // 显式指定了 --no-local
2027
+ if (options.local === false) {
2028
+ return false;
2029
+ }
2030
+ // 显式指定了 --local [mode]
2031
+ if (options.local === "staged" || options.local === "uncommitted") {
2032
+ return options.local;
2033
+ }
2034
+ // CI 或 PR 模式下不启用本地模式
2035
+ if (env.ci || env.hasPrNumber) {
2036
+ return false;
2037
+ }
2038
+ // 指定了 base/head 时不启用本地模式
2039
+ if (env.hasBaseHead) {
2040
+ return false;
2041
+ }
2042
+ // 默认启用 uncommitted 模式
2043
+ return "uncommitted";
2044
+ }
2045
+ /**
2014
2046
  * 将文件路径规范化为相对于仓库根目录的路径
2015
2047
  * 支持绝对路径和相对路径输入
2016
2048
  */ normalizeFilePaths(files) {
@@ -2076,13 +2108,21 @@ class ReviewService {
2076
2108
  * @param context 审查上下文,包含 owner、repo、prNumber 等信息
2077
2109
  * @returns 审查结果,包含发现的问题列表和统计信息
2078
2110
  */ async execute(context) {
2079
- const { owner, repo, prNumber, baseRef, headRef, specSources, dryRun, ci, verbose, includes, llmMode, files, commits: filterCommits, deletionOnly } = context;
2111
+ const { owner, repo, prNumber, baseRef, headRef, specSources, dryRun, ci, verbose, includes, llmMode, files, commits: filterCommits, deletionOnly, localMode } = context;
2080
2112
  // 直接审查文件模式:指定了 -f 文件且 base=head
2081
2113
  const isDirectFileMode = files && files.length > 0 && baseRef === headRef;
2114
+ // 本地模式:审查未提交的代码(可能回退到分支比较)
2115
+ let isLocalMode = !!localMode;
2116
+ // 用于回退时动态计算的 base/head
2117
+ let effectiveBaseRef = baseRef;
2118
+ let effectiveHeadRef = headRef;
2082
2119
  if (shouldLog(verbose, 1)) {
2083
2120
  console.log(`🔍 Review 启动`);
2084
2121
  console.log(` DRY-RUN mode: ${dryRun ? "enabled" : "disabled"}`);
2085
2122
  console.log(` CI mode: ${ci ? "enabled" : "disabled"}`);
2123
+ if (isLocalMode) {
2124
+ console.log(` Local mode: ${localMode}`);
2125
+ }
2086
2126
  console.log(` Verbose: ${verbose}`);
2087
2127
  }
2088
2128
  // 如果是 deletionOnly 模式,直接执行删除代码分析
@@ -2097,6 +2137,52 @@ class ReviewService {
2097
2137
  let pr;
2098
2138
  let commits = [];
2099
2139
  let changedFiles = [];
2140
+ if (isLocalMode) {
2141
+ // 本地模式:从 git 获取未提交/暂存区的变更
2142
+ if (shouldLog(verbose, 1)) {
2143
+ console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
2144
+ }
2145
+ const localFiles = localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
2146
+ if (localFiles.length === 0) {
2147
+ // 本地无变更,回退到分支比较模式
2148
+ if (shouldLog(verbose, 1)) {
2149
+ console.log(`ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`);
2150
+ }
2151
+ isLocalMode = false;
2152
+ effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
2153
+ effectiveBaseRef = this.gitSdk.getDefaultBranch();
2154
+ if (shouldLog(verbose, 1)) {
2155
+ console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
2156
+ }
2157
+ // 同分支无法比较,提前返回
2158
+ if (effectiveBaseRef === effectiveHeadRef) {
2159
+ console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
2160
+ return {
2161
+ success: true,
2162
+ description: "",
2163
+ issues: [],
2164
+ summary: [],
2165
+ round: 1
2166
+ };
2167
+ }
2168
+ } else {
2169
+ // 一次性获取所有 diff,避免每个文件调用一次 git 命令
2170
+ const localDiffs = localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
2171
+ const diffMap = new Map(localDiffs.map((d)=>[
2172
+ d.filename,
2173
+ d.patch
2174
+ ]));
2175
+ changedFiles = localFiles.map((f)=>({
2176
+ filename: f.filename,
2177
+ status: f.status,
2178
+ patch: diffMap.get(f.filename)
2179
+ }));
2180
+ if (shouldLog(verbose, 1)) {
2181
+ console.log(` Changed files: ${changedFiles.length}`);
2182
+ }
2183
+ }
2184
+ }
2185
+ // PR 模式、分支比较模式、或本地模式回退后的分支比较
2100
2186
  if (prNumber) {
2101
2187
  if (shouldLog(verbose, 1)) {
2102
2188
  console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
@@ -2109,9 +2195,9 @@ class ReviewService {
2109
2195
  console.log(` Commits: ${commits.length}`);
2110
2196
  console.log(` Changed files: ${changedFiles.length}`);
2111
2197
  }
2112
- } else if (baseRef && headRef) {
2198
+ } else if (effectiveBaseRef && effectiveHeadRef) {
2113
2199
  // 如果指定了 -f 文件且 base=head(无差异模式),直接审查指定文件
2114
- if (files && files.length > 0 && baseRef === headRef) {
2200
+ if (files && files.length > 0 && effectiveBaseRef === effectiveHeadRef) {
2115
2201
  if (shouldLog(verbose, 1)) {
2116
2202
  console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
2117
2203
  }
@@ -2119,18 +2205,20 @@ class ReviewService {
2119
2205
  filename: f,
2120
2206
  status: "modified"
2121
2207
  }));
2122
- } else {
2208
+ } else if (changedFiles.length === 0) {
2209
+ // 仅当 changedFiles 为空时才获取(避免与回退逻辑重复)
2123
2210
  if (shouldLog(verbose, 1)) {
2124
- console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
2211
+ console.log(`📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`);
2125
2212
  }
2126
- changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
2127
- commits = await this.getCommitsBetweenRefs(baseRef, headRef);
2213
+ changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, effectiveBaseRef, effectiveHeadRef);
2214
+ commits = await this.getCommitsBetweenRefs(effectiveBaseRef, effectiveHeadRef);
2128
2215
  if (shouldLog(verbose, 1)) {
2129
2216
  console.log(` Changed files: ${changedFiles.length}`);
2130
2217
  console.log(` Commits: ${commits.length}`);
2131
2218
  }
2132
2219
  }
2133
- } else {
2220
+ } else if (!isLocalMode) {
2221
+ // 非本地模式且无有效的 base/head
2134
2222
  if (shouldLog(verbose, 1)) {
2135
2223
  console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, {
2136
2224
  prNumber,
@@ -2227,7 +2315,7 @@ class ReviewService {
2227
2315
  };
2228
2316
  }
2229
2317
  const headSha = pr?.head?.sha || headRef || "HEAD";
2230
- const fileContents = await this.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, verbose);
2318
+ const fileContents = await this.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, verbose, isLocalMode);
2231
2319
  if (!llmMode) {
2232
2320
  throw new Error("必须指定 LLM 类型");
2233
2321
  }
@@ -2636,9 +2724,9 @@ class ReviewService {
2636
2724
  /**
2637
2725
  * 获取文件内容并构建行号到 commit hash 的映射
2638
2726
  * 返回 Map<filename, Array<[commitHash, lineCode]>>
2639
- */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, verbose) {
2727
+ */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, verbose, isLocalMode) {
2640
2728
  const contents = new Map();
2641
- const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "-------";
2729
+ const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
2642
2730
  // 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
2643
2731
  // 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
2644
2732
  // buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
@@ -2649,7 +2737,10 @@ class ReviewService {
2649
2737
  if (file.filename && file.status !== "deleted") {
2650
2738
  try {
2651
2739
  let rawContent;
2652
- if (prNumber) {
2740
+ if (isLocalMode) {
2741
+ // 本地模式:读取工作区文件的当前内容
2742
+ rawContent = this.gitSdk.getWorkingFileContent(file.filename);
2743
+ } else if (prNumber) {
2653
2744
  rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
2654
2745
  } else {
2655
2746
  rawContent = await this.gitSdk.getFileContent(ref, file.filename);
@@ -5504,6 +5595,14 @@ const extension = defineExtension({
5504
5595
  {
5505
5596
  flags: "--event-action <action>",
5506
5597
  description: t("review:options.eventAction")
5598
+ },
5599
+ {
5600
+ flags: "--local [mode]",
5601
+ description: t("review:options.local")
5602
+ },
5603
+ {
5604
+ flags: "--no-local",
5605
+ description: t("review:options.noLocal")
5507
5606
  }
5508
5607
  ],
5509
5608
  run: async (_args, options, ctx)=>{
@@ -5543,8 +5642,15 @@ const extension = defineExtension({
5543
5642
  generateDescription: options?.generateDescription ? true : undefined,
5544
5643
  showAll: !!options?.showAll,
5545
5644
  flush: isFlush,
5546
- eventAction: options?.eventAction
5645
+ eventAction: options?.eventAction,
5646
+ local: parseLocalOption(options?.local)
5547
5647
  };
5648
+ function parseLocalOption(value) {
5649
+ if (value === false) return false;
5650
+ if (value === true || value === undefined || value === "") return undefined;
5651
+ if (value === "staged" || value === "uncommitted") return value;
5652
+ return undefined;
5653
+ }
5548
5654
  try {
5549
5655
  const context = await reviewService.getContextFromEnv(reviewOptions);
5550
5656
  await reviewService.execute(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -28,7 +28,7 @@
28
28
  "@spaceflow/cli": "0.38.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@spaceflow/core": "0.23.0"
31
+ "@spaceflow/core": "0.24.0"
32
32
  },
33
33
  "spaceflow": {
34
34
  "type": "flow",
package/src/index.ts CHANGED
@@ -2,7 +2,13 @@ import "./locales";
2
2
  export * from "./review-spec";
3
3
  export * from "./review-report";
4
4
  import { defineExtension, t } from "@spaceflow/core";
5
- import type { GitProviderService, LlmProxyService, GitSdkService, LLMMode } from "@spaceflow/core";
5
+ import type {
6
+ GitProviderService,
7
+ LlmProxyService,
8
+ GitSdkService,
9
+ LLMMode,
10
+ LocalReviewMode,
11
+ } from "@spaceflow/core";
6
12
  import { parseVerbose } from "@spaceflow/core";
7
13
  import { reviewSchema, type AnalyzeDeletionsMode } from "./review.config";
8
14
  import { ReviewService } from "./review.service";
@@ -46,6 +52,8 @@ export const extension = defineExtension({
46
52
  { flags: "--show-all", description: t("review:options.showAll") },
47
53
  { flags: "--flush", description: t("review:options.flush") },
48
54
  { flags: "--event-action <action>", description: t("review:options.eventAction") },
55
+ { flags: "--local [mode]", description: t("review:options.local") },
56
+ { flags: "--no-local", description: t("review:options.noLocal") },
49
57
  ],
50
58
  run: async (_args, options, ctx) => {
51
59
  const isFlush = !!options?.flush;
@@ -104,8 +112,16 @@ export const extension = defineExtension({
104
112
  showAll: !!options?.showAll,
105
113
  flush: isFlush,
106
114
  eventAction: options?.eventAction as string,
115
+ local: parseLocalOption(options?.local),
107
116
  };
108
117
 
118
+ function parseLocalOption(value: unknown): LocalReviewMode | undefined {
119
+ if (value === false) return false;
120
+ if (value === true || value === undefined || value === "") return undefined;
121
+ if (value === "staged" || value === "uncommitted") return value;
122
+ return undefined;
123
+ }
124
+
109
125
  try {
110
126
  const context = await reviewService.getContextFromEnv(reviewOptions);
111
127
  await reviewService.execute(context);
@@ -19,6 +19,8 @@
19
19
  "options.showAll": "Show all issues found, including those on unchanged lines",
20
20
  "options.flush": "Only sync status (reactions, resolved conversations, replies), skip LLM review",
21
21
  "options.eventAction": "PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review",
22
+ "options.local": "Review local uncommitted code. Mode: 'uncommitted' (default, staged + working) or 'staged' (only staged)",
23
+ "options.noLocal": "Disable local mode, use branch comparison instead",
22
24
  "extensionDescription": "Code review command using LLM for automated PR review",
23
25
  "mcp.serverDescription": "Code review rules query service",
24
26
  "mcp.listRules": "List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.",
@@ -19,6 +19,8 @@
19
19
  "options.showAll": "显示所有发现的问题,不过滤非变更行的问题",
20
20
  "options.flush": "仅刷新状态(同步 reactions、resolved conversations、replies),不执行 LLM 审查",
21
21
  "options.eventAction": "PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查",
22
+ "options.local": "审查本地未提交的代码。模式: 'uncommitted'(默认,暂存区+工作区)或 'staged'(仅暂存区)",
23
+ "options.noLocal": "禁用本地模式,使用分支比较",
22
24
  "extensionDescription": "代码审查命令,使用 LLM 对 PR 代码进行自动审查",
23
25
  "mcp.serverDescription": "代码审查规则查询服务",
24
26
  "mcp.listRules": "获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息",
@@ -1,5 +1,5 @@
1
1
  import { z } from "@spaceflow/core";
2
- import type { LLMMode, VerboseLevel } from "@spaceflow/core";
2
+ import type { LLMMode, VerboseLevel, LocalReviewMode } from "@spaceflow/core";
3
3
  import type { ReportFormat } from "./review-report";
4
4
 
5
5
  /** LLM 模式 schema(与 core 中的 LLMMode 保持一致) */
@@ -71,6 +71,14 @@ export interface ReviewOptions {
71
71
  timeout?: number;
72
72
  retries?: number;
73
73
  retryDelay?: number;
74
+ /**
75
+ * 本地代码审查模式
76
+ * - 'uncommitted': 审查所有未提交的代码(暂存区 + 工作区)
77
+ * - 'staged': 仅审查暂存区的代码
78
+ * - false: 禁用本地模式
79
+ * 在非 CI 和非 PR 模式下默认为 'uncommitted'
80
+ */
81
+ local?: LocalReviewMode;
74
82
  }
75
83
 
76
84
  /** review 命令配置 schema(LLM 敏感配置由系统 llm.config.ts 管理) */
@@ -75,6 +75,7 @@ describe("ReviewService", () => {
75
75
  updateIssueComment: vi.fn().mockResolvedValue({}),
76
76
  deleteIssueComment: vi.fn().mockResolvedValue(undefined),
77
77
  updatePullReview: vi.fn().mockResolvedValue({}),
78
+ getTeamMembers: vi.fn().mockResolvedValue([]),
78
79
  };
79
80
 
80
81
  configService = {
@@ -1246,6 +1247,7 @@ describe("ReviewService", () => {
1246
1247
  invalid: 0,
1247
1248
  pending: 0,
1248
1249
  fixRate: 0,
1250
+ resolveRate: 0,
1249
1251
  });
1250
1252
  });
1251
1253
 
@@ -1555,6 +1557,19 @@ describe("ReviewService", () => {
1555
1557
  expect(consoleSpy).toHaveBeenCalled();
1556
1558
  consoleSpy.mockRestore();
1557
1559
  });
1560
+
1561
+ it("should log error when deleting comment fails", async () => {
1562
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1563
+ gitProvider.listPullReviews.mockResolvedValue([] as any);
1564
+ gitProvider.listIssueComments.mockResolvedValue([
1565
+ { id: 10, body: "<!-- spaceflow-review --> old comment" },
1566
+ ] as any);
1567
+ gitProvider.deleteIssueComment.mockRejectedValue(new Error("delete failed"));
1568
+
1569
+ await (service as any).deleteExistingAiReviews("o", "r", 1);
1570
+ expect(consoleSpy).toHaveBeenCalledWith("⚠️ 删除评论 10 失败:", expect.any(Error));
1571
+ consoleSpy.mockRestore();
1572
+ });
1558
1573
  });
1559
1574
 
1560
1575
  describe("ReviewService.invalidateIssuesForChangedFiles", () => {
@@ -2381,11 +2396,48 @@ describe("ReviewService", () => {
2381
2396
  { content: "-1", user: { login: "random-user" } },
2382
2397
  ] as any);
2383
2398
  const result = { issues: [{ file: "test.ts", line: "10", valid: "true", reactions: [] }] };
2384
- await (service as any).syncReactionsToIssues("o", "r", 1, result);
2385
2399
  expect(result.issues[0].valid).toBe("true");
2386
2400
  });
2387
2401
  });
2388
2402
 
2403
+ describe("ReviewService.buildLineReviewBody", () => {
2404
+ it("should include previous round summary when round > 1", () => {
2405
+ const issues = [
2406
+ { round: 2, fixed: "2024-01-01", resolved: undefined, valid: undefined },
2407
+ { round: 2, resolved: "2024-01-02", fixed: undefined, valid: undefined },
2408
+ { round: 2, valid: "false", fixed: undefined, resolved: undefined },
2409
+ { round: 2, fixed: undefined, resolved: undefined, valid: undefined },
2410
+ ];
2411
+ const allIssues = [
2412
+ ...issues,
2413
+ { round: 1, fixed: "2024-01-01" },
2414
+ { round: 1, resolved: "2024-01-02" },
2415
+ { round: 1, valid: "false" },
2416
+ { round: 1 },
2417
+ ];
2418
+ const result = (service as any).buildLineReviewBody(issues, 2, allIssues);
2419
+ expect(result).toContain("Round 1 回顾");
2420
+ expect(result).toContain("🟢 已修复 | 1");
2421
+ expect(result).toContain("⚪ 已解决 | 1");
2422
+ expect(result).toContain("❌ 无效 | 1");
2423
+ expect(result).toContain("⚠️ 待处理 | 1");
2424
+ });
2425
+
2426
+ it("should not include previous round summary when round <= 1", () => {
2427
+ const issues = [{ round: 1 }];
2428
+ const allIssues = [{ round: 1 }];
2429
+ const result = (service as any).buildLineReviewBody(issues, 1, allIssues);
2430
+ expect(result).not.toContain("Round 1 回顾");
2431
+ });
2432
+
2433
+ it("should show no issues message when issues array is empty", () => {
2434
+ const issues = [];
2435
+ const allIssues = [];
2436
+ const result = (service as any).buildLineReviewBody(issues, 1, allIssues);
2437
+ expect(result).toContain("✅ 未发现新问题");
2438
+ });
2439
+ });
2440
+
2389
2441
  describe("ReviewService.buildReviewPrompt", () => {
2390
2442
  it("should build prompts for changed files", async () => {
2391
2443
  const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
@@ -3226,4 +3278,450 @@ describe("ReviewService", () => {
3226
3278
  expect(result.size).toBe(0);
3227
3279
  });
3228
3280
  });
3281
+
3282
+ describe("ReviewService.invalidateIssuesForChangedFiles", () => {
3283
+ it("should return issues unchanged when no headSha", async () => {
3284
+ const issues = [{ file: "test.ts" }];
3285
+ const result = await (service as any).invalidateIssuesForChangedFiles(
3286
+ issues,
3287
+ undefined,
3288
+ "o",
3289
+ "r",
3290
+ );
3291
+ expect(result).toBe(issues);
3292
+ });
3293
+
3294
+ it("should log warning when no headSha", async () => {
3295
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3296
+ const issues = [{ file: "test.ts" }];
3297
+ await (service as any).invalidateIssuesForChangedFiles(issues, undefined, "o", "r", 1);
3298
+ expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 无法获取 PR head SHA,跳过变更文件检查");
3299
+ consoleSpy.mockRestore();
3300
+ });
3301
+
3302
+ it("should invalidate issues for changed files", async () => {
3303
+ gitProvider.getCommitDiff = vi
3304
+ .fn()
3305
+ .mockResolvedValue(
3306
+ "diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
3307
+ ) as any;
3308
+ const issues = [
3309
+ { file: "changed.ts", line: "1", ruleId: "R1" },
3310
+ { file: "unchanged.ts", line: "2", ruleId: "R2" },
3311
+ { file: "changed.ts", line: "3", ruleId: "R3", fixed: "2024-01-01" },
3312
+ ];
3313
+ const result = await (service as any).invalidateIssuesForChangedFiles(
3314
+ issues,
3315
+ "abc123",
3316
+ "o",
3317
+ "r",
3318
+ 1,
3319
+ );
3320
+ expect(result).toHaveLength(3);
3321
+ expect(result[0].valid).toBe("false");
3322
+ expect(result[1].valid).toBeUndefined();
3323
+ expect(result[2].fixed).toBe("2024-01-01");
3324
+ });
3325
+
3326
+ it("should log when files are invalidated", async () => {
3327
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3328
+ gitProvider.getCommitDiff = vi
3329
+ .fn()
3330
+ .mockResolvedValue(
3331
+ "diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
3332
+ ) as any;
3333
+ const issues = [{ file: "changed.ts", line: "1", ruleId: "R1" }];
3334
+ await (service as any).invalidateIssuesForChangedFiles(issues, "abc123", "o", "r", 1);
3335
+ expect(consoleSpy).toHaveBeenCalledWith(
3336
+ " 🗑️ Issue changed.ts:1 所在文件有变更,标记为无效",
3337
+ );
3338
+ expect(consoleSpy).toHaveBeenCalledWith(" 📊 共标记 1 个历史问题为无效(文件有变更)");
3339
+ consoleSpy.mockRestore();
3340
+ });
3341
+
3342
+ it("should return issues unchanged when no diff files", async () => {
3343
+ gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
3344
+ const issues = [{ file: "test.ts", line: "1" }];
3345
+ const result = await (service as any).invalidateIssuesForChangedFiles(
3346
+ issues,
3347
+ "abc123",
3348
+ "o",
3349
+ "r",
3350
+ 1,
3351
+ );
3352
+ expect(result).toBe(issues);
3353
+ });
3354
+
3355
+ it("should log when no diff files", async () => {
3356
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3357
+ gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
3358
+ const issues = [{ file: "test.ts", line: "1" }];
3359
+ await (service as any).invalidateIssuesForChangedFiles(issues, "abc123", "o", "r", 1);
3360
+ expect(consoleSpy).toHaveBeenCalledWith(" ⏭️ 最新 commit 无文件变更");
3361
+ consoleSpy.mockRestore();
3362
+ });
3363
+
3364
+ it("should handle API error gracefully", async () => {
3365
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3366
+ gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
3367
+ const issues = [{ file: "test.ts", line: "1" }];
3368
+ const result = await (service as any).invalidateIssuesForChangedFiles(
3369
+ issues,
3370
+ "abc123",
3371
+ "o",
3372
+ "r",
3373
+ 1,
3374
+ );
3375
+ expect(result).toBe(issues);
3376
+ expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 获取最新 commit 变更文件失败: Error: fail");
3377
+ consoleSpy.mockRestore();
3378
+ });
3379
+ });
3380
+
3381
+ describe("ReviewService.updateIssueLineNumbers", () => {
3382
+ beforeEach(() => {
3383
+ mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
3384
+ const lines: number[] = [];
3385
+ const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
3386
+ if (rangeMatch) {
3387
+ const start = parseInt(rangeMatch[1], 10);
3388
+ const end = parseInt(rangeMatch[2], 10);
3389
+ for (let i = start; i <= end; i++) {
3390
+ lines.push(i);
3391
+ }
3392
+ } else {
3393
+ const line = parseInt(lineStr, 10);
3394
+ if (!isNaN(line)) {
3395
+ lines.push(line);
3396
+ }
3397
+ }
3398
+ return lines;
3399
+ });
3400
+ });
3401
+
3402
+ it("should return issues unchanged when no patch for file", () => {
3403
+ const issues = [{ file: "test.ts", line: "5", ruleId: "R1" }];
3404
+ const filePatchMap = new Map([["other.ts", "@@ -1,1 +1,2 @@\n-old1\n+new1\n+new2"]]);
3405
+ const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
3406
+ expect(result).toEqual(issues);
3407
+ });
3408
+
3409
+ it("should skip issues that are already fixed/resolved/invalid", () => {
3410
+ const issues = [
3411
+ { file: "test.ts", line: "5", ruleId: "R1", fixed: "2024-01-01" },
3412
+ { file: "test.ts", line: "6", ruleId: "R2", resolved: "2024-01-02" },
3413
+ { file: "test.ts", line: "7", ruleId: "R3", valid: "false" },
3414
+ ];
3415
+ const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,2 @@\n-old1\n+new1\n+new2"]]);
3416
+ const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
3417
+ expect(result).toEqual(issues);
3418
+ });
3419
+
3420
+ it("should mark issue as invalid when line is deleted", () => {
3421
+ const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,0 @@\n-old1"]]);
3422
+ const issues = [{ file: "test.ts", line: "1", ruleId: "R1" }];
3423
+ const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
3424
+ expect(result[0].valid).toBe("false");
3425
+ expect(result[0].originalLine).toBe("1");
3426
+ });
3427
+
3428
+ it("should log when line is deleted and marked invalid", () => {
3429
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3430
+ const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,0 @@\n-old1"]]);
3431
+ const issues = [{ file: "test.ts", line: "1", ruleId: "R1" }];
3432
+ (service as any).updateIssueLineNumbers(issues, filePatchMap, 1);
3433
+ expect(consoleSpy).toHaveBeenCalledWith("📍 Issue test.ts:1 对应的代码已被删除,标记为无效");
3434
+ consoleSpy.mockRestore();
3435
+ });
3436
+
3437
+ it("should log when line range is collapsed to single line", () => {
3438
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3439
+ const filePatchMap = new Map([["test.ts", "@@ -1,2 +1,1 @@\n-old1\n-old2\n+new1"]]);
3440
+ const issues = [{ file: "test.ts", line: "1-2", ruleId: "R1" }];
3441
+ (service as any).updateIssueLineNumbers(issues, filePatchMap, 1);
3442
+ expect(consoleSpy).toHaveBeenCalledWith("📍 Issue 行号更新: test.ts:1-2 -> test.ts:1");
3443
+ consoleSpy.mockRestore();
3444
+ });
3445
+ });
3446
+
3447
+ describe("ReviewService.findExistingAiComments", () => {
3448
+ it("should log comments when verbose level >= 2", async () => {
3449
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3450
+ const mockComments = [
3451
+ { id: 1, body: "test comment 1" },
3452
+ { id: 2, body: "test comment 2<!-- spaceflow-review -->" },
3453
+ ] as any;
3454
+ gitProvider.listIssueComments.mockResolvedValue(mockComments);
3455
+
3456
+ await (service as any).findExistingAiComments("o", "r", 1, 2);
3457
+ expect(consoleSpy).toHaveBeenCalledWith(
3458
+ "[findExistingAiComments] listIssueComments returned 2 comments",
3459
+ );
3460
+ expect(consoleSpy).toHaveBeenCalledWith(
3461
+ "[findExistingAiComments] comment id=1, body starts with: test comment 1",
3462
+ );
3463
+ consoleSpy.mockRestore();
3464
+ });
3465
+
3466
+ it("should log error when API fails", async () => {
3467
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
3468
+ gitProvider.listIssueComments.mockRejectedValue(new Error("API error"));
3469
+
3470
+ const result = await (service as any).findExistingAiComments("o", "r", 1);
3471
+ expect(result).toEqual([]);
3472
+ expect(consoleSpy).toHaveBeenCalledWith("[findExistingAiComments] error:", expect.any(Error));
3473
+ consoleSpy.mockRestore();
3474
+ });
3475
+ });
3476
+
3477
+ describe("ReviewService.syncReactionsToIssues", () => {
3478
+ it("should log when no AI review found", async () => {
3479
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3480
+ gitProvider.listPullReviews.mockResolvedValue([] as any);
3481
+
3482
+ await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
3483
+ expect(consoleSpy).toHaveBeenCalledWith("[syncReactionsToIssues] No AI review found");
3484
+ consoleSpy.mockRestore();
3485
+ });
3486
+
3487
+ it("should log reviewers from reviews", async () => {
3488
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3489
+ const mockReviews = [
3490
+ { user: { login: "user1" }, body: "normal review" },
3491
+ { user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
3492
+ ] as any;
3493
+ gitProvider.listPullReviews.mockResolvedValue(mockReviews);
3494
+ gitProvider.listPullReviewComments.mockResolvedValue([] as any);
3495
+
3496
+ await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
3497
+ expect(consoleSpy).toHaveBeenCalledWith(
3498
+ "[syncReactionsToIssues] reviewers from reviews: user1",
3499
+ );
3500
+ consoleSpy.mockRestore();
3501
+ });
3502
+
3503
+ it("should log requested reviewers and teams", async () => {
3504
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3505
+ const mockReviews = [
3506
+ { user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
3507
+ ] as any;
3508
+ const mockPr = {
3509
+ requested_reviewers: [{ login: "reviewer1" }],
3510
+ requested_reviewers_teams: [{ name: "team1", id: 123 }],
3511
+ } as any;
3512
+ gitProvider.listPullReviews.mockResolvedValue(mockReviews);
3513
+ gitProvider.getPullRequest.mockResolvedValue(mockPr);
3514
+ gitProvider.getTeamMembers.mockResolvedValue([{ login: "teamuser1" }]);
3515
+ gitProvider.listPullReviewComments.mockResolvedValue([] as any);
3516
+
3517
+ await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
3518
+ expect(consoleSpy).toHaveBeenCalledWith(
3519
+ "[syncReactionsToIssues] requested_reviewers: reviewer1",
3520
+ );
3521
+ expect(consoleSpy).toHaveBeenCalledWith(
3522
+ '[syncReactionsToIssues] requested_reviewers_teams: [{"name":"team1","id":123}]',
3523
+ );
3524
+ expect(consoleSpy).toHaveBeenCalledWith(
3525
+ "[syncReactionsToIssues] team team1(123) members: teamuser1",
3526
+ );
3527
+ consoleSpy.mockRestore();
3528
+ });
3529
+
3530
+ it("should log final reviewers", async () => {
3531
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3532
+ const mockReviews = [
3533
+ { user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
3534
+ ] as any;
3535
+ gitProvider.listPullReviews.mockResolvedValue(mockReviews);
3536
+ gitProvider.getPullRequest.mockRejectedValue(new Error("PR not found"));
3537
+ gitProvider.listPullReviewComments.mockResolvedValue([] as any);
3538
+
3539
+ await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
3540
+ expect(consoleSpy).toHaveBeenCalledWith("[syncReactionsToIssues] final reviewers: ");
3541
+ consoleSpy.mockRestore();
3542
+ });
3543
+ });
3544
+
3545
+ describe("ReviewService.deleteExistingAiReviews", () => {
3546
+ beforeEach(() => {
3547
+ mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
3548
+ const lines: number[] = [];
3549
+ const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
3550
+ if (rangeMatch) {
3551
+ const start = parseInt(rangeMatch[1], 10);
3552
+ const end = parseInt(rangeMatch[2], 10);
3553
+ for (let i = start; i <= end; i++) {
3554
+ lines.push(i);
3555
+ }
3556
+ } else {
3557
+ const line = parseInt(lineStr, 10);
3558
+ if (!isNaN(line)) {
3559
+ lines.push(line);
3560
+ }
3561
+ }
3562
+ return lines;
3563
+ });
3564
+ });
3565
+
3566
+ it("should filter issues by valid commit hashes", () => {
3567
+ const commits = [{ sha: "abc1234567890" }];
3568
+ const fileContents = new Map([
3569
+ [
3570
+ "test.ts",
3571
+ [
3572
+ ["-------", "line1"],
3573
+ ["abc1234", "line2"],
3574
+ ["-------", "line3"],
3575
+ ],
3576
+ ],
3577
+ ]);
3578
+ const issues = [
3579
+ { file: "test.ts", line: "2", ruleId: "R1" }, // 应该保留,hash匹配
3580
+ { file: "test.ts", line: "1", ruleId: "R2" }, // 应该过滤,hash不匹配
3581
+ { file: "test.ts", line: "3", ruleId: "R3" }, // 应该过滤,hash不匹配
3582
+ ];
3583
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 2);
3584
+ expect(result).toHaveLength(1);
3585
+ expect(result[0].ruleId).toBe("R1");
3586
+ });
3587
+
3588
+ it("should log filtering summary", () => {
3589
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3590
+ const commits = [{ sha: "abc1234567890" }];
3591
+ const fileContents = new Map([
3592
+ [
3593
+ "test.ts",
3594
+ [
3595
+ ["-------", "line1"],
3596
+ ["abc1234", "line2"],
3597
+ ],
3598
+ ],
3599
+ ]);
3600
+ const issues = [
3601
+ { file: "test.ts", line: "1", ruleId: "R1" },
3602
+ { file: "test.ts", line: "2", ruleId: "R2" },
3603
+ ];
3604
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 1);
3605
+ expect(consoleSpy).toHaveBeenCalledWith(" 过滤非本次 PR commits 问题后: 2 -> 1 个问题");
3606
+ consoleSpy.mockRestore();
3607
+ });
3608
+
3609
+ it("should keep issues when file not in fileContents", () => {
3610
+ const commits = [{ sha: "abc1234567890" }];
3611
+ const fileContents = new Map();
3612
+ const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
3613
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3614
+ expect(result).toEqual(issues);
3615
+ });
3616
+
3617
+ it("should keep issues when line range cannot be parsed", () => {
3618
+ const commits = [{ sha: "abc1234567890" }];
3619
+ const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
3620
+ const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
3621
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3622
+ expect(result).toEqual(issues);
3623
+ });
3624
+
3625
+ it("should handle range line numbers", () => {
3626
+ const commits = [{ sha: "abc1234567890" }];
3627
+ const fileContents = new Map([
3628
+ [
3629
+ "test.ts",
3630
+ [
3631
+ ["-------", "line1"],
3632
+ ["abc1234", "line2"],
3633
+ ["-------", "line3"],
3634
+ ],
3635
+ ],
3636
+ ]);
3637
+ const issues = [{ file: "test.ts", line: "1-3", ruleId: "R1" }];
3638
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3639
+ expect(result).toHaveLength(1); // 只要范围内有一行匹配就保留
3640
+ });
3641
+
3642
+ it("should log when file not in fileContents at verbose level 3", () => {
3643
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3644
+ const commits = [{ sha: "abc1234567890" }];
3645
+ const fileContents = new Map();
3646
+ const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
3647
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3648
+ expect(consoleSpy).toHaveBeenCalledWith(
3649
+ " ✅ Issue missing.ts:1 - 文件不在 fileContents 中,保留",
3650
+ );
3651
+ consoleSpy.mockRestore();
3652
+ });
3653
+
3654
+ it("should log when line range cannot be parsed at verbose level 3", () => {
3655
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3656
+ const commits = [{ sha: "abc1234567890" }];
3657
+ const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
3658
+ const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
3659
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3660
+ expect(consoleSpy).toHaveBeenCalledWith(" ✅ Issue test.ts:abc - 无法解析行号,保留");
3661
+ consoleSpy.mockRestore();
3662
+ });
3663
+
3664
+ it("should log detailed hash matching at verbose level 3", () => {
3665
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3666
+ const commits = [{ sha: "abc1234567890" }];
3667
+ const fileContents = new Map([
3668
+ [
3669
+ "test.ts",
3670
+ [
3671
+ ["-------", "line1"],
3672
+ ["abc1234", "line2"],
3673
+ ],
3674
+ ],
3675
+ ]);
3676
+ const issues = [{ file: "test.ts", line: "2", ruleId: "R1" }];
3677
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3678
+ expect(consoleSpy).toHaveBeenCalledWith(" 🔍 有效 commit hashes: abc1234");
3679
+ expect(consoleSpy).toHaveBeenCalledWith(
3680
+ " ✅ Issue test.ts:2 - 行 2 hash=abc1234 匹配,保留",
3681
+ );
3682
+ consoleSpy.mockRestore();
3683
+ });
3684
+ });
3685
+
3686
+ describe("ReviewService.ensureClaudeCli", () => {
3687
+ it("should do nothing when claude is already installed", async () => {
3688
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3689
+ // execSync is already mocked globally
3690
+
3691
+ await (service as any).ensureClaudeCli();
3692
+ expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
3693
+ consoleSpy.mockRestore();
3694
+ });
3695
+
3696
+ it("should install claude when not found", async () => {
3697
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3698
+ // Mock execSync to throw then succeed
3699
+ const execSyncMock = vi.mocked(await import("child_process"));
3700
+ execSyncMock.execSync
3701
+ .mockImplementationOnce(() => {
3702
+ throw new Error("command not found");
3703
+ })
3704
+ .mockImplementationOnce(() => Buffer.from(""));
3705
+
3706
+ await (service as any).ensureClaudeCli();
3707
+ expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
3708
+ expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
3709
+ consoleSpy.mockRestore();
3710
+ });
3711
+
3712
+ it("should throw error when installation fails", async () => {
3713
+ const execSyncMock = vi.mocked(await import("child_process"));
3714
+ execSyncMock.execSync
3715
+ .mockImplementationOnce(() => {
3716
+ throw new Error("command not found");
3717
+ })
3718
+ .mockImplementationOnce(() => {
3719
+ throw new Error("install failed");
3720
+ });
3721
+
3722
+ await expect((service as any).ensureClaudeCli()).rejects.toThrow(
3723
+ "Claude CLI 安装失败: install failed",
3724
+ );
3725
+ });
3726
+ });
3229
3727
  });
@@ -22,7 +22,7 @@ import {
22
22
  parseHunksFromPatch,
23
23
  calculateNewLineNumber,
24
24
  } from "@spaceflow/core";
25
- import type { IConfigReader } from "@spaceflow/core";
25
+ import type { IConfigReader, LocalReviewMode } from "@spaceflow/core";
26
26
  import { type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
27
27
  import {
28
28
  ReviewSpecService,
@@ -73,6 +73,13 @@ export interface ReviewContext extends ReviewOptions {
73
73
  showAll?: boolean;
74
74
  /** PR 事件类型(opened, synchronize, closed 等) */
75
75
  eventAction?: string;
76
+ /**
77
+ * 本地代码审查模式(已解析)
78
+ * - 'uncommitted': 审查所有未提交的代码(暂存区 + 工作区)
79
+ * - 'staged': 仅审查暂存区的代码
80
+ * - false: 禁用本地模式
81
+ */
82
+ localMode?: LocalReviewMode;
76
83
  }
77
84
 
78
85
  export interface FileReviewPrompt {
@@ -239,10 +246,17 @@ export class ReviewService {
239
246
  specSources.push(...reviewConf.references);
240
247
  }
241
248
 
242
- // 当没有 PR 且没有指定 base/head 时,自动获取默认值
249
+ // 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
250
+ const localMode = this.resolveLocalMode(options, {
251
+ ci: options.ci,
252
+ hasPrNumber: !!prNumber,
253
+ hasBaseHead: !!(options.base || options.head),
254
+ });
255
+
256
+ // 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
243
257
  let baseRef = options.base;
244
258
  let headRef = options.head;
245
- if (!prNumber && !baseRef && !headRef) {
259
+ if (!prNumber && !baseRef && !headRef && !localMode) {
246
260
  headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
247
261
  baseRef = this.gitSdk.getDefaultBranch();
248
262
  if (shouldLog(options.verbose, 1)) {
@@ -291,9 +305,40 @@ export class ReviewService {
291
305
  showAll: options.showAll ?? false,
292
306
  flush: options.flush ?? false,
293
307
  eventAction: options.eventAction,
308
+ localMode,
294
309
  };
295
310
  }
296
311
 
312
+ /**
313
+ * 解析本地代码审查模式
314
+ * - 显式指定 --local [mode] 时使用指定值
315
+ * - 显式指定 --no-local 时禁用
316
+ * - 非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
317
+ */
318
+ protected resolveLocalMode(
319
+ options: ReviewOptions,
320
+ env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean },
321
+ ): "uncommitted" | "staged" | false {
322
+ // 显式指定了 --no-local
323
+ if (options.local === false) {
324
+ return false;
325
+ }
326
+ // 显式指定了 --local [mode]
327
+ if (options.local === "staged" || options.local === "uncommitted") {
328
+ return options.local;
329
+ }
330
+ // CI 或 PR 模式下不启用本地模式
331
+ if (env.ci || env.hasPrNumber) {
332
+ return false;
333
+ }
334
+ // 指定了 base/head 时不启用本地模式
335
+ if (env.hasBaseHead) {
336
+ return false;
337
+ }
338
+ // 默认启用 uncommitted 模式
339
+ return "uncommitted";
340
+ }
341
+
297
342
  /**
298
343
  * 将文件路径规范化为相对于仓库根目录的路径
299
344
  * 支持绝对路径和相对路径输入
@@ -387,15 +432,24 @@ export class ReviewService {
387
432
  files,
388
433
  commits: filterCommits,
389
434
  deletionOnly,
435
+ localMode,
390
436
  } = context;
391
437
 
392
438
  // 直接审查文件模式:指定了 -f 文件且 base=head
393
439
  const isDirectFileMode = files && files.length > 0 && baseRef === headRef;
440
+ // 本地模式:审查未提交的代码(可能回退到分支比较)
441
+ let isLocalMode = !!localMode;
442
+ // 用于回退时动态计算的 base/head
443
+ let effectiveBaseRef = baseRef;
444
+ let effectiveHeadRef = headRef;
394
445
 
395
446
  if (shouldLog(verbose, 1)) {
396
447
  console.log(`🔍 Review 启动`);
397
448
  console.log(` DRY-RUN mode: ${dryRun ? "enabled" : "disabled"}`);
398
449
  console.log(` CI mode: ${ci ? "enabled" : "disabled"}`);
450
+ if (isLocalMode) {
451
+ console.log(` Local mode: ${localMode}`);
452
+ }
399
453
  console.log(` Verbose: ${verbose}`);
400
454
  }
401
455
 
@@ -415,6 +469,57 @@ export class ReviewService {
415
469
  let commits: PullRequestCommit[] = [];
416
470
  let changedFiles: ChangedFile[] = [];
417
471
 
472
+ if (isLocalMode) {
473
+ // 本地模式:从 git 获取未提交/暂存区的变更
474
+ if (shouldLog(verbose, 1)) {
475
+ console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
476
+ }
477
+ const localFiles =
478
+ localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
479
+
480
+ if (localFiles.length === 0) {
481
+ // 本地无变更,回退到分支比较模式
482
+ if (shouldLog(verbose, 1)) {
483
+ console.log(
484
+ `ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`,
485
+ );
486
+ }
487
+ isLocalMode = false;
488
+ effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
489
+ effectiveBaseRef = this.gitSdk.getDefaultBranch();
490
+ if (shouldLog(verbose, 1)) {
491
+ console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
492
+ }
493
+ // 同分支无法比较,提前返回
494
+ if (effectiveBaseRef === effectiveHeadRef) {
495
+ console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
496
+ return {
497
+ success: true,
498
+ description: "",
499
+ issues: [],
500
+ summary: [],
501
+ round: 1,
502
+ };
503
+ }
504
+ } else {
505
+ // 一次性获取所有 diff,避免每个文件调用一次 git 命令
506
+ const localDiffs =
507
+ localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
508
+ const diffMap = new Map(localDiffs.map((d) => [d.filename, d.patch]));
509
+
510
+ changedFiles = localFiles.map((f) => ({
511
+ filename: f.filename,
512
+ status: f.status as ChangedFile["status"],
513
+ patch: diffMap.get(f.filename),
514
+ }));
515
+
516
+ if (shouldLog(verbose, 1)) {
517
+ console.log(` Changed files: ${changedFiles.length}`);
518
+ }
519
+ }
520
+ }
521
+
522
+ // PR 模式、分支比较模式、或本地模式回退后的分支比较
418
523
  if (prNumber) {
419
524
  if (shouldLog(verbose, 1)) {
420
525
  console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
@@ -427,25 +532,34 @@ export class ReviewService {
427
532
  console.log(` Commits: ${commits.length}`);
428
533
  console.log(` Changed files: ${changedFiles.length}`);
429
534
  }
430
- } else if (baseRef && headRef) {
535
+ } else if (effectiveBaseRef && effectiveHeadRef) {
431
536
  // 如果指定了 -f 文件且 base=head(无差异模式),直接审查指定文件
432
- if (files && files.length > 0 && baseRef === headRef) {
537
+ if (files && files.length > 0 && effectiveBaseRef === effectiveHeadRef) {
433
538
  if (shouldLog(verbose, 1)) {
434
539
  console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
435
540
  }
436
541
  changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
437
- } else {
542
+ } else if (changedFiles.length === 0) {
543
+ // 仅当 changedFiles 为空时才获取(避免与回退逻辑重复)
438
544
  if (shouldLog(verbose, 1)) {
439
- console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
545
+ console.log(
546
+ `📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
547
+ );
440
548
  }
441
- changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
442
- commits = await this.getCommitsBetweenRefs(baseRef, headRef);
549
+ changedFiles = await this.getChangedFilesBetweenRefs(
550
+ owner,
551
+ repo,
552
+ effectiveBaseRef,
553
+ effectiveHeadRef,
554
+ );
555
+ commits = await this.getCommitsBetweenRefs(effectiveBaseRef, effectiveHeadRef);
443
556
  if (shouldLog(verbose, 1)) {
444
557
  console.log(` Changed files: ${changedFiles.length}`);
445
558
  console.log(` Commits: ${commits.length}`);
446
559
  }
447
560
  }
448
- } else {
561
+ } else if (!isLocalMode) {
562
+ // 非本地模式且无有效的 base/head
449
563
  if (shouldLog(verbose, 1)) {
450
564
  console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, { prNumber, baseRef, headRef });
451
565
  }
@@ -560,6 +674,7 @@ export class ReviewService {
560
674
  headSha,
561
675
  prNumber,
562
676
  verbose,
677
+ isLocalMode,
563
678
  );
564
679
  if (!llmMode) {
565
680
  throw new Error("必须指定 LLM 类型");
@@ -1130,9 +1245,10 @@ export class ReviewService {
1130
1245
  ref: string,
1131
1246
  prNumber?: number,
1132
1247
  verbose?: VerboseLevel,
1248
+ isLocalMode?: boolean,
1133
1249
  ): Promise<FileContentsMap> {
1134
1250
  const contents: FileContentsMap = new Map();
1135
- const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "-------";
1251
+ const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
1136
1252
 
1137
1253
  // 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
1138
1254
  // 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
@@ -1145,7 +1261,10 @@ export class ReviewService {
1145
1261
  if (file.filename && file.status !== "deleted") {
1146
1262
  try {
1147
1263
  let rawContent: string;
1148
- if (prNumber) {
1264
+ if (isLocalMode) {
1265
+ // 本地模式:读取工作区文件的当前内容
1266
+ rawContent = this.gitSdk.getWorkingFileContent(file.filename);
1267
+ } else if (prNumber) {
1149
1268
  rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
1150
1269
  } else {
1151
1270
  rawContent = await this.gitSdk.getFileContent(ref, file.filename);