From 679b78d8eb665bf362d81abfbf8a8c0bd85d065f Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Mon, 2 Mar 2026 16:08:27 +0100 Subject: [PATCH] feat: config.local.yml override for deploy-safe config A gitignored config.local.yml is deep-merged on top of config.yml at startup, so production-specific settings (like ai_review.enabled) survive git reset --hard deploys without modifying the update binary. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + __tests__/unit/config.test.js | 34 ++++++++++++++++++++++++++++++++++ config.yml | 10 ++++++++++ src/config.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/.gitignore b/.gitignore index 98d366d..0cc937a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ yarn-error.log* .env.development .env.test .env.production +config.local.yml # Logs logs diff --git a/__tests__/unit/config.test.js b/__tests__/unit/config.test.js index c03e8af..6b64df9 100644 --- a/__tests__/unit/config.test.js +++ b/__tests__/unit/config.test.js @@ -5,10 +5,44 @@ import { getRequiredSignaturesFlag, getDependabotLabels, getTargetIssueLabels, + deepMerge, _setConfigForTesting, DEFAULT_MERGE_SETTINGS } from '../../src/config.js'; +describe('deepMerge', () => { + it('merges top-level keys from both objects', () => { + const result = deepMerge({ a: 1 }, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('overrides primitive values from source', () => { + const result = deepMerge({ a: 1, b: 'old' }, { b: 'new' }); + expect(result).toEqual({ a: 1, b: 'new' }); + }); + + it('recursively merges nested objects', () => { + const result = deepMerge( + { nested: { a: 1, b: 2 } }, + { nested: { b: 3, c: 4 } } + ); + expect(result).toEqual({ nested: { a: 1, b: 3, c: 4 } }); + }); + + it('replaces arrays entirely from source', () => { + const result = deepMerge( + { items: [1, 2, 3] }, + { items: [4, 5] } + ); + expect(result).toEqual({ items: [4, 5] }); + }); + + it('allows null to override a value', () => { + const result = deepMerge({ a: { b: 1 } }, { a: null }); + expect(result).toEqual({ a: null }); + }); +}); + describe('config', () => { afterEach(() => { // Reset to real config after each test diff --git a/config.yml b/config.yml index a4cfdcc..85398e5 100644 --- a/config.yml +++ b/config.yml @@ -62,6 +62,16 @@ dependabot_generation: default_labels: ["dependencies"] max_directories_per_ecosystem: 5 +ai_review: + enabled: false + endpoint: "http://localhost:11434/v1/chat/completions" + model: "qwen2.5-coder:3b" + max_diff_size: 12000 + max_tokens: 2000 + temperature: 0.3 + timeout: 120000 + allow_remote_endpoint: false + scheduler: interval_minutes: 5 max_tasks_per_tick: 5 diff --git a/src/config.js b/src/config.js index 38966cf..d625bf4 100644 --- a/src/config.js +++ b/src/config.js @@ -27,12 +27,45 @@ export const DEPENDABOT_LABEL_DEFAULTS = { }; const CONFIG_PATH = path.join(__dirname, '..', 'config.yml'); +const LOCAL_CONFIG_PATH = path.join(__dirname, '..', 'config.local.yml'); let config = {}; let TARGET_SETTINGS = { ...DEFAULT_MERGE_SETTINGS }; +/** + * Deep-merge source into target. Objects merge recursively; + * arrays and primitives from source replace target values. + */ +export function deepMerge(target, source) { + const result = { ...target }; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = target[key]; + if ( + srcVal !== null && + typeof srcVal === 'object' && + !Array.isArray(srcVal) && + tgtVal !== null && + typeof tgtVal === 'object' && + !Array.isArray(tgtVal) + ) { + result[key] = deepMerge(tgtVal, srcVal); + } else { + result[key] = srcVal; + } + } + return result; +} + export function loadConfig() { try { config = yaml.load(fs.readFileSync(CONFIG_PATH, 'utf8')) || {}; + + if (fs.existsSync(LOCAL_CONFIG_PATH)) { + const localConfig = yaml.load(fs.readFileSync(LOCAL_CONFIG_PATH, 'utf8')) || {}; + config = deepMerge(config, localConfig); + getLogger().info('Merged local overrides from config.local.yml'); + } + TARGET_SETTINGS = config?.settings?.merge || { ...DEFAULT_MERGE_SETTINGS }; const validation = validateConfig(config); if (!validation.valid) {