@spaceflow/review 0.80.0 → 0.82.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 +27 -0
- package/dist/index.js +1048 -762
- package/package.json +3 -3
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-context.ts +4 -2
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +21 -280
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +94 -12
- package/src/review-spec/review-spec.service.ts +289 -59
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { LlmJsonPut, REVIEW_STATE, addLocaleResources,
|
|
2
|
-
import { access, mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
1
|
+
import { LlmJsonPut, REVIEW_STATE, addLocaleResources, createStreamLoggerState, defineExtension, logStreamEvent, normalizeVerbose, parallel, parseChangedLinesFromPatch, parseDiffText, parseRepoUrl, parseVerbose, shouldLog, t, z } from "@spaceflow/core";
|
|
2
|
+
import { access, mkdir, readFile, readdir, unlink, writeFile } from "fs/promises";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, normalize, relative } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
|
-
import { execSync, spawn } from "child_process";
|
|
5
|
+
import { execFileSync, execSync, spawn } from "child_process";
|
|
6
6
|
import micromatch_0 from "micromatch";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
8
|
var __webpack_modules__ = ({});
|
|
@@ -332,12 +332,19 @@ const CODE_BLOCK_TYPES = [
|
|
|
332
332
|
|
|
333
333
|
|
|
334
334
|
|
|
335
|
-
/** 远程规则缓存 TTL(毫秒),默认 5 分钟 */ const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
|
|
336
335
|
class ReviewSpecService {
|
|
337
336
|
gitProvider;
|
|
338
337
|
constructor(gitProvider){
|
|
339
338
|
this.gitProvider = gitProvider;
|
|
340
339
|
}
|
|
340
|
+
normalizeServerUrl(url) {
|
|
341
|
+
return url.trim().replace(/\/+$/, "");
|
|
342
|
+
}
|
|
343
|
+
logVerbose(verbose, level, message) {
|
|
344
|
+
if (shouldLog(verbose, level)) {
|
|
345
|
+
console.log(message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
341
348
|
/**
|
|
342
349
|
* 检查规则 ID 是否匹配(精确匹配或前缀匹配)
|
|
343
350
|
* 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
|
|
@@ -371,15 +378,7 @@ class ReviewSpecService {
|
|
|
371
378
|
* 根据变更文件的扩展名过滤适用的规则文件
|
|
372
379
|
* 只按扩展名过滤,includes 和 override 在 LLM 审查后处理
|
|
373
380
|
*/ filterApplicableSpecs(specs, changedFiles) {
|
|
374
|
-
const changedExtensions =
|
|
375
|
-
for (const file of changedFiles){
|
|
376
|
-
if (file.filename) {
|
|
377
|
-
const ext = extname(file.filename).slice(1).toLowerCase();
|
|
378
|
-
if (ext) {
|
|
379
|
-
changedExtensions.add(ext);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
381
|
+
const changedExtensions = changedFiles.extensions();
|
|
383
382
|
console.log(`[filterApplicableSpecs] changedExtensions=${JSON.stringify([
|
|
384
383
|
...changedExtensions
|
|
385
384
|
])}, specs count=${specs.length}`);
|
|
@@ -414,54 +413,219 @@ class ReviewSpecService {
|
|
|
414
413
|
}
|
|
415
414
|
return specs;
|
|
416
415
|
}
|
|
417
|
-
async resolveSpecSources(sources) {
|
|
416
|
+
async resolveSpecSources(sources, verbose) {
|
|
418
417
|
const dirs = [];
|
|
419
418
|
for (const source of sources){
|
|
420
|
-
|
|
419
|
+
this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
|
|
421
420
|
const repoRef = parseRepoUrl(source);
|
|
421
|
+
if (repoRef) {
|
|
422
|
+
this.logVerbose(verbose, 3, ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`);
|
|
423
|
+
} else {
|
|
424
|
+
this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
|
|
425
|
+
}
|
|
422
426
|
if (repoRef && this.gitProvider) {
|
|
423
|
-
|
|
427
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
|
|
428
|
+
const dir = await this.fetchRemoteSpecs(repoRef, verbose);
|
|
424
429
|
if (dir) {
|
|
425
430
|
dirs.push(dir);
|
|
431
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
|
|
435
|
+
}
|
|
436
|
+
if (repoRef) {
|
|
437
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
|
|
438
|
+
const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
|
|
439
|
+
if (teaDir) {
|
|
440
|
+
dirs.push(teaDir);
|
|
441
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
|
|
426
442
|
continue;
|
|
427
443
|
}
|
|
444
|
+
this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
|
|
445
|
+
}
|
|
446
|
+
// API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
|
|
447
|
+
if (repoRef) {
|
|
448
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
|
|
449
|
+
const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
|
|
450
|
+
this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
|
|
451
|
+
const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
|
|
452
|
+
if (fallbackDir) {
|
|
453
|
+
dirs.push(fallbackDir);
|
|
454
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
|
|
428
458
|
}
|
|
429
459
|
if (this.isRepoUrl(source)) {
|
|
430
|
-
|
|
460
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
|
|
461
|
+
const dir = await this.cloneSpecRepo(source, undefined, verbose);
|
|
431
462
|
if (dir) {
|
|
432
463
|
dirs.push(dir);
|
|
464
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
|
|
465
|
+
} else {
|
|
466
|
+
this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
|
|
433
467
|
}
|
|
434
468
|
} else {
|
|
435
469
|
// 检查是否是 deps 目录,如果是则扫描子目录的 references
|
|
436
470
|
const resolvedDirs = await this.resolveDepsDir(source);
|
|
437
471
|
dirs.push(...resolvedDirs);
|
|
472
|
+
this.logVerbose(verbose, 3, ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`);
|
|
438
473
|
}
|
|
439
474
|
}
|
|
440
475
|
return dirs;
|
|
441
476
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
477
|
+
buildRemoteSpecDir(ref) {
|
|
478
|
+
const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
|
|
479
|
+
return join(homedir(), ".spaceflow", "review-spec", dirKey);
|
|
480
|
+
}
|
|
481
|
+
async getLocalSpecsDir(dir) {
|
|
482
|
+
try {
|
|
483
|
+
const entries = await readdir(dir);
|
|
484
|
+
if (!entries.some((f)=>f.endsWith(".md"))) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
return dir;
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async prepareRemoteSpecDirForWrite(dir) {
|
|
493
|
+
await mkdir(dir, {
|
|
494
|
+
recursive: true
|
|
495
|
+
});
|
|
496
|
+
try {
|
|
497
|
+
const entries = await readdir(dir);
|
|
498
|
+
for (const name of entries){
|
|
499
|
+
if (name.endsWith(".md") || name === ".timestamp") {
|
|
500
|
+
await unlink(join(dir, name));
|
|
460
501
|
}
|
|
461
|
-
} catch {
|
|
462
|
-
// 缓存不存在或无效,继续拉取
|
|
463
502
|
}
|
|
503
|
+
} catch {
|
|
504
|
+
// 忽略目录清理失败,后续写入时再处理
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
isTeaInstalled() {
|
|
508
|
+
try {
|
|
509
|
+
execSync("command -v tea", {
|
|
510
|
+
stdio: "pipe"
|
|
511
|
+
});
|
|
512
|
+
return true;
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
getTeaLoginForServer(serverUrl) {
|
|
518
|
+
try {
|
|
519
|
+
const output = execFileSync("tea", [
|
|
520
|
+
"login",
|
|
521
|
+
"list",
|
|
522
|
+
"-o",
|
|
523
|
+
"json"
|
|
524
|
+
], {
|
|
525
|
+
encoding: "utf-8",
|
|
526
|
+
stdio: "pipe"
|
|
527
|
+
});
|
|
528
|
+
const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
|
|
529
|
+
const logins = JSON.parse(output);
|
|
530
|
+
const matched = logins.find((login)=>login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl);
|
|
531
|
+
return matched?.name ?? null;
|
|
532
|
+
} catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
runTeaApi(endpoint, loginName) {
|
|
537
|
+
const args = [
|
|
538
|
+
"api",
|
|
539
|
+
"-l",
|
|
540
|
+
loginName,
|
|
541
|
+
endpoint
|
|
542
|
+
];
|
|
543
|
+
return execFileSync("tea", args, {
|
|
544
|
+
encoding: "utf-8",
|
|
545
|
+
stdio: "pipe"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
encodePathSegments(path) {
|
|
549
|
+
if (!path) return "";
|
|
550
|
+
return path.split("/").filter(Boolean).map((segment)=>encodeURIComponent(segment)).join("/");
|
|
551
|
+
}
|
|
552
|
+
buildTeaContentsEndpoint(ref) {
|
|
553
|
+
const owner = encodeURIComponent(ref.owner);
|
|
554
|
+
const repo = encodeURIComponent(ref.repo);
|
|
555
|
+
const encodedPath = this.encodePathSegments(ref.path || "");
|
|
556
|
+
const pathPart = encodedPath ? `/${encodedPath}` : "";
|
|
557
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
558
|
+
return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
|
|
559
|
+
}
|
|
560
|
+
buildTeaRawFileEndpoint(ref, filePath) {
|
|
561
|
+
const owner = encodeURIComponent(ref.owner);
|
|
562
|
+
const repo = encodeURIComponent(ref.repo);
|
|
563
|
+
const encodedFilePath = this.encodePathSegments(filePath);
|
|
564
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
565
|
+
return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* 使用 tea api 拉取远程规则
|
|
569
|
+
* 前置条件:本地安装 tea 且已登录目标服务器
|
|
570
|
+
*/ async fetchRemoteSpecsViaTea(ref, verbose) {
|
|
571
|
+
if (!this.isTeaInstalled()) {
|
|
572
|
+
this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
const loginName = this.getTeaLoginForServer(ref.serverUrl);
|
|
576
|
+
if (!loginName) {
|
|
577
|
+
this.logVerbose(verbose, 3, ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`);
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
|
|
581
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
582
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
583
|
+
try {
|
|
584
|
+
console.log(` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
|
|
585
|
+
const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
|
|
586
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
|
|
587
|
+
const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
|
|
588
|
+
const contents = JSON.parse(contentsRaw);
|
|
589
|
+
const mdFiles = contents.filter((f)=>f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path);
|
|
590
|
+
if (mdFiles.length === 0) {
|
|
591
|
+
console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
const fetchedFiles = [];
|
|
595
|
+
for (const file of mdFiles){
|
|
596
|
+
const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path);
|
|
597
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
|
|
598
|
+
const fileContent = this.runTeaApi(fileEndpoint, loginName);
|
|
599
|
+
fetchedFiles.push({
|
|
600
|
+
name: file.name,
|
|
601
|
+
content: fileContent
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
605
|
+
for (const file of fetchedFiles){
|
|
606
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
607
|
+
}
|
|
608
|
+
console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
609
|
+
return specDir;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
|
|
612
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
613
|
+
if (localDir) {
|
|
614
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
615
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
616
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
617
|
+
return localDir;
|
|
618
|
+
}
|
|
619
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
620
|
+
return null;
|
|
464
621
|
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* 通过 Git API 从远程仓库拉取规则文件
|
|
625
|
+
* 保存到 ~/.spaceflow/review-spec/ 目录
|
|
626
|
+
*/ async fetchRemoteSpecs(ref, verbose) {
|
|
627
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
628
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
465
629
|
try {
|
|
466
630
|
console.log(` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
|
|
467
631
|
const contents = await this.gitProvider.listRepositoryContents(ref.owner, ref.repo, ref.path || undefined, ref.ref);
|
|
@@ -470,32 +634,41 @@ class ReviewSpecService {
|
|
|
470
634
|
console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
|
|
471
635
|
return null;
|
|
472
636
|
}
|
|
473
|
-
|
|
474
|
-
recursive: true
|
|
475
|
-
});
|
|
637
|
+
const fetchedFiles = [];
|
|
476
638
|
for (const file of mdFiles){
|
|
477
639
|
const content = await this.gitProvider.getFileContent(ref.owner, ref.repo, file.path, ref.ref);
|
|
478
|
-
|
|
640
|
+
fetchedFiles.push({
|
|
641
|
+
name: file.name,
|
|
642
|
+
content
|
|
643
|
+
});
|
|
479
644
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
645
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
646
|
+
for (const file of fetchedFiles){
|
|
647
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
648
|
+
}
|
|
649
|
+
console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
650
|
+
return specDir;
|
|
484
651
|
} catch (error) {
|
|
485
652
|
console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// 无缓存可用
|
|
495
|
-
}
|
|
653
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
654
|
+
if (localDir) {
|
|
655
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
656
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
657
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
658
|
+
return localDir;
|
|
659
|
+
}
|
|
660
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
496
661
|
return null;
|
|
497
662
|
}
|
|
498
663
|
}
|
|
664
|
+
async getSpecFileCount(dir) {
|
|
665
|
+
try {
|
|
666
|
+
const entries = await readdir(dir);
|
|
667
|
+
return entries.filter((f)=>f.endsWith(".md")).length;
|
|
668
|
+
} catch {
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
499
672
|
/**
|
|
500
673
|
* 解析 deps 目录,扫描子目录中的 references 文件夹
|
|
501
674
|
* 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
|
|
@@ -541,16 +714,35 @@ class ReviewSpecService {
|
|
|
541
714
|
isRepoUrl(source) {
|
|
542
715
|
return source.startsWith("http://") || source.startsWith("https://") || source.startsWith("git@") || source.includes("://");
|
|
543
716
|
}
|
|
544
|
-
|
|
717
|
+
buildRepoCloneUrl(ref) {
|
|
718
|
+
return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
|
|
719
|
+
}
|
|
720
|
+
async resolveClonedSpecDir(cacheDir, subPath) {
|
|
721
|
+
const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
|
|
722
|
+
if (!normalizedSubPath) {
|
|
723
|
+
return cacheDir;
|
|
724
|
+
}
|
|
725
|
+
const targetDir = join(cacheDir, normalizedSubPath);
|
|
726
|
+
try {
|
|
727
|
+
await access(targetDir);
|
|
728
|
+
return targetDir;
|
|
729
|
+
} catch {
|
|
730
|
+
console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
|
|
731
|
+
return cacheDir;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async cloneSpecRepo(repoUrl, subPath, verbose) {
|
|
545
735
|
const repoName = this.extractRepoName(repoUrl);
|
|
546
736
|
if (!repoName) {
|
|
547
737
|
console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
|
|
548
738
|
return null;
|
|
549
739
|
}
|
|
550
740
|
const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
|
|
741
|
+
this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
|
|
551
742
|
try {
|
|
552
743
|
await access(cacheDir);
|
|
553
744
|
// console.log(` 使用缓存的规则仓库: ${cacheDir}`);
|
|
745
|
+
this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
|
|
554
746
|
try {
|
|
555
747
|
execSync("git pull --ff-only", {
|
|
556
748
|
cwd: cacheDir,
|
|
@@ -560,10 +752,11 @@ class ReviewSpecService {
|
|
|
560
752
|
} catch {
|
|
561
753
|
console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
|
|
562
754
|
}
|
|
563
|
-
return cacheDir;
|
|
755
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
564
756
|
} catch {
|
|
565
757
|
// console.log(` 克隆规则仓库: ${repoUrl}`);
|
|
566
758
|
try {
|
|
759
|
+
this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
|
|
567
760
|
await mkdir(join(homedir(), ".spaceflow", "review-spec"), {
|
|
568
761
|
recursive: true
|
|
569
762
|
});
|
|
@@ -571,7 +764,7 @@ class ReviewSpecService {
|
|
|
571
764
|
stdio: "pipe"
|
|
572
765
|
});
|
|
573
766
|
// console.log(` 克隆完成: ${cacheDir}`);
|
|
574
|
-
return cacheDir;
|
|
767
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
575
768
|
} catch (error) {
|
|
576
769
|
console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
|
|
577
770
|
return null;
|
|
@@ -579,6 +772,10 @@ class ReviewSpecService {
|
|
|
579
772
|
}
|
|
580
773
|
}
|
|
581
774
|
extractRepoName(repoUrl) {
|
|
775
|
+
const parsedRef = parseRepoUrl(repoUrl);
|
|
776
|
+
if (parsedRef) {
|
|
777
|
+
return `${parsedRef.owner}__${parsedRef.repo}`;
|
|
778
|
+
}
|
|
582
779
|
let path = repoUrl;
|
|
583
780
|
path = path.replace(/\.git$/, "");
|
|
584
781
|
path = path.replace(/^git@[^:]+:/, "");
|
|
@@ -1509,13 +1706,10 @@ class MarkdownFormatter {
|
|
|
1509
1706
|
`| 指标 | 数量 |`,
|
|
1510
1707
|
`|------|------|`
|
|
1511
1708
|
];
|
|
1512
|
-
lines.push(`|
|
|
1513
|
-
lines.push(`|
|
|
1514
|
-
lines.push(`|
|
|
1515
|
-
lines.push(`|
|
|
1516
|
-
lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
|
|
1517
|
-
lines.push(`| 修复率 | ${stats.fixRate}% |`);
|
|
1518
|
-
lines.push(`| 解决率 | ${stats.resolveRate}% |`);
|
|
1709
|
+
lines.push(`| 有效问题 | ${stats.validTotal} (🟢已验收 ${stats.fixed}, ⚪已解决 ${stats.resolved}, ⚠️待处理 ${stats.pending}) |`);
|
|
1710
|
+
lines.push(`| ❌ 无效问题 | ${stats.invalid} |`);
|
|
1711
|
+
lines.push(`| 验收率 | ${stats.fixRate}% (${stats.fixed}/${stats.validTotal}) |`);
|
|
1712
|
+
lines.push(`| 解决率 | ${stats.resolveRate}% (${stats.resolved}/${stats.validTotal}) |`);
|
|
1519
1713
|
return lines.join("\n");
|
|
1520
1714
|
}
|
|
1521
1715
|
}
|
|
@@ -2165,10 +2359,13 @@ function generateIssueKey(issue) {
|
|
|
2165
2359
|
* - 合并历史 issues(this.issues)+ newIssues
|
|
2166
2360
|
* - 复制 newResult 的元信息(title/description/deletionImpact 等)
|
|
2167
2361
|
*
|
|
2168
|
-
* 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes
|
|
2362
|
+
* 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes 等)。
|
|
2169
2363
|
*/ nextRound(newResult) {
|
|
2170
2364
|
const nextRoundNum = this._result.round + 1;
|
|
2171
|
-
|
|
2365
|
+
// 去重:过滤掉已存在于历史 issues 中的新问题(含 valid:false 的都参与去重)
|
|
2366
|
+
const existingKeys = new Set(this._result.issues.map((i)=>generateIssueKey(i)));
|
|
2367
|
+
const dedupedNewIssues = newResult.issues.filter((i)=>!existingKeys.has(generateIssueKey(i)));
|
|
2368
|
+
const taggedNewIssues = dedupedNewIssues.map((issue)=>({
|
|
2172
2369
|
...issue,
|
|
2173
2370
|
round: nextRoundNum
|
|
2174
2371
|
}));
|
|
@@ -2393,8 +2590,9 @@ function generateIssueKey(issue) {
|
|
|
2393
2590
|
}
|
|
2394
2591
|
/**
|
|
2395
2592
|
* 将有变更文件的历史 issue 标记为无效。
|
|
2396
|
-
*
|
|
2397
|
-
|
|
2593
|
+
* 策略:如果文件在最新 commit 中有变更,则将该文件的历史问题标记为无效,但以下情况保留:
|
|
2594
|
+
* - issue 已被用户手动 resolved 且当前代码行内容与 issue.code 不同(说明用户 resolve 后代码已变,应保留其 resolve 状态)
|
|
2595
|
+
*/ async invalidateChangedFiles(headSha, fileContents, verbose) {
|
|
2398
2596
|
if (!headSha) {
|
|
2399
2597
|
if (shouldLog(verbose, 1)) {
|
|
2400
2598
|
console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
|
|
@@ -2423,13 +2621,32 @@ function generateIssueKey(issue) {
|
|
|
2423
2621
|
}
|
|
2424
2622
|
// 将变更文件的历史 issue 标记为无效
|
|
2425
2623
|
let invalidatedCount = 0;
|
|
2624
|
+
let preservedCount = 0;
|
|
2426
2625
|
this._result.issues = this._result.issues.map((issue)=>{
|
|
2427
|
-
// 如果 issue
|
|
2428
|
-
if (issue.fixed || issue.
|
|
2626
|
+
// 如果 issue 已修复或已无效,不需要处理
|
|
2627
|
+
if (issue.fixed || issue.valid === "false") {
|
|
2429
2628
|
return issue;
|
|
2430
2629
|
}
|
|
2431
|
-
// 如果 issue
|
|
2630
|
+
// 如果 issue 所在文件有变更
|
|
2432
2631
|
if (changedFileSet.has(issue.file)) {
|
|
2632
|
+
// 已 resolved 的 issue:检查当前代码行是否与 issue.code 不同
|
|
2633
|
+
// 不同说明用户 resolve 后代码确实变了,保留其 resolve 状态
|
|
2634
|
+
if (issue.resolved && issue.code && fileContents) {
|
|
2635
|
+
const contentLines = fileContents.get(issue.file);
|
|
2636
|
+
if (contentLines) {
|
|
2637
|
+
const lineNums = issue.line.split("-").map(Number).filter((n)=>!isNaN(n));
|
|
2638
|
+
const startLine = lineNums[0];
|
|
2639
|
+
const endLine = lineNums[lineNums.length - 1];
|
|
2640
|
+
const currentCode = contentLines.slice(startLine - 1, endLine).map(([, line])=>line).join("\n").trim();
|
|
2641
|
+
if (currentCode !== issue.code) {
|
|
2642
|
+
preservedCount++;
|
|
2643
|
+
if (shouldLog(verbose, 1)) {
|
|
2644
|
+
console.log(` ✅ Issue ${issue.file}:${issue.line} 已 resolved 且代码已变更,保留`);
|
|
2645
|
+
}
|
|
2646
|
+
return issue;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2433
2650
|
invalidatedCount++;
|
|
2434
2651
|
if (shouldLog(verbose, 1)) {
|
|
2435
2652
|
console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
|
|
@@ -2442,8 +2659,11 @@ function generateIssueKey(issue) {
|
|
|
2442
2659
|
}
|
|
2443
2660
|
return issue;
|
|
2444
2661
|
});
|
|
2445
|
-
if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
|
|
2446
|
-
|
|
2662
|
+
if ((invalidatedCount > 0 || preservedCount > 0) && shouldLog(verbose, 1)) {
|
|
2663
|
+
const parts = [];
|
|
2664
|
+
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
2665
|
+
if (preservedCount > 0) parts.push(`保留 ${preservedCount} 个已 resolved`);
|
|
2666
|
+
console.log(` 📊 Issue 处理: ${parts.join(",")}`);
|
|
2447
2667
|
}
|
|
2448
2668
|
} catch (error) {
|
|
2449
2669
|
if (shouldLog(verbose, 1)) {
|
|
@@ -2995,6 +3215,80 @@ function generateIssueKey(issue) {
|
|
|
2995
3215
|
}).optional()
|
|
2996
3216
|
});
|
|
2997
3217
|
|
|
3218
|
+
;// CONCATENATED MODULE: ./src/changed-file-collection.ts
|
|
3219
|
+
|
|
3220
|
+
/**
|
|
3221
|
+
* 变更文件集合,封装 ChangedFile[] 并提供常用访问器。
|
|
3222
|
+
*/ class ChangedFileCollection {
|
|
3223
|
+
_files;
|
|
3224
|
+
constructor(files){
|
|
3225
|
+
this._files = files;
|
|
3226
|
+
}
|
|
3227
|
+
static from(files) {
|
|
3228
|
+
return new ChangedFileCollection(files);
|
|
3229
|
+
}
|
|
3230
|
+
static empty() {
|
|
3231
|
+
return new ChangedFileCollection([]);
|
|
3232
|
+
}
|
|
3233
|
+
get length() {
|
|
3234
|
+
return this._files.length;
|
|
3235
|
+
}
|
|
3236
|
+
toArray() {
|
|
3237
|
+
return [
|
|
3238
|
+
...this._files
|
|
3239
|
+
];
|
|
3240
|
+
}
|
|
3241
|
+
[Symbol.iterator]() {
|
|
3242
|
+
return this._files[Symbol.iterator]();
|
|
3243
|
+
}
|
|
3244
|
+
filenames() {
|
|
3245
|
+
return this._files.map((f)=>f.filename ?? "").filter(Boolean);
|
|
3246
|
+
}
|
|
3247
|
+
extensions() {
|
|
3248
|
+
const exts = new Set();
|
|
3249
|
+
for (const f of this._files){
|
|
3250
|
+
if (f.filename) {
|
|
3251
|
+
const ext = extname(f.filename).replace(/^\./, "").toLowerCase();
|
|
3252
|
+
if (ext) exts.add(ext);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
return exts;
|
|
3256
|
+
}
|
|
3257
|
+
has(filename) {
|
|
3258
|
+
return this._files.some((f)=>f.filename === filename);
|
|
3259
|
+
}
|
|
3260
|
+
filter(predicate) {
|
|
3261
|
+
return new ChangedFileCollection(this._files.filter(predicate));
|
|
3262
|
+
}
|
|
3263
|
+
map(fn) {
|
|
3264
|
+
return this._files.map(fn);
|
|
3265
|
+
}
|
|
3266
|
+
countByStatus() {
|
|
3267
|
+
let added = 0, modified = 0, deleted = 0;
|
|
3268
|
+
for (const f of this._files){
|
|
3269
|
+
if (f.status === "added") added++;
|
|
3270
|
+
else if (f.status === "modified") modified++;
|
|
3271
|
+
else if (f.status === "deleted") deleted++;
|
|
3272
|
+
}
|
|
3273
|
+
return {
|
|
3274
|
+
added,
|
|
3275
|
+
modified,
|
|
3276
|
+
deleted
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
nonDeletedFiles() {
|
|
3280
|
+
return this.filter((f)=>f.status !== "deleted" && !!f.filename);
|
|
3281
|
+
}
|
|
3282
|
+
filterByFilenames(names) {
|
|
3283
|
+
const nameSet = new Set(names);
|
|
3284
|
+
return this.filter((f)=>!!f.filename && nameSet.has(f.filename));
|
|
3285
|
+
}
|
|
3286
|
+
filterByCommitFiles(commitFilenames) {
|
|
3287
|
+
const nameSet = new Set(commitFilenames);
|
|
3288
|
+
return this.filter((f)=>!!f.filename && nameSet.has(f.filename));
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
|
|
2998
3292
|
;// CONCATENATED MODULE: ./src/parse-title-options.ts
|
|
2999
3293
|
|
|
3000
3294
|
/**
|
|
@@ -3177,7 +3471,7 @@ class ReviewContextBuilder {
|
|
|
3177
3471
|
}
|
|
3178
3472
|
async getContextFromEnv(options) {
|
|
3179
3473
|
const reviewConf = this.config.getPluginConfig("review");
|
|
3180
|
-
if (shouldLog(options.verbose,
|
|
3474
|
+
if (shouldLog(options.verbose, 3)) {
|
|
3181
3475
|
console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
|
|
3182
3476
|
}
|
|
3183
3477
|
const ciConf = this.config.get("ci");
|
|
@@ -3536,7 +3830,7 @@ class ReviewIssueFilter {
|
|
|
3536
3830
|
if (shouldLog(verbose, 1)) {
|
|
3537
3831
|
console.log(`📂 解析规则来源: ${specSources.length} 个`);
|
|
3538
3832
|
}
|
|
3539
|
-
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
|
|
3833
|
+
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
|
|
3540
3834
|
if (shouldLog(verbose, 2)) {
|
|
3541
3835
|
console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
|
|
3542
3836
|
}
|
|
@@ -3558,9 +3852,8 @@ class ReviewIssueFilter {
|
|
|
3558
3852
|
}
|
|
3559
3853
|
/**
|
|
3560
3854
|
* LLM 验证历史问题是否已修复
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
const { llmMode, specSources, verbose } = context;
|
|
3855
|
+
*/ async verifyAndUpdateIssues(context, issues, commits, preloaded) {
|
|
3856
|
+
const { llmMode, verbose } = context;
|
|
3564
3857
|
const unfixedIssues = issues.filter((i)=>i.valid !== "false" && !i.fixed);
|
|
3565
3858
|
if (unfixedIssues.length === 0) {
|
|
3566
3859
|
return issues;
|
|
@@ -3571,26 +3864,10 @@ class ReviewIssueFilter {
|
|
|
3571
3864
|
}
|
|
3572
3865
|
return issues;
|
|
3573
3866
|
}
|
|
3574
|
-
if (!preloaded && (!specSources?.length || !pr)) {
|
|
3575
|
-
if (shouldLog(verbose, 1)) {
|
|
3576
|
-
console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 pr)`);
|
|
3577
|
-
}
|
|
3578
|
-
return issues;
|
|
3579
|
-
}
|
|
3580
3867
|
if (shouldLog(verbose, 1)) {
|
|
3581
3868
|
console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
|
|
3582
3869
|
}
|
|
3583
|
-
|
|
3584
|
-
let fileContents;
|
|
3585
|
-
if (preloaded) {
|
|
3586
|
-
specs = preloaded.specs;
|
|
3587
|
-
fileContents = preloaded.fileContents;
|
|
3588
|
-
} else {
|
|
3589
|
-
const changedFiles = await pr.getFiles();
|
|
3590
|
-
const headSha = await pr.getHeadSha();
|
|
3591
|
-
specs = await this.loadSpecs(specSources, verbose);
|
|
3592
|
-
fileContents = await this.getFileContents(pr.owner, pr.repo, changedFiles, commits, headSha, pr.number, verbose);
|
|
3593
|
-
}
|
|
3870
|
+
const { specs, fileContents } = preloaded;
|
|
3594
3871
|
return await this.issueVerifyService.verifyIssueFixes(issues, fileContents, specs, llmMode, verbose, context.verifyConcurrency);
|
|
3595
3872
|
}
|
|
3596
3873
|
async getChangedFilesBetweenRefs(_owner, _repo, baseRef, headRef) {
|
|
@@ -3627,77 +3904,6 @@ class ReviewIssueFilter {
|
|
|
3627
3904
|
return this.gitSdk.getFilesForCommit(sha);
|
|
3628
3905
|
}
|
|
3629
3906
|
}
|
|
3630
|
-
/**
|
|
3631
|
-
* 获取文件内容并构建行号到 commit hash 的映射
|
|
3632
|
-
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
3633
|
-
*/ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, verbose, isLocalMode) {
|
|
3634
|
-
const contents = new Map();
|
|
3635
|
-
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
3636
|
-
// 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
|
|
3637
|
-
// 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
|
|
3638
|
-
// buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
|
|
3639
|
-
if (shouldLog(verbose, 1)) {
|
|
3640
|
-
console.log(`📊 正在构建行号到变更的映射...`);
|
|
3641
|
-
}
|
|
3642
|
-
for (const file of changedFiles){
|
|
3643
|
-
if (file.filename && file.status !== "deleted") {
|
|
3644
|
-
try {
|
|
3645
|
-
let rawContent;
|
|
3646
|
-
if (isLocalMode) {
|
|
3647
|
-
// 本地模式:读取工作区文件的当前内容
|
|
3648
|
-
rawContent = this.gitSdk.getWorkingFileContent(file.filename);
|
|
3649
|
-
} else if (prNumber) {
|
|
3650
|
-
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
3651
|
-
} else {
|
|
3652
|
-
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
3653
|
-
}
|
|
3654
|
-
const lines = rawContent.split("\n");
|
|
3655
|
-
// 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
|
|
3656
|
-
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
3657
|
-
// 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
|
|
3658
|
-
// 情况1: 文件是新增的(status 为 added/A)
|
|
3659
|
-
// 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
|
|
3660
|
-
const isNewFile = file.status === "added" || file.status === "A" || file.additions && file.additions > 0 && file.deletions === 0 && !file.patch;
|
|
3661
|
-
if (changedLines.size === 0 && isNewFile) {
|
|
3662
|
-
changedLines = new Set(lines.map((_, i)=>i + 1));
|
|
3663
|
-
if (shouldLog(verbose, 2)) {
|
|
3664
|
-
console.log(` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`);
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
if (shouldLog(verbose, 3)) {
|
|
3668
|
-
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
3669
|
-
console.log(` latestCommitHash: ${latestCommitHash}`);
|
|
3670
|
-
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
3671
|
-
console.log(` 变更行号: ${Array.from(changedLines).sort((a, b)=>a - b).join(", ")}`);
|
|
3672
|
-
} else if (changedLines.size > 20) {
|
|
3673
|
-
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
3674
|
-
}
|
|
3675
|
-
if (!file.patch) {
|
|
3676
|
-
console.log(` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`);
|
|
3677
|
-
} else {
|
|
3678
|
-
console.log(` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`);
|
|
3679
|
-
}
|
|
3680
|
-
}
|
|
3681
|
-
const contentLines = lines.map((line, index)=>{
|
|
3682
|
-
const lineNum = index + 1;
|
|
3683
|
-
// 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
|
|
3684
|
-
const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
|
|
3685
|
-
return [
|
|
3686
|
-
hash,
|
|
3687
|
-
line
|
|
3688
|
-
];
|
|
3689
|
-
});
|
|
3690
|
-
contents.set(file.filename, contentLines);
|
|
3691
|
-
} catch (error) {
|
|
3692
|
-
console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
|
|
3693
|
-
}
|
|
3694
|
-
}
|
|
3695
|
-
}
|
|
3696
|
-
if (shouldLog(verbose, 1)) {
|
|
3697
|
-
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
3698
|
-
}
|
|
3699
|
-
return contents;
|
|
3700
|
-
}
|
|
3701
3907
|
async fillIssueCode(issues, fileContents) {
|
|
3702
3908
|
return issues.map((issue)=>{
|
|
3703
3909
|
const contentLines = fileContents.get(issue.file);
|
|
@@ -3722,85 +3928,18 @@ class ReviewIssueFilter {
|
|
|
3722
3928
|
});
|
|
3723
3929
|
}
|
|
3724
3930
|
/**
|
|
3725
|
-
* 根据代码变更更新历史 issue 的行号
|
|
3726
|
-
* 当代码发生变化时,之前发现的 issue 行号可能已经不准确
|
|
3727
|
-
* 此方法通过分析 diff 来计算新的行号
|
|
3728
|
-
*/ updateIssueLineNumbers(issues, filePatchMap, verbose) {
|
|
3729
|
-
let updatedCount = 0;
|
|
3730
|
-
let invalidatedCount = 0;
|
|
3731
|
-
const updatedIssues = issues.map((issue)=>{
|
|
3732
|
-
// 如果 issue 已修复、已解决或无效,不需要更新行号
|
|
3733
|
-
if (issue.fixed || issue.resolved || issue.valid === "false") {
|
|
3734
|
-
return issue;
|
|
3735
|
-
}
|
|
3736
|
-
const patch = filePatchMap.get(issue.file);
|
|
3737
|
-
if (!patch) {
|
|
3738
|
-
// 文件没有变更,行号不变
|
|
3739
|
-
return issue;
|
|
3740
|
-
}
|
|
3741
|
-
const lines = this.reviewSpecService.parseLineRange(issue.line);
|
|
3742
|
-
if (lines.length === 0) {
|
|
3743
|
-
return issue;
|
|
3744
|
-
}
|
|
3745
|
-
const startLine = lines[0];
|
|
3746
|
-
const endLine = lines[lines.length - 1];
|
|
3747
|
-
const hunks = parseHunksFromPatch(patch);
|
|
3748
|
-
// 计算新的起始行号
|
|
3749
|
-
const newStartLine = calculateNewLineNumber(startLine, hunks);
|
|
3750
|
-
if (newStartLine === null) {
|
|
3751
|
-
// 起始行被删除,直接标记为无效问题
|
|
3752
|
-
invalidatedCount++;
|
|
3753
|
-
if (shouldLog(verbose, 1)) {
|
|
3754
|
-
console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
|
|
3755
|
-
}
|
|
3756
|
-
return {
|
|
3757
|
-
...issue,
|
|
3758
|
-
valid: "false",
|
|
3759
|
-
originalLine: issue.originalLine ?? issue.line
|
|
3760
|
-
};
|
|
3761
|
-
}
|
|
3762
|
-
// 如果是范围行号,计算新的结束行号
|
|
3763
|
-
let newLine;
|
|
3764
|
-
if (startLine === endLine) {
|
|
3765
|
-
newLine = String(newStartLine);
|
|
3766
|
-
} else {
|
|
3767
|
-
const newEndLine = calculateNewLineNumber(endLine, hunks);
|
|
3768
|
-
if (newEndLine === null || newEndLine === newStartLine) {
|
|
3769
|
-
// 结束行被删除或范围缩小为单行,使用起始行
|
|
3770
|
-
newLine = String(newStartLine);
|
|
3771
|
-
} else {
|
|
3772
|
-
newLine = `${newStartLine}-${newEndLine}`;
|
|
3773
|
-
}
|
|
3774
|
-
}
|
|
3775
|
-
// 如果行号发生变化,更新 issue
|
|
3776
|
-
if (newLine !== issue.line) {
|
|
3777
|
-
updatedCount++;
|
|
3778
|
-
if (shouldLog(verbose, 1)) {
|
|
3779
|
-
console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
|
|
3780
|
-
}
|
|
3781
|
-
return {
|
|
3782
|
-
...issue,
|
|
3783
|
-
line: newLine,
|
|
3784
|
-
originalLine: issue.originalLine ?? issue.line
|
|
3785
|
-
};
|
|
3786
|
-
}
|
|
3787
|
-
return issue;
|
|
3788
|
-
});
|
|
3789
|
-
if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
|
|
3790
|
-
const parts = [];
|
|
3791
|
-
if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
|
|
3792
|
-
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
3793
|
-
console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
|
|
3794
|
-
}
|
|
3795
|
-
return updatedIssues;
|
|
3796
|
-
}
|
|
3797
|
-
/**
|
|
3798
3931
|
* 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
|
|
3799
3932
|
* 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
|
|
3800
3933
|
*/ filterIssuesByValidCommits(issues, commits, fileContents, verbose) {
|
|
3801
3934
|
const validCommitHashes = new Set(commits.map((c)=>c.sha?.slice(0, 7)).filter(Boolean));
|
|
3935
|
+
// commits 为空时(如分支比较模式本地无 commit 信息),退化为"行是否在 diff 变更范围内"模式
|
|
3936
|
+
const useChangedLinesMode = validCommitHashes.size === 0;
|
|
3802
3937
|
if (shouldLog(verbose, 3)) {
|
|
3803
|
-
|
|
3938
|
+
if (useChangedLinesMode) {
|
|
3939
|
+
console.log(` 🔍 commits 为空,使用变更行模式过滤`);
|
|
3940
|
+
} else {
|
|
3941
|
+
console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
|
|
3942
|
+
}
|
|
3804
3943
|
}
|
|
3805
3944
|
const beforeCount = issues.length;
|
|
3806
3945
|
const filtered = issues.filter((issue)=>{
|
|
@@ -3819,12 +3958,14 @@ class ReviewIssueFilter {
|
|
|
3819
3958
|
}
|
|
3820
3959
|
return true;
|
|
3821
3960
|
}
|
|
3822
|
-
//
|
|
3961
|
+
// 检查问题行范围内是否有任意一行属于本次变更(diff 范围)
|
|
3823
3962
|
for (const lineNum of lineNums){
|
|
3824
3963
|
const lineData = contentLines[lineNum - 1];
|
|
3825
3964
|
if (lineData) {
|
|
3826
3965
|
const [actualHash] = lineData;
|
|
3827
|
-
|
|
3966
|
+
const isChangedLine = actualHash !== "-------";
|
|
3967
|
+
const isValid = useChangedLinesMode ? isChangedLine : isChangedLine && validCommitHashes.has(actualHash);
|
|
3968
|
+
if (isValid) {
|
|
3828
3969
|
if (shouldLog(verbose, 3)) {
|
|
3829
3970
|
console.log(` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`);
|
|
3830
3971
|
}
|
|
@@ -3832,9 +3973,9 @@ class ReviewIssueFilter {
|
|
|
3832
3973
|
}
|
|
3833
3974
|
}
|
|
3834
3975
|
}
|
|
3835
|
-
//
|
|
3976
|
+
// 问题行都不属于本次变更范围
|
|
3836
3977
|
if (shouldLog(verbose, 2)) {
|
|
3837
|
-
console.log(` Issue ${issue.file}:${issue.line}
|
|
3978
|
+
console.log(` Issue ${issue.file}:${issue.line} 不在本次变更行范围内,跳过`);
|
|
3838
3979
|
}
|
|
3839
3980
|
if (shouldLog(verbose, 3)) {
|
|
3840
3981
|
const hashes = lineNums.map((ln)=>{
|
|
@@ -3846,7 +3987,7 @@ class ReviewIssueFilter {
|
|
|
3846
3987
|
return false;
|
|
3847
3988
|
});
|
|
3848
3989
|
if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
|
|
3849
|
-
console.log(`
|
|
3990
|
+
console.log(` 变更行过滤后: ${beforeCount} -> ${filtered.length} 个问题`);
|
|
3850
3991
|
}
|
|
3851
3992
|
return filtered;
|
|
3852
3993
|
}
|
|
@@ -3866,43 +4007,6 @@ class ReviewIssueFilter {
|
|
|
3866
4007
|
generateIssueKey(issue) {
|
|
3867
4008
|
return generateIssueKey(issue);
|
|
3868
4009
|
}
|
|
3869
|
-
/**
|
|
3870
|
-
* 构建文件行号到 commit hash 的映射
|
|
3871
|
-
* 遍历每个 commit,获取其修改的文件和行号
|
|
3872
|
-
* 优先使用 API,失败时回退到 git 命令
|
|
3873
|
-
*/ async buildLineCommitMap(owner, repo, commits, verbose) {
|
|
3874
|
-
// Map<filename, Map<lineNumber, commitHash>>
|
|
3875
|
-
const fileLineMap = new Map();
|
|
3876
|
-
// 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
|
|
3877
|
-
for (const commit of commits){
|
|
3878
|
-
if (!commit.sha) continue;
|
|
3879
|
-
const shortHash = commit.sha.slice(0, 7);
|
|
3880
|
-
let files = [];
|
|
3881
|
-
// 优先使用 getCommitDiff API 获取 diff 文本
|
|
3882
|
-
try {
|
|
3883
|
-
const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
|
|
3884
|
-
files = parseDiffText(diffText);
|
|
3885
|
-
} catch {
|
|
3886
|
-
// API 失败,回退到 git 命令
|
|
3887
|
-
files = this.gitSdk.getCommitDiff(commit.sha);
|
|
3888
|
-
}
|
|
3889
|
-
if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
|
|
3890
|
-
for (const file of files){
|
|
3891
|
-
// 解析这个 commit 修改的行号
|
|
3892
|
-
const changedLines = parseChangedLinesFromPatch(file.patch);
|
|
3893
|
-
// 获取或创建文件的行号映射
|
|
3894
|
-
if (!fileLineMap.has(file.filename)) {
|
|
3895
|
-
fileLineMap.set(file.filename, new Map());
|
|
3896
|
-
}
|
|
3897
|
-
const lineMap = fileLineMap.get(file.filename);
|
|
3898
|
-
// 记录每行对应的 commit hash
|
|
3899
|
-
for (const lineNum of changedLines){
|
|
3900
|
-
lineMap.set(lineNum, shortHash);
|
|
3901
|
-
}
|
|
3902
|
-
}
|
|
3903
|
-
}
|
|
3904
|
-
return fileLineMap;
|
|
3905
|
-
}
|
|
3906
4010
|
}
|
|
3907
4011
|
|
|
3908
4012
|
;// CONCATENATED MODULE: ./src/utils/review-llm.ts
|
|
@@ -4533,6 +4637,7 @@ const buildDeletionImpactAgentPrompt = (ctx)=>{
|
|
|
4533
4637
|
const { spec, rule } = ctx.ruleInfo;
|
|
4534
4638
|
ruleSection = `### ${spec.filename} (${spec.type})\n\n${spec.content.slice(0, 200)}...\n\n#### 规则\n- ${rule.id}: ${rule.title}\n ${rule.description}`;
|
|
4535
4639
|
}
|
|
4640
|
+
const originalCodeSection = ctx.issue.code ? `\n## 问题发现时的原始代码(用于对比)\n\n\`\`\`\n${ctx.issue.code}\n\`\`\`\n` : "";
|
|
4536
4641
|
const userPrompt = `## 规则定义
|
|
4537
4642
|
|
|
4538
4643
|
${ruleSection}
|
|
@@ -4540,18 +4645,19 @@ ${ruleSection}
|
|
|
4540
4645
|
## 之前发现的问题
|
|
4541
4646
|
|
|
4542
4647
|
- **文件**: ${ctx.issue.file}
|
|
4543
|
-
- **行号**: ${ctx.issue.line}
|
|
4648
|
+
- **行号**: ${ctx.issue.line}(问题发现时的行号,可能因代码变更而偏移)
|
|
4544
4649
|
- **规则**: ${ctx.issue.ruleId} (来自 ${ctx.issue.specFile})
|
|
4545
4650
|
- **问题描述**: ${ctx.issue.reason}
|
|
4546
4651
|
${ctx.issue.suggestion ? `- **原建议**: ${ctx.issue.suggestion}` : ""}
|
|
4547
|
-
|
|
4652
|
+
${originalCodeSection}
|
|
4548
4653
|
## 当前文件内容
|
|
4549
4654
|
|
|
4550
4655
|
\`\`\`
|
|
4551
4656
|
${linesWithNumbers}
|
|
4552
4657
|
\`\`\`
|
|
4553
4658
|
|
|
4554
|
-
|
|
4659
|
+
请判断这个问题是否有效,以及是否已经被修复。
|
|
4660
|
+
**注意**:如果提供了"问题发现时的原始代码",请优先通过搜索该代码片段来定位问题位置,而不是仅依赖行号(行号可能因代码变更已经偏移)。`;
|
|
4555
4661
|
return {
|
|
4556
4662
|
systemPrompt,
|
|
4557
4663
|
userPrompt
|
|
@@ -4703,8 +4809,8 @@ class ReviewLlmProcessor {
|
|
|
4703
4809
|
}
|
|
4704
4810
|
async buildReviewPrompt(specs, changedFiles, fileContents, commits, existingResult, whenModifiedCode, verbose, systemRules) {
|
|
4705
4811
|
const round = (existingResult?.round ?? 0) + 1;
|
|
4706
|
-
const { staticIssues, skippedFiles } = applyStaticRules(changedFiles, fileContents, systemRules, round, verbose);
|
|
4707
|
-
const fileDataList = changedFiles.
|
|
4812
|
+
const { staticIssues, skippedFiles } = applyStaticRules(changedFiles.toArray(), fileContents, systemRules, round, verbose);
|
|
4813
|
+
const fileDataList = changedFiles.nonDeletedFiles().map((file)=>{
|
|
4708
4814
|
const filename = file.filename;
|
|
4709
4815
|
if (skippedFiles.has(filename)) return null;
|
|
4710
4816
|
const contentLines = fileContents.get(filename);
|
|
@@ -4985,7 +5091,7 @@ class ReviewLlmProcessor {
|
|
|
4985
5091
|
*/ async generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose) {
|
|
4986
5092
|
const { userPrompt } = buildPrDescriptionPrompt({
|
|
4987
5093
|
commits,
|
|
4988
|
-
changedFiles,
|
|
5094
|
+
changedFiles: changedFiles.toArray(),
|
|
4989
5095
|
fileContents
|
|
4990
5096
|
});
|
|
4991
5097
|
try {
|
|
@@ -5025,7 +5131,7 @@ class ReviewLlmProcessor {
|
|
|
5025
5131
|
*/ async generatePrTitle(commits, changedFiles) {
|
|
5026
5132
|
const { userPrompt } = buildPrTitlePrompt({
|
|
5027
5133
|
commits,
|
|
5028
|
-
changedFiles
|
|
5134
|
+
changedFiles: changedFiles.toArray()
|
|
5029
5135
|
});
|
|
5030
5136
|
try {
|
|
5031
5137
|
const stream = this.llmProxyService.chatStream([
|
|
@@ -5068,9 +5174,7 @@ class ReviewLlmProcessor {
|
|
|
5068
5174
|
}
|
|
5069
5175
|
}
|
|
5070
5176
|
if (changedFiles.length > 0) {
|
|
5071
|
-
const added = changedFiles.
|
|
5072
|
-
const modified = changedFiles.filter((f)=>f.status === "modified").length;
|
|
5073
|
-
const deleted = changedFiles.filter((f)=>f.status === "deleted").length;
|
|
5177
|
+
const { added, modified, deleted } = changedFiles.countByStatus();
|
|
5074
5178
|
const stats = [];
|
|
5075
5179
|
if (added > 0) stats.push(`新增 ${added}`);
|
|
5076
5180
|
if (modified > 0) stats.push(`修改 ${modified}`);
|
|
@@ -5084,200 +5188,153 @@ class ReviewLlmProcessor {
|
|
|
5084
5188
|
}
|
|
5085
5189
|
}
|
|
5086
5190
|
|
|
5087
|
-
;// CONCATENATED MODULE: ./src/review.
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5191
|
+
;// CONCATENATED MODULE: ./src/review-source-resolver.ts
|
|
5091
5192
|
|
|
5092
5193
|
|
|
5093
5194
|
|
|
5094
5195
|
|
|
5095
5196
|
|
|
5096
5197
|
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5198
|
+
/**
|
|
5199
|
+
* 审查源数据解析器:根据审查模式(本地/PR/分支比较)获取 commits、changedFiles 等输入数据,
|
|
5200
|
+
* 并应用前置过滤管道(merge commit、files、commits、includes)。
|
|
5201
|
+
*
|
|
5202
|
+
* 从 ReviewService 中提取,职责单一化:只负责"获取和过滤源数据",不涉及 LLM 审查、报告生成等。
|
|
5203
|
+
*/ class ReviewSourceResolver {
|
|
5100
5204
|
gitProvider;
|
|
5101
|
-
config;
|
|
5102
|
-
reviewSpecService;
|
|
5103
|
-
llmProxyService;
|
|
5104
|
-
reviewReportService;
|
|
5105
|
-
issueVerifyService;
|
|
5106
|
-
deletionImpactService;
|
|
5107
5205
|
gitSdk;
|
|
5108
|
-
contextBuilder;
|
|
5109
5206
|
issueFilter;
|
|
5110
|
-
|
|
5111
|
-
resultModelDeps;
|
|
5112
|
-
constructor(gitProvider, config, reviewSpecService, llmProxyService, reviewReportService, issueVerifyService, deletionImpactService, gitSdk){
|
|
5207
|
+
constructor(gitProvider, gitSdk, issueFilter){
|
|
5113
5208
|
this.gitProvider = gitProvider;
|
|
5114
|
-
this.config = config;
|
|
5115
|
-
this.reviewSpecService = reviewSpecService;
|
|
5116
|
-
this.llmProxyService = llmProxyService;
|
|
5117
|
-
this.reviewReportService = reviewReportService;
|
|
5118
|
-
this.issueVerifyService = issueVerifyService;
|
|
5119
|
-
this.deletionImpactService = deletionImpactService;
|
|
5120
5209
|
this.gitSdk = gitSdk;
|
|
5121
|
-
this.
|
|
5122
|
-
this.issueFilter = new ReviewIssueFilter(gitProvider, config, reviewSpecService, issueVerifyService, gitSdk);
|
|
5123
|
-
this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
|
|
5124
|
-
this.resultModelDeps = {
|
|
5125
|
-
gitProvider,
|
|
5126
|
-
config,
|
|
5127
|
-
reviewSpecService,
|
|
5128
|
-
reviewReportService
|
|
5129
|
-
};
|
|
5130
|
-
}
|
|
5131
|
-
async getContextFromEnv(options) {
|
|
5132
|
-
return this.contextBuilder.getContextFromEnv(options);
|
|
5210
|
+
this.issueFilter = issueFilter;
|
|
5133
5211
|
}
|
|
5134
5212
|
/**
|
|
5135
|
-
*
|
|
5136
|
-
*
|
|
5137
|
-
*
|
|
5138
|
-
* 2. 获取 PR/分支的变更文件和提交记录
|
|
5139
|
-
* 3. 调用 LLM 进行代码审查
|
|
5140
|
-
* 4. 处理历史 issue(更新行号、验证修复状态)
|
|
5141
|
-
* 5. 生成并发布审查报告
|
|
5213
|
+
* 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
|
|
5214
|
+
* 包含前置过滤(merge commit、files、commits、includes)。
|
|
5215
|
+
* 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
|
|
5142
5216
|
*
|
|
5143
|
-
*
|
|
5144
|
-
*
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
return this.handleNoApplicableSpecs(context, applicableSpecs, changedFiles, commits);
|
|
5217
|
+
* 数据获取流程:
|
|
5218
|
+
* 1. 本地模式 → resolveLocalFiles(暂存区/未提交变更,无变更时回退分支比较)
|
|
5219
|
+
* 2. 直接文件模式(-f)→ 构造 changedFiles
|
|
5220
|
+
* 3. PR 模式 → resolvePrData(含重复 workflow 检查)
|
|
5221
|
+
* 4. 分支比较模式 → resolveBranchCompareData
|
|
5222
|
+
*
|
|
5223
|
+
* 前置过滤管道(applyPreFilters):
|
|
5224
|
+
* 0. merge commit 过滤
|
|
5225
|
+
* 1. --files 过滤
|
|
5226
|
+
* 2. --commits 过滤
|
|
5227
|
+
* 3. --includes 过滤(支持 status| 前缀语法)
|
|
5228
|
+
*/ async resolve(context) {
|
|
5229
|
+
const { prNumber, verbose, files, localMode } = context;
|
|
5230
|
+
const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
|
|
5231
|
+
let isLocalMode = !!localMode;
|
|
5232
|
+
let effectiveBaseRef = context.baseRef;
|
|
5233
|
+
let effectiveHeadRef = context.headRef;
|
|
5234
|
+
let prModel;
|
|
5235
|
+
let commits = [];
|
|
5236
|
+
let changedFiles = [];
|
|
5237
|
+
// ── 阶段 1:按模式获取 commits + changedFiles ──────────
|
|
5238
|
+
if (isLocalMode) {
|
|
5239
|
+
const local = this.resolveLocalFiles(localMode, verbose);
|
|
5240
|
+
if (local.earlyReturn) return {
|
|
5241
|
+
...local.earlyReturn,
|
|
5242
|
+
changedFiles: ChangedFileCollection.from(local.earlyReturn.changedFiles),
|
|
5243
|
+
isDirectFileMode: false,
|
|
5244
|
+
fileContents: new Map()
|
|
5245
|
+
};
|
|
5246
|
+
isLocalMode = local.isLocalMode;
|
|
5247
|
+
changedFiles = local.changedFiles;
|
|
5248
|
+
effectiveBaseRef = local.effectiveBaseRef ?? effectiveBaseRef;
|
|
5249
|
+
effectiveHeadRef = local.effectiveHeadRef ?? effectiveHeadRef;
|
|
5177
5250
|
}
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
let existingResultModel = null;
|
|
5183
|
-
if (context.ci && prModel) {
|
|
5184
|
-
existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
|
|
5185
|
-
if (existingResultModel && shouldLog(verbose, 1)) {
|
|
5186
|
-
console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
|
|
5251
|
+
if (isDirectFileMode) {
|
|
5252
|
+
// 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
|
|
5253
|
+
if (shouldLog(verbose, 1)) {
|
|
5254
|
+
console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
|
|
5187
5255
|
}
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5195
|
-
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
});
|
|
5216
|
-
// 静态规则产生的系统问题直接合并,不经过过滤管道
|
|
5217
|
-
if (reviewPrompt.staticIssues?.length) {
|
|
5218
|
-
result.issues = [
|
|
5219
|
-
...reviewPrompt.staticIssues,
|
|
5220
|
-
...result.issues
|
|
5221
|
-
];
|
|
5256
|
+
changedFiles = files.map((f)=>({
|
|
5257
|
+
filename: f,
|
|
5258
|
+
status: "modified"
|
|
5259
|
+
}));
|
|
5260
|
+
isLocalMode = true;
|
|
5261
|
+
} else if (prNumber) {
|
|
5262
|
+
const prData = await this.resolvePrData(context);
|
|
5263
|
+
if (prData.earlyReturn) {
|
|
5264
|
+
return {
|
|
5265
|
+
...prData,
|
|
5266
|
+
changedFiles: ChangedFileCollection.from(prData.changedFiles),
|
|
5267
|
+
headSha: prData.headSha,
|
|
5268
|
+
isLocalMode,
|
|
5269
|
+
isDirectFileMode,
|
|
5270
|
+
fileContents: new Map()
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
prModel = prData.prModel;
|
|
5274
|
+
commits = prData.commits;
|
|
5275
|
+
changedFiles = prData.changedFiles;
|
|
5276
|
+
} else if (effectiveBaseRef && effectiveHeadRef) {
|
|
5277
|
+
if (changedFiles.length === 0) {
|
|
5278
|
+
const branchData = await this.resolveBranchCompareData(context, effectiveBaseRef, effectiveHeadRef);
|
|
5279
|
+
commits = branchData.commits;
|
|
5280
|
+
changedFiles = branchData.changedFiles;
|
|
5281
|
+
}
|
|
5282
|
+
} else if (!isLocalMode) {
|
|
5222
5283
|
if (shouldLog(verbose, 1)) {
|
|
5223
|
-
console.log(
|
|
5284
|
+
console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, {
|
|
5285
|
+
prNumber,
|
|
5286
|
+
baseRef: context.baseRef,
|
|
5287
|
+
headRef: context.headRef
|
|
5288
|
+
});
|
|
5224
5289
|
}
|
|
5290
|
+
throw new Error("必须指定 PR 编号或者 base/head 分支");
|
|
5225
5291
|
}
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
const
|
|
5292
|
+
// ── 阶段 2:前置过滤管道 ─────────────────────────────
|
|
5293
|
+
({ commits, changedFiles } = await this.applyPreFilters(context, commits, changedFiles, isDirectFileMode));
|
|
5294
|
+
const headSha = prModel ? await prModel.getHeadSha() : context.headRef || "HEAD";
|
|
5295
|
+
const collectedFiles = ChangedFileCollection.from(changedFiles);
|
|
5296
|
+
const fileContents = await this.getFileContents(context.owner, context.repo, collectedFiles.toArray(), commits, headSha, context.prNumber, isLocalMode, context.verbose);
|
|
5297
|
+
return {
|
|
5231
5298
|
prModel,
|
|
5232
5299
|
commits,
|
|
5300
|
+
changedFiles: collectedFiles,
|
|
5233
5301
|
headSha,
|
|
5234
|
-
|
|
5302
|
+
isLocalMode,
|
|
5303
|
+
isDirectFileMode,
|
|
5235
5304
|
fileContents
|
|
5236
|
-
}
|
|
5237
|
-
// 6. 保存 + 输出
|
|
5238
|
-
await this.saveAndOutput(context, finalModel, commits);
|
|
5239
|
-
return finalModel.result;
|
|
5305
|
+
};
|
|
5240
5306
|
}
|
|
5241
|
-
// ───
|
|
5307
|
+
// ─── 数据获取子方法 ──────────────────────────────────────
|
|
5242
5308
|
/**
|
|
5243
|
-
*
|
|
5244
|
-
*
|
|
5245
|
-
*
|
|
5246
|
-
*/
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
let commits = [];
|
|
5254
|
-
let changedFiles = [];
|
|
5255
|
-
if (isLocalMode) {
|
|
5256
|
-
// 本地模式:从 git 获取未提交/暂存区的变更
|
|
5309
|
+
* 本地模式:获取暂存区或未提交的变更文件。
|
|
5310
|
+
* 如果本地无变更,自动回退到分支比较模式并检测 base/head 分支。
|
|
5311
|
+
* 同分支时通过 earlyReturn 提前终止。
|
|
5312
|
+
*/ resolveLocalFiles(localMode, verbose) {
|
|
5313
|
+
if (shouldLog(verbose, 1)) {
|
|
5314
|
+
console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
|
|
5315
|
+
}
|
|
5316
|
+
const localFiles = localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
|
|
5317
|
+
if (localFiles.length === 0) {
|
|
5318
|
+
// 本地无变更,回退到分支比较模式
|
|
5257
5319
|
if (shouldLog(verbose, 1)) {
|
|
5258
|
-
console.log(
|
|
5320
|
+
console.log(`ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`);
|
|
5259
5321
|
}
|
|
5260
|
-
const
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
// 同分支无法比较,提前返回
|
|
5273
|
-
if (effectiveBaseRef === effectiveHeadRef) {
|
|
5274
|
-
console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
|
|
5275
|
-
return {
|
|
5322
|
+
const effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
|
|
5323
|
+
const effectiveBaseRef = this.gitSdk.getDefaultBranch();
|
|
5324
|
+
if (shouldLog(verbose, 1)) {
|
|
5325
|
+
console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
|
|
5326
|
+
}
|
|
5327
|
+
// 同分支无法比较,提前返回
|
|
5328
|
+
if (effectiveBaseRef === effectiveHeadRef) {
|
|
5329
|
+
console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
|
|
5330
|
+
return {
|
|
5331
|
+
changedFiles: [],
|
|
5332
|
+
isLocalMode: false,
|
|
5333
|
+
earlyReturn: {
|
|
5276
5334
|
commits: [],
|
|
5277
5335
|
changedFiles: [],
|
|
5278
5336
|
headSha: "HEAD",
|
|
5279
5337
|
isLocalMode: false,
|
|
5280
|
-
isDirectFileMode: false,
|
|
5281
5338
|
earlyReturn: {
|
|
5282
5339
|
success: true,
|
|
5283
5340
|
description: "",
|
|
@@ -5285,176 +5342,533 @@ class ReviewService {
|
|
|
5285
5342
|
summary: [],
|
|
5286
5343
|
round: 1
|
|
5287
5344
|
}
|
|
5288
|
-
}
|
|
5289
|
-
}
|
|
5290
|
-
} else {
|
|
5291
|
-
// 一次性获取所有 diff,避免每个文件调用一次 git 命令
|
|
5292
|
-
const localDiffs = localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
|
|
5293
|
-
const diffMap = new Map(localDiffs.map((d)=>[
|
|
5294
|
-
d.filename,
|
|
5295
|
-
d.patch
|
|
5296
|
-
]));
|
|
5297
|
-
changedFiles = localFiles.map((f)=>({
|
|
5298
|
-
filename: f.filename,
|
|
5299
|
-
status: f.status,
|
|
5300
|
-
patch: diffMap.get(f.filename)
|
|
5301
|
-
}));
|
|
5302
|
-
if (shouldLog(verbose, 1)) {
|
|
5303
|
-
console.log(` Changed files: ${changedFiles.length}`);
|
|
5304
|
-
}
|
|
5345
|
+
}
|
|
5346
|
+
};
|
|
5305
5347
|
}
|
|
5348
|
+
return {
|
|
5349
|
+
changedFiles: [],
|
|
5350
|
+
isLocalMode: false,
|
|
5351
|
+
effectiveBaseRef,
|
|
5352
|
+
effectiveHeadRef
|
|
5353
|
+
};
|
|
5306
5354
|
}
|
|
5307
|
-
//
|
|
5308
|
-
|
|
5355
|
+
// 一次性获取所有 diff,避免每个文件调用一次 git 命令
|
|
5356
|
+
const localDiffs = localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
|
|
5357
|
+
const diffMap = new Map(localDiffs.map((d)=>[
|
|
5358
|
+
d.filename,
|
|
5359
|
+
d.patch
|
|
5360
|
+
]));
|
|
5361
|
+
const changedFiles = localFiles.map((f)=>({
|
|
5362
|
+
filename: f.filename,
|
|
5363
|
+
status: f.status,
|
|
5364
|
+
patch: diffMap.get(f.filename)
|
|
5365
|
+
}));
|
|
5366
|
+
if (shouldLog(verbose, 1)) {
|
|
5367
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
5368
|
+
}
|
|
5369
|
+
return {
|
|
5370
|
+
changedFiles,
|
|
5371
|
+
isLocalMode: true
|
|
5372
|
+
};
|
|
5373
|
+
}
|
|
5374
|
+
/**
|
|
5375
|
+
* PR 模式:获取 PR 信息、commits、changedFiles。
|
|
5376
|
+
* 同时检查是否有同名 review workflow 正在运行(防止重复审查)。
|
|
5377
|
+
*/ async resolvePrData(context) {
|
|
5378
|
+
const { owner, repo, prNumber, verbose, ci, duplicateWorkflowResolved } = context;
|
|
5379
|
+
if (shouldLog(verbose, 1)) {
|
|
5380
|
+
console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
|
|
5381
|
+
}
|
|
5382
|
+
const prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
|
|
5383
|
+
const prInfo = await prModel.getInfo();
|
|
5384
|
+
const commits = await prModel.getCommits();
|
|
5385
|
+
const changedFiles = await prModel.getFiles();
|
|
5386
|
+
if (shouldLog(verbose, 1)) {
|
|
5387
|
+
console.log(` PR: ${prInfo?.title}`);
|
|
5388
|
+
console.log(` Commits: ${commits.length}`);
|
|
5389
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
5390
|
+
}
|
|
5391
|
+
// 检查是否有其他同名 review workflow 正在运行中
|
|
5392
|
+
if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
|
|
5393
|
+
const duplicateResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, duplicateWorkflowResolved, verbose);
|
|
5394
|
+
if (duplicateResult) {
|
|
5395
|
+
return {
|
|
5396
|
+
prModel,
|
|
5397
|
+
commits,
|
|
5398
|
+
changedFiles,
|
|
5399
|
+
headSha: prInfo.head.sha,
|
|
5400
|
+
earlyReturn: duplicateResult
|
|
5401
|
+
};
|
|
5402
|
+
}
|
|
5403
|
+
}
|
|
5404
|
+
return {
|
|
5405
|
+
prModel,
|
|
5406
|
+
commits,
|
|
5407
|
+
changedFiles
|
|
5408
|
+
};
|
|
5409
|
+
}
|
|
5410
|
+
/**
|
|
5411
|
+
* 分支比较模式:获取 base...head 之间的 changedFiles 和 commits。
|
|
5412
|
+
*/ async resolveBranchCompareData(context, baseRef, headRef) {
|
|
5413
|
+
const { owner, repo, verbose } = context;
|
|
5414
|
+
if (shouldLog(verbose, 1)) {
|
|
5415
|
+
console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
|
|
5416
|
+
}
|
|
5417
|
+
const changedFiles = await this.issueFilter.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
|
|
5418
|
+
const commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
|
|
5419
|
+
if (shouldLog(verbose, 1)) {
|
|
5420
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
5421
|
+
console.log(` Commits: ${commits.length}`);
|
|
5422
|
+
}
|
|
5423
|
+
return {
|
|
5424
|
+
commits,
|
|
5425
|
+
changedFiles
|
|
5426
|
+
};
|
|
5427
|
+
}
|
|
5428
|
+
// ─── 前置过滤 ──────────────────────────────────────────
|
|
5429
|
+
/**
|
|
5430
|
+
* 前置过滤管道:对 commits 和 changedFiles 依次执行过滤。
|
|
5431
|
+
*
|
|
5432
|
+
* 过滤顺序:
|
|
5433
|
+
* 0. merge commit — 排除以 "Merge " 开头的 commit
|
|
5434
|
+
* 1. --files — 仅保留用户指定的文件
|
|
5435
|
+
* 2. --commits — 仅保留用户指定的 commit 及其涉及的文件
|
|
5436
|
+
* 3. --includes — glob 模式过滤文件和 commits(支持 status| 前缀语法)
|
|
5437
|
+
*/ async applyPreFilters(context, commits, rawChangedFiles, isDirectFileMode) {
|
|
5438
|
+
const { owner, repo, prNumber, verbose, includes, files, commits: filterCommits } = context;
|
|
5439
|
+
let changedFiles = ChangedFileCollection.from(rawChangedFiles);
|
|
5440
|
+
// 0. 过滤掉 merge commit
|
|
5441
|
+
{
|
|
5442
|
+
const before = commits.length;
|
|
5443
|
+
commits = commits.filter((c)=>{
|
|
5444
|
+
const message = c.commit?.message || "";
|
|
5445
|
+
return !message.startsWith("Merge ");
|
|
5446
|
+
});
|
|
5447
|
+
if (before !== commits.length && shouldLog(verbose, 1)) {
|
|
5448
|
+
console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
// 1. 按指定的 files 过滤
|
|
5452
|
+
if (files && files.length > 0) {
|
|
5453
|
+
const before = changedFiles.length;
|
|
5454
|
+
changedFiles = changedFiles.filterByFilenames(files);
|
|
5309
5455
|
if (shouldLog(verbose, 1)) {
|
|
5310
|
-
console.log(
|
|
5456
|
+
console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
|
|
5311
5457
|
}
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
} else if (prNumber) {
|
|
5458
|
+
}
|
|
5459
|
+
// 2. 按指定的 commits 过滤(同时过滤文件:仅保留属于匹配 commits 的文件)
|
|
5460
|
+
if (filterCommits && filterCommits.length > 0) {
|
|
5461
|
+
const beforeCommits = commits.length;
|
|
5462
|
+
commits = commits.filter((c)=>filterCommits.some((fc)=>fc && c.sha?.startsWith(fc)));
|
|
5318
5463
|
if (shouldLog(verbose, 1)) {
|
|
5319
|
-
console.log(
|
|
5464
|
+
console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
|
|
5320
5465
|
}
|
|
5321
|
-
|
|
5322
|
-
const
|
|
5323
|
-
|
|
5324
|
-
|
|
5466
|
+
const beforeFiles = changedFiles.length;
|
|
5467
|
+
const commitFilenames = new Set();
|
|
5468
|
+
for (const commit of commits){
|
|
5469
|
+
if (!commit.sha) continue;
|
|
5470
|
+
const commitFiles = await this.issueFilter.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
5471
|
+
commitFiles.forEach((f)=>commitFilenames.add(f));
|
|
5472
|
+
}
|
|
5473
|
+
changedFiles = changedFiles.filterByCommitFiles(commitFilenames);
|
|
5325
5474
|
if (shouldLog(verbose, 1)) {
|
|
5326
|
-
console.log(`
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
if (
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5475
|
+
console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
// 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
|
|
5479
|
+
if (isDirectFileMode && includes && includes.length > 0) {
|
|
5480
|
+
if (shouldLog(verbose, 1)) {
|
|
5481
|
+
console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
|
|
5482
|
+
}
|
|
5483
|
+
} else if (includes && includes.length > 0) {
|
|
5484
|
+
const beforeFiles = changedFiles.length;
|
|
5485
|
+
if (shouldLog(verbose, 2)) {
|
|
5486
|
+
console.log(`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f)=>({
|
|
5487
|
+
filename: f.filename,
|
|
5488
|
+
status: f.status
|
|
5489
|
+
})))}, includes=${JSON.stringify(includes)}`);
|
|
5490
|
+
}
|
|
5491
|
+
changedFiles = ChangedFileCollection.from(filterFilesByIncludes(changedFiles.toArray(), includes));
|
|
5492
|
+
if (shouldLog(verbose, 1)) {
|
|
5493
|
+
console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
5494
|
+
}
|
|
5495
|
+
if (shouldLog(verbose, 2)) {
|
|
5496
|
+
console.log(`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f)=>f.filename))}`);
|
|
5497
|
+
}
|
|
5498
|
+
// 按 includes glob 过滤 commits:仅保留涉及匹配文件的 commits
|
|
5499
|
+
const globs = extractGlobsFromIncludes(includes);
|
|
5500
|
+
const beforeCommits = commits.length;
|
|
5501
|
+
const filteredCommits = [];
|
|
5502
|
+
for (const commit of commits){
|
|
5503
|
+
if (!commit.sha) continue;
|
|
5504
|
+
const commitFiles = await this.issueFilter.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
5505
|
+
if (micromatch_0.some(commitFiles, globs)) {
|
|
5506
|
+
filteredCommits.push(commit);
|
|
5343
5507
|
}
|
|
5344
5508
|
}
|
|
5345
|
-
|
|
5346
|
-
if (
|
|
5347
|
-
|
|
5348
|
-
|
|
5509
|
+
commits = filteredCommits;
|
|
5510
|
+
if (shouldLog(verbose, 1)) {
|
|
5511
|
+
console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
|
|
5512
|
+
}
|
|
5513
|
+
}
|
|
5514
|
+
return {
|
|
5515
|
+
commits,
|
|
5516
|
+
changedFiles: changedFiles.toArray()
|
|
5517
|
+
};
|
|
5518
|
+
}
|
|
5519
|
+
// ─── 文件内容 ─────────────────────────────────────────
|
|
5520
|
+
/**
|
|
5521
|
+
* 获取文件内容并构建行号到 commit hash 的映射
|
|
5522
|
+
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
5523
|
+
*/ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, isLocalMode, verbose) {
|
|
5524
|
+
const contents = new Map();
|
|
5525
|
+
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
5526
|
+
if (shouldLog(verbose, 1)) {
|
|
5527
|
+
console.log(`📊 正在构建行号到变更的映射...`);
|
|
5528
|
+
}
|
|
5529
|
+
for (const file of changedFiles){
|
|
5530
|
+
if (file.filename && file.status !== "deleted") {
|
|
5531
|
+
try {
|
|
5532
|
+
let rawContent;
|
|
5533
|
+
if (isLocalMode) {
|
|
5534
|
+
rawContent = this.gitSdk.getWorkingFileContent(file.filename);
|
|
5535
|
+
} else if (prNumber) {
|
|
5536
|
+
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
5537
|
+
} else {
|
|
5538
|
+
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
5539
|
+
}
|
|
5540
|
+
const lines = rawContent.split("\n");
|
|
5541
|
+
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
5542
|
+
const isNewFile = file.status === "added" || file.status === "A" || file.additions && file.additions > 0 && file.deletions === 0 && !file.patch;
|
|
5543
|
+
if (changedLines.size === 0 && isNewFile) {
|
|
5544
|
+
changedLines = new Set(lines.map((_, i)=>i + 1));
|
|
5545
|
+
if (shouldLog(verbose, 2)) {
|
|
5546
|
+
console.log(` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`);
|
|
5547
|
+
}
|
|
5548
|
+
}
|
|
5549
|
+
let blameMap;
|
|
5550
|
+
if (!isLocalMode) {
|
|
5551
|
+
try {
|
|
5552
|
+
blameMap = await this.gitSdk.getFileBlame(ref, file.filename);
|
|
5553
|
+
} catch {
|
|
5554
|
+
// blame 失败时回退到 latestCommitHash
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
if (shouldLog(verbose, 3)) {
|
|
5558
|
+
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
5559
|
+
console.log(` blame: ${blameMap ? `${blameMap.size} 行` : `不可用,回退到 ${latestCommitHash}`}`);
|
|
5560
|
+
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
5561
|
+
console.log(` 变更行号: ${Array.from(changedLines).sort((a, b)=>a - b).join(", ")}`);
|
|
5562
|
+
} else if (changedLines.size > 20) {
|
|
5563
|
+
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
5564
|
+
}
|
|
5565
|
+
if (!file.patch) {
|
|
5566
|
+
console.log(` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`);
|
|
5567
|
+
} else {
|
|
5568
|
+
console.log(` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`);
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
const contentLines = lines.map((line, index)=>{
|
|
5572
|
+
const lineNum = index + 1;
|
|
5573
|
+
if (!changedLines.has(lineNum)) {
|
|
5574
|
+
return [
|
|
5575
|
+
"-------",
|
|
5576
|
+
line
|
|
5577
|
+
];
|
|
5578
|
+
}
|
|
5579
|
+
const hash = blameMap?.get(lineNum) ?? latestCommitHash;
|
|
5580
|
+
return [
|
|
5581
|
+
hash,
|
|
5582
|
+
line
|
|
5583
|
+
];
|
|
5584
|
+
});
|
|
5585
|
+
contents.set(file.filename, contentLines);
|
|
5586
|
+
} catch (error) {
|
|
5587
|
+
console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
if (shouldLog(verbose, 1)) {
|
|
5592
|
+
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
5593
|
+
}
|
|
5594
|
+
return contents;
|
|
5595
|
+
}
|
|
5596
|
+
// ─── 重复 workflow 检查 ──────────────────────────────────
|
|
5597
|
+
/**
|
|
5598
|
+
* 检查是否有其他同名 review workflow 正在运行中。
|
|
5599
|
+
* 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论。
|
|
5600
|
+
*/ async checkDuplicateWorkflow(prModel, headSha, mode, verbose) {
|
|
5601
|
+
const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
|
|
5602
|
+
const prMatch = ref.match(/refs\/pull\/(\d+)/);
|
|
5603
|
+
const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
|
|
5604
|
+
try {
|
|
5605
|
+
const runningWorkflows = await prModel.listWorkflowRuns({
|
|
5606
|
+
status: "in_progress"
|
|
5607
|
+
});
|
|
5608
|
+
const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
|
|
5609
|
+
const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
|
|
5610
|
+
const duplicateReviewRuns = runningWorkflows.filter((w)=>w.sha === headSha && w.name === currentWorkflowName && (!currentRunId || String(w.id) !== currentRunId));
|
|
5611
|
+
if (duplicateReviewRuns.length > 0) {
|
|
5612
|
+
if (mode === "delete") {
|
|
5613
|
+
// 删除模式:清理旧的 AI Review 评论和 PR Review
|
|
5614
|
+
if (shouldLog(verbose, 1)) {
|
|
5615
|
+
console.log(`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`);
|
|
5616
|
+
}
|
|
5617
|
+
await this.cleanupDuplicateAiReviews(prModel, verbose);
|
|
5618
|
+
// 清理后继续执行当前审查
|
|
5619
|
+
return null;
|
|
5349
5620
|
}
|
|
5350
|
-
|
|
5351
|
-
commits = await this.getCommitsBetweenRefs(effectiveBaseRef, effectiveHeadRef);
|
|
5621
|
+
// 跳过模式(默认)
|
|
5352
5622
|
if (shouldLog(verbose, 1)) {
|
|
5353
|
-
console.log(
|
|
5354
|
-
console.log(` Commits: ${commits.length}`);
|
|
5623
|
+
console.log(`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`);
|
|
5355
5624
|
}
|
|
5625
|
+
return {
|
|
5626
|
+
success: true,
|
|
5627
|
+
description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
|
|
5628
|
+
issues: [],
|
|
5629
|
+
summary: [],
|
|
5630
|
+
round: 1
|
|
5631
|
+
};
|
|
5356
5632
|
}
|
|
5357
|
-
}
|
|
5633
|
+
} catch (error) {
|
|
5358
5634
|
if (shouldLog(verbose, 1)) {
|
|
5359
|
-
console.
|
|
5360
|
-
prNumber,
|
|
5361
|
-
baseRef,
|
|
5362
|
-
headRef
|
|
5363
|
-
});
|
|
5635
|
+
console.warn(`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`, error instanceof Error ? error.message : error);
|
|
5364
5636
|
}
|
|
5365
|
-
throw new Error("必须指定 PR 编号或者 base/head 分支");
|
|
5366
5637
|
}
|
|
5367
|
-
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5638
|
+
return null;
|
|
5639
|
+
}
|
|
5640
|
+
/**
|
|
5641
|
+
* 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
|
|
5642
|
+
*/ async cleanupDuplicateAiReviews(prModel, verbose) {
|
|
5643
|
+
try {
|
|
5644
|
+
// 删除 Issue Comments(主评论)
|
|
5645
|
+
const comments = await prModel.getComments();
|
|
5646
|
+
const aiComments = comments.filter((c)=>c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
5647
|
+
let deletedComments = 0;
|
|
5648
|
+
for (const comment of aiComments){
|
|
5649
|
+
if (comment.id) {
|
|
5650
|
+
try {
|
|
5651
|
+
await prModel.deleteComment(comment.id);
|
|
5652
|
+
deletedComments++;
|
|
5653
|
+
} catch {
|
|
5654
|
+
// 忽略删除失败
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
if (deletedComments > 0 && shouldLog(verbose, 1)) {
|
|
5659
|
+
console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
|
|
5660
|
+
}
|
|
5661
|
+
// 删除 PR Reviews(行级评论)
|
|
5662
|
+
const reviews = await prModel.getReviews();
|
|
5663
|
+
const aiReviews = reviews.filter((r)=>r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
5664
|
+
let deletedReviews = 0;
|
|
5665
|
+
for (const review of aiReviews){
|
|
5666
|
+
if (review.id) {
|
|
5667
|
+
try {
|
|
5668
|
+
await prModel.deleteReview(review.id);
|
|
5669
|
+
deletedReviews++;
|
|
5670
|
+
} catch {
|
|
5671
|
+
// 已提交的 review 无法删除,忽略
|
|
5672
|
+
}
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
5675
|
+
if (deletedReviews > 0 && shouldLog(verbose, 1)) {
|
|
5676
|
+
console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
|
|
5677
|
+
}
|
|
5678
|
+
} catch (error) {
|
|
5679
|
+
if (shouldLog(verbose, 1)) {
|
|
5680
|
+
console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
|
|
5681
|
+
}
|
|
5682
|
+
}
|
|
5683
|
+
}
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
;// CONCATENATED MODULE: ./src/review.service.ts
|
|
5687
|
+
|
|
5688
|
+
|
|
5689
|
+
|
|
5690
|
+
|
|
5691
|
+
|
|
5692
|
+
|
|
5693
|
+
|
|
5694
|
+
|
|
5695
|
+
|
|
5696
|
+
|
|
5697
|
+
|
|
5698
|
+
class ReviewService {
|
|
5699
|
+
gitProvider;
|
|
5700
|
+
config;
|
|
5701
|
+
reviewSpecService;
|
|
5702
|
+
llmProxyService;
|
|
5703
|
+
reviewReportService;
|
|
5704
|
+
issueVerifyService;
|
|
5705
|
+
deletionImpactService;
|
|
5706
|
+
gitSdk;
|
|
5707
|
+
contextBuilder;
|
|
5708
|
+
issueFilter;
|
|
5709
|
+
llmProcessor;
|
|
5710
|
+
resultModelDeps;
|
|
5711
|
+
sourceResolver;
|
|
5712
|
+
constructor(gitProvider, config, reviewSpecService, llmProxyService, reviewReportService, issueVerifyService, deletionImpactService, gitSdk){
|
|
5713
|
+
this.gitProvider = gitProvider;
|
|
5714
|
+
this.config = config;
|
|
5715
|
+
this.reviewSpecService = reviewSpecService;
|
|
5716
|
+
this.llmProxyService = llmProxyService;
|
|
5717
|
+
this.reviewReportService = reviewReportService;
|
|
5718
|
+
this.issueVerifyService = issueVerifyService;
|
|
5719
|
+
this.deletionImpactService = deletionImpactService;
|
|
5720
|
+
this.gitSdk = gitSdk;
|
|
5721
|
+
this.contextBuilder = new ReviewContextBuilder(gitProvider, config, gitSdk);
|
|
5722
|
+
this.issueFilter = new ReviewIssueFilter(gitProvider, config, reviewSpecService, issueVerifyService, gitSdk);
|
|
5723
|
+
this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
|
|
5724
|
+
this.sourceResolver = new ReviewSourceResolver(gitProvider, gitSdk, this.issueFilter);
|
|
5725
|
+
this.resultModelDeps = {
|
|
5726
|
+
gitProvider,
|
|
5727
|
+
config,
|
|
5728
|
+
reviewSpecService,
|
|
5729
|
+
reviewReportService
|
|
5730
|
+
};
|
|
5731
|
+
}
|
|
5732
|
+
async getContextFromEnv(options) {
|
|
5733
|
+
return this.contextBuilder.getContextFromEnv(options);
|
|
5734
|
+
}
|
|
5735
|
+
/**
|
|
5736
|
+
* 执行代码审查的主方法
|
|
5737
|
+
* 该方法负责协调整个审查流程,包括:
|
|
5738
|
+
* 1. 加载审查规范(specs)
|
|
5739
|
+
* 2. 获取 PR/分支的变更文件和提交记录
|
|
5740
|
+
* 3. 调用 LLM 进行代码审查
|
|
5741
|
+
* 4. 处理历史 issue(更新行号、验证修复状态)
|
|
5742
|
+
* 5. 生成并发布审查报告
|
|
5743
|
+
*
|
|
5744
|
+
* @param context 审查上下文,包含 owner、repo、prNumber 等信息
|
|
5745
|
+
* @returns 审查结果,包含发现的问题列表和统计信息
|
|
5746
|
+
*/ async execute(context) {
|
|
5747
|
+
const { specSources, verbose, llmMode, deletionOnly } = context;
|
|
5748
|
+
if (shouldLog(verbose, 1)) {
|
|
5749
|
+
console.log(`🔍 Review 启动`);
|
|
5750
|
+
console.log(` DRY-RUN mode: ${context.dryRun ? "enabled" : "disabled"}`);
|
|
5751
|
+
console.log(` CI mode: ${context.ci ? "enabled" : "disabled"}`);
|
|
5752
|
+
if (context.localMode) console.log(` Local mode: ${context.localMode}`);
|
|
5753
|
+
console.log(` Verbose: ${verbose}`);
|
|
5754
|
+
}
|
|
5755
|
+
// 早期分流
|
|
5756
|
+
if (deletionOnly) return this.executeDeletionOnly(context);
|
|
5757
|
+
if (context.eventAction === "closed" || context.flush) return this.executeCollectOnly(context);
|
|
5758
|
+
// 1. 解析输入数据(本地/PR/分支模式 + 前置过滤)
|
|
5759
|
+
const source = await this.resolveSourceData(context);
|
|
5760
|
+
if (source.earlyReturn) return source.earlyReturn;
|
|
5761
|
+
const effectiveWhenModifiedCode = source.isDirectFileMode ? undefined : context.whenModifiedCode;
|
|
5762
|
+
if (source.isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
|
|
5763
|
+
console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
|
|
5378
5764
|
}
|
|
5379
|
-
//
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
}
|
|
5765
|
+
// 2. 规则匹配
|
|
5766
|
+
const allSpecs = await this.issueFilter.loadSpecs(specSources, verbose);
|
|
5767
|
+
const specs = this.reviewSpecService.filterApplicableSpecs(allSpecs, source.changedFiles);
|
|
5768
|
+
if (shouldLog(verbose, 2)) {
|
|
5769
|
+
console.log(`[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`);
|
|
5770
|
+
console.log(`[execute] filterApplicableSpecs: ${specs.length} applicable out of ${allSpecs.length}, changedFiles=${JSON.stringify(source.changedFiles.filenames())}`);
|
|
5386
5771
|
}
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
const beforeCommits = commits.length;
|
|
5390
|
-
commits = commits.filter((c)=>filterCommits.some((fc)=>fc && c.sha?.startsWith(fc)));
|
|
5391
|
-
if (shouldLog(verbose, 1)) {
|
|
5392
|
-
console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
|
|
5393
|
-
}
|
|
5394
|
-
const beforeFiles = changedFiles.length;
|
|
5395
|
-
const commitFilenames = new Set();
|
|
5396
|
-
for (const commit of commits){
|
|
5397
|
-
if (!commit.sha) continue;
|
|
5398
|
-
const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
5399
|
-
commitFiles.forEach((f)=>commitFilenames.add(f));
|
|
5400
|
-
}
|
|
5401
|
-
changedFiles = changedFiles.filter((f)=>commitFilenames.has(f.filename || ""));
|
|
5402
|
-
if (shouldLog(verbose, 1)) {
|
|
5403
|
-
console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
5404
|
-
}
|
|
5772
|
+
if (shouldLog(verbose, 1)) {
|
|
5773
|
+
console.log(` 适用的规则文件: ${specs.length}`);
|
|
5405
5774
|
}
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
}
|
|
5419
|
-
changedFiles = filterFilesByIncludes(changedFiles, includes);
|
|
5420
|
-
if (shouldLog(verbose, 1)) {
|
|
5421
|
-
console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
5422
|
-
}
|
|
5423
|
-
if (shouldLog(verbose, 2)) {
|
|
5424
|
-
console.log(`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f)=>f.filename))}`);
|
|
5425
|
-
}
|
|
5426
|
-
const globs = extractGlobsFromIncludes(includes);
|
|
5427
|
-
const beforeCommits = commits.length;
|
|
5428
|
-
const filteredCommits = [];
|
|
5429
|
-
for (const commit of commits){
|
|
5430
|
-
if (!commit.sha) continue;
|
|
5431
|
-
const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
5432
|
-
if (micromatch_0.some(commitFiles, globs)) {
|
|
5433
|
-
filteredCommits.push(commit);
|
|
5434
|
-
}
|
|
5435
|
-
}
|
|
5436
|
-
commits = filteredCommits;
|
|
5437
|
-
if (shouldLog(verbose, 1)) {
|
|
5438
|
-
console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
|
|
5775
|
+
if (specs.length === 0 || source.changedFiles.length === 0) {
|
|
5776
|
+
return this.handleNoApplicableSpecs(context, specs, source.changedFiles, source.commits);
|
|
5777
|
+
}
|
|
5778
|
+
// 3. LLM 审查
|
|
5779
|
+
const { fileContents } = source;
|
|
5780
|
+
if (!llmMode) throw new Error("必须指定 LLM 类型");
|
|
5781
|
+
// 获取上一次的审查结果(用于提示词优化和轮次推进)
|
|
5782
|
+
let existingResultModel = null;
|
|
5783
|
+
if (context.ci && source.prModel) {
|
|
5784
|
+
existingResultModel = await ReviewResultModel.loadFromPr(source.prModel, this.resultModelDeps);
|
|
5785
|
+
if (existingResultModel && shouldLog(verbose, 1)) {
|
|
5786
|
+
console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
|
|
5439
5787
|
}
|
|
5440
5788
|
}
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5789
|
+
if (shouldLog(verbose, 1)) {
|
|
5790
|
+
console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
|
|
5791
|
+
}
|
|
5792
|
+
const reviewPrompt = await this.llmProcessor.buildReviewPrompt(specs, source.changedFiles, fileContents, source.commits, existingResultModel?.result ?? null, effectiveWhenModifiedCode, verbose, context.systemRules);
|
|
5793
|
+
// 4. 运行 LLM 审查 + 过滤新 issues
|
|
5794
|
+
const result = await this.buildReviewResult(context, reviewPrompt, llmMode, {
|
|
5795
|
+
specs,
|
|
5796
|
+
fileContents,
|
|
5797
|
+
changedFiles: source.changedFiles,
|
|
5798
|
+
commits: source.commits,
|
|
5799
|
+
isDirectFileMode: source.isDirectFileMode
|
|
5800
|
+
});
|
|
5801
|
+
// 5. 构建最终的 ReviewResultModel
|
|
5802
|
+
const finalModel = await this.buildFinalModel(context, result, {
|
|
5803
|
+
prModel: source.prModel,
|
|
5804
|
+
commits: source.commits,
|
|
5805
|
+
headSha: source.headSha,
|
|
5806
|
+
specs,
|
|
5807
|
+
fileContents
|
|
5808
|
+
}, existingResultModel);
|
|
5809
|
+
// 6. 保存 + 输出
|
|
5810
|
+
await this.saveAndOutput(context, finalModel, source.commits);
|
|
5811
|
+
return finalModel.result;
|
|
5812
|
+
}
|
|
5813
|
+
/**
|
|
5814
|
+
* 运行 LLM 审查并构建过滤后的 ReviewResult:
|
|
5815
|
+
* - 调用 LLM 生成问题列表
|
|
5816
|
+
* - 填充 PR 标题/描述
|
|
5817
|
+
* - 过滤新 issues(去重、commit 范围等)
|
|
5818
|
+
* - 合并静态规则问题
|
|
5819
|
+
*/ async buildReviewResult(context, reviewPrompt, llmMode, source) {
|
|
5820
|
+
const { verbose } = context;
|
|
5821
|
+
const { specs, fileContents, changedFiles, commits, isDirectFileMode } = source;
|
|
5822
|
+
const result = await this.llmProcessor.runLLMReview(llmMode, reviewPrompt, {
|
|
5823
|
+
verbose,
|
|
5824
|
+
concurrency: context.concurrency,
|
|
5825
|
+
timeout: context.timeout,
|
|
5826
|
+
retries: context.retries,
|
|
5827
|
+
retryDelay: context.retryDelay
|
|
5828
|
+
});
|
|
5829
|
+
// 填充 PR 功能描述和标题
|
|
5830
|
+
const prInfo = context.generateDescription ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
|
|
5831
|
+
result.title = prInfo.title;
|
|
5832
|
+
result.description = prInfo.description;
|
|
5833
|
+
if (shouldLog(verbose, 1)) {
|
|
5834
|
+
console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
|
|
5835
|
+
}
|
|
5836
|
+
result.issues = await this.issueFilter.fillIssueCode(result.issues, fileContents);
|
|
5837
|
+
result.issues = this.filterNewIssues(result.issues, specs, {
|
|
5444
5838
|
commits,
|
|
5839
|
+
fileContents,
|
|
5445
5840
|
changedFiles,
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5841
|
+
isDirectFileMode,
|
|
5842
|
+
context
|
|
5843
|
+
});
|
|
5844
|
+
// 静态规则产生的系统问题直接合并,不经过过滤管道
|
|
5845
|
+
if (reviewPrompt.staticIssues?.length) {
|
|
5846
|
+
result.issues = [
|
|
5847
|
+
...reviewPrompt.staticIssues,
|
|
5848
|
+
...result.issues
|
|
5849
|
+
];
|
|
5850
|
+
if (shouldLog(verbose, 1)) {
|
|
5851
|
+
console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
|
|
5852
|
+
}
|
|
5853
|
+
}
|
|
5854
|
+
if (shouldLog(verbose, 1)) {
|
|
5855
|
+
console.log(`📝 最终发现 ${result.issues.length} 个问题`);
|
|
5856
|
+
}
|
|
5857
|
+
return result;
|
|
5858
|
+
}
|
|
5859
|
+
/**
|
|
5860
|
+
* 解析输入数据:委托给 ReviewSourceResolver。
|
|
5861
|
+
* @see ReviewSourceResolver#resolve
|
|
5862
|
+
*/ async resolveSourceData(context) {
|
|
5863
|
+
return this.sourceResolver.resolve(context);
|
|
5450
5864
|
}
|
|
5451
5865
|
/**
|
|
5452
5866
|
* LLM 审查后的 issue 过滤管道:
|
|
5453
5867
|
* includes → 规则存在性 → overrides → 变更行过滤 → 格式化
|
|
5454
|
-
*/ filterNewIssues(issues, specs,
|
|
5868
|
+
*/ filterNewIssues(issues, specs, opts) {
|
|
5455
5869
|
const { commits, fileContents, changedFiles, isDirectFileMode, context } = opts;
|
|
5456
5870
|
const { verbose } = context;
|
|
5457
|
-
let filtered = this.reviewSpecService.filterIssuesByIncludes(issues,
|
|
5871
|
+
let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, specs);
|
|
5458
5872
|
if (shouldLog(verbose, 1)) {
|
|
5459
5873
|
console.log(` 应用 includes 过滤后: ${filtered.length} 个问题`);
|
|
5460
5874
|
}
|
|
@@ -5462,26 +5876,26 @@ class ReviewService {
|
|
|
5462
5876
|
if (shouldLog(verbose, 1)) {
|
|
5463
5877
|
console.log(` 应用规则存在性过滤后: ${filtered.length} 个问题`);
|
|
5464
5878
|
}
|
|
5465
|
-
filtered = this.reviewSpecService.filterIssuesByOverrides(filtered,
|
|
5879
|
+
filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, specs, verbose);
|
|
5466
5880
|
// 变更行过滤
|
|
5467
5881
|
if (shouldLog(verbose, 3)) {
|
|
5468
5882
|
console.log(` 🔍 变更行过滤条件检查:`);
|
|
5469
5883
|
console.log(` showAll=${context.showAll}, isDirectFileMode=${isDirectFileMode}, commits.length=${commits.length}`);
|
|
5470
5884
|
}
|
|
5471
|
-
if (!context.showAll && !isDirectFileMode
|
|
5885
|
+
if (!context.showAll && !isDirectFileMode) {
|
|
5472
5886
|
if (shouldLog(verbose, 2)) {
|
|
5473
5887
|
console.log(` 🔍 开始变更行过滤,当前 ${filtered.length} 个问题`);
|
|
5474
5888
|
}
|
|
5475
|
-
filtered = this.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
|
|
5889
|
+
filtered = this.issueFilter.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
|
|
5476
5890
|
if (shouldLog(verbose, 2)) {
|
|
5477
5891
|
console.log(` 🔍 变更行过滤完成,剩余 ${filtered.length} 个问题`);
|
|
5478
5892
|
}
|
|
5479
5893
|
} else if (shouldLog(verbose, 1)) {
|
|
5480
|
-
console.log(` 跳过变更行过滤 (${context.showAll ? "showAll=true" :
|
|
5894
|
+
console.log(` 跳过变更行过滤 (${context.showAll ? "showAll=true" : "直接审查文件模式"})`);
|
|
5481
5895
|
}
|
|
5482
5896
|
filtered = this.reviewSpecService.formatIssues(filtered, {
|
|
5483
5897
|
specs,
|
|
5484
|
-
changedFiles
|
|
5898
|
+
changedFiles: changedFiles.toArray()
|
|
5485
5899
|
});
|
|
5486
5900
|
if (shouldLog(verbose, 1)) {
|
|
5487
5901
|
console.log(` 应用格式化后: ${filtered.length} 个问题`);
|
|
@@ -5493,6 +5907,7 @@ class ReviewService {
|
|
|
5493
5907
|
*/ async buildFinalModel(context, result, source, existingResultModel) {
|
|
5494
5908
|
const { prModel, commits, headSha, specs, fileContents } = source;
|
|
5495
5909
|
const { verbose, ci } = context;
|
|
5910
|
+
result.headSha = headSha;
|
|
5496
5911
|
if (ci && prModel && existingResultModel && existingResultModel.issues.length > 0) {
|
|
5497
5912
|
if (shouldLog(verbose, 1)) {
|
|
5498
5913
|
console.log(`📋 已有评论中存在 ${existingResultModel.issues.length} 个问题`);
|
|
@@ -5502,32 +5917,24 @@ class ReviewService {
|
|
|
5502
5917
|
// 如果文件有变更,将该文件的历史问题标记为无效
|
|
5503
5918
|
const reviewConf = this.config.getPluginConfig("review");
|
|
5504
5919
|
if (reviewConf.invalidateChangedFiles !== "off" && reviewConf.invalidateChangedFiles !== "keep") {
|
|
5505
|
-
await existingResultModel.invalidateChangedFiles(headSha, verbose);
|
|
5920
|
+
await existingResultModel.invalidateChangedFiles(headSha, fileContents, verbose);
|
|
5506
5921
|
}
|
|
5507
5922
|
// 验证历史问题是否已修复
|
|
5508
5923
|
if (context.verifyFixes) {
|
|
5509
5924
|
existingResultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, existingResultModel.issues, commits, {
|
|
5510
5925
|
specs,
|
|
5511
5926
|
fileContents
|
|
5512
|
-
}
|
|
5927
|
+
});
|
|
5513
5928
|
} else {
|
|
5514
5929
|
if (shouldLog(verbose, 1)) {
|
|
5515
5930
|
console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
|
|
5516
5931
|
}
|
|
5517
5932
|
}
|
|
5518
|
-
//
|
|
5519
|
-
const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(result.issues, existingResultModel.issues);
|
|
5520
|
-
if (skippedCount > 0 && shouldLog(verbose, 1)) {
|
|
5521
|
-
console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
|
|
5522
|
-
}
|
|
5523
|
-
result.issues = newIssues;
|
|
5524
|
-
result.headSha = headSha;
|
|
5525
|
-
// 自动 round 递增 + issues 合并
|
|
5933
|
+
// 自动 round 递增 + 去重 + issues 合并
|
|
5526
5934
|
return existingResultModel.nextRound(result);
|
|
5527
5935
|
}
|
|
5528
5936
|
// 首次审查或无历史结果
|
|
5529
5937
|
result.round = 1;
|
|
5530
|
-
result.headSha = headSha;
|
|
5531
5938
|
result.issues = result.issues.map((issue)=>({
|
|
5532
5939
|
...issue,
|
|
5533
5940
|
round: 1
|
|
@@ -5541,7 +5948,7 @@ class ReviewService {
|
|
|
5541
5948
|
const prModel = finalModel.pr.number > 0 ? finalModel.pr : undefined;
|
|
5542
5949
|
// 填充 author 信息
|
|
5543
5950
|
if (commits.length > 0) {
|
|
5544
|
-
finalModel.issues = await this.fillIssueAuthors(finalModel.issues, commits, owner, repo, verbose);
|
|
5951
|
+
finalModel.issues = await this.contextBuilder.fillIssueAuthors(finalModel.issues, commits, owner, repo, verbose);
|
|
5545
5952
|
}
|
|
5546
5953
|
// 删除代码影响分析(在 save 之前完成,避免多次 save 产生重复的 Round 评论)
|
|
5547
5954
|
if (context.analyzeDeletions && llmMode) {
|
|
@@ -5608,16 +6015,27 @@ class ReviewService {
|
|
|
5608
6015
|
}
|
|
5609
6016
|
// 2. 获取 commits 并填充 author 信息
|
|
5610
6017
|
const commits = await prModel.getCommits();
|
|
5611
|
-
resultModel.issues = await this.fillIssueAuthors(resultModel.issues, commits, owner, repo, verbose);
|
|
6018
|
+
resultModel.issues = await this.contextBuilder.fillIssueAuthors(resultModel.issues, commits, owner, repo, verbose);
|
|
5612
6019
|
// 3. 同步已解决的评论状态
|
|
5613
6020
|
await resultModel.syncResolved();
|
|
5614
6021
|
// 4. 同步评论 reactions(👍/👎/☹️)
|
|
5615
6022
|
await resultModel.syncReactions(verbose);
|
|
5616
6023
|
// 5. LLM 验证历史问题是否已修复
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
6024
|
+
if (context.verifyFixes && context.specSources?.length) {
|
|
6025
|
+
try {
|
|
6026
|
+
const changedFiles = await prModel.getFiles();
|
|
6027
|
+
const headSha = await prModel.getHeadSha();
|
|
6028
|
+
const verifySpecs = await this.issueFilter.loadSpecs(context.specSources, verbose);
|
|
6029
|
+
const verifyFileContents = await this.sourceResolver.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, false, verbose);
|
|
6030
|
+
resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, resultModel.issues, commits, {
|
|
6031
|
+
specs: verifySpecs,
|
|
6032
|
+
fileContents: verifyFileContents
|
|
6033
|
+
});
|
|
6034
|
+
} catch (error) {
|
|
6035
|
+
console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
|
|
6036
|
+
}
|
|
6037
|
+
} else if (!context.verifyFixes && shouldLog(verbose, 1)) {
|
|
6038
|
+
console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
|
|
5621
6039
|
}
|
|
5622
6040
|
// 6. 统计问题状态并设置到 result
|
|
5623
6041
|
const stats = resultModel.updateStats();
|
|
@@ -5660,20 +6078,20 @@ class ReviewService {
|
|
|
5660
6078
|
// 获取 commits 和 changedFiles 用于生成描述
|
|
5661
6079
|
let prModel;
|
|
5662
6080
|
let commits = [];
|
|
5663
|
-
let changedFiles =
|
|
6081
|
+
let changedFiles = ChangedFileCollection.empty();
|
|
5664
6082
|
if (prNumber) {
|
|
5665
6083
|
prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
|
|
5666
6084
|
commits = await prModel.getCommits();
|
|
5667
|
-
changedFiles = await prModel.getFiles();
|
|
6085
|
+
changedFiles = ChangedFileCollection.from(await prModel.getFiles());
|
|
5668
6086
|
} else if (baseRef && headRef) {
|
|
5669
|
-
changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
|
|
5670
|
-
commits = await this.getCommitsBetweenRefs(baseRef, headRef);
|
|
6087
|
+
changedFiles = ChangedFileCollection.from(await this.issueFilter.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef));
|
|
6088
|
+
commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
|
|
5671
6089
|
}
|
|
5672
6090
|
// 使用 includes 过滤文件(支持 added|/modified|/deleted| 前缀语法)
|
|
5673
6091
|
if (context.includes && context.includes.length > 0) {
|
|
5674
|
-
changedFiles = filterFilesByIncludes(changedFiles, context.includes);
|
|
6092
|
+
changedFiles = ChangedFileCollection.from(filterFilesByIncludes(changedFiles.toArray(), context.includes));
|
|
5675
6093
|
}
|
|
5676
|
-
const prDesc = context.generateDescription ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.buildBasicDescription(commits, changedFiles);
|
|
6094
|
+
const prDesc = context.generateDescription ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
|
|
5677
6095
|
const result = {
|
|
5678
6096
|
success: true,
|
|
5679
6097
|
title: prDesc.title,
|
|
@@ -5721,13 +6139,13 @@ class ReviewService {
|
|
|
5721
6139
|
}
|
|
5722
6140
|
const currentRound = (existingResultModel?.round ?? 0) + 1;
|
|
5723
6141
|
// 即使没有适用的规则,也为每个变更文件生成摘要
|
|
5724
|
-
const summary = changedFiles.
|
|
6142
|
+
const summary = changedFiles.nonDeletedFiles().map((f)=>({
|
|
5725
6143
|
file: f.filename,
|
|
5726
6144
|
resolved: 0,
|
|
5727
6145
|
unresolved: 0,
|
|
5728
6146
|
summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过"
|
|
5729
6147
|
}));
|
|
5730
|
-
const prDesc = context.generateDescription && llmMode ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.buildBasicDescription(commits, changedFiles);
|
|
6148
|
+
const prDesc = context.generateDescription && llmMode ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
|
|
5731
6149
|
const result = {
|
|
5732
6150
|
success: true,
|
|
5733
6151
|
title: prDesc.title,
|
|
@@ -5753,139 +6171,6 @@ class ReviewService {
|
|
|
5753
6171
|
return result;
|
|
5754
6172
|
}
|
|
5755
6173
|
/**
|
|
5756
|
-
* 检查是否有其他同名 review workflow 正在运行中
|
|
5757
|
-
* 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
|
|
5758
|
-
*/ async checkDuplicateWorkflow(prModel, headSha, mode, verbose) {
|
|
5759
|
-
const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
|
|
5760
|
-
const prMatch = ref.match(/refs\/pull\/(\d+)/);
|
|
5761
|
-
const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
|
|
5762
|
-
try {
|
|
5763
|
-
const runningWorkflows = await prModel.listWorkflowRuns({
|
|
5764
|
-
status: "in_progress"
|
|
5765
|
-
});
|
|
5766
|
-
const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
|
|
5767
|
-
const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
|
|
5768
|
-
const duplicateReviewRuns = runningWorkflows.filter((w)=>w.sha === headSha && w.name === currentWorkflowName && (!currentRunId || String(w.id) !== currentRunId));
|
|
5769
|
-
if (duplicateReviewRuns.length > 0) {
|
|
5770
|
-
if (mode === "delete") {
|
|
5771
|
-
// 删除模式:清理旧的 AI Review 评论和 PR Review
|
|
5772
|
-
if (shouldLog(verbose, 1)) {
|
|
5773
|
-
console.log(`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`);
|
|
5774
|
-
}
|
|
5775
|
-
await this.cleanupDuplicateAiReviews(prModel, verbose);
|
|
5776
|
-
// 清理后继续执行当前审查
|
|
5777
|
-
return null;
|
|
5778
|
-
}
|
|
5779
|
-
// 跳过模式(默认)
|
|
5780
|
-
if (shouldLog(verbose, 1)) {
|
|
5781
|
-
console.log(`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`);
|
|
5782
|
-
}
|
|
5783
|
-
return {
|
|
5784
|
-
success: true,
|
|
5785
|
-
description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
|
|
5786
|
-
issues: [],
|
|
5787
|
-
summary: [],
|
|
5788
|
-
round: 1
|
|
5789
|
-
};
|
|
5790
|
-
}
|
|
5791
|
-
} catch (error) {
|
|
5792
|
-
if (shouldLog(verbose, 1)) {
|
|
5793
|
-
console.warn(`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`, error instanceof Error ? error.message : error);
|
|
5794
|
-
}
|
|
5795
|
-
}
|
|
5796
|
-
return null;
|
|
5797
|
-
}
|
|
5798
|
-
/**
|
|
5799
|
-
* 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
|
|
5800
|
-
*/ async cleanupDuplicateAiReviews(prModel, verbose) {
|
|
5801
|
-
try {
|
|
5802
|
-
// 删除 Issue Comments(主评论)
|
|
5803
|
-
const comments = await prModel.getComments();
|
|
5804
|
-
const aiComments = comments.filter((c)=>c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
5805
|
-
let deletedComments = 0;
|
|
5806
|
-
for (const comment of aiComments){
|
|
5807
|
-
if (comment.id) {
|
|
5808
|
-
try {
|
|
5809
|
-
await prModel.deleteComment(comment.id);
|
|
5810
|
-
deletedComments++;
|
|
5811
|
-
} catch {
|
|
5812
|
-
// 忽略删除失败
|
|
5813
|
-
}
|
|
5814
|
-
}
|
|
5815
|
-
}
|
|
5816
|
-
if (deletedComments > 0 && shouldLog(verbose, 1)) {
|
|
5817
|
-
console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
|
|
5818
|
-
}
|
|
5819
|
-
// 删除 PR Reviews(行级评论)
|
|
5820
|
-
const reviews = await prModel.getReviews();
|
|
5821
|
-
const aiReviews = reviews.filter((r)=>r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
5822
|
-
let deletedReviews = 0;
|
|
5823
|
-
for (const review of aiReviews){
|
|
5824
|
-
if (review.id) {
|
|
5825
|
-
try {
|
|
5826
|
-
await prModel.deleteReview(review.id);
|
|
5827
|
-
deletedReviews++;
|
|
5828
|
-
} catch {
|
|
5829
|
-
// 已提交的 review 无法删除,忽略
|
|
5830
|
-
}
|
|
5831
|
-
}
|
|
5832
|
-
}
|
|
5833
|
-
if (deletedReviews > 0 && shouldLog(verbose, 1)) {
|
|
5834
|
-
console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
|
|
5835
|
-
}
|
|
5836
|
-
} catch (error) {
|
|
5837
|
-
if (shouldLog(verbose, 1)) {
|
|
5838
|
-
console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
|
|
5839
|
-
}
|
|
5840
|
-
}
|
|
5841
|
-
}
|
|
5842
|
-
// --- Delegation methods for backward compatibility with tests ---
|
|
5843
|
-
async fillIssueAuthors(...args) {
|
|
5844
|
-
return this.contextBuilder.fillIssueAuthors(...args);
|
|
5845
|
-
}
|
|
5846
|
-
async getFileContents(...args) {
|
|
5847
|
-
return this.issueFilter.getFileContents(...args);
|
|
5848
|
-
}
|
|
5849
|
-
async getFilesForCommit(...args) {
|
|
5850
|
-
return this.issueFilter.getFilesForCommit(...args);
|
|
5851
|
-
}
|
|
5852
|
-
async getChangedFilesBetweenRefs(...args) {
|
|
5853
|
-
return this.issueFilter.getChangedFilesBetweenRefs(...args);
|
|
5854
|
-
}
|
|
5855
|
-
async getCommitsBetweenRefs(...args) {
|
|
5856
|
-
return this.issueFilter.getCommitsBetweenRefs(...args);
|
|
5857
|
-
}
|
|
5858
|
-
filterIssuesByValidCommits(...args) {
|
|
5859
|
-
return this.issueFilter.filterIssuesByValidCommits(...args);
|
|
5860
|
-
}
|
|
5861
|
-
filterDuplicateIssues(...args) {
|
|
5862
|
-
return this.issueFilter.filterDuplicateIssues(...args);
|
|
5863
|
-
}
|
|
5864
|
-
async fillIssueCode(...args) {
|
|
5865
|
-
return this.issueFilter.fillIssueCode(...args);
|
|
5866
|
-
}
|
|
5867
|
-
async runLLMReview(...args) {
|
|
5868
|
-
return this.llmProcessor.runLLMReview(...args);
|
|
5869
|
-
}
|
|
5870
|
-
async buildReviewPrompt(...args) {
|
|
5871
|
-
return this.llmProcessor.buildReviewPrompt(...args);
|
|
5872
|
-
}
|
|
5873
|
-
async generatePrDescription(...args) {
|
|
5874
|
-
return this.llmProcessor.generatePrDescription(...args);
|
|
5875
|
-
}
|
|
5876
|
-
async buildBasicDescription(...args) {
|
|
5877
|
-
return this.llmProcessor.buildBasicDescription(...args);
|
|
5878
|
-
}
|
|
5879
|
-
normalizeFilePaths(...args) {
|
|
5880
|
-
return this.contextBuilder.normalizeFilePaths(...args);
|
|
5881
|
-
}
|
|
5882
|
-
resolveAnalyzeDeletions(...args) {
|
|
5883
|
-
return this.contextBuilder.resolveAnalyzeDeletions(...args);
|
|
5884
|
-
}
|
|
5885
|
-
async getPrNumberFromEvent(...args) {
|
|
5886
|
-
return this.contextBuilder.getPrNumberFromEvent(...args);
|
|
5887
|
-
}
|
|
5888
|
-
/**
|
|
5889
6174
|
* 确保 Claude CLI 已安装
|
|
5890
6175
|
*/ async ensureClaudeCli(ci) {
|
|
5891
6176
|
try {
|
|
@@ -6766,6 +7051,7 @@ class DeletionImpactService {
|
|
|
6766
7051
|
|
|
6767
7052
|
|
|
6768
7053
|
|
|
7054
|
+
|
|
6769
7055
|
/** MCP 工具输入 schema */ const listRulesInputSchema = z.object({});
|
|
6770
7056
|
const getRulesForFileInputSchema = z.object({
|
|
6771
7057
|
filePath: z.string().describe(t("review:mcp.dto.filePath")),
|
|
@@ -6862,11 +7148,11 @@ const tools = [
|
|
|
6862
7148
|
const workDir = ctx.cwd;
|
|
6863
7149
|
const allSpecs = await loadAllSpecs(workDir, ctx);
|
|
6864
7150
|
const specService = new ReviewSpecService();
|
|
6865
|
-
const applicableSpecs = specService.filterApplicableSpecs(allSpecs, [
|
|
7151
|
+
const applicableSpecs = specService.filterApplicableSpecs(allSpecs, ChangedFileCollection.from([
|
|
6866
7152
|
{
|
|
6867
7153
|
filename: filePath
|
|
6868
7154
|
}
|
|
6869
|
-
]);
|
|
7155
|
+
]));
|
|
6870
7156
|
const micromatchModule = await __webpack_require__.e(/* import() */ "551").then(__webpack_require__.bind(__webpack_require__, 946));
|
|
6871
7157
|
const micromatch = micromatchModule.default || micromatchModule;
|
|
6872
7158
|
const rules = applicableSpecs.flatMap((spec)=>spec.rules.filter((rule)=>{
|