mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
186
src/config/__tests__/config-manager.test.ts
Normal file
186
src/config/__tests__/config-manager.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
404
src/config/config-manager.ts
Normal file
404
src/config/config-manager.ts
Normal 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
422
src/config/config-schema.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
10
typings/bun-test.d.ts
vendored
Normal file
10
typings/bun-test.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user