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:
parent
dd6cdde8a5
commit
1b0daff98a
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import 'package:args/command_runner.dart';
|
|||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
_configureLogging();
|
||||
|
||||
final runner = _CodeWorkSpawnerCommandRunner();
|
||||
try {
|
||||
await runner.run(arguments);
|
||||
|
|
@ -71,8 +69,17 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
|||
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<void> {
|
|||
await app.runOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
await app.run();
|
||||
} finally {
|
||||
await app.close();
|
||||
|
|
@ -162,7 +168,7 @@ class _InitConfigCommand extends Command<void> {
|
|||
|
||||
void _configureLogging() {
|
||||
AppLogger.configure(
|
||||
minimumLevel: AppLogLevel.info,
|
||||
minimumLevel: AppLogger.resolveMinimumLevel(AppEnvironment.variables),
|
||||
useColors: stdout.hasTerminal,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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({
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: <String, String>{
|
||||
...Platform.environment,
|
||||
...AppEnvironment.variables,
|
||||
...responder.env,
|
||||
'CWS_PROJECT_KEY': project.key,
|
||||
'CWS_REPO': project.repo,
|
||||
|
|
|
|||
|
|
@ -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<String, ProjectConfig> projects;
|
||||
|
||||
static Future<AppConfig> 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)!) ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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));
|
||||
return Process.run(
|
||||
'git',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue