From d946423d4554f816100c2b3e7157d11c00e0a029 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Tue, 3 Mar 2026 16:31:42 +0800 Subject: [PATCH] feat(config): add runtime config manager with 3-layer priority Implement ConfigManager with three-layer configuration priority: - Layer 1: Zod schema defaults - Layer 2: Environment variables (process.env) - Layer 3: JSON file overrides (config-overrides.json) Features: - Atomic file writes with temp+rename for reliability - Synchronous load at startup for immediate availability - Runtime hot-reload via async methods - Source tracking (default/env/override) per config key - Full Zod schema validation with type safety Files added: - src/config/config-manager.ts: Core manager implementation - src/config/config-schema.ts: Field metadata and group definitions - src/config/__tests__/: Unit tests for config manager - typings/: TypeScript declaration files --- src/config/__tests__/config-manager.test.ts | 186 +++++++++ src/config/config-manager.ts | 404 +++++++++++++++++++ src/config/config-schema.ts | 422 ++++++++++++++++++++ src/config/index.ts | 149 +------ typings/bun-test.d.ts | 10 + 5 files changed, 1030 insertions(+), 141 deletions(-) create mode 100644 src/config/__tests__/config-manager.test.ts create mode 100644 src/config/config-manager.ts create mode 100644 src/config/config-schema.ts create mode 100644 typings/bun-test.d.ts diff --git a/src/config/__tests__/config-manager.test.ts b/src/config/__tests__/config-manager.test.ts new file mode 100644 index 0000000..ff2df25 --- /dev/null +++ b/src/config/__tests__/config-manager.test.ts @@ -0,0 +1,186 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const it: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; + export const beforeAll: any; + export const afterAll: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { unlink, readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import type { AppConfig } from '../config-manager'; + +// ── All env keys in the Zod schema ────────────────────────────────────────── +const SCHEMA_KEYS = [ + 'GITEA_API_URL', 'GITEA_ACCESS_TOKEN', 'GITEA_ADMIN_TOKEN', + 'OPENAI_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_MODEL', + 'CUSTOM_SUMMARY_PROMPT', 'CUSTOM_LINE_COMMENT_PROMPT', + 'FEISHU_WEBHOOK_URL', 'FEISHU_WEBHOOK_SECRET', + 'PORT', 'WEBHOOK_SECRET', 'ADMIN_PASSWORD', 'JWT_SECRET', + 'REVIEW_ENGINE', 'REVIEW_WORKDIR', 'REVIEW_MODEL_PLANNER', + 'REVIEW_MODEL_SPECIALIST', 'REVIEW_MODEL_JUDGE', + 'REVIEW_MAX_PARALLEL_RUNS', 'REVIEW_MAX_FILES_PER_RUN', + 'REVIEW_MAX_FILE_CONTENT_CHARS', 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', + 'REVIEW_ENABLE_HUMAN_GATE', 'REVIEW_ALLOWED_COMMANDS', 'REVIEW_COMMAND_TIMEOUT_MS', + 'QDRANT_URL', 'ENABLE_MEMORY', 'FEW_SHOT_EXAMPLES_COUNT', + 'ENABLE_REFLECTION', 'MAX_REFLECTION_ROUNDS', 'ENABLE_DEBATE', 'DEBATE_THRESHOLD', +] as const; + +const CONTROL_KEYS = ['CONFIG_OVERRIDES_PATH', 'NODE_ENV'] as const; +const ALL_KEYS: readonly string[] = [...SCHEMA_KEYS, ...CONTROL_KEYS]; + +/** + * Dynamically import a fresh config-manager module. + * Appending a unique query string to the specifier forces Bun to bypass the + * module cache, giving us a brand-new ConfigManager singleton each time. + */ +async function importFresh() { + const mod = await import(`../config-manager.ts?t=${Date.now()}-${randomUUID()}`); + return mod.configManager; +} + +describe('ConfigManager', () => { + let tmpPath: string; + const savedEnv: Record = {}; + + beforeEach(() => { + tmpPath = join(tmpdir(), `cfg-test-${randomUUID()}.json`); + + // Snapshot every env key we might touch + for (const key of ALL_KEYS) { + savedEnv[key] = process.env[key]; + } + + // Neutralise all schema keys ('' is treated as "absent" by getCurrent). + // This also prevents dotenv from injecting values from a local .env file. + for (const key of SCHEMA_KEYS) { + process.env[key] = ''; + } + + // Per-test temp overrides file + process.env.CONFIG_OVERRIDES_PATH = tmpPath; + + // FEISHU_WEBHOOK_URL has no Zod default → must be a valid URL for schema to pass. + process.env.FEISHU_WEBHOOK_URL = 'https://hooks.example.com/test'; + }); + + afterEach(async () => { + for (const key of ALL_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]!; + } + } + try { await unlink(tmpPath); } catch { /* ok if missing */ } + }); + + // ─── 1. Layering: defaults < env < override ───────────────────────── + + describe('layering: defaults < env < override', () => { + test('Zod default used when env and override are absent', async () => { + const cm = await importFresh(); + expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini'); + }); + + test('env value overrides Zod default', async () => { + process.env.OPENAI_MODEL = 'env-model'; + const cm = await importFresh(); + expect(cm.getCurrent().openai.model).toBe('env-model'); + }); + + test('override wins over env', async () => { + process.env.OPENAI_MODEL = 'env-model'; + const cm = await importFresh(); + await cm.setOverrides({ OPENAI_MODEL: 'override-model' }); + expect(cm.getCurrent().openai.model).toBe('override-model'); + }); + }); + + // ─── 2. Empty string resets override ───────────────────────────────── + + describe('empty string resets override', () => { + test('setting override to "" removes it, value falls back to Zod default', async () => { + const cm = await importFresh(); + await cm.setOverrides({ OPENAI_MODEL: 'temp-override' }); + expect(cm.getCurrent().openai.model).toBe('temp-override'); + + await cm.setOverrides({ OPENAI_MODEL: '' }); + + // OPENAI_MODEL is '' in env (neutralised) → falls to Zod default + expect(cm.getCurrent().openai.model).toBe('gpt-4o-mini'); + expect(cm.getOverrides()).not.toHaveProperty('OPENAI_MODEL'); + }); + }); + + // ─── 3. Persistence ───────────────────────────────────────────────── + + describe('persistence', () => { + test('setOverrides writes JSON file; new instance loads it', async () => { + const cm1 = await importFresh(); + await cm1.setOverrides({ OPENAI_MODEL: 'persisted-model' }); + + // File structure check + const raw = await readFile(tmpPath, 'utf-8'); + const data = JSON.parse(raw); + expect(data.version).toBe(1); + expect(typeof data.updatedAt).toBe('string'); + expect(data.overrides.OPENAI_MODEL).toBe('persisted-model'); + + // Fresh instance picks it up + const cm2 = await importFresh(); + expect(cm2.getCurrent().openai.model).toBe('persisted-model'); + }); + }); + + // ─── 4. getSource() ───────────────────────────────────────────────── + + describe('getSource()', () => { + test('returns "default" when neither env nor override is set', async () => { + // OPENAI_MODEL = '' (neutralised) → getSource sees '' → 'default' + const cm = await importFresh(); + expect(cm.getSource('OPENAI_MODEL')).toBe('default'); + }); + + test('returns "env" when process.env has a non-empty value', async () => { + process.env.OPENAI_MODEL = 'from-env'; + const cm = await importFresh(); + expect(cm.getSource('OPENAI_MODEL')).toBe('env'); + }); + + test('returns "override" when override is set', async () => { + process.env.OPENAI_MODEL = 'from-env'; + const cm = await importFresh(); + await cm.setOverrides({ OPENAI_MODEL: 'from-override' }); + expect(cm.getSource('OPENAI_MODEL')).toBe('override'); + }); + }); + + // ─── 5. Dev fallback ───────────────────────────────────────────────── + + describe('dev fallback', () => { + test('FEISHU_WEBHOOK_URL missing + NODE_ENV=development → feishu.webhookUrl ""', async () => { + process.env.FEISHU_WEBHOOK_URL = ''; // invalid → safeParse fails + process.env.NODE_ENV = 'development'; + const cm = await importFresh(); + const cfg: AppConfig = cm.getCurrent(); + expect(cfg.feishu.webhookUrl).toBe(''); + }); + + test('FEISHU_WEBHOOK_URL missing + NODE_ENV unset → feishu.webhookUrl ""', async () => { + process.env.FEISHU_WEBHOOK_URL = ''; + process.env.NODE_ENV = ''; // falsy → same branch as undefined + const cm = await importFresh(); + const cfg: AppConfig = cm.getCurrent(); + expect(cfg.feishu.webhookUrl).toBe(''); + }); + }); +}); diff --git a/src/config/config-manager.ts b/src/config/config-manager.ts new file mode 100644 index 0000000..cd03e04 --- /dev/null +++ b/src/config/config-manager.ts @@ -0,0 +1,404 @@ +/** + * Three-layer configuration manager. + * Priority: Zod defaults → process.env → JSON overrides + * + * Override file format: + * { version: 1, updatedAt: string, overrides: Record } + * + * Bun-friendly IO: reads via readFile, writes atomically via temp+rename. + */ + +import { z } from 'zod'; +import { dirname, resolve } from 'node:path'; +import { rename, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { config as dotenvConfig } from 'dotenv'; + +// Load .env before any process.env access (must precede singleton construction) +dotenvConfig(); + +// --------------------------------------------------------------------------- +// Override file types +// --------------------------------------------------------------------------- + +interface OverridesFile { + version: 1; + updatedAt: string; + overrides: Record; +} + +// --------------------------------------------------------------------------- +// Zod schema (identical to src/config/index.ts) +// --------------------------------------------------------------------------- + +const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc']; + +const envSchema = z.object({ + // Gitea + GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'), + GITEA_ACCESS_TOKEN: z.string().default('test_token'), + GITEA_ADMIN_TOKEN: z.string().optional(), + + // OpenAI + OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'), + OPENAI_API_KEY: z.string().default('test_openai_key'), + OPENAI_MODEL: z.string().default('gpt-4o-mini'), + CUSTOM_SUMMARY_PROMPT: z.string().optional(), + CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(), + + // Feishu + FEISHU_WEBHOOK_URL: z.string().url(), + FEISHU_WEBHOOK_SECRET: z.string().optional(), + + // App + PORT: z.string().transform(Number).default('5174'), + WEBHOOK_SECRET: z.string().default('test_webhook_secret'), + + // Admin + ADMIN_PASSWORD: z.string().default('password'), + JWT_SECRET: z.string().default('a-secure-secret-for-jwt'), + + // Review engine + REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'), + REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'), + REVIEW_MODEL_PLANNER: z.string().default('gpt-4o-mini'), + REVIEW_MODEL_SPECIALIST: z.string().default('gpt-4o-mini'), + REVIEW_MODEL_JUDGE: z.string().default('gpt-4o-mini'), + REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2), + REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200), + REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000), + REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), + REVIEW_ENABLE_HUMAN_GATE: z + .enum(['true', 'false']) + .default('true') + .transform((value) => value === 'true'), + REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')), + REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000), + + // Memory & learning + QDRANT_URL: z.preprocess( + (val) => (typeof val === 'string' && val.trim() === '' ? undefined : val), + z.string().url().optional(), + ), + ENABLE_MEMORY: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10), + + // Reflection & debate + ENABLE_REFLECTION: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2), + ENABLE_DEBATE: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'), +}); + +// --------------------------------------------------------------------------- +// Config shape (matches default export of src/config/index.ts) +// --------------------------------------------------------------------------- + +export interface AppConfig { + gitea: { + apiUrl: string; + accessToken: string; + }; + openai: { + baseUrl: string; + apiKey: string; + model: string; + customSummaryPrompt: string | undefined; + customLineCommentPrompt: string | undefined; + }; + feishu: { + webhookUrl: string; + webhookSecret: string | undefined; + }; + app: { + port: number; + webhookSecret: string; + }; + admin: { + password: string; + jwtSecret: string; + giteaAdminToken: string | undefined; + }; + review: { + engine: string; + workdir: string; + modelPlanner: string; + modelSpecialist: string; + modelJudge: string; + maxParallelRuns: number; + maxFilesPerRun: number; + maxFileContentChars: number; + autoPublishMinConfidence: number; + enableHumanGate: boolean; + allowedCommands: string[]; + commandTimeoutMs: number; + qdrantUrl: string | undefined; + enableMemory: boolean; + fewShotExamplesCount: number; + enableReflection: boolean; + maxReflectionRounds: number; + enableDebate: boolean; + debateThreshold: string; + }; +} + +// --------------------------------------------------------------------------- +// Dev fallback (matches src/config/index.ts behavior when validation fails) +// --------------------------------------------------------------------------- + +const DEV_FALLBACK_CONFIG: AppConfig = { + gitea: { + apiUrl: 'http://localhost:5174/api/v1', + accessToken: 'test_token', + }, + openai: { + baseUrl: 'https://api.openai.com/v1', + apiKey: 'test_openai_key', + model: 'gpt-4o-mini', + customSummaryPrompt: undefined, + customLineCommentPrompt: undefined, + }, + feishu: { + webhookUrl: '', + webhookSecret: '', + }, + app: { + port: 5174, + webhookSecret: 'test_webhook_secret', + }, + admin: { + password: 'password', + jwtSecret: 'a-secure-secret-for-jwt', + giteaAdminToken: undefined, + }, + review: { + engine: 'legacy', + workdir: '/tmp/gitea-assistant', + modelPlanner: 'gpt-4o-mini', + modelSpecialist: 'gpt-4o-mini', + modelJudge: 'gpt-4o-mini', + maxParallelRuns: 2, + maxFilesPerRun: 200, + maxFileContentChars: 40_000, + autoPublishMinConfidence: 0.8, + enableHumanGate: true, + allowedCommands: ['git', 'rg', 'cat', 'sed', 'wc'], + commandTimeoutMs: 10000, + qdrantUrl: undefined, + enableMemory: false, + fewShotExamplesCount: 10, + enableReflection: false, + maxReflectionRounds: 2, + enableDebate: false, + debateThreshold: 'high', + }, +}; + + +// --------------------------------------------------------------------------- +// ConfigManager +// --------------------------------------------------------------------------- + +class ConfigManager { + private readonly overridesPath: string; + private overrides: Record = {}; + + constructor() { + this.overridesPath = resolve(process.env.CONFIG_OVERRIDES_PATH || './config-overrides.json'); + this.loadOverridesSync(); + } + + /** Synchronously load overrides at construction time (file is tiny). */ + private loadOverridesSync(): void { + try { + const text = readFileSync(this.overridesPath, 'utf-8'); + const data: OverridesFile = JSON.parse(text); + if (data && typeof data.overrides === 'object' && data.overrides !== null) { + this.overrides = { ...data.overrides }; + } + } catch { + // File missing or invalid JSON — start with empty overrides + } + } + + // ── Override file I/O ──────────────────────────────────────────────────── + + /** Load overrides from disk. If file is missing or malformed, treat as empty. */ + async loadOverrides(): Promise { + try { + const text = await readFile(this.overridesPath, 'utf-8'); + const data: OverridesFile = JSON.parse(text); + if (data && typeof data.overrides === 'object' && data.overrides !== null) { + this.overrides = { ...data.overrides }; + } else { + this.overrides = {}; + } + } catch { + // File missing or invalid JSON — start with empty overrides + this.overrides = {}; + } + } + + /** Persist current overrides to disk atomically (write temp → rename). */ + private async persistOverrides(): Promise { + const dir = dirname(this.overridesPath); + await mkdir(dir, { recursive: true }); + + const payload: OverridesFile = { + version: 1, + updatedAt: new Date().toISOString(), + overrides: { ...this.overrides }, + }; + + const tmpPath = `${this.overridesPath}.${randomUUID()}.tmp`; + await writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8'); + await rename(tmpPath, this.overridesPath); + } + + // ── Core API ───────────────────────────────────────────────────────────── + + /** + * Returns the fully resolved config object with the same shape as the + * default export of `src/config/index.ts`. + * + * Layering: Zod defaults → process.env → overrides JSON + */ + getCurrent(): AppConfig { + // Build a merged env-like record: process.env overlaid with overrides + const merged: Record = {}; + for (const key of Object.keys(envSchema.shape)) { + const envVal = process.env[key]; + if (envVal !== undefined && envVal !== '') { + merged[key] = envVal; + } + // Override wins if present and non-empty + const ov = this.overrides[key]; + if (ov !== undefined && ov !== '') { + merged[key] = ov; + } + } + + const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; + + const parseResult = envSchema.safeParse(merged); + + if (!parseResult.success) { + if (!isDev) { + throw new Error('Configuration validation error'); + } + return DEV_FALLBACK_CONFIG; + } + + const env = parseResult.data; + + return { + gitea: { + apiUrl: env.GITEA_API_URL, + accessToken: env.GITEA_ACCESS_TOKEN, + }, + openai: { + baseUrl: env.OPENAI_BASE_URL, + apiKey: env.OPENAI_API_KEY, + model: env.OPENAI_MODEL, + customSummaryPrompt: env.CUSTOM_SUMMARY_PROMPT, + customLineCommentPrompt: env.CUSTOM_LINE_COMMENT_PROMPT, + }, + feishu: { + webhookUrl: env.FEISHU_WEBHOOK_URL, + webhookSecret: env.FEISHU_WEBHOOK_SECRET, + }, + app: { + port: env.PORT, + webhookSecret: env.WEBHOOK_SECRET, + }, + admin: { + password: env.ADMIN_PASSWORD, + jwtSecret: env.JWT_SECRET, + giteaAdminToken: env.GITEA_ADMIN_TOKEN, + }, + review: { + engine: env.REVIEW_ENGINE, + workdir: env.REVIEW_WORKDIR, + modelPlanner: env.REVIEW_MODEL_PLANNER, + modelSpecialist: env.REVIEW_MODEL_SPECIALIST, + modelJudge: env.REVIEW_MODEL_JUDGE, + maxParallelRuns: env.REVIEW_MAX_PARALLEL_RUNS, + maxFilesPerRun: env.REVIEW_MAX_FILES_PER_RUN, + maxFileContentChars: env.REVIEW_MAX_FILE_CONTENT_CHARS, + autoPublishMinConfidence: env.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE, + enableHumanGate: env.REVIEW_ENABLE_HUMAN_GATE, + allowedCommands: env.REVIEW_ALLOWED_COMMANDS.split(',') + .map((item) => item.trim()) + .filter(Boolean), + commandTimeoutMs: env.REVIEW_COMMAND_TIMEOUT_MS, + qdrantUrl: env.QDRANT_URL, + enableMemory: env.ENABLE_MEMORY, + fewShotExamplesCount: env.FEW_SHOT_EXAMPLES_COUNT, + enableReflection: env.ENABLE_REFLECTION, + maxReflectionRounds: env.MAX_REFLECTION_ROUNDS, + enableDebate: env.ENABLE_DEBATE, + debateThreshold: env.DEBATE_THRESHOLD, + }, + }; + } + + /** Return raw overrides record. */ + getOverrides(): Record { + return { ...this.overrides }; + } + + /** + * Merge updates into overrides and persist. + * If a value is empty string `''`, that key is deleted (reset to lower layer). + */ + async setOverrides(updates: Record): Promise { + for (const [key, value] of Object.entries(updates)) { + if (value === '') { + delete this.overrides[key]; + } else { + this.overrides[key] = value; + } + } + await this.persistOverrides(); + } + + /** Remove specified keys from overrides and persist. */ + async resetKeys(keys: string[]): Promise { + for (const key of keys) { + delete this.overrides[key]; + } + await this.persistOverrides(); + } + + /** + * Determine where the effective value for a given env key comes from. + */ + getSource(envKey: string): 'default' | 'env' | 'override' { + const ov = this.overrides[envKey]; + if (ov !== undefined && ov !== '') { + return 'override'; + } + const envVal = process.env[envKey]; + if (envVal !== undefined && envVal !== '') { + return 'env'; + } + return 'default'; + } + +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +export const configManager = new ConfigManager(); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts new file mode 100644 index 0000000..ba71d8a --- /dev/null +++ b/src/config/config-schema.ts @@ -0,0 +1,422 @@ +/** + * 配置字段元数据定义 + * 纯静态元数据,不读取任何环境变量。供后端 API 和前端 GUI 渲染/编辑配置使用。 + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConfigGroup = 'gitea' | 'openai' | 'feishu' | 'app' | 'admin' | 'review' | 'memory'; + +export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum'; + +export interface ConfigFieldMeta { + envKey: string; + group: ConfigGroup; + label: string; + description: string; + type: ConfigFieldType; + sensitive: boolean; + readonly?: boolean; + readonlyWarning?: string; + enumValues?: string[]; + min?: number; + max?: number; + defaultValue?: string | number | boolean; +} + +export interface ConfigGroupMeta { + key: ConfigGroup; + label: string; + description: string; + icon: string; +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +export const CONFIG_GROUPS: ConfigGroupMeta[] = [ + { + key: 'gitea', + label: 'Gitea 连接', + description: 'Gitea 实例地址与访问令牌', + icon: 'link', + }, + { + key: 'openai', + label: 'OpenAI / LLM', + description: 'AI 模型接口与自定义提示词', + icon: 'bot', + }, + { + key: 'feishu', + label: '飞书通知', + description: '飞书 Webhook 通知配置', + icon: 'bell', + }, + { + key: 'app', + label: '应用', + description: '服务端口与 Webhook 安全', + icon: 'settings', + }, + { + key: 'admin', + label: '管理后台', + description: '后台登录密码与 JWT 密钥', + icon: 'shield', + }, + { + key: 'review', + label: '审查引擎', + description: 'Agent 审查模式、并发与沙箱设置', + icon: 'file-check', + }, + { + key: 'memory', + label: '记忆与学习', + description: '向量记忆、反思与辩论系统', + icon: 'brain', + }, +]; + +// --------------------------------------------------------------------------- +// Fields +// --------------------------------------------------------------------------- + +export const CONFIG_FIELDS: ConfigFieldMeta[] = [ + // ── Gitea ─────────────────────────────────────────────────────────────── + { + envKey: 'GITEA_API_URL', + group: 'gitea', + label: 'Gitea API 地址', + description: 'Gitea 实例的 API 根路径', + type: 'url', + sensitive: false, + defaultValue: 'http://localhost:5174/api/v1', + }, + { + envKey: 'GITEA_ACCESS_TOKEN', + group: 'gitea', + label: '访问令牌', + description: '用于代码审查的 Gitea 访问令牌(需要仓库读权限和评论权限)', + type: 'string', + sensitive: true, + defaultValue: 'test_token', + }, + { + envKey: 'GITEA_ADMIN_TOKEN', + group: 'gitea', + label: '管理员令牌', + description: '用于后台管理的 Gitea 管理员令牌(可选,需要仓库读写及 Webhook 管理权限)', + type: 'string', + sensitive: true, + }, + + // ── OpenAI ────────────────────────────────────────────────────────────── + { + envKey: 'OPENAI_BASE_URL', + group: 'openai', + label: 'API 地址', + description: 'OpenAI 兼容 API 的基础 URL', + type: 'url', + sensitive: false, + defaultValue: 'https://api.openai.com/v1', + }, + { + envKey: 'OPENAI_API_KEY', + group: 'openai', + label: 'API 密钥', + description: 'OpenAI API 密钥', + type: 'string', + sensitive: true, + defaultValue: 'test_openai_key', + }, + { + envKey: 'OPENAI_MODEL', + group: 'openai', + label: '模型', + description: '默认使用的 OpenAI 模型名称', + type: 'string', + sensitive: false, + defaultValue: 'gpt-4o-mini', + }, + { + envKey: 'CUSTOM_SUMMARY_PROMPT', + group: 'openai', + label: '自定义总结提示词', + description: '覆盖默认的代码审查总结提示词(留空使用内置提示词)', + type: 'text', + sensitive: false, + }, + { + envKey: 'CUSTOM_LINE_COMMENT_PROMPT', + group: 'openai', + label: '自定义行评论提示词', + description: '覆盖默认的行级评论提示词(留空使用内置提示词)', + type: 'text', + sensitive: false, + }, + + // ── 飞书 ──────────────────────────────────────────────────────────────── + { + envKey: 'FEISHU_WEBHOOK_URL', + group: 'feishu', + label: 'Webhook 地址', + description: '飞书机器人 Webhook URL', + type: 'url', + sensitive: false, + }, + { + envKey: 'FEISHU_WEBHOOK_SECRET', + group: 'feishu', + label: 'Webhook 签名密钥', + description: '飞书 Webhook 签名密钥(可选)', + type: 'string', + sensitive: true, + }, + + // ── 应用 ──────────────────────────────────────────────────────────────── + { + envKey: 'PORT', + group: 'app', + label: '监听端口', + description: '服务监听的 HTTP 端口号,修改需通过 .env 配置并重启服务', + type: 'number', + sensitive: false, + readonly: true, + defaultValue: 5174, + }, + { + envKey: 'WEBHOOK_SECRET', + group: 'app', + label: 'Webhook 密钥', + description: '用于验证 Gitea Webhook 请求来源的 HMAC 密钥,修改需通过 .env 配置并同步更新 Gitea', + type: 'string', + sensitive: true, + readonly: true, + defaultValue: 'test_webhook_secret', + }, + + // ── 管理后台 ──────────────────────────────────────────────────────────── + { + envKey: 'ADMIN_PASSWORD', + group: 'admin', + label: '管理员密码', + description: '后台管理界面的登录密码', + type: 'string', + sensitive: true, + readonlyWarning: '修改后当前登录会话可能失效', + defaultValue: 'password', + }, + { + envKey: 'JWT_SECRET', + group: 'admin', + label: 'JWT 密钥', + description: '用于签发后台登录 Token 的密钥,修改需通过 .env 配置', + type: 'string', + sensitive: true, + readonly: true, + defaultValue: 'a-secure-secret-for-jwt', + }, + + // ── 审查引擎 ──────────────────────────────────────────────────────────── + { + envKey: 'REVIEW_ENGINE', + group: 'review', + label: '审查引擎', + description: '代码审查模式:legacy(传统)或 agent(多代理编排)', + type: 'enum', + sensitive: false, + enumValues: ['legacy', 'agent'], + defaultValue: 'legacy', + }, + { + envKey: 'REVIEW_WORKDIR', + group: 'review', + label: '工作目录', + description: 'Agent 模式下本地仓库 mirror/worktree 的工作目录', + type: 'string', + sensitive: false, + defaultValue: '/tmp/gitea-assistant', + }, + { + envKey: 'REVIEW_MODEL_PLANNER', + group: 'review', + label: '规划模型', + description: 'Agent 模式下规划阶段使用的模型', + type: 'string', + sensitive: false, + defaultValue: 'gpt-4o-mini', + }, + { + envKey: 'REVIEW_MODEL_SPECIALIST', + group: 'review', + label: '专家模型', + description: 'Agent 模式下专家子代理使用的模型', + type: 'string', + sensitive: false, + defaultValue: 'gpt-4o-mini', + }, + { + envKey: 'REVIEW_MODEL_JUDGE', + group: 'review', + label: '评审模型', + description: 'Agent 模式下 Judge 聚合阶段使用的模型', + type: 'string', + sensitive: false, + defaultValue: 'gpt-4o-mini', + }, + { + envKey: 'REVIEW_MAX_PARALLEL_RUNS', + group: 'review', + label: '最大并发数', + description: '单机同时执行的审查任务上限', + type: 'number', + sensitive: false, + min: 1, + max: 8, + defaultValue: 2, + }, + { + envKey: 'REVIEW_MAX_FILES_PER_RUN', + group: 'review', + label: '单次最大文件数', + description: '单次审查最多处理的文件数量', + type: 'number', + sensitive: false, + min: 1, + max: 1000, + defaultValue: 200, + }, + { + envKey: 'REVIEW_MAX_FILE_CONTENT_CHARS', + group: 'review', + label: '单文件最大字符数', + description: '单个文件上下文的最大字符数', + type: 'number', + sensitive: false, + min: 1000, + max: 1000000, + defaultValue: 40000, + }, + { + envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', + group: 'review', + label: '自动发布置信度', + description: '自动发布评论所需的最小置信度(0~1)', + type: 'number', + sensitive: false, + min: 0, + max: 1, + defaultValue: 0.8, + }, + { + envKey: 'REVIEW_ENABLE_HUMAN_GATE', + group: 'review', + label: '人工审批', + description: '是否启用人工审批队列(低置信度评论需人工确认后发布)', + type: 'boolean', + sensitive: false, + defaultValue: true, + }, + { + envKey: 'REVIEW_ALLOWED_COMMANDS', + group: 'review', + label: '允许命令', + description: '本地审查沙箱中允许执行的命令白名单(逗号分隔)', + type: 'string', + sensitive: false, + defaultValue: 'git,rg,cat,sed,wc', + }, + { + envKey: 'REVIEW_COMMAND_TIMEOUT_MS', + group: 'review', + label: '命令超时(ms)', + description: '单条本地命令的执行超时时间(毫秒)', + type: 'number', + sensitive: false, + min: 1000, + max: 300000, + defaultValue: 10000, + }, + + // ── 记忆与学习 ────────────────────────────────────────────────────────── + { + envKey: 'QDRANT_URL', + group: 'memory', + label: 'Qdrant 地址', + description: 'Qdrant 向量数据库的连接 URL', + type: 'url', + sensitive: false, + }, + { + envKey: 'ENABLE_MEMORY', + group: 'memory', + label: '启用记忆', + description: '是否启用向量记忆系统(需配置 Qdrant)', + type: 'boolean', + sensitive: false, + defaultValue: false, + }, + { + envKey: 'FEW_SHOT_EXAMPLES_COUNT', + group: 'memory', + label: 'Few-shot 示例数', + description: '检索的 few-shot 示例数量', + type: 'number', + sensitive: false, + min: 0, + max: 20, + defaultValue: 10, + }, + { + envKey: 'ENABLE_REFLECTION', + group: 'memory', + label: '启用反思', + description: '是否启用审查结果自我反思机制', + type: 'boolean', + sensitive: false, + defaultValue: false, + }, + { + envKey: 'MAX_REFLECTION_ROUNDS', + group: 'memory', + label: '最大反思轮数', + description: '反思迭代的最大轮数', + type: 'number', + sensitive: false, + min: 1, + max: 5, + defaultValue: 2, + }, + { + envKey: 'ENABLE_DEBATE', + group: 'memory', + label: '启用辩论', + description: '是否启用多视角辩论机制', + type: 'boolean', + sensitive: false, + defaultValue: false, + }, + { + envKey: 'DEBATE_THRESHOLD', + group: 'memory', + label: '辩论阈值', + description: '触发辩论的严重程度阈值', + type: 'enum', + sensitive: false, + enumValues: ['high', 'medium'], + defaultValue: 'high', + }, +]; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +export function getFieldsByGroup(group: ConfigGroup): ConfigFieldMeta[] { + return CONFIG_FIELDS.filter((f) => f.group === group); +} diff --git a/src/config/index.ts b/src/config/index.ts index 195e03d..cc07959 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,145 +1,12 @@ -import { config } from 'dotenv'; -import { z } from 'zod'; +import { configManager } from './config-manager'; -// 加载环境变量 -config(); +type AppConfig = import('./config-manager').AppConfig; -// 判断是否为开发环境 -const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; -const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc']; - -// 环境变量验证模式 -const envSchema = z.object({ - // Gitea配置 - GITEA_API_URL: z.string().url().default('http://localhost:5174/api/v1'), - GITEA_ACCESS_TOKEN: z.string().default('test_token'), - GITEA_ADMIN_TOKEN: z.string().optional(), - - // OpenAI配置 - OPENAI_BASE_URL: z.string().url().default('https://api.openai.com/v1'), - OPENAI_API_KEY: z.string().default('test_openai_key'), - OPENAI_MODEL: z.string().default('gpt-4o-mini'), - CUSTOM_SUMMARY_PROMPT: z.string().optional(), - CUSTOM_LINE_COMMENT_PROMPT: z.string().optional(), - - // 飞书配置 - FEISHU_WEBHOOK_URL: z.string().url(), - FEISHU_WEBHOOK_SECRET: z.string().optional(), - - // 应用配置 - PORT: z.string().transform(Number).default('5174'), - WEBHOOK_SECRET: z.string().default('test_webhook_secret'), - - // 管理后台配置 - ADMIN_PASSWORD: z.string().default('password'), - JWT_SECRET: z.string().default('a-secure-secret-for-jwt'), - - // Agent审查配置 - REVIEW_ENGINE: z.enum(['legacy', 'agent']).default('legacy'), - REVIEW_WORKDIR: z.string().default('/tmp/gitea-assistant'), - REVIEW_MODEL_PLANNER: z.string().default('gpt-4o-mini'), - REVIEW_MODEL_SPECIALIST: z.string().default('gpt-4o-mini'), - REVIEW_MODEL_JUDGE: z.string().default('gpt-4o-mini'), - REVIEW_MAX_PARALLEL_RUNS: z.coerce.number().int().min(1).max(8).default(2), - REVIEW_MAX_FILES_PER_RUN: z.coerce.number().int().min(1).max(1000).default(200), - REVIEW_MAX_FILE_CONTENT_CHARS: z.coerce.number().int().min(1000).max(1_000_000).default(40_000), - REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), - REVIEW_ENABLE_HUMAN_GATE: z - .enum(['true', 'false']) - .default('true') - .transform((value) => value === 'true'), - REVIEW_ALLOWED_COMMANDS: z.string().default(defaultAllowedReviewCommands.join(',')), - REVIEW_COMMAND_TIMEOUT_MS: z.coerce.number().int().min(1000).max(300000).default(10000), - - // 向量记忆和学习系统配置 - QDRANT_URL: z.preprocess( - (val) => (typeof val === 'string' && val.trim() === '' ? undefined : val), - z.string().url().optional() - ), - ENABLE_MEMORY: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), - FEW_SHOT_EXAMPLES_COUNT: z.coerce.number().int().min(0).max(20).default(10), - - // Reflection和Debate配置(第三阶段) - ENABLE_REFLECTION: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), - MAX_REFLECTION_ROUNDS: z.coerce.number().int().min(1).max(5).default(2), - ENABLE_DEBATE: z - .enum(['true', 'false']) - .default('false') - .transform((value) => value === 'true'), - DEBATE_THRESHOLD: z.enum(['high', 'medium']).default('high'), +const config = new Proxy({} as AppConfig, { + get(_target, prop) { + return configManager.getCurrent()[prop as keyof AppConfig]; + }, }); -// 处理验证结果 -const envParseResult = envSchema.safeParse(process.env); - -if (!envParseResult.success) { - console.error('❌ 环境变量验证失败:'); - console.error(envParseResult.error.format()); - - if (isDev) { - console.warn('⚠️ 使用开发环境默认值'); - } else { - throw new Error('环境变量配置错误'); - } -} - -// 导出配置 -export default { - gitea: { - apiUrl: envParseResult.success ? envParseResult.data.GITEA_API_URL : 'http://localhost:5174/api/v1', - accessToken: envParseResult.success ? envParseResult.data.GITEA_ACCESS_TOKEN : 'test_token', - }, - openai: { - baseUrl: envParseResult.success ? envParseResult.data.OPENAI_BASE_URL : 'https://api.openai.com/v1', - apiKey: envParseResult.success ? envParseResult.data.OPENAI_API_KEY : 'test_openai_key', - model: envParseResult.success ? envParseResult.data.OPENAI_MODEL : 'gpt-4o-mini', - customSummaryPrompt: envParseResult.success ? envParseResult.data.CUSTOM_SUMMARY_PROMPT : undefined, - customLineCommentPrompt: envParseResult.success ? envParseResult.data.CUSTOM_LINE_COMMENT_PROMPT : undefined, - }, - feishu: { - webhookUrl: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_URL : '', - webhookSecret: envParseResult.success ? envParseResult.data.FEISHU_WEBHOOK_SECRET : '', - }, - app: { - port: envParseResult.success ? envParseResult.data.PORT : 5174, - webhookSecret: envParseResult.success ? envParseResult.data.WEBHOOK_SECRET : 'test_webhook_secret', - }, - admin: { - password: envParseResult.success ? envParseResult.data.ADMIN_PASSWORD : 'password', - jwtSecret: envParseResult.success ? envParseResult.data.JWT_SECRET : 'a-secure-secret-for-jwt', - giteaAdminToken: envParseResult.success ? envParseResult.data.GITEA_ADMIN_TOKEN : undefined, - }, - review: { - engine: envParseResult.success ? envParseResult.data.REVIEW_ENGINE : 'legacy', - workdir: envParseResult.success ? envParseResult.data.REVIEW_WORKDIR : '/tmp/gitea-assistant', - modelPlanner: envParseResult.success ? envParseResult.data.REVIEW_MODEL_PLANNER : 'gpt-4o-mini', - modelSpecialist: envParseResult.success ? envParseResult.data.REVIEW_MODEL_SPECIALIST : 'gpt-4o-mini', - modelJudge: envParseResult.success ? envParseResult.data.REVIEW_MODEL_JUDGE : 'gpt-4o-mini', - maxParallelRuns: envParseResult.success ? envParseResult.data.REVIEW_MAX_PARALLEL_RUNS : 2, - maxFilesPerRun: envParseResult.success ? envParseResult.data.REVIEW_MAX_FILES_PER_RUN : 200, - maxFileContentChars: envParseResult.success ? envParseResult.data.REVIEW_MAX_FILE_CONTENT_CHARS : 40_000, - autoPublishMinConfidence: envParseResult.success - ? envParseResult.data.REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE - : 0.8, - enableHumanGate: envParseResult.success ? envParseResult.data.REVIEW_ENABLE_HUMAN_GATE : true, - allowedCommands: envParseResult.success - ? envParseResult.data.REVIEW_ALLOWED_COMMANDS.split(',') - .map((item) => item.trim()) - .filter(Boolean) - : defaultAllowedReviewCommands, - commandTimeoutMs: envParseResult.success ? envParseResult.data.REVIEW_COMMAND_TIMEOUT_MS : 10000, - qdrantUrl: envParseResult.success ? envParseResult.data.QDRANT_URL : undefined, - enableMemory: envParseResult.success ? envParseResult.data.ENABLE_MEMORY : false, - fewShotExamplesCount: envParseResult.success ? envParseResult.data.FEW_SHOT_EXAMPLES_COUNT : 10, - enableReflection: envParseResult.success ? envParseResult.data.ENABLE_REFLECTION : false, - maxReflectionRounds: envParseResult.success ? envParseResult.data.MAX_REFLECTION_ROUNDS : 2, - enableDebate: envParseResult.success ? envParseResult.data.ENABLE_DEBATE : false, - debateThreshold: envParseResult.success ? envParseResult.data.DEBATE_THRESHOLD : 'high', - }, -}; +export { configManager }; +export default config; diff --git a/typings/bun-test.d.ts b/typings/bun-test.d.ts new file mode 100644 index 0000000..eea2a4a --- /dev/null +++ b/typings/bun-test.d.ts @@ -0,0 +1,10 @@ +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const it: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; + export const beforeAll: any; + export const afterAll: any; +}