chore: 初始化仓库

This commit is contained in:
Lydanne
2026-02-15 22:02:21 +08:00
commit 08d011d63f
381 changed files with 87202 additions and 0 deletions

56
core/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

175
core/README.md Normal file
View 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
View 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
View 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
View 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",
},
},
},
],
},
};

View File

@@ -0,0 +1,9 @@
module.exports = {
createOpencodeClient: jest.fn().mockReturnValue({
session: {
create: jest.fn(),
prompt: jest.fn(),
delete: jest.fn(),
},
}),
};

View File

@@ -0,0 +1,3 @@
export const loadConfig = jest.fn().mockResolvedValue({
config: {},
});

18
core/src/app.module.ts Normal file
View 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 {}

View 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,
});

View 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;
});
}

View 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 {}

View 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,
};
}
}

View 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 配置",
});

View 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
View 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,
];

View 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 服务配置",
});

View 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");
}
}

View 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 };

View 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: "存储服务配置",
});

View 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 [];
}

View File

@@ -0,0 +1 @@
export * from "./extension.interface";

80
core/src/index.ts Normal file
View 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";

View 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}}"
}

View 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}}"
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { ClaudeSetupService } from "./claude-setup.service";
@Module({
providers: [ClaudeSetupService],
exports: [ClaudeSetupService],
})
export class ClaudeSetupModule {}

View 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}`);
}
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./claude-setup.service";
export * from "./claude-setup.module";

View 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}`;
}

View 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],
};
}
}

View 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[];
}
}

View 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}`);
}
}
}

View File

@@ -0,0 +1,4 @@
export * from "./feishu-sdk.module";
export * from "./feishu-sdk.service";
export * from "./fieshu-card.service";
export * from "./types/index";

View 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>;
}

View 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;

View 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";

View 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";

View 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;
};
}

View 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[];
}

View 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";
}

View 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(),
);
});
});
});

View 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`);
}
}

View 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(),
);
});
});
});

View 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";
}
}

View 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 pathowner/repo标识
* - Merge RequestMR对应 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./gitea.adapter";
export * from "./github.adapter";
export * from "./gitlab.adapter";

View 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");
});
});
});

View 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_TOKENGitLab 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 || "";
}

View 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[]>;
}

View 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],
};
}
}

View 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);
});
});
});

View 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);
}
}

View 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";

View 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();
});
});
});

View 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 commithttps://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 URLgit+ssh://git@host/owner/repo.git
* - SSH URLgit@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;
}

View 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;
}

View 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([]);
});
});
});

View 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;
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { GitSdkService } from "./git-sdk.service";
@Module({
providers: [GitSdkService],
exports: [GitSdkService],
})
export class GitSdkModule {}

View 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;
}
}

View 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;
}

View 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";

View 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");
}
});
});

View 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);
}
}

View File

@@ -0,0 +1 @@
export { initI18n, t, addLocaleResources } from "./i18n";

View 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;
}

View 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 });
}
}

View 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[];
}

View 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");
});
});
});

View 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;
}
}

View File

@@ -0,0 +1,4 @@
export * from "./llm-adapter.interface";
export * from "./claude-code.adapter";
export * from "./openai.adapter";
export * from "./open-code.adapter";

View 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");

View 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;
}
}

View 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);
});
});

View 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;
}
}

View 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";

View 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");

View File

@@ -0,0 +1,4 @@
export * from "./message.interface";
export * from "./session.interface";
export * from "./config.interface";
export type { VerboseLevel } from "../../verbose";

View 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 };

View 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;
}

View 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;
}
}

View 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");
});
});
});

View 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;
}
}

View 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([]);
});
});
});

View 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,
};
}
}

View 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;
}
}

View File

@@ -0,0 +1,11 @@
export { Logger } from "./logger";
export type {
LoggerOptions,
LogLevel,
RenderMode,
Spinner,
ProgressBar,
ProgressBarOptions,
TaskItem,
TaskControl,
} from "./logger.interface";

View 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=tuiCI/管道=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;
}

View 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();
});
});
});

View 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 模式下显示动画 spinnerplain 模式下降级为普通日志
*/
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,
});
}
}

View 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);
}
}

View 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);
}
}

View 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`);
}

View File

@@ -0,0 +1,2 @@
export * from "./output.service";
export * from "./output.module";

View 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 {}

View 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 };

View 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;
}
}

View File

@@ -0,0 +1 @@
export * from "./parallel-executor";

View 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);
}

View File

@@ -0,0 +1 @@
export * from "./rspack-config";

Some files were not shown because too many files have changed in this diff Show More