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
This commit is contained in:
jeffusion
2026-03-03 16:31:42 +08:00
parent b4feb0a822
commit d946423d45
5 changed files with 1030 additions and 141 deletions

View File

@@ -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<string, string | undefined> = {};
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('');
});
});
});

View File

@@ -0,0 +1,404 @@
/**
* Three-layer configuration manager.
* Priority: Zod defaults → process.env → JSON overrides
*
* Override file format:
* { version: 1, updatedAt: string, overrides: Record<string, string> }
*
* 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<string, string>;
}
// ---------------------------------------------------------------------------
// 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<string, string> = {};
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<void> {
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<void> {
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<string, string | undefined> = {};
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<string, string> {
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<string, string>): Promise<void> {
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<void> {
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();

422
src/config/config-schema.ts Normal file
View File

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

View File

@@ -1,145 +1,12 @@
import { config } from 'dotenv'; import { configManager } from './config-manager';
import { z } from 'zod';
// 加载环境变量 type AppConfig = import('./config-manager').AppConfig;
config();
// 判断是否为开发环境 const config = new Proxy({} as AppConfig, {
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; get(_target, prop) {
const defaultAllowedReviewCommands = ['git', 'rg', 'cat', 'sed', 'wc']; return configManager.getCurrent()[prop as keyof AppConfig];
},
// 环境变量验证模式
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'),
}); });
// 处理验证结果 export { configManager };
const envParseResult = envSchema.safeParse(process.env); export default config;
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',
},
};

10
typings/bun-test.d.ts vendored Normal file
View File

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