mirror of
https://github.com/Lydanne/spaceflow.git
synced 2026-03-11 19:52:45 +08:00
chore: 初始化仓库
This commit is contained in:
56
core/.gitignore
vendored
Normal file
56
core/.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
1176
core/CHANGELOG.md
Normal file
1176
core/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
175
core/README.md
Normal file
175
core/README.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# @spaceflow/cli
|
||||
|
||||
Spaceflow CLI 核心框架,提供插件系统、内置命令和共享模块。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pnpm add @spaceflow/cli
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
### 命令行
|
||||
|
||||
```bash
|
||||
# 使用 spaceflow 或 space 命令
|
||||
spaceflow <command> [options]
|
||||
space <command> [options]
|
||||
```
|
||||
|
||||
### 作为库使用
|
||||
|
||||
```typescript
|
||||
import {
|
||||
// 插件系统
|
||||
SpaceflowPlugin,
|
||||
PluginLoaderService,
|
||||
|
||||
// 共享模块
|
||||
GitProviderService,
|
||||
GitSdkService,
|
||||
LlmProxyService,
|
||||
FeishuSdkService,
|
||||
StorageService,
|
||||
|
||||
// NestJS 重导出
|
||||
Command,
|
||||
CommandRunner,
|
||||
Module,
|
||||
Injectable,
|
||||
} from "@spaceflow/cli";
|
||||
```
|
||||
|
||||
## 内置命令
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ------------ | --------------------- |
|
||||
| `install` | 安装插件(命令/技能) |
|
||||
| `uninstall` | 卸载插件 |
|
||||
| `build` | 构建插件 |
|
||||
| `dev` | 开发模式运行 |
|
||||
| `create` | 创建新插件 |
|
||||
| `list` | 列出已安装插件 |
|
||||
| `clear` | 清理缓存 |
|
||||
| `runx` / `x` | 执行插件命令 |
|
||||
| `schema` | 生成配置 Schema |
|
||||
| `commit` | 智能提交 |
|
||||
| `setup` | 初始化配置 |
|
||||
|
||||
## 共享模块
|
||||
|
||||
核心框架导出以下共享模块,供插件开发使用:
|
||||
|
||||
| 模块 | 描述 |
|
||||
| --------------- | ------------------------------------- |
|
||||
| `git-provider` | Git 平台适配器(GitHub/Gitea/GitLab) |
|
||||
| `git-sdk` | Git 命令操作封装 |
|
||||
| `llm-proxy` | 多 LLM 适配器(OpenAI、Claude 等) |
|
||||
| `feishu-sdk` | 飞书 API 操作封装 |
|
||||
| `storage` | 通用存储服务 |
|
||||
| `claude-setup` | Claude Agent 配置 |
|
||||
| `parallel` | 并行执行工具 |
|
||||
| `output` | 输出服务 |
|
||||
| `verbose` | 日志级别控制 |
|
||||
| `editor-config` | 编辑器配置管理 |
|
||||
| `llm-jsonput` | JSON 结构化输出 |
|
||||
|
||||
## 插件开发
|
||||
|
||||
### 创建插件
|
||||
|
||||
```bash
|
||||
spaceflow create my-plugin --type command
|
||||
```
|
||||
|
||||
### 插件结构
|
||||
|
||||
```typescript
|
||||
import {
|
||||
SpaceflowPlugin,
|
||||
Module,
|
||||
Command,
|
||||
CommandRunner,
|
||||
} from "@spaceflow/cli";
|
||||
|
||||
@Command({ name: "my-command", description: "My command description" })
|
||||
class MyCommand extends CommandRunner {
|
||||
async run(): Promise<void> {
|
||||
console.log("Hello from my command!");
|
||||
}
|
||||
}
|
||||
|
||||
@Module({ providers: [MyCommand] })
|
||||
class MyModule {}
|
||||
|
||||
export class MyPlugin implements SpaceflowPlugin {
|
||||
name = "my-plugin";
|
||||
version = "1.0.0";
|
||||
module = MyModule;
|
||||
}
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
core/
|
||||
├── src/
|
||||
│ ├── commands/ # 内置命令
|
||||
│ │ ├── install/ # 插件安装
|
||||
│ │ ├── uninstall/ # 插件卸载
|
||||
│ │ ├── build/ # 插件构建
|
||||
│ │ ├── dev/ # 开发模式
|
||||
│ │ ├── create/ # 创建插件
|
||||
│ │ ├── list/ # 列出插件
|
||||
│ │ ├── clear/ # 清理缓存
|
||||
│ │ ├── runx/ # 执行命令
|
||||
│ │ ├── schema/ # Schema 生成
|
||||
│ │ ├── commit/ # 智能提交
|
||||
│ │ └── setup/ # 初始化配置
|
||||
│ ├── shared/ # 共享模块
|
||||
│ │ ├── git-provider/ # Git Provider 适配器
|
||||
│ │ ├── git-sdk/ # Git SDK
|
||||
│ │ ├── llm-proxy/ # LLM 代理
|
||||
│ │ ├── feishu-sdk/ # 飞书 SDK
|
||||
│ │ ├── storage/ # 存储服务
|
||||
│ │ └── ...
|
||||
│ ├── plugin-system/ # 插件系统核心
|
||||
│ ├── config/ # 配置管理
|
||||
│ ├── cli.ts # CLI 入口
|
||||
│ └── index.ts # 库导出
|
||||
└── test/ # 测试文件
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 开发模式
|
||||
pnpm run start:dev
|
||||
|
||||
# 构建
|
||||
pnpm run build
|
||||
|
||||
# 测试
|
||||
pnpm run test
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 代码格式化
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **NestJS** - 依赖注入框架
|
||||
- **nest-commander** - CLI 命令框架
|
||||
- **rspack** - 构建工具
|
||||
- **TypeScript** - 类型系统
|
||||
|
||||
## 许可证
|
||||
|
||||
UNLICENSED
|
||||
10
core/nest-cli.json
Normal file
10
core/nest-cli.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"webpack": true,
|
||||
"webpackConfigPath": "webpack.config.cjs"
|
||||
}
|
||||
}
|
||||
120
core/package.json
Normal file
120
core/package.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "@spaceflow/core",
|
||||
"version": "0.1.0",
|
||||
"description": "Spaceflow 核心能力库",
|
||||
"license": "UNLICENSED",
|
||||
"author": "",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./verbose": {
|
||||
"types": "./src/shared/verbose/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./editor-config": {
|
||||
"types": "./src/shared/editor-config/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./source-utils": {
|
||||
"types": "./src/shared/source-utils/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./package-manager": {
|
||||
"types": "./src/shared/package-manager/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./spaceflow-dir": {
|
||||
"types": "./src/shared/spaceflow-dir/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./spaceflow.config": {
|
||||
"types": "./src/config/spaceflow.config.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./schema-generator.service": {
|
||||
"types": "./src/config/schema-generator.service.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./rspack-config": {
|
||||
"types": "./src/shared/rspack-config/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./llm-proxy": {
|
||||
"types": "./src/shared/llm-proxy/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./llm-jsonput": {
|
||||
"types": "./src/shared/llm-jsonput/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./parallel": {
|
||||
"types": "./src/shared/parallel/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rspack build -c rspack.config.mjs",
|
||||
"format": "oxfmt src test --write",
|
||||
"lint": "oxlint src test",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.7",
|
||||
"@larksuiteoapi/node-sdk": "^1.55.0",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@nestjs/common": "catalog:",
|
||||
"@nestjs/config": "catalog:",
|
||||
"@nestjs/core": "catalog:",
|
||||
"@nestjs/event-emitter": "catalog:",
|
||||
"@nestjs/platform-express": "catalog:",
|
||||
"@nestjs/swagger": "catalog:",
|
||||
"@opencode-ai/sdk": "^1.1.23",
|
||||
"@release-it/conventional-changelog": "^10.0.4",
|
||||
"@rspack/cli": "^1.7.4",
|
||||
"@rspack/core": "^1.7.4",
|
||||
"chalk": "^5.6.2",
|
||||
"class-transformer": "catalog:",
|
||||
"class-validator": "catalog:",
|
||||
"i18next": "^25.8.4",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"jsonrepair": "^3.13.1",
|
||||
"log-update": "^7.1.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"nest-commander": "catalog:",
|
||||
"openai": "^6.1.0",
|
||||
"ora": "^9.3.0",
|
||||
"reflect-metadata": "catalog:",
|
||||
"release-it": "^19.2.2",
|
||||
"release-it-gitea": "^1.8.0",
|
||||
"rxjs": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "catalog:",
|
||||
"@nestjs/schematics": "catalog:",
|
||||
"@nestjs/testing": "catalog:",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/express": "catalog:",
|
||||
"@types/micromatch": "^4.0.10",
|
||||
"@types/node": "catalog:",
|
||||
"@types/supertest": "catalog:",
|
||||
"source-map-support": "catalog:",
|
||||
"supertest": "catalog:",
|
||||
"ts-loader": "catalog:",
|
||||
"ts-node": "catalog:",
|
||||
"tsconfig-paths": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"unplugin-swc": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
62
core/rspack.config.mjs
Normal file
62
core/rspack.config.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default {
|
||||
optimization: {
|
||||
minimize: false,
|
||||
},
|
||||
entry: {
|
||||
index: "./src/index.ts",
|
||||
},
|
||||
plugins: [],
|
||||
target: "node",
|
||||
mode: process.env.NODE_ENV === "production" ? "production" : "development",
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: resolve(__dirname, "dist"),
|
||||
library: { type: "module" },
|
||||
chunkFormat: "module",
|
||||
clean: true,
|
||||
},
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
externalsType: "module-import",
|
||||
externals: [
|
||||
{ micromatch: "node-commonjs micromatch" },
|
||||
/^(?!src\/)[^./]/, // node_modules 包(排除 src/ 别名)
|
||||
],
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
extensionAlias: {
|
||||
".js": [".ts", ".js"],
|
||||
},
|
||||
tsConfig: {
|
||||
configFile: resolve(__dirname, "tsconfig.json"),
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
loader: "builtin:swc-loader",
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: "typescript",
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
target: "es2022",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
9
core/src/__mocks__/@opencode-ai/sdk.js
Normal file
9
core/src/__mocks__/@opencode-ai/sdk.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
createOpencodeClient: jest.fn().mockReturnValue({
|
||||
session: {
|
||||
create: jest.fn(),
|
||||
prompt: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
3
core/src/__mocks__/c12.ts
Normal file
3
core/src/__mocks__/c12.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const loadConfig = jest.fn().mockResolvedValue({
|
||||
config: {},
|
||||
});
|
||||
18
core/src/app.module.ts
Normal file
18
core/src/app.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StorageModule } from "./shared/storage/storage.module";
|
||||
import { OutputModule } from "./shared/output";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { configLoaders, getEnvFilePaths } from "./config";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: configLoaders,
|
||||
envFilePath: getEnvFilePaths(),
|
||||
}),
|
||||
StorageModule.forFeature(),
|
||||
OutputModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
29
core/src/config/ci.config.ts
Normal file
29
core/src/config/ci.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { createEnvConfigLoader } from "./config-loader";
|
||||
|
||||
/** CI 配置 Schema */
|
||||
const schemaFactory = () =>
|
||||
z.object({
|
||||
/** 仓库名称 (owner/repo 格式) */
|
||||
repository: z
|
||||
.string()
|
||||
.default(process.env.GITHUB_REPOSITORY || "")
|
||||
.describe("仓库名称 (owner/repo 格式)"),
|
||||
/** 当前分支名称 */
|
||||
refName: z
|
||||
.string()
|
||||
.default(process.env.GITHUB_REF_NAME || "")
|
||||
.describe("当前分支名称"),
|
||||
actor: z
|
||||
.string()
|
||||
.default(process.env.GITHUB_ACTOR || "")
|
||||
.describe("当前操作者"),
|
||||
});
|
||||
|
||||
/** CI 配置类型 */
|
||||
export type CiConfig = z.infer<ReturnType<typeof schemaFactory>>;
|
||||
|
||||
export const ciConfig = createEnvConfigLoader({
|
||||
configKey: "ci",
|
||||
schemaFactory,
|
||||
});
|
||||
101
core/src/config/config-loader.ts
Normal file
101
core/src/config/config-loader.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { z } from "zod";
|
||||
import { readConfigSync } from "./spaceflow.config";
|
||||
import { registerPluginSchema } from "./schema-generator.service";
|
||||
|
||||
/**
|
||||
* 配置加载器选项
|
||||
*/
|
||||
export interface ConfigLoaderOptions<T extends z.ZodObject<z.ZodRawShape>> {
|
||||
/** 配置 key(用于 registerAs 和从 spaceflow.json 读取) */
|
||||
configKey: string;
|
||||
/** zod schema(应包含 .default() 设置默认值) */
|
||||
schemaFactory: () => T;
|
||||
/** 配置描述(用于生成 JSON Schema) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建配置加载器
|
||||
* 统一使用 zod 进行配置验证和默认值填充
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const GitProviderConfigSchema = z.object({
|
||||
* serverUrl: z.string().default(process.env.GITHUB_SERVER_URL || ""),
|
||||
* token: z.string().default(process.env.GITHUB_TOKEN || ""),
|
||||
* });
|
||||
*
|
||||
* export type GitProviderConfig = z.infer<typeof GitProviderConfigSchema>;
|
||||
*
|
||||
* export const gitProviderConfig = createConfigLoader({
|
||||
* configKey: "gitProvider",
|
||||
* schemaFactory: GitProviderConfigSchema,
|
||||
* description: "Git Provider 服务配置",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createConfigLoader<T extends z.ZodObject<z.ZodRawShape>>(
|
||||
options: ConfigLoaderOptions<T>,
|
||||
) {
|
||||
const { configKey, schemaFactory, description } = options;
|
||||
|
||||
// 创建配置加载器
|
||||
return registerAs(configKey, (): z.infer<T> => {
|
||||
const schema = schemaFactory();
|
||||
// 注册 schema 用于 JSON Schema 生成
|
||||
registerPluginSchema({
|
||||
configKey,
|
||||
schemaFactory: () => schema,
|
||||
description,
|
||||
});
|
||||
|
||||
const fileConfig = readConfigSync();
|
||||
const rawConfig = fileConfig[configKey] ?? {};
|
||||
|
||||
// 使用 zod 验证并填充默认值
|
||||
const result = schema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`配置 "${configKey}" 验证失败:\n${errors}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建简单配置加载器(仅从环境变量读取,不读取配置文件)
|
||||
* 适用于 CI 等纯环境变量配置
|
||||
*/
|
||||
export function createEnvConfigLoader<T extends z.ZodObject<z.ZodRawShape>>(
|
||||
options: Omit<ConfigLoaderOptions<T>, "description"> & { description?: string },
|
||||
) {
|
||||
const { configKey, schemaFactory, description } = options;
|
||||
|
||||
return registerAs(configKey, (): z.infer<T> => {
|
||||
const schema = schemaFactory();
|
||||
// 注册 schema(如果有描述)
|
||||
if (description) {
|
||||
registerPluginSchema({
|
||||
configKey,
|
||||
schemaFactory: () => schema,
|
||||
description,
|
||||
});
|
||||
}
|
||||
// 直接使用空对象,让 schema 的 default 值生效
|
||||
const result = schema.safeParse({});
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`配置 "${configKey}" 验证失败:\n${errors}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
});
|
||||
}
|
||||
16
core/src/config/config-reader.module.ts
Normal file
16
core/src/config/config-reader.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { ConfigReaderService } from "./config-reader.service";
|
||||
import { SchemaGeneratorService } from "./schema-generator.service";
|
||||
|
||||
/**
|
||||
* 配置读取模块
|
||||
* 提供插件配置读取服务和 Schema 生成服务
|
||||
*
|
||||
* 插件的 defaultConfig 通过 getMetadata() 返回,在插件加载时自动注册
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ConfigReaderService, SchemaGeneratorService],
|
||||
exports: [ConfigReaderService, SchemaGeneratorService],
|
||||
})
|
||||
export class ConfigReaderModule {}
|
||||
133
core/src/config/config-reader.service.ts
Normal file
133
core/src/config/config-reader.service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { SpaceflowConfig } from "./spaceflow.config";
|
||||
|
||||
/**
|
||||
* 系统配置(不包含插件配置)
|
||||
*/
|
||||
export interface SystemConfig {
|
||||
/** 已安装的技能包注册表 */
|
||||
dependencies?: Record<string, string>;
|
||||
/** 支持的编辑器列表 */
|
||||
support?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件配置注册信息
|
||||
*/
|
||||
export interface PluginConfigRegistry {
|
||||
/** 插件名称 */
|
||||
name: string;
|
||||
/** 配置 key */
|
||||
configKey?: string;
|
||||
/** 依赖的其他插件配置 */
|
||||
configDependencies?: string[];
|
||||
/** 配置 schema 工厂函数 */
|
||||
configSchema?: () => unknown;
|
||||
}
|
||||
|
||||
/** 全局插件配置注册表(使用 global 确保跨模块共享) */
|
||||
const PLUGIN_REGISTRY_KEY = Symbol.for("spaceflow.pluginRegistry");
|
||||
const globalAny = global as any;
|
||||
if (!globalAny[PLUGIN_REGISTRY_KEY]) {
|
||||
globalAny[PLUGIN_REGISTRY_KEY] = new Map<string, PluginConfigRegistry>();
|
||||
}
|
||||
const pluginRegistry: Map<string, PluginConfigRegistry> = globalAny[PLUGIN_REGISTRY_KEY];
|
||||
|
||||
/**
|
||||
* 注册插件配置(由插件加载器调用)
|
||||
*/
|
||||
export function registerPluginConfig(registry: PluginConfigRegistry): void {
|
||||
if (registry.configKey) {
|
||||
pluginRegistry.set(registry.configKey, registry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已注册的插件配置
|
||||
*/
|
||||
export function getRegisteredPluginConfig(configKey: string): PluginConfigRegistry | undefined {
|
||||
return pluginRegistry.get(configKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置读取服务
|
||||
* 提供三种配置读取方式:
|
||||
* 1. getPluginConfig - 读取指定插件的配置
|
||||
* 2. getOtherPluginConfig - 读取其他插件的配置(需要在 metadata 中声明依赖)
|
||||
* 3. getSystemConfig - 读取系统配置
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfigReaderService {
|
||||
constructor(protected readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* 读取插件的配置
|
||||
* 使用 schema 验证并合并默认值
|
||||
* @param configKey 插件的配置 key
|
||||
* @returns 验证后的插件配置
|
||||
*/
|
||||
getPluginConfig<T>(configKey: string): T {
|
||||
const rawConfig = this.configService.get<Record<string, unknown>>("spaceflow");
|
||||
const userConfig = rawConfig?.[configKey] ?? {};
|
||||
|
||||
// 从注册表获取 schema 工厂函数
|
||||
const registry = pluginRegistry.get(configKey);
|
||||
const schemaFactory = registry?.configSchema;
|
||||
|
||||
if (!schemaFactory || typeof schemaFactory !== "function") {
|
||||
return userConfig as T;
|
||||
}
|
||||
|
||||
// 调用工厂函数获取 schema
|
||||
const schema = schemaFactory();
|
||||
if (!schema || typeof (schema as any).parse !== "function") {
|
||||
return userConfig as T;
|
||||
}
|
||||
|
||||
// 使用 schema.parse() 验证并填充默认值
|
||||
try {
|
||||
return (schema as any).parse(userConfig) as T;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ 配置 "${configKey}" 验证失败:`, error);
|
||||
return userConfig as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取其他插件的配置
|
||||
* 必须在插件的 metadata.configDependencies 中声明依赖
|
||||
* @param fromConfigKey 当前插件的配置 key
|
||||
* @param targetConfigKey 目标插件的配置 key
|
||||
* @returns 合并后的插件配置
|
||||
*/
|
||||
getOtherPluginConfig<T>(fromConfigKey: string, targetConfigKey: string): T {
|
||||
const fromRegistry = pluginRegistry.get(fromConfigKey);
|
||||
if (!fromRegistry) {
|
||||
throw new Error(`插件 "${fromConfigKey}" 未注册`);
|
||||
}
|
||||
|
||||
// 检查是否已声明依赖
|
||||
const dependencies = fromRegistry.configDependencies ?? [];
|
||||
if (!dependencies.includes(targetConfigKey)) {
|
||||
throw new Error(
|
||||
`插件 "${fromRegistry.name}" 未声明对 "${targetConfigKey}" 配置的依赖。` +
|
||||
`请在插件 metadata 的 configDependencies 中添加 "${targetConfigKey}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getPluginConfig<T>(targetConfigKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取系统配置(不包含插件配置)
|
||||
* @returns 系统配置
|
||||
*/
|
||||
getSystemConfig(): SystemConfig {
|
||||
const rawConfig = this.configService.get<SpaceflowConfig>("spaceflow");
|
||||
return {
|
||||
dependencies: rawConfig?.dependencies,
|
||||
support: rawConfig?.support,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
core/src/config/feishu.config.ts
Normal file
35
core/src/config/feishu.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { createConfigLoader } from "./config-loader";
|
||||
|
||||
const schemaFactory = () =>
|
||||
z.object({
|
||||
/** 飞书应用 ID */
|
||||
appId: z
|
||||
.string()
|
||||
.default(process.env.FEISHU_APP_ID || "")
|
||||
.describe("飞书应用 ID"),
|
||||
/** 飞书应用密钥 */
|
||||
appSecret: z
|
||||
.string()
|
||||
.default(process.env.FEISHU_APP_SECRET || "")
|
||||
.describe("飞书应用密钥"),
|
||||
/** 应用类型:自建应用或商店应用 */
|
||||
appType: z
|
||||
.enum(["self_build", "store"])
|
||||
.default((process.env.FEISHU_APP_TYPE as "self_build" | "store") || "self_build")
|
||||
.describe("应用类型"),
|
||||
/** 域名:飞书或 Lark */
|
||||
domain: z
|
||||
.enum(["feishu", "lark"])
|
||||
.default((process.env.FEISHU_DOMAIN as "feishu" | "lark") || "feishu")
|
||||
.describe("域名"),
|
||||
});
|
||||
|
||||
/** 飞书配置类型 */
|
||||
export type FeishuConfig = z.infer<ReturnType<typeof schemaFactory>>;
|
||||
|
||||
export const feishuConfig = createConfigLoader({
|
||||
configKey: "feishu",
|
||||
schemaFactory,
|
||||
description: "飞书 SDK 配置",
|
||||
});
|
||||
29
core/src/config/git-provider.config.ts
Normal file
29
core/src/config/git-provider.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { createConfigLoader } from "./config-loader";
|
||||
import { detectProvider } from "../shared/git-provider/detect-provider";
|
||||
|
||||
/** 从环境自动检测的默认值 */
|
||||
const detected = detectProvider();
|
||||
|
||||
/** Git Provider 配置 Schema */
|
||||
const schemaFactory = () =>
|
||||
z.object({
|
||||
/** Git Provider 类型(自动检测或手动指定) */
|
||||
provider: z
|
||||
.enum(["gitea", "github", "gitlab"])
|
||||
.default(detected.provider)
|
||||
.describe("Git Provider 类型 (github | gitea | gitlab),未指定时自动检测"),
|
||||
/** Git Provider 服务器 URL */
|
||||
serverUrl: z.string().default(detected.serverUrl).describe("Git Provider 服务器 URL"),
|
||||
/** Git Provider API Token */
|
||||
token: z.string().default(detected.token).describe("Git Provider API Token"),
|
||||
});
|
||||
|
||||
/** Git Provider 配置类型 */
|
||||
export type GitProviderConfig = z.infer<ReturnType<typeof schemaFactory>>;
|
||||
|
||||
export const gitProviderConfig = createConfigLoader({
|
||||
configKey: "gitProvider",
|
||||
schemaFactory,
|
||||
description: "Git Provider 服务配置",
|
||||
});
|
||||
29
core/src/config/index.ts
Normal file
29
core/src/config/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export * from "./git-provider.config";
|
||||
export * from "./ci.config";
|
||||
export * from "./llm.config";
|
||||
export * from "./feishu.config";
|
||||
export * from "./storage.config";
|
||||
export * from "./spaceflow.config";
|
||||
export * from "./config-reader.service";
|
||||
export * from "./config-reader.module";
|
||||
export * from "./schema-generator.service";
|
||||
export * from "./config-loader";
|
||||
|
||||
import { gitProviderConfig } from "./git-provider.config";
|
||||
import { ciConfig } from "./ci.config";
|
||||
import { llmConfig } from "./llm.config";
|
||||
import { feishuConfig } from "./feishu.config";
|
||||
import { storageConfig } from "./storage.config";
|
||||
import { spaceflowConfig } from "./spaceflow.config";
|
||||
|
||||
/**
|
||||
* 所有配置加载器
|
||||
*/
|
||||
export const configLoaders = [
|
||||
gitProviderConfig,
|
||||
ciConfig,
|
||||
llmConfig,
|
||||
feishuConfig,
|
||||
storageConfig,
|
||||
spaceflowConfig,
|
||||
];
|
||||
110
core/src/config/llm.config.ts
Normal file
110
core/src/config/llm.config.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod";
|
||||
import { createConfigLoader } from "./config-loader";
|
||||
|
||||
const schemaFactory = () => {
|
||||
/** Claude Code 适配器配置 Schema */
|
||||
const ClaudeCodeConfigSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.default(process.env.CLAUDE_CODE_BASE_URL || "")
|
||||
.describe("API 基础 URL"),
|
||||
authToken: z
|
||||
.string()
|
||||
.default(process.env.CLAUDE_CODE_AUTH_TOKEN || "")
|
||||
.describe("认证令牌"),
|
||||
model: z
|
||||
.string()
|
||||
.default(process.env.CLAUDE_CODE_MODEL || "claude-sonnet-4-5")
|
||||
.describe("模型名称"),
|
||||
hasCompletedOnboarding: z.boolean().optional().describe("是否已完成 Claude Code 引导流程"),
|
||||
});
|
||||
|
||||
/** OpenAI 适配器配置 Schema */
|
||||
const OpenAIConfigSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.default(process.env.OPENAI_BASE_URL || "")
|
||||
.describe("API 基础 URL"),
|
||||
apiKey: z
|
||||
.string()
|
||||
.default(process.env.OPENAI_API_KEY || "")
|
||||
.describe("API Key"),
|
||||
model: z
|
||||
.string()
|
||||
.default(process.env.OPENAI_MODEL || "gpt-4o")
|
||||
.describe("模型名称"),
|
||||
});
|
||||
|
||||
/** OpenCode 适配器配置 Schema */
|
||||
const OpenCodeConfigSchema = z.object({
|
||||
serverUrl: z
|
||||
.string()
|
||||
.default(process.env.OPENCODE_SERVER_URL || "http://localhost:4096")
|
||||
.describe("服务器 URL"),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.default(process.env.OPENCODE_BASE_URL || "")
|
||||
.describe("API 基础 URL"),
|
||||
apiKey: z
|
||||
.string()
|
||||
.default(process.env.OPENCODE_API_KEY || "")
|
||||
.describe("API Key"),
|
||||
providerID: z
|
||||
.string()
|
||||
.default(process.env.OPENCODE_PROVIDER_ID || "openai")
|
||||
.describe("Provider ID"),
|
||||
model: z
|
||||
.string()
|
||||
.default(process.env.OPENCODE_MODEL || "")
|
||||
.describe("模型名称"),
|
||||
});
|
||||
|
||||
/** Gemini 适配器配置 Schema */
|
||||
const GeminiConfigSchema = z.object({
|
||||
baseUrl: z
|
||||
.string()
|
||||
.default(process.env.GEMINI_BASE_URL || "")
|
||||
.describe("API 基础 URL"),
|
||||
apiKey: z
|
||||
.string()
|
||||
.default(process.env.GEMINI_API_KEY || "")
|
||||
.describe("API Key"),
|
||||
model: z
|
||||
.string()
|
||||
.default(process.env.GEMINI_MODEL || "")
|
||||
.describe("模型名称"),
|
||||
});
|
||||
|
||||
/** LLM 配置 Schema */
|
||||
const LlmConfigSchema = z.object({
|
||||
claudeCode: z
|
||||
.preprocess((v) => v ?? {}, ClaudeCodeConfigSchema)
|
||||
.describe("Claude Code 适配器配置"),
|
||||
openai: z.preprocess((v) => v ?? {}, OpenAIConfigSchema).describe("OpenAI 适配器配置"),
|
||||
openCode: z.preprocess((v) => v ?? {}, OpenCodeConfigSchema).describe("OpenCode 适配器配置"),
|
||||
gemini: z.preprocess((v) => v ?? {}, GeminiConfigSchema).describe("Gemini 适配器配置"),
|
||||
});
|
||||
|
||||
return LlmConfigSchema;
|
||||
};
|
||||
|
||||
/** LLM 系统配置类型 */
|
||||
export type LlmConfig = z.infer<ReturnType<typeof schemaFactory>>;
|
||||
|
||||
/** Claude Code 适配器配置类型 */
|
||||
export type ClaudeCodeConfig = z.infer<LlmConfig["claudeCode"]>;
|
||||
|
||||
/** OpenAI 适配器配置类型 */
|
||||
export type OpenAIConfig = z.infer<LlmConfig["openai"]>;
|
||||
|
||||
/** OpenCode 适配器配置类型 */
|
||||
export type OpenCodeConfig = z.infer<LlmConfig["openCode"]>;
|
||||
|
||||
/** Gemini 适配器配置类型 */
|
||||
export type GeminiConfig = z.infer<LlmConfig["gemini"]>;
|
||||
|
||||
export const llmConfig = createConfigLoader({
|
||||
configKey: "llm",
|
||||
schemaFactory,
|
||||
description: "LLM 服务配置",
|
||||
});
|
||||
129
core/src/config/schema-generator.service.ts
Normal file
129
core/src/config/schema-generator.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { z } from "zod";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/** Schema 注册信息 */
|
||||
export interface SchemaRegistry {
|
||||
/** 配置 key */
|
||||
configKey: string;
|
||||
/** zod schema 工厂函数 */
|
||||
schemaFactory: () => z.ZodType;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 全局 schema 注册表 */
|
||||
const schemaRegistry = new Map<string, SchemaRegistry>();
|
||||
|
||||
/**
|
||||
* 注册插件配置 schema(由插件加载器调用)
|
||||
*/
|
||||
export function registerPluginSchema(registry: SchemaRegistry): void {
|
||||
schemaRegistry.set(registry.configKey, registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的 schema
|
||||
*/
|
||||
export function getRegisteredSchemas(): Map<string, SchemaRegistry> {
|
||||
return schemaRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema 生成服务
|
||||
* 用于生成 JSON Schema 文件
|
||||
*/
|
||||
@Injectable()
|
||||
export class SchemaGeneratorService {
|
||||
/**
|
||||
* 生成完整的 spaceflow.json 的 JSON Schema
|
||||
* @param outputPath 输出路径
|
||||
*/
|
||||
generateJsonSchema(outputPath: string): void {
|
||||
const properties: Record<string, unknown> = {
|
||||
dependencies: {
|
||||
type: "object",
|
||||
description: "已安装的技能包注册表",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
support: {
|
||||
type: "array",
|
||||
description: "支持的编辑器列表",
|
||||
items: { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
// 添加所有插件的 schema
|
||||
for (const [configKey, registry] of schemaRegistry) {
|
||||
try {
|
||||
const schema = registry.schemaFactory();
|
||||
const jsonSchema = z.toJSONSchema(schema, {
|
||||
target: "draft-07",
|
||||
});
|
||||
properties[configKey] = {
|
||||
...jsonSchema,
|
||||
description: registry.description || `${configKey} 插件配置`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ 无法转换 ${configKey} 的 schema:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const fullSchema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
title: "Spaceflow Configuration",
|
||||
description: "Spaceflow 配置文件 schema",
|
||||
type: "object",
|
||||
properties,
|
||||
additionalProperties: true,
|
||||
};
|
||||
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
fs.writeFileSync(outputPath, JSON.stringify(fullSchema, null, 2), "utf-8");
|
||||
console.log(`✅ JSON Schema 已生成: ${outputPath}`);
|
||||
|
||||
// 自动添加到 .gitignore
|
||||
this.addToGitignore(dir, path.basename(outputPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 JSON Schema 到默认路径 (.spaceflow/config-schema.json)
|
||||
*/
|
||||
generate(): void {
|
||||
const outputPath = path.join(process.cwd(), ".spaceflow", "config-schema.json");
|
||||
this.generateJsonSchema(outputPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件添加到 .gitignore(如果不存在)
|
||||
*/
|
||||
private addToGitignore(dir: string, filename: string): void {
|
||||
const gitignorePath = path.join(dir, ".gitignore");
|
||||
let content = "";
|
||||
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
content = fs.readFileSync(gitignorePath, "utf-8");
|
||||
// 检查是否已存在
|
||||
const lines = content.split("\n").map((line) => line.trim());
|
||||
if (lines.includes(filename)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
content = "# 自动生成的 .gitignore\n";
|
||||
}
|
||||
|
||||
// 追加文件名
|
||||
const newContent = content.endsWith("\n")
|
||||
? `${content}${filename}\n`
|
||||
: `${content}\n${filename}\n`;
|
||||
|
||||
fs.writeFileSync(gitignorePath, newContent, "utf-8");
|
||||
}
|
||||
}
|
||||
292
core/src/config/spaceflow.config.ts
Normal file
292
core/src/config/spaceflow.config.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { readFileSync, existsSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import stringify from "json-stringify-pretty-compact";
|
||||
import { z } from "zod";
|
||||
import type { LLMMode } from "../shared/llm-proxy";
|
||||
import { registerPluginSchema } from "./schema-generator.service";
|
||||
|
||||
/** 默认编辑器 */
|
||||
export const DEFAULT_SUPPORT_EDITOR = "claudeCode";
|
||||
|
||||
/** 配置文件名 */
|
||||
export const CONFIG_FILE_NAME = "spaceflow.json";
|
||||
|
||||
/** RC 配置文件名(位于 .spaceflow 同级目录) */
|
||||
export const RC_FILE_NAME = ".spaceflowrc";
|
||||
|
||||
/** Spaceflow 核心配置 Schema */
|
||||
const SpaceflowCoreConfigSchema = z.object({
|
||||
/** 界面语言,如 zh-CN、en */
|
||||
lang: z.string().optional().describe("界面语言,如 zh-CN、en"),
|
||||
/** 已安装的技能包注册表 */
|
||||
dependencies: z.record(z.string(), z.string()).optional().describe("已安装的技能包注册表"),
|
||||
/** 支持的编辑器列表,用于安装 skills 和 commands 时关联目录 */
|
||||
support: z.array(z.string()).default([DEFAULT_SUPPORT_EDITOR]).describe("支持的编辑器列表"),
|
||||
});
|
||||
|
||||
// 注册 spaceflow 核心配置 schema
|
||||
registerPluginSchema({
|
||||
configKey: "spaceflow",
|
||||
schemaFactory: () => SpaceflowCoreConfigSchema,
|
||||
description: "Spaceflow 核心配置",
|
||||
});
|
||||
|
||||
/**
|
||||
* SpaceflowConfig - 通用配置
|
||||
* 子命令的配置由各自模块定义和管理
|
||||
*/
|
||||
export type SpaceflowConfig = z.infer<typeof SpaceflowCoreConfigSchema> & {
|
||||
/** 子命令配置,由各子命令模块自行定义类型 */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// ============ 配置文件操作工具函数 ============
|
||||
|
||||
// 不应该被深度合并的字段,这些字段应该直接覆盖而非合并
|
||||
const NO_MERGE_FIELDS = ["dependencies"];
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
* 后面的对象会覆盖前面的对象,数组会被替换而非合并
|
||||
* NO_MERGE_FIELDS 中的字段不会被深度合并,而是直接覆盖
|
||||
*/
|
||||
function deepMerge<T extends Record<string, unknown>>(...objects: Partial<T>[]): Partial<T> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const obj of objects) {
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
const existing = result[key];
|
||||
|
||||
// 对于 NO_MERGE_FIELDS 中的字段,直接覆盖而非合并
|
||||
if (NO_MERGE_FIELDS.includes(key)) {
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
existing !== null &&
|
||||
typeof existing === "object" &&
|
||||
!Array.isArray(existing)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
existing as Record<string, unknown>,
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
} else if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主配置文件路径(用于写入)
|
||||
* 配置文件统一存放在 .spaceflow/ 目录下
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getConfigPath(cwd?: string): string {
|
||||
return join(cwd || process.cwd(), ".spaceflow", CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有配置文件路径(按优先级从低到高排列)
|
||||
* 优先级: ~/.spaceflow/spaceflow.json < ~/.spaceflowrc < ./.spaceflow/spaceflow.json < ./.spaceflowrc
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getConfigPaths(cwd?: string): string[] {
|
||||
const workDir = cwd || process.cwd();
|
||||
return [
|
||||
join(homedir(), ".spaceflow", CONFIG_FILE_NAME),
|
||||
join(homedir(), RC_FILE_NAME),
|
||||
join(workDir, ".spaceflow", CONFIG_FILE_NAME),
|
||||
join(workDir, RC_FILE_NAME),
|
||||
];
|
||||
}
|
||||
|
||||
/** .env 文件名 */
|
||||
const ENV_FILE_NAME = ".env";
|
||||
|
||||
/**
|
||||
* 获取所有 .env 文件路径(按优先级从高到低排列,供 ConfigModule.envFilePath 使用)
|
||||
*
|
||||
* NestJS ConfigModule 中 envFilePath 数组靠前的优先级更高(先读到的变量不会被后面覆盖)
|
||||
* 因此返回顺序为从高到低:
|
||||
* 1. ./.env (程序启动目录,最高优先级)
|
||||
* 2. ./.spaceflow/.env (项目配置目录)
|
||||
* 3. ~/.env (全局 home 目录)
|
||||
* 4. ~/.spaceflow/.env (全局配置目录,最低优先级)
|
||||
*
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getEnvFilePaths(cwd?: string): string[] {
|
||||
const workDir = cwd || process.cwd();
|
||||
return [
|
||||
join(workDir, ENV_FILE_NAME),
|
||||
join(workDir, ".spaceflow", ENV_FILE_NAME),
|
||||
join(homedir(), ENV_FILE_NAME),
|
||||
join(homedir(), ".spaceflow", ENV_FILE_NAME),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单个配置文件(同步)
|
||||
* @param configPath 配置文件路径
|
||||
*/
|
||||
function readSingleConfigSync(configPath: string): Partial<SpaceflowConfig> {
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
console.warn(`警告: 无法解析配置文件 ${configPath}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置文件(同步)
|
||||
* 按优先级从低到高读取并合并配置:
|
||||
* 1. ~/.spaceflow/spaceflow.json (全局配置,最低优先级)
|
||||
* 2. ~/.spaceflowrc (全局 RC 配置)
|
||||
* 3. ./.spaceflow/spaceflow.json (项目配置)
|
||||
* 4. ./.spaceflowrc (项目根目录 RC 配置,最高优先级)
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function readConfigSync(cwd?: string): Partial<SpaceflowConfig> {
|
||||
const configPaths = getConfigPaths(cwd);
|
||||
const configs = configPaths.map((p) => readSingleConfigSync(p));
|
||||
return deepMerge(...configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入配置文件(同步)
|
||||
* @param config 配置对象
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function writeConfigSync(config: Partial<SpaceflowConfig>, cwd?: string): void {
|
||||
const configPath = getConfigPath(cwd);
|
||||
writeFileSync(configPath, stringify(config, { indent: 2 }) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的编辑器列表
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getSupportedEditors(cwd?: string): string[] {
|
||||
const config = readConfigSync(cwd);
|
||||
return config.support || [DEFAULT_SUPPORT_EDITOR];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 dependencies
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getDependencies(cwd?: string): Record<string, string> {
|
||||
const config = readConfigSync(cwd);
|
||||
return (config.dependencies as Record<string, string>) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单个 dependency
|
||||
* @param name 依赖名称
|
||||
* @param source 依赖来源
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
* @returns 是否有更新(false 表示已存在相同配置)
|
||||
*/
|
||||
export function updateDependency(name: string, source: string, cwd?: string): boolean {
|
||||
const config = readConfigSync(cwd) as Record<string, unknown>;
|
||||
|
||||
if (!config.dependencies) {
|
||||
config.dependencies = {};
|
||||
}
|
||||
|
||||
const dependencies = config.dependencies as Record<string, string>;
|
||||
|
||||
// 检查是否已存在相同配置
|
||||
if (dependencies[name] === source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dependencies[name] = source;
|
||||
writeConfigSync(config, cwd);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单个 dependency
|
||||
* @param name 依赖名称
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
* @returns 是否有删除(false 表示不存在)
|
||||
*/
|
||||
export function removeDependency(name: string, cwd?: string): boolean {
|
||||
const config = readConfigSync(cwd) as Record<string, unknown>;
|
||||
|
||||
if (!config.dependencies) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dependencies = config.dependencies as Record<string, string>;
|
||||
|
||||
if (!(name in dependencies)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete dependencies[name];
|
||||
writeConfigSync(config, cwd);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const spaceflowConfig = registerAs("spaceflow", (): SpaceflowConfig => {
|
||||
const fileConfig = readConfigSync();
|
||||
|
||||
// 使用 zod 验证核心配置
|
||||
const result = SpaceflowCoreConfigSchema.safeParse(fileConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Spaceflow 配置验证失败:\n${errors}`);
|
||||
}
|
||||
|
||||
// 返回验证后的核心配置 + 其他插件配置
|
||||
return {
|
||||
...fileConfig,
|
||||
...result.data,
|
||||
} as SpaceflowConfig;
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载 spaceflow.json 配置(用于 CLI 启动时)
|
||||
* 使用 zod 验证配置
|
||||
*/
|
||||
export function loadSpaceflowConfig(): SpaceflowConfig {
|
||||
const fileConfig = readConfigSync();
|
||||
|
||||
// 使用 zod 验证核心配置
|
||||
const result = SpaceflowCoreConfigSchema.safeParse(fileConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues
|
||||
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Spaceflow 配置验证失败:\n${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...fileConfig,
|
||||
...result.data,
|
||||
} as SpaceflowConfig;
|
||||
}
|
||||
|
||||
export type { LLMMode };
|
||||
33
core/src/config/storage.config.ts
Normal file
33
core/src/config/storage.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { createConfigLoader } from "./config-loader";
|
||||
|
||||
/** Storage 配置 Schema */
|
||||
const schemaFactory = () =>
|
||||
z.object({
|
||||
/** 适配器类型:memory 或 file */
|
||||
adapter: z
|
||||
.enum(["memory", "file"])
|
||||
.default((process.env.STORAGE_ADAPTER as "memory" | "file") || "memory")
|
||||
.describe("适配器类型"),
|
||||
/** 文件适配器的存储路径(仅 file 适配器需要) */
|
||||
filePath: z.string().optional().describe("文件存储路径"),
|
||||
/** 默认过期时间(毫秒),0 表示永不过期 */
|
||||
defaultTtl: z
|
||||
.number()
|
||||
.default(process.env.STORAGE_DEFAULT_TTL ? parseInt(process.env.STORAGE_DEFAULT_TTL, 10) : 0)
|
||||
.describe("默认过期时间(毫秒)"),
|
||||
/** 最大 key 数量,0 表示不限制 */
|
||||
maxKeys: z
|
||||
.number()
|
||||
.default(process.env.STORAGE_MAX_KEYS ? parseInt(process.env.STORAGE_MAX_KEYS, 10) : 0)
|
||||
.describe("最大 key 数量"),
|
||||
});
|
||||
|
||||
/** Storage 配置类型 */
|
||||
export type StorageConfig = z.infer<ReturnType<typeof schemaFactory>>;
|
||||
|
||||
export const storageConfig = createConfigLoader({
|
||||
configKey: "storage",
|
||||
schemaFactory,
|
||||
description: "存储服务配置",
|
||||
});
|
||||
221
core/src/extension-system/extension.interface.ts
Normal file
221
core/src/extension-system/extension.interface.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Type, DynamicModule } from "@nestjs/common";
|
||||
|
||||
/** .spaceflow 目录名 */
|
||||
export const SPACEFLOW_DIR = ".spaceflow";
|
||||
|
||||
/** package.json 文件名 */
|
||||
export const PACKAGE_JSON = "package.json";
|
||||
|
||||
/**
|
||||
* Extension 元数据
|
||||
*/
|
||||
export interface SpaceflowExtensionMetadata {
|
||||
/** Extension 名称 */
|
||||
name: string;
|
||||
/** 提供的命令列表 */
|
||||
commands: string[];
|
||||
/** 对应 spaceflow.json 中的配置 key(可选) */
|
||||
configKey?: string;
|
||||
/** 依赖的其他 Extension 配置 key 列表,读取其他 Extension 配置前必须在此声明 */
|
||||
configDependencies?: string[];
|
||||
/** 配置 schema 工厂函数,返回 zod schema,用于验证配置和生成 JSON Schema */
|
||||
configSchema?: () => unknown;
|
||||
/** Extension 版本 */
|
||||
version?: string;
|
||||
/** Extension 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension 模块类型,支持静态模块或动态模块
|
||||
*/
|
||||
export type ExtensionModuleType = Type<any> | DynamicModule;
|
||||
|
||||
/**
|
||||
* Extension 接口
|
||||
*/
|
||||
export interface SpaceflowExtension {
|
||||
/** 获取 Extension 元数据 */
|
||||
getMetadata(): SpaceflowExtensionMetadata;
|
||||
/**
|
||||
* 获取 NestJS Module
|
||||
* 可以返回静态 Module 类或 DynamicModule
|
||||
* 如果需要动态配置,推荐返回 DynamicModule
|
||||
*/
|
||||
getModule(): ExtensionModuleType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension 类静态接口
|
||||
*/
|
||||
export interface SpaceflowExtensionConstructor {
|
||||
new (): SpaceflowExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension 入口导出格式
|
||||
*/
|
||||
export type SpaceflowExtensionExport =
|
||||
| SpaceflowExtensionConstructor
|
||||
| {
|
||||
default: SpaceflowExtensionConstructor;
|
||||
};
|
||||
|
||||
/**
|
||||
* 已加载的 Extension 信息
|
||||
*/
|
||||
export interface LoadedExtension {
|
||||
/** Extension 名称 */
|
||||
name: string;
|
||||
/** Extension 来源(npm 包名) */
|
||||
source: string;
|
||||
/** NestJS 模块(静态或动态) */
|
||||
module: ExtensionModuleType;
|
||||
/** 包的完整导出(用于 MCP 服务发现) */
|
||||
exports?: Record<string, unknown>;
|
||||
/** 提供的命令列表 */
|
||||
commands: string[];
|
||||
/** 配置 key */
|
||||
configKey?: string;
|
||||
/** 依赖的其他 Extension 配置 key 列表 */
|
||||
configDependencies?: string[];
|
||||
/** 配置 schema 工厂函数 */
|
||||
configSchema?: () => unknown;
|
||||
/** Extension 版本 */
|
||||
version?: string;
|
||||
/** Extension 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* .spaceflow/package.json 中的 dependencies
|
||||
*/
|
||||
export type ExtensionDependencies = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Spaceflow 导出类型
|
||||
* - flow: 子命令(默认),需要构建,注册为 CLI 子命令
|
||||
* - command: 编辑器命令,复制到 .claude/commands/ 等目录
|
||||
* - skill: 技能包,复制到 .claude/skills/ 等目录
|
||||
* - mcp: MCP Server,注册到编辑器的 mcp.json 配置
|
||||
*/
|
||||
export type SpaceflowExportType = "flow" | "command" | "skill" | "mcp";
|
||||
|
||||
/**
|
||||
* MCP Server 配置
|
||||
*/
|
||||
export interface McpServerConfig {
|
||||
/** 启动命令,如 "node", "python" */
|
||||
command: string;
|
||||
/** 启动参数,如 ["dist/index.js"] */
|
||||
args?: string[];
|
||||
/** 需要的环境变量名列表,安装时会提示用户配置 */
|
||||
env?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个导出项配置
|
||||
*/
|
||||
export interface SpaceflowExportConfig {
|
||||
/** 导出类型,默认为 flow */
|
||||
type?: SpaceflowExportType;
|
||||
/** 入口路径,相对于包根目录 */
|
||||
entry: string;
|
||||
/** 描述(可选) */
|
||||
description?: string;
|
||||
/** MCP Server 配置(仅 type 为 mcp 时有效) */
|
||||
mcp?: McpServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json 中的 spaceflow 配置
|
||||
*
|
||||
* 完整格式:
|
||||
* ```json
|
||||
* "spaceflow": {
|
||||
* "exports": {
|
||||
* "review": { "type": "flow", "entry": "." },
|
||||
* "review-rules": { "type": "skill", "entry": "./skills" },
|
||||
* "my-mcp": { "type": "mcp", "entry": ".", "mcp": { "command": "node", "args": ["dist/index.js"] } }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* 简化格式(单导出):
|
||||
* ```json
|
||||
* "spaceflow": {
|
||||
* "type": "mcp",
|
||||
* "entry": ".",
|
||||
* "mcp": { "command": "node", "args": ["dist/index.js"] }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface SpaceflowPackageConfig {
|
||||
/** 多导出配置 */
|
||||
exports?: Record<string, SpaceflowExportConfig>;
|
||||
/** 简化格式:导出类型 */
|
||||
type?: SpaceflowExportType;
|
||||
/** 简化格式:入口路径 */
|
||||
entry?: string;
|
||||
/** 简化格式:描述 */
|
||||
description?: string;
|
||||
/** 简化格式:MCP 配置 */
|
||||
mcp?: McpServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后的导出项
|
||||
*/
|
||||
export interface ResolvedSpaceflowExport {
|
||||
/** 导出名称 */
|
||||
name: string;
|
||||
/** 导出类型 */
|
||||
type: SpaceflowExportType;
|
||||
/** 入口路径(绝对路径) */
|
||||
entry: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** MCP 配置(仅 type 为 mcp 时有效) */
|
||||
mcp?: McpServerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 spaceflow 配置,返回所有导出项
|
||||
*/
|
||||
export function resolveSpaceflowConfig(
|
||||
config: SpaceflowPackageConfig | undefined,
|
||||
packageName: string,
|
||||
packagePath: string,
|
||||
): ResolvedSpaceflowExport[] {
|
||||
const { join } = require("path");
|
||||
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 完整格式:有 exports 字段
|
||||
if (config.exports) {
|
||||
return Object.entries(config.exports).map(([name, exportConfig]) => ({
|
||||
name,
|
||||
type: exportConfig.type || "flow",
|
||||
entry: join(packagePath, exportConfig.entry),
|
||||
description: exportConfig.description,
|
||||
mcp: exportConfig.mcp,
|
||||
}));
|
||||
}
|
||||
|
||||
// 简化格式:直接有 type/entry 字段
|
||||
if (config.entry) {
|
||||
return [
|
||||
{
|
||||
name: packageName,
|
||||
type: config.type || "flow",
|
||||
entry: join(packagePath, config.entry),
|
||||
description: config.description,
|
||||
mcp: config.mcp,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
1
core/src/extension-system/index.ts
Normal file
1
core/src/extension-system/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./extension.interface";
|
||||
80
core/src/index.ts
Normal file
80
core/src/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// ============ 插件系统 ============
|
||||
export * from "./extension-system";
|
||||
|
||||
// ============ 基础能力模块 ============
|
||||
// Git Provider - 多平台 Git 托管 API 操作(GitHub、Gitea、GitLab)
|
||||
export * from "./shared/git-provider";
|
||||
|
||||
// Git SDK - Git 命令操作
|
||||
export * from "./shared/git-sdk";
|
||||
|
||||
// LLM Proxy - 多 LLM 适配器
|
||||
export * from "./shared/llm-proxy";
|
||||
|
||||
// Feishu SDK - 飞书 API 操作
|
||||
export * from "./shared/feishu-sdk";
|
||||
|
||||
// Storage - 存储服务
|
||||
export * from "./shared/storage";
|
||||
|
||||
// Claude Setup - Claude Agent 配置
|
||||
export * from "./shared/claude-setup";
|
||||
|
||||
// Parallel - 并行执行工具
|
||||
export * from "./shared/parallel";
|
||||
|
||||
// Output - 输出服务
|
||||
export * from "./shared/output";
|
||||
|
||||
// Verbose - 日志级别
|
||||
export * from "./shared/verbose";
|
||||
|
||||
// Editor Config - 编辑器配置
|
||||
export * from "./shared/editor-config";
|
||||
|
||||
// LLM JsonPut - JSON 结构化输出
|
||||
export * from "./shared/llm-jsonput";
|
||||
|
||||
// Source Utils - 源类型判断工具
|
||||
export * from "./shared/source-utils";
|
||||
|
||||
// Package Manager - 包管理器检测
|
||||
export * from "./shared/package-manager";
|
||||
|
||||
// Spaceflow Dir - .spaceflow 目录管理
|
||||
export * from "./shared/spaceflow-dir";
|
||||
|
||||
// Rspack Config - Rspack 配置工具
|
||||
export * from "./shared/rspack-config";
|
||||
|
||||
// MCP - Model Context Protocol 支持
|
||||
export * from "./shared/mcp";
|
||||
|
||||
// I18n - 国际化
|
||||
export * from "./shared/i18n";
|
||||
|
||||
// Logger - 全局日志工具
|
||||
export * from "./shared/logger";
|
||||
|
||||
// ============ 配置相关 ============
|
||||
export * from "./config";
|
||||
|
||||
// ============ NestJS 重导出 ============
|
||||
export { Command, CommandRunner, Option, SubCommand } from "nest-commander";
|
||||
export { Module, Injectable, Inject, Global } from "@nestjs/common";
|
||||
export { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
|
||||
// ============ Zod 重导出 ============
|
||||
export { z } from "zod";
|
||||
|
||||
// ============ Swagger DTO 重导出 ============
|
||||
export { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
export {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
} from "class-validator";
|
||||
11
core/src/locales/en/translation.json
Normal file
11
core/src/locales/en/translation.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"common.executionFailed": "Execution failed: {{error}}",
|
||||
"common.stackTrace": "\nStack trace:\n{{stack}}",
|
||||
"common.options.dryRun": "Only print actions without executing",
|
||||
"common.options.verbose": "Show verbose logs (-v: basic, -vv: detailed)",
|
||||
"common.options.verboseDebug": "Show verbose logs (-v: basic, -vv: detailed, -vvv: debug)",
|
||||
"common.options.ci": "Run in CI environment",
|
||||
"config.parseWarning": "Warning: unable to parse config file {{path}}",
|
||||
"config.validationFailed": "Spaceflow config validation failed:\n{{errors}}",
|
||||
"extensionLoader.loadFailed": "⚠️ Failed to load Extension {{name}}: {{error}}"
|
||||
}
|
||||
11
core/src/locales/zh-cn/translation.json
Normal file
11
core/src/locales/zh-cn/translation.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"common.executionFailed": "执行失败: {{error}}",
|
||||
"common.stackTrace": "\n堆栈信息:\n{{stack}}",
|
||||
"common.options.dryRun": "仅打印将要执行的操作,不实际执行",
|
||||
"common.options.verbose": "显示详细日志 (-v: 基本日志, -vv: 详细日志)",
|
||||
"common.options.verboseDebug": "显示详细日志 (-v: 基本日志, -vv: 详细日志, -vvv: 调试日志)",
|
||||
"common.options.ci": "是否在 CI 环境中运行",
|
||||
"config.parseWarning": "警告: 无法解析配置文件 {{path}}",
|
||||
"config.validationFailed": "Spaceflow 配置验证失败:\n{{errors}}",
|
||||
"extensionLoader.loadFailed": "⚠️ 加载 Extension {{name}} 失败: {{error}}"
|
||||
}
|
||||
8
core/src/shared/claude-setup/claude-setup.module.ts
Normal file
8
core/src/shared/claude-setup/claude-setup.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ClaudeSetupService } from "./claude-setup.service";
|
||||
|
||||
@Module({
|
||||
providers: [ClaudeSetupService],
|
||||
exports: [ClaudeSetupService],
|
||||
})
|
||||
export class ClaudeSetupModule {}
|
||||
131
core/src/shared/claude-setup/claude-setup.service.ts
Normal file
131
core/src/shared/claude-setup/claude-setup.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { readFile, writeFile, mkdir, copyFile, unlink } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import { LlmConfig } from "../../config";
|
||||
|
||||
import { shouldLog, type VerboseLevel } from "../verbose";
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeSetupService {
|
||||
constructor(protected readonly configService: ConfigService) {}
|
||||
|
||||
private getPaths() {
|
||||
const claudeDir = join(homedir(), ".claude");
|
||||
return {
|
||||
claudeDir,
|
||||
settingsPath: join(claudeDir, "settings.json"),
|
||||
settingsBackupPath: join(claudeDir, "settings.json.bak"),
|
||||
claudeJsonPath: join(homedir(), ".claude.json"),
|
||||
claudeJsonBackupPath: join(homedir(), ".claude.json.bak"),
|
||||
};
|
||||
}
|
||||
|
||||
async backup(): Promise<void> {
|
||||
const paths = this.getPaths();
|
||||
try {
|
||||
await copyFile(paths.settingsPath, paths.settingsBackupPath);
|
||||
} catch (e) {
|
||||
// 忽略文件不存在的情况
|
||||
}
|
||||
try {
|
||||
await copyFile(paths.claudeJsonPath, paths.claudeJsonBackupPath);
|
||||
} catch (e) {
|
||||
// 忽略文件不存在的情况
|
||||
}
|
||||
}
|
||||
|
||||
async restore(): Promise<void> {
|
||||
const paths = this.getPaths();
|
||||
try {
|
||||
await copyFile(paths.settingsBackupPath, paths.settingsPath);
|
||||
await unlink(paths.settingsBackupPath);
|
||||
} catch (e) {
|
||||
// 忽略备份文件不存在的情况
|
||||
}
|
||||
try {
|
||||
await copyFile(paths.claudeJsonBackupPath, paths.claudeJsonPath);
|
||||
await unlink(paths.claudeJsonBackupPath);
|
||||
} catch (e) {
|
||||
// 忽略备份文件不存在的情况
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用临时配置执行操作
|
||||
* 自动备份现有配置,执行完成后恢复
|
||||
*/
|
||||
async withTemporaryConfig<T>(fn: () => Promise<T>, verbose?: VerboseLevel): Promise<T> {
|
||||
await this.backup();
|
||||
try {
|
||||
await this.configure(verbose);
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.restore();
|
||||
}
|
||||
}
|
||||
|
||||
async configure(verbose?: VerboseLevel): Promise<void> {
|
||||
const { claudeDir, settingsPath, claudeJsonPath } = this.getPaths();
|
||||
|
||||
const llmConf = this.configService.get<LlmConfig>("llm");
|
||||
const claudeCode = llmConf?.claudeCode;
|
||||
|
||||
if (!claudeCode) {
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log("未配置 claude 设置,跳过");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
} catch {
|
||||
// ignore if exists
|
||||
}
|
||||
|
||||
let existingSettings = {};
|
||||
try {
|
||||
const content = await readFile(settingsPath, "utf-8");
|
||||
existingSettings = JSON.parse(content);
|
||||
} catch {
|
||||
// file doesn't exist or invalid JSON
|
||||
}
|
||||
|
||||
const existing = existingSettings as Record<string, Record<string, unknown>>;
|
||||
const env: Record<string, string> = { ...(existing.env as Record<string, string>) };
|
||||
if (claudeCode.baseUrl) env.ANTHROPIC_BASE_URL = claudeCode.baseUrl;
|
||||
if (claudeCode.authToken) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = claudeCode.authToken;
|
||||
} else {
|
||||
throw new Error("未配置 claudeCode.authToken 设置");
|
||||
}
|
||||
if (claudeCode.model) env.ANTHROPIC_MODEL = claudeCode.model;
|
||||
|
||||
const mergedSettings = {
|
||||
...existingSettings,
|
||||
env,
|
||||
};
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2), "utf-8");
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(`✅ 已写入 ${settingsPath}`);
|
||||
}
|
||||
|
||||
if (claudeCode.hasCompletedOnboarding !== undefined) {
|
||||
let claudeJson: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = await readFile(claudeJsonPath, "utf-8");
|
||||
claudeJson = JSON.parse(content);
|
||||
} catch {
|
||||
// file doesn't exist or invalid JSON
|
||||
}
|
||||
claudeJson.hasCompletedOnboarding = claudeCode.hasCompletedOnboarding;
|
||||
await writeFile(claudeJsonPath, JSON.stringify(claudeJson, null, 2), "utf-8");
|
||||
if (shouldLog(verbose, 1)) {
|
||||
console.log(`✅ 已写入 ${claudeJsonPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
core/src/shared/claude-setup/index.ts
Normal file
2
core/src/shared/claude-setup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./claude-setup.service";
|
||||
export * from "./claude-setup.module";
|
||||
23
core/src/shared/editor-config/index.ts
Normal file
23
core/src/shared/editor-config/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 编辑器配置目录映射
|
||||
* key: 编辑器名称(用于配置文件)
|
||||
* value: 编辑器配置目录名(以 . 开头)
|
||||
*/
|
||||
export const EDITOR_DIR_MAPPING: Record<string, string> = {
|
||||
claudeCode: ".claude",
|
||||
windsurf: ".windsurf",
|
||||
cursor: ".cursor",
|
||||
opencode: ".opencode",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认支持的编辑器
|
||||
*/
|
||||
export const DEFAULT_EDITOR = "claudeCode";
|
||||
|
||||
/**
|
||||
* 根据编辑器名称获取配置目录名
|
||||
*/
|
||||
export function getEditorDirName(editor: string): string {
|
||||
return EDITOR_DIR_MAPPING[editor] || `.${editor}`;
|
||||
}
|
||||
77
core/src/shared/feishu-sdk/feishu-sdk.module.ts
Normal file
77
core/src/shared/feishu-sdk/feishu-sdk.module.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { DynamicModule, Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { FeishuSdkService } from "./feishu-sdk.service";
|
||||
import { FeishuCardService } from "./fieshu-card.service";
|
||||
import { FeishuModuleOptions, FeishuModuleAsyncOptions, FEISHU_MODULE_OPTIONS } from "./types";
|
||||
import { feishuConfig, FeishuConfig } from "../../config";
|
||||
|
||||
@Module({})
|
||||
export class FeishuSdkModule {
|
||||
/**
|
||||
* 同步注册模块
|
||||
*/
|
||||
static forRoot(options: FeishuModuleOptions): DynamicModule {
|
||||
return {
|
||||
module: FeishuSdkModule,
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: FEISHU_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
FeishuSdkService,
|
||||
FeishuCardService,
|
||||
],
|
||||
exports: [FeishuSdkService, FeishuCardService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步注册模块 - 支持从环境变量等动态获取配置
|
||||
*/
|
||||
static forRootAsync(options: FeishuModuleAsyncOptions): DynamicModule {
|
||||
return {
|
||||
module: FeishuSdkModule,
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: FEISHU_MODULE_OPTIONS,
|
||||
useFactory: options.useFactory,
|
||||
inject: options.inject || [],
|
||||
},
|
||||
FeishuSdkService,
|
||||
FeishuCardService,
|
||||
],
|
||||
exports: [FeishuSdkService, FeishuCardService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ConfigService 注册模块
|
||||
*/
|
||||
static forFeature(): DynamicModule {
|
||||
return {
|
||||
module: FeishuSdkModule,
|
||||
imports: [ConfigModule.forFeature(feishuConfig), EventEmitterModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: FEISHU_MODULE_OPTIONS,
|
||||
useFactory: (configService: ConfigService): FeishuModuleOptions => {
|
||||
const config = configService.get<FeishuConfig>("feishu");
|
||||
return {
|
||||
appId: config?.appId || "",
|
||||
appSecret: config?.appSecret || "",
|
||||
appType: config?.appType,
|
||||
domain: config?.domain,
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
FeishuSdkService,
|
||||
FeishuCardService,
|
||||
],
|
||||
exports: [FeishuSdkService, FeishuCardService],
|
||||
};
|
||||
}
|
||||
}
|
||||
130
core/src/shared/feishu-sdk/feishu-sdk.service.ts
Normal file
130
core/src/shared/feishu-sdk/feishu-sdk.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import * as lark from "@larksuiteoapi/node-sdk";
|
||||
import {
|
||||
FeishuModuleOptions,
|
||||
FEISHU_MODULE_OPTIONS,
|
||||
FeishuUser,
|
||||
GetUserParams,
|
||||
FEISHU_CARD_ACTION_TRIGGER,
|
||||
type CardEvents,
|
||||
type CardActionTriggerResponse,
|
||||
type CardActionTriggerEvent,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* 飞书 API 服务
|
||||
*/
|
||||
@Injectable()
|
||||
export class FeishuSdkService implements OnModuleInit, OnModuleDestroy {
|
||||
protected readonly client: lark.Client;
|
||||
|
||||
onModuleInit(): void {
|
||||
const eventDispatcher = new lark.EventDispatcher({
|
||||
encryptKey: "encrypt key",
|
||||
}).register<CardEvents>({
|
||||
[FEISHU_CARD_ACTION_TRIGGER]: async (data) => {
|
||||
let done: (result: CardActionTriggerResponse) => void = () => null;
|
||||
const p = new Promise<CardActionTriggerResponse>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
|
||||
// 转换为事件监听器友好的格式
|
||||
const event: CardActionTriggerEvent = {
|
||||
...data,
|
||||
done,
|
||||
};
|
||||
|
||||
this.eventEmitter.emit(FEISHU_CARD_ACTION_TRIGGER, event);
|
||||
return await p;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.eventEmitter.removeAllListeners();
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(FEISHU_MODULE_OPTIONS)
|
||||
protected readonly options: FeishuModuleOptions,
|
||||
protected readonly eventEmitter: EventEmitter2,
|
||||
) {
|
||||
this.client = new lark.Client({
|
||||
appId: options.appId,
|
||||
appSecret: options.appSecret,
|
||||
appType: options.appType === "store" ? lark.AppType.ISV : lark.AppType.SelfBuild,
|
||||
domain: options.domain === "lark" ? lark.Domain.Lark : lark.Domain.Feishu,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证飞书配置
|
||||
*/
|
||||
validateConfig(): void {
|
||||
if (!this.options?.appId) {
|
||||
throw new Error("缺少配置 feishu.appId (环境变量 FEISHU_APP_ID)");
|
||||
}
|
||||
|
||||
if (!this.options?.appSecret) {
|
||||
throw new Error("缺少配置 feishu.appSecret (环境变量 FEISHU_APP_SECRET)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始 Lark Client 实例
|
||||
* 用于调用 SDK 中未封装的 API
|
||||
*/
|
||||
getClient(): lark.Client {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param params 获取用户信息的参数
|
||||
* @returns 用户信息
|
||||
*/
|
||||
async getUser(params: GetUserParams): Promise<FeishuUser | null> {
|
||||
const { userId, userIdType = "open_id", departmentIdType } = params;
|
||||
|
||||
const response = await this.client.contact.user.get({
|
||||
path: {
|
||||
user_id: userId,
|
||||
},
|
||||
params: {
|
||||
user_id_type: userIdType,
|
||||
department_id_type: departmentIdType,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`飞书 API 错误: ${response.code} - ${response.msg}`);
|
||||
}
|
||||
|
||||
return response.data?.user as FeishuUser | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户信息
|
||||
* @param userIds 用户 ID 列表
|
||||
* @param userIdType 用户 ID 类型
|
||||
* @returns 用户信息列表
|
||||
*/
|
||||
async batchGetUsers(
|
||||
userIds: string[],
|
||||
userIdType: "open_id" | "union_id" | "user_id" = "open_id",
|
||||
): Promise<FeishuUser[]> {
|
||||
const response = await this.client.contact.user.batch({
|
||||
params: {
|
||||
user_ids: userIds,
|
||||
user_id_type: userIdType,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`飞书 API 错误: ${response.code} - ${response.msg}`);
|
||||
}
|
||||
|
||||
return (response.data?.items || []) as FeishuUser[];
|
||||
}
|
||||
}
|
||||
139
core/src/shared/feishu-sdk/fieshu-card.service.ts
Normal file
139
core/src/shared/feishu-sdk/fieshu-card.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { FeishuSdkService } from "./feishu-sdk.service";
|
||||
import {
|
||||
SendCardParams,
|
||||
ReplyCardParams,
|
||||
UpdateCardParams,
|
||||
SendMessageResponse,
|
||||
CardContent,
|
||||
type CardActionTriggerCallback,
|
||||
} from "./types";
|
||||
import { OnEvent, type EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { FEISHU_CARD_ACTION_TRIGGER } from "./types";
|
||||
|
||||
/**
|
||||
* 飞书卡片消息服务
|
||||
* 提供卡片消息的发送、回复、更新功能
|
||||
*/
|
||||
@Injectable()
|
||||
export class FeishuCardService {
|
||||
constructor(
|
||||
protected readonly feishuSdkService: FeishuSdkService,
|
||||
protected readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
@OnEvent(FEISHU_CARD_ACTION_TRIGGER)
|
||||
async handleCardActionTrigger(event: CardActionTriggerCallback) {
|
||||
const event_hook = event.header.event_type ?? event.event.action.tag;
|
||||
|
||||
// console.log(event_hook);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将卡片内容转换为字符串
|
||||
*/
|
||||
protected serializeCard(card: CardContent): string {
|
||||
return typeof card === "string" ? card : JSON.stringify(card);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换 API 响应为统一格式
|
||||
*/
|
||||
protected transformMessageResponse(data: any): SendMessageResponse {
|
||||
return {
|
||||
messageId: data.message_id,
|
||||
rootId: data.root_id,
|
||||
parentId: data.parent_id,
|
||||
msgType: data.msg_type,
|
||||
createTime: data.create_time,
|
||||
updateTime: data.update_time,
|
||||
deleted: data.deleted,
|
||||
updated: data.updated,
|
||||
chatId: data.chat_id,
|
||||
sender: {
|
||||
id: data.sender?.id,
|
||||
idType: data.sender?.id_type,
|
||||
senderType: data.sender?.sender_type,
|
||||
tenantKey: data.sender?.tenant_key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送卡片消息
|
||||
* @param params 发送卡片消息的参数
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async sendCard(params: SendCardParams): Promise<SendMessageResponse> {
|
||||
const { receiveId, receiveIdType, card, uuid } = params;
|
||||
const client = this.feishuSdkService.getClient();
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: {
|
||||
receive_id_type: receiveIdType,
|
||||
},
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: "interactive",
|
||||
content: this.serializeCard(card),
|
||||
uuid,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`飞书发送卡片失败: ${response.code} - ${response.msg}`);
|
||||
}
|
||||
|
||||
return this.transformMessageResponse(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复卡片消息
|
||||
* @param params 回复卡片消息的参数
|
||||
* @returns 回复结果
|
||||
*/
|
||||
async replyCard(params: ReplyCardParams): Promise<SendMessageResponse> {
|
||||
const { messageId, card, uuid } = params;
|
||||
const client = this.feishuSdkService.getClient();
|
||||
|
||||
const response = await client.im.message.reply({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
},
|
||||
data: {
|
||||
msg_type: "interactive",
|
||||
content: this.serializeCard(card),
|
||||
uuid,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`飞书回复卡片失败: ${response.code} - ${response.msg}`);
|
||||
}
|
||||
|
||||
return this.transformMessageResponse(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新卡片消息
|
||||
* @param params 更新卡片消息的参数
|
||||
* @returns 更新是否成功
|
||||
*/
|
||||
async updateCard(params: UpdateCardParams): Promise<void> {
|
||||
const { messageId, card } = params;
|
||||
const client = this.feishuSdkService.getClient();
|
||||
|
||||
const response = await client.im.message.patch({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
},
|
||||
data: {
|
||||
content: this.serializeCard(card),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`飞书更新卡片失败: ${response.code} - ${response.msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
core/src/shared/feishu-sdk/index.ts
Normal file
4
core/src/shared/feishu-sdk/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./feishu-sdk.module";
|
||||
export * from "./feishu-sdk.service";
|
||||
export * from "./fieshu-card.service";
|
||||
export * from "./types/index";
|
||||
132
core/src/shared/feishu-sdk/types/card-action.ts
Normal file
132
core/src/shared/feishu-sdk/types/card-action.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 飞书 SDK 卡片交互回调相关类型
|
||||
*/
|
||||
|
||||
import type { I18nLocale } from "./common";
|
||||
import type { CardData } from "./card";
|
||||
|
||||
/** 卡片交互事件名称常量 */
|
||||
export const FEISHU_CARD_ACTION_TRIGGER = "card.action.trigger" as const;
|
||||
|
||||
/** 回调基本信息 (header) */
|
||||
export interface CardActionHeader {
|
||||
/** 回调的唯一标识 */
|
||||
event_id: string;
|
||||
/** 应用的 Verification Token */
|
||||
token: string;
|
||||
/** 回调发送的时间,微秒级时间戳 */
|
||||
create_time: string;
|
||||
/** 回调类型,固定为 "card.action.trigger" */
|
||||
event_type: typeof FEISHU_CARD_ACTION_TRIGGER;
|
||||
/** 应用归属的 tenant key */
|
||||
tenant_key: string;
|
||||
/** 应用的 App ID */
|
||||
app_id: string;
|
||||
}
|
||||
|
||||
/** 操作者信息 */
|
||||
export interface CardActionOperator {
|
||||
/** 回调触发者的 tenant key */
|
||||
tenant_key: string;
|
||||
/** 回调触发者的 user_id (需开启权限) */
|
||||
user_id?: string;
|
||||
/** 回调触发者的 open_id */
|
||||
open_id: string;
|
||||
/** 回调触发者的 union_id */
|
||||
union_id?: string;
|
||||
}
|
||||
|
||||
/** 交互信息 */
|
||||
export interface CardActionInfo {
|
||||
/** 交互组件绑定的开发者自定义回传数据 */
|
||||
value?: Record<string, unknown> | string;
|
||||
/** 交互组件的标签 */
|
||||
tag: string;
|
||||
/** 用户当前所在地区的时区 */
|
||||
timezone?: string;
|
||||
/** 组件的自定义唯一标识 */
|
||||
name?: string;
|
||||
/** 表单容器内用户提交的数据 */
|
||||
form_value?: Record<string, unknown>;
|
||||
/** 输入框组件提交的数据 (未内嵌在表单中时) */
|
||||
input_value?: string;
|
||||
/** 单选组件的选项回调值 */
|
||||
option?: string;
|
||||
/** 多选组件的选项回调值 */
|
||||
options?: string[];
|
||||
/** 勾选器组件的回调数据 */
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
/** 展示场景上下文 */
|
||||
export interface CardActionContext {
|
||||
/** 链接地址 (适用于链接预览场景) */
|
||||
url?: string;
|
||||
/** 链接预览的 token */
|
||||
preview_token?: string;
|
||||
/** 消息 ID */
|
||||
open_message_id: string;
|
||||
/** 会话 ID */
|
||||
open_chat_id: string;
|
||||
}
|
||||
|
||||
/** 回调详细信息 (event) */
|
||||
export interface CardActionEventData {
|
||||
/** 回调触发者信息 */
|
||||
operator: CardActionOperator;
|
||||
/** 更新卡片用的凭证,有效期 30 分钟,最多可更新 2 次 */
|
||||
token: string;
|
||||
/** 交互信息 */
|
||||
action: CardActionInfo;
|
||||
/** 卡片展示场景 */
|
||||
host?: string;
|
||||
/** 卡片分发类型,链接预览卡片时为 url_preview */
|
||||
delivery_type?: "url_preview";
|
||||
/** 展示场景上下文 */
|
||||
context: CardActionContext;
|
||||
}
|
||||
|
||||
/** 回调结构体 (schema 2.0) */
|
||||
export interface CardActionTriggerCallback {
|
||||
/** 回调版本,固定为 "2.0" */
|
||||
schema: "2.0";
|
||||
/** 回调基本信息 */
|
||||
header: CardActionHeader;
|
||||
/** 回调详细信息 */
|
||||
event: CardActionEventData;
|
||||
}
|
||||
|
||||
/** Toast 提示配置 */
|
||||
export interface CardActionToast {
|
||||
/** 弹窗提示的类型 */
|
||||
type?: "info" | "success" | "error" | "warning";
|
||||
/** 单语言提示文案 */
|
||||
content?: string;
|
||||
/** 多语言提示文案 */
|
||||
i18n?: Partial<Record<I18nLocale, string>>;
|
||||
}
|
||||
|
||||
/** 响应回调的结构体 */
|
||||
export interface CardActionTriggerResponse {
|
||||
/** Toast 弹窗提示 */
|
||||
toast?: CardActionToast;
|
||||
/** 卡片数据 (用于更新卡片) */
|
||||
card?: CardData;
|
||||
}
|
||||
|
||||
/** 交互事件回调接口 */
|
||||
export interface CardActionTriggerEventCallback {
|
||||
/** 完成回调,返回响应结果 */
|
||||
done: (result: CardActionTriggerResponse) => void;
|
||||
}
|
||||
|
||||
/** 交互事件 (用于事件监听器),包含回调数据和 done 方法 */
|
||||
export interface CardActionTriggerEvent
|
||||
extends CardActionTriggerEventCallback, CardActionTriggerCallback {}
|
||||
|
||||
/** 事件注册类型 */
|
||||
export interface CardEvents {
|
||||
[FEISHU_CARD_ACTION_TRIGGER]: (
|
||||
data: CardActionTriggerCallback,
|
||||
) => Promise<CardActionTriggerResponse>;
|
||||
}
|
||||
64
core/src/shared/feishu-sdk/types/card.ts
Normal file
64
core/src/shared/feishu-sdk/types/card.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 飞书 SDK 卡片消息相关类型
|
||||
*/
|
||||
|
||||
import type { ReceiveIdType } from "./message";
|
||||
|
||||
/** 卡片消息内容,可以是 JSON 对象或 JSON 字符串 */
|
||||
export type CardContent = Record<string, unknown> | string;
|
||||
|
||||
/** 发送卡片消息参数 */
|
||||
export interface SendCardParams {
|
||||
/** 接收者 ID */
|
||||
receiveId: string;
|
||||
/** 接收者 ID 类型 */
|
||||
receiveIdType: ReceiveIdType;
|
||||
/** 卡片内容 (JSON 对象或字符串) */
|
||||
card: CardContent;
|
||||
/** 可选的 UUID,用于幂等性控制 */
|
||||
uuid?: string;
|
||||
}
|
||||
|
||||
/** 回复卡片消息参数 */
|
||||
export interface ReplyCardParams {
|
||||
/** 要回复的消息 ID */
|
||||
messageId: string;
|
||||
/** 卡片内容 (JSON 对象或字符串) */
|
||||
card: CardContent;
|
||||
/** 可选的 UUID,用于幂等性控制 */
|
||||
uuid?: string;
|
||||
}
|
||||
|
||||
/** 更新卡片消息参数 */
|
||||
export interface UpdateCardParams {
|
||||
/** 要更新的消息 ID */
|
||||
messageId: string;
|
||||
/** 新的卡片内容 (JSON 对象或字符串) */
|
||||
card: CardContent;
|
||||
}
|
||||
|
||||
/** 卡片数据 - 使用 JSON 代码 */
|
||||
export interface CardDataRaw {
|
||||
/** 卡片类型: raw 表示 JSON 构建的卡片 */
|
||||
type: "raw";
|
||||
/** 卡片的 JSON 数据 */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 卡片数据 - 使用卡片模板 */
|
||||
export interface CardDataTemplate {
|
||||
/** 卡片类型: template 表示卡片模板 */
|
||||
type: "template";
|
||||
/** 卡片模板数据 */
|
||||
data: {
|
||||
/** 卡片模板 ID */
|
||||
template_id: string;
|
||||
/** 卡片模板版本号 */
|
||||
template_version_name?: string;
|
||||
/** 卡片模板变量 */
|
||||
template_variable?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 卡片数据类型 */
|
||||
export type CardData = CardDataRaw | CardDataTemplate;
|
||||
22
core/src/shared/feishu-sdk/types/common.ts
Normal file
22
core/src/shared/feishu-sdk/types/common.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 飞书 SDK 通用类型
|
||||
*/
|
||||
|
||||
/** 支持的语言类型 */
|
||||
export type I18nLocale =
|
||||
| "zh_cn"
|
||||
| "en_us"
|
||||
| "zh_hk"
|
||||
| "zh_tw"
|
||||
| "ja_jp"
|
||||
| "id_id"
|
||||
| "vi_vn"
|
||||
| "th_th"
|
||||
| "pt_br"
|
||||
| "es_es"
|
||||
| "ko_kr"
|
||||
| "de_de"
|
||||
| "fr_fr"
|
||||
| "it_it"
|
||||
| "ru_ru"
|
||||
| "ms_my";
|
||||
46
core/src/shared/feishu-sdk/types/index.ts
Normal file
46
core/src/shared/feishu-sdk/types/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 飞书 SDK 类型定义
|
||||
*/
|
||||
|
||||
// Module - 模块配置
|
||||
export {
|
||||
FEISHU_MODULE_OPTIONS,
|
||||
type FeishuModuleOptions,
|
||||
type FeishuModuleAsyncOptions,
|
||||
} from "./module";
|
||||
|
||||
// Common - 通用类型
|
||||
export { type I18nLocale } from "./common";
|
||||
|
||||
// User - 用户相关
|
||||
export { type UserIdType, type FeishuUser, type GetUserParams } from "./user";
|
||||
|
||||
// Message - 消息相关
|
||||
export { type ReceiveIdType, type SendMessageResponse } from "./message";
|
||||
|
||||
// Card - 卡片消息
|
||||
export {
|
||||
type CardContent,
|
||||
type SendCardParams,
|
||||
type ReplyCardParams,
|
||||
type UpdateCardParams,
|
||||
type CardDataRaw,
|
||||
type CardDataTemplate,
|
||||
type CardData,
|
||||
} from "./card";
|
||||
|
||||
// CardAction - 卡片交互回调
|
||||
export {
|
||||
FEISHU_CARD_ACTION_TRIGGER,
|
||||
type CardActionHeader,
|
||||
type CardActionOperator,
|
||||
type CardActionInfo,
|
||||
type CardActionContext,
|
||||
type CardActionEventData,
|
||||
type CardActionTriggerCallback,
|
||||
type CardActionToast,
|
||||
type CardActionTriggerResponse,
|
||||
type CardActionTriggerEventCallback,
|
||||
type CardActionTriggerEvent,
|
||||
type CardEvents,
|
||||
} from "./card-action";
|
||||
35
core/src/shared/feishu-sdk/types/message.ts
Normal file
35
core/src/shared/feishu-sdk/types/message.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 飞书 SDK 消息相关类型
|
||||
*/
|
||||
|
||||
/** 消息接收者类型 */
|
||||
export type ReceiveIdType = "open_id" | "user_id" | "union_id" | "email" | "chat_id";
|
||||
|
||||
/** 发送/回复消息的响应 */
|
||||
export interface SendMessageResponse {
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 根消息 ID (用于消息链) */
|
||||
rootId?: string;
|
||||
/** 父消息 ID */
|
||||
parentId?: string;
|
||||
/** 消息类型 */
|
||||
msgType: string;
|
||||
/** 创建时间 (毫秒时间戳) */
|
||||
createTime: string;
|
||||
/** 更新时间 (毫秒时间戳) */
|
||||
updateTime: string;
|
||||
/** 是否被撤回 */
|
||||
deleted: boolean;
|
||||
/** 是否被更新 */
|
||||
updated: boolean;
|
||||
/** 会话 ID */
|
||||
chatId: string;
|
||||
/** 发送者信息 */
|
||||
sender: {
|
||||
id: string;
|
||||
idType: string;
|
||||
senderType: string;
|
||||
tenantKey?: string;
|
||||
};
|
||||
}
|
||||
21
core/src/shared/feishu-sdk/types/module.ts
Normal file
21
core/src/shared/feishu-sdk/types/module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 飞书 SDK 模块配置类型
|
||||
*/
|
||||
|
||||
export const FEISHU_MODULE_OPTIONS = "FEISHU_MODULE_OPTIONS";
|
||||
|
||||
export interface FeishuModuleOptions {
|
||||
/** 应用 ID */
|
||||
appId: string;
|
||||
/** 应用密钥 */
|
||||
appSecret: string;
|
||||
/** 应用类型:自建应用或商店应用 */
|
||||
appType?: "self_build" | "store";
|
||||
/** 域名:飞书或 Lark */
|
||||
domain?: "feishu" | "lark";
|
||||
}
|
||||
|
||||
export interface FeishuModuleAsyncOptions {
|
||||
useFactory: (...args: any[]) => Promise<FeishuModuleOptions> | FeishuModuleOptions;
|
||||
inject?: any[];
|
||||
}
|
||||
77
core/src/shared/feishu-sdk/types/user.ts
Normal file
77
core/src/shared/feishu-sdk/types/user.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 飞书 SDK 用户相关类型
|
||||
*/
|
||||
|
||||
/** 用户 ID 类型 */
|
||||
export type UserIdType = "open_id" | "union_id" | "user_id";
|
||||
|
||||
/** 用户信息 */
|
||||
export interface FeishuUser {
|
||||
/** 用户的 union_id */
|
||||
union_id?: string;
|
||||
/** 用户的 user_id */
|
||||
user_id?: string;
|
||||
/** 用户的 open_id */
|
||||
open_id?: string;
|
||||
/** 用户名 */
|
||||
name?: string;
|
||||
/** 英文名 */
|
||||
en_name?: string;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 邮箱 */
|
||||
email?: string;
|
||||
/** 手机号 */
|
||||
mobile?: string;
|
||||
/** 手机号码可见性 */
|
||||
mobile_visible?: boolean;
|
||||
/** 性别 */
|
||||
gender?: number;
|
||||
/** 头像 */
|
||||
avatar?: {
|
||||
avatar_72?: string;
|
||||
avatar_240?: string;
|
||||
avatar_640?: string;
|
||||
avatar_origin?: string;
|
||||
};
|
||||
/** 用户状态 */
|
||||
status?: {
|
||||
is_frozen?: boolean;
|
||||
is_resigned?: boolean;
|
||||
is_activated?: boolean;
|
||||
is_exited?: boolean;
|
||||
is_unjoin?: boolean;
|
||||
};
|
||||
/** 所属部门 ID 列表 */
|
||||
department_ids?: string[];
|
||||
/** 直属主管的用户 ID */
|
||||
leader_user_id?: string;
|
||||
/** 城市 */
|
||||
city?: string;
|
||||
/** 国家或地区 */
|
||||
country?: string;
|
||||
/** 工位 */
|
||||
work_station?: string;
|
||||
/** 入职时间 */
|
||||
join_time?: number;
|
||||
/** 是否是租户管理员 */
|
||||
is_tenant_manager?: boolean;
|
||||
/** 工号 */
|
||||
employee_no?: string;
|
||||
/** 员工类型 */
|
||||
employee_type?: number;
|
||||
/** 企业邮箱 */
|
||||
enterprise_email?: string;
|
||||
/** 职务 */
|
||||
job_title?: string;
|
||||
}
|
||||
|
||||
/** 获取用户信息的参数 */
|
||||
export interface GetUserParams {
|
||||
/** 用户 ID */
|
||||
userId: string;
|
||||
/** 用户 ID 类型 */
|
||||
userIdType?: UserIdType;
|
||||
/** 部门 ID 类型 */
|
||||
departmentIdType?: "department_id" | "open_department_id";
|
||||
}
|
||||
473
core/src/shared/git-provider/adapters/gitea.adapter.spec.ts
Normal file
473
core/src/shared/git-provider/adapters/gitea.adapter.spec.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { vi, type MockInstance } from "vitest";
|
||||
import { GiteaAdapter } from "./gitea.adapter";
|
||||
import type { GitProviderModuleOptions } from "../types";
|
||||
|
||||
const mockOptions: GitProviderModuleOptions = {
|
||||
provider: "gitea",
|
||||
baseUrl: "https://gitea.example.com",
|
||||
token: "test-token",
|
||||
};
|
||||
|
||||
/** 构造 mock Response */
|
||||
function mockResponse(body: unknown, status = 200): Response {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
text: vi.fn().mockResolvedValue(text),
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("GiteaAdapter", () => {
|
||||
let adapter: GiteaAdapter;
|
||||
let fetchSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new GiteaAdapter(mockOptions);
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse({}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ============ 配置验证 ============
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("配置完整时不应抛出异常", () => {
|
||||
expect(() => adapter.validateConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it("缺少 baseUrl 时应抛出异常", () => {
|
||||
const a = new GiteaAdapter({ ...mockOptions, baseUrl: "" });
|
||||
expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.baseUrl");
|
||||
});
|
||||
|
||||
it("缺少 token 时应抛出异常", () => {
|
||||
const a = new GiteaAdapter({ ...mockOptions, token: "" });
|
||||
expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.token");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ request 基础方法 ============
|
||||
|
||||
describe("request", () => {
|
||||
it("GET 请求应拼接正确的 URL 和 headers", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
|
||||
await adapter.getRepository("owner", "repo");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "token test-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST 请求应正确序列化 body", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
|
||||
await adapter.createIssue("owner", "repo", { title: "test" });
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/issues",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "test" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("API 返回错误时应抛出异常", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
|
||||
await expect(adapter.getRepository("owner", "repo")).rejects.toThrow("Gitea API error: 404");
|
||||
});
|
||||
|
||||
it("204 响应应返回空对象", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("", 204));
|
||||
const result = await adapter.deleteBranchProtection("owner", "repo", "main");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
describe("listBranchProtections", () => {
|
||||
it("应返回分支保护规则列表", async () => {
|
||||
const protections = [{ rule_name: "main", enable_push: false }];
|
||||
fetchSpy.mockResolvedValue(mockResponse(protections));
|
||||
const result = await adapter.listBranchProtections("owner", "repo");
|
||||
expect(result).toEqual(protections);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lockBranch", () => {
|
||||
it("无现有规则时应创建新规则(无白名单)", async () => {
|
||||
// listBranchProtections 返回空
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse([]));
|
||||
// createBranchProtection 返回新规则
|
||||
const newProtection = { rule_name: "main", enable_push: false };
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(newProtection));
|
||||
const result = await adapter.lockBranch("owner", "repo", "main");
|
||||
expect(result).toEqual(newProtection);
|
||||
// 第二次调用应该是 POST 创建
|
||||
const createCall = fetchSpy.mock.calls[1];
|
||||
expect(createCall[0]).toContain("/branch_protections");
|
||||
expect(createCall[1].method).toBe("POST");
|
||||
const body = JSON.parse(createCall[1].body);
|
||||
expect(body.enable_push).toBe(false);
|
||||
});
|
||||
|
||||
it("有现有规则时应更新规则", async () => {
|
||||
const existing = [{ rule_name: "main", enable_push: true }];
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(existing));
|
||||
const updated = { rule_name: "main", enable_push: false };
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(updated));
|
||||
const result = await adapter.lockBranch("owner", "repo", "main");
|
||||
expect(result).toEqual(updated);
|
||||
// 第二次调用应该是 PATCH 更新
|
||||
const editCall = fetchSpy.mock.calls[1];
|
||||
expect(editCall[1].method).toBe("PATCH");
|
||||
});
|
||||
|
||||
it("有白名单时应启用推送白名单", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse([]));
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ rule_name: "main" }));
|
||||
await adapter.lockBranch("owner", "repo", "main", {
|
||||
pushWhitelistUsernames: ["bot-user"],
|
||||
});
|
||||
const body = JSON.parse(fetchSpy.mock.calls[1][1].body);
|
||||
expect(body.enable_push).toBe(true);
|
||||
expect(body.enable_push_whitelist).toBe(true);
|
||||
expect(body.push_whitelist_usernames).toEqual(["bot-user"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockBranch", () => {
|
||||
it("有保护规则时应删除并返回", async () => {
|
||||
const existing = [{ rule_name: "main", enable_push: false }];
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(existing));
|
||||
// deleteBranchProtection 返回 204
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse("", 204));
|
||||
const result = await adapter.unlockBranch("owner", "repo", "main");
|
||||
expect(result).toEqual(existing[0]);
|
||||
expect(fetchSpy.mock.calls[1][1].method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("无保护规则时应返回 null", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse([]));
|
||||
const result = await adapter.unlockBranch("owner", "repo", "main");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Pull Request ============
|
||||
|
||||
describe("getPullRequest", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
const pr = { id: 1, number: 42, title: "Test PR" };
|
||||
fetchSpy.mockResolvedValue(mockResponse(pr));
|
||||
const result = await adapter.getPullRequest("owner", "repo", 42);
|
||||
expect(result).toEqual(pr);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAllPullRequests", () => {
|
||||
it("应支持分页获取全部", async () => {
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ id: i }));
|
||||
const page2 = [{ id: 50 }, { id: 51 }];
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockResponse(page1))
|
||||
.mockResolvedValueOnce(mockResponse(page2));
|
||||
const result = await adapter.listAllPullRequests("owner", "repo", { state: "closed" });
|
||||
expect(result).toHaveLength(52);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("单页不满时应停止分页", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse([{ id: 1 }]));
|
||||
const result = await adapter.listAllPullRequests("owner", "repo");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPullRequestCommits", () => {
|
||||
it("应支持分页获取全部 commits", async () => {
|
||||
const page1 = Array.from({ length: 50 }, (_, i) => ({ sha: `sha${i}` }));
|
||||
const page2 = [{ sha: "sha50" }];
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockResponse(page1))
|
||||
.mockResolvedValueOnce(mockResponse(page2));
|
||||
const result = await adapter.getPullRequestCommits("owner", "repo", 1);
|
||||
expect(result).toHaveLength(51);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPullRequestFiles", () => {
|
||||
it("文件有 patch 时应直接返回", async () => {
|
||||
const files = [{ filename: "a.ts", patch: "@@ -1 +1 @@", status: "modified" }];
|
||||
fetchSpy.mockResolvedValue(mockResponse(files));
|
||||
const result = await adapter.getPullRequestFiles("owner", "repo", 1);
|
||||
expect(result).toEqual(files);
|
||||
// 只调用一次(不需要获取 diff)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("文件缺少 patch 时应从 diff 回填", async () => {
|
||||
const files = [{ filename: "a.ts", status: "modified" }];
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(files));
|
||||
// getPullRequestDiff 返回 diff 文本
|
||||
const diffText = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new";
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse(diffText));
|
||||
const result = await adapter.getPullRequestFiles("owner", "repo", 1);
|
||||
expect(result[0].filename).toBe("a.ts");
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deleted 文件缺少 patch 不应触发 diff 回填", async () => {
|
||||
const files = [{ filename: "a.ts", status: "deleted" }];
|
||||
fetchSpy.mockResolvedValue(mockResponse(files));
|
||||
await adapter.getPullRequestFiles("owner", "repo", 1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPullRequestDiff", () => {
|
||||
it("应请求 .diff 后缀 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("diff text"));
|
||||
await adapter.getPullRequestDiff("owner", "repo", 1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/1.diff",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Commit ============
|
||||
|
||||
describe("getCommit", () => {
|
||||
it("应请求 /git/commits/ 路径", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ sha: "abc123" }));
|
||||
await adapter.getCommit("owner", "repo", "abc123");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/git/commits/abc123",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCompareDiff", () => {
|
||||
it("应请求 compare URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("diff"));
|
||||
await adapter.getCompareDiff("owner", "repo", "base", "head");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/compare/base...head.diff",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommitDiff", () => {
|
||||
it("应请求 /git/commits/sha.diff", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("diff"));
|
||||
await adapter.getCommitDiff("owner", "repo", "abc");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/git/commits/abc.diff",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
describe("getFileContent", () => {
|
||||
it("应请求 /raw/ 路径", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("file content"));
|
||||
const result = await adapter.getFileContent("owner", "repo", "src/index.ts");
|
||||
expect(result).toBe("file content");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/raw/src%2Findex.ts"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("指定 ref 时应添加 query 参数", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("content"));
|
||||
await adapter.getFileContent("owner", "repo", "a.ts", "main");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("?ref=main"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("404 时应返回空字符串", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
|
||||
const result = await adapter.getFileContent("owner", "repo", "missing.ts");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("应 POST 创建 issue", async () => {
|
||||
const issue = { id: 1, title: "Bug" };
|
||||
fetchSpy.mockResolvedValue(mockResponse(issue));
|
||||
const result = await adapter.createIssue("owner", "repo", { title: "Bug" });
|
||||
expect(result).toEqual(issue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listIssueComments", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([]));
|
||||
await adapter.listIssueComments("owner", "repo", 5);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/issues/5/comments",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIssueComment", () => {
|
||||
it("应 POST 创建评论", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1, body: "hello" }));
|
||||
await adapter.createIssueComment("owner", "repo", 5, { body: "hello" });
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body).toEqual({ body: "hello" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateIssueComment", () => {
|
||||
it("应 PATCH 更新评论", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1, body: "updated" }));
|
||||
await adapter.updateIssueComment("owner", "repo", 10, "updated");
|
||||
expect(fetchSpy.mock.calls[0][1].method).toBe("PATCH");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/issues/comments/10",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIssueComment", () => {
|
||||
it("应 DELETE 删除评论", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("", 204));
|
||||
await adapter.deleteIssueComment("owner", "repo", 10);
|
||||
expect(fetchSpy.mock.calls[0][1].method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ PR Review ============
|
||||
|
||||
describe("createPullReview", () => {
|
||||
it("应 POST 创建 review", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
|
||||
await adapter.createPullReview("owner", "repo", 1, {
|
||||
event: "COMMENT",
|
||||
body: "LGTM",
|
||||
comments: [{ path: "a.ts", body: "fix this", new_position: 10 }],
|
||||
});
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.event).toBe("COMMENT");
|
||||
expect(body.comments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPullReviews", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([]));
|
||||
await adapter.listPullReviews("owner", "repo", 42);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deletePullReview", () => {
|
||||
it("应 DELETE 删除 review", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("", 204));
|
||||
await adapter.deletePullReview("owner", "repo", 42, 100);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews/100",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPullReviewComments", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([]));
|
||||
await adapter.listPullReviewComments("owner", "repo", 42, 100);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/pulls/42/reviews/100/comments",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Reaction ============
|
||||
|
||||
describe("getIssueCommentReactions", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([]));
|
||||
await adapter.getIssueCommentReactions("owner", "repo", 10);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/issues/comments/10/reactions",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIssueReactions", () => {
|
||||
it("应请求正确的 URL", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([]));
|
||||
await adapter.getIssueReactions("owner", "repo", 5);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/repos/owner/repo/issues/5/reactions",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
describe("searchUsers", () => {
|
||||
it("应请求 /users/search 并解构 data 字段", async () => {
|
||||
const users = [{ id: 1, login: "test" }];
|
||||
fetchSpy.mockResolvedValue(mockResponse({ data: users }));
|
||||
const result = await adapter.searchUsers("test", 5);
|
||||
expect(result).toEqual(users);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/users/search?q=test&limit=5"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("data 为空时应返回空数组", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ data: null }));
|
||||
const result = await adapter.searchUsers("nobody");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeamMembers", () => {
|
||||
it("应请求 /teams/{id}/members", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse([{ id: 1 }]));
|
||||
await adapter.getTeamMembers(99);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://gitea.example.com/api/v1/teams/99/members",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
499
core/src/shared/git-provider/adapters/gitea.adapter.ts
Normal file
499
core/src/shared/git-provider/adapters/gitea.adapter.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
import { execSync } from "child_process";
|
||||
import { parseDiffText } from "../../git-sdk/git-sdk-diff.utils";
|
||||
import type {
|
||||
GitProvider,
|
||||
LockBranchOptions,
|
||||
ListPullRequestsOptions,
|
||||
} from "../git-provider.interface";
|
||||
import type {
|
||||
GitProviderModuleOptions,
|
||||
BranchProtection,
|
||||
CreateBranchProtectionOption,
|
||||
EditBranchProtectionOption,
|
||||
Branch,
|
||||
Repository,
|
||||
PullRequest,
|
||||
PullRequestCommit,
|
||||
ChangedFile,
|
||||
CommitInfo,
|
||||
IssueComment,
|
||||
CreateIssueCommentOption,
|
||||
CreateIssueOption,
|
||||
Issue,
|
||||
CreatePullReviewOption,
|
||||
PullReview,
|
||||
PullReviewComment,
|
||||
Reaction,
|
||||
EditPullRequestOption,
|
||||
User,
|
||||
RepositoryContent,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Gitea 平台适配器
|
||||
*/
|
||||
export class GiteaAdapter implements GitProvider {
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly token: string;
|
||||
|
||||
constructor(protected readonly options: GitProviderModuleOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
validateConfig(): void {
|
||||
if (!this.options?.baseUrl) {
|
||||
throw new Error("缺少配置 gitProvider.baseUrl (环境变量 GIT_PROVIDER_URL)");
|
||||
}
|
||||
if (!this.options?.token) {
|
||||
throw new Error("缺少配置 gitProvider.token (环境变量 GIT_PROVIDER_TOKEN)");
|
||||
}
|
||||
}
|
||||
|
||||
protected async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}/api/v1${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `token ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
protected async fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `token ${this.token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
|
||||
async getRepository(owner: string, repo: string): Promise<Repository> {
|
||||
return this.request<Repository>("GET", `/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
// ============ 分支操作 ============
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string): Promise<Branch> {
|
||||
return this.request<Branch>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
async listBranchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return this.request<BranchProtection[]>("GET", `/repos/${owner}/${repo}/branch_protections`);
|
||||
}
|
||||
|
||||
async getBranchProtection(owner: string, repo: string, name: string): Promise<BranchProtection> {
|
||||
return this.request<BranchProtection>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: CreateBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
return this.request<BranchProtection>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/branch_protections`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async editBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
options: EditBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
return this.request<BranchProtection>(
|
||||
"PATCH",
|
||||
`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(name)}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBranchProtection(owner: string, repo: string, name: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async lockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
options?: LockBranchOptions,
|
||||
): Promise<BranchProtection> {
|
||||
const protections = await this.listBranchProtections(owner, repo);
|
||||
const existing = protections.find((p) => p.rule_name === branch || p.branch_name === branch);
|
||||
const pushWhitelistUsernames = options?.pushWhitelistUsernames;
|
||||
const hasWhitelist = pushWhitelistUsernames && pushWhitelistUsernames.length > 0;
|
||||
const protectionOptions = hasWhitelist
|
||||
? {
|
||||
enable_push: true,
|
||||
enable_push_whitelist: true,
|
||||
push_whitelist_usernames: pushWhitelistUsernames,
|
||||
}
|
||||
: { enable_push: false };
|
||||
if (existing) {
|
||||
return this.editBranchProtection(
|
||||
owner,
|
||||
repo,
|
||||
existing.rule_name || branch,
|
||||
protectionOptions,
|
||||
);
|
||||
} else {
|
||||
return this.createBranchProtection(owner, repo, {
|
||||
rule_name: branch,
|
||||
branch_name: branch,
|
||||
...protectionOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unlockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<BranchProtection | null> {
|
||||
const protections = await this.listBranchProtections(owner, repo);
|
||||
const existing = protections.find((p) => p.rule_name === branch || p.branch_name === branch);
|
||||
if (existing) {
|
||||
await this.deleteBranchProtection(owner, repo, existing.rule_name || branch);
|
||||
return existing;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
unlockBranchSync(owner: string, repo: string, branch: string): void {
|
||||
try {
|
||||
const listResult = execSync(
|
||||
`curl -s -X GET "${this.baseUrl}/api/v1/repos/${owner}/${repo}/branch_protections" -H "Authorization: token ${this.token}"`,
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
const protections = JSON.parse(listResult) as Array<{
|
||||
rule_name?: string;
|
||||
branch_name?: string;
|
||||
}>;
|
||||
const existing = protections.find((p) => p.rule_name === branch || p.branch_name === branch);
|
||||
if (existing) {
|
||||
const ruleName = existing.rule_name || branch;
|
||||
execSync(
|
||||
`curl -s -X DELETE "${this.baseUrl}/api/v1/repos/${owner}/${repo}/branch_protections/${ruleName}" -H "Authorization: token ${this.token}"`,
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
console.log(`✅ 分支已解锁(同步): ${ruleName}`);
|
||||
} else {
|
||||
console.log(`✅ 分支本身没有保护规则,无需解锁`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("⚠️ 同步解锁分支失败:", error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Pull Request 操作 ============
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number): Promise<PullRequest> {
|
||||
return this.request<PullRequest>("GET", `/repos/${owner}/${repo}/pulls/${index}`);
|
||||
}
|
||||
|
||||
async editPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: EditPullRequestOption,
|
||||
): Promise<PullRequest> {
|
||||
return this.request<PullRequest>("PATCH", `/repos/${owner}/${repo}/pulls/${index}`, options);
|
||||
}
|
||||
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state?: "open" | "closed" | "all",
|
||||
): Promise<PullRequest[]> {
|
||||
const query = state ? `?state=${state}` : "";
|
||||
return this.request<PullRequest[]>("GET", `/repos/${owner}/${repo}/pulls${query}`);
|
||||
}
|
||||
|
||||
async listAllPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: ListPullRequestsOptions,
|
||||
): Promise<PullRequest[]> {
|
||||
const allPRs: PullRequest[] = [];
|
||||
let page = 1;
|
||||
const limit = 50;
|
||||
while (true) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("limit", String(limit));
|
||||
if (options?.state) params.set("state", options.state);
|
||||
if (options?.sort) params.set("sort", options.sort);
|
||||
if (options?.milestone) params.set("milestone", String(options.milestone));
|
||||
if (options?.labels?.length) params.set("labels", options.labels.join(","));
|
||||
const prs = await this.request<PullRequest[]>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls?${params.toString()}`,
|
||||
);
|
||||
if (!prs || prs.length === 0) break;
|
||||
allPRs.push(...prs);
|
||||
if (prs.length < limit) break;
|
||||
page++;
|
||||
}
|
||||
return allPRs;
|
||||
}
|
||||
|
||||
async getPullRequestCommits(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
): Promise<PullRequestCommit[]> {
|
||||
const allCommits: PullRequestCommit[] = [];
|
||||
let page = 1;
|
||||
const limit = 50;
|
||||
while (true) {
|
||||
const commits = await this.request<PullRequestCommit[]>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/commits?page=${page}&limit=${limit}`,
|
||||
);
|
||||
if (!commits || commits.length === 0) break;
|
||||
allCommits.push(...commits);
|
||||
if (commits.length < limit) break;
|
||||
page++;
|
||||
}
|
||||
return allCommits;
|
||||
}
|
||||
|
||||
async getPullRequestFiles(owner: string, repo: string, index: number): Promise<ChangedFile[]> {
|
||||
const files = await this.request<ChangedFile[]>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/files`,
|
||||
);
|
||||
const needsPatch = files.some((f) => !f.patch && f.status !== "deleted");
|
||||
if (!needsPatch) {
|
||||
return files;
|
||||
}
|
||||
try {
|
||||
const diffText = await this.getPullRequestDiff(owner, repo, index);
|
||||
const diffFiles = parseDiffText(diffText);
|
||||
const patchMap = new Map(diffFiles.map((f) => [f.filename, f.patch]));
|
||||
for (const file of files) {
|
||||
if (!file.patch && file.filename) {
|
||||
file.patch = patchMap.get(file.filename);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`警告: 无法获取 PR diff 来填充 patch 字段:`, error);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async getPullRequestDiff(owner: string, repo: string, index: number): Promise<string> {
|
||||
return this.fetchText(`${this.baseUrl}/api/v1/repos/${owner}/${repo}/pulls/${index}.diff`);
|
||||
}
|
||||
|
||||
// ============ Commit 操作 ============
|
||||
|
||||
async getCommit(owner: string, repo: string, sha: string): Promise<CommitInfo> {
|
||||
return this.request<CommitInfo>("GET", `/repos/${owner}/${repo}/git/commits/${sha}`);
|
||||
}
|
||||
|
||||
async getCompareDiff(
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
): Promise<string> {
|
||||
return this.fetchText(
|
||||
`${this.baseUrl}/api/v1/repos/${owner}/${repo}/compare/${baseSha}...${headSha}.diff`,
|
||||
);
|
||||
}
|
||||
|
||||
async getCommitDiff(owner: string, repo: string, sha: string): Promise<string> {
|
||||
return this.fetchText(`${this.baseUrl}/api/v1/repos/${owner}/${repo}/git/commits/${sha}.diff`);
|
||||
}
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
async getFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filepath: string,
|
||||
ref?: string,
|
||||
): Promise<string> {
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const url = `${this.baseUrl}/api/v1/repos/${owner}/${repo}/raw/${encodeURIComponent(filepath)}${query}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `token ${this.token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return "";
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async listRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path = "",
|
||||
ref?: string,
|
||||
): Promise<RepositoryContent[]> {
|
||||
const encodedPath = path ? `/${encodeURIComponent(path)}` : "";
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const result = await this.request<
|
||||
Array<{ name: string; path: string; type: string; size: number; download_url?: string }>
|
||||
>("GET", `/repos/${owner}/${repo}/contents${encodedPath}${query}`);
|
||||
return result.map((item) => ({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: item.type === "dir" ? ("dir" as const) : ("file" as const),
|
||||
size: item.size,
|
||||
download_url: item.download_url,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
async createIssue(owner: string, repo: string, options: CreateIssueOption): Promise<Issue> {
|
||||
return this.request<Issue>("POST", `/repos/${owner}/${repo}/issues`, options);
|
||||
}
|
||||
|
||||
async listIssueComments(owner: string, repo: string, index: number): Promise<IssueComment[]> {
|
||||
return this.request<IssueComment[]>("GET", `/repos/${owner}/${repo}/issues/${index}/comments`);
|
||||
}
|
||||
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreateIssueCommentOption,
|
||||
): Promise<IssueComment> {
|
||||
return this.request<IssueComment>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/issues/${index}/comments`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async updateIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
body: string,
|
||||
): Promise<IssueComment> {
|
||||
return this.request<IssueComment>(
|
||||
"PATCH",
|
||||
`/repos/${owner}/${repo}/issues/comments/${commentId}`,
|
||||
{ body },
|
||||
);
|
||||
}
|
||||
|
||||
async deleteIssueComment(owner: string, repo: string, commentId: number): Promise<void> {
|
||||
await this.request<void>("DELETE", `/repos/${owner}/${repo}/issues/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// ============ PR Review 操作 ============
|
||||
|
||||
async createPullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreatePullReviewOption,
|
||||
): Promise<PullReview> {
|
||||
return this.request<PullReview>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]> {
|
||||
return this.request<PullReview[]>("GET", `/repos/${owner}/${repo}/pulls/${index}/reviews`);
|
||||
}
|
||||
|
||||
async deletePullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<void> {
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews/${reviewId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullReviewComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<PullReviewComment[]> {
|
||||
return this.request<PullReviewComment[]>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews/${reviewId}/comments`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Reaction 操作 ============
|
||||
|
||||
async getIssueCommentReactions(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
): Promise<Reaction[]> {
|
||||
return this.request<Reaction[]>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
|
||||
);
|
||||
}
|
||||
|
||||
async getIssueReactions(owner: string, repo: string, index: number): Promise<Reaction[]> {
|
||||
return this.request<Reaction[]>("GET", `/repos/${owner}/${repo}/issues/${index}/reactions`);
|
||||
}
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
async searchUsers(query: string, limit = 10): Promise<User[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("q", query);
|
||||
params.set("limit", String(limit));
|
||||
const result = await this.request<{ data: User[] }>(
|
||||
"GET",
|
||||
`/users/search?${params.toString()}`,
|
||||
);
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: number): Promise<User[]> {
|
||||
return this.request<User[]>("GET", `/teams/${teamId}/members`);
|
||||
}
|
||||
}
|
||||
341
core/src/shared/git-provider/adapters/github.adapter.spec.ts
Normal file
341
core/src/shared/git-provider/adapters/github.adapter.spec.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { vi, type MockInstance } from "vitest";
|
||||
import { GithubAdapter } from "./github.adapter";
|
||||
import type { GitProviderModuleOptions } from "../types";
|
||||
|
||||
const mockOptions: GitProviderModuleOptions = {
|
||||
provider: "github",
|
||||
baseUrl: "https://api.github.com",
|
||||
token: "ghp-test-token",
|
||||
};
|
||||
|
||||
/** 构造 mock Response */
|
||||
function mockResponse(body: unknown, status = 200): Response {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? "OK" : "Error",
|
||||
text: vi.fn().mockResolvedValue(text),
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("GithubAdapter", () => {
|
||||
let adapter: GithubAdapter;
|
||||
let fetchSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new GithubAdapter(mockOptions);
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(mockResponse({}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ============ 配置验证 ============
|
||||
|
||||
describe("validateConfig", () => {
|
||||
it("配置完整时不应抛出异常", () => {
|
||||
expect(() => adapter.validateConfig()).not.toThrow();
|
||||
});
|
||||
|
||||
it("缺少 baseUrl 时应抛出异常", () => {
|
||||
const a = new GithubAdapter({ ...mockOptions, baseUrl: "" });
|
||||
expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.baseUrl");
|
||||
});
|
||||
|
||||
it("缺少 token 时应抛出异常", () => {
|
||||
const a = new GithubAdapter({ ...mockOptions, token: "" });
|
||||
expect(() => a.validateConfig()).toThrow("缺少配置 gitProvider.token");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ request 基础方法 ============
|
||||
|
||||
describe("request", () => {
|
||||
it("应使用 Bearer 认证和 GitHub Accept header", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1, name: "repo" }));
|
||||
await adapter.getRepository("owner", "repo");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://api.github.com/repos/owner/repo",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer ghp-test-token",
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("API 返回错误时应抛出异常", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
|
||||
await expect(adapter.getRepository("owner", "repo")).rejects.toThrow("GitHub API error: 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
|
||||
describe("getRepository", () => {
|
||||
it("应正确映射 GitHub 响应", async () => {
|
||||
const ghRepo = {
|
||||
id: 123,
|
||||
name: "my-repo",
|
||||
full_name: "owner/my-repo",
|
||||
default_branch: "main",
|
||||
owner: { id: 1, login: "owner", full_name: "Owner" },
|
||||
};
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghRepo));
|
||||
const result = await adapter.getRepository("owner", "my-repo");
|
||||
expect(result.id).toBe(123);
|
||||
expect(result.name).toBe("my-repo");
|
||||
expect(result.default_branch).toBe("main");
|
||||
expect(result.owner?.login).toBe("owner");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 分支操作 ============
|
||||
|
||||
describe("getBranch", () => {
|
||||
it("应正确映射 GitHub 分支响应", async () => {
|
||||
const ghBranch = {
|
||||
name: "main",
|
||||
protected: true,
|
||||
commit: { sha: "abc123", commit: { message: "init" } },
|
||||
};
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghBranch));
|
||||
const result = await adapter.getBranch("owner", "repo", "main");
|
||||
expect(result.name).toBe("main");
|
||||
expect(result.protected).toBe(true);
|
||||
expect(result.commit?.id).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
describe("lockBranch", () => {
|
||||
it("无白名单时应设置 restrictions", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({}));
|
||||
await adapter.lockBranch("owner", "repo", "main");
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.restrictions).toEqual({ users: [], teams: [] });
|
||||
expect(body.enforce_admins).toBe(true);
|
||||
});
|
||||
|
||||
it("有白名单时应设置 restrictions.users", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({}));
|
||||
await adapter.lockBranch("owner", "repo", "main", {
|
||||
pushWhitelistUsernames: ["bot"],
|
||||
});
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.restrictions.users).toEqual(["bot"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockBranch", () => {
|
||||
it("有保护规则时应删除并返回", async () => {
|
||||
// getBranchProtection
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse({ url: "..." }));
|
||||
// deleteBranchProtection
|
||||
fetchSpy.mockResolvedValueOnce(mockResponse("", 204));
|
||||
const result = await adapter.unlockBranch("owner", "repo", "main");
|
||||
expect(result).not.toBeNull();
|
||||
expect(fetchSpy.mock.calls[1][1].method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("无保护规则时应返回 null", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
|
||||
const result = await adapter.unlockBranch("owner", "repo", "main");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Pull Request ============
|
||||
|
||||
describe("getPullRequest", () => {
|
||||
it("应正确映射 GitHub PR 响应", async () => {
|
||||
const ghPR = {
|
||||
id: 1,
|
||||
number: 42,
|
||||
title: "Fix bug",
|
||||
body: "desc",
|
||||
state: "open",
|
||||
head: {
|
||||
ref: "feature",
|
||||
sha: "abc",
|
||||
repo: { id: 1, name: "r", full_name: "o/r", owner: { id: 1, login: "o" } },
|
||||
},
|
||||
base: {
|
||||
ref: "main",
|
||||
sha: "def",
|
||||
repo: { id: 1, name: "r", full_name: "o/r", owner: { id: 1, login: "o" } },
|
||||
},
|
||||
user: { id: 1, login: "author" },
|
||||
requested_reviewers: [{ id: 2, login: "reviewer" }],
|
||||
requested_teams: [{ id: 10, name: "team-a" }],
|
||||
created_at: "2025-01-01",
|
||||
merge_commit_sha: "merge123",
|
||||
};
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghPR));
|
||||
const result = await adapter.getPullRequest("owner", "repo", 42);
|
||||
expect(result.number).toBe(42);
|
||||
expect(result.title).toBe("Fix bug");
|
||||
expect(result.head?.ref).toBe("feature");
|
||||
expect(result.user?.login).toBe("author");
|
||||
expect(result.requested_reviewers?.[0]?.login).toBe("reviewer");
|
||||
expect(result.requested_reviewers_teams?.[0]?.name).toBe("team-a");
|
||||
expect(result.merge_base).toBe("merge123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAllPullRequests", () => {
|
||||
it("应支持分页获取全部", async () => {
|
||||
const page1 = Array.from({ length: 100 }, (_, i) => ({ id: i, number: i }));
|
||||
const page2 = [{ id: 100, number: 100 }];
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockResponse(page1))
|
||||
.mockResolvedValueOnce(mockResponse(page2));
|
||||
const result = await adapter.listAllPullRequests("o", "r", { state: "all" });
|
||||
expect(result).toHaveLength(101);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPullRequestFiles", () => {
|
||||
it("应正确映射文件变更", async () => {
|
||||
const ghFiles = [
|
||||
{ filename: "a.ts", status: "modified", additions: 5, deletions: 2, patch: "@@ ..." },
|
||||
];
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghFiles));
|
||||
const result = await adapter.getPullRequestFiles("o", "r", 1);
|
||||
expect(result[0].filename).toBe("a.ts");
|
||||
expect(result[0].additions).toBe(5);
|
||||
expect(result[0].patch).toBe("@@ ...");
|
||||
});
|
||||
|
||||
it("应支持分页获取全部文件", async () => {
|
||||
const page1 = Array.from({ length: 100 }, (_, i) => ({ filename: `f${i}.ts` }));
|
||||
const page2 = [{ filename: "f100.ts" }];
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(mockResponse(page1))
|
||||
.mockResolvedValueOnce(mockResponse(page2));
|
||||
const result = await adapter.getPullRequestFiles("o", "r", 1);
|
||||
expect(result).toHaveLength(101);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Commit ============
|
||||
|
||||
describe("getCommit", () => {
|
||||
it("应正确映射 commit 和 files", async () => {
|
||||
const ghCommit = {
|
||||
sha: "abc123",
|
||||
commit: { message: "fix", author: { name: "A", email: "a@b.c", date: "2025-01-01" } },
|
||||
author: { id: 1, login: "a" },
|
||||
files: [{ filename: "x.ts", status: "modified", additions: 1 }],
|
||||
};
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghCommit));
|
||||
const result = await adapter.getCommit("o", "r", "abc123");
|
||||
expect(result.sha).toBe("abc123");
|
||||
expect(result.commit?.message).toBe("fix");
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files?.[0].filename).toBe("x.ts");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
describe("getFileContent", () => {
|
||||
it("应解码 base64 内容", async () => {
|
||||
const content = Buffer.from("hello world").toString("base64");
|
||||
fetchSpy.mockResolvedValue(mockResponse({ content, encoding: "base64" }));
|
||||
const result = await adapter.getFileContent("o", "r", "a.ts");
|
||||
expect(result).toBe("hello world");
|
||||
});
|
||||
|
||||
it("404 时应返回空字符串", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse("Not Found", 404));
|
||||
const result = await adapter.getFileContent("o", "r", "missing.ts");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("指定 ref 时应添加 query 参数", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ content: "", encoding: "base64" }));
|
||||
await adapter.getFileContent("o", "r", "a.ts", "dev");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("?ref=dev"), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("应正确映射 issue 响应", async () => {
|
||||
const ghIssue = {
|
||||
id: 1,
|
||||
number: 10,
|
||||
title: "Bug",
|
||||
state: "open",
|
||||
user: { id: 1, login: "u" },
|
||||
html_url: "https://github.com/o/r/issues/10",
|
||||
};
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghIssue));
|
||||
const result = await adapter.createIssue("o", "r", { title: "Bug" });
|
||||
expect(result.number).toBe(10);
|
||||
expect(result.html_url).toBe("https://github.com/o/r/issues/10");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ PR Review ============
|
||||
|
||||
describe("createPullReview", () => {
|
||||
it("应正确映射 review 选项", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ id: 1 }));
|
||||
await adapter.createPullReview("o", "r", 1, {
|
||||
event: "COMMENT",
|
||||
body: "LGTM",
|
||||
comments: [{ path: "a.ts", body: "fix", new_position: 10 }],
|
||||
});
|
||||
const body = JSON.parse(fetchSpy.mock.calls[0][1].body);
|
||||
expect(body.event).toBe("COMMENT");
|
||||
expect(body.comments[0].position).toBe(10);
|
||||
expect(body.comments[0].path).toBe("a.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPullReviews", () => {
|
||||
it("应正确映射 review 响应", async () => {
|
||||
const ghReviews = [
|
||||
{
|
||||
id: 1,
|
||||
body: "ok",
|
||||
state: "APPROVED",
|
||||
user: { id: 1, login: "r" },
|
||||
submitted_at: "2025-01-01",
|
||||
},
|
||||
];
|
||||
fetchSpy.mockResolvedValue(mockResponse(ghReviews));
|
||||
const result = await adapter.listPullReviews("o", "r", 1);
|
||||
expect(result[0].state).toBe("APPROVED");
|
||||
expect(result[0].created_at).toBe("2025-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
describe("searchUsers", () => {
|
||||
it("应请求 /search/users 并解构 items 字段", async () => {
|
||||
fetchSpy.mockResolvedValue(mockResponse({ items: [{ id: 1, login: "u", name: "User" }] }));
|
||||
const result = await adapter.searchUsers("u", 5);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].login).toBe("u");
|
||||
expect(result[0].full_name).toBe("User");
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/search/users?q=u&per_page=5"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
830
core/src/shared/git-provider/adapters/github.adapter.ts
Normal file
830
core/src/shared/git-provider/adapters/github.adapter.ts
Normal file
@@ -0,0 +1,830 @@
|
||||
import { execSync } from "child_process";
|
||||
import type {
|
||||
GitProvider,
|
||||
LockBranchOptions,
|
||||
ListPullRequestsOptions,
|
||||
} from "../git-provider.interface";
|
||||
import type {
|
||||
GitProviderModuleOptions,
|
||||
BranchProtection,
|
||||
CreateBranchProtectionOption,
|
||||
EditBranchProtectionOption,
|
||||
Branch,
|
||||
Repository,
|
||||
PullRequest,
|
||||
PullRequestCommit,
|
||||
ChangedFile,
|
||||
CommitInfo,
|
||||
IssueComment,
|
||||
CreateIssueCommentOption,
|
||||
CreateIssueOption,
|
||||
Issue,
|
||||
CreatePullReviewOption,
|
||||
PullReview,
|
||||
PullReviewComment,
|
||||
Reaction,
|
||||
EditPullRequestOption,
|
||||
User,
|
||||
RepositoryContent,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* GitHub 平台适配器
|
||||
*/
|
||||
export class GithubAdapter implements GitProvider {
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly token: string;
|
||||
|
||||
constructor(protected readonly options: GitProviderModuleOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
validateConfig(): void {
|
||||
if (!this.options?.baseUrl) {
|
||||
throw new Error("缺少配置 gitProvider.baseUrl (环境变量 GIT_PROVIDER_URL)");
|
||||
}
|
||||
if (!this.options?.token) {
|
||||
throw new Error("缺少配置 gitProvider.token (环境变量 GIT_PROVIDER_TOKEN)");
|
||||
}
|
||||
}
|
||||
|
||||
protected async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
protected async fetchText(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "application/vnd.github.v3.diff",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
|
||||
async getRepository(owner: string, repo: string): Promise<Repository> {
|
||||
const result = await this.request<Record<string, unknown>>("GET", `/repos/${owner}/${repo}`);
|
||||
return this.mapRepository(result);
|
||||
}
|
||||
|
||||
// ============ 分支操作 ============
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string): Promise<Branch> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
return this.mapBranch(result);
|
||||
}
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
async listBranchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
try {
|
||||
const rules = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/rules/rulesets`,
|
||||
);
|
||||
return rules.map((rule) => this.mapRulesetToProtection(rule));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getBranchProtection(owner: string, repo: string, name: string): Promise<BranchProtection> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(name)}/protection`,
|
||||
);
|
||||
return this.mapGithubProtection(result, name);
|
||||
}
|
||||
|
||||
async createBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: CreateBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
const branchName = options.branch_name || options.rule_name || "";
|
||||
const body = this.buildGithubProtectionBody(options);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PUT",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(branchName)}/protection`,
|
||||
body,
|
||||
);
|
||||
return this.mapGithubProtection(result, branchName);
|
||||
}
|
||||
|
||||
async editBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
options: EditBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
const body = this.buildGithubProtectionBody(options);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PUT",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(name)}/protection`,
|
||||
body,
|
||||
);
|
||||
return this.mapGithubProtection(result, name);
|
||||
}
|
||||
|
||||
async deleteBranchProtection(owner: string, repo: string, name: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(name)}/protection`,
|
||||
);
|
||||
}
|
||||
|
||||
async lockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
options?: LockBranchOptions,
|
||||
): Promise<BranchProtection> {
|
||||
const pushWhitelistUsernames = options?.pushWhitelistUsernames;
|
||||
const hasWhitelist = pushWhitelistUsernames && pushWhitelistUsernames.length > 0;
|
||||
const body: Record<string, unknown> = {
|
||||
required_status_checks: null,
|
||||
enforce_admins: true,
|
||||
required_pull_request_reviews: null,
|
||||
restrictions: hasWhitelist
|
||||
? { users: pushWhitelistUsernames, teams: [] }
|
||||
: { users: [], teams: [] },
|
||||
};
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PUT",
|
||||
`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`,
|
||||
body,
|
||||
);
|
||||
return this.mapGithubProtection(result, branch);
|
||||
}
|
||||
|
||||
async unlockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<BranchProtection | null> {
|
||||
try {
|
||||
const existing = await this.getBranchProtection(owner, repo, branch);
|
||||
await this.deleteBranchProtection(owner, repo, branch);
|
||||
return existing;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
unlockBranchSync(owner: string, repo: string, branch: string): void {
|
||||
try {
|
||||
execSync(
|
||||
`curl -s -X DELETE "${this.baseUrl}/repos/${owner}/${repo}/branches/${branch}/protection" -H "Authorization: Bearer ${this.token}" -H "Accept: application/vnd.github+json"`,
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
console.log(`✅ 分支已解锁(同步): ${branch}`);
|
||||
} catch (error) {
|
||||
console.error("⚠️ 同步解锁分支失败:", error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Pull Request 操作 ============
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number): Promise<PullRequest> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}`,
|
||||
);
|
||||
return this.mapPullRequest(result);
|
||||
}
|
||||
|
||||
async editPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: EditPullRequestOption,
|
||||
): Promise<PullRequest> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PATCH",
|
||||
`/repos/${owner}/${repo}/pulls/${index}`,
|
||||
options,
|
||||
);
|
||||
return this.mapPullRequest(result);
|
||||
}
|
||||
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state?: "open" | "closed" | "all",
|
||||
): Promise<PullRequest[]> {
|
||||
const query = state ? `?state=${state}` : "";
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls${query}`,
|
||||
);
|
||||
return results.map((pr) => this.mapPullRequest(pr));
|
||||
}
|
||||
|
||||
async listAllPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: ListPullRequestsOptions,
|
||||
): Promise<PullRequest[]> {
|
||||
const allPRs: PullRequest[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("per_page", String(perPage));
|
||||
if (options?.state) params.set("state", options.state);
|
||||
if (options?.sort) params.set("sort", this.mapSortParam(options.sort));
|
||||
if (options?.labels?.length) params.set("labels", options.labels.join(","));
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls?${params.toString()}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allPRs.push(...results.map((pr) => this.mapPullRequest(pr)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allPRs;
|
||||
}
|
||||
|
||||
async getPullRequestCommits(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
): Promise<PullRequestCommit[]> {
|
||||
const allCommits: PullRequestCommit[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/commits?page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allCommits.push(...results.map((c) => this.mapCommit(c)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allCommits;
|
||||
}
|
||||
|
||||
async getPullRequestFiles(owner: string, repo: string, index: number): Promise<ChangedFile[]> {
|
||||
const allFiles: ChangedFile[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/files?page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allFiles.push(...results.map((f) => this.mapChangedFile(f)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async getPullRequestDiff(owner: string, repo: string, index: number): Promise<string> {
|
||||
return this.fetchText(`${this.baseUrl}/repos/${owner}/${repo}/pulls/${index}`);
|
||||
}
|
||||
|
||||
// ============ Commit 操作 ============
|
||||
|
||||
async getCommit(owner: string, repo: string, sha: string): Promise<CommitInfo> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/commits/${sha}`,
|
||||
);
|
||||
const commit = this.mapCommit(result);
|
||||
const files = ((result.files as Array<Record<string, unknown>>) || []).map((f) =>
|
||||
this.mapChangedFile(f),
|
||||
);
|
||||
return { ...commit, files };
|
||||
}
|
||||
|
||||
async getCompareDiff(
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
): Promise<string> {
|
||||
return this.fetchText(`${this.baseUrl}/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`);
|
||||
}
|
||||
|
||||
async getCommitDiff(owner: string, repo: string, sha: string): Promise<string> {
|
||||
return this.fetchText(`${this.baseUrl}/repos/${owner}/${repo}/commits/${sha}`);
|
||||
}
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
async getFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filepath: string,
|
||||
ref?: string,
|
||||
): Promise<string> {
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
try {
|
||||
const result = await this.request<{ content?: string; encoding?: string }>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/contents/${filepath}${query}`,
|
||||
);
|
||||
if (result.content && result.encoding === "base64") {
|
||||
return Buffer.from(result.content, "base64").toString("utf-8");
|
||||
}
|
||||
return result.content || "";
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("404")) {
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path = "",
|
||||
ref?: string,
|
||||
): Promise<RepositoryContent[]> {
|
||||
const encodedPath = path ? `/${path}` : "";
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const result = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/contents${encodedPath}${query}`,
|
||||
);
|
||||
return result.map((item) => ({
|
||||
name: item.name as string,
|
||||
path: item.path as string,
|
||||
type: (item.type as string) === "dir" ? ("dir" as const) : ("file" as const),
|
||||
size: item.size as number,
|
||||
download_url: item.download_url as string,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
async createIssue(owner: string, repo: string, options: CreateIssueOption): Promise<Issue> {
|
||||
const body: Record<string, unknown> = {
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
assignees: options.assignees,
|
||||
labels: options.labels,
|
||||
milestone: options.milestone,
|
||||
};
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/issues`,
|
||||
body,
|
||||
);
|
||||
return this.mapIssue(result);
|
||||
}
|
||||
|
||||
async listIssueComments(owner: string, repo: string, index: number): Promise<IssueComment[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/issues/${index}/comments`,
|
||||
);
|
||||
return results.map((c) => this.mapIssueComment(c));
|
||||
}
|
||||
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreateIssueCommentOption,
|
||||
): Promise<IssueComment> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/issues/${index}/comments`,
|
||||
{ body: options.body },
|
||||
);
|
||||
return this.mapIssueComment(result);
|
||||
}
|
||||
|
||||
async updateIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
body: string,
|
||||
): Promise<IssueComment> {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PATCH",
|
||||
`/repos/${owner}/${repo}/issues/comments/${commentId}`,
|
||||
{ body },
|
||||
);
|
||||
return this.mapIssueComment(result);
|
||||
}
|
||||
|
||||
async deleteIssueComment(owner: string, repo: string, commentId: number): Promise<void> {
|
||||
await this.request<void>("DELETE", `/repos/${owner}/${repo}/issues/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// ============ PR Review 操作 ============
|
||||
|
||||
async createPullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreatePullReviewOption,
|
||||
): Promise<PullReview> {
|
||||
const body: Record<string, unknown> = {
|
||||
event: this.mapReviewEvent(options.event),
|
||||
body: options.body,
|
||||
commit_id: options.commit_id,
|
||||
};
|
||||
if (options.comments?.length) {
|
||||
body.comments = options.comments.map((c) => ({
|
||||
path: c.path,
|
||||
body: c.body,
|
||||
position: c.new_position,
|
||||
}));
|
||||
}
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews`,
|
||||
body,
|
||||
);
|
||||
return this.mapPullReview(result);
|
||||
}
|
||||
|
||||
async listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews`,
|
||||
);
|
||||
return results.map((r) => this.mapPullReview(r));
|
||||
}
|
||||
|
||||
async deletePullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<void> {
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews/${reviewId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullReviewComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<PullReviewComment[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/pulls/${index}/reviews/${reviewId}/comments`,
|
||||
);
|
||||
return results.map((c) => this.mapPullReviewComment(c));
|
||||
}
|
||||
|
||||
// ============ Reaction 操作 ============
|
||||
|
||||
async getIssueCommentReactions(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
): Promise<Reaction[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
|
||||
);
|
||||
return results.map((r) => this.mapReaction(r));
|
||||
}
|
||||
|
||||
async getIssueReactions(owner: string, repo: string, index: number): Promise<Reaction[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/repos/${owner}/${repo}/issues/${index}/reactions`,
|
||||
);
|
||||
return results.map((r) => this.mapReaction(r));
|
||||
}
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
async searchUsers(query: string, limit = 10): Promise<User[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("q", query);
|
||||
params.set("per_page", String(limit));
|
||||
const result = await this.request<{ items: Array<Record<string, unknown>> }>(
|
||||
"GET",
|
||||
`/search/users?${params.toString()}`,
|
||||
);
|
||||
return (result.items || []).map((u) => this.mapUser(u));
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: number): Promise<User[]> {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/teams/${teamId}/members`,
|
||||
);
|
||||
return results.map((u) => this.mapUser(u));
|
||||
}
|
||||
|
||||
// ============ 映射辅助方法 ============
|
||||
|
||||
protected mapRepository(data: Record<string, unknown>): Repository {
|
||||
const owner = data.owner as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
owner: owner
|
||||
? {
|
||||
id: owner.id as number,
|
||||
login: owner.login as string,
|
||||
full_name: owner.full_name as string,
|
||||
}
|
||||
: undefined,
|
||||
name: data.name as string,
|
||||
full_name: data.full_name as string,
|
||||
default_branch: data.default_branch as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapBranch(data: Record<string, unknown>): Branch {
|
||||
const commit = data.commit as Record<string, unknown> | undefined;
|
||||
const commitObj = commit?.commit as Record<string, unknown> | undefined;
|
||||
return {
|
||||
name: data.name as string,
|
||||
protected: data.protected as boolean,
|
||||
commit: commit
|
||||
? {
|
||||
id: commit.sha as string,
|
||||
message: commitObj?.message as string,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapPullRequest(data: Record<string, unknown>): PullRequest {
|
||||
const head = data.head as Record<string, unknown> | undefined;
|
||||
const base = data.base as Record<string, unknown> | undefined;
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
const reviewers = data.requested_reviewers as Array<Record<string, unknown>> | undefined;
|
||||
const teams = data.requested_teams as Array<Record<string, unknown>> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
number: data.number as number,
|
||||
title: data.title as string,
|
||||
body: data.body as string,
|
||||
state: data.state as string,
|
||||
head: head
|
||||
? {
|
||||
ref: head.ref as string,
|
||||
sha: head.sha as string,
|
||||
repo: head.repo ? this.mapRepository(head.repo as Record<string, unknown>) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
base: base
|
||||
? {
|
||||
ref: base.ref as string,
|
||||
sha: base.sha as string,
|
||||
repo: base.repo ? this.mapRepository(base.repo as Record<string, unknown>) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
requested_reviewers: reviewers?.map((r) => ({
|
||||
id: r.id as number,
|
||||
login: r.login as string,
|
||||
})),
|
||||
requested_reviewers_teams: teams?.map((t) => ({
|
||||
id: t.id as number,
|
||||
name: t.name as string,
|
||||
})),
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
merged_at: data.merged_at as string,
|
||||
merge_base: data.merge_commit_sha as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapCommit(data: Record<string, unknown>): PullRequestCommit {
|
||||
const commit = data.commit as Record<string, unknown> | undefined;
|
||||
const author = commit?.author as Record<string, unknown> | undefined;
|
||||
const ghAuthor = data.author as Record<string, unknown> | undefined;
|
||||
const ghCommitter = data.committer as Record<string, unknown> | undefined;
|
||||
return {
|
||||
sha: data.sha as string,
|
||||
commit: commit
|
||||
? {
|
||||
message: commit.message as string,
|
||||
author: author
|
||||
? {
|
||||
name: author.name as string,
|
||||
email: author.email as string,
|
||||
date: author.date as string,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
author: ghAuthor ? { id: ghAuthor.id as number, login: ghAuthor.login as string } : undefined,
|
||||
committer: ghCommitter
|
||||
? { id: ghCommitter.id as number, login: ghCommitter.login as string }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapChangedFile(data: Record<string, unknown>): ChangedFile {
|
||||
return {
|
||||
filename: data.filename as string,
|
||||
status: data.status as string,
|
||||
additions: data.additions as number,
|
||||
deletions: data.deletions as number,
|
||||
changes: data.changes as number,
|
||||
patch: data.patch as string,
|
||||
raw_url: data.raw_url as string,
|
||||
contents_url: data.contents_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapIssueComment(data: Record<string, unknown>): IssueComment {
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
body: data.body as string,
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapIssue(data: Record<string, unknown>): Issue {
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
const labels = data.labels as Array<Record<string, unknown>> | undefined;
|
||||
const assignees = data.assignees as Array<Record<string, unknown>> | undefined;
|
||||
const milestone = data.milestone as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
number: data.number as number,
|
||||
title: data.title as string,
|
||||
body: data.body as string,
|
||||
state: data.state as string,
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
labels: labels?.map((l) => ({
|
||||
id: l.id as number,
|
||||
name: l.name as string,
|
||||
color: l.color as string,
|
||||
})),
|
||||
assignees: assignees?.map((a) => ({ id: a.id as number, login: a.login as string })),
|
||||
milestone: milestone
|
||||
? { id: milestone.id as number, title: milestone.title as string }
|
||||
: undefined,
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
closed_at: data.closed_at as string,
|
||||
html_url: data.html_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapPullReview(data: Record<string, unknown>): PullReview {
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
body: data.body as string,
|
||||
state: data.state as string,
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
created_at: data.submitted_at as string,
|
||||
updated_at: data.submitted_at as string,
|
||||
commit_id: data.commit_id as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapPullReviewComment(data: Record<string, unknown>): PullReviewComment {
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
body: data.body as string,
|
||||
path: data.path as string,
|
||||
position: data.position as number,
|
||||
original_position: data.original_position as number,
|
||||
commit_id: data.commit_id as string,
|
||||
original_commit_id: data.original_commit_id as string,
|
||||
diff_hunk: data.diff_hunk as string,
|
||||
pull_request_review_id: data.pull_request_review_id as number,
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
html_url: data.html_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapReaction(data: Record<string, unknown>): Reaction {
|
||||
const user = data.user as Record<string, unknown> | undefined;
|
||||
return {
|
||||
user: user ? { id: user.id as number, login: user.login as string } : undefined,
|
||||
content: data.content as string,
|
||||
created_at: data.created_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapUser(data: Record<string, unknown>): User {
|
||||
return {
|
||||
id: data.id as number,
|
||||
login: data.login as string,
|
||||
full_name: data.name as string,
|
||||
email: data.email as string,
|
||||
avatar_url: data.avatar_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapGithubProtection(
|
||||
data: Record<string, unknown>,
|
||||
branchName: string,
|
||||
): BranchProtection {
|
||||
const requiredReviews = data.required_pull_request_reviews as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return {
|
||||
branch_name: branchName,
|
||||
rule_name: branchName,
|
||||
required_approvals: requiredReviews?.required_approving_review_count as number,
|
||||
dismiss_stale_approvals: requiredReviews?.dismiss_stale_reviews as boolean,
|
||||
enable_push: true,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapRulesetToProtection(data: Record<string, unknown>): BranchProtection {
|
||||
return {
|
||||
rule_name: data.name as string,
|
||||
branch_name: data.name as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected buildGithubProtectionBody(
|
||||
options: CreateBranchProtectionOption | EditBranchProtectionOption,
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
required_status_checks: options.enable_status_check
|
||||
? { strict: true, contexts: options.status_check_contexts || [] }
|
||||
: null,
|
||||
enforce_admins: options.block_admin_merge_override ?? false,
|
||||
required_pull_request_reviews: options.required_approvals
|
||||
? {
|
||||
required_approving_review_count: options.required_approvals,
|
||||
dismiss_stale_reviews: options.dismiss_stale_approvals ?? false,
|
||||
}
|
||||
: null,
|
||||
restrictions: options.enable_push_whitelist
|
||||
? {
|
||||
users: options.push_whitelist_usernames || [],
|
||||
teams: options.push_whitelist_teams || [],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
protected mapReviewEvent(event?: string): string {
|
||||
const eventMap: Record<string, string> = {
|
||||
APPROVE: "APPROVE",
|
||||
REQUEST_CHANGES: "REQUEST_CHANGES",
|
||||
COMMENT: "COMMENT",
|
||||
PENDING: "PENDING",
|
||||
};
|
||||
return event ? eventMap[event] || event : "COMMENT";
|
||||
}
|
||||
|
||||
protected mapSortParam(sort: string): string {
|
||||
const sortMap: Record<string, string> = {
|
||||
oldest: "created",
|
||||
recentupdate: "updated",
|
||||
leastupdate: "updated",
|
||||
mostcomment: "comments",
|
||||
leastcomment: "comments",
|
||||
};
|
||||
return sortMap[sort] || "created";
|
||||
}
|
||||
}
|
||||
839
core/src/shared/git-provider/adapters/gitlab.adapter.ts
Normal file
839
core/src/shared/git-provider/adapters/gitlab.adapter.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import { execSync } from "child_process";
|
||||
import type {
|
||||
GitProvider,
|
||||
LockBranchOptions,
|
||||
ListPullRequestsOptions,
|
||||
} from "../git-provider.interface";
|
||||
import type {
|
||||
GitProviderModuleOptions,
|
||||
BranchProtection,
|
||||
CreateBranchProtectionOption,
|
||||
EditBranchProtectionOption,
|
||||
Branch,
|
||||
Repository,
|
||||
PullRequest,
|
||||
PullRequestCommit,
|
||||
ChangedFile,
|
||||
CommitInfo,
|
||||
IssueComment,
|
||||
CreateIssueCommentOption,
|
||||
CreateIssueOption,
|
||||
Issue,
|
||||
CreatePullReviewOption,
|
||||
PullReview,
|
||||
PullReviewComment,
|
||||
Reaction,
|
||||
EditPullRequestOption,
|
||||
User,
|
||||
RepositoryContent,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* GitLab 平台适配器
|
||||
*
|
||||
* GitLab API 特点:
|
||||
* - 使用 PRIVATE-TOKEN 认证
|
||||
* - 项目通过 URL-encoded path(owner/repo)标识
|
||||
* - Merge Request(MR)对应 Pull Request
|
||||
* - Notes 对应 Comments/Reviews
|
||||
* - API 前缀 /api/v4
|
||||
*/
|
||||
export class GitlabAdapter implements GitProvider {
|
||||
protected readonly baseUrl: string;
|
||||
protected readonly token: string;
|
||||
|
||||
constructor(protected readonly options: GitProviderModuleOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||
this.token = options.token;
|
||||
}
|
||||
|
||||
/** 将 owner/repo 编码为 GitLab 项目路径 */
|
||||
protected encodeProject(owner: string, repo: string): string {
|
||||
return encodeURIComponent(`${owner}/${repo}`);
|
||||
}
|
||||
|
||||
validateConfig(): void {
|
||||
if (!this.options?.baseUrl) {
|
||||
throw new Error("缺少配置 gitProvider.baseUrl (环境变量 GIT_PROVIDER_URL)");
|
||||
}
|
||||
if (!this.options?.token) {
|
||||
throw new Error("缺少配置 gitProvider.token (环境变量 GIT_PROVIDER_TOKEN)");
|
||||
}
|
||||
}
|
||||
|
||||
protected async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}/api/v4${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"PRIVATE-TOKEN": this.token,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GitLab API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
protected async fetchText(path: string): Promise<string> {
|
||||
const url = `${this.baseUrl}/api/v4${path}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "PRIVATE-TOKEN": this.token },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GitLab API error: ${response.status} ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
|
||||
async getRepository(owner: string, repo: string): Promise<Repository> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>("GET", `/projects/${project}`);
|
||||
const namespace = result.namespace as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: result.id as number,
|
||||
name: result.name as string,
|
||||
full_name: result.path_with_namespace as string,
|
||||
default_branch: result.default_branch as string,
|
||||
owner: namespace
|
||||
? {
|
||||
id: namespace.id as number,
|
||||
login: namespace.path as string,
|
||||
full_name: namespace.name as string,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 分支操作 ============
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string): Promise<Branch> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/branches/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
const commit = result.commit as Record<string, unknown> | undefined;
|
||||
return {
|
||||
name: result.name as string,
|
||||
protected: result.protected as boolean,
|
||||
commit: commit ? { id: commit.id as string, message: commit.message as string } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
async listBranchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/protected_branches`,
|
||||
);
|
||||
return results.map((p) => this.mapProtection(p));
|
||||
}
|
||||
|
||||
async getBranchProtection(owner: string, repo: string, name: string): Promise<BranchProtection> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/projects/${project}/protected_branches/${encodeURIComponent(name)}`,
|
||||
);
|
||||
return this.mapProtection(result);
|
||||
}
|
||||
|
||||
async createBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: CreateBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const branchName = options.branch_name || options.rule_name || "";
|
||||
const body: Record<string, unknown> = {
|
||||
name: branchName,
|
||||
push_access_level: options.enable_push ? 30 : 0, // 30=Developer, 0=No one
|
||||
merge_access_level: 30,
|
||||
};
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/projects/${project}/protected_branches`,
|
||||
body,
|
||||
);
|
||||
return this.mapProtection(result);
|
||||
}
|
||||
|
||||
async editBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
options: EditBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
// GitLab 不支持直接编辑,需要先删除再创建
|
||||
await this.deleteBranchProtection(owner, repo, name);
|
||||
return this.createBranchProtection(owner, repo, {
|
||||
branch_name: name,
|
||||
rule_name: name,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBranchProtection(owner: string, repo: string, name: string): Promise<void> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/projects/${project}/protected_branches/${encodeURIComponent(name)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async lockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
options?: LockBranchOptions,
|
||||
): Promise<BranchProtection> {
|
||||
// 先尝试删除已有保护
|
||||
try {
|
||||
await this.deleteBranchProtection(owner, repo, branch);
|
||||
} catch {
|
||||
// 不存在时忽略
|
||||
}
|
||||
const pushLevel = options?.pushWhitelistUsernames?.length ? 30 : 0;
|
||||
return this.createBranchProtection(owner, repo, {
|
||||
branch_name: branch,
|
||||
rule_name: branch,
|
||||
enable_push: pushLevel > 0,
|
||||
});
|
||||
}
|
||||
|
||||
async unlockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<BranchProtection | null> {
|
||||
try {
|
||||
const existing = await this.getBranchProtection(owner, repo, branch);
|
||||
await this.deleteBranchProtection(owner, repo, branch);
|
||||
return existing;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
unlockBranchSync(owner: string, repo: string, branch: string): void {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
try {
|
||||
execSync(
|
||||
`curl -s -X DELETE "${this.baseUrl}/api/v4/projects/${project}/protected_branches/${encodeURIComponent(branch)}" -H "PRIVATE-TOKEN: ${this.token}"`,
|
||||
{ encoding: "utf-8" },
|
||||
);
|
||||
console.log(`✅ 分支已解锁(同步): ${branch}`);
|
||||
} catch (error) {
|
||||
console.error("⚠️ 同步解锁分支失败:", error instanceof Error ? error.message : error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Merge Request(对应 Pull Request) ============
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number): Promise<PullRequest> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}`,
|
||||
);
|
||||
return this.mapMergeRequest(result);
|
||||
}
|
||||
|
||||
async editPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: EditPullRequestOption,
|
||||
): Promise<PullRequest> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (options.title) body.title = options.title;
|
||||
if (options.body !== undefined) body.description = options.body;
|
||||
if (options.state) body.state_event = options.state === "closed" ? "close" : "reopen";
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"PUT",
|
||||
`/projects/${project}/merge_requests/${index}`,
|
||||
body,
|
||||
);
|
||||
return this.mapMergeRequest(result);
|
||||
}
|
||||
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state?: "open" | "closed" | "all",
|
||||
): Promise<PullRequest[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const glState = this.mapStateParam(state);
|
||||
const query = glState ? `?state=${glState}` : "";
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests${query}`,
|
||||
);
|
||||
return results.map((mr) => this.mapMergeRequest(mr));
|
||||
}
|
||||
|
||||
async listAllPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: ListPullRequestsOptions,
|
||||
): Promise<PullRequest[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const allMRs: PullRequest[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("per_page", String(perPage));
|
||||
if (options?.state) params.set("state", this.mapStateParam(options.state) || "all");
|
||||
if (options?.labels?.length) params.set("labels", options.labels.join(","));
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests?${params.toString()}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allMRs.push(...results.map((mr) => this.mapMergeRequest(mr)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allMRs;
|
||||
}
|
||||
|
||||
async getPullRequestCommits(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
): Promise<PullRequestCommit[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const allCommits: PullRequestCommit[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/commits?page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allCommits.push(...results.map((c) => this.mapGitlabCommit(c)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allCommits;
|
||||
}
|
||||
|
||||
async getPullRequestFiles(owner: string, repo: string, index: number): Promise<ChangedFile[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const allFiles: ChangedFile[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (true) {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/diffs?page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
if (!results || results.length === 0) break;
|
||||
allFiles.push(...results.map((d) => this.mapDiffToChangedFile(d)));
|
||||
if (results.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async getPullRequestDiff(owner: string, repo: string, index: number): Promise<string> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
return this.fetchText(`/projects/${project}/merge_requests/${index}/raw_diffs`);
|
||||
}
|
||||
|
||||
// ============ Commit 操作 ============
|
||||
|
||||
async getCommit(owner: string, repo: string, sha: string): Promise<CommitInfo> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/commits/${sha}`,
|
||||
);
|
||||
// 获取 commit 的 diff 来填充 files
|
||||
let files: ChangedFile[] = [];
|
||||
try {
|
||||
const diffs = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/commits/${sha}/diff`,
|
||||
);
|
||||
files = diffs.map((d) => this.mapDiffToChangedFile(d));
|
||||
} catch {
|
||||
// diff 获取失败时忽略
|
||||
}
|
||||
const commit = this.mapGitlabCommit(result);
|
||||
return { ...commit, files };
|
||||
}
|
||||
|
||||
async getCompareDiff(
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
): Promise<string> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<{ diffs: Array<Record<string, unknown>> }>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/compare?from=${encodeURIComponent(baseSha)}&to=${encodeURIComponent(headSha)}`,
|
||||
);
|
||||
// 将 diffs 拼接为 unified diff 文本
|
||||
return (result.diffs || [])
|
||||
.map((d) => {
|
||||
const oldPath = d.old_path as string;
|
||||
const newPath = d.new_path as string;
|
||||
const diff = d.diff as string;
|
||||
return `diff --git a/${oldPath} b/${newPath}\n${diff}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async getCommitDiff(owner: string, repo: string, sha: string): Promise<string> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const diffs = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/commits/${sha}/diff`,
|
||||
);
|
||||
return diffs
|
||||
.map((d) => {
|
||||
const oldPath = d.old_path as string;
|
||||
const newPath = d.new_path as string;
|
||||
const diff = d.diff as string;
|
||||
return `diff --git a/${oldPath} b/${newPath}\n${diff}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
async getFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filepath: string,
|
||||
ref?: string,
|
||||
): Promise<string> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const encodedPath = encodeURIComponent(filepath);
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
try {
|
||||
const url = `${this.baseUrl}/api/v4/projects/${project}/repository/files/${encodedPath}/raw${query}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "PRIVATE-TOKEN": this.token },
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return "";
|
||||
}
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`GitLab API error: ${response.status} ${response.statusText} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
return response.text();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("404")) {
|
||||
return "";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async listRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path = "",
|
||||
ref?: string,
|
||||
): Promise<RepositoryContent[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
if (ref) params.set("ref", ref);
|
||||
const result = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/repository/tree?${params.toString()}`,
|
||||
);
|
||||
return result.map((item) => ({
|
||||
name: item.name as string,
|
||||
path: item.path as string,
|
||||
type: (item.type as string) === "tree" ? ("dir" as const) : ("file" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
async createIssue(owner: string, repo: string, options: CreateIssueOption): Promise<Issue> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const body: Record<string, unknown> = {
|
||||
title: options.title,
|
||||
description: options.body,
|
||||
assignee_ids: options.assignees,
|
||||
labels: options.labels?.join(","),
|
||||
milestone_id: options.milestone,
|
||||
};
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/projects/${project}/issues`,
|
||||
body,
|
||||
);
|
||||
return this.mapIssue(result);
|
||||
}
|
||||
|
||||
async listIssueComments(owner: string, repo: string, index: number): Promise<IssueComment[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
// GitLab: MR notes 作为 issue comments
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/notes?sort=asc`,
|
||||
);
|
||||
return results.filter((n) => !(n.system as boolean)).map((n) => this.mapNote(n));
|
||||
}
|
||||
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreateIssueCommentOption,
|
||||
): Promise<IssueComment> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/projects/${project}/merge_requests/${index}/notes`,
|
||||
{ body: options.body },
|
||||
);
|
||||
return this.mapNote(result);
|
||||
}
|
||||
|
||||
async updateIssueComment(
|
||||
_owner: string,
|
||||
_repo: string,
|
||||
_commentId: number,
|
||||
_body: string,
|
||||
): Promise<IssueComment> {
|
||||
// GitLab 更新 note 需要 noteable_iid,接口签名不含此信息
|
||||
throw new Error("GitLab 适配器暂不支持通过 commentId 更新评论,请使用 createIssueComment 替代");
|
||||
}
|
||||
|
||||
async deleteIssueComment(_owner: string, _repo: string, _commentId: number): Promise<void> {
|
||||
throw new Error("GitLab 适配器暂不支持通过 commentId 删除评论");
|
||||
}
|
||||
|
||||
// ============ MR Review(对应 PR Review) ============
|
||||
|
||||
async createPullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreatePullReviewOption,
|
||||
): Promise<PullReview> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
// GitLab 没有 review 概念,用 note 模拟
|
||||
// 如果有 body,创建一个总体评论
|
||||
if (options.body) {
|
||||
const result = await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/projects/${project}/merge_requests/${index}/notes`,
|
||||
{ body: options.body },
|
||||
);
|
||||
const note = this.mapNote(result);
|
||||
return {
|
||||
id: note.id,
|
||||
body: note.body,
|
||||
state: options.event || "COMMENT",
|
||||
user: note.user,
|
||||
created_at: note.created_at,
|
||||
updated_at: note.updated_at,
|
||||
};
|
||||
}
|
||||
// 如果有行级评论,逐个创建
|
||||
if (options.comments?.length) {
|
||||
for (const comment of options.comments) {
|
||||
await this.request<Record<string, unknown>>(
|
||||
"POST",
|
||||
`/projects/${project}/merge_requests/${index}/notes`,
|
||||
{ body: `**${comment.path}** (line ${comment.new_position})\n\n${comment.body}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
// 如果是 APPROVE 事件,调用 approve API
|
||||
if (options.event === "APPROVE") {
|
||||
await this.request<void>("POST", `/projects/${project}/merge_requests/${index}/approve`);
|
||||
}
|
||||
return {
|
||||
id: 0,
|
||||
body: options.body || "",
|
||||
state: options.event || "COMMENT",
|
||||
};
|
||||
}
|
||||
|
||||
async listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
// GitLab 没有 review 概念,用 notes 模拟
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/notes?sort=asc`,
|
||||
);
|
||||
return results
|
||||
.filter((n) => !(n.system as boolean))
|
||||
.map((n) => {
|
||||
const note = this.mapNote(n);
|
||||
return {
|
||||
id: note.id,
|
||||
body: note.body,
|
||||
state: "COMMENT",
|
||||
user: note.user,
|
||||
created_at: note.created_at,
|
||||
updated_at: note.updated_at,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async deletePullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<void> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
await this.request<void>(
|
||||
"DELETE",
|
||||
`/projects/${project}/merge_requests/${index}/notes/${reviewId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullReviewComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
_reviewId: number,
|
||||
): Promise<PullReviewComment[]> {
|
||||
// GitLab 没有 review 下的 comments 概念,返回所有 diff notes
|
||||
const project = this.encodeProject(owner, repo);
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/notes?sort=asc`,
|
||||
);
|
||||
return results
|
||||
.filter((n) => !!(n.position as Record<string, unknown> | undefined))
|
||||
.map((n) => {
|
||||
const user = (n.author as Record<string, unknown>) || {};
|
||||
const position = (n.position as Record<string, unknown>) || {};
|
||||
return {
|
||||
id: n.id as number,
|
||||
body: n.body as string,
|
||||
path: (position.new_path || position.old_path) as string,
|
||||
position: position.new_line as number,
|
||||
original_position: position.old_line as number,
|
||||
user: { id: user.id as number, login: user.username as string },
|
||||
created_at: n.created_at as string,
|
||||
updated_at: n.updated_at as string,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Reaction 操作 ============
|
||||
|
||||
async getIssueCommentReactions(
|
||||
_owner: string,
|
||||
_repo: string,
|
||||
_commentId: number,
|
||||
): Promise<Reaction[]> {
|
||||
// GitLab: award emoji on notes(需要 noteable_iid,此处简化返回空)
|
||||
return [];
|
||||
}
|
||||
|
||||
async getIssueReactions(owner: string, repo: string, index: number): Promise<Reaction[]> {
|
||||
const project = this.encodeProject(owner, repo);
|
||||
try {
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/projects/${project}/merge_requests/${index}/award_emoji`,
|
||||
);
|
||||
return results.map((r) => {
|
||||
const user = r.user as Record<string, unknown> | undefined;
|
||||
return {
|
||||
user: user ? { id: user.id as number, login: user.username as string } : undefined,
|
||||
content: r.name as string,
|
||||
created_at: r.created_at as string,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
async searchUsers(query: string, limit = 10): Promise<User[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("search", query);
|
||||
params.set("per_page", String(limit));
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/users?${params.toString()}`,
|
||||
);
|
||||
return results.map((u) => ({
|
||||
id: u.id as number,
|
||||
login: u.username as string,
|
||||
full_name: u.name as string,
|
||||
email: u.email as string,
|
||||
avatar_url: u.avatar_url as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: number): Promise<User[]> {
|
||||
// GitLab: group members
|
||||
const results = await this.request<Array<Record<string, unknown>>>(
|
||||
"GET",
|
||||
`/groups/${teamId}/members`,
|
||||
);
|
||||
return results.map((u) => ({
|
||||
id: u.id as number,
|
||||
login: u.username as string,
|
||||
full_name: u.name as string,
|
||||
avatar_url: u.avatar_url as string,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ 映射辅助方法 ============
|
||||
|
||||
protected mapProtection(data: Record<string, unknown>): BranchProtection {
|
||||
const pushAccess = data.push_access_levels as Array<Record<string, unknown>> | undefined;
|
||||
return {
|
||||
branch_name: data.name as string,
|
||||
rule_name: data.name as string,
|
||||
enable_push: pushAccess ? pushAccess.some((l) => (l.access_level as number) > 0) : false,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapMergeRequest(data: Record<string, unknown>): PullRequest {
|
||||
const author = data.author as Record<string, unknown> | undefined;
|
||||
const reviewers = data.reviewers as Array<Record<string, unknown>> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
number: data.iid as number,
|
||||
title: data.title as string,
|
||||
body: data.description as string,
|
||||
state: data.state as string,
|
||||
head: {
|
||||
ref: data.source_branch as string,
|
||||
sha: data.sha as string,
|
||||
},
|
||||
base: {
|
||||
ref: data.target_branch as string,
|
||||
sha: data.diff_refs
|
||||
? ((data.diff_refs as Record<string, unknown>).base_sha as string)
|
||||
: undefined,
|
||||
},
|
||||
user: author ? { id: author.id as number, login: author.username as string } : undefined,
|
||||
requested_reviewers: reviewers?.map((r) => ({
|
||||
id: r.id as number,
|
||||
login: r.username as string,
|
||||
})),
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
merged_at: data.merged_at as string,
|
||||
merge_base: data.merge_commit_sha as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapGitlabCommit(data: Record<string, unknown>): PullRequestCommit {
|
||||
return {
|
||||
sha: (data.id || data.sha) as string,
|
||||
commit: {
|
||||
message: (data.message || data.title) as string,
|
||||
author: {
|
||||
name: data.author_name as string,
|
||||
email: data.author_email as string,
|
||||
date: (data.authored_date || data.created_at) as string,
|
||||
},
|
||||
},
|
||||
author: data.author_name ? { login: data.author_name as string } : undefined,
|
||||
committer: data.committer_name ? { login: data.committer_name as string } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapDiffToChangedFile(data: Record<string, unknown>): ChangedFile {
|
||||
let status = "modified";
|
||||
if (data.new_file) status = "added";
|
||||
else if (data.deleted_file) status = "deleted";
|
||||
else if (data.renamed_file) status = "renamed";
|
||||
const diff = data.diff as string | undefined;
|
||||
// 从 diff 中计算 additions/deletions
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
if (diff) {
|
||||
for (const line of diff.split("\n")) {
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
||||
if (line.startsWith("-") && !line.startsWith("---")) deletions++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
filename: (data.new_path || data.old_path) as string,
|
||||
status,
|
||||
additions,
|
||||
deletions,
|
||||
changes: additions + deletions,
|
||||
patch: diff,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapNote(data: Record<string, unknown>): IssueComment {
|
||||
const author = data.author as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
body: data.body as string,
|
||||
user: author ? { id: author.id as number, login: author.username as string } : undefined,
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapIssue(data: Record<string, unknown>): Issue {
|
||||
const author = data.author as Record<string, unknown> | undefined;
|
||||
const labels = data.labels as string[] | undefined;
|
||||
const assignees = data.assignees as Array<Record<string, unknown>> | undefined;
|
||||
const milestone = data.milestone as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: data.id as number,
|
||||
number: data.iid as number,
|
||||
title: data.title as string,
|
||||
body: data.description as string,
|
||||
state: data.state as string,
|
||||
user: author ? { id: author.id as number, login: author.username as string } : undefined,
|
||||
labels: labels?.map((l) => ({ name: l })),
|
||||
assignees: assignees?.map((a) => ({ id: a.id as number, login: a.username as string })),
|
||||
milestone: milestone
|
||||
? { id: milestone.id as number, title: milestone.title as string }
|
||||
: undefined,
|
||||
created_at: data.created_at as string,
|
||||
updated_at: data.updated_at as string,
|
||||
closed_at: data.closed_at as string,
|
||||
html_url: data.web_url as string,
|
||||
};
|
||||
}
|
||||
|
||||
protected mapStateParam(state?: "open" | "closed" | "all"): string | undefined {
|
||||
if (!state) return undefined;
|
||||
const stateMap: Record<string, string> = {
|
||||
open: "opened",
|
||||
closed: "closed",
|
||||
all: "all",
|
||||
};
|
||||
return stateMap[state] || state;
|
||||
}
|
||||
}
|
||||
3
core/src/shared/git-provider/adapters/index.ts
Normal file
3
core/src/shared/git-provider/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./gitea.adapter";
|
||||
export * from "./github.adapter";
|
||||
export * from "./gitlab.adapter";
|
||||
195
core/src/shared/git-provider/detect-provider.spec.ts
Normal file
195
core/src/shared/git-provider/detect-provider.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { detectProvider } from "./detect-provider";
|
||||
|
||||
describe("detectProvider", () => {
|
||||
// ============ 显式指定 ============
|
||||
|
||||
describe("GIT_PROVIDER_TYPE 显式指定", () => {
|
||||
it("指定 github 时应返回 github", () => {
|
||||
const result = detectProvider({ GIT_PROVIDER_TYPE: "github", GITHUB_TOKEN: "ghp-xxx" });
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.source).toContain("GIT_PROVIDER_TYPE");
|
||||
});
|
||||
|
||||
it("指定 gitea 时应返回 gitea", () => {
|
||||
const result = detectProvider({ GIT_PROVIDER_TYPE: "gitea", GITEA_TOKEN: "tok" });
|
||||
expect(result.provider).toBe("gitea");
|
||||
expect(result.source).toContain("GIT_PROVIDER_TYPE");
|
||||
});
|
||||
|
||||
it("指定 gitlab 时应返回 gitlab", () => {
|
||||
const result = detectProvider({ GIT_PROVIDER_TYPE: "gitlab", GITLAB_TOKEN: "tok" });
|
||||
expect(result.provider).toBe("gitlab");
|
||||
expect(result.source).toContain("GIT_PROVIDER_TYPE");
|
||||
});
|
||||
|
||||
it("指定无效值时应走自动检测", () => {
|
||||
const result = detectProvider({ GIT_PROVIDER_TYPE: "bitbucket" });
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.source).toBe("默认");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GITEA_TOKEN 检测 ============
|
||||
|
||||
describe("GITEA_TOKEN 检测", () => {
|
||||
it("有 GITEA_TOKEN 时应识别为 gitea", () => {
|
||||
const result = detectProvider({
|
||||
GITEA_TOKEN: "tok",
|
||||
GITEA_SERVER_URL: "https://gitea.example.com",
|
||||
});
|
||||
expect(result.provider).toBe("gitea");
|
||||
expect(result.serverUrl).toBe("https://gitea.example.com");
|
||||
expect(result.token).toBe("tok");
|
||||
expect(result.source).toContain("GITEA_TOKEN");
|
||||
});
|
||||
|
||||
it("GITEA_TOKEN 优先于 GITHUB_TOKEN", () => {
|
||||
const result = detectProvider({
|
||||
GITEA_TOKEN: "gitea-tok",
|
||||
GITHUB_TOKEN: "gh-tok",
|
||||
GITHUB_SERVER_URL: "https://github.com",
|
||||
});
|
||||
expect(result.provider).toBe("gitea");
|
||||
expect(result.token).toBe("gitea-tok");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GITLAB_TOKEN / CI_JOB_TOKEN 检测 ============
|
||||
|
||||
describe("GitLab 检测", () => {
|
||||
it("有 GITLAB_TOKEN 时应识别为 gitlab", () => {
|
||||
const result = detectProvider({
|
||||
GITLAB_TOKEN: "glpat-xxx",
|
||||
CI_SERVER_URL: "https://gitlab.example.com",
|
||||
});
|
||||
expect(result.provider).toBe("gitlab");
|
||||
expect(result.serverUrl).toBe("https://gitlab.example.com");
|
||||
expect(result.token).toBe("glpat-xxx");
|
||||
expect(result.source).toContain("GITLAB_TOKEN");
|
||||
});
|
||||
|
||||
it("有 CI_JOB_TOKEN 时应识别为 gitlab", () => {
|
||||
const result = detectProvider({
|
||||
CI_JOB_TOKEN: "ci-tok",
|
||||
CI_SERVER_URL: "https://gitlab.company.com",
|
||||
});
|
||||
expect(result.provider).toBe("gitlab");
|
||||
expect(result.serverUrl).toBe("https://gitlab.company.com");
|
||||
expect(result.token).toBe("ci-tok");
|
||||
expect(result.source).toContain("CI_JOB_TOKEN");
|
||||
});
|
||||
|
||||
it("未指定 CI_SERVER_URL 时应使用默认 gitlab.com", () => {
|
||||
const result = detectProvider({ GITLAB_TOKEN: "glpat-xxx" });
|
||||
expect(result.provider).toBe("gitlab");
|
||||
expect(result.serverUrl).toBe("https://gitlab.com");
|
||||
});
|
||||
|
||||
it("GITEA_TOKEN 优先于 GITLAB_TOKEN", () => {
|
||||
const result = detectProvider({
|
||||
GITEA_TOKEN: "gitea-tok",
|
||||
GITLAB_TOKEN: "gl-tok",
|
||||
});
|
||||
expect(result.provider).toBe("gitea");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GITHUB_TOKEN 检测 ============
|
||||
|
||||
describe("GITHUB_TOKEN 检测", () => {
|
||||
it("GITHUB_TOKEN + github.com 应识别为 github", () => {
|
||||
const result = detectProvider({
|
||||
GITHUB_TOKEN: "ghp-xxx",
|
||||
GITHUB_SERVER_URL: "https://github.com",
|
||||
GITHUB_API_URL: "https://api.github.com",
|
||||
});
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.serverUrl).toBe("https://api.github.com");
|
||||
expect(result.token).toBe("ghp-xxx");
|
||||
expect(result.source).toContain("GITHUB_TOKEN");
|
||||
});
|
||||
|
||||
it("GITHUB_TOKEN + 子域名也应识别为 github", () => {
|
||||
const result = detectProvider({
|
||||
GITHUB_TOKEN: "ghp-xxx",
|
||||
GITHUB_SERVER_URL: "https://enterprise.github.com",
|
||||
});
|
||||
expect(result.provider).toBe("github");
|
||||
});
|
||||
|
||||
it("GITHUB_TOKEN + 自定义域名也应识别为 github", () => {
|
||||
const result = detectProvider({
|
||||
GITHUB_TOKEN: "tok",
|
||||
GITHUB_SERVER_URL: "https://git.example.com",
|
||||
});
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.token).toBe("tok");
|
||||
expect(result.source).toContain("GITHUB_TOKEN");
|
||||
});
|
||||
|
||||
it("GITHUB_TOKEN 无 SERVER_URL 应识别为 github", () => {
|
||||
const result = detectProvider({ GITHUB_TOKEN: "tok" });
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.source).toContain("GITHUB_TOKEN");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 默认 ============
|
||||
|
||||
describe("无任何环境变量", () => {
|
||||
it("应默认为 github", () => {
|
||||
const result = detectProvider({});
|
||||
expect(result.provider).toBe("github");
|
||||
expect(result.serverUrl).toBe("https://api.github.com");
|
||||
expect(result.token).toBe("");
|
||||
expect(result.source).toBe("默认");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GIT_PROVIDER_URL / GIT_PROVIDER_TOKEN 优先级 ============
|
||||
|
||||
describe("GIT_PROVIDER_URL / GIT_PROVIDER_TOKEN 优先级", () => {
|
||||
it("GIT_PROVIDER_URL 应覆盖自动检测的 URL", () => {
|
||||
const result = detectProvider({
|
||||
GITEA_TOKEN: "tok",
|
||||
GITEA_SERVER_URL: "https://gitea.example.com",
|
||||
GIT_PROVIDER_URL: "https://custom.example.com",
|
||||
});
|
||||
expect(result.serverUrl).toBe("https://custom.example.com");
|
||||
});
|
||||
|
||||
it("GIT_PROVIDER_TOKEN 应覆盖自动检测的 token", () => {
|
||||
const result = detectProvider({
|
||||
GITEA_TOKEN: "gitea-tok",
|
||||
GIT_PROVIDER_TOKEN: "custom-tok",
|
||||
});
|
||||
expect(result.token).toBe("custom-tok");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GitHub 默认 API URL ============
|
||||
|
||||
describe("GitHub 默认 API URL", () => {
|
||||
it("未指定 GITHUB_API_URL 时应使用默认值", () => {
|
||||
const result = detectProvider({
|
||||
GIT_PROVIDER_TYPE: "github",
|
||||
GITHUB_TOKEN: "ghp-xxx",
|
||||
});
|
||||
expect(result.serverUrl).toBe("https://api.github.com");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Gitea Actions 场景(显式指定 GIT_PROVIDER_TYPE) ============
|
||||
|
||||
describe("Gitea Actions 场景", () => {
|
||||
it("显式指定 gitea 时应回退到 GITHUB_SERVER_URL", () => {
|
||||
const result = detectProvider({
|
||||
GIT_PROVIDER_TYPE: "gitea",
|
||||
GITHUB_TOKEN: "tok",
|
||||
GITHUB_SERVER_URL: "https://git.bjxgj.com",
|
||||
});
|
||||
expect(result.provider).toBe("gitea");
|
||||
expect(result.serverUrl).toBe("https://git.bjxgj.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
112
core/src/shared/git-provider/detect-provider.ts
Normal file
112
core/src/shared/git-provider/detect-provider.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { GitProviderType } from "./types";
|
||||
|
||||
/** 环境检测结果 */
|
||||
export interface DetectedProviderInfo {
|
||||
/** 检测到的 provider 类型 */
|
||||
provider: GitProviderType;
|
||||
/** 检测到的服务器 URL */
|
||||
serverUrl: string;
|
||||
/** 检测到的 API Token */
|
||||
token: string;
|
||||
/** 检测来源说明 */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从环境变量自动检测 Git Provider 类型和配置
|
||||
*
|
||||
* 检测优先级:
|
||||
* 1. 显式指定 `GIT_PROVIDER_TYPE` 环境变量(最高优先级)
|
||||
* 2. 存在 `GITEA_TOKEN` → Gitea
|
||||
* 3. 存在 `GITLAB_TOKEN` 或 `CI_JOB_TOKEN`(GitLab CI)→ GitLab
|
||||
* 4. 存在 `GITHUB_TOKEN` → GitHub
|
||||
* 5. 默认 → GitHub
|
||||
*/
|
||||
export function detectProvider(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): DetectedProviderInfo {
|
||||
const explicit = env.GIT_PROVIDER_TYPE as GitProviderType | undefined;
|
||||
if (explicit && isValidProvider(explicit)) {
|
||||
return buildResult(explicit, env, "GIT_PROVIDER_TYPE 环境变量");
|
||||
}
|
||||
if (env.GITEA_TOKEN) {
|
||||
return buildResult("gitea", env, "检测到 GITEA_TOKEN");
|
||||
}
|
||||
if (env.GITLAB_TOKEN || env.CI_JOB_TOKEN) {
|
||||
return buildResult(
|
||||
"gitlab",
|
||||
env,
|
||||
env.GITLAB_TOKEN ? "检测到 GITLAB_TOKEN" : "检测到 CI_JOB_TOKEN(GitLab CI)",
|
||||
);
|
||||
}
|
||||
if (env.GITHUB_TOKEN) {
|
||||
return buildResult("github", env, "检测到 GITHUB_TOKEN");
|
||||
}
|
||||
return buildResult("github", env, "默认");
|
||||
}
|
||||
|
||||
/** 检查是否为有效的 provider 类型 */
|
||||
function isValidProvider(value: string): value is GitProviderType {
|
||||
return value === "gitea" || value === "github" || value === "gitlab";
|
||||
}
|
||||
|
||||
/** 根据 provider 类型从环境变量中提取 serverUrl 和 token */
|
||||
function buildResult(
|
||||
provider: GitProviderType,
|
||||
env: Record<string, string | undefined>,
|
||||
source: string,
|
||||
): DetectedProviderInfo {
|
||||
const serverUrl = resolveServerUrl(provider, env);
|
||||
const token = resolveToken(provider, env);
|
||||
return { provider, serverUrl, token, source };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析服务器 URL
|
||||
* - 优先使用 GIT_PROVIDER_URL
|
||||
* - Gitea: GITEA_SERVER_URL > GITHUB_SERVER_URL
|
||||
* - GitHub: GITHUB_API_URL > 默认 https://api.github.com
|
||||
* - GitLab: CI_SERVER_URL > GITLAB_URL
|
||||
*/
|
||||
function resolveServerUrl(
|
||||
provider: GitProviderType,
|
||||
env: Record<string, string | undefined>,
|
||||
): string {
|
||||
if (env.GIT_PROVIDER_URL) {
|
||||
return env.GIT_PROVIDER_URL;
|
||||
}
|
||||
if (provider === "github") {
|
||||
return env.GITHUB_API_URL || "https://api.github.com";
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return env.CI_SERVER_URL || env.GITLAB_URL || "https://gitlab.com";
|
||||
}
|
||||
// Gitea: 优先 GITEA_SERVER_URL,其次从 GITHUB_SERVER_URL 推导
|
||||
if (env.GITEA_SERVER_URL) {
|
||||
return env.GITEA_SERVER_URL;
|
||||
}
|
||||
if (env.GITHUB_SERVER_URL) {
|
||||
return env.GITHUB_SERVER_URL;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 API Token
|
||||
* - 优先使用 GIT_PROVIDER_TOKEN
|
||||
* - Gitea: GITEA_TOKEN > GITHUB_TOKEN
|
||||
* - GitHub: GITHUB_TOKEN
|
||||
* - GitLab: GITLAB_TOKEN > CI_JOB_TOKEN
|
||||
*/
|
||||
function resolveToken(provider: GitProviderType, env: Record<string, string | undefined>): string {
|
||||
if (env.GIT_PROVIDER_TOKEN) {
|
||||
return env.GIT_PROVIDER_TOKEN;
|
||||
}
|
||||
if (provider === "github") {
|
||||
return env.GITHUB_TOKEN || "";
|
||||
}
|
||||
if (provider === "gitlab") {
|
||||
return env.GITLAB_TOKEN || env.CI_JOB_TOKEN || "";
|
||||
}
|
||||
return env.GITEA_TOKEN || env.GITHUB_TOKEN || "";
|
||||
}
|
||||
188
core/src/shared/git-provider/git-provider.interface.ts
Normal file
188
core/src/shared/git-provider/git-provider.interface.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
BranchProtection,
|
||||
CreateBranchProtectionOption,
|
||||
EditBranchProtectionOption,
|
||||
Branch,
|
||||
Repository,
|
||||
PullRequest,
|
||||
PullRequestCommit,
|
||||
ChangedFile,
|
||||
CommitInfo,
|
||||
IssueComment,
|
||||
CreateIssueCommentOption,
|
||||
CreateIssueOption,
|
||||
Issue,
|
||||
CreatePullReviewOption,
|
||||
PullReview,
|
||||
PullReviewComment,
|
||||
Reaction,
|
||||
EditPullRequestOption,
|
||||
User,
|
||||
RepositoryContent,
|
||||
} from "./types";
|
||||
|
||||
/** PR 列表查询选项 */
|
||||
export interface ListPullRequestsOptions {
|
||||
state?: "open" | "closed" | "all";
|
||||
sort?: "oldest" | "recentupdate" | "leastupdate" | "mostcomment" | "leastcomment" | "priority";
|
||||
milestone?: number;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
/** 锁定分支选项 */
|
||||
export interface LockBranchOptions {
|
||||
/** 允许推送的用户名白名单(如 CI 机器人) */
|
||||
pushWhitelistUsernames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Provider 抽象接口
|
||||
* 定义所有 Git 托管平台需要实现的通用操作
|
||||
*/
|
||||
export interface GitProvider {
|
||||
// ============ 配置验证 ============
|
||||
/** 验证配置是否完整 */
|
||||
validateConfig(): void;
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
/** 获取仓库信息 */
|
||||
getRepository(owner: string, repo: string): Promise<Repository>;
|
||||
|
||||
// ============ 分支操作 ============
|
||||
/** 获取分支信息 */
|
||||
getBranch(owner: string, repo: string, branch: string): Promise<Branch>;
|
||||
|
||||
// ============ 分支保护 ============
|
||||
/** 列出仓库的所有分支保护规则 */
|
||||
listBranchProtections(owner: string, repo: string): Promise<BranchProtection[]>;
|
||||
/** 获取特定分支保护规则 */
|
||||
getBranchProtection(owner: string, repo: string, name: string): Promise<BranchProtection>;
|
||||
/** 创建分支保护规则 */
|
||||
createBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: CreateBranchProtectionOption,
|
||||
): Promise<BranchProtection>;
|
||||
/** 编辑分支保护规则 */
|
||||
editBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
options: EditBranchProtectionOption,
|
||||
): Promise<BranchProtection>;
|
||||
/** 删除分支保护规则 */
|
||||
deleteBranchProtection(owner: string, repo: string, name: string): Promise<void>;
|
||||
/** 锁定分支 - 禁止推送(可配置白名单用户) */
|
||||
lockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
options?: LockBranchOptions,
|
||||
): Promise<BranchProtection>;
|
||||
/** 解锁分支 - 允许推送 */
|
||||
unlockBranch(owner: string, repo: string, branch: string): Promise<BranchProtection | null>;
|
||||
/** 同步解锁分支(用于进程退出时的清理) */
|
||||
unlockBranchSync(owner: string, repo: string, branch: string): void;
|
||||
|
||||
// ============ Pull Request 操作 ============
|
||||
/** 获取 Pull Request 信息 */
|
||||
getPullRequest(owner: string, repo: string, index: number): Promise<PullRequest>;
|
||||
/** 编辑 Pull Request */
|
||||
editPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: EditPullRequestOption,
|
||||
): Promise<PullRequest>;
|
||||
/** 列出仓库的 Pull Request */
|
||||
listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state?: "open" | "closed" | "all",
|
||||
): Promise<PullRequest[]>;
|
||||
/** 列出仓库的所有 Pull Request(支持分页获取全部) */
|
||||
listAllPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: ListPullRequestsOptions,
|
||||
): Promise<PullRequest[]>;
|
||||
/** 获取 PR 的 commits */
|
||||
getPullRequestCommits(owner: string, repo: string, index: number): Promise<PullRequestCommit[]>;
|
||||
/** 获取 PR 的文件变更 */
|
||||
getPullRequestFiles(owner: string, repo: string, index: number): Promise<ChangedFile[]>;
|
||||
/** 获取 PR 的 diff */
|
||||
getPullRequestDiff(owner: string, repo: string, index: number): Promise<string>;
|
||||
|
||||
// ============ Commit 操作 ============
|
||||
/** 获取单个 commit 信息 */
|
||||
getCommit(owner: string, repo: string, sha: string): Promise<CommitInfo>;
|
||||
/** 获取两个 ref 之间的 diff */
|
||||
getCompareDiff(owner: string, repo: string, baseSha: string, headSha: string): Promise<string>;
|
||||
/** 获取单个 commit 的 diff */
|
||||
getCommitDiff(owner: string, repo: string, sha: string): Promise<string>;
|
||||
|
||||
// ============ 文件操作 ============
|
||||
/** 获取文件内容 */
|
||||
getFileContent(owner: string, repo: string, filepath: string, ref?: string): Promise<string>;
|
||||
/** 列出仓库目录下的文件和子目录 */
|
||||
listRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path?: string,
|
||||
ref?: string,
|
||||
): Promise<RepositoryContent[]>;
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
/** 创建 Issue */
|
||||
createIssue(owner: string, repo: string, options: CreateIssueOption): Promise<Issue>;
|
||||
/** 列出 Issue/PR 的评论 */
|
||||
listIssueComments(owner: string, repo: string, index: number): Promise<IssueComment[]>;
|
||||
/** 创建 Issue/PR 评论 */
|
||||
createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreateIssueCommentOption,
|
||||
): Promise<IssueComment>;
|
||||
/** 更新 Issue/PR 评论 */
|
||||
updateIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
body: string,
|
||||
): Promise<IssueComment>;
|
||||
/** 删除 Issue/PR 评论 */
|
||||
deleteIssueComment(owner: string, repo: string, commentId: number): Promise<void>;
|
||||
|
||||
// ============ PR Review 操作 ============
|
||||
/** 创建 PR Review */
|
||||
createPullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreatePullReviewOption,
|
||||
): Promise<PullReview>;
|
||||
/** 列出 PR 的所有 Reviews */
|
||||
listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]>;
|
||||
/** 删除 PR Review */
|
||||
deletePullReview(owner: string, repo: string, index: number, reviewId: number): Promise<void>;
|
||||
/** 获取 PR Review 的行级评论列表 */
|
||||
listPullReviewComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<PullReviewComment[]>;
|
||||
|
||||
// ============ Reaction 操作 ============
|
||||
/** 获取 Issue/PR 评论的 reactions */
|
||||
getIssueCommentReactions(owner: string, repo: string, commentId: number): Promise<Reaction[]>;
|
||||
/** 获取 Issue/PR 的 reactions */
|
||||
getIssueReactions(owner: string, repo: string, index: number): Promise<Reaction[]>;
|
||||
|
||||
// ============ 用户操作 ============
|
||||
/** 搜索用户 */
|
||||
searchUsers(query: string, limit?: number): Promise<User[]>;
|
||||
/** 获取团队成员列表 */
|
||||
getTeamMembers(teamId: number): Promise<User[]>;
|
||||
}
|
||||
73
core/src/shared/git-provider/git-provider.module.ts
Normal file
73
core/src/shared/git-provider/git-provider.module.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DynamicModule, Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { GitProviderService } from "./git-provider.service";
|
||||
import {
|
||||
type GitProviderModuleOptions,
|
||||
type GitProviderModuleAsyncOptions,
|
||||
GIT_PROVIDER_MODULE_OPTIONS,
|
||||
} from "./types";
|
||||
import { gitProviderConfig, type GitProviderConfig } from "../../config/git-provider.config";
|
||||
|
||||
@Module({})
|
||||
export class GitProviderModule {
|
||||
/**
|
||||
* 同步注册模块
|
||||
*/
|
||||
static forRoot(options: GitProviderModuleOptions): DynamicModule {
|
||||
return {
|
||||
module: GitProviderModule,
|
||||
providers: [
|
||||
{
|
||||
provide: GIT_PROVIDER_MODULE_OPTIONS,
|
||||
useValue: options,
|
||||
},
|
||||
GitProviderService,
|
||||
],
|
||||
exports: [GitProviderService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步注册模块 - 支持从环境变量等动态获取配置
|
||||
*/
|
||||
static forRootAsync(options: GitProviderModuleAsyncOptions): DynamicModule {
|
||||
return {
|
||||
module: GitProviderModule,
|
||||
providers: [
|
||||
{
|
||||
provide: GIT_PROVIDER_MODULE_OPTIONS,
|
||||
useFactory: options.useFactory,
|
||||
inject: options.inject || [],
|
||||
},
|
||||
GitProviderService,
|
||||
],
|
||||
exports: [GitProviderService],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ConfigService 注册模块
|
||||
*/
|
||||
static forFeature(): DynamicModule {
|
||||
return {
|
||||
module: GitProviderModule,
|
||||
imports: [ConfigModule.forFeature(gitProviderConfig)],
|
||||
providers: [
|
||||
{
|
||||
provide: GIT_PROVIDER_MODULE_OPTIONS,
|
||||
useFactory: (configService: ConfigService): GitProviderModuleOptions => {
|
||||
const config = configService.get<GitProviderConfig>("gitProvider");
|
||||
return {
|
||||
provider: config?.provider || "gitea",
|
||||
baseUrl: config?.serverUrl || "",
|
||||
token: config?.token || "",
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
GitProviderService,
|
||||
],
|
||||
exports: [GitProviderService],
|
||||
};
|
||||
}
|
||||
}
|
||||
282
core/src/shared/git-provider/git-provider.service.spec.ts
Normal file
282
core/src/shared/git-provider/git-provider.service.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { vi } from "vitest";
|
||||
import { GitProviderService } from "./git-provider.service";
|
||||
import type { GitProviderModuleOptions } from "./types";
|
||||
import { GiteaAdapter } from "./adapters/gitea.adapter";
|
||||
import { GithubAdapter } from "./adapters/github.adapter";
|
||||
import { GitlabAdapter } from "./adapters/gitlab.adapter";
|
||||
|
||||
/** 直接实例化 service(绕过 NestJS DI) */
|
||||
function createService(options: GitProviderModuleOptions): GitProviderService {
|
||||
return new (GitProviderService as any)(options);
|
||||
}
|
||||
|
||||
describe("GitProviderService", () => {
|
||||
describe("createAdapter", () => {
|
||||
it("provider=gitea 时应创建 GiteaAdapter", () => {
|
||||
const service = createService({ provider: "gitea", baseUrl: "https://g.com", token: "t" });
|
||||
expect((service as any).adapter).toBeInstanceOf(GiteaAdapter);
|
||||
});
|
||||
|
||||
it("provider=github 时应创建 GithubAdapter", () => {
|
||||
const service = createService({
|
||||
provider: "github",
|
||||
baseUrl: "https://api.github.com",
|
||||
token: "t",
|
||||
});
|
||||
expect((service as any).adapter).toBeInstanceOf(GithubAdapter);
|
||||
});
|
||||
|
||||
it("provider=gitlab 时应创建 GitlabAdapter", () => {
|
||||
const service = createService({
|
||||
provider: "gitlab",
|
||||
baseUrl: "https://gitlab.com",
|
||||
token: "t",
|
||||
});
|
||||
expect((service as any).adapter).toBeInstanceOf(GitlabAdapter);
|
||||
});
|
||||
|
||||
it("不支持的 provider 应抛出异常", () => {
|
||||
expect(() =>
|
||||
createService({ provider: "bitbucket" as any, baseUrl: "x", token: "t" }),
|
||||
).toThrow("不支持的 Git Provider 类型: bitbucket");
|
||||
});
|
||||
});
|
||||
|
||||
describe("代理方法", () => {
|
||||
let service: GitProviderService;
|
||||
let mockAdapter: Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = createService({ provider: "gitea", baseUrl: "https://g.com", token: "t" });
|
||||
mockAdapter = {
|
||||
validateConfig: vi.fn(),
|
||||
getRepository: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
getBranch: vi.fn().mockResolvedValue({ name: "main" }),
|
||||
listBranchProtections: vi.fn().mockResolvedValue([]),
|
||||
getBranchProtection: vi.fn().mockResolvedValue({}),
|
||||
createBranchProtection: vi.fn().mockResolvedValue({}),
|
||||
editBranchProtection: vi.fn().mockResolvedValue({}),
|
||||
deleteBranchProtection: vi.fn().mockResolvedValue(undefined),
|
||||
lockBranch: vi.fn().mockResolvedValue({}),
|
||||
unlockBranch: vi.fn().mockResolvedValue(null),
|
||||
unlockBranchSync: vi.fn(),
|
||||
getPullRequest: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
editPullRequest: vi.fn().mockResolvedValue({}),
|
||||
listPullRequests: vi.fn().mockResolvedValue([]),
|
||||
listAllPullRequests: vi.fn().mockResolvedValue([]),
|
||||
getPullRequestCommits: vi.fn().mockResolvedValue([]),
|
||||
getPullRequestFiles: vi.fn().mockResolvedValue([]),
|
||||
getPullRequestDiff: vi.fn().mockResolvedValue("diff"),
|
||||
getCommit: vi.fn().mockResolvedValue({}),
|
||||
getCompareDiff: vi.fn().mockResolvedValue("diff"),
|
||||
getCommitDiff: vi.fn().mockResolvedValue("diff"),
|
||||
getFileContent: vi.fn().mockResolvedValue("content"),
|
||||
createIssue: vi.fn().mockResolvedValue({}),
|
||||
listIssueComments: vi.fn().mockResolvedValue([]),
|
||||
createIssueComment: vi.fn().mockResolvedValue({}),
|
||||
updateIssueComment: vi.fn().mockResolvedValue({}),
|
||||
deleteIssueComment: vi.fn().mockResolvedValue(undefined),
|
||||
createPullReview: vi.fn().mockResolvedValue({}),
|
||||
listPullReviews: vi.fn().mockResolvedValue([]),
|
||||
deletePullReview: vi.fn().mockResolvedValue(undefined),
|
||||
listPullReviewComments: vi.fn().mockResolvedValue([]),
|
||||
getIssueCommentReactions: vi.fn().mockResolvedValue([]),
|
||||
getIssueReactions: vi.fn().mockResolvedValue([]),
|
||||
searchUsers: vi.fn().mockResolvedValue([]),
|
||||
getTeamMembers: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
(service as any).adapter = mockAdapter;
|
||||
});
|
||||
|
||||
it("validateConfig 应代理到 adapter", () => {
|
||||
service.validateConfig();
|
||||
expect(mockAdapter.validateConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getRepository 应代理到 adapter", async () => {
|
||||
const result = await service.getRepository("o", "r");
|
||||
expect(mockAdapter.getRepository).toHaveBeenCalledWith("o", "r");
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it("getPullRequest 应代理到 adapter", async () => {
|
||||
await service.getPullRequest("o", "r", 42);
|
||||
expect(mockAdapter.getPullRequest).toHaveBeenCalledWith("o", "r", 42);
|
||||
});
|
||||
|
||||
it("lockBranch 应代理到 adapter", async () => {
|
||||
await service.lockBranch("o", "r", "main", { pushWhitelistUsernames: ["bot"] });
|
||||
expect(mockAdapter.lockBranch).toHaveBeenCalledWith("o", "r", "main", {
|
||||
pushWhitelistUsernames: ["bot"],
|
||||
});
|
||||
});
|
||||
|
||||
it("unlockBranchSync 应代理到 adapter", () => {
|
||||
service.unlockBranchSync("o", "r", "main");
|
||||
expect(mockAdapter.unlockBranchSync).toHaveBeenCalledWith("o", "r", "main");
|
||||
});
|
||||
|
||||
it("createPullReview 应代理到 adapter", async () => {
|
||||
const opts = { event: "COMMENT" as const, body: "ok" };
|
||||
await service.createPullReview("o", "r", 1, opts);
|
||||
expect(mockAdapter.createPullReview).toHaveBeenCalledWith("o", "r", 1, opts);
|
||||
});
|
||||
|
||||
it("getFileContent 应代理到 adapter", async () => {
|
||||
const result = await service.getFileContent("o", "r", "a.ts", "main");
|
||||
expect(mockAdapter.getFileContent).toHaveBeenCalledWith("o", "r", "a.ts", "main");
|
||||
expect(result).toBe("content");
|
||||
});
|
||||
|
||||
it("searchUsers 应代理到 adapter", async () => {
|
||||
await service.searchUsers("test", 5);
|
||||
expect(mockAdapter.searchUsers).toHaveBeenCalledWith("test", 5);
|
||||
});
|
||||
|
||||
it("getTeamMembers 应代理到 adapter", async () => {
|
||||
await service.getTeamMembers(99);
|
||||
expect(mockAdapter.getTeamMembers).toHaveBeenCalledWith(99);
|
||||
});
|
||||
|
||||
it("getBranch 应代理到 adapter", async () => {
|
||||
await service.getBranch("o", "r", "main");
|
||||
expect(mockAdapter.getBranch).toHaveBeenCalledWith("o", "r", "main");
|
||||
});
|
||||
|
||||
it("listBranchProtections 应代理到 adapter", async () => {
|
||||
await service.listBranchProtections("o", "r");
|
||||
expect(mockAdapter.listBranchProtections).toHaveBeenCalledWith("o", "r");
|
||||
});
|
||||
|
||||
it("getBranchProtection 应代理到 adapter", async () => {
|
||||
await service.getBranchProtection("o", "r", "main");
|
||||
expect(mockAdapter.getBranchProtection).toHaveBeenCalledWith("o", "r", "main");
|
||||
});
|
||||
|
||||
it("createBranchProtection 应代理到 adapter", async () => {
|
||||
const opts = { branchName: "main" };
|
||||
await service.createBranchProtection("o", "r", opts as any);
|
||||
expect(mockAdapter.createBranchProtection).toHaveBeenCalledWith("o", "r", opts);
|
||||
});
|
||||
|
||||
it("editBranchProtection 应代理到 adapter", async () => {
|
||||
const opts = { enablePush: true };
|
||||
await service.editBranchProtection("o", "r", "main", opts as any);
|
||||
expect(mockAdapter.editBranchProtection).toHaveBeenCalledWith("o", "r", "main", opts);
|
||||
});
|
||||
|
||||
it("deleteBranchProtection 应代理到 adapter", async () => {
|
||||
await service.deleteBranchProtection("o", "r", "main");
|
||||
expect(mockAdapter.deleteBranchProtection).toHaveBeenCalledWith("o", "r", "main");
|
||||
});
|
||||
|
||||
it("unlockBranch 应代理到 adapter", async () => {
|
||||
await service.unlockBranch("o", "r", "main");
|
||||
expect(mockAdapter.unlockBranch).toHaveBeenCalledWith("o", "r", "main");
|
||||
});
|
||||
|
||||
it("editPullRequest 应代理到 adapter", async () => {
|
||||
const opts = { title: "new" };
|
||||
await service.editPullRequest("o", "r", 1, opts as any);
|
||||
expect(mockAdapter.editPullRequest).toHaveBeenCalledWith("o", "r", 1, opts);
|
||||
});
|
||||
|
||||
it("listPullRequests 应代理到 adapter", async () => {
|
||||
await service.listPullRequests("o", "r", "open");
|
||||
expect(mockAdapter.listPullRequests).toHaveBeenCalledWith("o", "r", "open");
|
||||
});
|
||||
|
||||
it("listAllPullRequests 应代理到 adapter", async () => {
|
||||
await service.listAllPullRequests("o", "r", { state: "open" } as any);
|
||||
expect(mockAdapter.listAllPullRequests).toHaveBeenCalledWith("o", "r", { state: "open" });
|
||||
});
|
||||
|
||||
it("getPullRequestCommits 应代理到 adapter", async () => {
|
||||
await service.getPullRequestCommits("o", "r", 1);
|
||||
expect(mockAdapter.getPullRequestCommits).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("getPullRequestFiles 应代理到 adapter", async () => {
|
||||
await service.getPullRequestFiles("o", "r", 1);
|
||||
expect(mockAdapter.getPullRequestFiles).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("getPullRequestDiff 应代理到 adapter", async () => {
|
||||
await service.getPullRequestDiff("o", "r", 1);
|
||||
expect(mockAdapter.getPullRequestDiff).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("getCommit 应代理到 adapter", async () => {
|
||||
await service.getCommit("o", "r", "abc");
|
||||
expect(mockAdapter.getCommit).toHaveBeenCalledWith("o", "r", "abc");
|
||||
});
|
||||
|
||||
it("getCompareDiff 应代理到 adapter", async () => {
|
||||
await service.getCompareDiff("o", "r", "a", "b");
|
||||
expect(mockAdapter.getCompareDiff).toHaveBeenCalledWith("o", "r", "a", "b");
|
||||
});
|
||||
|
||||
it("getCommitDiff 应代理到 adapter", async () => {
|
||||
await service.getCommitDiff("o", "r", "abc");
|
||||
expect(mockAdapter.getCommitDiff).toHaveBeenCalledWith("o", "r", "abc");
|
||||
});
|
||||
|
||||
it("listRepositoryContents 应代理到 adapter", async () => {
|
||||
mockAdapter.listRepositoryContents = vi.fn().mockResolvedValue([]);
|
||||
await service.listRepositoryContents("o", "r", "src", "main");
|
||||
expect(mockAdapter.listRepositoryContents).toHaveBeenCalledWith("o", "r", "src", "main");
|
||||
});
|
||||
|
||||
it("createIssue 应代理到 adapter", async () => {
|
||||
const opts = { title: "bug" };
|
||||
await service.createIssue("o", "r", opts as any);
|
||||
expect(mockAdapter.createIssue).toHaveBeenCalledWith("o", "r", opts);
|
||||
});
|
||||
|
||||
it("listIssueComments 应代理到 adapter", async () => {
|
||||
await service.listIssueComments("o", "r", 1);
|
||||
expect(mockAdapter.listIssueComments).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("createIssueComment 应代理到 adapter", async () => {
|
||||
const opts = { body: "comment" };
|
||||
await service.createIssueComment("o", "r", 1, opts as any);
|
||||
expect(mockAdapter.createIssueComment).toHaveBeenCalledWith("o", "r", 1, opts);
|
||||
});
|
||||
|
||||
it("updateIssueComment 应代理到 adapter", async () => {
|
||||
await service.updateIssueComment("o", "r", 1, "updated");
|
||||
expect(mockAdapter.updateIssueComment).toHaveBeenCalledWith("o", "r", 1, "updated");
|
||||
});
|
||||
|
||||
it("deleteIssueComment 应代理到 adapter", async () => {
|
||||
await service.deleteIssueComment("o", "r", 1);
|
||||
expect(mockAdapter.deleteIssueComment).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("listPullReviews 应代理到 adapter", async () => {
|
||||
await service.listPullReviews("o", "r", 1);
|
||||
expect(mockAdapter.listPullReviews).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("deletePullReview 应代理到 adapter", async () => {
|
||||
await service.deletePullReview("o", "r", 1, 2);
|
||||
expect(mockAdapter.deletePullReview).toHaveBeenCalledWith("o", "r", 1, 2);
|
||||
});
|
||||
|
||||
it("listPullReviewComments 应代理到 adapter", async () => {
|
||||
await service.listPullReviewComments("o", "r", 1, 2);
|
||||
expect(mockAdapter.listPullReviewComments).toHaveBeenCalledWith("o", "r", 1, 2);
|
||||
});
|
||||
|
||||
it("getIssueCommentReactions 应代理到 adapter", async () => {
|
||||
await service.getIssueCommentReactions("o", "r", 1);
|
||||
expect(mockAdapter.getIssueCommentReactions).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
|
||||
it("getIssueReactions 应代理到 adapter", async () => {
|
||||
await service.getIssueReactions("o", "r", 1);
|
||||
expect(mockAdapter.getIssueReactions).toHaveBeenCalledWith("o", "r", 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
309
core/src/shared/git-provider/git-provider.service.ts
Normal file
309
core/src/shared/git-provider/git-provider.service.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import type {
|
||||
GitProvider,
|
||||
LockBranchOptions,
|
||||
ListPullRequestsOptions,
|
||||
} from "./git-provider.interface";
|
||||
import {
|
||||
type GitProviderModuleOptions,
|
||||
GIT_PROVIDER_MODULE_OPTIONS,
|
||||
type BranchProtection,
|
||||
type CreateBranchProtectionOption,
|
||||
type EditBranchProtectionOption,
|
||||
type Branch,
|
||||
type Repository,
|
||||
type PullRequest,
|
||||
type PullRequestCommit,
|
||||
type ChangedFile,
|
||||
type CommitInfo,
|
||||
type IssueComment,
|
||||
type CreateIssueCommentOption,
|
||||
type CreateIssueOption,
|
||||
type Issue,
|
||||
type CreatePullReviewOption,
|
||||
type PullReview,
|
||||
type PullReviewComment,
|
||||
type Reaction,
|
||||
type EditPullRequestOption,
|
||||
type User,
|
||||
type RepositoryContent,
|
||||
} from "./types";
|
||||
import { GiteaAdapter } from "./adapters/gitea.adapter";
|
||||
import { GithubAdapter } from "./adapters/github.adapter";
|
||||
import { GitlabAdapter } from "./adapters/gitlab.adapter";
|
||||
|
||||
/**
|
||||
* Git Provider 统一服务
|
||||
* 根据配置的 provider 类型代理到对应的适配器实现
|
||||
*/
|
||||
@Injectable()
|
||||
export class GitProviderService implements GitProvider {
|
||||
protected readonly adapter: GitProvider;
|
||||
|
||||
constructor(
|
||||
@Inject(GIT_PROVIDER_MODULE_OPTIONS) protected readonly options: GitProviderModuleOptions,
|
||||
) {
|
||||
this.adapter = this.createAdapter(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 provider 类型创建对应的适配器
|
||||
*/
|
||||
protected createAdapter(options: GitProviderModuleOptions): GitProvider {
|
||||
switch (options.provider) {
|
||||
case "gitea":
|
||||
return new GiteaAdapter(options);
|
||||
case "github":
|
||||
return new GithubAdapter(options);
|
||||
case "gitlab":
|
||||
return new GitlabAdapter(options);
|
||||
default:
|
||||
throw new Error(`不支持的 Git Provider 类型: ${options.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 配置验证 ============
|
||||
|
||||
validateConfig(): void {
|
||||
this.adapter.validateConfig();
|
||||
}
|
||||
|
||||
// ============ 仓库操作 ============
|
||||
|
||||
async getRepository(owner: string, repo: string): Promise<Repository> {
|
||||
return this.adapter.getRepository(owner, repo);
|
||||
}
|
||||
|
||||
// ============ 分支操作 ============
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string): Promise<Branch> {
|
||||
return this.adapter.getBranch(owner, repo, branch);
|
||||
}
|
||||
|
||||
// ============ 分支保护 ============
|
||||
|
||||
async listBranchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
|
||||
return this.adapter.listBranchProtections(owner, repo);
|
||||
}
|
||||
|
||||
async getBranchProtection(owner: string, repo: string, name: string): Promise<BranchProtection> {
|
||||
return this.adapter.getBranchProtection(owner, repo, name);
|
||||
}
|
||||
|
||||
async createBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options: CreateBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
return this.adapter.createBranchProtection(owner, repo, options);
|
||||
}
|
||||
|
||||
async editBranchProtection(
|
||||
owner: string,
|
||||
repo: string,
|
||||
name: string,
|
||||
options: EditBranchProtectionOption,
|
||||
): Promise<BranchProtection> {
|
||||
return this.adapter.editBranchProtection(owner, repo, name, options);
|
||||
}
|
||||
|
||||
async deleteBranchProtection(owner: string, repo: string, name: string): Promise<void> {
|
||||
return this.adapter.deleteBranchProtection(owner, repo, name);
|
||||
}
|
||||
|
||||
async lockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
options?: LockBranchOptions,
|
||||
): Promise<BranchProtection> {
|
||||
return this.adapter.lockBranch(owner, repo, branch, options);
|
||||
}
|
||||
|
||||
async unlockBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<BranchProtection | null> {
|
||||
return this.adapter.unlockBranch(owner, repo, branch);
|
||||
}
|
||||
|
||||
unlockBranchSync(owner: string, repo: string, branch: string): void {
|
||||
this.adapter.unlockBranchSync(owner, repo, branch);
|
||||
}
|
||||
|
||||
// ============ Pull Request 操作 ============
|
||||
|
||||
async getPullRequest(owner: string, repo: string, index: number): Promise<PullRequest> {
|
||||
return this.adapter.getPullRequest(owner, repo, index);
|
||||
}
|
||||
|
||||
async editPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: EditPullRequestOption,
|
||||
): Promise<PullRequest> {
|
||||
return this.adapter.editPullRequest(owner, repo, index, options);
|
||||
}
|
||||
|
||||
async listPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
state?: "open" | "closed" | "all",
|
||||
): Promise<PullRequest[]> {
|
||||
return this.adapter.listPullRequests(owner, repo, state);
|
||||
}
|
||||
|
||||
async listAllPullRequests(
|
||||
owner: string,
|
||||
repo: string,
|
||||
options?: ListPullRequestsOptions,
|
||||
): Promise<PullRequest[]> {
|
||||
return this.adapter.listAllPullRequests(owner, repo, options);
|
||||
}
|
||||
|
||||
async getPullRequestCommits(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
): Promise<PullRequestCommit[]> {
|
||||
return this.adapter.getPullRequestCommits(owner, repo, index);
|
||||
}
|
||||
|
||||
async getPullRequestFiles(owner: string, repo: string, index: number): Promise<ChangedFile[]> {
|
||||
return this.adapter.getPullRequestFiles(owner, repo, index);
|
||||
}
|
||||
|
||||
async getPullRequestDiff(owner: string, repo: string, index: number): Promise<string> {
|
||||
return this.adapter.getPullRequestDiff(owner, repo, index);
|
||||
}
|
||||
|
||||
// ============ Commit 操作 ============
|
||||
|
||||
async getCommit(owner: string, repo: string, sha: string): Promise<CommitInfo> {
|
||||
return this.adapter.getCommit(owner, repo, sha);
|
||||
}
|
||||
|
||||
async getCompareDiff(
|
||||
owner: string,
|
||||
repo: string,
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
): Promise<string> {
|
||||
return this.adapter.getCompareDiff(owner, repo, baseSha, headSha);
|
||||
}
|
||||
|
||||
async getCommitDiff(owner: string, repo: string, sha: string): Promise<string> {
|
||||
return this.adapter.getCommitDiff(owner, repo, sha);
|
||||
}
|
||||
|
||||
// ============ 文件操作 ============
|
||||
|
||||
async getFileContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filepath: string,
|
||||
ref?: string,
|
||||
): Promise<string> {
|
||||
return this.adapter.getFileContent(owner, repo, filepath, ref);
|
||||
}
|
||||
|
||||
async listRepositoryContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path?: string,
|
||||
ref?: string,
|
||||
): Promise<RepositoryContent[]> {
|
||||
return this.adapter.listRepositoryContents(owner, repo, path, ref);
|
||||
}
|
||||
|
||||
// ============ Issue 操作 ============
|
||||
|
||||
async createIssue(owner: string, repo: string, options: CreateIssueOption): Promise<Issue> {
|
||||
return this.adapter.createIssue(owner, repo, options);
|
||||
}
|
||||
|
||||
async listIssueComments(owner: string, repo: string, index: number): Promise<IssueComment[]> {
|
||||
return this.adapter.listIssueComments(owner, repo, index);
|
||||
}
|
||||
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreateIssueCommentOption,
|
||||
): Promise<IssueComment> {
|
||||
return this.adapter.createIssueComment(owner, repo, index, options);
|
||||
}
|
||||
|
||||
async updateIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
body: string,
|
||||
): Promise<IssueComment> {
|
||||
return this.adapter.updateIssueComment(owner, repo, commentId, body);
|
||||
}
|
||||
|
||||
async deleteIssueComment(owner: string, repo: string, commentId: number): Promise<void> {
|
||||
return this.adapter.deleteIssueComment(owner, repo, commentId);
|
||||
}
|
||||
|
||||
// ============ PR Review 操作 ============
|
||||
|
||||
async createPullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
options: CreatePullReviewOption,
|
||||
): Promise<PullReview> {
|
||||
return this.adapter.createPullReview(owner, repo, index, options);
|
||||
}
|
||||
|
||||
async listPullReviews(owner: string, repo: string, index: number): Promise<PullReview[]> {
|
||||
return this.adapter.listPullReviews(owner, repo, index);
|
||||
}
|
||||
|
||||
async deletePullReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<void> {
|
||||
return this.adapter.deletePullReview(owner, repo, index, reviewId);
|
||||
}
|
||||
|
||||
async listPullReviewComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
index: number,
|
||||
reviewId: number,
|
||||
): Promise<PullReviewComment[]> {
|
||||
return this.adapter.listPullReviewComments(owner, repo, index, reviewId);
|
||||
}
|
||||
|
||||
// ============ Reaction 操作 ============
|
||||
|
||||
async getIssueCommentReactions(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
): Promise<Reaction[]> {
|
||||
return this.adapter.getIssueCommentReactions(owner, repo, commentId);
|
||||
}
|
||||
|
||||
async getIssueReactions(owner: string, repo: string, index: number): Promise<Reaction[]> {
|
||||
return this.adapter.getIssueReactions(owner, repo, index);
|
||||
}
|
||||
|
||||
// ============ 用户操作 ============
|
||||
|
||||
async searchUsers(query: string, limit?: number): Promise<User[]> {
|
||||
return this.adapter.searchUsers(query, limit);
|
||||
}
|
||||
|
||||
async getTeamMembers(teamId: number): Promise<User[]> {
|
||||
return this.adapter.getTeamMembers(teamId);
|
||||
}
|
||||
}
|
||||
7
core/src/shared/git-provider/index.ts
Normal file
7
core/src/shared/git-provider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./types";
|
||||
export * from "./detect-provider";
|
||||
export * from "./parse-repo-url";
|
||||
export * from "./git-provider.interface";
|
||||
export * from "./git-provider.service";
|
||||
export * from "./git-provider.module";
|
||||
export * from "./adapters";
|
||||
221
core/src/shared/git-provider/parse-repo-url.spec.ts
Normal file
221
core/src/shared/git-provider/parse-repo-url.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { parseRepoUrl } from "./parse-repo-url";
|
||||
|
||||
describe("parseRepoUrl", () => {
|
||||
// ============ Gitea 仓库 URL ============
|
||||
|
||||
describe("Gitea 仓库 URL", () => {
|
||||
it("应解析仓库根目录 URL(未知域名默认为 github)", () => {
|
||||
const result = parseRepoUrl("https://git.bjxgj.com/xgj/review-spec");
|
||||
expect(result).toEqual({
|
||||
owner: "xgj",
|
||||
repo: "review-spec",
|
||||
path: "",
|
||||
provider: "github",
|
||||
serverUrl: "https://git.bjxgj.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析带 /src/branch/ 的目录 URL", () => {
|
||||
const result = parseRepoUrl(
|
||||
"https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "xgj",
|
||||
repo: "review-spec",
|
||||
path: "references",
|
||||
ref: "main",
|
||||
provider: "gitea",
|
||||
serverUrl: "https://git.bjxgj.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析多层子目录路径", () => {
|
||||
const result = parseRepoUrl(
|
||||
"https://git.example.com/org/repo/src/branch/develop/path/to/specs",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "path/to/specs",
|
||||
ref: "develop",
|
||||
provider: "gitea",
|
||||
serverUrl: "https://git.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析 /src/tag/ URL", () => {
|
||||
const result = parseRepoUrl("https://git.example.com/org/repo/src/tag/v1.0/docs");
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "docs",
|
||||
ref: "v1.0",
|
||||
provider: "gitea",
|
||||
serverUrl: "https://git.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析 /src/commit/ URL", () => {
|
||||
const result = parseRepoUrl("https://git.example.com/org/repo/src/commit/abc123/docs");
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "docs",
|
||||
ref: "abc123",
|
||||
provider: "gitea",
|
||||
serverUrl: "https://git.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("分支目录但无子路径时 path 应为空", () => {
|
||||
const result = parseRepoUrl("https://git.example.com/org/repo/src/branch/main");
|
||||
expect(result?.path).toBe("");
|
||||
expect(result?.ref).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GitHub 仓库 URL ============
|
||||
|
||||
describe("GitHub 仓库 URL", () => {
|
||||
it("应解析仓库根目录 URL", () => {
|
||||
const result = parseRepoUrl("https://github.com/facebook/react");
|
||||
expect(result).toEqual({
|
||||
owner: "facebook",
|
||||
repo: "react",
|
||||
path: "",
|
||||
provider: "github",
|
||||
serverUrl: "https://github.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析 /tree/ 目录 URL", () => {
|
||||
const result = parseRepoUrl("https://github.com/org/repo/tree/main/docs/specs");
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "docs/specs",
|
||||
ref: "main",
|
||||
provider: "github",
|
||||
serverUrl: "https://github.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("分支目录但无子路径时 path 应为空", () => {
|
||||
const result = parseRepoUrl("https://github.com/org/repo/tree/develop");
|
||||
expect(result?.path).toBe("");
|
||||
expect(result?.ref).toBe("develop");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ GitLab 仓库 URL ============
|
||||
|
||||
describe("GitLab 仓库 URL", () => {
|
||||
it("应解析仓库根目录 URL", () => {
|
||||
const result = parseRepoUrl("https://gitlab.com/org/repo");
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "",
|
||||
provider: "gitlab",
|
||||
serverUrl: "https://gitlab.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析 /-/tree/ 目录 URL", () => {
|
||||
const result = parseRepoUrl("https://gitlab.com/org/repo/-/tree/main/docs/specs");
|
||||
expect(result).toEqual({
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
path: "docs/specs",
|
||||
ref: "main",
|
||||
provider: "gitlab",
|
||||
serverUrl: "https://gitlab.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("分支目录但无子路径时 path 应为空", () => {
|
||||
const result = parseRepoUrl("https://gitlab.com/org/repo/-/tree/develop");
|
||||
expect(result?.path).toBe("");
|
||||
expect(result?.ref).toBe("develop");
|
||||
expect(result?.provider).toBe("gitlab");
|
||||
});
|
||||
|
||||
it("应解析 GitLab SSH 格式", () => {
|
||||
const result = parseRepoUrl("git@gitlab.com:org/repo.git");
|
||||
expect(result?.provider).toBe("gitlab");
|
||||
expect(result?.owner).toBe("org");
|
||||
expect(result?.repo).toBe("repo");
|
||||
});
|
||||
|
||||
it("自建 GitLab 使用 /-/tree/ 格式也应识别", () => {
|
||||
const result = parseRepoUrl("https://git.company.com/team/project/-/tree/main/references");
|
||||
expect(result?.path).toBe("references");
|
||||
expect(result?.ref).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ SSH URL ============
|
||||
|
||||
describe("SSH URL", () => {
|
||||
it("应解析 git@ 格式", () => {
|
||||
const result = parseRepoUrl("git@git.bjxgj.com:xgj/review-spec.git");
|
||||
expect(result).toEqual({
|
||||
owner: "xgj",
|
||||
repo: "review-spec",
|
||||
path: "",
|
||||
provider: "github",
|
||||
serverUrl: "https://git.bjxgj.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("应解析 GitHub SSH 格式", () => {
|
||||
const result = parseRepoUrl("git@github.com:org/repo.git");
|
||||
expect(result?.provider).toBe("github");
|
||||
expect(result?.owner).toBe("org");
|
||||
expect(result?.repo).toBe("repo");
|
||||
});
|
||||
|
||||
it("应解析 git+ssh:// 格式", () => {
|
||||
const result = parseRepoUrl("git+ssh://git@git.bjxgj.com/xgj/review-spec.git");
|
||||
expect(result?.owner).toBe("xgj");
|
||||
expect(result?.repo).toBe("review-spec");
|
||||
expect(result?.provider).toBe("github");
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 边界情况 ============
|
||||
|
||||
describe("边界情况", () => {
|
||||
it("空字符串应返回 null", () => {
|
||||
expect(parseRepoUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
it("纯文本应返回 null", () => {
|
||||
expect(parseRepoUrl("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
it("只有域名无路径应返回 null", () => {
|
||||
expect(parseRepoUrl("https://github.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("只有一段路径应返回 null", () => {
|
||||
expect(parseRepoUrl("https://github.com/org")).toBeNull();
|
||||
});
|
||||
|
||||
it("URL 带尾部斜杠应正常解析", () => {
|
||||
const result = parseRepoUrl("https://github.com/org/repo/");
|
||||
expect(result?.owner).toBe("org");
|
||||
expect(result?.repo).toBe("repo");
|
||||
});
|
||||
|
||||
it("URL 带 .git 后缀应去除", () => {
|
||||
const result = parseRepoUrl("https://github.com/org/repo.git");
|
||||
expect(result?.repo).toBe("repo");
|
||||
});
|
||||
|
||||
it("本地路径应返回 null", () => {
|
||||
expect(parseRepoUrl("./references")).toBeNull();
|
||||
expect(parseRepoUrl("/home/user/specs")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
155
core/src/shared/git-provider/parse-repo-url.ts
Normal file
155
core/src/shared/git-provider/parse-repo-url.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { RemoteRepoRef, GitProviderType } from "./types";
|
||||
import { detectProvider } from "./detect-provider";
|
||||
|
||||
/** 已知的 GitHub 域名 */
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
|
||||
/** 已知的 GitLab 域名 */
|
||||
const GITLAB_HOSTS = new Set(["gitlab.com", "www.gitlab.com"]);
|
||||
|
||||
/**
|
||||
* 解析浏览器中复制的仓库 URL 为结构化的仓库引用
|
||||
*
|
||||
* 支持的 URL 格式:
|
||||
* - Gitea 仓库:https://git.example.com/owner/repo
|
||||
* - Gitea 目录:https://git.example.com/owner/repo/src/branch/main/path/to/dir
|
||||
* - Gitea 标签:https://git.example.com/owner/repo/src/tag/v1.0/path/to/dir
|
||||
* - Gitea commit:https://git.example.com/owner/repo/src/commit/abc123/path/to/dir
|
||||
* - GitHub 仓库:https://github.com/owner/repo
|
||||
* - GitHub 目录:https://github.com/owner/repo/tree/main/path/to/dir
|
||||
* - GitLab 仓库:https://gitlab.com/owner/repo
|
||||
* - GitLab 目录:https://gitlab.com/owner/repo/-/tree/main/path/to/dir
|
||||
* - git+ssh URL:git+ssh://git@host/owner/repo.git
|
||||
* - SSH URL:git@host:owner/repo.git
|
||||
*
|
||||
* @returns 解析后的仓库引用,无法解析时返回 null
|
||||
*/
|
||||
export function parseRepoUrl(url: string): RemoteRepoRef | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
// SSH 格式: git@host:owner/repo.git
|
||||
if (trimmed.startsWith("git@")) {
|
||||
return parseSshUrl(trimmed);
|
||||
}
|
||||
// git+ssh:// 格式
|
||||
if (trimmed.startsWith("git+ssh://")) {
|
||||
return parseGitSshUrl(trimmed);
|
||||
}
|
||||
// HTTP(S) URL
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||
return parseHttpUrl(trimmed);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 解析 HTTP(S) URL */
|
||||
function parseHttpUrl(url: string): RemoteRepoRef | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const hostname = parsed.hostname;
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
const owner = segments[0];
|
||||
const repo = segments[1].replace(/\.git$/, "");
|
||||
const serverUrl = `${parsed.protocol}//${parsed.host}`;
|
||||
const isGithub = isGithubHost(hostname);
|
||||
const isGitlab = isGitlabHost(hostname);
|
||||
const provider: GitProviderType = isGithub
|
||||
? "github"
|
||||
: isGitlab
|
||||
? "gitlab"
|
||||
: detectProviderForHost(serverUrl);
|
||||
// 仓库根目录(只有 owner/repo)
|
||||
if (segments.length === 2) {
|
||||
return { owner, repo, path: "", provider, serverUrl };
|
||||
}
|
||||
// GitHub: /owner/repo/tree/branch/path...
|
||||
if (isGithub && segments[2] === "tree" && segments.length >= 4) {
|
||||
const ref = segments[3];
|
||||
const path = segments.slice(4).join("/");
|
||||
return { owner, repo, path, ref, provider, serverUrl };
|
||||
}
|
||||
// GitLab: /owner/repo/-/tree/branch/path...
|
||||
if (segments[2] === "-" && segments[3] === "tree" && segments.length >= 5) {
|
||||
const ref = segments[4];
|
||||
const path = segments.slice(5).join("/");
|
||||
return { owner, repo, path, ref, provider: isGitlab ? "gitlab" : provider, serverUrl };
|
||||
}
|
||||
// Gitea: /owner/repo/src/branch/<branch>/path...
|
||||
// /owner/repo/src/tag/<tag>/path...
|
||||
// /owner/repo/src/commit/<sha>/path...
|
||||
if (segments[2] === "src" && segments.length >= 4) {
|
||||
const refType = segments[3]; // "branch", "tag", "commit"
|
||||
if (
|
||||
(refType === "branch" || refType === "tag" || refType === "commit") &&
|
||||
segments.length >= 5
|
||||
) {
|
||||
const ref = segments[4];
|
||||
const path = segments.slice(5).join("/");
|
||||
return { owner, repo, path, ref, provider: "gitea", serverUrl };
|
||||
}
|
||||
}
|
||||
// 无法识别的子路径,当作仓库根目录
|
||||
return { owner, repo, path: "", provider, serverUrl };
|
||||
}
|
||||
|
||||
/** 解析 git@host:owner/repo.git 格式 */
|
||||
function parseSshUrl(url: string): RemoteRepoRef | null {
|
||||
const match = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
||||
if (!match) return null;
|
||||
const host = match[1];
|
||||
const pathPart = match[2];
|
||||
const segments = pathPart.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
const owner = segments[0];
|
||||
const repo = segments[1];
|
||||
const serverUrl = `https://${host}`;
|
||||
const provider: GitProviderType = isGithubHost(host)
|
||||
? "github"
|
||||
: isGitlabHost(host)
|
||||
? "gitlab"
|
||||
: detectProviderForHost(serverUrl);
|
||||
return { owner, repo, path: "", provider, serverUrl };
|
||||
}
|
||||
|
||||
/** 解析 git+ssh://git@host/owner/repo.git 格式 */
|
||||
function parseGitSshUrl(url: string): RemoteRepoRef | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url.replace("git+ssh://", "ssh://"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname;
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
const owner = segments[0];
|
||||
const repo = segments[1].replace(/\.git$/, "");
|
||||
const serverUrl = `https://${host}`;
|
||||
const provider: GitProviderType = isGithubHost(host)
|
||||
? "github"
|
||||
: isGitlabHost(host)
|
||||
? "gitlab"
|
||||
: detectProviderForHost(serverUrl);
|
||||
return { owner, repo, path: "", provider, serverUrl };
|
||||
}
|
||||
|
||||
/** 判断是否为 GitHub 域名 */
|
||||
function isGithubHost(hostname: string): boolean {
|
||||
return GITHUB_HOSTS.has(hostname) || hostname.endsWith(".github.com");
|
||||
}
|
||||
|
||||
/** 判断是否为 GitLab 域名 */
|
||||
function isGitlabHost(hostname: string): boolean {
|
||||
return GITLAB_HOSTS.has(hostname) || hostname.endsWith(".gitlab.com");
|
||||
}
|
||||
|
||||
/** 根据服务器 URL 检测 provider 类型(非 GitHub/GitLab 默认为 Gitea) */
|
||||
function detectProviderForHost(serverUrl: string): GitProviderType {
|
||||
const detected = detectProvider({ GIT_PROVIDER_URL: serverUrl });
|
||||
return detected.provider;
|
||||
}
|
||||
434
core/src/shared/git-provider/types.ts
Normal file
434
core/src/shared/git-provider/types.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Git Provider 通用类型定义
|
||||
* 适用于 Gitea、GitHub 等多种 Git 托管平台
|
||||
*/
|
||||
|
||||
/** Git Provider 平台类型 */
|
||||
export type GitProviderType = "gitea" | "github" | "gitlab";
|
||||
|
||||
/** Git Provider 模块配置选项 */
|
||||
export interface GitProviderModuleOptions {
|
||||
/** Git Provider 平台类型 */
|
||||
provider: GitProviderType;
|
||||
/** 服务器 URL */
|
||||
baseUrl: string;
|
||||
/** API Token */
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** Git Provider 模块异步配置选项 */
|
||||
export interface GitProviderModuleAsyncOptions {
|
||||
useFactory: (...args: unknown[]) => Promise<GitProviderModuleOptions> | GitProviderModuleOptions;
|
||||
inject?: any[];
|
||||
}
|
||||
|
||||
/** 注入令牌 */
|
||||
export const GIT_PROVIDER_MODULE_OPTIONS = "GIT_PROVIDER_MODULE_OPTIONS";
|
||||
|
||||
/** 分支保护规则 */
|
||||
export interface BranchProtection {
|
||||
approvals_whitelist_teams?: string[];
|
||||
approvals_whitelist_username?: string[];
|
||||
block_admin_merge_override?: boolean;
|
||||
block_on_official_review_requests?: boolean;
|
||||
block_on_outdated_branch?: boolean;
|
||||
block_on_rejected_reviews?: boolean;
|
||||
branch_name?: string;
|
||||
created_at?: string;
|
||||
dismiss_stale_approvals?: boolean;
|
||||
enable_approvals_whitelist?: boolean;
|
||||
enable_force_push?: boolean;
|
||||
enable_force_push_allowlist?: boolean;
|
||||
enable_merge_whitelist?: boolean;
|
||||
enable_push?: boolean;
|
||||
enable_push_whitelist?: boolean;
|
||||
enable_status_check?: boolean;
|
||||
force_push_allowlist_deploy_keys?: boolean;
|
||||
force_push_allowlist_teams?: string[];
|
||||
force_push_allowlist_usernames?: string[];
|
||||
ignore_stale_approvals?: boolean;
|
||||
merge_whitelist_teams?: string[];
|
||||
merge_whitelist_usernames?: string[];
|
||||
priority?: number;
|
||||
protected_file_patterns?: string;
|
||||
push_whitelist_deploy_keys?: boolean;
|
||||
push_whitelist_teams?: string[];
|
||||
push_whitelist_usernames?: string[];
|
||||
require_signed_commits?: boolean;
|
||||
required_approvals?: number;
|
||||
rule_name?: string;
|
||||
status_check_contexts?: string[];
|
||||
unprotected_file_patterns?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建分支保护规则选项 */
|
||||
export interface CreateBranchProtectionOption {
|
||||
approvals_whitelist_teams?: string[];
|
||||
approvals_whitelist_username?: string[];
|
||||
block_admin_merge_override?: boolean;
|
||||
block_on_official_review_requests?: boolean;
|
||||
block_on_outdated_branch?: boolean;
|
||||
block_on_rejected_reviews?: boolean;
|
||||
/** 分支名称或通配符规则 */
|
||||
branch_name?: string;
|
||||
/** 规则名称 */
|
||||
rule_name?: string;
|
||||
dismiss_stale_approvals?: boolean;
|
||||
enable_approvals_whitelist?: boolean;
|
||||
enable_force_push?: boolean;
|
||||
enable_force_push_allowlist?: boolean;
|
||||
enable_merge_whitelist?: boolean;
|
||||
/** 是否允许推送 */
|
||||
enable_push?: boolean;
|
||||
enable_push_whitelist?: boolean;
|
||||
enable_status_check?: boolean;
|
||||
force_push_allowlist_deploy_keys?: boolean;
|
||||
force_push_allowlist_teams?: string[];
|
||||
force_push_allowlist_usernames?: string[];
|
||||
ignore_stale_approvals?: boolean;
|
||||
merge_whitelist_teams?: string[];
|
||||
merge_whitelist_usernames?: string[];
|
||||
priority?: number;
|
||||
protected_file_patterns?: string;
|
||||
push_whitelist_deploy_keys?: boolean;
|
||||
push_whitelist_teams?: string[];
|
||||
push_whitelist_usernames?: string[];
|
||||
require_signed_commits?: boolean;
|
||||
required_approvals?: number;
|
||||
status_check_contexts?: string[];
|
||||
unprotected_file_patterns?: string;
|
||||
}
|
||||
|
||||
/** 编辑分支保护规则选项 */
|
||||
export interface EditBranchProtectionOption {
|
||||
approvals_whitelist_teams?: string[];
|
||||
approvals_whitelist_username?: string[];
|
||||
block_admin_merge_override?: boolean;
|
||||
block_on_official_review_requests?: boolean;
|
||||
block_on_outdated_branch?: boolean;
|
||||
block_on_rejected_reviews?: boolean;
|
||||
dismiss_stale_approvals?: boolean;
|
||||
enable_approvals_whitelist?: boolean;
|
||||
enable_force_push?: boolean;
|
||||
enable_force_push_allowlist?: boolean;
|
||||
enable_merge_whitelist?: boolean;
|
||||
/** 是否允许推送 */
|
||||
enable_push?: boolean;
|
||||
enable_push_whitelist?: boolean;
|
||||
enable_status_check?: boolean;
|
||||
force_push_allowlist_deploy_keys?: boolean;
|
||||
force_push_allowlist_teams?: string[];
|
||||
force_push_allowlist_usernames?: string[];
|
||||
ignore_stale_approvals?: boolean;
|
||||
merge_whitelist_teams?: string[];
|
||||
merge_whitelist_usernames?: string[];
|
||||
priority?: number;
|
||||
protected_file_patterns?: string;
|
||||
push_whitelist_deploy_keys?: boolean;
|
||||
push_whitelist_teams?: string[];
|
||||
push_whitelist_usernames?: string[];
|
||||
require_signed_commits?: boolean;
|
||||
required_approvals?: number;
|
||||
status_check_contexts?: string[];
|
||||
unprotected_file_patterns?: string;
|
||||
}
|
||||
|
||||
/** 分支信息 */
|
||||
export interface Branch {
|
||||
commit?: {
|
||||
id?: string;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
effective_branch_protection_name?: string;
|
||||
enable_status_check?: boolean;
|
||||
name?: string;
|
||||
protected?: boolean;
|
||||
required_approvals?: number;
|
||||
status_check_contexts?: string[];
|
||||
user_can_merge?: boolean;
|
||||
user_can_push?: boolean;
|
||||
}
|
||||
|
||||
/** 仓库信息 */
|
||||
export interface Repository {
|
||||
id?: number;
|
||||
owner?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
full_name?: string;
|
||||
};
|
||||
name?: string;
|
||||
full_name?: string;
|
||||
default_branch?: string;
|
||||
}
|
||||
|
||||
/** Pull Request 信息 */
|
||||
export interface PullRequest {
|
||||
id?: number;
|
||||
number?: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
state?: string;
|
||||
head?: {
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
repo?: Repository;
|
||||
};
|
||||
base?: {
|
||||
ref?: string;
|
||||
sha?: string;
|
||||
repo?: Repository;
|
||||
};
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
/** PR 指定的评审人(个人用户) */
|
||||
requested_reviewers?: Array<{
|
||||
id?: number;
|
||||
login?: string;
|
||||
}>;
|
||||
/** PR 指定的评审团队 */
|
||||
requested_reviewers_teams?: Array<{
|
||||
id?: number;
|
||||
name?: string;
|
||||
/** 团队成员 */
|
||||
members?: Array<{
|
||||
id?: number;
|
||||
login?: string;
|
||||
}>;
|
||||
}>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
merged_at?: string;
|
||||
merge_base?: string;
|
||||
}
|
||||
|
||||
/** 编辑 PR 的选项 */
|
||||
export interface EditPullRequestOption {
|
||||
title?: string;
|
||||
body?: string;
|
||||
state?: "open" | "closed";
|
||||
base?: string;
|
||||
}
|
||||
|
||||
/** PR Commit 信息 */
|
||||
export interface PullRequestCommit {
|
||||
sha?: string;
|
||||
commit?: {
|
||||
message?: string;
|
||||
author?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
date?: string;
|
||||
};
|
||||
};
|
||||
author?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
committer?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 文件变更信息 */
|
||||
export interface ChangedFile {
|
||||
filename?: string;
|
||||
status?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changes?: number;
|
||||
patch?: string;
|
||||
raw_url?: string;
|
||||
contents_url?: string;
|
||||
}
|
||||
|
||||
/** Commit 详细信息(包含文件变更) */
|
||||
export interface CommitInfo extends PullRequestCommit {
|
||||
files?: ChangedFile[];
|
||||
}
|
||||
|
||||
/** Issue/PR 评论 */
|
||||
export interface IssueComment {
|
||||
id?: number;
|
||||
body?: string;
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建 Issue 评论选项 */
|
||||
export interface CreateIssueCommentOption {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** 创建 Issue 的选项 */
|
||||
export interface CreateIssueOption {
|
||||
/** Issue 标题 */
|
||||
title: string;
|
||||
/** Issue 内容 */
|
||||
body?: string;
|
||||
/** 指派人用户名列表 */
|
||||
assignees?: string[];
|
||||
/** 标签 ID 列表 */
|
||||
labels?: number[];
|
||||
/** 里程碑 ID */
|
||||
milestone?: number;
|
||||
/** 截止日期 */
|
||||
due_date?: string;
|
||||
}
|
||||
|
||||
/** Issue 响应 */
|
||||
export interface Issue {
|
||||
id?: number;
|
||||
number?: number;
|
||||
title?: string;
|
||||
body?: string;
|
||||
state?: string;
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
labels?: Array<{
|
||||
id?: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
assignees?: Array<{
|
||||
id?: number;
|
||||
login?: string;
|
||||
}>;
|
||||
milestone?: {
|
||||
id?: number;
|
||||
title?: string;
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
closed_at?: string;
|
||||
html_url?: string;
|
||||
}
|
||||
|
||||
/** PR Review 事件类型 */
|
||||
export type ReviewStateType = "APPROVE" | "REQUEST_CHANGES" | "COMMENT" | "PENDING";
|
||||
|
||||
/** PR Review 行级评论 */
|
||||
export interface CreatePullReviewComment {
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 评论内容 */
|
||||
body: string;
|
||||
/** 旧文件行号(删除行),新增行设为 0 */
|
||||
old_position?: number;
|
||||
/** 新文件行号(新增/修改行),删除行设为 0 */
|
||||
new_position?: number;
|
||||
}
|
||||
|
||||
/** 创建 PR Review 的选项 */
|
||||
export interface CreatePullReviewOption {
|
||||
/** Review 事件类型 */
|
||||
event?: ReviewStateType;
|
||||
/** Review 主体评论 */
|
||||
body?: string;
|
||||
/** 行级评论列表 */
|
||||
comments?: CreatePullReviewComment[];
|
||||
/** 提交 SHA(可选) */
|
||||
commit_id?: string;
|
||||
}
|
||||
|
||||
/** PR Review 响应 */
|
||||
export interface PullReview {
|
||||
id?: number;
|
||||
body?: string;
|
||||
state?: string;
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
commit_id?: string;
|
||||
}
|
||||
|
||||
/** PR Review 行级评论响应 */
|
||||
export interface PullReviewComment {
|
||||
id?: number;
|
||||
body?: string;
|
||||
path?: string;
|
||||
position?: number;
|
||||
original_position?: number;
|
||||
commit_id?: string;
|
||||
original_commit_id?: string;
|
||||
diff_hunk?: string;
|
||||
pull_request_review_id?: number;
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
/** 解决者,如果不为 null 表示评论已被标记为已解决 */
|
||||
resolver?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
} | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
html_url?: string;
|
||||
}
|
||||
|
||||
/** 用户信息 */
|
||||
export interface User {
|
||||
id?: number;
|
||||
login?: string;
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
/** 仓库目录/文件条目 */
|
||||
export interface RepositoryContent {
|
||||
/** 文件名 */
|
||||
name: string;
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 类型:file 或 dir */
|
||||
type: "file" | "dir";
|
||||
/** 文件大小(字节) */
|
||||
size?: number;
|
||||
/** 下载 URL */
|
||||
download_url?: string;
|
||||
}
|
||||
|
||||
/** 远程仓库引用(解析自浏览器 URL) */
|
||||
export interface RemoteRepoRef {
|
||||
/** 仓库所有者 */
|
||||
owner: string;
|
||||
/** 仓库名称 */
|
||||
repo: string;
|
||||
/** 子目录路径(空字符串表示根目录) */
|
||||
path: string;
|
||||
/** 分支/标签(undefined 表示默认分支) */
|
||||
ref?: string;
|
||||
/** 来源平台类型 */
|
||||
provider: GitProviderType;
|
||||
/** 原始服务器 URL(如 https://git.bjxgj.com) */
|
||||
serverUrl: string;
|
||||
}
|
||||
|
||||
/** 评论/Issue 的 Reaction(表情符号反应) */
|
||||
export interface Reaction {
|
||||
/** 添加 reaction 的用户 */
|
||||
user?: {
|
||||
id?: number;
|
||||
login?: string;
|
||||
};
|
||||
/** reaction 内容,如 +1, -1, laugh, hooray, confused, heart, rocket, eyes */
|
||||
content?: string;
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
}
|
||||
344
core/src/shared/git-sdk/git-sdk-diff.utils.spec.ts
Normal file
344
core/src/shared/git-sdk/git-sdk-diff.utils.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
mapGitStatus,
|
||||
parseChangedLinesFromPatch,
|
||||
parseHunksFromPatch,
|
||||
calculateNewLineNumber,
|
||||
calculateLineOffsets,
|
||||
parseDiffText,
|
||||
} from "./git-sdk-diff.utils";
|
||||
|
||||
describe("git-sdk-diff.utils", () => {
|
||||
describe("parseHunksFromPatch", () => {
|
||||
it("should parse single hunk", () => {
|
||||
const patch = `@@ -1,3 +1,4 @@
|
||||
line1
|
||||
+new line
|
||||
line2
|
||||
line3`;
|
||||
const hunks = parseHunksFromPatch(patch);
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0]).toEqual({
|
||||
oldStart: 1,
|
||||
oldCount: 3,
|
||||
newStart: 1,
|
||||
newCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse multiple hunks", () => {
|
||||
const patch = `@@ -1,3 +1,4 @@
|
||||
line1
|
||||
+new line
|
||||
line2
|
||||
line3
|
||||
@@ -10,2 +11,3 @@
|
||||
line10
|
||||
+another new line
|
||||
line11`;
|
||||
const hunks = parseHunksFromPatch(patch);
|
||||
expect(hunks).toHaveLength(2);
|
||||
expect(hunks[0]).toEqual({
|
||||
oldStart: 1,
|
||||
oldCount: 3,
|
||||
newStart: 1,
|
||||
newCount: 4,
|
||||
});
|
||||
expect(hunks[1]).toEqual({
|
||||
oldStart: 10,
|
||||
oldCount: 2,
|
||||
newStart: 11,
|
||||
newCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle single line hunk without count", () => {
|
||||
const patch = `@@ -5 +5,2 @@
|
||||
line5
|
||||
+new line`;
|
||||
const hunks = parseHunksFromPatch(patch);
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0]).toEqual({
|
||||
oldStart: 5,
|
||||
oldCount: 1,
|
||||
newStart: 5,
|
||||
newCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array for undefined patch", () => {
|
||||
expect(parseHunksFromPatch(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateNewLineNumber", () => {
|
||||
it("should return same line number when no hunks", () => {
|
||||
expect(calculateNewLineNumber(5, [])).toBe(5);
|
||||
});
|
||||
|
||||
it("should offset line number after insertion", () => {
|
||||
// 在第1行后插入1行,原来的第5行变成第6行
|
||||
const hunks = [{ oldStart: 1, oldCount: 1, newStart: 1, newCount: 2 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBe(6);
|
||||
});
|
||||
|
||||
it("should offset line number after multiple insertions", () => {
|
||||
// 在第1行插入2行,原来的第5行变成第7行
|
||||
const hunks = [{ oldStart: 1, oldCount: 1, newStart: 1, newCount: 3 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBe(7);
|
||||
});
|
||||
|
||||
it("should offset line number after deletion", () => {
|
||||
// 删除第1-2行,原来的第5行变成第3行
|
||||
const hunks = [{ oldStart: 1, oldCount: 2, newStart: 1, newCount: 0 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBe(3);
|
||||
});
|
||||
|
||||
it("should return null when line is deleted", () => {
|
||||
// 删除第5行
|
||||
const hunks = [{ oldStart: 5, oldCount: 1, newStart: 5, newCount: 0 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle line before any hunk", () => {
|
||||
// 第10行有变更,第5行不受影响
|
||||
const hunks = [{ oldStart: 10, oldCount: 1, newStart: 10, newCount: 2 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBe(5);
|
||||
});
|
||||
|
||||
it("should handle multiple hunks with cumulative offset", () => {
|
||||
// 第1行插入1行,第10行插入1行
|
||||
// 原来的第15行:经过第一个hunk偏移+1,经过第二个hunk偏移+1,变成17行
|
||||
const hunks = [
|
||||
{ oldStart: 1, oldCount: 1, newStart: 1, newCount: 2 },
|
||||
{ oldStart: 10, oldCount: 1, newStart: 11, newCount: 2 },
|
||||
];
|
||||
expect(calculateNewLineNumber(15, hunks)).toBe(17);
|
||||
});
|
||||
|
||||
it("should handle line within modified hunk", () => {
|
||||
// 第5-7行被修改为5-8行(3行变4行)
|
||||
const hunks = [{ oldStart: 5, oldCount: 3, newStart: 5, newCount: 4 }];
|
||||
expect(calculateNewLineNumber(5, hunks)).toBe(5);
|
||||
expect(calculateNewLineNumber(6, hunks)).toBe(6);
|
||||
expect(calculateNewLineNumber(7, hunks)).toBe(7);
|
||||
// 第8行在hunk之后,偏移+1
|
||||
expect(calculateNewLineNumber(8, hunks)).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateLineOffsets", () => {
|
||||
it("should calculate offsets for multiple lines", () => {
|
||||
// 在第1行后插入2行:原1-3行变成1-5行
|
||||
// 原第1行 -> 新第1行(在hunk内,位置0)
|
||||
// 原第2行 -> 新第2行(在hunk内,位置1)
|
||||
// 原第3行 -> 新第3行(在hunk内,位置2)
|
||||
// 原第5行 -> 新第7行(在hunk后,偏移+2)
|
||||
// 原第10行 -> 新第12行(在hunk后,偏移+2)
|
||||
const patch = `@@ -1,3 +1,5 @@
|
||||
line1
|
||||
+new line 1
|
||||
+new line 2
|
||||
line2
|
||||
line3`;
|
||||
const result = calculateLineOffsets([1, 2, 3, 5, 10], patch);
|
||||
expect(result.get(1)).toBe(1);
|
||||
expect(result.get(2)).toBe(2); // 原第2行在hunk内,位置1
|
||||
expect(result.get(3)).toBe(3); // 原第3行在hunk内,位置2
|
||||
expect(result.get(5)).toBe(7); // 原第5行在hunk后,偏移+2
|
||||
expect(result.get(10)).toBe(12); // 原第10行在hunk后,偏移+2
|
||||
});
|
||||
|
||||
it("should mark deleted lines as null", () => {
|
||||
const patch = `@@ -5,2 +5,0 @@
|
||||
-deleted line 1
|
||||
-deleted line 2`;
|
||||
const result = calculateLineOffsets([4, 5, 6, 7], patch);
|
||||
expect(result.get(4)).toBe(4); // 不受影响
|
||||
expect(result.get(5)).toBeNull(); // 被删除
|
||||
expect(result.get(6)).toBeNull(); // 被删除
|
||||
expect(result.get(7)).toBe(5); // 偏移-2
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue line number update scenarios", () => {
|
||||
// 模拟 ReviewService.updateIssueLineNumbers 的核心逻辑
|
||||
function updateIssueLine(
|
||||
oldLine: string,
|
||||
patch: string,
|
||||
): { newLine: string | null; deleted: boolean } {
|
||||
const lineMatch = oldLine.match(/^(\d+)(?:-(\d+))?$/);
|
||||
if (!lineMatch) return { newLine: oldLine, deleted: false };
|
||||
|
||||
const startLine = parseInt(lineMatch[1], 10);
|
||||
const endLine = lineMatch[2] ? parseInt(lineMatch[2], 10) : startLine;
|
||||
const hunks = parseHunksFromPatch(patch);
|
||||
|
||||
const newStartLine = calculateNewLineNumber(startLine, hunks);
|
||||
if (newStartLine === null) {
|
||||
return { newLine: null, deleted: true };
|
||||
}
|
||||
|
||||
if (startLine === endLine) {
|
||||
return { newLine: String(newStartLine), deleted: false };
|
||||
}
|
||||
|
||||
const newEndLine = calculateNewLineNumber(endLine, hunks);
|
||||
if (newEndLine === null) {
|
||||
return { newLine: String(newStartLine), deleted: false };
|
||||
}
|
||||
return { newLine: `${newStartLine}-${newEndLine}`, deleted: false };
|
||||
}
|
||||
|
||||
it("should update line when code is inserted before issue", () => {
|
||||
// 在第1行插入2行,原第5行变成第7行
|
||||
const patch = `@@ -1,3 +1,5 @@
|
||||
line1
|
||||
+new line 1
|
||||
+new line 2
|
||||
line2
|
||||
line3`;
|
||||
const result = updateIssueLine("5", patch);
|
||||
expect(result.newLine).toBe("7");
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("should update line when code is deleted before issue", () => {
|
||||
// 删除第1-2行,原第5行变成第3行
|
||||
const patch = `@@ -1,4 +1,2 @@
|
||||
-line1
|
||||
-line2
|
||||
line3
|
||||
line4`;
|
||||
const result = updateIssueLine("5", patch);
|
||||
expect(result.newLine).toBe("3");
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark as deleted when issue line is removed", () => {
|
||||
// 删除第5行
|
||||
const patch = `@@ -5,1 +5,0 @@
|
||||
-deleted line`;
|
||||
const result = updateIssueLine("5", patch);
|
||||
expect(result.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle range line numbers", () => {
|
||||
// 在第1行插入2行,原第5-7行变成第7-9行
|
||||
const patch = `@@ -1,3 +1,5 @@
|
||||
line1
|
||||
+new line 1
|
||||
+new line 2
|
||||
line2
|
||||
line3`;
|
||||
const result = updateIssueLine("5-7", patch);
|
||||
expect(result.newLine).toBe("7-9");
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle range when end line is deleted", () => {
|
||||
// 删除第7行,原第5-7行变成第5行
|
||||
const patch = `@@ -7,1 +7,0 @@
|
||||
-deleted line`;
|
||||
const result = updateIssueLine("5-7", patch);
|
||||
expect(result.newLine).toBe("5");
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("should not change line when no relevant changes", () => {
|
||||
// 在第10行插入,原第5行不变
|
||||
const patch = `@@ -10,1 +10,2 @@
|
||||
line10
|
||||
+new line`;
|
||||
const result = updateIssueLine("5", patch);
|
||||
expect(result.newLine).toBe("5");
|
||||
expect(result.deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGitStatus", () => {
|
||||
it("should map known statuses", () => {
|
||||
expect(mapGitStatus("A")).toBe("added");
|
||||
expect(mapGitStatus("M")).toBe("modified");
|
||||
expect(mapGitStatus("D")).toBe("deleted");
|
||||
expect(mapGitStatus("R")).toBe("renamed");
|
||||
expect(mapGitStatus("C")).toBe("copied");
|
||||
});
|
||||
|
||||
it("should default to modified for unknown status", () => {
|
||||
expect(mapGitStatus("X")).toBe("modified");
|
||||
expect(mapGitStatus("")).toBe("modified");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseChangedLinesFromPatch", () => {
|
||||
it("should return empty set for undefined patch", () => {
|
||||
expect(parseChangedLinesFromPatch(undefined)).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("should parse added lines", () => {
|
||||
const patch = `@@ -1,3 +1,5 @@
|
||||
line1
|
||||
+added1
|
||||
+added2
|
||||
line2
|
||||
line3`;
|
||||
const result = parseChangedLinesFromPatch(patch);
|
||||
expect(result).toEqual(new Set([2, 3]));
|
||||
});
|
||||
|
||||
it("should skip deleted lines", () => {
|
||||
const patch = `@@ -1,3 +1,1 @@
|
||||
-deleted1
|
||||
-deleted2
|
||||
line3`;
|
||||
const result = parseChangedLinesFromPatch(patch);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle mixed additions and deletions", () => {
|
||||
const patch = `@@ -1,4 +1,4 @@
|
||||
line1
|
||||
-old line
|
||||
+new line
|
||||
line3
|
||||
line4`;
|
||||
const result = parseChangedLinesFromPatch(patch);
|
||||
expect(result).toEqual(new Set([2]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDiffText", () => {
|
||||
it("should parse diff text into files", () => {
|
||||
const diffText = `diff --git a/file1.ts b/file1.ts
|
||||
--- a/file1.ts
|
||||
+++ b/file1.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
line1
|
||||
+new line
|
||||
line2
|
||||
line3
|
||||
diff --git a/file2.ts b/file2.ts
|
||||
--- a/file2.ts
|
||||
+++ b/file2.ts
|
||||
@@ -1,2 +1,1 @@
|
||||
-old
|
||||
kept`;
|
||||
const files = parseDiffText(diffText);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files[0].filename).toBe("file1.ts");
|
||||
expect(files[0].patch).toContain("@@ -1,3 +1,4 @@");
|
||||
expect(files[1].filename).toBe("file2.ts");
|
||||
});
|
||||
|
||||
it("should skip files without patch", () => {
|
||||
const diffText = `diff --git a/binary.png b/binary.png
|
||||
Binary files differ`;
|
||||
const files = parseDiffText(diffText);
|
||||
expect(files).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
expect(parseDiffText("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
core/src/shared/git-sdk/git-sdk-diff.utils.ts
Normal file
151
core/src/shared/git-sdk/git-sdk-diff.utils.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { GitDiffFile } from "./git-sdk.types";
|
||||
|
||||
const GIT_STATUS_MAP: Record<string, string> = {
|
||||
A: "added",
|
||||
M: "modified",
|
||||
D: "deleted",
|
||||
R: "renamed",
|
||||
C: "copied",
|
||||
};
|
||||
|
||||
export function mapGitStatus(status: string): string {
|
||||
return GIT_STATUS_MAP[status] || "modified";
|
||||
}
|
||||
|
||||
export function parseChangedLinesFromPatch(patch?: string): Set<number> {
|
||||
const changedLines = new Set<number>();
|
||||
if (!patch) return changedLines;
|
||||
|
||||
const lines = patch.split("\n");
|
||||
let currentLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
currentLine = parseInt(hunkMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
changedLines.add(currentLine);
|
||||
currentLine++;
|
||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
||||
// 删除行,不增加 currentLine
|
||||
} else if (!line.startsWith("\\")) {
|
||||
currentLine++;
|
||||
}
|
||||
}
|
||||
|
||||
return changedLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表示一个 diff hunk 的行号变更信息
|
||||
*/
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 patch 中解析所有 hunk 信息
|
||||
*/
|
||||
export function parseHunksFromPatch(patch?: string): DiffHunk[] {
|
||||
const hunks: DiffHunk[] = [];
|
||||
if (!patch) return hunks;
|
||||
|
||||
const lines = patch.split("\n");
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
hunks.push({
|
||||
oldStart: parseInt(hunkMatch[1], 10),
|
||||
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
|
||||
newStart: parseInt(hunkMatch[3], 10),
|
||||
newCount: parseInt(hunkMatch[4] ?? "1", 10),
|
||||
});
|
||||
}
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 diff hunks 计算旧行号对应的新行号
|
||||
* @param oldLine 旧文件中的行号
|
||||
* @param hunks diff hunk 列表
|
||||
* @returns 新行号,如果该行被删除则返回 null
|
||||
*/
|
||||
export function calculateNewLineNumber(oldLine: number, hunks: DiffHunk[]): number | null {
|
||||
let offset = 0;
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const oldEnd = hunk.oldStart + hunk.oldCount - 1;
|
||||
|
||||
if (oldLine < hunk.oldStart) {
|
||||
// 行号在这个 hunk 之前,应用之前累积的偏移
|
||||
break;
|
||||
}
|
||||
|
||||
if (oldLine >= hunk.oldStart && oldLine <= oldEnd) {
|
||||
// 行号在这个 hunk 的删除范围内
|
||||
// 需要检查这一行是否被删除
|
||||
// 简化处理:如果 hunk 有删除(oldCount > 0),且行在范围内,认为可能被修改
|
||||
// 返回对应的新位置(基于 hunk 的起始位置)
|
||||
const lineOffsetInHunk = oldLine - hunk.oldStart;
|
||||
if (lineOffsetInHunk < hunk.newCount) {
|
||||
// 行仍然存在(可能被修改)
|
||||
return hunk.newStart + lineOffsetInHunk + offset;
|
||||
} else {
|
||||
// 行被删除
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算这个 hunk 带来的偏移
|
||||
offset += hunk.newCount - hunk.oldCount;
|
||||
}
|
||||
|
||||
// 行号在所有 hunk 之后,应用总偏移
|
||||
return oldLine + offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量计算文件中多个旧行号对应的新行号
|
||||
* @param oldLines 旧行号数组
|
||||
* @param patch diff patch 文本
|
||||
* @returns Map<旧行号, 新行号>,被删除的行不在结果中
|
||||
*/
|
||||
export function calculateLineOffsets(
|
||||
oldLines: number[],
|
||||
patch?: string,
|
||||
): Map<number, number | null> {
|
||||
const result = new Map<number, number | null>();
|
||||
const hunks = parseHunksFromPatch(patch);
|
||||
|
||||
for (const oldLine of oldLines) {
|
||||
result.set(oldLine, calculateNewLineNumber(oldLine, hunks));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseDiffText(diffText: string): GitDiffFile[] {
|
||||
const files: GitDiffFile[] = [];
|
||||
const fileDiffs = diffText.split(/^diff --git /m).filter(Boolean);
|
||||
|
||||
for (const fileDiff of fileDiffs) {
|
||||
const headerMatch = fileDiff.match(/^a\/(.+?) b\/(.+?)[\r\n]/);
|
||||
if (!headerMatch) continue;
|
||||
|
||||
const filename = headerMatch[2];
|
||||
const patchStart = fileDiff.indexOf("@@");
|
||||
if (patchStart === -1) continue;
|
||||
|
||||
const patch = fileDiff.slice(patchStart);
|
||||
files.push({ filename, patch });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
8
core/src/shared/git-sdk/git-sdk.module.ts
Normal file
8
core/src/shared/git-sdk/git-sdk.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { GitSdkService } from "./git-sdk.service";
|
||||
|
||||
@Module({
|
||||
providers: [GitSdkService],
|
||||
exports: [GitSdkService],
|
||||
})
|
||||
export class GitSdkModule {}
|
||||
235
core/src/shared/git-sdk/git-sdk.service.ts
Normal file
235
core/src/shared/git-sdk/git-sdk.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { spawn, execSync } from "child_process";
|
||||
import type { GitCommit, GitChangedFile, GitDiffFile, GitRunOptions } from "./git-sdk.types";
|
||||
import { mapGitStatus, parseDiffText } from "./git-sdk-diff.utils";
|
||||
|
||||
@Injectable()
|
||||
export class GitSdkService {
|
||||
protected readonly defaultOptions: GitRunOptions = {
|
||||
cwd: process.cwd(),
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
};
|
||||
|
||||
runCommand(args: string[], options?: GitRunOptions): Promise<string> {
|
||||
const opts = { ...this.defaultOptions, ...options };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd: opts.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`Git 命令失败 (${code}): ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runCommandSync(args: string[], options?: GitRunOptions): string {
|
||||
const opts = { ...this.defaultOptions, ...options };
|
||||
return execSync(`git ${args.join(" ")}`, {
|
||||
cwd: opts.cwd,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: opts.maxBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
getRemoteUrl(options?: GitRunOptions): string | null {
|
||||
try {
|
||||
return this.runCommandSync(["remote", "get-url", "origin"], options).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentBranch(options?: GitRunOptions): string | null {
|
||||
try {
|
||||
return this.runCommandSync(["rev-parse", "--abbrev-ref", "HEAD"], options).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultBranch(options?: GitRunOptions): string {
|
||||
try {
|
||||
const result = this.runCommandSync(
|
||||
["symbolic-ref", "refs/remotes/origin/HEAD"],
|
||||
options,
|
||||
).trim();
|
||||
return result.replace("refs/remotes/origin/", "");
|
||||
} catch {
|
||||
// 回退到常见默认分支
|
||||
for (const branch of ["main", "master"]) {
|
||||
try {
|
||||
this.runCommandSync(["rev-parse", "--verify", `origin/${branch}`], options);
|
||||
return branch;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
|
||||
parseRepositoryFromRemoteUrl(remoteUrl: string): { owner: string; repo: string } | null {
|
||||
const match = remoteUrl.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
return { owner: match[1], repo: match[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getChangedFilesBetweenRefs(
|
||||
baseRef: string,
|
||||
headRef: string,
|
||||
options?: GitRunOptions,
|
||||
): Promise<GitChangedFile[]> {
|
||||
const resolvedBase = await this.resolveRef(baseRef, options);
|
||||
const resolvedHead = await this.resolveRef(headRef, options);
|
||||
const result = await this.runCommand(
|
||||
["diff", "--name-status", `${resolvedBase}..${resolvedHead}`],
|
||||
options,
|
||||
);
|
||||
|
||||
const files: GitChangedFile[] = [];
|
||||
const lines = result.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const [status, filename] = line.split("\t");
|
||||
files.push({
|
||||
filename,
|
||||
status: mapGitStatus(status),
|
||||
});
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两个 ref 之间的 diff(包含 patch 信息)
|
||||
* @param baseRef 基准 ref
|
||||
* @param headRef 目标 ref
|
||||
* @param options 运行选项
|
||||
* @returns 包含 filename 和 patch 的文件列表
|
||||
*/
|
||||
async getDiffBetweenRefs(
|
||||
baseRef: string,
|
||||
headRef: string,
|
||||
options?: GitRunOptions,
|
||||
): Promise<GitDiffFile[]> {
|
||||
const resolvedBase = await this.resolveRef(baseRef, options);
|
||||
const resolvedHead = await this.resolveRef(headRef, options);
|
||||
const result = await this.runCommand(["diff", `${resolvedBase}..${resolvedHead}`], options);
|
||||
|
||||
return parseDiffText(result);
|
||||
}
|
||||
|
||||
async getCommitsBetweenRefs(
|
||||
baseRef: string,
|
||||
headRef: string,
|
||||
options?: GitRunOptions,
|
||||
): Promise<GitCommit[]> {
|
||||
const resolvedBase = await this.resolveRef(baseRef, options);
|
||||
const resolvedHead = await this.resolveRef(headRef, options);
|
||||
const result = await this.runCommand(
|
||||
["log", "--format=%H|%s|%an|%ae|%aI", `${resolvedBase}..${resolvedHead}`],
|
||||
options,
|
||||
);
|
||||
|
||||
const commits: GitCommit[] = [];
|
||||
const lines = result.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const [sha, message, authorName, authorEmail, date] = line.split("|");
|
||||
commits.push({
|
||||
sha,
|
||||
message,
|
||||
author: {
|
||||
name: authorName,
|
||||
email: authorEmail,
|
||||
date,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return commits;
|
||||
}
|
||||
|
||||
async getFilesForCommit(sha: string, options?: GitRunOptions): Promise<string[]> {
|
||||
const result = await this.runCommand(["show", "--name-only", "--format=", sha], options);
|
||||
return result.trim().split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
async getFileContent(ref: string, filename: string, options?: GitRunOptions): Promise<string> {
|
||||
return this.runCommand(["show", `${ref}:${filename}`], options);
|
||||
}
|
||||
|
||||
getCommitDiff(sha: string, options?: GitRunOptions): GitDiffFile[] {
|
||||
try {
|
||||
const output = this.runCommandSync(["show", "--format=", "--patch", sha], options);
|
||||
return parseDiffText(output);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ git show 失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ref,支持本地分支、远程分支、commit SHA
|
||||
* 优先级:commit SHA > 本地分支 > origin/分支 > fetch后重试 > 原始值
|
||||
*/
|
||||
async resolveRef(ref: string, options?: GitRunOptions): Promise<string> {
|
||||
if (!ref) {
|
||||
throw new Error(`resolveRef: ref 参数不能为空。调用栈: ${new Error().stack}`);
|
||||
}
|
||||
if (/^[0-9a-f]{7,40}$/i.test(ref)) {
|
||||
return ref;
|
||||
}
|
||||
if (ref.startsWith("origin/")) {
|
||||
return ref;
|
||||
}
|
||||
try {
|
||||
await this.runCommand(["rev-parse", "--verify", ref], options);
|
||||
return ref;
|
||||
} catch {
|
||||
// 本地分支不存在
|
||||
}
|
||||
try {
|
||||
await this.runCommand(["rev-parse", "--verify", `origin/${ref}`], options);
|
||||
return `origin/${ref}`;
|
||||
} catch {
|
||||
// origin/分支也不存在
|
||||
}
|
||||
try {
|
||||
await this.runCommand(
|
||||
["fetch", "origin", `${ref}:refs/remotes/origin/${ref}`, "--depth=1"],
|
||||
options,
|
||||
);
|
||||
return `origin/${ref}`;
|
||||
} catch {
|
||||
// fetch 失败
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
25
core/src/shared/git-sdk/git-sdk.types.ts
Normal file
25
core/src/shared/git-sdk/git-sdk.types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface GitCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
date?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitChangedFile {
|
||||
filename: string;
|
||||
status: string;
|
||||
patch?: string;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
filename: string;
|
||||
patch: string;
|
||||
}
|
||||
|
||||
export interface GitRunOptions {
|
||||
cwd?: string;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
4
core/src/shared/git-sdk/index.ts
Normal file
4
core/src/shared/git-sdk/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./git-sdk.types";
|
||||
export * from "./git-sdk-diff.utils";
|
||||
export { GitSdkService } from "./git-sdk.service";
|
||||
export { GitSdkModule } from "./git-sdk.module";
|
||||
96
core/src/shared/i18n/i18n.spec.ts
Normal file
96
core/src/shared/i18n/i18n.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { initI18n, t, addLocaleResources, resetI18n } from "./i18n";
|
||||
import zhCN from "../../locales/zh-cn/translation.json";
|
||||
import en from "../../locales/en/translation.json";
|
||||
|
||||
beforeEach(() => {
|
||||
resetI18n();
|
||||
});
|
||||
|
||||
describe("initI18n", () => {
|
||||
it("默认初始化为 zh-CN", () => {
|
||||
initI18n("zh-CN");
|
||||
expect(t("common.options.dryRun")).toBe(zhCN["common.options.dryRun"]);
|
||||
});
|
||||
|
||||
it("可以指定英文", () => {
|
||||
initI18n("en");
|
||||
expect(t("common.options.dryRun")).toBe(en["common.options.dryRun"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("t", () => {
|
||||
it("返回中文公共翻译", () => {
|
||||
initI18n("zh-CN");
|
||||
expect(t("common.executionFailed", { error: "test" })).toBe("执行失败: test");
|
||||
});
|
||||
|
||||
it("返回英文公共翻译", () => {
|
||||
initI18n("en");
|
||||
expect(t("common.executionFailed", { error: "test" })).toBe("Execution failed: test");
|
||||
});
|
||||
|
||||
it("key 不存在时返回 key 本身", () => {
|
||||
initI18n("zh-CN");
|
||||
expect(t("nonexistent.key")).toBe("nonexistent.key");
|
||||
});
|
||||
|
||||
it("未初始化时自动初始化", () => {
|
||||
const result = t("common.options.dryRun");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addLocaleResources", () => {
|
||||
it("注册 Extension 命名空间并可通过 t 访问(中文)", () => {
|
||||
initI18n("zh-CN");
|
||||
addLocaleResources("build", {
|
||||
"zh-CN": { description: "构建插件", buildFailed: "构建失败: {{error}}" },
|
||||
en: { description: "Build plugins", buildFailed: "Build failed: {{error}}" },
|
||||
});
|
||||
expect(t("build:description")).toBe("构建插件");
|
||||
expect(t("build:buildFailed", { error: "test" })).toBe("构建失败: test");
|
||||
});
|
||||
|
||||
it("注册 Extension 命名空间并可通过 t 访问(英文)", () => {
|
||||
initI18n("en");
|
||||
addLocaleResources("build", {
|
||||
"zh-CN": { description: "构建插件" },
|
||||
en: { description: "Build plugins" },
|
||||
});
|
||||
expect(t("build:description")).toBe("Build plugins");
|
||||
});
|
||||
|
||||
it("多个命名空间互不干扰", () => {
|
||||
initI18n("zh-CN");
|
||||
addLocaleResources("ext-a", {
|
||||
"zh-CN": { name: "扩展A" },
|
||||
en: { name: "Extension A" },
|
||||
});
|
||||
addLocaleResources("ext-b", {
|
||||
"zh-CN": { name: "扩展B" },
|
||||
en: { name: "Extension B" },
|
||||
});
|
||||
expect(t("ext-a:name")).toBe("扩展A");
|
||||
expect(t("ext-b:name")).toBe("扩展B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("语言包完整性", () => {
|
||||
it("zh-cn 和 en 的 key 数量一致", () => {
|
||||
const zhKeys = Object.keys(zhCN).sort();
|
||||
const enKeys = Object.keys(en).sort();
|
||||
expect(zhKeys).toEqual(enKeys);
|
||||
});
|
||||
|
||||
it("所有 key 的值都不为空", () => {
|
||||
for (const [_key, value] of Object.entries(zhCN)) {
|
||||
expect(value).toBeTruthy();
|
||||
expect(typeof value).toBe("string");
|
||||
}
|
||||
for (const [_key, value] of Object.entries(en)) {
|
||||
expect(value).toBeTruthy();
|
||||
expect(typeof value).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
86
core/src/shared/i18n/i18n.ts
Normal file
86
core/src/shared/i18n/i18n.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as i18nextModule from "i18next";
|
||||
import type { TOptions, i18n } from "i18next";
|
||||
|
||||
// 兼容 CJS/ESM 混合环境
|
||||
const i18next: i18n =
|
||||
(i18nextModule as unknown as { default: i18n }).default || (i18nextModule as unknown as i18n);
|
||||
import { detectLocale } from "./locale-detect";
|
||||
import zhCN from "../../locales/zh-cn/translation.json";
|
||||
import en from "../../locales/en/translation.json";
|
||||
|
||||
/** 默认命名空间 */
|
||||
const DEFAULT_NS = "translation";
|
||||
|
||||
/** 是否已初始化 */
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 i18n
|
||||
* 当提供 resources 且无后端加载器时,i18next.init() 同步完成
|
||||
* @param lang 指定语言,不传则自动检测
|
||||
*/
|
||||
export function initI18n(lang?: string): void {
|
||||
if (initialized) return;
|
||||
const lng = lang || detectLocale();
|
||||
// i18next v25+ 移除了 initSync,但提供内联 resources 时 init() 同步完成
|
||||
void i18next.init({
|
||||
lng,
|
||||
fallbackLng: "zh-CN",
|
||||
defaultNS: DEFAULT_NS,
|
||||
ns: [DEFAULT_NS],
|
||||
resources: {
|
||||
"zh-CN": { [DEFAULT_NS]: zhCN },
|
||||
en: { [DEFAULT_NS]: en },
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
returnNull: false,
|
||||
returnEmptyString: false,
|
||||
// i18next v25.8+ 会在 init 时输出 locize.com 推广日志
|
||||
showSupportNotice: false,
|
||||
});
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 i18n 状态(仅用于测试)
|
||||
*/
|
||||
export function resetI18n(): void {
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
* 装饰器和运行时均可使用
|
||||
* @param key 翻译 key
|
||||
* @param options 插值参数
|
||||
*/
|
||||
export function t(key: string, options?: TOptions): string {
|
||||
if (!initialized) {
|
||||
initI18n();
|
||||
}
|
||||
return i18next.t(key, options) as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为外部 Extension 注册语言资源
|
||||
* @param ns 命名空间(通常为 Extension name)
|
||||
* @param resources 语言资源,key 为语言代码,值为翻译对象
|
||||
*/
|
||||
export function addLocaleResources(
|
||||
ns: string,
|
||||
resources: Record<string, Record<string, unknown>>,
|
||||
): void {
|
||||
if (!initialized) {
|
||||
initI18n();
|
||||
}
|
||||
for (const [lng, translations] of Object.entries(resources)) {
|
||||
i18next.addResourceBundle(lng, ns, translations, true, true);
|
||||
}
|
||||
if (!i18next.options.ns) {
|
||||
i18next.options.ns = [DEFAULT_NS, ns];
|
||||
} else if (Array.isArray(i18next.options.ns) && !i18next.options.ns.includes(ns)) {
|
||||
i18next.options.ns.push(ns);
|
||||
}
|
||||
}
|
||||
1
core/src/shared/i18n/index.ts
Normal file
1
core/src/shared/i18n/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { initI18n, t, addLocaleResources } from "./i18n";
|
||||
134
core/src/shared/i18n/locale-detect.ts
Normal file
134
core/src/shared/i18n/locale-detect.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
import { homedir, platform } from "os";
|
||||
|
||||
/** 默认语言 */
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
|
||||
/** 配置文件名 */
|
||||
const CONFIG_FILE_NAME = "spaceflow.json";
|
||||
|
||||
/** .spaceflow 目录名 */
|
||||
const SPACEFLOW_DIR = ".spaceflow";
|
||||
|
||||
/**
|
||||
* 标准化 locale 字符串为 BCP 47 格式
|
||||
* @example "zh_CN" → "zh-CN", "zh-Hans" → "zh-CN", "en_US.UTF-8" → "en-US"
|
||||
*/
|
||||
function normalizeLocale(raw: string): string | undefined {
|
||||
const cleaned = raw.replace(/\..*$/, "").trim();
|
||||
// 处理 Apple 格式:zh-Hans → zh-CN, zh-Hant → zh-TW
|
||||
if (/^zh[-_]?Hans/i.test(cleaned)) return "zh-CN";
|
||||
if (/^zh[-_]?Hant/i.test(cleaned)) return "zh-TW";
|
||||
// 处理标准格式:zh_CN / zh-CN / en_US / en-US
|
||||
const match = cleaned.match(/^([a-z]{2})[-_]([A-Z]{2})/i);
|
||||
if (match) return `${match[1].toLowerCase()}-${match[2].toUpperCase()}`;
|
||||
// 处理纯语言代码:zh → zh-CN, en → en
|
||||
const langOnly = cleaned.match(/^([a-z]{2})$/i);
|
||||
if (langOnly) {
|
||||
const lang = langOnly[1].toLowerCase();
|
||||
return lang === "zh" ? "zh-CN" : lang;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 spaceflow.json 读取 lang 字段
|
||||
* 按优先级从高到低查找:项目 > 全局
|
||||
*/
|
||||
function readLangFromConfig(): string | undefined {
|
||||
const paths = [
|
||||
join(process.cwd(), SPACEFLOW_DIR, CONFIG_FILE_NAME),
|
||||
join(homedir(), SPACEFLOW_DIR, CONFIG_FILE_NAME),
|
||||
];
|
||||
for (const configPath of paths) {
|
||||
if (!existsSync(configPath)) continue;
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8");
|
||||
const config = JSON.parse(content) as Record<string, unknown>;
|
||||
if (typeof config.lang === "string" && config.lang.length > 0) {
|
||||
return normalizeLocale(config.lang) ?? config.lang;
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从系统环境变量推断 locale
|
||||
* 解析 LANG 格式如 "zh_CN.UTF-8" → "zh-CN"
|
||||
*/
|
||||
function readEnvLocale(): string | undefined {
|
||||
const raw = process.env.LC_ALL || process.env.LANG;
|
||||
if (!raw) return undefined;
|
||||
return normalizeLocale(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS:通过 defaults read 获取系统首选语言
|
||||
* AppleLanguages 比 LANG 环境变量更准确地反映用户实际语言偏好
|
||||
*/
|
||||
function readMacOSLocale(): string | undefined {
|
||||
try {
|
||||
const output = execSync("defaults read -g AppleLanguages", {
|
||||
encoding: "utf-8",
|
||||
timeout: 500,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
// 输出格式: (\n "zh-Hans-CN",\n "en-CN"\n)
|
||||
const match = output.match(/"([^"]+)"/);
|
||||
if (!match) return undefined;
|
||||
return normalizeLocale(match[1]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows:通过 PowerShell 获取系统 UI 语言
|
||||
*/
|
||||
function readWindowsLocale(): string | undefined {
|
||||
try {
|
||||
const output = execSync('powershell -NoProfile -Command "(Get-Culture).Name"', {
|
||||
encoding: "utf-8",
|
||||
timeout: 1000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return normalizeLocale(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取操作系统级别的语言偏好
|
||||
* macOS: defaults read -g AppleLanguages
|
||||
* Windows: PowerShell Get-Culture
|
||||
*/
|
||||
function readOSLocale(): string | undefined {
|
||||
const os = platform();
|
||||
if (os === "darwin") return readMacOSLocale();
|
||||
if (os === "win32") return readWindowsLocale();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前语言
|
||||
*
|
||||
* 优先级:
|
||||
* 1. 环境变量 SPACEFLOW_LANG
|
||||
* 2. spaceflow.json 中的 lang 字段(项目 > 全局)
|
||||
* 3. 操作系统语言偏好(macOS AppleLanguages / Windows Get-Culture)
|
||||
* 4. 系统环境变量 LC_ALL / LANG
|
||||
* 5. 回退到 zh-CN
|
||||
*/
|
||||
export function detectLocale(): string {
|
||||
const envLang = process.env.SPACEFLOW_LANG;
|
||||
if (envLang) return normalizeLocale(envLang) ?? envLang;
|
||||
return readLangFromConfig() || readOSLocale() || readEnvLocale() || DEFAULT_LOCALE;
|
||||
}
|
||||
94
core/src/shared/llm-jsonput/index.ts
Normal file
94
core/src/shared/llm-jsonput/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
import type { LlmJsonPutSchema, LlmJsonSchema } from "./types";
|
||||
|
||||
export type { LlmJsonPutSchema, LlmJsonSchema, LlmJsonSchemaType } from "./types";
|
||||
|
||||
export interface ParseOptions {
|
||||
disableRequestRetry?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmJsonPutOptions {
|
||||
llmRequest?: (prompt: { systemPrompt: string; userPrompt: string }) => Promise<string>;
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
const JSON_FORMAT_INSTRUCTION = `请严格以 JSON 格式输出结果,不要输出任何其他内容,格式如下:`;
|
||||
|
||||
export class LlmJsonPut<T = any> {
|
||||
public readonly jsonFormatInstruction: string;
|
||||
constructor(
|
||||
protected schema: LlmJsonPutSchema,
|
||||
protected opts?: LlmJsonPutOptions,
|
||||
) {
|
||||
this.jsonFormatInstruction = this.getJsonFormatInstruction(schema);
|
||||
}
|
||||
|
||||
isMatched(prompt: string): boolean {
|
||||
return prompt.includes(JSON_FORMAT_INSTRUCTION);
|
||||
}
|
||||
|
||||
getSchema(): Record<string, unknown> {
|
||||
return this.schema as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
getJsonFormatInstruction(schema: LlmJsonPutSchema): string {
|
||||
const generateExample = (s: LlmJsonSchema): any => {
|
||||
if (s.type === "object") {
|
||||
const obj: any = {};
|
||||
for (const [key, value] of Object.entries(s.properties || {})) {
|
||||
obj[key] = generateExample(value);
|
||||
}
|
||||
return obj;
|
||||
} else if (s.type === "array") {
|
||||
return [generateExample(s.items!)];
|
||||
}
|
||||
return s.description || `<${s.type}>`;
|
||||
};
|
||||
|
||||
const example = JSON.stringify(generateExample(schema), null, 2);
|
||||
|
||||
return `${JSON_FORMAT_INSTRUCTION}\n\`\`\`json\n${example}\`\`\`\n注意:只输出 JSON,不要包含 markdown 代码块或其他文字。`;
|
||||
}
|
||||
|
||||
async parse(input: string, opts?: ParseOptions): Promise<T> {
|
||||
let content = input.trim();
|
||||
|
||||
// 尝试移除 markdown 代码块
|
||||
if (content.startsWith("```")) {
|
||||
content = content.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
// 如果原生解析失败,尝试修复
|
||||
const repaired = jsonrepair(content);
|
||||
return JSON.parse(repaired);
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果以上都不行就都丢给大模型,然后在重新走parse,但是要记录重新执行的次数,最多5次
|
||||
let retryCount = 0;
|
||||
while (retryCount < 5 && !opts?.disableRequestRetry) {
|
||||
const response = await this.request(input);
|
||||
return response;
|
||||
}
|
||||
throw new Error(
|
||||
`无法解析或修复 LLM 返回的 JSON: ${err instanceof Error ? err.message : String(err)}\n原始内容: ${input}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async request(userPrompt: string): Promise<T> {
|
||||
if (!this.opts?.llmRequest) {
|
||||
throw new Error("未配置 llmRequest 方法,无法发起请求");
|
||||
}
|
||||
|
||||
const systemPrompt = `${this.opts.systemPrompt ? this.opts.systemPrompt + "\n" : ""}${this.jsonFormatInstruction}`;
|
||||
const response = await this.opts.llmRequest({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
});
|
||||
return this.parse(response, { disableRequestRetry: true });
|
||||
}
|
||||
}
|
||||
17
core/src/shared/llm-jsonput/types.ts
Normal file
17
core/src/shared/llm-jsonput/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type LlmJsonSchemaType = "string" | "number" | "boolean" | "object" | "array" | "null";
|
||||
|
||||
export interface LlmJsonSchema {
|
||||
type: LlmJsonSchemaType;
|
||||
description?: string;
|
||||
properties?: Record<string, LlmJsonSchema>;
|
||||
items?: LlmJsonSchema;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
enum?: string[];
|
||||
}
|
||||
|
||||
export interface LlmJsonPutSchema extends LlmJsonSchema {
|
||||
type: "object";
|
||||
properties: Record<string, LlmJsonSchema>;
|
||||
required: string[];
|
||||
}
|
||||
131
core/src/shared/llm-proxy/adapters/claude-code.adapter.spec.ts
Normal file
131
core/src/shared/llm-proxy/adapters/claude-code.adapter.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { vi, type Mocked, type Mock } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { ClaudeCodeAdapter } from "./claude-code.adapter";
|
||||
import { ClaudeSetupService } from "../../claude-setup";
|
||||
import { LlmStreamEvent } from "../interfaces";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
describe("ClaudeAdapter", () => {
|
||||
let adapter: ClaudeCodeAdapter;
|
||||
let claudeSetupService: Mocked<ClaudeSetupService>;
|
||||
|
||||
const mockConfig = {
|
||||
claudeCode: {
|
||||
model: "claude-3-5-sonnet",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSetup = {
|
||||
configure: vi.fn().mockResolvedValue(undefined),
|
||||
backup: vi.fn().mockResolvedValue(undefined),
|
||||
restore: vi.fn().mockResolvedValue(undefined),
|
||||
withTemporaryConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClaudeCodeAdapter,
|
||||
{ provide: "LLM_PROXY_CONFIG", useValue: mockConfig },
|
||||
{ provide: ClaudeSetupService, useValue: mockSetup },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
adapter = module.get<ClaudeCodeAdapter>(ClaudeCodeAdapter);
|
||||
claudeSetupService = module.get(ClaudeSetupService);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(adapter).toBeDefined();
|
||||
expect(adapter.name).toBe("claude-code");
|
||||
});
|
||||
|
||||
describe("isConfigured", () => {
|
||||
it("should return true if claude config exists", () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if claude config is missing", async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClaudeCodeAdapter,
|
||||
{ provide: "LLM_PROXY_CONFIG", useValue: {} },
|
||||
{ provide: ClaudeSetupService, useValue: { configure: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
const unconfiguredAdapter = module.get<ClaudeCodeAdapter>(ClaudeCodeAdapter);
|
||||
expect(unconfiguredAdapter.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatStream", () => {
|
||||
it("should call claudeSetupService.configure and query with correct params", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockQueryResponse = (async function* () {
|
||||
yield { type: "assistant", message: { content: "hi" } };
|
||||
yield { type: "result", subtype: "success", response: { content: "hi" } };
|
||||
})();
|
||||
(query as Mock).mockReturnValue(mockQueryResponse);
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
const events: LlmStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(claudeSetupService.configure).toHaveBeenCalled();
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "hello",
|
||||
options: expect.objectContaining({
|
||||
model: "claude-3-5-sonnet",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual({ type: "text", content: "hi" });
|
||||
});
|
||||
|
||||
it("should handle EPIPE error during stream", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const epipeError = new Error("EPIPE: broken pipe");
|
||||
(epipeError as any).code = "EPIPE";
|
||||
|
||||
(query as Mock).mockImplementation(() => {
|
||||
throw epipeError;
|
||||
});
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
const events: LlmStreamEvent[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "error",
|
||||
message: expect.stringContaining("连接中断 (EPIPE)"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw other errors during stream", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const unexpectedError = new Error("Unexpected error");
|
||||
|
||||
(query as Mock).mockImplementation(() => {
|
||||
throw unexpectedError;
|
||||
});
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// ignore
|
||||
}
|
||||
}).rejects.toThrow("Unexpected error");
|
||||
});
|
||||
});
|
||||
});
|
||||
208
core/src/shared/llm-proxy/adapters/claude-code.adapter.ts
Normal file
208
core/src/shared/llm-proxy/adapters/claude-code.adapter.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { spawn } from "child_process";
|
||||
import type { LlmAdapter } from "./llm-adapter.interface";
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmStreamEvent,
|
||||
LlmProxyConfig,
|
||||
} from "../interfaces";
|
||||
import { ClaudeSetupService } from "../../claude-setup";
|
||||
import { shouldLog } from "../../verbose";
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeCodeAdapter implements LlmAdapter {
|
||||
readonly name = "claude-code";
|
||||
|
||||
constructor(
|
||||
@Inject("LLM_PROXY_CONFIG") private readonly config: LlmProxyConfig,
|
||||
private readonly claudeSetupService: ClaudeSetupService,
|
||||
) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.config.claudeCode;
|
||||
}
|
||||
|
||||
async chat(messages: LlmMessage[], options?: LlmRequestOptions): Promise<LlmResponse> {
|
||||
let result: LlmResponse = { content: "" };
|
||||
|
||||
for await (const event of this.chatStream(messages, options)) {
|
||||
if (event.type === "result") {
|
||||
result = event.response;
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async *chatStream(
|
||||
messages: LlmMessage[],
|
||||
options?: LlmRequestOptions,
|
||||
): AsyncIterable<LlmStreamEvent> {
|
||||
// 备份原有配置
|
||||
await this.claudeSetupService.backup();
|
||||
|
||||
try {
|
||||
// 应用临时配置
|
||||
await this.claudeSetupService.configure(options?.verbose);
|
||||
|
||||
const claudeConf = this.config.claudeCode;
|
||||
|
||||
if (!claudeConf) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: "[LLMProxy.ClaudeCodeAdapter.chatStream] 未配置 claude 设置",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const model = options?.model || claudeConf.model || "claude-sonnet-4-5";
|
||||
const systemPrompt = this.extractSystemPrompt(messages);
|
||||
const userPrompt = this.extractUserPrompt(messages);
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(
|
||||
`[LLMProxy.ClaudeCodeAdapter.chatStream] 配置: Model=${model}, BaseURL=${claudeConf.baseUrl || "(默认)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const handleUncaughtError = (err: Error) => {
|
||||
if ((err as any).code === "EPIPE") {
|
||||
console.error(
|
||||
"[LLMProxy.ClaudeCodeAdapter.chatStream] EPIPE 错误: Claude CLI 子进程意外退出",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
process.on("uncaughtException", handleUncaughtError);
|
||||
|
||||
try {
|
||||
const spawnEnv = { ...process.env };
|
||||
if (claudeConf.baseUrl) spawnEnv.ANTHROPIC_BASE_URL = claudeConf.baseUrl;
|
||||
if (claudeConf.authToken) spawnEnv.ANTHROPIC_AUTH_TOKEN = claudeConf.authToken;
|
||||
|
||||
const spawnClaudeCodeProcess = (spawnOptions: SpawnOptions) => {
|
||||
if (shouldLog(options?.verbose, 2)) {
|
||||
console.log(
|
||||
`[LLMProxy.ClaudeCodeAdapter.chatStream] Spawning: ${spawnOptions.command} ${spawnOptions.args?.join(" ")}`,
|
||||
);
|
||||
}
|
||||
const child = spawn(spawnOptions.command, spawnOptions.args || [], {
|
||||
...spawnOptions,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: spawnEnv,
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
console.error(`[LLMProxy.ClaudeCodeAdapter.chatStream] CLI stderr: ${data.toString()}`);
|
||||
});
|
||||
|
||||
return child;
|
||||
};
|
||||
|
||||
const queryOptions: Parameters<typeof query>[0]["options"] = {
|
||||
model,
|
||||
systemPrompt,
|
||||
permissionMode: "default",
|
||||
spawnClaudeCodeProcess,
|
||||
};
|
||||
|
||||
if (options?.allowedTools?.length) {
|
||||
queryOptions.allowedTools = options.allowedTools as any;
|
||||
}
|
||||
|
||||
if (options?.jsonSchema) {
|
||||
queryOptions.outputFormat = {
|
||||
type: "json_schema",
|
||||
schema: options.jsonSchema.getSchema(),
|
||||
};
|
||||
}
|
||||
|
||||
const response = query({
|
||||
prompt: userPrompt,
|
||||
options: queryOptions,
|
||||
});
|
||||
|
||||
let finalContent = "";
|
||||
let structuredOutput: unknown = undefined;
|
||||
|
||||
for await (const message of response) {
|
||||
if (message.type === "assistant") {
|
||||
const content = message.message.content;
|
||||
if (typeof content === "string") {
|
||||
yield { type: "text", content };
|
||||
finalContent += content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
yield { type: "text", content: block.text };
|
||||
finalContent += block.text;
|
||||
} else if (block.type === "tool_use") {
|
||||
yield { type: "tool_use", name: block.name, input: block.input };
|
||||
} else if (block.type === ("thought" as any)) {
|
||||
yield { type: "thought", content: (block as any).thought };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "result") {
|
||||
if (message.subtype === "success") {
|
||||
if (message.structured_output) {
|
||||
structuredOutput = message.structured_output;
|
||||
}
|
||||
yield {
|
||||
type: "result",
|
||||
response: {
|
||||
content: finalContent,
|
||||
structuredOutput,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
yield {
|
||||
type: "error",
|
||||
message: `[LLMProxy.ClaudeCodeAdapter.chatStream] ${message.errors?.join(", ") || "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.code === "EPIPE" || error?.message?.includes("EPIPE")) {
|
||||
yield {
|
||||
type: "error",
|
||||
message:
|
||||
"[LLMProxy.ClaudeCodeAdapter.chatStream] 连接中断 (EPIPE)。请检查:\n" +
|
||||
"1. ANTHROPIC_AUTH_TOKEN 环境变量是否正确设置\n" +
|
||||
"2. ANTHROPIC_BASE_URL 是否与 Claude Agent SDK 兼容\n" +
|
||||
"3. Claude CLI 是否已正确安装",
|
||||
};
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
process.removeListener("uncaughtException", handleUncaughtError);
|
||||
}
|
||||
} finally {
|
||||
// 恢复原有配置
|
||||
await this.claudeSetupService.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private extractSystemPrompt(messages: LlmMessage[]): string {
|
||||
const systemMessage = messages.find((m) => m.role === "system");
|
||||
return systemMessage?.content || "";
|
||||
}
|
||||
|
||||
private extractUserPrompt(messages: LlmMessage[]): string {
|
||||
const userMessages = messages.filter((m) => m.role === "user");
|
||||
return userMessages.map((m) => m.content).join("\n\n");
|
||||
}
|
||||
|
||||
isSupportJsonSchema(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
4
core/src/shared/llm-proxy/adapters/index.ts
Normal file
4
core/src/shared/llm-proxy/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./llm-adapter.interface";
|
||||
export * from "./claude-code.adapter";
|
||||
export * from "./openai.adapter";
|
||||
export * from "./open-code.adapter";
|
||||
23
core/src/shared/llm-proxy/adapters/llm-adapter.interface.ts
Normal file
23
core/src/shared/llm-proxy/adapters/llm-adapter.interface.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { LlmMessage, LlmRequestOptions, LlmResponse, LlmStreamEvent } from "../interfaces";
|
||||
import type { VerboseLevel } from "../../verbose";
|
||||
|
||||
export interface LlmAdapterConfig {
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
export interface LlmAdapter {
|
||||
readonly name: string;
|
||||
|
||||
chat(messages: LlmMessage[], options?: LlmRequestOptions): Promise<LlmResponse>;
|
||||
|
||||
chatStream(messages: LlmMessage[], options?: LlmRequestOptions): AsyncIterable<LlmStreamEvent>;
|
||||
|
||||
isConfigured(): boolean;
|
||||
|
||||
isSupportJsonSchema(): boolean;
|
||||
}
|
||||
|
||||
export const LLM_ADAPTER = Symbol("LLM_ADAPTER");
|
||||
342
core/src/shared/llm-proxy/adapters/open-code.adapter.ts
Normal file
342
core/src/shared/llm-proxy/adapters/open-code.adapter.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { createOpencode } from "@opencode-ai/sdk";
|
||||
import type { LlmAdapter } from "./llm-adapter.interface";
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmStreamEvent,
|
||||
LlmProxyConfig,
|
||||
OpenCodeAdapterConfig,
|
||||
} from "../interfaces";
|
||||
import { shouldLog } from "../../verbose";
|
||||
|
||||
@Injectable()
|
||||
export class OpenCodeAdapter implements LlmAdapter {
|
||||
readonly name = "open-code";
|
||||
|
||||
constructor(@Inject("LLM_PROXY_CONFIG") private readonly config: LlmProxyConfig) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.config.openCode;
|
||||
}
|
||||
|
||||
async chat(messages: LlmMessage[], options?: LlmRequestOptions): Promise<LlmResponse> {
|
||||
let result: LlmResponse = { content: "" };
|
||||
|
||||
for await (const event of this.chatStream(messages, options)) {
|
||||
if (event.type === "result") {
|
||||
result = event.response;
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async *chatStream(
|
||||
messages: LlmMessage[],
|
||||
options?: LlmRequestOptions,
|
||||
): AsyncIterable<LlmStreamEvent> {
|
||||
const openCodeConf = this.config.openCode;
|
||||
|
||||
if (!openCodeConf) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: "[LLMProxy.OpenCodeAdapter.chatStream] 未配置 openCode 设置",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const providerID = openCodeConf.providerID || "openai";
|
||||
const configModel = options?.model || openCodeConf.model || "gpt-4o";
|
||||
const model = configModel.includes("/") ? configModel : `${providerID}/${configModel}`;
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 配置: Model=${model}, ProviderID=${providerID}, BaseURL=${openCodeConf.baseUrl || "默认"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 OpenCode 实例(自动启动服务器,使用动态端口避免冲突)
|
||||
let opencode: Awaited<ReturnType<typeof createOpencode>> | null = null;
|
||||
const port = 4096 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// 确保进程退出时关闭服务器
|
||||
const cleanup = () => {
|
||||
if (opencode?.server) {
|
||||
opencode.server.close();
|
||||
opencode = null;
|
||||
}
|
||||
};
|
||||
process.once("exit", cleanup);
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
|
||||
try {
|
||||
opencode = await createOpencode({
|
||||
port,
|
||||
config: this.buildOpenCodeConfig(openCodeConf, model),
|
||||
});
|
||||
|
||||
const { client } = opencode;
|
||||
|
||||
// 设置 provider 认证(使用自定义 provider ID)
|
||||
const customProviderID = "custom-openai";
|
||||
if (openCodeConf.apiKey) {
|
||||
await client.auth.set({
|
||||
path: { id: customProviderID },
|
||||
body: { type: "api", key: openCodeConf.apiKey },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await client.session.create({
|
||||
body: { title: `spaceflow-${Date.now()}` },
|
||||
});
|
||||
|
||||
if (!session.data?.id) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: "[LLMProxy.OpenCodeAdapter.chatStream] 创建 session 失败",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = session.data.id;
|
||||
const systemPrompt = this.extractSystemPrompt(messages);
|
||||
const userPrompt = this.extractUserPrompt(messages);
|
||||
|
||||
if (systemPrompt) {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: systemPrompt }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 从原始 model 中提取 modelID,但使用自定义 provider ID
|
||||
const [, modelID] = model.includes("/") ? model.split("/", 2) : [customProviderID, model];
|
||||
|
||||
if (shouldLog(options?.verbose, 2)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 发送 prompt: model=${customProviderID}/${modelID}, userPrompt长度=${userPrompt.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
model: { providerID: customProviderID, modelID },
|
||||
parts: [{ type: "text", text: userPrompt }],
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldLog(options?.verbose, 2)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 完整响应对象:\n${JSON.stringify(result, null, 2)}`,
|
||||
);
|
||||
console.log(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] result.data:\n${JSON.stringify(result.data, null, 2)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let finalContent = "";
|
||||
|
||||
if (result.data?.parts) {
|
||||
for (const part of result.data.parts) {
|
||||
const partType = part.type;
|
||||
|
||||
switch (partType) {
|
||||
case "text": {
|
||||
const text = (part as any).text || "";
|
||||
yield { type: "text", content: text };
|
||||
finalContent += text;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
// 工具调用(ToolPart)
|
||||
const toolPart = part as any;
|
||||
const state = toolPart.state || {};
|
||||
yield {
|
||||
type: "tool_use",
|
||||
name: toolPart.tool || "unknown",
|
||||
input: state.input || {},
|
||||
status: state.status,
|
||||
output: state.output,
|
||||
title: state.title,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent": {
|
||||
// 子代理调用(AgentPart)
|
||||
const agentPart = part as any;
|
||||
yield {
|
||||
type: "agent",
|
||||
name: agentPart.name || "unknown",
|
||||
source: agentPart.source?.value,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "subtask": {
|
||||
// 子任务
|
||||
const subtaskPart = part as any;
|
||||
yield {
|
||||
type: "subtask",
|
||||
agent: subtaskPart.agent,
|
||||
prompt: subtaskPart.prompt,
|
||||
description: subtaskPart.description,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "step-start": {
|
||||
yield {
|
||||
type: "step_start",
|
||||
snapshot: (part as any).snapshot,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "step-finish": {
|
||||
const stepPart = part as any;
|
||||
yield {
|
||||
type: "step_finish",
|
||||
reason: stepPart.reason,
|
||||
tokens: stepPart.tokens,
|
||||
cost: stepPart.cost,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case "reasoning": {
|
||||
const reasoningPart = part as any;
|
||||
yield {
|
||||
type: "reasoning",
|
||||
content: reasoningPart.text || "",
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// 其他类型(file, snapshot, patch, retry, compaction 等)暂不处理
|
||||
if (shouldLog(options?.verbose, 2)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 未处理的 part 类型: ${partType}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLog(options?.verbose, 1) && !finalContent) {
|
||||
console.warn(
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 警告: 响应内容为空,parts=${JSON.stringify(result.data?.parts)}`,
|
||||
);
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
response: {
|
||||
content: finalContent,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await client.session.delete({ path: { id: sessionId } });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
} catch (error: any) {
|
||||
yield {
|
||||
type: "error",
|
||||
message:
|
||||
`[LLMProxy.OpenCodeAdapter.chatStream] 错误: ${error.message}\n` +
|
||||
`请检查:\n` +
|
||||
`1. baseUrl 配置是否正确\n` +
|
||||
`2. apiKey 是否有效\n` +
|
||||
`3. 模型配置是否有效`,
|
||||
};
|
||||
} finally {
|
||||
// 移除事件监听器
|
||||
process.removeListener("exit", cleanup);
|
||||
process.removeListener("SIGINT", cleanup);
|
||||
process.removeListener("SIGTERM", cleanup);
|
||||
// 关闭服务器
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 OpenCode 配置
|
||||
*/
|
||||
private buildOpenCodeConfig(
|
||||
openCodeConf: OpenCodeAdapterConfig,
|
||||
model: string,
|
||||
): Record<string, any> {
|
||||
// 使用自定义 provider ID(如 custom-openai)而不是 openai
|
||||
// 因为 OpenCode 会根据 providerID 决定使用哪个 SDK 方法
|
||||
// openai provider 会调用 sdk.responses(),而自定义 provider 使用 @ai-sdk/openai-compatible
|
||||
const customProviderID = "custom-openai";
|
||||
const [, modelID] = model.includes("/") ? model.split("/", 2) : [customProviderID, model];
|
||||
|
||||
// 使用 @ai-sdk/openai-compatible,使用 Chat Completions API (/chat/completions)
|
||||
const config: Record<string, any> = {
|
||||
model: `${customProviderID}/${modelID}`,
|
||||
provider: {
|
||||
[customProviderID]: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Custom OpenAI Compatible",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 配置 provider baseURL
|
||||
if (openCodeConf.baseUrl) {
|
||||
config.provider[customProviderID].options = {
|
||||
baseURL: openCodeConf.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// 注册自定义模型
|
||||
config.provider[customProviderID].models = {
|
||||
[modelID]: {
|
||||
name: modelID,
|
||||
attachment: true,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
},
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 16000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private extractSystemPrompt(messages: LlmMessage[]): string {
|
||||
const systemMessage = messages.find((m) => m.role === "system");
|
||||
return systemMessage?.content || "";
|
||||
}
|
||||
|
||||
private extractUserPrompt(messages: LlmMessage[]): string {
|
||||
const userMessages = messages.filter((m) => m.role === "user");
|
||||
return userMessages.map((m) => m.content).join("\n\n");
|
||||
}
|
||||
|
||||
isSupportJsonSchema(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
215
core/src/shared/llm-proxy/adapters/openai.adapter.spec.ts
Normal file
215
core/src/shared/llm-proxy/adapters/openai.adapter.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { OpenAIAdapter } from "./openai.adapter";
|
||||
import OpenAI from "openai";
|
||||
|
||||
vi.mock("openai");
|
||||
|
||||
describe("OpenAIAdapter", () => {
|
||||
let adapter: OpenAIAdapter;
|
||||
let mockOpenAIInstance: any;
|
||||
|
||||
const mockConfig = {
|
||||
openai: {
|
||||
apiKey: "test-key",
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockOpenAIInstance = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
(OpenAI as unknown as Mock).mockImplementation(function () {
|
||||
return mockOpenAIInstance;
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OpenAIAdapter, { provide: "LLM_PROXY_CONFIG", useValue: mockConfig }],
|
||||
}).compile();
|
||||
|
||||
adapter = module.get<OpenAIAdapter>(OpenAIAdapter);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(adapter).toBeDefined();
|
||||
expect(adapter.name).toBe("openai");
|
||||
});
|
||||
|
||||
describe("isConfigured", () => {
|
||||
it("should return true if openai config exists", () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if openai config is missing", async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OpenAIAdapter, { provide: "LLM_PROXY_CONFIG", useValue: {} }],
|
||||
}).compile();
|
||||
const unconfiguredAdapter = module.get<OpenAIAdapter>(OpenAIAdapter);
|
||||
expect(unconfiguredAdapter.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
it("should call openai.chat.completions.create with correct params", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockResponse = {
|
||||
choices: [{ message: { content: "hi" } }],
|
||||
model: "gpt-4o",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
};
|
||||
mockOpenAIInstance.chat.completions.create.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await adapter.chat(messages);
|
||||
|
||||
expect(mockOpenAIInstance.chat.completions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
);
|
||||
expect(result.content).toBe("hi");
|
||||
expect(result.usage?.totalTokens).toBe(15);
|
||||
});
|
||||
|
||||
it("should handle API error", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const apiError = new Error("API Error");
|
||||
(apiError as any).status = 401;
|
||||
Object.setPrototypeOf(apiError, OpenAI.APIError.prototype);
|
||||
mockOpenAIInstance.chat.completions.create.mockRejectedValue(apiError);
|
||||
|
||||
await expect(adapter.chat(messages)).rejects.toThrow("API 错误 (401)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatStream", () => {
|
||||
it("should handle streaming response", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockStream = (async function* () {
|
||||
yield { choices: [{ delta: { content: "h" } }] };
|
||||
yield { choices: [{ delta: { content: "i" } }] };
|
||||
})();
|
||||
mockOpenAIInstance.chat.completions.create.mockResolvedValue(mockStream);
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
const events: any[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toContainEqual({ type: "text", content: "h" });
|
||||
expect(events).toContainEqual({ type: "text", content: "i" });
|
||||
expect(events).toContainEqual({ type: "result", response: { content: "hi" } });
|
||||
});
|
||||
|
||||
it("should handle stream API error", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const apiError = new Error("Stream Error");
|
||||
(apiError as any).status = 500;
|
||||
Object.setPrototypeOf(apiError, OpenAI.APIError.prototype);
|
||||
mockOpenAIInstance.chat.completions.create.mockRejectedValue(apiError);
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
const events: any[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events[0]).toMatchObject({
|
||||
type: "error",
|
||||
message: expect.stringContaining("API 错误 (500)"),
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw non-API errors in chatStream", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
mockOpenAIInstance.chat.completions.create.mockRejectedValue(new Error("network"));
|
||||
|
||||
const stream = adapter.chatStream(messages);
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
}).rejects.toThrow("network");
|
||||
});
|
||||
|
||||
it("should handle chunk with empty delta", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockStream = (async function* () {
|
||||
yield { choices: [{ delta: {} }] };
|
||||
yield { choices: [{ delta: { content: "ok" } }] };
|
||||
})();
|
||||
mockOpenAIInstance.chat.completions.create.mockResolvedValue(mockStream);
|
||||
|
||||
const events: any[] = [];
|
||||
for await (const event of adapter.chatStream(messages)) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(events).toContainEqual({ type: "text", content: "ok" });
|
||||
expect(events).toContainEqual({ type: "result", response: { content: "ok" } });
|
||||
});
|
||||
|
||||
it("should yield error when openai not configured", async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OpenAIAdapter, { provide: "LLM_PROXY_CONFIG", useValue: {} }],
|
||||
}).compile();
|
||||
const unconfigured = module.get<OpenAIAdapter>(OpenAIAdapter);
|
||||
const events: any[] = [];
|
||||
for await (const event of unconfigured.chatStream([{ role: "user", content: "hi" }] as any)) {
|
||||
events.push(event);
|
||||
}
|
||||
expect(events[0]).toMatchObject({ type: "error" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat edge cases", () => {
|
||||
it("should throw non-API errors in chat", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
mockOpenAIInstance.chat.completions.create.mockRejectedValue(new Error("timeout"));
|
||||
await expect(adapter.chat(messages)).rejects.toThrow("timeout");
|
||||
});
|
||||
|
||||
it("should throw when openai not configured for chat", async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [OpenAIAdapter, { provide: "LLM_PROXY_CONFIG", useValue: {} }],
|
||||
}).compile();
|
||||
const unconfigured = module.get<OpenAIAdapter>(OpenAIAdapter);
|
||||
await expect(unconfigured.chat([{ role: "user", content: "hi" }] as any)).rejects.toThrow(
|
||||
"未配置 openai",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty content when choices empty", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
mockOpenAIInstance.chat.completions.create.mockResolvedValue({
|
||||
choices: [{ message: {} }],
|
||||
model: "gpt-4o",
|
||||
});
|
||||
const result = await adapter.chat(messages);
|
||||
expect(result.content).toBe("");
|
||||
expect(result.usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reuse cached client on second call", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
mockOpenAIInstance.chat.completions.create.mockResolvedValue({
|
||||
choices: [{ message: { content: "a" } }],
|
||||
model: "gpt-4o",
|
||||
});
|
||||
(OpenAI as unknown as Mock).mockClear();
|
||||
await adapter.chat(messages);
|
||||
await adapter.chat(messages);
|
||||
expect(OpenAI).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("isSupportJsonSchema should return false", () => {
|
||||
expect(adapter.isSupportJsonSchema()).toBe(false);
|
||||
});
|
||||
});
|
||||
153
core/src/shared/llm-proxy/adapters/openai.adapter.ts
Normal file
153
core/src/shared/llm-proxy/adapters/openai.adapter.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import OpenAI from "openai";
|
||||
import type { LlmAdapter } from "./llm-adapter.interface";
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmStreamEvent,
|
||||
LlmProxyConfig,
|
||||
} from "../interfaces";
|
||||
import { shouldLog } from "../../verbose";
|
||||
|
||||
@Injectable()
|
||||
export class OpenAIAdapter implements LlmAdapter {
|
||||
readonly name = "openai";
|
||||
|
||||
private client: OpenAI | null = null;
|
||||
|
||||
constructor(@Inject("LLM_PROXY_CONFIG") private readonly config: LlmProxyConfig) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.config.openai;
|
||||
}
|
||||
|
||||
private getClient(): OpenAI {
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const openaiConf = this.config.openai;
|
||||
|
||||
if (!openaiConf) {
|
||||
throw new Error("[LLMProxy.OpenAIAdapter.getClient] 未配置 openai 设置");
|
||||
}
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey: openaiConf.apiKey,
|
||||
baseURL: openaiConf.baseUrl || undefined,
|
||||
});
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async chat(messages: LlmMessage[], options?: LlmRequestOptions): Promise<LlmResponse> {
|
||||
const openaiConf = this.config.openai;
|
||||
|
||||
if (!openaiConf) {
|
||||
throw new Error("[LLMProxy.OpenAIAdapter.chat] 未配置 openai 设置");
|
||||
}
|
||||
|
||||
const client = this.getClient();
|
||||
const model = options?.model || openaiConf.model;
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenAIAdapter.chat] 配置: Model=${model}, BaseURL=${openaiConf.baseUrl || "(默认)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content || "";
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(
|
||||
`[LLMProxy.OpenAIAdapter.chat] 响应: Model=${response.model}, Usage=${response.usage?.total_tokens} tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: response.usage
|
||||
? {
|
||||
promptTokens: response.usage.prompt_tokens,
|
||||
completionTokens: response.usage.completion_tokens,
|
||||
totalTokens: response.usage.total_tokens,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
throw new Error(
|
||||
`[LLMProxy.OpenAIAdapter.chat] API 错误 (${error.status}): ${error.message}\n` +
|
||||
`请检查:\n` +
|
||||
`1. API Key 是否正确\n` +
|
||||
`2. Base URL 是否正确\n` +
|
||||
`3. 模型名称是否有效`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async *chatStream(
|
||||
messages: LlmMessage[],
|
||||
options?: LlmRequestOptions,
|
||||
): AsyncIterable<LlmStreamEvent> {
|
||||
const openaiConf = this.config.openai;
|
||||
|
||||
if (!openaiConf) {
|
||||
yield { type: "error", message: "[LLMProxy.OpenAIAdapter.chatStream] 未配置 openai 设置" };
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.getClient();
|
||||
const model = options?.model || openaiConf.model;
|
||||
|
||||
if (shouldLog(options?.verbose, 1)) {
|
||||
console.log(`[LLMProxy.OpenAIAdapter.chatStream] 配置: Model=${model}`);
|
||||
}
|
||||
try {
|
||||
const stream = await client.chat.completions.create({
|
||||
model,
|
||||
messages: messages,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullContent = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
yield { type: "text", content: delta };
|
||||
fullContent += delta;
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
response: {
|
||||
content: fullContent,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
yield {
|
||||
type: "error",
|
||||
message: `[LLMProxy.OpenAIAdapter.chatStream] API 错误 (${error.status}): ${error.message}`,
|
||||
};
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSupportJsonSchema(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
core/src/shared/llm-proxy/index.ts
Normal file
6
core/src/shared/llm-proxy/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./interfaces";
|
||||
export * from "./adapters";
|
||||
export * from "./llm-session";
|
||||
export * from "./llm-proxy.service";
|
||||
export * from "./llm-proxy.module";
|
||||
export * from "./stream-logger";
|
||||
32
core/src/shared/llm-proxy/interfaces/config.interface.ts
Normal file
32
core/src/shared/llm-proxy/interfaces/config.interface.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface ClaudeAdapterConfig {
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface OpenAIAdapterConfig {
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeAdapterConfig {
|
||||
model?: string;
|
||||
/** OpenCode 服务地址,默认 http://localhost:4096 */
|
||||
serverUrl?: string;
|
||||
/** 云厂商 API 地址(会动态写入 opencode.json 配置) */
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
providerID?: string;
|
||||
}
|
||||
|
||||
export type LLMMode = "claude-code" | "openai" | "gemini" | "open-code";
|
||||
|
||||
export interface LlmProxyConfig {
|
||||
defaultAdapter?: LLMMode;
|
||||
claudeCode?: ClaudeAdapterConfig;
|
||||
openai?: OpenAIAdapterConfig;
|
||||
openCode?: OpenCodeAdapterConfig;
|
||||
}
|
||||
|
||||
export const LLM_PROXY_CONFIG = Symbol("LLM_PROXY_CONFIG");
|
||||
4
core/src/shared/llm-proxy/interfaces/index.ts
Normal file
4
core/src/shared/llm-proxy/interfaces/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./message.interface";
|
||||
export * from "./session.interface";
|
||||
export * from "./config.interface";
|
||||
export type { VerboseLevel } from "../../verbose";
|
||||
48
core/src/shared/llm-proxy/interfaces/message.interface.ts
Normal file
48
core/src/shared/llm-proxy/interfaces/message.interface.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { LlmJsonPut } from "../../llm-jsonput";
|
||||
import type { VerboseLevel } from "../../verbose";
|
||||
|
||||
export type LlmRole = "system" | "user" | "assistant";
|
||||
|
||||
export interface LlmMessage {
|
||||
role: LlmRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LlmRequestOptions {
|
||||
model?: string;
|
||||
jsonSchema?: LlmJsonPut;
|
||||
stream?: boolean;
|
||||
verbose?: VerboseLevel;
|
||||
allowedTools?: string[];
|
||||
}
|
||||
|
||||
export interface LlmUsage {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface LlmResponse {
|
||||
content: string;
|
||||
structuredOutput?: unknown;
|
||||
usage?: LlmUsage;
|
||||
}
|
||||
|
||||
export type LlmStreamEvent =
|
||||
| { type: "text"; content: string }
|
||||
| {
|
||||
type: "tool_use";
|
||||
name: string;
|
||||
input: unknown;
|
||||
status?: string;
|
||||
output?: string;
|
||||
title?: string;
|
||||
}
|
||||
| { type: "thought"; content: string }
|
||||
| { type: "result"; response: LlmResponse }
|
||||
| { type: "error"; message: string }
|
||||
| { type: "agent"; name: string; source?: string }
|
||||
| { type: "subtask"; agent: string; prompt: string; description: string }
|
||||
| { type: "step_start"; snapshot?: string }
|
||||
| { type: "step_finish"; reason: string; tokens?: unknown; cost?: number }
|
||||
| { type: "reasoning"; content: string };
|
||||
28
core/src/shared/llm-proxy/interfaces/session.interface.ts
Normal file
28
core/src/shared/llm-proxy/interfaces/session.interface.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmStreamEvent,
|
||||
} from "./message.interface";
|
||||
import type { VerboseLevel } from "../../verbose";
|
||||
|
||||
export interface SessionOptions {
|
||||
systemPrompt?: string;
|
||||
model?: string;
|
||||
verbose?: VerboseLevel;
|
||||
}
|
||||
|
||||
export interface LlmSession {
|
||||
readonly id: string;
|
||||
readonly adapterName: string;
|
||||
|
||||
send(content: string, options?: LlmRequestOptions): Promise<LlmResponse>;
|
||||
|
||||
sendStream(content: string, options?: LlmRequestOptions): AsyncIterable<LlmStreamEvent>;
|
||||
|
||||
getHistory(): LlmMessage[];
|
||||
|
||||
clearHistory(): void;
|
||||
|
||||
setSystemPrompt(prompt: string): void;
|
||||
}
|
||||
140
core/src/shared/llm-proxy/llm-proxy.module.ts
Normal file
140
core/src/shared/llm-proxy/llm-proxy.module.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Module, DynamicModule, Provider, Type } from "@nestjs/common";
|
||||
import { LlmProxyService } from "./llm-proxy.service";
|
||||
import { ClaudeCodeAdapter } from "./adapters/claude-code.adapter";
|
||||
import { OpenAIAdapter } from "./adapters/openai.adapter";
|
||||
import { ClaudeSetupModule } from "../claude-setup";
|
||||
import type { LlmProxyConfig } from "./interfaces";
|
||||
import { OpenCodeAdapter } from "./adapters";
|
||||
|
||||
export interface LlmProxyModuleOptions extends LlmProxyConfig {}
|
||||
|
||||
export interface LlmProxyModuleAsyncOptions {
|
||||
imports?: any[];
|
||||
useFactory?: (...args: any[]) => Promise<LlmProxyConfig> | LlmProxyConfig;
|
||||
inject?: any[];
|
||||
useClass?: Type<LlmProxyOptionsFactory>;
|
||||
useExisting?: Type<LlmProxyOptionsFactory>;
|
||||
}
|
||||
|
||||
export interface LlmProxyOptionsFactory {
|
||||
createLlmProxyOptions(): Promise<LlmProxyConfig> | LlmProxyConfig;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class LlmProxyModule {
|
||||
static forRoot(options: LlmProxyModuleOptions): DynamicModule {
|
||||
const resolvedOptions = this.resolveOpenCodeConfig(options);
|
||||
return {
|
||||
module: LlmProxyModule,
|
||||
imports: [ClaudeSetupModule],
|
||||
providers: [
|
||||
{
|
||||
provide: "LLM_PROXY_CONFIG",
|
||||
useValue: resolvedOptions,
|
||||
},
|
||||
ClaudeCodeAdapter,
|
||||
OpenAIAdapter,
|
||||
OpenCodeAdapter,
|
||||
LlmProxyService,
|
||||
],
|
||||
exports: [LlmProxyService, ClaudeCodeAdapter, OpenCodeAdapter, OpenAIAdapter],
|
||||
};
|
||||
}
|
||||
|
||||
static forRootAsync(options: LlmProxyModuleAsyncOptions): DynamicModule {
|
||||
const asyncProviders = this.createAsyncProviders(options);
|
||||
|
||||
return {
|
||||
module: LlmProxyModule,
|
||||
imports: [...(options.imports || []), ClaudeSetupModule],
|
||||
providers: [
|
||||
...asyncProviders,
|
||||
ClaudeCodeAdapter,
|
||||
OpenAIAdapter,
|
||||
OpenCodeAdapter,
|
||||
LlmProxyService,
|
||||
],
|
||||
exports: [LlmProxyService, ClaudeCodeAdapter, OpenCodeAdapter, OpenAIAdapter],
|
||||
};
|
||||
}
|
||||
|
||||
private static createAsyncProviders(options: LlmProxyModuleAsyncOptions): Provider[] {
|
||||
if (options.useFactory) {
|
||||
const originalFactory = options.useFactory;
|
||||
return [
|
||||
{
|
||||
provide: "LLM_PROXY_CONFIG",
|
||||
useFactory: async (...args: any[]) => {
|
||||
const config = await originalFactory(...args);
|
||||
return this.resolveOpenCodeConfig(config);
|
||||
},
|
||||
inject: options.inject || [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (options.useClass) {
|
||||
return [
|
||||
{
|
||||
provide: "LLM_PROXY_CONFIG",
|
||||
useFactory: async (optionsFactory: LlmProxyOptionsFactory) =>
|
||||
optionsFactory.createLlmProxyOptions(),
|
||||
inject: [options.useClass],
|
||||
},
|
||||
{
|
||||
provide: options.useClass,
|
||||
useClass: options.useClass,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (options.useExisting) {
|
||||
return [
|
||||
{
|
||||
provide: "LLM_PROXY_CONFIG",
|
||||
useFactory: async (optionsFactory: LlmProxyOptionsFactory) =>
|
||||
optionsFactory.createLlmProxyOptions(),
|
||||
inject: [options.useExisting],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static resolveOpenCodeConfig(config: LlmProxyConfig): LlmProxyConfig {
|
||||
if (!config.openCode) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const providerID = config.openCode.providerID || "openai";
|
||||
let apiKey = config.openCode.apiKey;
|
||||
let baseUrl = config.openCode.baseUrl;
|
||||
let model = config.openCode.model;
|
||||
|
||||
// 根据 providerID 从对应的 adapter 配置中读取缺失的值
|
||||
if (providerID === "openai" && config.openai) {
|
||||
if (!apiKey) apiKey = config.openai.apiKey;
|
||||
if (!baseUrl) baseUrl = config.openai.baseUrl;
|
||||
if (!model) model = config.openai.model;
|
||||
} else if (providerID === "anthropic" && config.claudeCode) {
|
||||
if (!apiKey) apiKey = config.claudeCode.authToken;
|
||||
if (!baseUrl) baseUrl = config.claudeCode.baseUrl;
|
||||
if (!model) model = config.claudeCode.model;
|
||||
}
|
||||
|
||||
// 如果有任何值需要更新
|
||||
if (
|
||||
apiKey !== config.openCode.apiKey ||
|
||||
baseUrl !== config.openCode.baseUrl ||
|
||||
model !== config.openCode.model
|
||||
) {
|
||||
return {
|
||||
...config,
|
||||
openCode: { ...config.openCode, apiKey, baseUrl, model },
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
303
core/src/shared/llm-proxy/llm-proxy.service.spec.ts
Normal file
303
core/src/shared/llm-proxy/llm-proxy.service.spec.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { LlmProxyService, ChatOptions } from "./llm-proxy.service";
|
||||
import { ClaudeCodeAdapter } from "./adapters/claude-code.adapter";
|
||||
import { OpenAIAdapter } from "./adapters/openai.adapter";
|
||||
import { OpenCodeAdapter } from "./adapters/open-code.adapter";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("LlmProxyService", () => {
|
||||
let service: LlmProxyService;
|
||||
let claudeAdapter: Mocked<ClaudeCodeAdapter>;
|
||||
let openaiAdapter: Mocked<OpenAIAdapter>;
|
||||
let opencodeAdapter: Mocked<OpenCodeAdapter>;
|
||||
|
||||
const mockConfig = {
|
||||
defaultAdapter: "claude-code" as const,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockClaude = {
|
||||
name: "claude-code",
|
||||
chat: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
isSupportJsonSchema: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const mockOpenAI = {
|
||||
name: "openai",
|
||||
chat: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
isSupportJsonSchema: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const mockOpenCode = {
|
||||
name: "open-code",
|
||||
chat: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
isSupportJsonSchema: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LlmProxyService,
|
||||
{ provide: "LLM_PROXY_CONFIG", useValue: mockConfig },
|
||||
{ provide: ClaudeCodeAdapter, useValue: mockClaude },
|
||||
{ provide: OpenAIAdapter, useValue: mockOpenAI },
|
||||
{ provide: OpenCodeAdapter, useValue: mockOpenCode },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LlmProxyService>(LlmProxyService);
|
||||
claudeAdapter = module.get(ClaudeCodeAdapter);
|
||||
openaiAdapter = module.get(OpenAIAdapter);
|
||||
opencodeAdapter = module.get(OpenCodeAdapter);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("createSession", () => {
|
||||
it("should create a session with default adapter", () => {
|
||||
const session = service.createSession();
|
||||
expect(session).toBeDefined();
|
||||
expect(session.adapterName).toBe("claude-code");
|
||||
});
|
||||
|
||||
it("should create a session with specified adapter", () => {
|
||||
const session = service.createSession("openai");
|
||||
expect(session).toBeDefined();
|
||||
expect(session.adapterName).toBe("openai");
|
||||
});
|
||||
|
||||
it("should throw error if adapter is not configured", () => {
|
||||
claudeAdapter.isConfigured.mockReturnValue(false);
|
||||
expect(() => service.createSession("claude-code")).toThrow('适配器 "claude-code" 未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
it("should call adapter.chat and return response", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockResponse = { content: "hi", role: "assistant" };
|
||||
claudeAdapter.chat.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await service.chat(messages);
|
||||
|
||||
expect(claudeAdapter.chat).toHaveBeenCalledWith(messages, undefined);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("should use specified adapter", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const options: ChatOptions = { adapter: "openai" };
|
||||
openaiAdapter.chat.mockResolvedValue({ content: "hi" } as any);
|
||||
|
||||
await service.chat(messages, options);
|
||||
|
||||
expect(openaiAdapter.chat).toHaveBeenCalled();
|
||||
expect(claudeAdapter.chat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle jsonSchema and parse output", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockParsed = { foo: "bar" };
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockResolvedValue(mockParsed),
|
||||
getSchema: vi.fn().mockReturnValue({ type: "object" }),
|
||||
};
|
||||
const options = { jsonSchema: mockJsonSchema };
|
||||
const mockResponse = { content: '{"foo":"bar"}', role: "assistant" };
|
||||
claudeAdapter.chat.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await service.chat(messages, options as any);
|
||||
|
||||
expect(result.structuredOutput).toEqual(mockParsed);
|
||||
expect(mockJsonSchema.parse).toHaveBeenCalledWith(mockResponse.content);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatStream", () => {
|
||||
it("should delegate to adapter.chatStream", async () => {
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
const mockStream = (async function* () {
|
||||
yield { type: "text", content: "hi" };
|
||||
})();
|
||||
claudeAdapter.chatStream.mockReturnValue(mockStream as any);
|
||||
|
||||
const stream = service.chatStream(messages);
|
||||
const chunks: any[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([{ type: "text", content: "hi" }]);
|
||||
expect(claudeAdapter.chatStream).toHaveBeenCalledWith(messages, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat with jsonSchema fallback", () => {
|
||||
it("should append jsonSchema prompt when adapter does not support jsonSchema", async () => {
|
||||
claudeAdapter.isSupportJsonSchema.mockReturnValue(false);
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockResolvedValue({ foo: "bar" }),
|
||||
getSchema: vi.fn(),
|
||||
jsonFormatInstruction: "请返回 JSON",
|
||||
isMatched: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const messages = [
|
||||
{ role: "system", content: "你是助手" },
|
||||
{ role: "user", content: "hello" },
|
||||
] as any;
|
||||
claudeAdapter.chat.mockResolvedValue({ content: '{"foo":"bar"}' } as any);
|
||||
const result = await service.chat(messages, { jsonSchema: mockJsonSchema } as any);
|
||||
expect(messages[0].content).toContain("请返回 JSON");
|
||||
expect(result.structuredOutput).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("should not append jsonSchema prompt if already matched", async () => {
|
||||
claudeAdapter.isSupportJsonSchema.mockReturnValue(false);
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockResolvedValue({}),
|
||||
getSchema: vi.fn(),
|
||||
jsonFormatInstruction: "请返回 JSON",
|
||||
isMatched: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
const messages = [{ role: "system", content: "已包含 JSON 指令" }] as any;
|
||||
claudeAdapter.chat.mockResolvedValue({ content: "{}" } as any);
|
||||
await service.chat(messages, { jsonSchema: mockJsonSchema } as any);
|
||||
expect(messages[0].content).toBe("已包含 JSON 指令");
|
||||
});
|
||||
|
||||
it("should add system message if none exists", async () => {
|
||||
claudeAdapter.isSupportJsonSchema.mockReturnValue(false);
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockResolvedValue({}),
|
||||
getSchema: vi.fn(),
|
||||
jsonFormatInstruction: "请返回 JSON",
|
||||
isMatched: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const messages = [{ role: "user", content: "hello" }] as any;
|
||||
claudeAdapter.chat.mockResolvedValue({ content: "{}" } as any);
|
||||
await service.chat(messages, { jsonSchema: mockJsonSchema } as any);
|
||||
expect(messages[0].role).toBe("system");
|
||||
expect(messages[0].content).toBe("请返回 JSON");
|
||||
});
|
||||
|
||||
it("should not parse if response has no content", async () => {
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
};
|
||||
claudeAdapter.chat.mockResolvedValue({ content: "" } as any);
|
||||
const result = await service.chat(
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{ jsonSchema: mockJsonSchema } as any,
|
||||
);
|
||||
expect(mockJsonSchema.parse).not.toHaveBeenCalled();
|
||||
expect(result.structuredOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not parse if structuredOutput already exists", async () => {
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn(),
|
||||
getSchema: vi.fn(),
|
||||
};
|
||||
claudeAdapter.chat.mockResolvedValue({
|
||||
content: "{}",
|
||||
structuredOutput: { existing: true },
|
||||
} as any);
|
||||
const result = await service.chat(
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{ jsonSchema: mockJsonSchema } as any,
|
||||
);
|
||||
expect(mockJsonSchema.parse).not.toHaveBeenCalled();
|
||||
expect(result.structuredOutput).toEqual({ existing: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatStream with jsonSchema", () => {
|
||||
it("should parse jsonSchema on result event", async () => {
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockResolvedValue({ parsed: true }),
|
||||
getSchema: vi.fn(),
|
||||
};
|
||||
const mockStream = (async function* () {
|
||||
yield { type: "result", response: { content: '{"parsed":true}' } };
|
||||
})();
|
||||
claudeAdapter.chatStream.mockReturnValue(mockStream as any);
|
||||
const chunks: any[] = [];
|
||||
for await (const chunk of service.chatStream(
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{ jsonSchema: mockJsonSchema } as any,
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(chunks[0].response.structuredOutput).toEqual({ parsed: true });
|
||||
});
|
||||
|
||||
it("should handle jsonSchema parse error gracefully", async () => {
|
||||
const mockJsonSchema = {
|
||||
parse: vi.fn().mockRejectedValue(new Error("parse error")),
|
||||
getSchema: vi.fn(),
|
||||
};
|
||||
const mockStream = (async function* () {
|
||||
yield { type: "result", response: { content: "invalid json" } };
|
||||
})();
|
||||
claudeAdapter.chatStream.mockReturnValue(mockStream as any);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const chunks: any[] = [];
|
||||
for await (const chunk of service.chatStream(
|
||||
[{ role: "user", content: "hello" }] as any,
|
||||
{ jsonSchema: mockJsonSchema } as any,
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(chunks[0].response.structuredOutput).toBeUndefined();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should use specified adapter for chatStream", async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield { type: "text", content: "hi" };
|
||||
})();
|
||||
openaiAdapter.chatStream.mockReturnValue(mockStream as any);
|
||||
const chunks: any[] = [];
|
||||
for await (const chunk of service.chatStream([{ role: "user", content: "hello" }] as any, {
|
||||
adapter: "openai",
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
expect(openaiAdapter.chatStream).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableAdapters", () => {
|
||||
it("should return list of configured adapters", () => {
|
||||
claudeAdapter.isConfigured.mockReturnValue(true);
|
||||
openaiAdapter.isConfigured.mockReturnValue(false);
|
||||
opencodeAdapter.isConfigured.mockReturnValue(false);
|
||||
|
||||
const available = service.getAvailableAdapters();
|
||||
|
||||
expect(available).toEqual(["claude-code"]);
|
||||
});
|
||||
|
||||
it("should return all adapters when all configured", () => {
|
||||
const available = service.getAvailableAdapters();
|
||||
expect(available).toEqual(["claude-code", "openai", "open-code"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAdapter errors", () => {
|
||||
it("should throw for unsupported adapter type", () => {
|
||||
expect(() => service.createSession("unknown" as any)).toThrow("不支持的 LLM 类型: unknown");
|
||||
});
|
||||
});
|
||||
});
|
||||
132
core/src/shared/llm-proxy/llm-proxy.service.ts
Normal file
132
core/src/shared/llm-proxy/llm-proxy.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { LlmAdapter } from "./adapters";
|
||||
import { ClaudeCodeAdapter } from "./adapters/claude-code.adapter";
|
||||
import { OpenAIAdapter } from "./adapters/openai.adapter";
|
||||
import { OpenCodeAdapter } from "./adapters/open-code.adapter";
|
||||
import { LlmSessionImpl } from "./llm-session";
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmSession,
|
||||
SessionOptions,
|
||||
LlmProxyConfig,
|
||||
LLMMode,
|
||||
} from "./interfaces";
|
||||
import type { LlmJsonPut } from "../llm-jsonput";
|
||||
|
||||
export interface ChatOptions extends LlmRequestOptions {
|
||||
adapter?: LLMMode;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LlmProxyService {
|
||||
private adapters: Map<LLMMode, LlmAdapter> = new Map();
|
||||
|
||||
constructor(
|
||||
@Inject("LLM_PROXY_CONFIG") private readonly config: LlmProxyConfig,
|
||||
private readonly claudeCodeAdapter: ClaudeCodeAdapter,
|
||||
private readonly openaiAdapter: OpenAIAdapter,
|
||||
private readonly openCodeAdapter: OpenCodeAdapter,
|
||||
) {
|
||||
this.adapters.set("claude-code", claudeCodeAdapter);
|
||||
this.adapters.set("openai", openaiAdapter);
|
||||
this.adapters.set("open-code", openCodeAdapter);
|
||||
}
|
||||
|
||||
createSession(adapterType?: LLMMode, options?: SessionOptions): LlmSession {
|
||||
const type = adapterType || this.getDefaultAdapterType();
|
||||
const adapter = this.getAdapter(type);
|
||||
|
||||
return new LlmSessionImpl(adapter, options);
|
||||
}
|
||||
|
||||
async chat(messages: LlmMessage[], options?: ChatOptions): Promise<LlmResponse> {
|
||||
const adapterType = options?.adapter || this.getDefaultAdapterType();
|
||||
const adapter = this.getAdapter(adapterType);
|
||||
|
||||
if (!adapter.isSupportJsonSchema() && options?.jsonSchema) {
|
||||
messages = this.appendJsonSchemaSystemPrompt(messages, options.jsonSchema);
|
||||
}
|
||||
|
||||
const response = await adapter.chat(messages, options);
|
||||
|
||||
if (options?.jsonSchema && response.content && !response.structuredOutput) {
|
||||
response.structuredOutput = await options.jsonSchema.parse(response.content);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async *chatStream(
|
||||
messages: LlmMessage[],
|
||||
options?: ChatOptions,
|
||||
): AsyncIterable<import("./interfaces").LlmStreamEvent> {
|
||||
const adapterType = options?.adapter || this.getDefaultAdapterType();
|
||||
const adapter = this.getAdapter(adapterType);
|
||||
|
||||
if (!adapter.isSupportJsonSchema() && options?.jsonSchema) {
|
||||
messages = this.appendJsonSchemaSystemPrompt(messages, options.jsonSchema);
|
||||
}
|
||||
|
||||
for await (const event of adapter.chatStream(messages, options)) {
|
||||
if (
|
||||
event.type === "result" &&
|
||||
options?.jsonSchema &&
|
||||
event.response.content &&
|
||||
!event.response.structuredOutput
|
||||
) {
|
||||
try {
|
||||
event.response.structuredOutput = await options.jsonSchema.parse(event.response.content);
|
||||
} catch (error: any) {
|
||||
// JSON 解析失败,保持 structuredOutput 为 undefined
|
||||
console.error("[LLMProxyService.chatStream] JSON 解析失败:", error);
|
||||
}
|
||||
}
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
appendJsonSchemaSystemPrompt(messages: LlmMessage[], jsonSchema: LlmJsonPut): LlmMessage[] {
|
||||
const systemMsg = messages.find((msg) => msg.role === "system");
|
||||
if (jsonSchema.isMatched(systemMsg?.content || "")) {
|
||||
return messages;
|
||||
}
|
||||
if (systemMsg) {
|
||||
systemMsg.content += `\n\n${jsonSchema.jsonFormatInstruction}`;
|
||||
} else {
|
||||
messages.unshift({ role: "system", content: jsonSchema.jsonFormatInstruction });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
getAvailableAdapters(): LLMMode[] {
|
||||
const available: LLMMode[] = [];
|
||||
|
||||
for (const [type, adapter] of this.adapters) {
|
||||
if (adapter.isConfigured()) {
|
||||
available.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
private getDefaultAdapterType(): LLMMode {
|
||||
return this.config.defaultAdapter || "openai";
|
||||
}
|
||||
|
||||
private getAdapter(type: LLMMode): LlmAdapter {
|
||||
const adapter = this.adapters.get(type);
|
||||
|
||||
if (!adapter) {
|
||||
throw new Error(`[LLMProxy.getAdapter] 不支持的 LLM 类型: ${type}`);
|
||||
}
|
||||
|
||||
if (!adapter.isConfigured()) {
|
||||
throw new Error(`[LLMProxy.getAdapter] 适配器 "${type}" 未配置`);
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
}
|
||||
111
core/src/shared/llm-proxy/llm-session.spec.ts
Normal file
111
core/src/shared/llm-proxy/llm-session.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { LlmSessionImpl } from "./llm-session";
|
||||
import { LlmAdapter } from "./adapters";
|
||||
import { LlmStreamEvent } from "./interfaces";
|
||||
|
||||
describe("LlmSessionImpl", () => {
|
||||
let adapter: Mocked<LlmAdapter>;
|
||||
let session: LlmSessionImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = {
|
||||
name: "test-adapter",
|
||||
chat: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
} as any;
|
||||
|
||||
session = new LlmSessionImpl(adapter);
|
||||
});
|
||||
|
||||
it("should initialize with a random UUID and adapter name", () => {
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.adapterName).toBe("test-adapter");
|
||||
});
|
||||
|
||||
it("should initialize with session options", () => {
|
||||
const options = {
|
||||
systemPrompt: "You are a helper",
|
||||
model: "gpt-4",
|
||||
verbose: 1 as const,
|
||||
};
|
||||
const sessionWithOptions = new LlmSessionImpl(adapter, options);
|
||||
|
||||
// Testing private state via history building and chat
|
||||
expect(sessionWithOptions.getHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("should add messages to history and call adapter.chat", async () => {
|
||||
const mockResponse = { content: "Hello there!", role: "assistant" };
|
||||
adapter.chat.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const response = await session.send("Hi");
|
||||
|
||||
expect(response).toEqual(mockResponse);
|
||||
const history = session.getHistory();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0]).toEqual({ role: "user", content: "Hi" });
|
||||
expect(history[1]).toEqual({ role: "assistant", content: "Hello there!" });
|
||||
|
||||
expect(adapter.chat).toHaveBeenCalledWith(
|
||||
[{ role: "user", content: "Hi" }],
|
||||
expect.objectContaining({ model: undefined, verbose: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include system prompt in messages sent to adapter", async () => {
|
||||
session.setSystemPrompt("System instruction");
|
||||
adapter.chat.mockResolvedValue({ content: "OK", role: "assistant" } as any);
|
||||
|
||||
await session.send("Hi");
|
||||
|
||||
expect(adapter.chat).toHaveBeenCalledWith(
|
||||
[
|
||||
{ role: "system", content: "System instruction" },
|
||||
{ role: "user", content: "Hi" },
|
||||
],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStream", () => {
|
||||
it("should handle streaming events and update history", async () => {
|
||||
const mockStream = (async function* () {
|
||||
yield { type: "text", content: "Hello" } as LlmStreamEvent;
|
||||
yield { type: "text", content: " world" } as LlmStreamEvent;
|
||||
yield {
|
||||
type: "result",
|
||||
response: { content: "Hello world", role: "assistant" },
|
||||
} as LlmStreamEvent;
|
||||
})();
|
||||
adapter.chatStream.mockReturnValue(mockStream as any);
|
||||
|
||||
const events: LlmStreamEvent[] = [];
|
||||
for await (const event of session.sendStream("Hi")) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(3);
|
||||
const history = session.getHistory();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[1].content).toBe("Hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("history management", () => {
|
||||
it("should clear history", () => {
|
||||
// Manually trigger a message (using send to populate)
|
||||
// We'll mock it to be simple
|
||||
session.clearHistory();
|
||||
expect(session.getHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return a copy of history", () => {
|
||||
const history = session.getHistory();
|
||||
history.push({ role: "user", content: "modified" });
|
||||
expect(session.getHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
core/src/shared/llm-proxy/llm-session.ts
Normal file
109
core/src/shared/llm-proxy/llm-session.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { LlmAdapter } from "./adapters";
|
||||
import type {
|
||||
LlmMessage,
|
||||
LlmRequestOptions,
|
||||
LlmResponse,
|
||||
LlmStreamEvent,
|
||||
LlmSession,
|
||||
SessionOptions,
|
||||
} from "./interfaces";
|
||||
import { type VerboseLevel, normalizeVerbose } from "../verbose";
|
||||
|
||||
export class LlmSessionImpl implements LlmSession {
|
||||
readonly id: string;
|
||||
readonly adapterName: string;
|
||||
|
||||
private history: LlmMessage[] = [];
|
||||
private systemPrompt: string = "";
|
||||
private defaultModel?: string;
|
||||
private verbose: VerboseLevel = 0;
|
||||
|
||||
constructor(
|
||||
private readonly adapter: LlmAdapter,
|
||||
options?: SessionOptions,
|
||||
) {
|
||||
this.id = randomUUID();
|
||||
this.adapterName = adapter.name;
|
||||
|
||||
if (options?.systemPrompt) {
|
||||
this.systemPrompt = options.systemPrompt;
|
||||
}
|
||||
if (options?.model) {
|
||||
this.defaultModel = options.model;
|
||||
}
|
||||
if (options?.verbose !== undefined) {
|
||||
this.verbose = normalizeVerbose(options.verbose);
|
||||
}
|
||||
}
|
||||
|
||||
async send(content: string, options?: LlmRequestOptions): Promise<LlmResponse> {
|
||||
const userMessage: LlmMessage = { role: "user", content };
|
||||
this.history.push(userMessage);
|
||||
|
||||
const messages = this.buildMessages();
|
||||
const mergedOptions = this.mergeOptions(options);
|
||||
|
||||
const response = await this.adapter.chat(messages, mergedOptions);
|
||||
|
||||
const assistantMessage: LlmMessage = { role: "assistant", content: response.content };
|
||||
this.history.push(assistantMessage);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async *sendStream(content: string, options?: LlmRequestOptions): AsyncIterable<LlmStreamEvent> {
|
||||
const userMessage: LlmMessage = { role: "user", content };
|
||||
this.history.push(userMessage);
|
||||
|
||||
const messages = this.buildMessages();
|
||||
const mergedOptions = this.mergeOptions(options);
|
||||
|
||||
let fullContent = "";
|
||||
|
||||
for await (const event of this.adapter.chatStream(messages, mergedOptions)) {
|
||||
yield event;
|
||||
|
||||
if (event.type === "text") {
|
||||
fullContent += event.content;
|
||||
} else if (event.type === "result") {
|
||||
fullContent = event.response.content;
|
||||
}
|
||||
}
|
||||
|
||||
const assistantMessage: LlmMessage = { role: "assistant", content: fullContent };
|
||||
this.history.push(assistantMessage);
|
||||
}
|
||||
|
||||
getHistory(): LlmMessage[] {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
setSystemPrompt(prompt: string): void {
|
||||
this.systemPrompt = prompt;
|
||||
}
|
||||
|
||||
private buildMessages(): LlmMessage[] {
|
||||
const messages: LlmMessage[] = [];
|
||||
|
||||
if (this.systemPrompt) {
|
||||
messages.push({ role: "system", content: this.systemPrompt });
|
||||
}
|
||||
|
||||
messages.push(...this.history);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private mergeOptions(options?: LlmRequestOptions): LlmRequestOptions {
|
||||
return {
|
||||
model: options?.model || this.defaultModel,
|
||||
verbose: options?.verbose ?? this.verbose,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
}
|
||||
97
core/src/shared/llm-proxy/stream-logger.ts
Normal file
97
core/src/shared/llm-proxy/stream-logger.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { LlmStreamEvent } from "./interfaces";
|
||||
|
||||
export interface StreamLoggerState {
|
||||
isFirstText: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个新的 StreamLogger 状态
|
||||
*/
|
||||
export function createStreamLoggerState(): StreamLoggerState {
|
||||
return { isFirstText: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 LLM 流式事件到终端
|
||||
* @param event LLM 流式事件
|
||||
* @param state 日志状态(用于跟踪是否是第一个文本块)
|
||||
*/
|
||||
export function logStreamEvent(event: LlmStreamEvent, state: StreamLoggerState): void {
|
||||
switch (event.type) {
|
||||
case "text":
|
||||
if (state.isFirstText) {
|
||||
process.stdout.write("\n🤖 AI: ");
|
||||
state.isFirstText = false;
|
||||
}
|
||||
process.stdout.write(event.content);
|
||||
break;
|
||||
case "tool_use":
|
||||
console.log(`\n🛠️ 工具调用: ${event.name}`);
|
||||
if (event.title) {
|
||||
console.log(` 标题: ${event.title}`);
|
||||
}
|
||||
console.log(` 输入: ${JSON.stringify(event.input)}`);
|
||||
if (event.status) {
|
||||
console.log(` 状态: ${event.status}`);
|
||||
}
|
||||
if (event.output) {
|
||||
console.log(
|
||||
` 输出: ${event.output.substring(0, 200)}${event.output.length > 200 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "thought":
|
||||
console.log(`\n💭 思考: ${event.content}`);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "result":
|
||||
console.log(`\n✅ 结果已返回`);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "error":
|
||||
console.error(`\n❌ 错误: ${event.message}`);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "agent":
|
||||
console.log(`\n🤖 子代理: ${event.name}`);
|
||||
if (event.source) {
|
||||
console.log(
|
||||
` 来源: ${event.source.substring(0, 100)}${event.source.length > 100 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "subtask":
|
||||
console.log(`\n📋 子任务: ${event.description}`);
|
||||
console.log(` 代理: ${event.agent}`);
|
||||
console.log(
|
||||
` 提示: ${event.prompt.substring(0, 100)}${event.prompt.length > 100 ? "..." : ""}`,
|
||||
);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "step_start":
|
||||
console.log(`\n▶️ 步骤开始`);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "step_finish":
|
||||
console.log(`\n⏹️ 步骤结束: ${event.reason}`);
|
||||
if (event.tokens) {
|
||||
const tokens = event.tokens as any;
|
||||
console.log(
|
||||
` Token: 输入=${tokens.input || 0}, 输出=${tokens.output || 0}, 推理=${tokens.reasoning || 0}`,
|
||||
);
|
||||
}
|
||||
if (event.cost !== undefined) {
|
||||
console.log(` 成本: $${event.cost.toFixed(6)}`);
|
||||
}
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
case "reasoning":
|
||||
console.log(
|
||||
`\n🧠 推理: ${event.content.substring(0, 200)}${event.content.length > 200 ? "..." : ""}`,
|
||||
);
|
||||
state.isFirstText = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
11
core/src/shared/logger/index.ts
Normal file
11
core/src/shared/logger/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { Logger } from "./logger";
|
||||
export type {
|
||||
LoggerOptions,
|
||||
LogLevel,
|
||||
RenderMode,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
ProgressBarOptions,
|
||||
TaskItem,
|
||||
TaskControl,
|
||||
} from "./logger.interface";
|
||||
93
core/src/shared/logger/logger.interface.ts
Normal file
93
core/src/shared/logger/logger.interface.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { LogLevel } from "../verbose";
|
||||
import { LOG_LEVEL_PRIORITY } from "../verbose";
|
||||
|
||||
export type { LogLevel };
|
||||
export { LOG_LEVEL_PRIORITY };
|
||||
|
||||
/** 渲染模式 */
|
||||
export type RenderMode = "plain" | "tui" | "auto";
|
||||
|
||||
/** Logger 配置 */
|
||||
export interface LoggerOptions {
|
||||
/** 日志所属命名空间(通常为命令名) */
|
||||
readonly name: string;
|
||||
/** 输出模式,默认 "auto"(TTY=tui,CI/管道=plain) */
|
||||
readonly mode?: RenderMode;
|
||||
/** 日志级别,默认 "info" */
|
||||
readonly level?: LogLevel;
|
||||
}
|
||||
|
||||
/** Spinner 控制接口 */
|
||||
export interface Spinner {
|
||||
/** 更新 spinner 文本 */
|
||||
update(message: string): void;
|
||||
/** 成功结束 */
|
||||
succeed(message?: string): void;
|
||||
/** 失败结束 */
|
||||
fail(message?: string): void;
|
||||
/** 静默停止 */
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** 进度条控制接口 */
|
||||
export interface ProgressBar {
|
||||
/** 更新进度 */
|
||||
update(current: number, message?: string): void;
|
||||
/** 完成 */
|
||||
finish(message?: string): void;
|
||||
}
|
||||
|
||||
/** 进度条配置 */
|
||||
export interface ProgressBarOptions {
|
||||
/** 总数 */
|
||||
readonly total: number;
|
||||
/** 标签 */
|
||||
readonly label?: string;
|
||||
/** 进度条宽度(字符数),默认 30 */
|
||||
readonly width?: number;
|
||||
}
|
||||
|
||||
/** 任务控制接口 */
|
||||
export interface TaskControl {
|
||||
/** 更新任务状态文本 */
|
||||
update(message: string): void;
|
||||
/** 跳过任务 */
|
||||
skip(reason?: string): void;
|
||||
}
|
||||
|
||||
/** 任务项定义 */
|
||||
export interface TaskItem<T = void> {
|
||||
/** 任务标题 */
|
||||
readonly title: string;
|
||||
/** 任务执行函数 */
|
||||
readonly task: (ctx: T, control: TaskControl) => Promise<T>;
|
||||
/** 是否启用,默认 true */
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
/** 任务执行结果 */
|
||||
export type TaskStatus = "pending" | "running" | "success" | "failed" | "skipped";
|
||||
|
||||
/** 渲染器接口(策略模式) */
|
||||
export interface LogRenderer {
|
||||
/** 输出 info 级别日志 */
|
||||
info(prefix: string, message: string): void;
|
||||
/** 输出 success 级别日志 */
|
||||
success(prefix: string, message: string): void;
|
||||
/** 输出 warn 级别日志 */
|
||||
warn(prefix: string, message: string): void;
|
||||
/** 输出 error 级别日志 */
|
||||
error(prefix: string, message: string): void;
|
||||
/** 输出 debug 级别日志 */
|
||||
debug(prefix: string, message: string): void;
|
||||
/** 输出 verbose 级别日志 */
|
||||
verbose(prefix: string, message: string): void;
|
||||
/** 创建 Spinner */
|
||||
createSpinner(prefix: string, message: string): Spinner;
|
||||
/** 创建进度条 */
|
||||
createProgressBar(prefix: string, options: ProgressBarOptions): ProgressBar;
|
||||
/** 执行任务列表 */
|
||||
runTasks<T>(prefix: string, items: TaskItem<T>[]): Promise<T[]>;
|
||||
/** 输出表格 */
|
||||
table(prefix: string, data: Record<string, unknown>[]): void;
|
||||
}
|
||||
178
core/src/shared/logger/logger.spec.ts
Normal file
178
core/src/shared/logger/logger.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { vi, type MockInstance } from "vitest";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
describe("Logger", () => {
|
||||
let consoleSpy: {
|
||||
log: MockInstance;
|
||||
warn: MockInstance;
|
||||
error: MockInstance;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(),
|
||||
error: vi.spyOn(console, "error").mockImplementation(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("基础日志", () => {
|
||||
it("info 输出包含前缀和消息", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
logger.info("hello");
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
|
||||
const output = consoleSpy.log.mock.calls[0][0] as string;
|
||||
expect(output).toContain("[test]");
|
||||
expect(output).toContain("hello");
|
||||
});
|
||||
|
||||
it("success 输出包含 ✅", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
logger.success("done");
|
||||
const output = consoleSpy.log.mock.calls[0][0] as string;
|
||||
expect(output).toContain("✅");
|
||||
expect(output).toContain("done");
|
||||
});
|
||||
|
||||
it("warn 使用 console.warn", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
logger.warn("caution");
|
||||
expect(consoleSpy.warn).toHaveBeenCalledTimes(1);
|
||||
const output = consoleSpy.warn.mock.calls[0][0] as string;
|
||||
expect(output).toContain("caution");
|
||||
});
|
||||
|
||||
it("error 使用 console.error", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
logger.error("fail");
|
||||
expect(consoleSpy.error).toHaveBeenCalledTimes(1);
|
||||
const output = consoleSpy.error.mock.calls[0][0] as string;
|
||||
expect(output).toContain("fail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("日志级别", () => {
|
||||
it("level=info 时 verbose 不输出", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain", level: "info" });
|
||||
logger.verbose("detail");
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("level=verbose 时 verbose 输出", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain", level: "verbose" });
|
||||
logger.verbose("detail");
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("level=verbose 时 debug 不输出", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain", level: "verbose" });
|
||||
logger.debug("trace");
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("level=debug 时 debug 输出", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain", level: "debug" });
|
||||
logger.debug("trace");
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
|
||||
const output = consoleSpy.log.mock.calls[0][0] as string;
|
||||
expect(output).toContain("[DEBUG]");
|
||||
});
|
||||
|
||||
it("level=silent 时所有日志不输出", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain", level: "silent" });
|
||||
logger.info("a");
|
||||
logger.success("b");
|
||||
logger.warn("c");
|
||||
logger.error("d");
|
||||
logger.verbose("e");
|
||||
logger.debug("f");
|
||||
expect(consoleSpy.log).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.warn).not.toHaveBeenCalled();
|
||||
expect(consoleSpy.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("字符串构造", () => {
|
||||
it("支持字符串参数快捷创建", () => {
|
||||
const logger = new Logger("build");
|
||||
logger.info("start");
|
||||
// 不报错即可,auto 模式下可能是 plain 或 tui
|
||||
expect(consoleSpy.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("child", () => {
|
||||
it("子 Logger 前缀包含父命名空间", () => {
|
||||
const logger = new Logger({ name: "build", mode: "plain" });
|
||||
const child = logger.child("compile");
|
||||
child.info("processing");
|
||||
const output = consoleSpy.log.mock.calls[0][0] as string;
|
||||
expect(output).toContain("[build:compile]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spinner (plain 模式)", () => {
|
||||
it("spin 输出开始消息", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
const spinner = logger.spin("loading");
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
|
||||
const output = consoleSpy.log.mock.calls[0][0] as string;
|
||||
expect(output).toContain("loading");
|
||||
spinner.succeed("loaded");
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProgressBar (plain 模式)", () => {
|
||||
it("progress 输出进度信息", () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
const bar = logger.progress({ total: 10, label: "files" });
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(1);
|
||||
bar.update(5);
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(2);
|
||||
const output = consoleSpy.log.mock.calls[1][0] as string;
|
||||
expect(output).toContain("50%");
|
||||
bar.finish();
|
||||
expect(consoleSpy.log).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tasks (plain 模式)", () => {
|
||||
it("顺序执行任务并输出状态", async () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
const results = await logger.tasks([
|
||||
{ title: "步骤1", task: async () => "a" as never },
|
||||
{ title: "步骤2", task: async () => "b" as never },
|
||||
]);
|
||||
expect(results).toHaveLength(2);
|
||||
const allOutput = consoleSpy.log.mock.calls.map((c: unknown[]) => c[0]).join("\n");
|
||||
expect(allOutput).toContain("步骤1");
|
||||
expect(allOutput).toContain("步骤2");
|
||||
});
|
||||
|
||||
it("任务失败时抛出错误", async () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
await expect(
|
||||
logger.tasks([
|
||||
{
|
||||
title: "会失败",
|
||||
task: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
]),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("enabled=false 的任务被跳过", async () => {
|
||||
const logger = new Logger({ name: "test", mode: "plain" });
|
||||
const taskFn = vi.fn();
|
||||
await logger.tasks([{ title: "跳过", task: taskFn, enabled: false }]);
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
175
core/src/shared/logger/logger.ts
Normal file
175
core/src/shared/logger/logger.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
LoggerOptions,
|
||||
LogLevel,
|
||||
RenderMode,
|
||||
LogRenderer,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
ProgressBarOptions,
|
||||
TaskItem,
|
||||
} from "./logger.interface";
|
||||
import { LOG_LEVEL_PRIORITY } from "./logger.interface";
|
||||
import { PlainRenderer } from "./renderers/plain.renderer";
|
||||
|
||||
/** 检测是否为 TUI 环境 */
|
||||
const detectMode = (): RenderMode => {
|
||||
if (process.env.CI || !process.stdout.isTTY) return "plain";
|
||||
return "tui";
|
||||
};
|
||||
|
||||
/** TUI 渲染器缓存(延迟加载,避免 ESM 兼容问题) */
|
||||
let tuiRendererCache: LogRenderer | null = null;
|
||||
|
||||
/** 延迟加载 TUI 渲染器(chalk/ora/log-update 均为纯 ESM 包) */
|
||||
const loadTuiRenderer = async (): Promise<LogRenderer> => {
|
||||
if (tuiRendererCache) return tuiRendererCache;
|
||||
const { TuiRenderer } = await import("./renderers/tui.renderer");
|
||||
tuiRendererCache = new TuiRenderer();
|
||||
return tuiRendererCache;
|
||||
};
|
||||
|
||||
/** 解析渲染模式,返回实际模式标识 */
|
||||
const resolveMode = (mode: RenderMode): "plain" | "tui" => {
|
||||
if (mode === "auto") return detectMode() === "tui" ? "tui" : "plain";
|
||||
return mode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局日志工具类
|
||||
*
|
||||
* 每个子命令创建独立实例,支持 plain / tui 两种输出模式。
|
||||
* TUI 模式提供 Spinner、进度条、任务列表等富交互能力,
|
||||
* plain 模式下自动降级为普通文本输出。
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const logger = new Logger("build");
|
||||
* logger.info("开始构建");
|
||||
* const s = logger.spin("编译中...");
|
||||
* s.succeed("编译完成");
|
||||
* ```
|
||||
*/
|
||||
export class Logger {
|
||||
private readonly name: string;
|
||||
private readonly level: LogLevel;
|
||||
private readonly resolvedMode: "plain" | "tui";
|
||||
private readonly plainRenderer: PlainRenderer;
|
||||
private tuiRenderer: LogRenderer | null = null;
|
||||
|
||||
constructor(options: LoggerOptions | string) {
|
||||
const opts = typeof options === "string" ? { name: options } : options;
|
||||
this.name = opts.name;
|
||||
this.level = opts.level ?? "info";
|
||||
this.resolvedMode = resolveMode(opts.mode ?? "auto");
|
||||
this.plainRenderer = new PlainRenderer();
|
||||
}
|
||||
|
||||
/** 格式化前缀 */
|
||||
private get prefix(): string {
|
||||
return `[${this.name}]`;
|
||||
}
|
||||
|
||||
/** 获取当前渲染器(TUI 未加载时降级为 plain) */
|
||||
private get renderer(): LogRenderer {
|
||||
if (this.resolvedMode === "tui" && this.tuiRenderer) return this.tuiRenderer;
|
||||
return this.plainRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 TUI 渲染器(异步)
|
||||
* 在使用 TUI 特有功能前调用,确保渲染器已加载
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.resolvedMode === "tui" && !this.tuiRenderer) {
|
||||
this.tuiRenderer = await loadTuiRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断是否应输出指定级别的日志 */
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[this.level];
|
||||
}
|
||||
|
||||
/** 输出 info 级别日志 */
|
||||
info(message: string): void {
|
||||
if (this.shouldLog("info")) {
|
||||
this.renderer.info(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出 success 级别日志 */
|
||||
success(message: string): void {
|
||||
if (this.shouldLog("info")) {
|
||||
this.renderer.success(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出 warn 级别日志 */
|
||||
warn(message: string): void {
|
||||
if (this.shouldLog("info")) {
|
||||
this.renderer.warn(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出 error 级别日志 */
|
||||
error(message: string): void {
|
||||
if (this.shouldLog("info")) {
|
||||
this.renderer.error(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出 verbose 级别日志(level >= verbose 才输出) */
|
||||
verbose(message: string): void {
|
||||
if (this.shouldLog("verbose")) {
|
||||
this.renderer.verbose(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 输出 debug 级别日志(level >= debug 才输出) */
|
||||
debug(message: string): void {
|
||||
if (this.shouldLog("debug")) {
|
||||
this.renderer.debug(this.prefix, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Spinner
|
||||
* TUI 模式下显示动画 spinner,plain 模式下降级为普通日志
|
||||
*/
|
||||
spin(message: string): Spinner {
|
||||
return this.renderer.createSpinner(this.prefix, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建进度条
|
||||
* TUI 模式下显示实时进度条,plain 模式下降级为百分比日志
|
||||
*/
|
||||
progress(options: ProgressBarOptions): ProgressBar {
|
||||
return this.renderer.createProgressBar(this.prefix, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务列表
|
||||
* TUI 模式下多行实时更新,plain 模式下顺序输出
|
||||
*/
|
||||
async tasks<T>(items: TaskItem<T>[]): Promise<T[]> {
|
||||
return this.renderer.runTasks<T>(this.prefix, items);
|
||||
}
|
||||
|
||||
/** 输出表格 */
|
||||
table(data: Record<string, unknown>[]): void {
|
||||
this.renderer.table(this.prefix, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建子 Logger
|
||||
* 命名空间自动拼接,如 "build" → "build:compile"
|
||||
*/
|
||||
child(name: string): Logger {
|
||||
return new Logger({
|
||||
name: `${this.name}:${name}`,
|
||||
level: this.level,
|
||||
mode: this.resolvedMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
116
core/src/shared/logger/renderers/plain.renderer.ts
Normal file
116
core/src/shared/logger/renderers/plain.renderer.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
LogRenderer,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
ProgressBarOptions,
|
||||
TaskItem,
|
||||
TaskControl,
|
||||
} from "../logger.interface";
|
||||
|
||||
/** 时间戳格式化 */
|
||||
const timestamp = (): string => {
|
||||
const now = new Date();
|
||||
return `${now.toLocaleTimeString()}`;
|
||||
};
|
||||
|
||||
/** plain 模式渲染器:纯文本输出,适合 CI / 管道 */
|
||||
export class PlainRenderer implements LogRenderer {
|
||||
info(prefix: string, message: string): void {
|
||||
console.log(`${timestamp()} ${prefix} ${message}`);
|
||||
}
|
||||
|
||||
success(prefix: string, message: string): void {
|
||||
console.log(`${timestamp()} ${prefix} ✅ ${message}`);
|
||||
}
|
||||
|
||||
warn(prefix: string, message: string): void {
|
||||
console.warn(`${timestamp()} ${prefix} ⚠️ ${message}`);
|
||||
}
|
||||
|
||||
error(prefix: string, message: string): void {
|
||||
console.error(`${timestamp()} ${prefix} ❌ ${message}`);
|
||||
}
|
||||
|
||||
debug(prefix: string, message: string): void {
|
||||
console.log(`${timestamp()} ${prefix} [DEBUG] ${message}`);
|
||||
}
|
||||
|
||||
verbose(prefix: string, message: string): void {
|
||||
console.log(`${timestamp()} ${prefix} ${message}`);
|
||||
}
|
||||
|
||||
createSpinner(prefix: string, message: string): Spinner {
|
||||
console.log(`${timestamp()} ${prefix} ⏳ ${message}`);
|
||||
return {
|
||||
update: (msg: string) => {
|
||||
console.log(`${timestamp()} ${prefix} ⏳ ${msg}`);
|
||||
},
|
||||
succeed: (msg?: string) => {
|
||||
console.log(`${timestamp()} ${prefix} ✅ ${msg ?? message}`);
|
||||
},
|
||||
fail: (msg?: string) => {
|
||||
console.error(`${timestamp()} ${prefix} ❌ ${msg ?? message}`);
|
||||
},
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
createProgressBar(prefix: string, options: ProgressBarOptions): ProgressBar {
|
||||
const { total, label = "" } = options;
|
||||
const tag = label ? `${label} ` : "";
|
||||
console.log(`${timestamp()} ${prefix} ${tag}0/${total}`);
|
||||
return {
|
||||
update: (current: number, msg?: string) => {
|
||||
const pct = Math.round((current / total) * 100);
|
||||
const suffix = msg ? ` ${msg}` : "";
|
||||
console.log(`${timestamp()} ${prefix} ${tag}${current}/${total} (${pct}%)${suffix}`);
|
||||
},
|
||||
finish: (msg?: string) => {
|
||||
const suffix = msg ? ` ${msg}` : "";
|
||||
console.log(`${timestamp()} ${prefix} ✅ ${tag}${total}/${total} (100%)${suffix}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async runTasks<T>(prefix: string, items: TaskItem<T>[]): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
for (const item of items) {
|
||||
if (item.enabled === false) {
|
||||
console.log(`${timestamp()} ${prefix} ⏭️ [跳过] ${item.title}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`${timestamp()} ${prefix} ▶ ${item.title}`);
|
||||
let skipped = false;
|
||||
let skipReason = "";
|
||||
const control: TaskControl = {
|
||||
update: (msg: string) => {
|
||||
console.log(`${timestamp()} ${prefix} ${msg}`);
|
||||
},
|
||||
skip: (reason?: string) => {
|
||||
skipped = true;
|
||||
skipReason = reason ?? "";
|
||||
},
|
||||
};
|
||||
try {
|
||||
const ctx = (results.length > 0 ? results[results.length - 1] : undefined) as T;
|
||||
const result = await item.task(ctx, control);
|
||||
if (skipped) {
|
||||
const suffix = skipReason ? `: ${skipReason}` : "";
|
||||
console.log(`${timestamp()} ${prefix} ⏭️ ${item.title}${suffix}`);
|
||||
} else {
|
||||
console.log(`${timestamp()} ${prefix} ✅ ${item.title}`);
|
||||
}
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`${timestamp()} ${prefix} ❌ ${item.title}: ${errMsg}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
table(_prefix: string, data: Record<string, unknown>[]): void {
|
||||
console.table(data);
|
||||
}
|
||||
}
|
||||
162
core/src/shared/logger/renderers/tui.renderer.ts
Normal file
162
core/src/shared/logger/renderers/tui.renderer.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import logUpdate from "log-update";
|
||||
import type {
|
||||
LogRenderer,
|
||||
Spinner,
|
||||
ProgressBar,
|
||||
ProgressBarOptions,
|
||||
TaskItem,
|
||||
TaskControl,
|
||||
TaskStatus,
|
||||
} from "../logger.interface";
|
||||
|
||||
/** 进度条默认宽度 */
|
||||
const DEFAULT_BAR_WIDTH = 30;
|
||||
|
||||
/** 任务状态图标 */
|
||||
const TASK_ICONS: Record<TaskStatus, string> = {
|
||||
pending: chalk.gray("○"),
|
||||
running: chalk.cyan("◌"),
|
||||
success: chalk.green("✔"),
|
||||
failed: chalk.red("✖"),
|
||||
skipped: chalk.yellow("⊘"),
|
||||
};
|
||||
|
||||
/** 渲染进度条字符串 */
|
||||
const renderBar = (current: number, total: number, width: number): string => {
|
||||
const ratio = Math.min(current / total, 1);
|
||||
const filled = Math.round(ratio * width);
|
||||
const empty = width - filled;
|
||||
const bar = chalk.green("█".repeat(filled)) + chalk.gray("░".repeat(empty));
|
||||
const pct = Math.round(ratio * 100);
|
||||
return `${bar} ${pct}%`;
|
||||
};
|
||||
|
||||
/** TUI 模式渲染器:富交互输出,适合终端 */
|
||||
export class TuiRenderer implements LogRenderer {
|
||||
info(prefix: string, message: string): void {
|
||||
console.log(`${chalk.gray(prefix)} ${message}`);
|
||||
}
|
||||
|
||||
success(prefix: string, message: string): void {
|
||||
console.log(`${chalk.gray(prefix)} ${chalk.green("✔")} ${message}`);
|
||||
}
|
||||
|
||||
warn(prefix: string, message: string): void {
|
||||
console.warn(`${chalk.gray(prefix)} ${chalk.yellow("⚠")} ${chalk.yellow(message)}`);
|
||||
}
|
||||
|
||||
error(prefix: string, message: string): void {
|
||||
console.error(`${chalk.gray(prefix)} ${chalk.red("✖")} ${chalk.red(message)}`);
|
||||
}
|
||||
|
||||
debug(prefix: string, message: string): void {
|
||||
console.log(`${chalk.gray(prefix)} ${chalk.magenta("[DEBUG]")} ${chalk.gray(message)}`);
|
||||
}
|
||||
|
||||
verbose(prefix: string, message: string): void {
|
||||
console.log(`${chalk.gray(prefix)} ${chalk.gray(message)}`);
|
||||
}
|
||||
|
||||
createSpinner(prefix: string, message: string): Spinner {
|
||||
const spinner = ora({
|
||||
text: `${chalk.gray(prefix)} ${message}`,
|
||||
prefixText: "",
|
||||
}).start();
|
||||
return {
|
||||
update: (msg: string) => {
|
||||
spinner.text = `${chalk.gray(prefix)} ${msg}`;
|
||||
},
|
||||
succeed: (msg?: string) => {
|
||||
spinner.succeed(`${chalk.gray(prefix)} ${msg ?? message}`);
|
||||
},
|
||||
fail: (msg?: string) => {
|
||||
spinner.fail(`${chalk.gray(prefix)} ${msg ?? message}`);
|
||||
},
|
||||
stop: () => {
|
||||
spinner.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createProgressBar(prefix: string, options: ProgressBarOptions): ProgressBar {
|
||||
const { total, label = "", width = DEFAULT_BAR_WIDTH } = options;
|
||||
const tag = label ? `${label} ` : "";
|
||||
logUpdate(`${chalk.gray(prefix)} ${tag}${renderBar(0, total, width)} 0/${total}`);
|
||||
return {
|
||||
update: (current: number, msg?: string) => {
|
||||
const suffix = msg ? ` ${chalk.gray(msg)}` : "";
|
||||
logUpdate(
|
||||
`${chalk.gray(prefix)} ${tag}${renderBar(current, total, width)} ${current}/${total}${suffix}`,
|
||||
);
|
||||
},
|
||||
finish: (msg?: string) => {
|
||||
const suffix = msg ? ` ${msg}` : "";
|
||||
logUpdate.done();
|
||||
console.log(
|
||||
`${chalk.gray(prefix)} ${chalk.green("✔")} ${tag}${total}/${total} (100%)${suffix}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async runTasks<T>(prefix: string, items: TaskItem<T>[]): Promise<T[]> {
|
||||
const enabledItems = items.filter((item) => item.enabled !== false);
|
||||
const statuses: TaskStatus[] = enabledItems.map(() => "pending");
|
||||
const messages: string[] = enabledItems.map((item) => item.title);
|
||||
const results: T[] = [];
|
||||
const renderTaskList = (): string => {
|
||||
return enabledItems
|
||||
.map((item, i) => {
|
||||
const icon = TASK_ICONS[statuses[i]];
|
||||
const title = statuses[i] === "running" ? chalk.cyan(item.title) : item.title;
|
||||
const suffix = messages[i] !== item.title ? chalk.gray(` ${messages[i]}`) : "";
|
||||
return `${chalk.gray(prefix)} ${icon} ${title}${suffix}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
logUpdate(renderTaskList());
|
||||
for (let i = 0; i < enabledItems.length; i++) {
|
||||
statuses[i] = "running";
|
||||
logUpdate(renderTaskList());
|
||||
let skipped = false;
|
||||
let skipReason = "";
|
||||
const control: TaskControl = {
|
||||
update: (msg: string) => {
|
||||
messages[i] = msg;
|
||||
logUpdate(renderTaskList());
|
||||
},
|
||||
skip: (reason?: string) => {
|
||||
skipped = true;
|
||||
skipReason = reason ?? "";
|
||||
},
|
||||
};
|
||||
try {
|
||||
const ctx = (results.length > 0 ? results[results.length - 1] : undefined) as T;
|
||||
const result = await enabledItems[i].task(ctx, control);
|
||||
if (skipped) {
|
||||
statuses[i] = "skipped";
|
||||
messages[i] = skipReason ? `${enabledItems[i].title} (${skipReason})` : enabledItems[i].title;
|
||||
} else {
|
||||
statuses[i] = "success";
|
||||
messages[i] = enabledItems[i].title;
|
||||
}
|
||||
results.push(result);
|
||||
} catch (err) {
|
||||
statuses[i] = "failed";
|
||||
messages[i] = err instanceof Error ? err.message : String(err);
|
||||
logUpdate(renderTaskList());
|
||||
logUpdate.done();
|
||||
throw err;
|
||||
}
|
||||
logUpdate(renderTaskList());
|
||||
}
|
||||
logUpdate.done();
|
||||
return results;
|
||||
}
|
||||
|
||||
table(_prefix: string, data: Record<string, unknown>[]): void {
|
||||
console.table(data);
|
||||
}
|
||||
}
|
||||
332
core/src/shared/mcp/index.ts
Normal file
332
core/src/shared/mcp/index.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* MCP (Model Context Protocol) 支持模块
|
||||
*
|
||||
* 提供装饰器和基础设施,用于在服务中定义 MCP 工具
|
||||
*
|
||||
* 使用方式:
|
||||
* ```typescript
|
||||
* class ListRulesInput {
|
||||
* @ApiPropertyOptional({ description: "项目目录" })
|
||||
* @IsString()
|
||||
* @IsOptional()
|
||||
* cwd?: string;
|
||||
* }
|
||||
*
|
||||
* @McpServer({ name: "review-rules", version: "1.0.0" })
|
||||
* export class ReviewMcpService {
|
||||
* @McpTool({
|
||||
* name: "list_rules",
|
||||
* description: "获取所有审查规则",
|
||||
* dto: ListRulesInput,
|
||||
* })
|
||||
* async listRules(input: ListRulesInput) {
|
||||
* return { rules: [...] };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import "reflect-metadata";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
/** MCP 服务元数据 key(使用 Symbol 确保唯一性) */
|
||||
export const MCP_SERVER_METADATA = Symbol.for("spaceflow:mcp:server");
|
||||
|
||||
/** MCP 工具元数据 key(使用 Symbol 确保唯一性) */
|
||||
export const MCP_TOOL_METADATA = Symbol.for("spaceflow:mcp:tool");
|
||||
|
||||
/** JSON Schema 类型 */
|
||||
export interface JsonSchema {
|
||||
type: string;
|
||||
properties?: Record<string, JsonSchema & { description?: string }>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** MCP 服务定义 */
|
||||
export interface McpServerDefinition {
|
||||
/** 服务名称 */
|
||||
name: string;
|
||||
/** 服务版本 */
|
||||
version?: string;
|
||||
/** 服务描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Swagger 元数据常量 */
|
||||
const SWAGGER_API_MODEL_PROPERTIES = "swagger/apiModelProperties";
|
||||
const SWAGGER_API_MODEL_PROPERTIES_ARRAY = "swagger/apiModelPropertiesArray";
|
||||
|
||||
/**
|
||||
* 从 @nestjs/swagger 的 @ApiProperty / @ApiPropertyOptional 元数据生成 JSON Schema
|
||||
* 直接读取 swagger 装饰器存储的 reflect-metadata,无需自定义装饰器
|
||||
*/
|
||||
export function dtoToJsonSchema(dtoClass: new (...args: any[]) => any): JsonSchema {
|
||||
const prototype = dtoClass.prototype;
|
||||
|
||||
// 读取属性名列表(swagger 存储格式为 ":propertyName")
|
||||
const propertyKeys: string[] = (
|
||||
Reflect.getMetadata(SWAGGER_API_MODEL_PROPERTIES_ARRAY, prototype) || []
|
||||
).map((key: string) => key.replace(/^:/, ""));
|
||||
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const key of propertyKeys) {
|
||||
const meta = Reflect.getMetadata(SWAGGER_API_MODEL_PROPERTIES, prototype, key) || {};
|
||||
|
||||
// 推断 JSON Schema type
|
||||
const typeMap: Record<string, string> = {
|
||||
String: "string",
|
||||
Number: "number",
|
||||
Boolean: "boolean",
|
||||
Array: "array",
|
||||
Object: "object",
|
||||
};
|
||||
|
||||
let schemaType = meta.type;
|
||||
// meta.type 可能是构造函数(如 Boolean)或字符串(如 "boolean")
|
||||
if (typeof schemaType === "function") {
|
||||
schemaType = typeMap[schemaType.name] || "string";
|
||||
}
|
||||
// 如果 swagger 没有显式 type,从 class-validator 元数据推断
|
||||
if (!schemaType) {
|
||||
try {
|
||||
const { getMetadataStorage } = require("class-validator");
|
||||
const validationMetas = getMetadataStorage().getTargetValidationMetadatas(
|
||||
dtoClass,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
);
|
||||
const validatorTypeMap: Record<string, string> = {
|
||||
isString: "string",
|
||||
isNumber: "number",
|
||||
isBoolean: "boolean",
|
||||
isArray: "array",
|
||||
isObject: "object",
|
||||
isInt: "number",
|
||||
isEnum: "string",
|
||||
};
|
||||
const propMeta = validationMetas.find(
|
||||
(m: any) => m.propertyName === key && validatorTypeMap[m.name],
|
||||
);
|
||||
if (propMeta) {
|
||||
schemaType = validatorTypeMap[propMeta.name];
|
||||
}
|
||||
} catch {
|
||||
// class-validator 不可用时忽略
|
||||
}
|
||||
}
|
||||
// 最后从 reflect-metadata 的 design:type 推断
|
||||
if (!schemaType) {
|
||||
const reflectedType = Reflect.getMetadata("design:type", prototype, key);
|
||||
if (reflectedType) {
|
||||
schemaType = typeMap[reflectedType.name] || "string";
|
||||
}
|
||||
}
|
||||
|
||||
const prop: Record<string, any> = {};
|
||||
if (schemaType) prop.type = schemaType;
|
||||
if (meta.description) prop.description = meta.description;
|
||||
if (meta.default !== undefined) prop.default = meta.default;
|
||||
if (meta.enum) prop.enum = meta.enum;
|
||||
if (meta.example !== undefined) prop.example = meta.example;
|
||||
|
||||
properties[key] = prop;
|
||||
|
||||
// required 判断:swagger 的 @ApiProperty 默认 required=true,@ApiPropertyOptional 为 false
|
||||
if (meta.required !== false) {
|
||||
required.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const schema: JsonSchema = { type: "object", properties };
|
||||
if (required.length > 0) schema.required = required;
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** MCP 工具定义 */
|
||||
export interface McpToolDefinition {
|
||||
/** 工具名称 */
|
||||
name: string;
|
||||
/** 工具描述 */
|
||||
description: string;
|
||||
/** 输入参数 schema (JSON Schema 格式,与 dto 二选一) */
|
||||
inputSchema?: JsonSchema;
|
||||
/** 输入参数 DTO 类(与 inputSchema 二选一,优先级高于 inputSchema) */
|
||||
dto?: new (...args: any[]) => any;
|
||||
}
|
||||
|
||||
/** 存储的工具元数据 */
|
||||
export interface McpToolMetadata extends McpToolDefinition {
|
||||
/** 方法名 */
|
||||
methodName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 服务装饰器
|
||||
* 标记一个类为 MCP 服务,内部自动应用 @Injectable()
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @McpServer({ name: "review-rules", version: "1.0.0" })
|
||||
* export class ReviewMcpService {
|
||||
* @McpTool({ name: "list_rules", description: "获取规则" })
|
||||
* async listRules() { ... }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function McpServer(definition: McpServerDefinition): ClassDecorator {
|
||||
return (target: Function) => {
|
||||
// 应用 @Injectable() 装饰器
|
||||
Injectable()(target);
|
||||
// 使用静态属性存储元数据(跨模块可访问)
|
||||
(target as any).__mcp_server__ = definition;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP 工具装饰器
|
||||
* 标记一个方法为 MCP 工具
|
||||
*/
|
||||
export function McpTool(definition: McpToolDefinition): MethodDecorator {
|
||||
return (target: object, propertyKey: string | symbol, _descriptor: PropertyDescriptor) => {
|
||||
const constructor = target.constructor as any;
|
||||
// 使用静态属性存储工具列表(跨模块可访问)
|
||||
if (!constructor.__mcp_tools__) {
|
||||
constructor.__mcp_tools__ = [];
|
||||
}
|
||||
|
||||
// 如果提供了 dto,自动从 swagger 元数据生成 inputSchema
|
||||
const resolvedDefinition = { ...definition };
|
||||
if (resolvedDefinition.dto) {
|
||||
resolvedDefinition.inputSchema = dtoToJsonSchema(resolvedDefinition.dto);
|
||||
}
|
||||
|
||||
constructor.__mcp_tools__.push({
|
||||
...resolvedDefinition,
|
||||
methodName: String(propertyKey),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查一个类是否是 MCP 服务
|
||||
*/
|
||||
export function isMcpServer(target: any): boolean {
|
||||
const constructor = target?.constructor || target;
|
||||
return !!constructor?.__mcp_server__;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 MCP 服务元数据
|
||||
*/
|
||||
export function getMcpServerMetadata(target: any): McpServerDefinition | undefined {
|
||||
const constructor = target?.constructor || target;
|
||||
return constructor?.__mcp_server__;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从服务类获取所有 MCP 工具定义
|
||||
*/
|
||||
export function getMcpTools(target: any): McpToolMetadata[] {
|
||||
const constructor = target?.constructor || target;
|
||||
return constructor?.__mcp_tools__ || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Server 运行器
|
||||
* 收集服务中的 MCP 工具并启动 stdio 服务
|
||||
*/
|
||||
export async function runMcpServer(
|
||||
service: any,
|
||||
serverInfo: { name: string; version: string },
|
||||
): Promise<void> {
|
||||
const tools = getMcpTools(service);
|
||||
|
||||
if (tools.length === 0) {
|
||||
console.error("没有找到 MCP 工具定义");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 动态导入 MCP SDK(避免在不需要时加载)
|
||||
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
|
||||
const server = new McpServer(serverInfo);
|
||||
|
||||
// 注册所有工具(使用 v1 API: server.tool)
|
||||
for (const tool of tools) {
|
||||
// v1 API: server.tool(name, description, schema, callback)
|
||||
// 使用工具定义中的 inputSchema 转为 zod,如果没有则传空对象
|
||||
const schema = tool.inputSchema ? jsonSchemaToZod(tool.inputSchema) : {};
|
||||
server.tool(tool.name, tool.description, schema, async (args: any) => {
|
||||
try {
|
||||
const result = await service[tool.methodName](args || {});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON Schema 转换为简单的 zod 对象(MCP SDK 需要 zod)
|
||||
* 仅处理顶层 properties,满足 MCP 工具注册需求
|
||||
*/
|
||||
function jsonSchemaToZod(jsonSchema: JsonSchema): Record<string, any> {
|
||||
const { z } = require("zod") as typeof import("zod");
|
||||
if (!jsonSchema.properties) return {};
|
||||
|
||||
const shape: Record<string, any> = {};
|
||||
const requiredFields = jsonSchema.required || [];
|
||||
|
||||
for (const [key, prop] of Object.entries(jsonSchema.properties)) {
|
||||
let field: any;
|
||||
switch (prop.type) {
|
||||
case "number":
|
||||
field = z.number();
|
||||
break;
|
||||
case "boolean":
|
||||
field = z.boolean();
|
||||
break;
|
||||
case "array":
|
||||
field = z.array(z.any());
|
||||
break;
|
||||
case "object":
|
||||
field = z.object({});
|
||||
break;
|
||||
default:
|
||||
field = z.string();
|
||||
}
|
||||
if (prop.description) field = field.describe(prop.description);
|
||||
if (!requiredFields.includes(key)) field = field.optional();
|
||||
shape[key] = field;
|
||||
}
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
// 启动 stdio 传输
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
console.error(`MCP Server "${serverInfo.name}" started with ${tools.length} tools`);
|
||||
}
|
||||
2
core/src/shared/output/index.ts
Normal file
2
core/src/shared/output/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./output.service";
|
||||
export * from "./output.module";
|
||||
9
core/src/shared/output/output.module.ts
Normal file
9
core/src/shared/output/output.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { OutputService } from "./output.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [OutputService],
|
||||
exports: [OutputService],
|
||||
})
|
||||
export class OutputModule {}
|
||||
97
core/src/shared/output/output.service.ts
Normal file
97
core/src/shared/output/output.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Scope } from "@nestjs/common";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const OUTPUT_MARKER_START = "::spaceflow-output::";
|
||||
const OUTPUT_MARKER_END = "::end::";
|
||||
|
||||
/**
|
||||
* OutputService - 用于标准化命令输出
|
||||
*
|
||||
* 命令可以通过此服务设置输出值,这些值会在命令执行完成后
|
||||
* 以特定格式输出到 stdout,供 CI 流程中的其他步骤使用。
|
||||
*
|
||||
* 输出格式: ::spaceflow-output::{"key":"value","_cacheId":"uuid"}::end::
|
||||
*
|
||||
* _cacheId 用于 actions/cache 在不同 job 之间传递数据
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* @Injectable()
|
||||
* export class MyService {
|
||||
* constructor(protected readonly output: OutputService) {}
|
||||
*
|
||||
* async execute() {
|
||||
* // ... 执行逻辑
|
||||
* this.output.set("version", "1.0.0");
|
||||
* this.output.set("tag", "v1.0.0");
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ scope: Scope.DEFAULT })
|
||||
export class OutputService {
|
||||
protected outputs: Record<string, string> = {};
|
||||
protected cacheId: string = randomUUID();
|
||||
|
||||
/**
|
||||
* 设置单个输出值
|
||||
*/
|
||||
set(key: string, value: string | number | boolean): void {
|
||||
this.outputs[key] = String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置输出值
|
||||
*/
|
||||
setAll(values: Record<string, string | number | boolean>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有输出值
|
||||
*/
|
||||
getAll(): Record<string, string> {
|
||||
return { ...this.outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有输出值
|
||||
*/
|
||||
clear(): void {
|
||||
this.outputs = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出所有值到 stdout(带标记格式)
|
||||
* 通常在命令执行完成后调用
|
||||
* _cacheId 会被 actions 捕获并用于 actions/cache
|
||||
*/
|
||||
flush(): void {
|
||||
if (Object.keys(this.outputs).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 输出到 stdout,包含 cacheId 供 actions/cache 使用
|
||||
const outputWithCache = { ...this.outputs, _cacheId: this.cacheId };
|
||||
const json = JSON.stringify(outputWithCache);
|
||||
console.log(`${OUTPUT_MARKER_START}${json}${OUTPUT_MARKER_END}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有输出值
|
||||
*/
|
||||
hasOutputs(): boolean {
|
||||
return Object.keys(this.outputs).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 cacheId
|
||||
*/
|
||||
getCacheId(): string {
|
||||
return this.cacheId;
|
||||
}
|
||||
}
|
||||
|
||||
export { OUTPUT_MARKER_START, OUTPUT_MARKER_END };
|
||||
115
core/src/shared/package-manager/index.ts
Normal file
115
core/src/shared/package-manager/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { execSync } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* 检测项目使用的包管理器
|
||||
* 必须同时满足:命令可用 AND lock 文件存在
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function getPackageManager(cwd?: string): string {
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
// pnpm: 命令可用 + pnpm-lock.yaml 存在
|
||||
if (existsSync(join(workDir, "pnpm-lock.yaml"))) {
|
||||
try {
|
||||
execSync("pnpm --version", { stdio: "ignore" });
|
||||
return "pnpm";
|
||||
} catch {
|
||||
// pnpm 命令不可用,继续检测其他
|
||||
}
|
||||
}
|
||||
|
||||
// yarn: 命令可用 + yarn.lock 存在
|
||||
if (existsSync(join(workDir, "yarn.lock"))) {
|
||||
try {
|
||||
execSync("yarn --version", { stdio: "ignore" });
|
||||
return "yarn";
|
||||
} catch {
|
||||
// yarn 命令不可用,继续检测其他
|
||||
}
|
||||
}
|
||||
|
||||
// npm: 命令可用 + package-lock.json 存在
|
||||
if (existsSync(join(workDir, "package-lock.json"))) {
|
||||
try {
|
||||
execSync("npm --version", { stdio: "ignore" });
|
||||
return "npm";
|
||||
} catch {
|
||||
// npm 命令不可用
|
||||
}
|
||||
}
|
||||
|
||||
// 默认回退到 npm
|
||||
return "npm";
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测指定目录使用的包管理器(基于 lock 文件)
|
||||
* 如果没有 lock 文件,尝试检测 pnpm 是否可用
|
||||
* @param dir 目标目录
|
||||
*/
|
||||
export function detectPackageManager(dir: string): string {
|
||||
if (existsSync(join(dir, "pnpm-lock.yaml"))) {
|
||||
return "pnpm";
|
||||
}
|
||||
if (existsSync(join(dir, "yarn.lock"))) {
|
||||
return "yarn";
|
||||
}
|
||||
if (existsSync(join(dir, "package-lock.json"))) {
|
||||
return "npm";
|
||||
}
|
||||
// 默认使用 pnpm(如果可用)
|
||||
try {
|
||||
execSync("pnpm --version", { stdio: "ignore" });
|
||||
return "pnpm";
|
||||
} catch {
|
||||
return "npm";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测当前目录是否为 pnpm workspace
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
*/
|
||||
export function isPnpmWorkspace(cwd?: string): boolean {
|
||||
const workDir = cwd || process.cwd();
|
||||
return existsSync(join(workDir, "pnpm-workspace.yaml"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 .spaceflow 添加到根项目的 devDependencies 中
|
||||
* 使用 file: 协议,兼容 npm 和 pnpm
|
||||
* @param cwd 工作目录,默认为 process.cwd()
|
||||
* @returns 是否成功添加(如果已存在则返回 false)
|
||||
*/
|
||||
export function addSpaceflowToDevDependencies(cwd?: string): boolean {
|
||||
const { readFileSync, writeFileSync } = require("fs");
|
||||
const workDir = cwd || process.cwd();
|
||||
const packageJsonPath = join(workDir, "package.json");
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(packageJsonPath, "utf-8");
|
||||
const pkg = JSON.parse(content);
|
||||
|
||||
// 检查是否已存在
|
||||
if (pkg.devDependencies?.["spaceflow"]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 添加到 devDependencies
|
||||
if (!pkg.devDependencies) {
|
||||
pkg.devDependencies = {};
|
||||
}
|
||||
pkg.devDependencies["spaceflow"] = "file:.spaceflow";
|
||||
|
||||
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + "\n");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1
core/src/shared/parallel/index.ts
Normal file
1
core/src/shared/parallel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./parallel-executor";
|
||||
169
core/src/shared/parallel/parallel-executor.ts
Normal file
169
core/src/shared/parallel/parallel-executor.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
export interface ParallelTask<T, R> {
|
||||
id: string;
|
||||
data: T;
|
||||
execute: (data: T) => Promise<R>;
|
||||
}
|
||||
|
||||
export interface ParallelResult<R> {
|
||||
id: string;
|
||||
success: boolean;
|
||||
result?: R;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface ParallelExecutorOptions {
|
||||
concurrency?: number;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
retryDelay?: number;
|
||||
onProgress?: (completed: number, total: number, taskId: string) => void;
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
onTaskComplete?: (taskId: string, success: boolean) => void;
|
||||
onRetry?: (taskId: string, attempt: number, error: Error) => void;
|
||||
stopOnError?: boolean;
|
||||
}
|
||||
|
||||
export class ParallelExecutor {
|
||||
private readonly concurrency: number;
|
||||
private readonly timeout?: number;
|
||||
private readonly retries: number;
|
||||
private readonly retryDelay: number;
|
||||
private readonly onProgress?: (completed: number, total: number, taskId: string) => void;
|
||||
private readonly onTaskStart?: (taskId: string) => void;
|
||||
private readonly onTaskComplete?: (taskId: string, success: boolean) => void;
|
||||
private readonly onRetry?: (taskId: string, attempt: number, error: Error) => void;
|
||||
private readonly stopOnError: boolean;
|
||||
|
||||
constructor(options: ParallelExecutorOptions = {}) {
|
||||
this.concurrency = options.concurrency ?? 5;
|
||||
this.timeout = options.timeout;
|
||||
this.retries = options.retries ?? 0;
|
||||
this.retryDelay = options.retryDelay ?? 1000;
|
||||
this.onProgress = options.onProgress;
|
||||
this.onTaskStart = options.onTaskStart;
|
||||
this.onTaskComplete = options.onTaskComplete;
|
||||
this.onRetry = options.onRetry;
|
||||
this.stopOnError = options.stopOnError ?? false;
|
||||
}
|
||||
|
||||
async execute<T, R>(tasks: ParallelTask<T, R>[]): Promise<ParallelResult<R>[]> {
|
||||
if (tasks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: ParallelResult<R>[] = [];
|
||||
const total = tasks.length;
|
||||
let completed = 0;
|
||||
let shouldStop = false;
|
||||
|
||||
const executeTask = async (task: ParallelTask<T, R>): Promise<ParallelResult<R>> => {
|
||||
if (shouldStop) {
|
||||
return { id: task.id, success: false, error: new Error("Execution stopped") };
|
||||
}
|
||||
|
||||
this.onTaskStart?.(task.id);
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
this.onRetry?.(task.id, attempt, lastError!);
|
||||
await this.delay(this.retryDelay);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.executeWithTimeout(task, task.data);
|
||||
completed++;
|
||||
this.onProgress?.(completed, total, task.id);
|
||||
this.onTaskComplete?.(task.id, true);
|
||||
return { id: task.id, success: true, result };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
completed++;
|
||||
this.onProgress?.(completed, total, task.id);
|
||||
this.onTaskComplete?.(task.id, false);
|
||||
|
||||
if (this.stopOnError) {
|
||||
shouldStop = true;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
success: false,
|
||||
error: lastError,
|
||||
};
|
||||
};
|
||||
|
||||
// 使用滑动窗口并发控制
|
||||
const pending: Promise<void>[] = [];
|
||||
const taskQueue = [...tasks];
|
||||
|
||||
while (taskQueue.length > 0 || pending.length > 0) {
|
||||
// 填充到并发上限
|
||||
while (pending.length < this.concurrency && taskQueue.length > 0 && !shouldStop) {
|
||||
const task = taskQueue.shift()!;
|
||||
const promise = executeTask(task).then((result) => {
|
||||
results.push(result);
|
||||
// 从 pending 中移除
|
||||
const index = pending.indexOf(promise);
|
||||
if (index > -1) {
|
||||
pending.splice(index, 1);
|
||||
}
|
||||
});
|
||||
pending.push(promise);
|
||||
}
|
||||
|
||||
// 等待任意一个完成
|
||||
if (pending.length > 0) {
|
||||
await Promise.race(pending);
|
||||
}
|
||||
}
|
||||
|
||||
// 按原始顺序排序结果
|
||||
const taskIdOrder = new Map(tasks.map((t, i) => [t.id, i]));
|
||||
results.sort((a, b) => (taskIdOrder.get(a.id) ?? 0) - (taskIdOrder.get(b.id) ?? 0));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async map<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
getId?: (item: T, index: number) => string,
|
||||
): Promise<ParallelResult<R>[]> {
|
||||
const tasks: ParallelTask<{ item: T; index: number }, R>[] = items.map((item, index) => ({
|
||||
id: getId ? getId(item, index) : String(index),
|
||||
data: { item, index },
|
||||
execute: async ({ item, index }) => fn(item, index),
|
||||
}));
|
||||
|
||||
return this.execute(tasks);
|
||||
}
|
||||
|
||||
private async executeWithTimeout<T, R>(task: ParallelTask<T, R>, data: T): Promise<R> {
|
||||
if (!this.timeout) {
|
||||
return task.execute(data);
|
||||
}
|
||||
|
||||
return Promise.race([
|
||||
task.execute(data),
|
||||
new Promise<R>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Task ${task.id} timed out after ${this.timeout}ms`)),
|
||||
this.timeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export function parallel(options?: ParallelExecutorOptions): ParallelExecutor {
|
||||
return new ParallelExecutor(options);
|
||||
}
|
||||
1
core/src/shared/rspack-config/index.ts
Normal file
1
core/src/shared/rspack-config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./rspack-config";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user