@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { vi, type Mock } from "vitest";
|
|
2
2
|
import { ReviewSpecService } from "./review-spec.service";
|
|
3
|
+
import { ChangedFileCollection } from "../changed-file-collection";
|
|
3
4
|
import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
|
|
4
5
|
import * as child_process from "child_process";
|
|
5
6
|
|
|
@@ -475,7 +476,7 @@ const MAX_COUNT = 100;
|
|
|
475
476
|
{ filename: "src/user/user.controller.ts" },
|
|
476
477
|
];
|
|
477
478
|
|
|
478
|
-
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
479
|
+
const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
|
|
479
480
|
|
|
480
481
|
// 只按扩展名过滤,includes 在 LLM 审查后处理
|
|
481
482
|
expect(result).toHaveLength(2);
|
|
@@ -509,7 +510,7 @@ const MAX_COUNT = 100;
|
|
|
509
510
|
|
|
510
511
|
const changedFiles = [{ filename: "src/app.ts" }];
|
|
511
512
|
|
|
512
|
-
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
513
|
+
const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
|
|
513
514
|
|
|
514
515
|
expect(result).toHaveLength(1);
|
|
515
516
|
expect(result[0].filename).toBe("js&ts.base.md");
|
|
@@ -976,6 +977,13 @@ const MAX_COUNT = 100;
|
|
|
976
977
|
expect(result).toBe("org__repo");
|
|
977
978
|
});
|
|
978
979
|
|
|
980
|
+
it("should extract from directory URL", () => {
|
|
981
|
+
const result = (service as any).extractRepoName(
|
|
982
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
983
|
+
);
|
|
984
|
+
expect(result).toBe("xgj__review-spec");
|
|
985
|
+
});
|
|
986
|
+
|
|
979
987
|
it("should handle single part path", () => {
|
|
980
988
|
const result = (service as any).extractRepoName("repo");
|
|
981
989
|
expect(result).toBe("repo");
|
|
@@ -1274,10 +1282,74 @@ const MAX_COUNT = 100;
|
|
|
1274
1282
|
]);
|
|
1275
1283
|
expect(result.length).toBeGreaterThanOrEqual(0);
|
|
1276
1284
|
});
|
|
1285
|
+
|
|
1286
|
+
it("should fallback to clone repo root URL when API fetch fails for directory URL", async () => {
|
|
1287
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
|
|
1288
|
+
(access as Mock)
|
|
1289
|
+
.mockRejectedValueOnce(new Error("not found"))
|
|
1290
|
+
.mockResolvedValueOnce(undefined);
|
|
1291
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1292
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1293
|
+
(child_process.execFileSync as Mock).mockImplementation(() => {
|
|
1294
|
+
throw new Error("tea unavailable");
|
|
1295
|
+
});
|
|
1296
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1297
|
+
|
|
1298
|
+
const result = await service.resolveSpecSources([
|
|
1299
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
1300
|
+
]);
|
|
1301
|
+
|
|
1302
|
+
expect(result.some((dir) => dir.includes("xgj__review-spec/references"))).toBe(true);
|
|
1303
|
+
const cloneCall = (child_process.execSync as Mock).mock.calls.find((call) =>
|
|
1304
|
+
String(call[0]).includes('git clone --depth 1 "https://git.bjxgj.com/xgj/review-spec.git"'),
|
|
1305
|
+
);
|
|
1306
|
+
expect(cloneCall).toBeTruthy();
|
|
1307
|
+
consoleSpy.mockRestore();
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("should resolve remote specs via tea when provider API fails", async () => {
|
|
1311
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
|
|
1312
|
+
(child_process.execSync as Mock).mockReturnValue(""); // command -v tea
|
|
1313
|
+
(child_process.execFileSync as Mock)
|
|
1314
|
+
.mockReturnValueOnce(
|
|
1315
|
+
JSON.stringify([
|
|
1316
|
+
{
|
|
1317
|
+
name: "git.bjxgj.com",
|
|
1318
|
+
url: "https://git.bjxgj.com",
|
|
1319
|
+
},
|
|
1320
|
+
]),
|
|
1321
|
+
) // tea login list -o json
|
|
1322
|
+
.mockReturnValueOnce(
|
|
1323
|
+
JSON.stringify([
|
|
1324
|
+
{
|
|
1325
|
+
type: "file",
|
|
1326
|
+
name: "js.base.md",
|
|
1327
|
+
path: "references/js.base.md",
|
|
1328
|
+
},
|
|
1329
|
+
]),
|
|
1330
|
+
) // tea api contents
|
|
1331
|
+
.mockReturnValueOnce("# Test `[JsTs.Base]`"); // tea api raw file
|
|
1332
|
+
(readdir as Mock).mockResolvedValue([]);
|
|
1333
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1334
|
+
(writeFile as Mock).mockResolvedValue(undefined);
|
|
1335
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1336
|
+
|
|
1337
|
+
const result = await service.resolveSpecSources([
|
|
1338
|
+
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
|
1339
|
+
]);
|
|
1340
|
+
|
|
1341
|
+
expect(result.some((dir) => dir.includes("review-spec"))).toBe(true);
|
|
1342
|
+
expect(child_process.execFileSync).toHaveBeenCalledWith(
|
|
1343
|
+
"tea",
|
|
1344
|
+
["api", "-l", "git.bjxgj.com", "/repos/xgj/review-spec/contents/references?ref=main"],
|
|
1345
|
+
expect.objectContaining({ encoding: "utf-8", stdio: "pipe" }),
|
|
1346
|
+
);
|
|
1347
|
+
consoleSpy.mockRestore();
|
|
1348
|
+
});
|
|
1277
1349
|
});
|
|
1278
1350
|
|
|
1279
1351
|
describe("fetchRemoteSpecs", () => {
|
|
1280
|
-
it("should fetch and
|
|
1352
|
+
it("should fetch and persist remote specs into review-spec dir", async () => {
|
|
1281
1353
|
gitProvider.listRepositoryContents.mockResolvedValue([
|
|
1282
1354
|
{ type: "file", name: "rule.md", path: "rule.md" },
|
|
1283
1355
|
]);
|
|
@@ -1301,7 +1373,7 @@ const MAX_COUNT = 100;
|
|
|
1301
1373
|
consoleSpy.mockRestore();
|
|
1302
1374
|
});
|
|
1303
1375
|
|
|
1304
|
-
it("should handle API failure and use
|
|
1376
|
+
it("should handle API failure and use local specs directory", async () => {
|
|
1305
1377
|
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1306
1378
|
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1307
1379
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1313,7 +1385,7 @@ const MAX_COUNT = 100;
|
|
|
1313
1385
|
logSpy.mockRestore();
|
|
1314
1386
|
});
|
|
1315
1387
|
|
|
1316
|
-
it("should handle API failure without
|
|
1388
|
+
it("should handle API failure without local specs directory", async () => {
|
|
1317
1389
|
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1318
1390
|
(readdir as Mock).mockRejectedValue(new Error("no cache"));
|
|
1319
1391
|
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -1325,15 +1397,12 @@ const MAX_COUNT = 100;
|
|
|
1325
1397
|
logSpy.mockRestore();
|
|
1326
1398
|
});
|
|
1327
1399
|
|
|
1328
|
-
it("should use
|
|
1329
|
-
|
|
1330
|
-
delete process.env.CI;
|
|
1331
|
-
(readFile as Mock).mockResolvedValue(String(Date.now()));
|
|
1400
|
+
it("should use local specs directory as fallback", async () => {
|
|
1401
|
+
gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
|
|
1332
1402
|
(readdir as Mock).mockResolvedValue(["cached.md"]);
|
|
1333
1403
|
const ref = { owner: "org", repo: "repo" };
|
|
1334
1404
|
const result = await (service as any).fetchRemoteSpecs(ref);
|
|
1335
1405
|
expect(result).toBeTruthy();
|
|
1336
|
-
process.env.CI = originalCI;
|
|
1337
1406
|
});
|
|
1338
1407
|
});
|
|
1339
1408
|
|
|
@@ -1416,6 +1485,19 @@ const MAX_COUNT = 100;
|
|
|
1416
1485
|
expect(result).toBeTruthy();
|
|
1417
1486
|
});
|
|
1418
1487
|
|
|
1488
|
+
it("should return sub directory when subPath is provided", async () => {
|
|
1489
|
+
(access as Mock)
|
|
1490
|
+
.mockRejectedValueOnce(new Error("not found"))
|
|
1491
|
+
.mockResolvedValueOnce(undefined);
|
|
1492
|
+
(mkdir as Mock).mockResolvedValue(undefined);
|
|
1493
|
+
(child_process.execSync as Mock).mockReturnValue("");
|
|
1494
|
+
const result = await (service as any).cloneSpecRepo(
|
|
1495
|
+
"https://github.com/org/repo.git",
|
|
1496
|
+
"references",
|
|
1497
|
+
);
|
|
1498
|
+
expect(result).toContain("org__repo/references");
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1419
1501
|
it("should handle clone failure", async () => {
|
|
1420
1502
|
(access as Mock).mockRejectedValue(new Error("not found"));
|
|
1421
1503
|
(mkdir as Mock).mockResolvedValue(undefined);
|
|
@@ -1451,7 +1533,7 @@ const MAX_COUNT = 100;
|
|
|
1451
1533
|
},
|
|
1452
1534
|
];
|
|
1453
1535
|
const changedFiles = [{ filename: "Makefile" }, { filename: "src/app.ts" }];
|
|
1454
|
-
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
1536
|
+
const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
|
|
1455
1537
|
expect(result).toHaveLength(1);
|
|
1456
1538
|
});
|
|
1457
1539
|
|
|
@@ -1469,7 +1551,7 @@ const MAX_COUNT = 100;
|
|
|
1469
1551
|
},
|
|
1470
1552
|
];
|
|
1471
1553
|
const changedFiles = [{}];
|
|
1472
|
-
const result = service.filterApplicableSpecs(specs, changedFiles);
|
|
1554
|
+
const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
|
|
1473
1555
|
expect(result).toHaveLength(0);
|
|
1474
1556
|
});
|
|
1475
1557
|
});
|
|
@@ -7,19 +7,27 @@ import {
|
|
|
7
7
|
type RemoteRepoRef,
|
|
8
8
|
type RepositoryContent,
|
|
9
9
|
} from "@spaceflow/core";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { ChangedFileCollection } from "../changed-file-collection";
|
|
11
|
+
import { readdir, readFile, mkdir, access, writeFile, unlink } from "fs/promises";
|
|
12
|
+
import { join, basename } from "path";
|
|
12
13
|
import { homedir } from "os";
|
|
13
|
-
import { execSync } from "child_process";
|
|
14
|
+
import { execSync, execFileSync } from "child_process";
|
|
14
15
|
import micromatch from "micromatch";
|
|
15
16
|
import { ReviewSpec, ReviewRule, RuleExample, Severity } from "./types";
|
|
16
17
|
import { extractGlobsFromIncludes } from "../review-includes-filter";
|
|
17
18
|
|
|
18
|
-
/** 远程规则缓存 TTL(毫秒),默认 5 分钟 */
|
|
19
|
-
const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
|
|
20
|
-
|
|
21
19
|
export class ReviewSpecService {
|
|
22
20
|
constructor(protected readonly gitProvider?: GitProviderService) {}
|
|
21
|
+
|
|
22
|
+
protected normalizeServerUrl(url: string): string {
|
|
23
|
+
return url.trim().replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
protected logVerbose(verbose: VerboseLevel | undefined, level: number, message: string): void {
|
|
27
|
+
if (shouldLog(verbose, level as VerboseLevel)) {
|
|
28
|
+
console.log(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
23
31
|
/**
|
|
24
32
|
* 检查规则 ID 是否匹配(精确匹配或前缀匹配)
|
|
25
33
|
* 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
|
|
@@ -58,17 +66,8 @@ export class ReviewSpecService {
|
|
|
58
66
|
* 根据变更文件的扩展名过滤适用的规则文件
|
|
59
67
|
* 只按扩展名过滤,includes 和 override 在 LLM 审查后处理
|
|
60
68
|
*/
|
|
61
|
-
filterApplicableSpecs(specs: ReviewSpec[], changedFiles:
|
|
62
|
-
const changedExtensions =
|
|
63
|
-
|
|
64
|
-
for (const file of changedFiles) {
|
|
65
|
-
if (file.filename) {
|
|
66
|
-
const ext = extname(file.filename).slice(1).toLowerCase();
|
|
67
|
-
if (ext) {
|
|
68
|
-
changedExtensions.add(ext);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
69
|
+
filterApplicableSpecs(specs: ReviewSpec[], changedFiles: ChangedFileCollection): ReviewSpec[] {
|
|
70
|
+
const changedExtensions = changedFiles.extensions();
|
|
72
71
|
|
|
73
72
|
console.log(
|
|
74
73
|
`[filterApplicableSpecs] changedExtensions=${JSON.stringify([...changedExtensions])}, specs count=${specs.length}`,
|
|
@@ -114,58 +113,249 @@ export class ReviewSpecService {
|
|
|
114
113
|
return specs;
|
|
115
114
|
}
|
|
116
115
|
|
|
117
|
-
async resolveSpecSources(sources: string[]): Promise<string[]> {
|
|
116
|
+
async resolveSpecSources(sources: string[], verbose?: VerboseLevel): Promise<string[]> {
|
|
118
117
|
const dirs: string[] = [];
|
|
119
118
|
|
|
120
119
|
for (const source of sources) {
|
|
121
|
-
|
|
120
|
+
this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
|
|
122
121
|
const repoRef = parseRepoUrl(source);
|
|
122
|
+
if (repoRef) {
|
|
123
|
+
this.logVerbose(
|
|
124
|
+
verbose,
|
|
125
|
+
3,
|
|
126
|
+
` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
|
|
130
|
+
}
|
|
123
131
|
if (repoRef && this.gitProvider) {
|
|
124
|
-
|
|
132
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
|
|
133
|
+
const dir = await this.fetchRemoteSpecs(repoRef, verbose);
|
|
125
134
|
if (dir) {
|
|
126
135
|
dirs.push(dir);
|
|
136
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
|
|
127
137
|
continue;
|
|
128
138
|
}
|
|
139
|
+
this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
|
|
140
|
+
}
|
|
141
|
+
if (repoRef) {
|
|
142
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
|
|
143
|
+
const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
|
|
144
|
+
if (teaDir) {
|
|
145
|
+
dirs.push(teaDir);
|
|
146
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
|
|
150
|
+
}
|
|
151
|
+
// API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
|
|
152
|
+
if (repoRef) {
|
|
153
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
|
|
154
|
+
const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
|
|
155
|
+
this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
|
|
156
|
+
const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
|
|
157
|
+
if (fallbackDir) {
|
|
158
|
+
dirs.push(fallbackDir);
|
|
159
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
|
|
129
163
|
}
|
|
130
164
|
if (this.isRepoUrl(source)) {
|
|
131
|
-
|
|
165
|
+
this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
|
|
166
|
+
const dir = await this.cloneSpecRepo(source, undefined, verbose);
|
|
132
167
|
if (dir) {
|
|
133
168
|
dirs.push(dir);
|
|
169
|
+
this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
|
|
170
|
+
} else {
|
|
171
|
+
this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
|
|
134
172
|
}
|
|
135
173
|
} else {
|
|
136
174
|
// 检查是否是 deps 目录,如果是则扫描子目录的 references
|
|
137
175
|
const resolvedDirs = await this.resolveDepsDir(source);
|
|
138
176
|
dirs.push(...resolvedDirs);
|
|
177
|
+
this.logVerbose(
|
|
178
|
+
verbose,
|
|
179
|
+
3,
|
|
180
|
+
` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`,
|
|
181
|
+
);
|
|
139
182
|
}
|
|
140
183
|
}
|
|
141
184
|
|
|
142
185
|
return dirs;
|
|
143
186
|
}
|
|
144
187
|
|
|
188
|
+
protected buildRemoteSpecDir(ref: RemoteRepoRef): string {
|
|
189
|
+
const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
|
|
190
|
+
return join(homedir(), ".spaceflow", "review-spec", dirKey);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
protected async getLocalSpecsDir(dir: string): Promise<string | null> {
|
|
194
|
+
try {
|
|
195
|
+
const entries = await readdir(dir);
|
|
196
|
+
if (!entries.some((f) => f.endsWith(".md"))) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return dir;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
protected async prepareRemoteSpecDirForWrite(dir: string): Promise<void> {
|
|
206
|
+
await mkdir(dir, { recursive: true });
|
|
207
|
+
try {
|
|
208
|
+
const entries = await readdir(dir);
|
|
209
|
+
for (const name of entries) {
|
|
210
|
+
if (name.endsWith(".md") || name === ".timestamp") {
|
|
211
|
+
await unlink(join(dir, name));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// 忽略目录清理失败,后续写入时再处理
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
protected isTeaInstalled(): boolean {
|
|
220
|
+
try {
|
|
221
|
+
execSync("command -v tea", { stdio: "pipe" });
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
protected getTeaLoginForServer(serverUrl: string): string | null {
|
|
229
|
+
try {
|
|
230
|
+
const output = execFileSync("tea", ["login", "list", "-o", "json"], {
|
|
231
|
+
encoding: "utf-8",
|
|
232
|
+
stdio: "pipe",
|
|
233
|
+
});
|
|
234
|
+
const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
|
|
235
|
+
const logins = JSON.parse(output) as Array<{ name?: string; url?: string }>;
|
|
236
|
+
const matched = logins.find(
|
|
237
|
+
(login) => login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl,
|
|
238
|
+
);
|
|
239
|
+
return matched?.name ?? null;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
protected runTeaApi(endpoint: string, loginName: string): string {
|
|
246
|
+
const args = ["api", "-l", loginName, endpoint];
|
|
247
|
+
return execFileSync("tea", args, {
|
|
248
|
+
encoding: "utf-8",
|
|
249
|
+
stdio: "pipe",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
protected encodePathSegments(path: string): string {
|
|
254
|
+
if (!path) return "";
|
|
255
|
+
return path
|
|
256
|
+
.split("/")
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.map((segment) => encodeURIComponent(segment))
|
|
259
|
+
.join("/");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
protected buildTeaContentsEndpoint(ref: RemoteRepoRef): string {
|
|
263
|
+
const owner = encodeURIComponent(ref.owner);
|
|
264
|
+
const repo = encodeURIComponent(ref.repo);
|
|
265
|
+
const encodedPath = this.encodePathSegments(ref.path || "");
|
|
266
|
+
const pathPart = encodedPath ? `/${encodedPath}` : "";
|
|
267
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
268
|
+
return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
protected buildTeaRawFileEndpoint(ref: RemoteRepoRef, filePath: string): string {
|
|
272
|
+
const owner = encodeURIComponent(ref.owner);
|
|
273
|
+
const repo = encodeURIComponent(ref.repo);
|
|
274
|
+
const encodedFilePath = this.encodePathSegments(filePath);
|
|
275
|
+
const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
|
|
276
|
+
return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
145
279
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
280
|
+
* 使用 tea api 拉取远程规则
|
|
281
|
+
* 前置条件:本地安装 tea 且已登录目标服务器
|
|
148
282
|
*/
|
|
149
|
-
protected async
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
283
|
+
protected async fetchRemoteSpecsViaTea(
|
|
284
|
+
ref: RemoteRepoRef,
|
|
285
|
+
verbose?: VerboseLevel,
|
|
286
|
+
): Promise<string | null> {
|
|
287
|
+
if (!this.isTeaInstalled()) {
|
|
288
|
+
this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const loginName = this.getTeaLoginForServer(ref.serverUrl);
|
|
292
|
+
if (!loginName) {
|
|
293
|
+
this.logVerbose(
|
|
294
|
+
verbose,
|
|
295
|
+
3,
|
|
296
|
+
` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`,
|
|
297
|
+
);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
|
|
301
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
302
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
303
|
+
try {
|
|
304
|
+
console.log(
|
|
305
|
+
` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
|
|
306
|
+
);
|
|
307
|
+
const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
|
|
308
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
|
|
309
|
+
const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
|
|
310
|
+
const contents = JSON.parse(contentsRaw) as Array<{
|
|
311
|
+
type?: string;
|
|
312
|
+
name?: string;
|
|
313
|
+
path?: string;
|
|
314
|
+
}>;
|
|
315
|
+
const mdFiles = contents.filter(
|
|
316
|
+
(f) => f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path,
|
|
317
|
+
);
|
|
318
|
+
if (mdFiles.length === 0) {
|
|
319
|
+
console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
|
|
320
|
+
return null;
|
|
167
321
|
}
|
|
322
|
+
const fetchedFiles: Array<{ name: string; content: string }> = [];
|
|
323
|
+
for (const file of mdFiles) {
|
|
324
|
+
const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path!);
|
|
325
|
+
this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
|
|
326
|
+
const fileContent = this.runTeaApi(fileEndpoint, loginName);
|
|
327
|
+
fetchedFiles.push({ name: file.name!, content: fileContent });
|
|
328
|
+
}
|
|
329
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
330
|
+
for (const file of fetchedFiles) {
|
|
331
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
332
|
+
}
|
|
333
|
+
console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
334
|
+
return specDir;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
|
|
337
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
338
|
+
if (localDir) {
|
|
339
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
340
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
341
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
342
|
+
return localDir;
|
|
343
|
+
}
|
|
344
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
345
|
+
return null;
|
|
168
346
|
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 通过 Git API 从远程仓库拉取规则文件
|
|
351
|
+
* 保存到 ~/.spaceflow/review-spec/ 目录
|
|
352
|
+
*/
|
|
353
|
+
protected async fetchRemoteSpecs(
|
|
354
|
+
ref: RemoteRepoRef,
|
|
355
|
+
verbose?: VerboseLevel,
|
|
356
|
+
): Promise<string | null> {
|
|
357
|
+
const specDir = this.buildRemoteSpecDir(ref);
|
|
358
|
+
this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
|
|
169
359
|
try {
|
|
170
360
|
console.log(
|
|
171
361
|
` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
|
|
@@ -183,7 +373,7 @@ export class ReviewSpecService {
|
|
|
183
373
|
console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
|
|
184
374
|
return null;
|
|
185
375
|
}
|
|
186
|
-
|
|
376
|
+
const fetchedFiles: Array<{ name: string; content: string }> = [];
|
|
187
377
|
for (const file of mdFiles) {
|
|
188
378
|
const content = await this.gitProvider!.getFileContent(
|
|
189
379
|
ref.owner,
|
|
@@ -191,28 +381,37 @@ export class ReviewSpecService {
|
|
|
191
381
|
file.path,
|
|
192
382
|
ref.ref,
|
|
193
383
|
);
|
|
194
|
-
|
|
384
|
+
fetchedFiles.push({ name: file.name, content });
|
|
195
385
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
386
|
+
await this.prepareRemoteSpecDirForWrite(specDir);
|
|
387
|
+
for (const file of fetchedFiles) {
|
|
388
|
+
await writeFile(join(specDir, file.name), file.content, "utf-8");
|
|
389
|
+
}
|
|
390
|
+
console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
|
|
391
|
+
return specDir;
|
|
200
392
|
} catch (error) {
|
|
201
393
|
console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// 无缓存可用
|
|
394
|
+
const localDir = await this.getLocalSpecsDir(specDir);
|
|
395
|
+
if (localDir) {
|
|
396
|
+
const mdCount = await this.getSpecFileCount(localDir);
|
|
397
|
+
this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
|
|
398
|
+
console.log(` 📦 使用本地已存在规则目录`);
|
|
399
|
+
return localDir;
|
|
211
400
|
}
|
|
401
|
+
this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
|
|
212
402
|
return null;
|
|
213
403
|
}
|
|
214
404
|
}
|
|
215
405
|
|
|
406
|
+
protected async getSpecFileCount(dir: string): Promise<number> {
|
|
407
|
+
try {
|
|
408
|
+
const entries = await readdir(dir);
|
|
409
|
+
return entries.filter((f) => f.endsWith(".md")).length;
|
|
410
|
+
} catch {
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
216
415
|
/**
|
|
217
416
|
* 解析 deps 目录,扫描子目录中的 references 文件夹
|
|
218
417
|
* 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
|
|
@@ -268,7 +467,30 @@ export class ReviewSpecService {
|
|
|
268
467
|
);
|
|
269
468
|
}
|
|
270
469
|
|
|
271
|
-
protected
|
|
470
|
+
protected buildRepoCloneUrl(ref: RemoteRepoRef): string {
|
|
471
|
+
return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
protected async resolveClonedSpecDir(cacheDir: string, subPath?: string): Promise<string> {
|
|
475
|
+
const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
|
|
476
|
+
if (!normalizedSubPath) {
|
|
477
|
+
return cacheDir;
|
|
478
|
+
}
|
|
479
|
+
const targetDir = join(cacheDir, normalizedSubPath);
|
|
480
|
+
try {
|
|
481
|
+
await access(targetDir);
|
|
482
|
+
return targetDir;
|
|
483
|
+
} catch {
|
|
484
|
+
console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
|
|
485
|
+
return cacheDir;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
protected async cloneSpecRepo(
|
|
490
|
+
repoUrl: string,
|
|
491
|
+
subPath?: string,
|
|
492
|
+
verbose?: VerboseLevel,
|
|
493
|
+
): Promise<string | null> {
|
|
272
494
|
const repoName = this.extractRepoName(repoUrl);
|
|
273
495
|
if (!repoName) {
|
|
274
496
|
console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
|
|
@@ -276,24 +498,27 @@ export class ReviewSpecService {
|
|
|
276
498
|
}
|
|
277
499
|
|
|
278
500
|
const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
|
|
501
|
+
this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
|
|
279
502
|
|
|
280
503
|
try {
|
|
281
504
|
await access(cacheDir);
|
|
282
505
|
// console.log(` 使用缓存的规则仓库: ${cacheDir}`);
|
|
506
|
+
this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
|
|
283
507
|
try {
|
|
284
508
|
execSync("git pull --ff-only", { cwd: cacheDir, stdio: "pipe" });
|
|
285
509
|
// console.log(` 已更新规则仓库`);
|
|
286
510
|
} catch {
|
|
287
511
|
console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
|
|
288
512
|
}
|
|
289
|
-
return cacheDir;
|
|
513
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
290
514
|
} catch {
|
|
291
515
|
// console.log(` 克隆规则仓库: ${repoUrl}`);
|
|
292
516
|
try {
|
|
517
|
+
this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
|
|
293
518
|
await mkdir(join(homedir(), ".spaceflow", "review-spec"), { recursive: true });
|
|
294
519
|
execSync(`git clone --depth 1 "${repoUrl}" "${cacheDir}"`, { stdio: "pipe" });
|
|
295
520
|
// console.log(` 克隆完成: ${cacheDir}`);
|
|
296
|
-
return cacheDir;
|
|
521
|
+
return this.resolveClonedSpecDir(cacheDir, subPath);
|
|
297
522
|
} catch (error) {
|
|
298
523
|
console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
|
|
299
524
|
return null;
|
|
@@ -302,6 +527,11 @@ export class ReviewSpecService {
|
|
|
302
527
|
}
|
|
303
528
|
|
|
304
529
|
protected extractRepoName(repoUrl: string): string | null {
|
|
530
|
+
const parsedRef = parseRepoUrl(repoUrl);
|
|
531
|
+
if (parsedRef) {
|
|
532
|
+
return `${parsedRef.owner}__${parsedRef.repo}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
305
535
|
let path = repoUrl;
|
|
306
536
|
path = path.replace(/\.git$/, "");
|
|
307
537
|
path = path.replace(/^git@[^:]+:/, "");
|