feat: load dotenv runtime config for #27

Load .env files beside the selected config so env interpolation, subprocesses, and logger verbosity work without exporting every variable manually.
This commit is contained in:
insleker 2026-04-09 00:55:30 +08:00
parent dd6cdde8a5
commit 1b0daff98a
11 changed files with 190 additions and 10 deletions

6
.env.example Normal file
View File

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

View File

@ -28,6 +28,12 @@ Create a local `agent-orchestrator.yaml` and use
[`examples/README.md`](examples/README.md) plus [`examples/README.md`](examples/README.md) plus
[`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference. [`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: Generate a starter config:
```bash ```bash

View File

@ -5,8 +5,6 @@ import 'package:args/command_runner.dart';
import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:code_work_spawner/code_work_spawner.dart';
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
_configureLogging();
final runner = _CodeWorkSpawnerCommandRunner(); final runner = _CodeWorkSpawnerCommandRunner();
try { try {
await runner.run(arguments); await runner.run(arguments);
@ -71,8 +69,17 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
return; 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'); final logger = AppLogger('code_work_spawner.cli');
logger.debug(
'startup once=${topLevelResults["once"]} '
'config=${topLevelResults["config"]} '
'db=${topLevelResults["db"]}',
);
logger.info( logger.info(
'loaded config=${config.configPath} ' 'loaded config=${config.configPath} '
'projects=${config.projects.length} ' 'projects=${config.projects.length} '
@ -101,7 +108,6 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
await app.runOnce(); await app.runOnce();
return; return;
} }
await app.run(); await app.run();
} finally { } finally {
await app.close(); await app.close();
@ -162,7 +168,7 @@ class _InitConfigCommand extends Command<void> {
void _configureLogging() { void _configureLogging() {
AppLogger.configure( AppLogger.configure(
minimumLevel: AppLogLevel.info, minimumLevel: AppLogger.resolveMinimumLevel(AppEnvironment.variables),
useColors: stdout.hasTerminal, useColors: stdout.hasTerminal,
); );
} }

View File

@ -2,6 +2,7 @@ export 'src/comment_templates.dart';
export 'src/config.dart'; export 'src/config.dart';
export 'src/database.dart'; export 'src/database.dart';
export 'src/app_logger.dart'; export 'src/app_logger.dart';
export 'src/app_environment.dart';
export 'src/issue_event_source/base.dart'; export 'src/issue_event_source/base.dart';
export 'src/issue_tracker_client.dart'; export 'src/issue_tracker_client.dart';
export 'src/issue_assistant_app.dart'; export 'src/issue_assistant_app.dart';

View File

@ -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<String, String> _variables = Map.unmodifiable(
Platform.environment,
);
static Map<String, String> 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<String, String>? variables]) {
_variables = Map.unmodifiable(variables ?? Platform.environment);
}
}

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:logger/web.dart' as logger; import 'package:logger/logger.dart' as logger;
typedef AppLogListener = void Function(AppLogRecord record); typedef AppLogListener = void Function(AppLogRecord record);
@ -36,6 +36,36 @@ class AppLogger {
static AppLogLevel get minimumLevel => _minimumLevel; static AppLogLevel get minimumLevel => _minimumLevel;
static AppLogLevel resolveMinimumLevel(
Map<String, String> 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({ static void configure({
AppLogLevel minimumLevel = AppLogLevel.info, AppLogLevel minimumLevel = AppLogLevel.info,
bool useColors = true, bool useColors = true,
@ -111,6 +141,7 @@ class AppLogger {
static logger.Logger _createBackend() { static logger.Logger _createBackend() {
return logger.Logger( return logger.Logger(
level: _mapLevel(_minimumLevel), level: _mapLevel(_minimumLevel),
filter: logger.ProductionFilter(),
printer: _AppLogPrinter(useColors: _useColors), printer: _AppLogPrinter(useColors: _useColors),
); );
} }

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'app_environment.dart';
import 'config.dart'; import 'config.dart';
import 'issue_tracker_client.dart'; import 'issue_tracker_client.dart';
@ -31,7 +32,7 @@ class CliResponderRunner {
responder.args, responder.args,
workingDirectory: workspacePath, workingDirectory: workspacePath,
environment: <String, String>{ environment: <String, String>{
...Platform.environment, ...AppEnvironment.variables,
...responder.env, ...responder.env,
'CWS_PROJECT_KEY': project.key, 'CWS_PROJECT_KEY': project.key,
'CWS_REPO': project.repo, 'CWS_REPO': project.repo,

View File

@ -5,6 +5,7 @@ import 'package:github/github.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
import 'app_environment.dart';
import 'comment_templates.dart'; import 'comment_templates.dart';
import 'config_schema.dart'; import 'config_schema.dart';
export 'config_schema.dart' export 'config_schema.dart'
@ -71,6 +72,7 @@ class AppConfig {
final Map<String, ProjectConfig> projects; final Map<String, ProjectConfig> projects;
static Future<AppConfig> load(String path) async { static Future<AppConfig> load(String path) async {
AppEnvironment.loadForConfig(path);
final file = File(path); final file = File(path);
final contents = await file.readAsString(); final contents = await file.readAsString();
final yaml = loadYaml(contents); final yaml = loadYaml(contents);
@ -645,7 +647,7 @@ String _expandHome(String path) {
return p.normalize(p.absolute(path)); return p.normalize(p.absolute(path));
} }
final home = Platform.environment['HOME']; final home = AppEnvironment.get('HOME');
if (home == null || home.isEmpty) { if (home == null || home.isEmpty) {
throw const FileSystemException('HOME is not set.'); 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_]*)\}'), ( return value.replaceAllMapped(RegExp(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}'), (
match, match,
) { ) {
return Platform.environment[match.group(1)!] ?? ''; return AppEnvironment.get(match.group(1)!) ?? '';
}); });
} }

View File

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:git/git.dart'; import 'package:git/git.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'app_environment.dart';
class WorkspaceManager { class WorkspaceManager {
WorkspaceManager({this.worktreeRoot}); WorkspaceManager({this.worktreeRoot});
@ -86,7 +88,7 @@ class WorkspaceManager {
} }
Future<ProcessResult> _runGit(List<String> arguments) { Future<ProcessResult> _runGit(List<String> arguments) {
final environment = Map<String, String>.from(Platform.environment) final environment = Map<String, String>.from(AppEnvironment.variables)
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key)); ..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
return Process.run( return Process.run(
'git', 'git',

View File

@ -115,6 +115,53 @@ projects:
expect(config.projects['sample']!.issueAssistant.maxConcurrentIssues, 3); 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 /// ```gherkin
/// Scenario: Override visible comment templates per project /// Scenario: Override visible comment templates per project
/// Given camelCase config with shared defaults and project-specific comment templates /// Given camelCase config with shared defaults and project-specific comment templates

53
test/app_logger_test.dart Normal file
View File

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