diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9aca55c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Optional webhook URL used by the starter Discord notification example. +CWS_DISCORD_WEBHOOK=https://discord.com/api/webhooks/your-webhook-id/your-webhook-token + +# Optional runtime log level. +# Supported values: trace, debug, info, warning, error, fatal +CWS_LOG_LEVEL=info diff --git a/README.md b/README.md index ba50301..5d83334 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ Create a local `agent-orchestrator.yaml` and use [`examples/README.md`](examples/README.md) plus [`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference. +Environment placeholders such as `${CWS_DISCORD_WEBHOOK}` are resolved from the +process environment and from a `.env` file placed next to the selected config +file. Logging also supports `CWS_LOG_LEVEL` (or `LOG_LEVEL`) with values such +as `trace`, `debug`, `info`, `warning`, `error`, or `fatal`. See +[.env.example](.env.example) for a minimal starter file. + Generate a starter config: ```bash diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index 0c5e495..907ea5f 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -5,8 +5,6 @@ import 'package:args/command_runner.dart'; import 'package:code_work_spawner/code_work_spawner.dart'; Future main(List arguments) async { - _configureLogging(); - final runner = _CodeWorkSpawnerCommandRunner(); try { await runner.run(arguments); @@ -71,8 +69,17 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { return; } - final config = await AppConfig.load(topLevelResults['config'] as String); + final configPath = topLevelResults['config'] as String; + AppEnvironment.loadForConfig(configPath); + _configureLogging(); + + final config = await AppConfig.load(configPath); final logger = AppLogger('code_work_spawner.cli'); + logger.debug( + 'startup once=${topLevelResults["once"]} ' + 'config=${topLevelResults["config"]} ' + 'db=${topLevelResults["db"]}', + ); logger.info( 'loaded config=${config.configPath} ' 'projects=${config.projects.length} ' @@ -101,7 +108,6 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { await app.runOnce(); return; } - await app.run(); } finally { await app.close(); @@ -162,7 +168,7 @@ class _InitConfigCommand extends Command { void _configureLogging() { AppLogger.configure( - minimumLevel: AppLogLevel.info, + minimumLevel: AppLogger.resolveMinimumLevel(AppEnvironment.variables), useColors: stdout.hasTerminal, ); } diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index 5bb59a3..cd539b2 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -2,6 +2,7 @@ export 'src/comment_templates.dart'; export 'src/config.dart'; export 'src/database.dart'; export 'src/app_logger.dart'; +export 'src/app_environment.dart'; export 'src/issue_event_source/base.dart'; export 'src/issue_tracker_client.dart'; export 'src/issue_assistant_app.dart'; diff --git a/lib/src/app_environment.dart b/lib/src/app_environment.dart new file mode 100644 index 0000000..058e755 --- /dev/null +++ b/lib/src/app_environment.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:dotenv/dotenv.dart' as dotenv; +import 'package:path/path.dart' as p; + +class AppEnvironment { + static Map _variables = Map.unmodifiable( + Platform.environment, + ); + + static Map get variables => _variables; + + static String? get(String key) => _variables[key]; + + static void loadForConfig(String configPath) { + final loader = dotenv.DotEnv(includePlatformEnvironment: true, quiet: true); + loader.load([p.join(p.dirname(p.absolute(configPath)), '.env')]); + // ignore: invalid_use_of_visible_for_testing_member + _variables = Map.unmodifiable(loader.map); + } + + static void resetForTest([Map? variables]) { + _variables = Map.unmodifiable(variables ?? Platform.environment); + } +} diff --git a/lib/src/app_logger.dart b/lib/src/app_logger.dart index c78f59b..6f131ac 100644 --- a/lib/src/app_logger.dart +++ b/lib/src/app_logger.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:logger/web.dart' as logger; +import 'package:logger/logger.dart' as logger; typedef AppLogListener = void Function(AppLogRecord record); @@ -36,6 +36,36 @@ class AppLogger { static AppLogLevel get minimumLevel => _minimumLevel; + static AppLogLevel resolveMinimumLevel( + Map environment, { + AppLogLevel fallback = AppLogLevel.info, + }) { + final configuredLevel = + environment['CWS_LOG_LEVEL'] ?? environment['LOG_LEVEL']; + return configuredLevel == null + ? fallback + : parseLevel(configuredLevel) ?? fallback; + } + + static AppLogLevel? parseLevel(String value) { + switch (value.trim().toLowerCase()) { + case 'trace': + return AppLogLevel.trace; + case 'debug': + return AppLogLevel.debug; + case 'info': + return AppLogLevel.info; + case 'warn': + case 'warning': + return AppLogLevel.warning; + case 'error': + return AppLogLevel.error; + case 'fatal': + return AppLogLevel.fatal; + } + return null; + } + static void configure({ AppLogLevel minimumLevel = AppLogLevel.info, bool useColors = true, @@ -111,6 +141,7 @@ class AppLogger { static logger.Logger _createBackend() { return logger.Logger( level: _mapLevel(_minimumLevel), + filter: logger.ProductionFilter(), printer: _AppLogPrinter(useColors: _useColors), ); } diff --git a/lib/src/cli_responder.dart b/lib/src/cli_responder.dart index 1554f4c..128fdf6 100644 --- a/lib/src/cli_responder.dart +++ b/lib/src/cli_responder.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'app_environment.dart'; import 'config.dart'; import 'issue_tracker_client.dart'; @@ -31,7 +32,7 @@ class CliResponderRunner { responder.args, workingDirectory: workspacePath, environment: { - ...Platform.environment, + ...AppEnvironment.variables, ...responder.env, 'CWS_PROJECT_KEY': project.key, 'CWS_REPO': project.repo, diff --git a/lib/src/config.dart b/lib/src/config.dart index 9fb2eac..8f08b61 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -5,6 +5,7 @@ import 'package:github/github.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; +import 'app_environment.dart'; import 'comment_templates.dart'; import 'config_schema.dart'; export 'config_schema.dart' @@ -71,6 +72,7 @@ class AppConfig { final Map projects; static Future load(String path) async { + AppEnvironment.loadForConfig(path); final file = File(path); final contents = await file.readAsString(); final yaml = loadYaml(contents); @@ -645,7 +647,7 @@ String _expandHome(String path) { return p.normalize(p.absolute(path)); } - final home = Platform.environment['HOME']; + final home = AppEnvironment.get('HOME'); if (home == null || home.isEmpty) { throw const FileSystemException('HOME is not set.'); } @@ -666,7 +668,7 @@ String? _expandOptionalEnvironmentString(String? value) { return value.replaceAllMapped(RegExp(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'), ( match, ) { - return Platform.environment[match.group(1)!] ?? ''; + return AppEnvironment.get(match.group(1)!) ?? ''; }); } diff --git a/lib/src/workspace_manager.dart b/lib/src/workspace_manager.dart index 2fb5fef..a100066 100644 --- a/lib/src/workspace_manager.dart +++ b/lib/src/workspace_manager.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; +import 'app_environment.dart'; + class WorkspaceManager { WorkspaceManager({this.worktreeRoot}); @@ -86,7 +88,7 @@ class WorkspaceManager { } Future _runGit(List arguments) { - final environment = Map.from(Platform.environment) + final environment = Map.from(AppEnvironment.variables) ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); return Process.run( 'git', diff --git a/test/app_config_test.dart b/test/app_config_test.dart index 34f4171..107bfea 100644 --- a/test/app_config_test.dart +++ b/test/app_config_test.dart @@ -115,6 +115,53 @@ projects: expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3); }); + /// ```gherkin + /// Scenario: Resolve config placeholders from a colocated dotenv file + /// Given an orchestrator config with environment placeholders and a colocated dotenv file + /// When loading the app config from disk + /// Then placeholder values are resolved from the dotenv file + /// And later runtime lookups use the same loaded environment values + /// ``` + test('Resolve config placeholders from a colocated dotenv file', () async { + // Given an orchestrator config with environment placeholders and a colocated dotenv file. + final tempDir = await Directory.systemTemp.createTemp('cws-dotenv-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + final dotenvFile = File(p.join(tempDir.path, '.env')); + addTearDown(AppEnvironment.resetForTest); + await dotenvFile.writeAsString(''' +CWS_SAMPLE_WEBHOOK=https://dotenv.example.test/webhook +CWS_LOG_LEVEL=debug +'''); + await configFile.writeAsString(''' +defaults: + issueAssistant: + notifications: + - type: discordWebhook + webhookUrl: \${CWS_SAMPLE_WEBHOOK} +projects: + sample: + repo: owner/sample + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then placeholder values are resolved from the dotenv file. + expect( + config + .projects['sample']! + .issueAssistant + .notifications + .single + .webhookUrl, + 'https://dotenv.example.test/webhook', + ); + + // And later runtime lookups use the same loaded environment values. + expect(AppEnvironment.get('CWS_LOG_LEVEL'), 'debug'); + }); + /// ```gherkin /// Scenario: Override visible comment templates per project /// Given camelCase config with shared defaults and project-specific comment templates diff --git a/test/app_logger_test.dart b/test/app_logger_test.dart new file mode 100644 index 0000000..8b5b082 --- /dev/null +++ b/test/app_logger_test.dart @@ -0,0 +1,53 @@ +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: App logger environment configuration + /// + /// As an operator running code_work_spawner + /// I want logger verbosity to be configurable through environment variables + /// So that I can increase or reduce log output without editing code + /// ``` + group('AppLogger.resolveMinimumLevel', () { + /// ```gherkin + /// Scenario: Prefer the dedicated Code Work Spawner log level variable + /// Given both a generic log level and a Code Work Spawner log level are set + /// When resolving the app logger minimum level from the environment + /// Then the dedicated Code Work Spawner value takes precedence + /// ``` + test('Prefer the dedicated Code Work Spawner log level variable', () { + // Given both a generic log level and a Code Work Spawner log level are set. + const environment = { + 'LOG_LEVEL': 'error', + 'CWS_LOG_LEVEL': 'debug', + }; + + // When resolving the app logger minimum level from the environment. + final level = AppLogger.resolveMinimumLevel(environment); + + // Then the dedicated Code Work Spawner value takes precedence. + expect(level, AppLogLevel.debug); + }); + + /// ```gherkin + /// Scenario: Fall back when the configured log level is invalid + /// Given a Code Work Spawner log level that is not recognized + /// When resolving the app logger minimum level from the environment + /// Then the logger falls back to the provided default level + /// ``` + test('Fall back when the configured log level is invalid', () { + // Given a Code Work Spawner log level that is not recognized. + const environment = {'CWS_LOG_LEVEL': 'verbose'}; + + // When resolving the app logger minimum level from the environment. + final level = AppLogger.resolveMinimumLevel( + environment, + fallback: AppLogLevel.warning, + ); + + // Then the logger falls back to the provided default level. + expect(level, AppLogLevel.warning); + }); + }); +}