@spaceflow/review 0.80.0 → 0.81.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 +10 -0
- package/dist/index.js +255 -50
- package/package.json +2 -2
- package/src/review-context.ts +4 -2
- package/src/review-issue-filter.ts +1 -1
- package/src/review-spec/review-spec.service.spec.ts +89 -8
- package/src/review-spec/review-spec.service.ts +287 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.80.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.79.0...@spaceflow/review@0.80.0) (2026-04-08)
|
|
4
|
+
|
|
5
|
+
### 新特性
|
|
6
|
+
|
|
7
|
+
* **review:** 在 review.md 中补充 `-f` 模式忽略 `includes` 过滤的说明,新增测试用例验证直接文件模式下的过滤行为 ([9c15284](https://github.com/Lydanne/spaceflow/commit/9c1528486f93b89442ba6eea4d1cb4503be5f6c7))
|
|
8
|
+
|
|
9
|
+
### 其他修改
|
|
10
|
+
|
|
11
|
+
* **review-summary:** released version 0.48.0 [no ci] ([71629d2](https://github.com/Lydanne/spaceflow/commit/71629d27024b4c27c5bbe8564a2b87d45548882d))
|
|
12
|
+
|
|
3
13
|
## [0.79.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.78.0...@spaceflow/review@0.79.0) (2026-04-08)
|
|
4
14
|
|
|
5
15
|
### 新特性
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { LlmJsonPut, REVIEW_STATE, addLocaleResources, calculateNewLineNumber, createStreamLoggerState, defineExtension, logStreamEvent, normalizeVerbose, parallel, parseChangedLinesFromPatch, parseDiffText, parseHunksFromPatch, parseRepoUrl, parseVerbose, shouldLog, t, z } from "@spaceflow/core";
|
|
2
|
-
import { access, mkdir, readFile, readdir, writeFile } from "fs/promises";
|
|
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"
|
|
@@ -414,54 +421,219 @@ class ReviewSpecService {
|
|
|
414
421
|
}
|
|
415
422
|
return specs;
|
|
416
423
|
}
|
|
417
|
-
async resolveSpecSources(sources) {
|
|
424
|
+
async resolveSpecSources(sources, verbose) {
|
|
418
425
|
const dirs = [];
|
|
419
426
|
for (const source of sources){
|
|
420
|
-
|
|
427
|
+
this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
|
|
421
428
|
const repoRef = parseRepoUrl(source);
|
|
429
|
+
if (repoRef) {
|
|
430
|
+
this.logVerbose(verbose, 3, ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`);
|
|
431
|
+
} else {
|
|
432
|
+
this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
|
|
433
|
+
}
|
|
422
434
|
if (repoRef && this.gitProvider) {
|
|
423
|
-
|
|
435
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
|
|
436
|
+
const dir = await this.fetchRemoteSpecs(repoRef, verbose);
|
|
424
437
|
if (dir) {
|
|
425
438
|
dirs.push(dir);
|
|
439
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
|
|
426
440
|
continue;
|
|
427
441
|
}
|
|
442
|
+
this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
|
|
443
|
+
}
|
|
444
|
+
if (repoRef) {
|
|
445
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
|
|
446
|
+
const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
|
|
447
|
+
if (teaDir) {
|
|
448
|
+
dirs.push(teaDir);
|
|
449
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
|
|
453
|
+
}
|
|
454
|
+
// API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
|
|
455
|
+
if (repoRef) {
|
|
456
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
|
|
457
|
+
const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
|
|
458
|
+
this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
|
|
459
|
+
const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
|
|
460
|
+
if (fallbackDir) {
|
|
461
|
+
dirs.push(fallbackDir);
|
|
462
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
|
|
428
466
|
}
|
|
429
467
|
if (this.isRepoUrl(source)) {
|
|
430
|
-
|
|
468
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
|
|
469
|
+
const dir = await this.cloneSpecRepo(source, undefined, verbose);
|
|
431
470
|
if (dir) {
|
|
432
471
|
dirs.push(dir);
|
|
472
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
|
|
473
|
+
} else {
|
|
474
|
+
this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
|
|
433
475
|
}
|
|
434
476
|
} else {
|
|
435
477
|
// 检查是否是 deps 目录,如果是则扫描子目录的 references
|
|
436
478
|
const resolvedDirs = await this.resolveDepsDir(source);
|
|
437
479
|
dirs.push(...resolvedDirs);
|
|
480
|
+
this.logVerbose(verbose, 3, ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`);
|
|
438
481
|
}
|
|
439
482
|
}
|
|
440
483
|
return dirs;
|
|
441
484
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
485
|
+
buildRemoteSpecDir(ref) {
|
|
486
|
+
const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
|
|
487
|
+
return join(homedir(), ".spaceflow", "review-spec", dirKey);
|
|
488
|
+
}
|
|
489
|
+
async getLocalSpecsDir(dir) {
|
|
490
|
+
try {
|
|
491
|
+
const entries = await readdir(dir);
|
|
492
|
+
if (!entries.some((f)=>f.endsWith(".md"))) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
return dir;
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async prepareRemoteSpecDirForWrite(dir) {
|
|
501
|
+
await mkdir(dir, {
|
|
502
|
+
recursive: true
|
|
503
|
+
});
|
|
504
|
+
try {
|
|
505
|
+
const entries = await readdir(dir);
|
|
506
|
+
for (const name of entries){
|
|
507
|
+
if (name.endsWith(".md") || name === ".timestamp") {
|
|
508
|
+
await unlink(join(dir, name));
|
|
460
509
|
}
|
|
461
|
-
} catch {
|
|
462
|
-
// 缓存不存在或无效,继续拉取
|
|
463
510
|
}
|
|
511
|
+
} catch {
|
|
512
|
+
// 忽略目录清理失败,后续写入时再处理
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
isTeaInstalled() {
|
|
516
|
+
try {
|
|
517
|
+
execSync("command -v tea", {
|
|
518
|
+
stdio: "pipe"
|
|
519
|
+
});
|
|
520
|
+
return true;
|
|
521
|
+
} catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
getTeaLoginForServer(serverUrl) {
|
|
526
|
+
try {
|
|
527
|
+
const output = execFileSync("tea", [
|
|
528
|
+
"login",
|
|
529
|
+
"list",
|
|
530
|
+
"-o",
|
|
531
|
+
"json"
|
|
532
|
+
], {
|
|
533
|
+
encoding: "utf-8",
|
|
534
|
+
stdio: "pipe"
|
|
535
|
+
});
|
|
536
|
+
const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
|
|
537
|
+
const logins = JSON.parse(output);
|
|
538
|
+
const matched = logins.find((login)=>login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl);
|
|
539
|
+
return matched?.name ?? null;
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
runTeaApi(endpoint, loginName) {
|
|
545
|
+
const args = [
|
|
546
|
+
"api",
|
|
547
|
+
"-l",
|
|
548
|
+
loginName,
|
|
549
|
+
endpoint
|
|
550
|
+
];
|
|
551
|
+
return execFileSync("tea", args, {
|
|
552
|
+
encoding: "utf-8",
|
|
553
|
+
stdio: "pipe"
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
encodePathSegments(path) {
|
|
557
|
+
if (!path) return "";
|
|
558
|
+
return path.split("/").filter(Boolean).map((segment)=>encodeURIComponent(segment)).join("/");
|
|
559
|
+
}
|
|
560
|
+
buildTeaContentsEndpoint(ref) {
|
|
561
|
+
const owner = encodeURIComponent(ref.owner);
|
|
562
|
+
const repo = encodeURIComponent(ref.repo);
|
|
563
|
+
const encodedPath = this.encodePathSegments(ref.path || "");
|
|
564
|
+
const pathPart = encodedPath ? `/${encodedPath}` : "";
|
|
565
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
566
|
+
return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
|
|
567
|
+
}
|
|
568
|
+
buildTeaRawFileEndpoint(ref, filePath) {
|
|
569
|
+
const owner = encodeURIComponent(ref.owner);
|
|
570
|
+
const repo = encodeURIComponent(ref.repo);
|
|
571
|
+
const encodedFilePath = this.encodePathSegments(filePath);
|
|
572
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
573
|
+
return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* 使用 tea api 拉取远程规则
|
|
577
|
+
* 前置条件:本地安装 tea 且已登录目标服务器
|
|
578
|
+
*/ async fetchRemoteSpecsViaTea(ref, verbose) {
|
|
579
|
+
if (!this.isTeaInstalled()) {
|
|
580
|
+
this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const loginName = this.getTeaLoginForServer(ref.serverUrl);
|
|
584
|
+
if (!loginName) {
|
|
585
|
+
this.logVerbose(verbose, 3, ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`);
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
|
|
589
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
590
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
591
|
+
try {
|
|
592
|
+
console.log(` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
|
|
593
|
+
const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
|
|
594
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
|
|
595
|
+
const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
|
|
596
|
+
const contents = JSON.parse(contentsRaw);
|
|
597
|
+
const mdFiles = contents.filter((f)=>f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path);
|
|
598
|
+
if (mdFiles.length === 0) {
|
|
599
|
+
console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
const fetchedFiles = [];
|
|
603
|
+
for (const file of mdFiles){
|
|
604
|
+
const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path);
|
|
605
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
|
|
606
|
+
const fileContent = this.runTeaApi(fileEndpoint, loginName);
|
|
607
|
+
fetchedFiles.push({
|
|
608
|
+
name: file.name,
|
|
609
|
+
content: fileContent
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
613
|
+
for (const file of fetchedFiles){
|
|
614
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
615
|
+
}
|
|
616
|
+
console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
617
|
+
return specDir;
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
|
|
620
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
621
|
+
if (localDir) {
|
|
622
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
623
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
624
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
625
|
+
return localDir;
|
|
626
|
+
}
|
|
627
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
628
|
+
return null;
|
|
464
629
|
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* 通过 Git API 从远程仓库拉取规则文件
|
|
633
|
+
* 保存到 ~/.spaceflow/review-spec/ 目录
|
|
634
|
+
*/ async fetchRemoteSpecs(ref, verbose) {
|
|
635
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
636
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
465
637
|
try {
|
|
466
638
|
console.log(` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
|
|
467
639
|
const contents = await this.gitProvider.listRepositoryContents(ref.owner, ref.repo, ref.path || undefined, ref.ref);
|
|
@@ -470,32 +642,41 @@ class ReviewSpecService {
|
|
|
470
642
|
console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
|
|
471
643
|
return null;
|
|
472
644
|
}
|
|
473
|
-
|
|
474
|
-
recursive: true
|
|
475
|
-
});
|
|
645
|
+
const fetchedFiles = [];
|
|
476
646
|
for (const file of mdFiles){
|
|
477
647
|
const content = await this.gitProvider.getFileContent(ref.owner, ref.repo, file.path, ref.ref);
|
|
478
|
-
|
|
648
|
+
fetchedFiles.push({
|
|
649
|
+
name: file.name,
|
|
650
|
+
content
|
|
651
|
+
});
|
|
479
652
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
653
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
654
|
+
for (const file of fetchedFiles){
|
|
655
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
656
|
+
}
|
|
657
|
+
console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
658
|
+
return specDir;
|
|
484
659
|
} catch (error) {
|
|
485
660
|
console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// 无缓存可用
|
|
495
|
-
}
|
|
661
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
662
|
+
if (localDir) {
|
|
663
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
664
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
665
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
666
|
+
return localDir;
|
|
667
|
+
}
|
|
668
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
496
669
|
return null;
|
|
497
670
|
}
|
|
498
671
|
}
|
|
672
|
+
async getSpecFileCount(dir) {
|
|
673
|
+
try {
|
|
674
|
+
const entries = await readdir(dir);
|
|
675
|
+
return entries.filter((f)=>f.endsWith(".md")).length;
|
|
676
|
+
} catch {
|
|
677
|
+
return 0;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
499
680
|
/**
|
|
500
681
|
* 解析 deps 目录,扫描子目录中的 references 文件夹
|
|
501
682
|
* 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
|
|
@@ -541,16 +722,35 @@ class ReviewSpecService {
|
|
|
541
722
|
isRepoUrl(source) {
|
|
542
723
|
return source.startsWith("http://") || source.startsWith("https://") || source.startsWith("git@") || source.includes("://");
|
|
543
724
|
}
|
|
544
|
-
|
|
725
|
+
buildRepoCloneUrl(ref) {
|
|
726
|
+
return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
|
|
727
|
+
}
|
|
728
|
+
async resolveClonedSpecDir(cacheDir, subPath) {
|
|
729
|
+
const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
|
|
730
|
+
if (!normalizedSubPath) {
|
|
731
|
+
return cacheDir;
|
|
732
|
+
}
|
|
733
|
+
const targetDir = join(cacheDir, normalizedSubPath);
|
|
734
|
+
try {
|
|
735
|
+
await access(targetDir);
|
|
736
|
+
return targetDir;
|
|
737
|
+
} catch {
|
|
738
|
+
console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
|
|
739
|
+
return cacheDir;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
async cloneSpecRepo(repoUrl, subPath, verbose) {
|
|
545
743
|
const repoName = this.extractRepoName(repoUrl);
|
|
546
744
|
if (!repoName) {
|
|
547
745
|
console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
|
|
548
746
|
return null;
|
|
549
747
|
}
|
|
550
748
|
const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
|
|
749
|
+
this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
|
|
551
750
|
try {
|
|
552
751
|
await access(cacheDir);
|
|
553
752
|
// console.log(` 使用缓存的规则仓库: ${cacheDir}`);
|
|
753
|
+
this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
|
|
554
754
|
try {
|
|
555
755
|
execSync("git pull --ff-only", {
|
|
556
756
|
cwd: cacheDir,
|
|
@@ -560,10 +760,11 @@ class ReviewSpecService {
|
|
|
560
760
|
} catch {
|
|
561
761
|
console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
|
|
562
762
|
}
|
|
563
|
-
return cacheDir;
|
|
763
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
564
764
|
} catch {
|
|
565
765
|
// console.log(` 克隆规则仓库: ${repoUrl}`);
|
|
566
766
|
try {
|
|
767
|
+
this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
|
|
567
768
|
await mkdir(join(homedir(), ".spaceflow", "review-spec"), {
|
|
568
769
|
recursive: true
|
|
569
770
|
});
|
|
@@ -571,7 +772,7 @@ class ReviewSpecService {
|
|
|
571
772
|
stdio: "pipe"
|
|
572
773
|
});
|
|
573
774
|
// console.log(` 克隆完成: ${cacheDir}`);
|
|
574
|
-
return cacheDir;
|
|
775
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
575
776
|
} catch (error) {
|
|
576
777
|
console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
|
|
577
778
|
return null;
|
|
@@ -579,6 +780,10 @@ class ReviewSpecService {
|
|
|
579
780
|
}
|
|
580
781
|
}
|
|
581
782
|
extractRepoName(repoUrl) {
|
|
783
|
+
const parsedRef = parseRepoUrl(repoUrl);
|
|
784
|
+
if (parsedRef) {
|
|
785
|
+
return `${parsedRef.owner}__${parsedRef.repo}`;
|
|
786
|
+
}
|
|
582
787
|
let path = repoUrl;
|
|
583
788
|
path = path.replace(/\.git$/, "");
|
|
584
789
|
path = path.replace(/^git@[^:]+:/, "");
|
|
@@ -3177,7 +3382,7 @@ class ReviewContextBuilder {
|
|
|
3177
3382
|
}
|
|
3178
3383
|
async getContextFromEnv(options) {
|
|
3179
3384
|
const reviewConf = this.config.getPluginConfig("review");
|
|
3180
|
-
if (shouldLog(options.verbose,
|
|
3385
|
+
if (shouldLog(options.verbose, 3)) {
|
|
3181
3386
|
console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
|
|
3182
3387
|
}
|
|
3183
3388
|
const ciConf = this.config.get("ci");
|
|
@@ -3536,7 +3741,7 @@ class ReviewIssueFilter {
|
|
|
3536
3741
|
if (shouldLog(verbose, 1)) {
|
|
3537
3742
|
console.log(`📂 解析规则来源: ${specSources.length} 个`);
|
|
3538
3743
|
}
|
|
3539
|
-
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
|
|
3744
|
+
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
|
|
3540
3745
|
if (shouldLog(verbose, 2)) {
|
|
3541
3746
|
console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
|
|
3542
3747
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spaceflow/review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lydanne",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@vitest/coverage-v8": "^4.0.18",
|
|
26
26
|
"unplugin-swc": "^1.5.9",
|
|
27
27
|
"vitest": "^4.0.18",
|
|
28
|
-
"@spaceflow/cli": "0.
|
|
28
|
+
"@spaceflow/cli": "0.41.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
31
|
"@spaceflow/core": "0.30.0"
|
package/src/review-context.ts
CHANGED
|
@@ -65,7 +65,7 @@ export class ReviewContextBuilder {
|
|
|
65
65
|
|
|
66
66
|
async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
|
|
67
67
|
const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
|
|
68
|
-
if (shouldLog(options.verbose,
|
|
68
|
+
if (shouldLog(options.verbose, 3)) {
|
|
69
69
|
console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
|
|
70
70
|
}
|
|
71
71
|
const ciConf = this.config.get<CiConfig>("ci");
|
|
@@ -268,7 +268,9 @@ export class ReviewContextBuilder {
|
|
|
268
268
|
*/
|
|
269
269
|
private normalizeSingleFilePath(file: string, cwd: string): string {
|
|
270
270
|
const normalizedInput = normalize(file);
|
|
271
|
-
const relativePath = isAbsolute(normalizedInput)
|
|
271
|
+
const relativePath = isAbsolute(normalizedInput)
|
|
272
|
+
? relative(cwd, normalizedInput)
|
|
273
|
+
: normalizedInput;
|
|
272
274
|
return relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
273
275
|
}
|
|
274
276
|
|
|
@@ -39,7 +39,7 @@ export class ReviewIssueFilter {
|
|
|
39
39
|
if (shouldLog(verbose, 1)) {
|
|
40
40
|
console.log(`📂 解析规则来源: ${specSources.length} 个`);
|
|
41
41
|
}
|
|
42
|
-
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
|
|
42
|
+
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
|
|
43
43
|
if (shouldLog(verbose, 2)) {
|
|
44
44
|
console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
|
|
45
45
|
}
|
|
@@ -976,6 +976,13 @@ const MAX_COUNT = 100;
|
|
|
976
976
|
expect(result).toBe("org__repo");
|
|
977
977
|
});
|
|
978
978
|
|
|
979
|
+
it("should extract from directory URL", () => {
|
|
980
|
+
const result = (service as any).extractRepoName(
|
|
981
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
982
|
+
);
|
|
983
|
+
expect(result).toBe("xgj__review-spec");
|
|
984
|
+
});
|
|
985
|
+
|
|
979
986
|
it("should handle single part path", () => {
|
|
980
987
|
const result = (service as any).extractRepoName("repo");
|
|
981
988
|
expect(result).toBe("repo");
|
|
@@ -1274,10 +1281,74 @@ const MAX_COUNT = 100;
|
|
|
1274
1281
|
]);
|
|
1275
1282
|
expect(result.length).toBeGreaterThanOrEqual(0);
|
|
1276
1283
|
});
|
|
1284
|
+
|
|
1285
|
+
it("should fallback to clone repo root URL when API fetch fails for directory URL", async () => {
|
|
1286
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
|
|
1287
|
+
(access as Mock)
|
|
1288
|
+
.mockRejectedValueOnce(new Error("not found"))
|
|
1289
|
+
.mockResolvedValueOnce(undefined);
|
|
1290
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1291
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1292
|
+
(child_process.execFileSync as Mock).mockImplementation(() => {
|
|
1293
|
+
throw new Error("tea unavailable");
|
|
1294
|
+
});
|
|
1295
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1296
|
+
|
|
1297
|
+
const result = await service.resolveSpecSources([
|
|
1298
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
1299
|
+
]);
|
|
1300
|
+
|
|
1301
|
+
expect(result.some((dir) => dir.includes("xgj__review-spec/references"))).toBe(true);
|
|
1302
|
+
const cloneCall = (child_process.execSync as Mock).mock.calls.find((call) =>
|
|
1303
|
+
String(call[0]).includes('git clone --depth 1 "https://git.bjxgj.com/xgj/review-spec.git"'),
|
|
1304
|
+
);
|
|
1305
|
+
expect(cloneCall).toBeTruthy();
|
|
1306
|
+
consoleSpy.mockRestore();
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("should resolve remote specs via tea when provider API fails", async () => {
|
|
1310
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
|
|
1311
|
+
(child_process.execSync as Mock).mockReturnValue(""); // command -v tea
|
|
1312
|
+
(child_process.execFileSync as Mock)
|
|
1313
|
+
.mockReturnValueOnce(
|
|
1314
|
+
JSON.stringify([
|
|
1315
|
+
{
|
|
1316
|
+
name: "git.bjxgj.com",
|
|
1317
|
+
url: "https://git.bjxgj.com",
|
|
1318
|
+
},
|
|
1319
|
+
]),
|
|
1320
|
+
) // tea login list -o json
|
|
1321
|
+
.mockReturnValueOnce(
|
|
1322
|
+
JSON.stringify([
|
|
1323
|
+
{
|
|
1324
|
+
type: "file",
|
|
1325
|
+
name: "js.base.md",
|
|
1326
|
+
path: "references/js.base.md",
|
|
1327
|
+
},
|
|
1328
|
+
]),
|
|
1329
|
+
) // tea api contents
|
|
1330
|
+
.mockReturnValueOnce("# Test `[JsTs.Base]`"); // tea api raw file
|
|
1331
|
+
(readdir as Mock).mockResolvedValue([]);
|
|
1332
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1333
|
+
(writeFile as Mock).mockResolvedValue(undefined);
|
|
1334
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1335
|
+
|
|
1336
|
+
const result = await service.resolveSpecSources([
|
|
1337
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
1338
|
+
]);
|
|
1339
|
+
|
|
1340
|
+
expect(result.some((dir) => dir.includes("review-spec"))).toBe(true);
|
|
1341
|
+
expect(child_process.execFileSync).toHaveBeenCalledWith(
|
|
1342
|
+
"tea",
|
|
1343
|
+
["api", "-l", "git.bjxgj.com", "/repos/xgj/review-spec/contents/references?ref=main"],
|
|
1344
|
+
expect.objectContaining({ encoding: "utf-8", stdio: "pipe" }),
|
|
1345
|
+
);
|
|
1346
|
+
consoleSpy.mockRestore();
|
|
1347
|
+
});
|
|
1277
1348
|
});
|
|
1278
1349
|
|
|
1279
1350
|
describe("fetchRemoteSpecs", () => {
|
|
1280
|
-
it("should fetch and
|
|
1351
|
+
it("should fetch and persist remote specs into review-spec dir", async () => {
|
|
1281
1352
|
gitProvider.listRepositoryContents.mockResolvedValue([
|
|
1282
1353
|
{ type: "file", name: "rule.md", path: "rule.md" },
|
|
1283
1354
|
]);
|
|
@@ -1301,7 +1372,7 @@ const MAX_COUNT = 100;
|
|
|
1301
1372
|
consoleSpy.mockRestore();
|
|
1302
1373
|
});
|
|
1303
1374
|
|
|
1304
|
-
it("should handle API failure and use
|
|
1375
|
+
it("should handle API failure and use local specs directory", async () => {
|
|
1305
1376
|
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1306
1377
|
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1307
1378
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1313,7 +1384,7 @@ const MAX_COUNT = 100;
|
|
|
1313
1384
|
logSpy.mockRestore();
|
|
1314
1385
|
});
|
|
1315
1386
|
|
|
1316
|
-
it("should handle API failure without
|
|
1387
|
+
it("should handle API failure without local specs directory", async () => {
|
|
1317
1388
|
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1318
1389
|
(readdir as Mock).mockRejectedValue(new Error("no cache"));
|
|
1319
1390
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1325,15 +1396,12 @@ const MAX_COUNT = 100;
|
|
|
1325
1396
|
logSpy.mockRestore();
|
|
1326
1397
|
});
|
|
1327
1398
|
|
|
1328
|
-
it("should use
|
|
1329
|
-
|
|
1330
|
-
delete process.env.CI;
|
|
1331
|
-
(readFile as Mock).mockResolvedValue(String(Date.now()));
|
|
1399
|
+
it("should use local specs directory as fallback", async () => {
|
|
1400
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1332
1401
|
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1333
1402
|
const ref = { owner: "org", repo: "repo" };
|
|
1334
1403
|
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1335
1404
|
expect(result).toBeTruthy();
|
|
1336
|
-
process.env.CI = originalCI;
|
|
1337
1405
|
});
|
|
1338
1406
|
});
|
|
1339
1407
|
|
|
@@ -1416,6 +1484,19 @@ const MAX_COUNT = 100;
|
|
|
1416
1484
|
expect(result).toBeTruthy();
|
|
1417
1485
|
});
|
|
1418
1486
|
|
|
1487
|
+
it("should return sub directory when subPath is provided", async () => {
|
|
1488
|
+
(access as Mock)
|
|
1489
|
+
.mockRejectedValueOnce(new Error("not found"))
|
|
1490
|
+
.mockResolvedValueOnce(undefined);
|
|
1491
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1492
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1493
|
+
const result = await (service as any).cloneSpecRepo(
|
|
1494
|
+
"https://github.com/org/repo.git",
|
|
1495
|
+
"references",
|
|
1496
|
+
);
|
|
1497
|
+
expect(result).toContain("org__repo/references");
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1419
1500
|
it("should handle clone failure", async () => {
|
|
1420
1501
|
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1421
1502
|
(mkdir as Mock).mockResolvedValue(undefined);
|
|
@@ -7,19 +7,26 @@ import {
|
|
|
7
7
|
type RemoteRepoRef,
|
|
8
8
|
type RepositoryContent,
|
|
9
9
|
} from "@spaceflow/core";
|
|
10
|
-
import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
|
|
10
|
+
import { readdir, readFile, mkdir, access, writeFile, unlink } from "fs/promises";
|
|
11
11
|
import { join, basename, extname } from "path";
|
|
12
12
|
import { homedir } from "os";
|
|
13
|
-
import { execSync } from "child_process";
|
|
13
|
+
import { execSync, execFileSync } from "child_process";
|
|
14
14
|
import micromatch from "micromatch";
|
|
15
15
|
import { ReviewSpec, ReviewRule, RuleExample, Severity } from "./types";
|
|
16
16
|
import { extractGlobsFromIncludes } from "../review-includes-filter";
|
|
17
17
|
|
|
18
|
-
/** 远程规则缓存 TTL(毫秒),默认 5 分钟 */
|
|
19
|
-
const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
|
|
20
|
-
|
|
21
18
|
export class ReviewSpecService {
|
|
22
19
|
constructor(protected readonly gitProvider?: GitProviderService) {}
|
|
20
|
+
|
|
21
|
+
protected normalizeServerUrl(url: string): string {
|
|
22
|
+
return url.trim().replace(/\/+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected logVerbose(verbose: VerboseLevel | undefined, level: number, message: string): void {
|
|
26
|
+
if (shouldLog(verbose, level as VerboseLevel)) {
|
|
27
|
+
console.log(message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
23
30
|
/**
|
|
24
31
|
* 检查规则 ID 是否匹配(精确匹配或前缀匹配)
|
|
25
32
|
* 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
|
|
@@ -114,58 +121,249 @@ export class ReviewSpecService {
|
|
|
114
121
|
return specs;
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
async resolveSpecSources(sources: string[]): Promise<string[]> {
|
|
124
|
+
async resolveSpecSources(sources: string[], verbose?: VerboseLevel): Promise<string[]> {
|
|
118
125
|
const dirs: string[] = [];
|
|
119
126
|
|
|
120
127
|
for (const source of sources) {
|
|
121
|
-
|
|
128
|
+
this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
|
|
122
129
|
const repoRef = parseRepoUrl(source);
|
|
130
|
+
if (repoRef) {
|
|
131
|
+
this.logVerbose(
|
|
132
|
+
verbose,
|
|
133
|
+
3,
|
|
134
|
+
` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`,
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
|
|
138
|
+
}
|
|
123
139
|
if (repoRef && this.gitProvider) {
|
|
124
|
-
|
|
140
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
|
|
141
|
+
const dir = await this.fetchRemoteSpecs(repoRef, verbose);
|
|
125
142
|
if (dir) {
|
|
126
143
|
dirs.push(dir);
|
|
144
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
|
|
148
|
+
}
|
|
149
|
+
if (repoRef) {
|
|
150
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
|
|
151
|
+
const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
|
|
152
|
+
if (teaDir) {
|
|
153
|
+
dirs.push(teaDir);
|
|
154
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
|
|
158
|
+
}
|
|
159
|
+
// API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
|
|
160
|
+
if (repoRef) {
|
|
161
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
|
|
162
|
+
const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
|
|
163
|
+
this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
|
|
164
|
+
const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
|
|
165
|
+
if (fallbackDir) {
|
|
166
|
+
dirs.push(fallbackDir);
|
|
167
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
|
|
127
168
|
continue;
|
|
128
169
|
}
|
|
170
|
+
this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
|
|
129
171
|
}
|
|
130
172
|
if (this.isRepoUrl(source)) {
|
|
131
|
-
|
|
173
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
|
|
174
|
+
const dir = await this.cloneSpecRepo(source, undefined, verbose);
|
|
132
175
|
if (dir) {
|
|
133
176
|
dirs.push(dir);
|
|
177
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
|
|
178
|
+
} else {
|
|
179
|
+
this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
|
|
134
180
|
}
|
|
135
181
|
} else {
|
|
136
182
|
// 检查是否是 deps 目录,如果是则扫描子目录的 references
|
|
137
183
|
const resolvedDirs = await this.resolveDepsDir(source);
|
|
138
184
|
dirs.push(...resolvedDirs);
|
|
185
|
+
this.logVerbose(
|
|
186
|
+
verbose,
|
|
187
|
+
3,
|
|
188
|
+
` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`,
|
|
189
|
+
);
|
|
139
190
|
}
|
|
140
191
|
}
|
|
141
192
|
|
|
142
193
|
return dirs;
|
|
143
194
|
}
|
|
144
195
|
|
|
196
|
+
protected buildRemoteSpecDir(ref: RemoteRepoRef): string {
|
|
197
|
+
const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
|
|
198
|
+
return join(homedir(), ".spaceflow", "review-spec", dirKey);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
protected async getLocalSpecsDir(dir: string): Promise<string | null> {
|
|
202
|
+
try {
|
|
203
|
+
const entries = await readdir(dir);
|
|
204
|
+
if (!entries.some((f) => f.endsWith(".md"))) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return dir;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
protected async prepareRemoteSpecDirForWrite(dir: string): Promise<void> {
|
|
214
|
+
await mkdir(dir, { recursive: true });
|
|
215
|
+
try {
|
|
216
|
+
const entries = await readdir(dir);
|
|
217
|
+
for (const name of entries) {
|
|
218
|
+
if (name.endsWith(".md") || name === ".timestamp") {
|
|
219
|
+
await unlink(join(dir, name));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// 忽略目录清理失败,后续写入时再处理
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
protected isTeaInstalled(): boolean {
|
|
228
|
+
try {
|
|
229
|
+
execSync("command -v tea", { stdio: "pipe" });
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
protected getTeaLoginForServer(serverUrl: string): string | null {
|
|
237
|
+
try {
|
|
238
|
+
const output = execFileSync("tea", ["login", "list", "-o", "json"], {
|
|
239
|
+
encoding: "utf-8",
|
|
240
|
+
stdio: "pipe",
|
|
241
|
+
});
|
|
242
|
+
const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
|
|
243
|
+
const logins = JSON.parse(output) as Array<{ name?: string; url?: string }>;
|
|
244
|
+
const matched = logins.find(
|
|
245
|
+
(login) => login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl,
|
|
246
|
+
);
|
|
247
|
+
return matched?.name ?? null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
protected runTeaApi(endpoint: string, loginName: string): string {
|
|
254
|
+
const args = ["api", "-l", loginName, endpoint];
|
|
255
|
+
return execFileSync("tea", args, {
|
|
256
|
+
encoding: "utf-8",
|
|
257
|
+
stdio: "pipe",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
protected encodePathSegments(path: string): string {
|
|
262
|
+
if (!path) return "";
|
|
263
|
+
return path
|
|
264
|
+
.split("/")
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.map((segment) => encodeURIComponent(segment))
|
|
267
|
+
.join("/");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
protected buildTeaContentsEndpoint(ref: RemoteRepoRef): string {
|
|
271
|
+
const owner = encodeURIComponent(ref.owner);
|
|
272
|
+
const repo = encodeURIComponent(ref.repo);
|
|
273
|
+
const encodedPath = this.encodePathSegments(ref.path || "");
|
|
274
|
+
const pathPart = encodedPath ? `/${encodedPath}` : "";
|
|
275
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
276
|
+
return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
protected buildTeaRawFileEndpoint(ref: RemoteRepoRef, filePath: string): string {
|
|
280
|
+
const owner = encodeURIComponent(ref.owner);
|
|
281
|
+
const repo = encodeURIComponent(ref.repo);
|
|
282
|
+
const encodedFilePath = this.encodePathSegments(filePath);
|
|
283
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
284
|
+
return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
145
287
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
288
|
+
* 使用 tea api 拉取远程规则
|
|
289
|
+
* 前置条件:本地安装 tea 且已登录目标服务器
|
|
148
290
|
*/
|
|
149
|
-
protected async
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
291
|
+
protected async fetchRemoteSpecsViaTea(
|
|
292
|
+
ref: RemoteRepoRef,
|
|
293
|
+
verbose?: VerboseLevel,
|
|
294
|
+
): Promise<string | null> {
|
|
295
|
+
if (!this.isTeaInstalled()) {
|
|
296
|
+
this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
const loginName = this.getTeaLoginForServer(ref.serverUrl);
|
|
300
|
+
if (!loginName) {
|
|
301
|
+
this.logVerbose(
|
|
302
|
+
verbose,
|
|
303
|
+
3,
|
|
304
|
+
` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`,
|
|
305
|
+
);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
|
|
309
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
310
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
311
|
+
try {
|
|
312
|
+
console.log(
|
|
313
|
+
` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
|
|
314
|
+
);
|
|
315
|
+
const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
|
|
316
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
|
|
317
|
+
const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
|
|
318
|
+
const contents = JSON.parse(contentsRaw) as Array<{
|
|
319
|
+
type?: string;
|
|
320
|
+
name?: string;
|
|
321
|
+
path?: string;
|
|
322
|
+
}>;
|
|
323
|
+
const mdFiles = contents.filter(
|
|
324
|
+
(f) => f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path,
|
|
325
|
+
);
|
|
326
|
+
if (mdFiles.length === 0) {
|
|
327
|
+
console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const fetchedFiles: Array<{ name: string; content: string }> = [];
|
|
331
|
+
for (const file of mdFiles) {
|
|
332
|
+
const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path!);
|
|
333
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
|
|
334
|
+
const fileContent = this.runTeaApi(fileEndpoint, loginName);
|
|
335
|
+
fetchedFiles.push({ name: file.name!, content: fileContent });
|
|
336
|
+
}
|
|
337
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
338
|
+
for (const file of fetchedFiles) {
|
|
339
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
167
340
|
}
|
|
341
|
+
console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
342
|
+
return specDir;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
|
|
345
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
346
|
+
if (localDir) {
|
|
347
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
348
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
349
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
350
|
+
return localDir;
|
|
351
|
+
}
|
|
352
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
353
|
+
return null;
|
|
168
354
|
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 通过 Git API 从远程仓库拉取规则文件
|
|
359
|
+
* 保存到 ~/.spaceflow/review-spec/ 目录
|
|
360
|
+
*/
|
|
361
|
+
protected async fetchRemoteSpecs(
|
|
362
|
+
ref: RemoteRepoRef,
|
|
363
|
+
verbose?: VerboseLevel,
|
|
364
|
+
): Promise<string | null> {
|
|
365
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
366
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
169
367
|
try {
|
|
170
368
|
console.log(
|
|
171
369
|
` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
|
|
@@ -183,7 +381,7 @@ export class ReviewSpecService {
|
|
|
183
381
|
console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
|
|
184
382
|
return null;
|
|
185
383
|
}
|
|
186
|
-
|
|
384
|
+
const fetchedFiles: Array<{ name: string; content: string }> = [];
|
|
187
385
|
for (const file of mdFiles) {
|
|
188
386
|
const content = await this.gitProvider!.getFileContent(
|
|
189
387
|
ref.owner,
|
|
@@ -191,28 +389,37 @@ export class ReviewSpecService {
|
|
|
191
389
|
file.path,
|
|
192
390
|
ref.ref,
|
|
193
391
|
);
|
|
194
|
-
|
|
392
|
+
fetchedFiles.push({ name: file.name, content });
|
|
195
393
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
394
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
395
|
+
for (const file of fetchedFiles) {
|
|
396
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
397
|
+
}
|
|
398
|
+
console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
399
|
+
return specDir;
|
|
200
400
|
} catch (error) {
|
|
201
401
|
console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// 无缓存可用
|
|
402
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
403
|
+
if (localDir) {
|
|
404
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
405
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
406
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
407
|
+
return localDir;
|
|
211
408
|
}
|
|
409
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
212
410
|
return null;
|
|
213
411
|
}
|
|
214
412
|
}
|
|
215
413
|
|
|
414
|
+
protected async getSpecFileCount(dir: string): Promise<number> {
|
|
415
|
+
try {
|
|
416
|
+
const entries = await readdir(dir);
|
|
417
|
+
return entries.filter((f) => f.endsWith(".md")).length;
|
|
418
|
+
} catch {
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
216
423
|
/**
|
|
217
424
|
* 解析 deps 目录,扫描子目录中的 references 文件夹
|
|
218
425
|
* 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
|
|
@@ -268,7 +475,32 @@ export class ReviewSpecService {
|
|
|
268
475
|
);
|
|
269
476
|
}
|
|
270
477
|
|
|
271
|
-
protected
|
|
478
|
+
protected buildRepoCloneUrl(ref: RemoteRepoRef): string {
|
|
479
|
+
return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
protected async resolveClonedSpecDir(cacheDir: string, subPath?: string): Promise<string> {
|
|
483
|
+
const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
|
|
484
|
+
if (!normalizedSubPath) {
|
|
485
|
+
return cacheDir;
|
|
486
|
+
}
|
|
487
|
+
const targetDir = join(cacheDir, normalizedSubPath);
|
|
488
|
+
try {
|
|
489
|
+
await access(targetDir);
|
|
490
|
+
return targetDir;
|
|
491
|
+
} catch {
|
|
492
|
+
console.warn(
|
|
493
|
+
` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`,
|
|
494
|
+
);
|
|
495
|
+
return cacheDir;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
protected async cloneSpecRepo(
|
|
500
|
+
repoUrl: string,
|
|
501
|
+
subPath?: string,
|
|
502
|
+
verbose?: VerboseLevel,
|
|
503
|
+
): Promise<string | null> {
|
|
272
504
|
const repoName = this.extractRepoName(repoUrl);
|
|
273
505
|
if (!repoName) {
|
|
274
506
|
console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
|
|
@@ -276,24 +508,27 @@ export class ReviewSpecService {
|
|
|
276
508
|
}
|
|
277
509
|
|
|
278
510
|
const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
|
|
511
|
+
this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
|
|
279
512
|
|
|
280
513
|
try {
|
|
281
514
|
await access(cacheDir);
|
|
282
515
|
// console.log(` 使用缓存的规则仓库: ${cacheDir}`);
|
|
516
|
+
this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
|
|
283
517
|
try {
|
|
284
518
|
execSync("git pull --ff-only", { cwd: cacheDir, stdio: "pipe" });
|
|
285
519
|
// console.log(` 已更新规则仓库`);
|
|
286
520
|
} catch {
|
|
287
521
|
console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
|
|
288
522
|
}
|
|
289
|
-
return cacheDir;
|
|
523
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
290
524
|
} catch {
|
|
291
525
|
// console.log(` 克隆规则仓库: ${repoUrl}`);
|
|
292
526
|
try {
|
|
527
|
+
this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
|
|
293
528
|
await mkdir(join(homedir(), ".spaceflow", "review-spec"), { recursive: true });
|
|
294
529
|
execSync(`git clone --depth 1 "${repoUrl}" "${cacheDir}"`, { stdio: "pipe" });
|
|
295
530
|
// console.log(` 克隆完成: ${cacheDir}`);
|
|
296
|
-
return cacheDir;
|
|
531
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
297
532
|
} catch (error) {
|
|
298
533
|
console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
|
|
299
534
|
return null;
|
|
@@ -302,6 +537,11 @@ export class ReviewSpecService {
|
|
|
302
537
|
}
|
|
303
538
|
|
|
304
539
|
protected extractRepoName(repoUrl: string): string | null {
|
|
540
|
+
const parsedRef = parseRepoUrl(repoUrl);
|
|
541
|
+
if (parsedRef) {
|
|
542
|
+
return `${parsedRef.owner}__${parsedRef.repo}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
305
545
|
let path = repoUrl;
|
|
306
546
|
path = path.replace(/\.git$/, "");
|
|
307
547
|
path = path.replace(/^git@[^:]+:/, "");
|