feat: add `init-config` option to gen initial config file
refactor: use `json_serializable` to achieve config file deserialization
This commit is contained in:
parent
5d3b4ff2c1
commit
8fe0d1017f
14
README.md
14
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# code_work_spawner
|
||||
|
||||
GitHub issue thread assistant with Agent Orchestrator style config.
|
||||
GitHub issue thread assistant with an Agent Orchestrator style YAML config.
|
||||
|
||||
The app polls configured GitHub repositories with `gh`, reads issue threads and
|
||||
issue comments, and uses configured CLI responders such as `codex`,
|
||||
|
|
@ -32,7 +32,17 @@ It supports multi-repo configuration, responder fallback chains.
|
|||
|
||||
## Config
|
||||
|
||||
Create a local `agent-orchestrator.yaml` follow [`/examples/README.md`](examples/README.md) as a starting point.
|
||||
Create a local `agent-orchestrator.yaml` and follow [`/examples/README.md`](examples/README.md) as a starting point.
|
||||
|
||||
Generate a starter config:
|
||||
|
||||
```bash
|
||||
dart run bin/code_work_spawner.dart init-config
|
||||
# Write it to a file:
|
||||
dart run bin/code_work_spawner.dart init-config --output agent-orchestrator.yaml
|
||||
# Overwrite an existing file:
|
||||
dart run bin/code_work_spawner.dart init-config --output agent-orchestrator.yaml --force
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,31 @@ Future<void> main(List<String> arguments) async {
|
|||
defaultsTo: 'gh',
|
||||
)
|
||||
..addFlag('help', abbr: 'h', negatable: false);
|
||||
final initConfigParser = parser.addCommand('init-config')
|
||||
..addOption(
|
||||
'output',
|
||||
abbr: 'o',
|
||||
help: 'Write the starter config to a file.',
|
||||
)
|
||||
..addFlag(
|
||||
'force',
|
||||
abbr: 'f',
|
||||
help: 'Overwrite the output file if it already exists.',
|
||||
negatable: false,
|
||||
)
|
||||
..addFlag('help', abbr: 'h', negatable: false);
|
||||
|
||||
final results = parser.parse(arguments);
|
||||
final command = results.command;
|
||||
if (command?.name == 'init-config') {
|
||||
if (command!['help'] as bool) {
|
||||
stdout.writeln(initConfigParser.usage);
|
||||
return;
|
||||
}
|
||||
await _runInitConfig(command);
|
||||
return;
|
||||
}
|
||||
|
||||
if (results['help'] as bool) {
|
||||
stdout.writeln(parser.usage);
|
||||
return;
|
||||
|
|
@ -73,6 +96,30 @@ Future<void> main(List<String> arguments) async {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _runInitConfig(ArgResults command) async {
|
||||
final outputPath = command['output'] as String?;
|
||||
final force = command['force'] as bool;
|
||||
final content = AppConfig.renderInitialConfig();
|
||||
|
||||
if (outputPath == null) {
|
||||
stdout.writeln(content);
|
||||
return;
|
||||
}
|
||||
|
||||
final outputFile = File(outputPath);
|
||||
if (await outputFile.exists() && !force) {
|
||||
stderr.writeln(
|
||||
'Refusing to overwrite existing file: $outputPath. Use --force to replace it.',
|
||||
);
|
||||
exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
await outputFile.parent.create(recursive: true);
|
||||
await outputFile.writeAsString('$content\n');
|
||||
stdout.writeln('Wrote starter config to ${outputFile.path}');
|
||||
}
|
||||
|
||||
void _configureLogging() {
|
||||
hierarchicalLoggingEnabled = true;
|
||||
Logger.root.level = Level.INFO;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
targets:
|
||||
$default:
|
||||
# sources:
|
||||
builders:
|
||||
drift_dev:
|
||||
generate_for:
|
||||
- lib/src/database.dart
|
||||
# json_serializable:
|
||||
# generate_for:
|
||||
# - lib/**/model/*.dart
|
||||
json_serializable:
|
||||
generate_for:
|
||||
- lib/src/config_schema.dart
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Config Examples
|
||||
|
||||
The repo try to [follow Agent Orchestrator style config](https://github.com/ComposioHQ/agent-orchestrator/tree/main/examples).
|
||||
The repo uses an Agent Orchestrator style YAML config with `defaults` and `projects`.
|
||||
|
||||
Project entries can override `issueAssistant` fields when a repo needs a
|
||||
different prompt, mention triggers, or responder chain.
|
||||
|
|
@ -12,7 +12,6 @@ account. Those markers support template variables such as `{{gh_login}}`,
|
|||
`{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty
|
||||
string to disable that section.
|
||||
|
||||
For a more minimal Agent Orchestrator compatible subset, start from
|
||||
[`examples/simple-github.yaml`](examples/simple-github.yaml). This repo accepts
|
||||
AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
|
||||
`worktreeDir` for bug-verification worktrees.
|
||||
Start from [`examples/simple-github.yaml`](examples/simple-github.yaml). This
|
||||
repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
||||
bug-verification worktrees.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
# Minimal setup for a single GitHub repo with GitHub Issues
|
||||
# AO-compatible subset supported by code_work_spawner.
|
||||
# Minimal setup for a single GitHub repo with GitHub Issues.
|
||||
# Replies require at least one configured responder.
|
||||
|
||||
dataDir: ~/.agent-orchestrator
|
||||
|
|
|
|||
|
|
@ -6,373 +6,10 @@ import 'package:path/path.dart' as p;
|
|||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import 'comment_templates.dart';
|
||||
import 'config_schema.dart';
|
||||
export 'config_schema.dart' show AssistantCapability;
|
||||
|
||||
class AppConfig {
|
||||
AppConfig({
|
||||
required this.configPath,
|
||||
required this.dataDir,
|
||||
required this.worktreeDir,
|
||||
required this.defaults,
|
||||
required this.projects,
|
||||
});
|
||||
|
||||
final String configPath;
|
||||
final String? dataDir;
|
||||
final String? worktreeDir;
|
||||
final IssueAssistantConfig defaults;
|
||||
final Map<String, ProjectConfig> projects;
|
||||
|
||||
static Future<AppConfig> load(String path) async {
|
||||
final file = File(path);
|
||||
final contents = await file.readAsString();
|
||||
final yaml = loadYaml(contents);
|
||||
if (yaml is! YamlMap) {
|
||||
throw const FormatException('Config root must be a YAML map.');
|
||||
}
|
||||
|
||||
final root = _asMap(yaml);
|
||||
final dialect = _detectDialect(root);
|
||||
return switch (dialect) {
|
||||
_ConfigDialect.current => _loadCurrentConfig(path, root),
|
||||
_ConfigDialect.aoSubset => _loadAoSubsetConfig(path, root),
|
||||
};
|
||||
}
|
||||
|
||||
static AppConfig _loadCurrentConfig(String path, Map<String, dynamic> root) {
|
||||
_validateKeys(
|
||||
scope: 'config',
|
||||
map: root,
|
||||
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
|
||||
);
|
||||
final defaultsMap = _asMap(root['defaults']);
|
||||
final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']);
|
||||
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
|
||||
final projects = _loadProjects(
|
||||
root['projects'],
|
||||
defaultAssistant: defaults,
|
||||
defaultAssistantMap: defaultAssistantMap,
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
configPath: p.normalize(p.absolute(path)),
|
||||
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
|
||||
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
|
||||
defaults: defaults,
|
||||
projects: projects,
|
||||
);
|
||||
}
|
||||
|
||||
static AppConfig _loadAoSubsetConfig(String path, Map<String, dynamic> root) {
|
||||
_validateKeys(
|
||||
scope: 'config',
|
||||
map: root,
|
||||
allowed: {'defaults', 'projects', 'dataDir', 'worktreeDir'},
|
||||
);
|
||||
|
||||
final defaultsMap = _asMap(root['defaults']);
|
||||
_validateKeys(
|
||||
scope: 'defaults',
|
||||
map: defaultsMap,
|
||||
allowed: {
|
||||
'defaultBranch',
|
||||
'default_branch',
|
||||
'sessionPrefix',
|
||||
'session_prefix',
|
||||
},
|
||||
);
|
||||
|
||||
final defaultAssistantMap = <String, dynamic>{};
|
||||
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
|
||||
final projects = _loadAoSubsetProjects(
|
||||
root['projects'],
|
||||
defaultsMap: defaultsMap,
|
||||
defaultAssistant: defaults,
|
||||
defaultAssistantMap: defaultAssistantMap,
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
configPath: p.normalize(p.absolute(path)),
|
||||
dataDir: _expandOptionalPath(root['dataDir']?.toString()),
|
||||
worktreeDir: _expandOptionalPath(root['worktreeDir']?.toString()),
|
||||
defaults: defaults,
|
||||
projects: projects,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, ProjectConfig> _loadProjects(
|
||||
Object? projectsNode, {
|
||||
required IssueAssistantConfig defaultAssistant,
|
||||
required Map<String, dynamic> defaultAssistantMap,
|
||||
}) {
|
||||
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
|
||||
throw const FormatException('projects must be a non-empty map.');
|
||||
}
|
||||
|
||||
final projects = <String, ProjectConfig>{};
|
||||
for (final entry in projectsNode.entries) {
|
||||
final key = entry.key.toString();
|
||||
final value = _asMap(entry.value);
|
||||
projects[key] = ProjectConfig.fromMap(
|
||||
key: key,
|
||||
map: value,
|
||||
defaultAssistant: defaultAssistant,
|
||||
defaultAssistantMap: defaultAssistantMap,
|
||||
);
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
static Map<String, ProjectConfig> _loadAoSubsetProjects(
|
||||
Object? projectsNode, {
|
||||
required Map<String, dynamic> defaultsMap,
|
||||
required IssueAssistantConfig defaultAssistant,
|
||||
required Map<String, dynamic> defaultAssistantMap,
|
||||
}) {
|
||||
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
|
||||
throw const FormatException('projects must be a non-empty map.');
|
||||
}
|
||||
|
||||
final projects = <String, ProjectConfig>{};
|
||||
for (final entry in projectsNode.entries) {
|
||||
final key = entry.key.toString();
|
||||
final value = _asMap(entry.value);
|
||||
_validateKeys(
|
||||
scope: 'projects.$key',
|
||||
map: value,
|
||||
allowed: {
|
||||
'repo',
|
||||
'path',
|
||||
'defaultBranch',
|
||||
'default_branch',
|
||||
'sessionPrefix',
|
||||
'session_prefix',
|
||||
},
|
||||
);
|
||||
final merged = <String, dynamic>{...defaultsMap, ...value};
|
||||
projects[key] = ProjectConfig.fromMap(
|
||||
key: key,
|
||||
map: merged,
|
||||
defaultAssistant: defaultAssistant,
|
||||
defaultAssistantMap: defaultAssistantMap,
|
||||
);
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
static _ConfigDialect _detectDialect(Map<String, dynamic> root) {
|
||||
final defaultsMap = _asMap(root['defaults']);
|
||||
if (defaultsMap.containsKey('issueAssistant')) {
|
||||
return _ConfigDialect.current;
|
||||
}
|
||||
return _ConfigDialect.aoSubset;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _asMap(Object? value) {
|
||||
if (value == null) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
(key, dynamicValue) => MapEntry(key.toString(), dynamicValue),
|
||||
);
|
||||
}
|
||||
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw FormatException('Expected map, got ${value.runtimeType}.');
|
||||
}
|
||||
|
||||
static void _validateKeys({
|
||||
required String scope,
|
||||
required Map<String, dynamic> map,
|
||||
required Set<String> allowed,
|
||||
}) {
|
||||
for (final key in map.keys) {
|
||||
if (!allowed.contains(key)) {
|
||||
throw FormatException('Unsupported config field: $scope.$key');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectConfig {
|
||||
ProjectConfig({
|
||||
required this.key,
|
||||
required this.repo,
|
||||
required this.repoSlug,
|
||||
required this.path,
|
||||
required this.defaultBranch,
|
||||
required this.sessionPrefix,
|
||||
required this.issueAssistant,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String repo;
|
||||
final RepositorySlug repoSlug;
|
||||
final String path;
|
||||
final String defaultBranch;
|
||||
final String sessionPrefix;
|
||||
final IssueAssistantConfig issueAssistant;
|
||||
|
||||
factory ProjectConfig.fromMap({
|
||||
required String key,
|
||||
required Map<String, dynamic> map,
|
||||
required IssueAssistantConfig defaultAssistant,
|
||||
required Map<String, dynamic> defaultAssistantMap,
|
||||
}) {
|
||||
final assistantMap = AppConfig._asMap(map['issueAssistant']);
|
||||
final mergedAssistantMap = <String, dynamic>{
|
||||
...defaultAssistantMap,
|
||||
...assistantMap,
|
||||
};
|
||||
final repo = map['repo']?.toString() ?? _missing('projects.$key.repo');
|
||||
return ProjectConfig(
|
||||
key: key,
|
||||
repo: repo,
|
||||
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||
path: _expandHome(
|
||||
map['path']?.toString() ?? _missing('projects.$key.path'),
|
||||
),
|
||||
defaultBranch:
|
||||
map['defaultBranch']?.toString() ??
|
||||
map['default_branch']?.toString() ??
|
||||
'main',
|
||||
sessionPrefix:
|
||||
map['sessionPrefix']?.toString() ??
|
||||
map['session_prefix']?.toString() ??
|
||||
_deriveSessionPrefix(key),
|
||||
issueAssistant: assistantMap.isEmpty
|
||||
? defaultAssistant
|
||||
: IssueAssistantConfig.fromMap(mergedAssistantMap),
|
||||
);
|
||||
}
|
||||
|
||||
static String _missing(String field) {
|
||||
throw FormatException('Missing required config field: $field');
|
||||
}
|
||||
|
||||
static RepositorySlug _parseRepositorySlug(
|
||||
String value, {
|
||||
required String field,
|
||||
}) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw FormatException('Missing required config field: $field');
|
||||
}
|
||||
|
||||
final parts = trimmed.split('/');
|
||||
if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) {
|
||||
throw FormatException(
|
||||
'Invalid repository slug for $field: "$value". Expected owner/repo.',
|
||||
);
|
||||
}
|
||||
return RepositorySlug(parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
class IssueAssistantConfig {
|
||||
IssueAssistantConfig({
|
||||
required this.enabled,
|
||||
required this.pollInterval,
|
||||
required this.maxConcurrentIssues,
|
||||
required this.mentionTriggers,
|
||||
required this.prompt,
|
||||
required this.responders,
|
||||
required this.capabilities,
|
||||
required this.commentTag,
|
||||
required this.commentPrefixTemplate,
|
||||
required this.commentFooterTemplate,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final Duration pollInterval;
|
||||
final int maxConcurrentIssues;
|
||||
final List<String> mentionTriggers;
|
||||
final String prompt;
|
||||
final List<CliResponderConfig> responders;
|
||||
final Set<AssistantCapability> capabilities;
|
||||
final String commentTag;
|
||||
final String commentPrefixTemplate;
|
||||
final String commentFooterTemplate;
|
||||
|
||||
factory IssueAssistantConfig.fromMap(Map<String, dynamic> map) {
|
||||
final respondersNode = map['responders'];
|
||||
final responders = respondersNode is List
|
||||
? respondersNode
|
||||
.map((item) => CliResponderConfig.fromMap(AppConfig._asMap(item)))
|
||||
.toList(growable: false)
|
||||
: const <CliResponderConfig>[];
|
||||
|
||||
final capabilitiesNode = map['capabilities'];
|
||||
final capabilities = capabilitiesNode is List
|
||||
? capabilitiesNode
|
||||
.map((item) => AssistantCapability.parse(item.toString()))
|
||||
.toSet()
|
||||
: {AssistantCapability.planning, AssistantCapability.bugVerification};
|
||||
final maxConcurrentIssues = _parsePositiveInt(
|
||||
map['maxConcurrentIssues'],
|
||||
fieldName: 'maxConcurrentIssues',
|
||||
defaultValue: 3,
|
||||
);
|
||||
|
||||
return IssueAssistantConfig(
|
||||
enabled: map['enabled'] as bool? ?? true,
|
||||
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '30s'),
|
||||
maxConcurrentIssues: maxConcurrentIssues,
|
||||
mentionTriggers:
|
||||
(map['mentionTriggers'] as List?)
|
||||
?.map((item) => item.toString())
|
||||
.toList(growable: false) ??
|
||||
const <String>[],
|
||||
prompt: map['prompt']?.toString() ?? _defaultPrompt,
|
||||
responders: responders,
|
||||
capabilities: capabilities,
|
||||
commentTag: map['commentTag']?.toString() ?? 'code-work-spawner',
|
||||
commentPrefixTemplate:
|
||||
map['commentPrefixTemplate']?.toString() ??
|
||||
defaultCommentPrefixTemplate,
|
||||
commentFooterTemplate:
|
||||
map['commentFooterTemplate']?.toString() ??
|
||||
defaultCommentFooterTemplate,
|
||||
);
|
||||
}
|
||||
static Duration _parseDuration(String input) {
|
||||
final match = RegExp(r'^(\d+)([smhd])$').firstMatch(input.trim());
|
||||
if (match == null) {
|
||||
throw FormatException('Invalid duration: $input');
|
||||
}
|
||||
|
||||
final value = int.parse(match.group(1)!);
|
||||
return switch (match.group(2)!) {
|
||||
's' => Duration(seconds: value),
|
||||
'm' => Duration(minutes: value),
|
||||
'h' => Duration(hours: value),
|
||||
'd' => Duration(days: value),
|
||||
_ => throw FormatException('Invalid duration unit: $input'),
|
||||
};
|
||||
}
|
||||
|
||||
static int _parsePositiveInt(
|
||||
Object? value, {
|
||||
required String fieldName,
|
||||
required int defaultValue,
|
||||
}) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed < 1) {
|
||||
throw FormatException(
|
||||
'$fieldName must be an integer greater than or equal to 1.',
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
static const String _defaultPrompt = '''
|
||||
const String defaultIssueAssistantPrompt = '''
|
||||
You are a GitHub issue thread assistant.
|
||||
|
||||
Decide whether to reply. Rules:
|
||||
|
|
@ -408,6 +45,203 @@ Issue body:
|
|||
Current issue thread JSON:
|
||||
{{thread_json}}
|
||||
''';
|
||||
|
||||
class AppConfig {
|
||||
AppConfig({
|
||||
required this.configPath,
|
||||
required this.dataDir,
|
||||
required this.worktreeDir,
|
||||
required this.defaults,
|
||||
required this.projects,
|
||||
});
|
||||
|
||||
final String configPath;
|
||||
final String? dataDir;
|
||||
final String? worktreeDir;
|
||||
final IssueAssistantConfig defaults;
|
||||
final Map<String, ProjectConfig> projects;
|
||||
|
||||
static Future<AppConfig> load(String path) async {
|
||||
final file = File(path);
|
||||
final contents = await file.readAsString();
|
||||
final yaml = loadYaml(contents);
|
||||
if (yaml is! YamlMap) {
|
||||
throw const FormatException('Config root must be a YAML map.');
|
||||
}
|
||||
|
||||
final root = _normalizeYamlNode(yaml);
|
||||
if (root is! Map<String, dynamic>) {
|
||||
throw const FormatException('Config root must be a YAML map.');
|
||||
}
|
||||
|
||||
_assertCamelCaseConfigKeys(root);
|
||||
final document = _parseDocument(root);
|
||||
return _fromDocument(path, document);
|
||||
}
|
||||
|
||||
static String renderInitialConfig() {
|
||||
return _InitialConfigRenderer().render(_initialTemplateDocument());
|
||||
}
|
||||
|
||||
static AppConfig _fromDocument(String path, AppConfigDocument document) {
|
||||
final defaults = IssueAssistantConfig.fromDocument(
|
||||
document.defaults?.issueAssistant,
|
||||
);
|
||||
final projectDocuments = document.projects;
|
||||
if (projectDocuments == null || projectDocuments.isEmpty) {
|
||||
throw const FormatException('projects must be a non-empty map.');
|
||||
}
|
||||
|
||||
final projects = <String, ProjectConfig>{};
|
||||
for (final entry in projectDocuments.entries) {
|
||||
projects[entry.key] = ProjectConfig.fromDocument(
|
||||
key: entry.key,
|
||||
document: entry.value,
|
||||
defaultAssistant: defaults,
|
||||
);
|
||||
}
|
||||
|
||||
return AppConfig(
|
||||
configPath: p.normalize(p.absolute(path)),
|
||||
dataDir: _expandOptionalPath(document.dataDir),
|
||||
worktreeDir: _expandOptionalPath(document.worktreeDir),
|
||||
defaults: defaults,
|
||||
projects: projects,
|
||||
);
|
||||
}
|
||||
|
||||
static AppConfigDocument _parseDocument(Map<String, dynamic> root) {
|
||||
try {
|
||||
return AppConfigDocument.fromJson(root);
|
||||
} catch (error) {
|
||||
throw FormatException(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
static AppConfigDocument _initialTemplateDocument() {
|
||||
return AppConfigDocument(
|
||||
dataDir: '~/.agent-orchestrator',
|
||||
worktreeDir: '~/.worktrees',
|
||||
defaults: AppConfigDefaultsDocument(
|
||||
issueAssistant: IssueAssistantConfigDocument(
|
||||
enabled: true,
|
||||
pollInterval: '30s',
|
||||
maxConcurrentIssues: 3,
|
||||
mentionTriggers: const ['@codex', '@helper'],
|
||||
),
|
||||
),
|
||||
projects: {
|
||||
'sample': ProjectConfigDocument(
|
||||
repo: 'owner/repo',
|
||||
path: '~/path/to/repo',
|
||||
defaultBranch: 'main',
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectConfig {
|
||||
ProjectConfig({
|
||||
required this.key,
|
||||
required this.repo,
|
||||
required this.repoSlug,
|
||||
required this.path,
|
||||
required this.defaultBranch,
|
||||
required this.sessionPrefix,
|
||||
required this.issueAssistant,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String repo;
|
||||
final RepositorySlug repoSlug;
|
||||
final String path;
|
||||
final String defaultBranch;
|
||||
final String sessionPrefix;
|
||||
final IssueAssistantConfig issueAssistant;
|
||||
|
||||
factory ProjectConfig.fromDocument({
|
||||
required String key,
|
||||
required ProjectConfigDocument document,
|
||||
required IssueAssistantConfig defaultAssistant,
|
||||
}) {
|
||||
final repo = document.repo?.trim();
|
||||
if (repo == null || repo.isEmpty) {
|
||||
throw FormatException(
|
||||
'Missing required config field: projects.$key.repo',
|
||||
);
|
||||
}
|
||||
final path = document.path?.trim();
|
||||
if (path == null || path.isEmpty) {
|
||||
throw FormatException(
|
||||
'Missing required config field: projects.$key.path',
|
||||
);
|
||||
}
|
||||
|
||||
return ProjectConfig(
|
||||
key: key,
|
||||
repo: repo,
|
||||
repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||
path: _expandHome(path),
|
||||
defaultBranch: document.defaultBranch ?? 'main',
|
||||
sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key),
|
||||
issueAssistant: defaultAssistant.merge(document.issueAssistant),
|
||||
);
|
||||
}
|
||||
|
||||
static RepositorySlug _parseRepositorySlug(
|
||||
String value, {
|
||||
required String field,
|
||||
}) {
|
||||
final parts = value.split('/');
|
||||
if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) {
|
||||
throw FormatException(
|
||||
'Invalid repository slug for $field: "$value". Expected owner/repo.',
|
||||
);
|
||||
}
|
||||
return RepositorySlug(parts[0], parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
class IssueAssistantConfig {
|
||||
IssueAssistantConfig({
|
||||
required this.enabled,
|
||||
required this.pollInterval,
|
||||
required this.maxConcurrentIssues,
|
||||
required this.mentionTriggers,
|
||||
required this.prompt,
|
||||
required this.responders,
|
||||
required this.capabilities,
|
||||
required this.commentTag,
|
||||
required this.commentPrefixTemplate,
|
||||
required this.commentFooterTemplate,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final Duration pollInterval;
|
||||
final int maxConcurrentIssues;
|
||||
final List<String> mentionTriggers;
|
||||
final String prompt;
|
||||
final List<CliResponderConfig> responders;
|
||||
final Set<AssistantCapability> capabilities;
|
||||
final String commentTag;
|
||||
final String commentPrefixTemplate;
|
||||
final String commentFooterTemplate;
|
||||
|
||||
factory IssueAssistantConfig.fromDocument(
|
||||
IssueAssistantConfigDocument? document,
|
||||
) {
|
||||
return const _IssueAssistantConfigResolver()
|
||||
.resolve(base: null, overlay: null)
|
||||
.merge(document);
|
||||
}
|
||||
|
||||
IssueAssistantConfig merge(IssueAssistantConfigDocument? overlay) {
|
||||
return const _IssueAssistantConfigResolver().resolve(
|
||||
base: this,
|
||||
overlay: overlay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CliResponderConfig {
|
||||
|
|
@ -427,33 +261,23 @@ class CliResponderConfig {
|
|||
final String stdinTemplate;
|
||||
final Duration timeout;
|
||||
|
||||
factory CliResponderConfig.fromMap(Map<String, dynamic> map) {
|
||||
final id = map['id']?.toString();
|
||||
final command = map['command']?.toString();
|
||||
factory CliResponderConfig.fromDocument(CliResponderConfigDocument document) {
|
||||
final id = document.id?.trim();
|
||||
if (id == null || id.isEmpty) {
|
||||
throw const FormatException('Responder id is required.');
|
||||
}
|
||||
final command = document.command?.trim();
|
||||
if (command == null || command.isEmpty) {
|
||||
throw FormatException('Responder "$id" must define command.');
|
||||
}
|
||||
|
||||
final args = _normalizeArgs(
|
||||
(map['args'] as List?)?.map((item) => item.toString()) ??
|
||||
const <String>[],
|
||||
);
|
||||
final env = AppConfig._asMap(
|
||||
map['env'],
|
||||
).map((key, value) => MapEntry(key, value.toString()));
|
||||
|
||||
return CliResponderConfig(
|
||||
id: id,
|
||||
command: command,
|
||||
args: args,
|
||||
env: env,
|
||||
stdinTemplate: map['stdinTemplate']?.toString() ?? '{{prompt}}',
|
||||
timeout: IssueAssistantConfig._parseDuration(
|
||||
map['timeout']?.toString() ?? '10m',
|
||||
),
|
||||
args: _normalizeArgs(document.args ?? const <String>[]),
|
||||
env: document.env ?? const <String, String>{},
|
||||
stdinTemplate: document.stdinTemplate ?? '{{prompt}}',
|
||||
timeout: _parseDuration(document.timeout ?? '10m'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -464,17 +288,50 @@ class CliResponderConfig {
|
|||
}
|
||||
}
|
||||
|
||||
enum AssistantCapability {
|
||||
planning,
|
||||
bugVerification;
|
||||
class _IssueAssistantConfigResolver {
|
||||
const _IssueAssistantConfigResolver();
|
||||
|
||||
static AssistantCapability parse(String input) {
|
||||
return switch (input) {
|
||||
'planning' => AssistantCapability.planning,
|
||||
'bugVerification' ||
|
||||
'bug_verification' => AssistantCapability.bugVerification,
|
||||
_ => throw FormatException('Unsupported capability: $input'),
|
||||
};
|
||||
IssueAssistantConfig resolve({
|
||||
required IssueAssistantConfig? base,
|
||||
required IssueAssistantConfigDocument? overlay,
|
||||
}) {
|
||||
final responders = overlay?.responders;
|
||||
return IssueAssistantConfig(
|
||||
enabled: overlay?.enabled ?? base?.enabled ?? true,
|
||||
pollInterval: overlay?.pollInterval != null
|
||||
? _parseDuration(overlay!.pollInterval!)
|
||||
: base?.pollInterval ?? const Duration(seconds: 30),
|
||||
maxConcurrentIssues: _resolvePositiveInt(
|
||||
value: overlay?.maxConcurrentIssues,
|
||||
fallback: base?.maxConcurrentIssues ?? 3,
|
||||
fieldName: 'maxConcurrentIssues',
|
||||
),
|
||||
mentionTriggers:
|
||||
overlay?.mentionTriggers ?? base?.mentionTriggers ?? const <String>[],
|
||||
prompt: overlay?.prompt ?? base?.prompt ?? defaultIssueAssistantPrompt,
|
||||
responders: responders != null
|
||||
? responders
|
||||
.map(CliResponderConfig.fromDocument)
|
||||
.toList(growable: false)
|
||||
: base?.responders ?? const <CliResponderConfig>[],
|
||||
capabilities: overlay?.capabilities != null
|
||||
? overlay!.capabilities!.toSet()
|
||||
: base?.capabilities ??
|
||||
{
|
||||
AssistantCapability.planning,
|
||||
AssistantCapability.bugVerification,
|
||||
},
|
||||
commentTag:
|
||||
overlay?.commentTag ?? base?.commentTag ?? 'code-work-spawner',
|
||||
commentPrefixTemplate:
|
||||
overlay?.commentPrefixTemplate ??
|
||||
base?.commentPrefixTemplate ??
|
||||
defaultCommentPrefixTemplate,
|
||||
commentFooterTemplate:
|
||||
overlay?.commentFooterTemplate ??
|
||||
base?.commentFooterTemplate ??
|
||||
defaultCommentFooterTemplate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -491,6 +348,58 @@ String encodePrettyJson(Object value) {
|
|||
return encoder.convert(value);
|
||||
}
|
||||
|
||||
Object? _normalizeYamlNode(Object? value) {
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
(key, dynamicValue) =>
|
||||
MapEntry(key.toString(), _normalizeYamlNode(dynamicValue)),
|
||||
);
|
||||
}
|
||||
if (value is YamlList) {
|
||||
return value.map(_normalizeYamlNode).toList(growable: false);
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.map(
|
||||
(key, dynamicValue) =>
|
||||
MapEntry(key.toString(), _normalizeYamlNode(dynamicValue)),
|
||||
);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_normalizeYamlNode).toList(growable: false);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Duration _parseDuration(String input) {
|
||||
final match = RegExp(r'^(\d+)([smhd])$').firstMatch(input.trim());
|
||||
if (match == null) {
|
||||
throw FormatException('Invalid duration: $input');
|
||||
}
|
||||
|
||||
final value = int.parse(match.group(1)!);
|
||||
return switch (match.group(2)!) {
|
||||
's' => Duration(seconds: value),
|
||||
'm' => Duration(minutes: value),
|
||||
'h' => Duration(hours: value),
|
||||
'd' => Duration(days: value),
|
||||
_ => throw FormatException('Invalid duration unit: $input'),
|
||||
};
|
||||
}
|
||||
|
||||
int _resolvePositiveInt({
|
||||
required int? value,
|
||||
required int fallback,
|
||||
required String fieldName,
|
||||
}) {
|
||||
final resolved = value ?? fallback;
|
||||
if (resolved < 1) {
|
||||
throw FormatException(
|
||||
'$fieldName must be an integer greater than or equal to 1.',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
String _expandHome(String path) {
|
||||
if (!path.startsWith('~/')) {
|
||||
return p.normalize(p.absolute(path));
|
||||
|
|
@ -510,6 +419,67 @@ String? _expandOptionalPath(String? path) {
|
|||
return _expandHome(path);
|
||||
}
|
||||
|
||||
void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
||||
_assertCamelCaseMap(root, scope: '');
|
||||
|
||||
final defaults = root['defaults'];
|
||||
if (defaults is Map<String, dynamic>) {
|
||||
final issueAssistant = defaults['issueAssistant'];
|
||||
if (issueAssistant is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(issueAssistant, scope: 'defaults.issueAssistant');
|
||||
final responders = issueAssistant['responders'];
|
||||
if (responders is List) {
|
||||
for (final responder in responders) {
|
||||
if (responder is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
responder,
|
||||
scope: 'defaults.issueAssistant.responders',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final projects = root['projects'];
|
||||
if (projects is Map<String, dynamic>) {
|
||||
for (final entry in projects.entries) {
|
||||
final project = entry.value;
|
||||
if (project is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
_assertCamelCaseMap(project, scope: 'projects.${entry.key}');
|
||||
final issueAssistant = project['issueAssistant'];
|
||||
if (issueAssistant is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
issueAssistant,
|
||||
scope: 'projects.${entry.key}.issueAssistant',
|
||||
);
|
||||
final responders = issueAssistant['responders'];
|
||||
if (responders is List) {
|
||||
for (final responder in responders) {
|
||||
if (responder is Map<String, dynamic>) {
|
||||
_assertCamelCaseMap(
|
||||
responder,
|
||||
scope: 'projects.${entry.key}.issueAssistant.responders',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _assertCamelCaseMap(Map<String, dynamic> map, {required String scope}) {
|
||||
for (final key in map.keys) {
|
||||
if (key.contains('_')) {
|
||||
final qualified = scope.isEmpty ? key : '$scope.$key';
|
||||
throw FormatException('Unsupported config field: $qualified');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _deriveSessionPrefix(String key) {
|
||||
if (key.contains('-')) {
|
||||
return key
|
||||
|
|
@ -532,4 +502,148 @@ String _deriveSessionPrefix(String key) {
|
|||
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
|
||||
}
|
||||
|
||||
enum _ConfigDialect { current, aoSubset }
|
||||
class _InitialConfigRenderer {
|
||||
String render(AppConfigDocument document) {
|
||||
final root = document.toJson();
|
||||
final defaultsSection = root.remove('defaults') as Map<String, dynamic>?;
|
||||
final projectsSection = root.remove('projects') as Map<String, dynamic>?;
|
||||
final assistant = document.defaults?.issueAssistant;
|
||||
|
||||
return '''
|
||||
# Starter config for code_work_spawner.
|
||||
# Required fields are active. Optional settings are shown as commented examples.
|
||||
#
|
||||
# Update the sample repo, path, and responder command before running the app.
|
||||
|
||||
${_YamlEmitter().write(root)}
|
||||
|
||||
${_YamlEmitter().write({'defaults': defaultsSection})}
|
||||
|
||||
# capabilities: ["planning", "bug_verification"]
|
||||
# commentTag: code-work-spawner
|
||||
# commentPrefixTemplate: |
|
||||
${_commentBlock(defaultCommentPrefixTemplate, indent: ' # ')}
|
||||
# commentFooterTemplate: |
|
||||
${_commentBlock(defaultCommentFooterTemplate, indent: ' # ')}
|
||||
# prompt: |
|
||||
${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')}
|
||||
# responders:
|
||||
# - id: codex
|
||||
# command: codex
|
||||
# args: ["exec", "--json"]
|
||||
# timeout: 10m
|
||||
# - id: opencode
|
||||
# command: opencode
|
||||
# timeout: 10m
|
||||
|
||||
${_YamlEmitter().write({'projects': projectsSection})}
|
||||
|
||||
# issueAssistant:
|
||||
# enabled: ${assistant?.enabled ?? true}
|
||||
# mentionTriggers: ["@helper"]
|
||||
# sessionPrefix: sample
|
||||
'''
|
||||
.trimRight();
|
||||
}
|
||||
|
||||
String _commentBlock(String value, {required String indent}) {
|
||||
return value.split('\n').map((line) => '$indent$line').join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
class _YamlEmitter {
|
||||
String write(Map<String, dynamic> map) {
|
||||
final buffer = StringBuffer();
|
||||
_writeMap(buffer, map, 0);
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
void _writeMap(StringBuffer buffer, Map<String, dynamic> map, int indent) {
|
||||
final prefix = ' ' * indent;
|
||||
for (final entry in map.entries) {
|
||||
final value = entry.value;
|
||||
if (value is Map<String, dynamic>) {
|
||||
buffer.writeln('$prefix${entry.key}:');
|
||||
_writeMap(buffer, value, indent + 2);
|
||||
continue;
|
||||
}
|
||||
if (value is List) {
|
||||
if (value.isEmpty) {
|
||||
buffer.writeln('$prefix${entry.key}: []');
|
||||
continue;
|
||||
}
|
||||
if (value.every((item) => item is! Map && item is! List)) {
|
||||
final rendered = value.map(_renderScalar).join(', ');
|
||||
buffer.writeln('$prefix${entry.key}: [$rendered]');
|
||||
continue;
|
||||
}
|
||||
buffer.writeln('$prefix${entry.key}:');
|
||||
_writeList(buffer, value, indent + 2);
|
||||
continue;
|
||||
}
|
||||
if (value is String && value.contains('\n')) {
|
||||
buffer.writeln('$prefix${entry.key}: |');
|
||||
for (final line in value.split('\n')) {
|
||||
buffer.writeln('${' ' * (indent + 2)}$line');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
buffer.writeln('$prefix${entry.key}: ${_renderScalar(value)}');
|
||||
}
|
||||
}
|
||||
|
||||
void _writeList(StringBuffer buffer, List<dynamic> values, int indent) {
|
||||
final prefix = ' ' * indent;
|
||||
for (final value in values) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
final entries = value.entries.toList(growable: false);
|
||||
if (entries.isEmpty) {
|
||||
buffer.writeln('$prefix- {}');
|
||||
continue;
|
||||
}
|
||||
final first = entries.first;
|
||||
buffer.writeln(
|
||||
'$prefix- ${first.key}: ${_renderInlineValue(first.value)}',
|
||||
);
|
||||
if (entries.length > 1) {
|
||||
_writeMap(
|
||||
buffer,
|
||||
Map<String, dynamic>.fromEntries(entries.skip(1)),
|
||||
indent + 2,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
buffer.writeln('$prefix- ${_renderScalar(value)}');
|
||||
}
|
||||
}
|
||||
|
||||
String _renderInlineValue(Object? value) {
|
||||
if (value is String && value.contains('\n')) {
|
||||
return '|';
|
||||
}
|
||||
if (value is List && value.every((item) => item is! Map && item is! List)) {
|
||||
return '[${value.map(_renderScalar).join(', ')}]';
|
||||
}
|
||||
return _renderScalar(value);
|
||||
}
|
||||
|
||||
String _renderScalar(Object? value) {
|
||||
if (value is String) {
|
||||
if (value.isEmpty) {
|
||||
return '""';
|
||||
}
|
||||
if (RegExp(r'^[A-Za-z0-9_./{}\-]+$').hasMatch(value)) {
|
||||
return value;
|
||||
}
|
||||
return jsonEncode(value);
|
||||
}
|
||||
if (value is bool || value is num) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value == null) {
|
||||
return 'null';
|
||||
}
|
||||
return jsonEncode(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'config_schema.g.dart';
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class AppConfigDocument {
|
||||
const AppConfigDocument({
|
||||
this.dataDir,
|
||||
this.worktreeDir,
|
||||
this.defaults,
|
||||
this.projects,
|
||||
});
|
||||
|
||||
final String? dataDir;
|
||||
final String? worktreeDir;
|
||||
final AppConfigDefaultsDocument? defaults;
|
||||
final Map<String, ProjectConfigDocument>? projects;
|
||||
|
||||
factory AppConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$AppConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class AppConfigDefaultsDocument {
|
||||
const AppConfigDefaultsDocument({this.issueAssistant});
|
||||
|
||||
final IssueAssistantConfigDocument? issueAssistant;
|
||||
|
||||
factory AppConfigDefaultsDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppConfigDefaultsDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$AppConfigDefaultsDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class ProjectConfigDocument {
|
||||
const ProjectConfigDocument({
|
||||
this.repo,
|
||||
this.path,
|
||||
this.defaultBranch,
|
||||
this.sessionPrefix,
|
||||
this.issueAssistant,
|
||||
});
|
||||
|
||||
final String? repo;
|
||||
final String? path;
|
||||
final String? defaultBranch;
|
||||
final String? sessionPrefix;
|
||||
final IssueAssistantConfigDocument? issueAssistant;
|
||||
|
||||
factory ProjectConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProjectConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ProjectConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class IssueAssistantConfigDocument {
|
||||
const IssueAssistantConfigDocument({
|
||||
this.enabled,
|
||||
this.pollInterval,
|
||||
this.maxConcurrentIssues,
|
||||
this.mentionTriggers,
|
||||
this.prompt,
|
||||
this.responders,
|
||||
this.capabilities,
|
||||
this.commentTag,
|
||||
this.commentPrefixTemplate,
|
||||
this.commentFooterTemplate,
|
||||
});
|
||||
|
||||
final bool? enabled;
|
||||
final String? pollInterval;
|
||||
final int? maxConcurrentIssues;
|
||||
final List<String>? mentionTriggers;
|
||||
final String? prompt;
|
||||
final List<CliResponderConfigDocument>? responders;
|
||||
final List<AssistantCapability>? capabilities;
|
||||
final String? commentTag;
|
||||
final String? commentPrefixTemplate;
|
||||
final String? commentFooterTemplate;
|
||||
|
||||
factory IssueAssistantConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$IssueAssistantConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$IssueAssistantConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(
|
||||
checked: true,
|
||||
createToJson: true,
|
||||
disallowUnrecognizedKeys: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
)
|
||||
class CliResponderConfigDocument {
|
||||
const CliResponderConfigDocument({
|
||||
this.id,
|
||||
this.command,
|
||||
this.args,
|
||||
this.env,
|
||||
this.stdinTemplate,
|
||||
this.timeout,
|
||||
});
|
||||
|
||||
final String? id;
|
||||
final String? command;
|
||||
final List<String>? args;
|
||||
final Map<String, String>? env;
|
||||
final String? stdinTemplate;
|
||||
final String? timeout;
|
||||
|
||||
factory CliResponderConfigDocument.fromJson(Map<String, dynamic> json) =>
|
||||
_$CliResponderConfigDocumentFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CliResponderConfigDocumentToJson(this);
|
||||
}
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum AssistantCapability { planning, bugVerification }
|
||||
|
|
@ -11,6 +11,7 @@ dependencies:
|
|||
crypto: ^3.0.6
|
||||
drift: ^2.28.2
|
||||
file: ^7.0.1
|
||||
json_annotation: ^4.11.0
|
||||
path: ^1.9.0
|
||||
sqlite3: ^2.9.3
|
||||
yaml: ^3.1.3
|
||||
|
|
@ -24,5 +25,6 @@ dependencies:
|
|||
dev_dependencies:
|
||||
build_runner: ^2.6.0
|
||||
drift_dev: ^2.28.1
|
||||
json_serializable: ^6.11.1
|
||||
lints: ^6.0.0
|
||||
test: ^1.25.6
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ void main() {
|
|||
group('AppConfig.load', () {
|
||||
/// ```gherkin
|
||||
/// Scenario: Merge defaults with project overrides
|
||||
/// Given AO style config with shared defaults and one project override
|
||||
/// Given camelCase config with shared defaults and one project override
|
||||
/// When loading the app config from disk
|
||||
/// Then the project keeps inherited defaults that were not overridden
|
||||
/// And the project-specific prompt replaces the shared prompt
|
||||
/// ```
|
||||
test('Merge defaults with project overrides', () async {
|
||||
// Given AO style config with shared defaults and one project override.
|
||||
// Given camelCase config with shared defaults and one project override.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
|
|
@ -110,12 +110,12 @@ projects:
|
|||
|
||||
/// ```gherkin
|
||||
/// Scenario: Override visible comment templates per project
|
||||
/// Given AO style config with shared defaults and project-specific comment templates
|
||||
/// Given camelCase config with shared defaults and project-specific comment templates
|
||||
/// When loading the app config from disk
|
||||
/// Then the project-specific comment templates replace the shared defaults
|
||||
/// ```
|
||||
test('Override visible comment templates per project', () async {
|
||||
// Given AO style config with shared defaults and project-specific comment templates.
|
||||
// Given camelCase config with shared defaults and project-specific comment templates.
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'cws-comment-template-',
|
||||
);
|
||||
|
|
@ -182,26 +182,28 @@ projects:
|
|||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load AO subset config with shared worktree root
|
||||
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
|
||||
/// Scenario: Load camelCase config with shared worktree root
|
||||
/// Given camelCase config with dataDir, worktreeDir, and project settings
|
||||
/// When loading the app config from disk
|
||||
/// Then the app exposes the configured data and worktree directories
|
||||
/// And each project inherits the supported shared defaults
|
||||
/// And each project keeps the configured camelCase settings
|
||||
/// ```
|
||||
test('Load AO subset config with shared worktree root', () async {
|
||||
// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-ao-subset-');
|
||||
test('Load camelCase config with shared worktree root', () async {
|
||||
// Given camelCase config with dataDir, worktreeDir, and project settings.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-camel-case-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
defaults:
|
||||
defaultBranch: trunk
|
||||
sessionPrefix: ao
|
||||
issueAssistant:
|
||||
enabled: false
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
defaultBranch: trunk
|
||||
sessionPrefix: ao
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
|
|
@ -217,31 +219,31 @@ projects:
|
|||
p.join(Platform.environment['HOME']!, '.worktrees'),
|
||||
);
|
||||
|
||||
// And each project inherits the supported shared defaults.
|
||||
// And each project keeps the configured camelCase settings.
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
expect(config.projects['sample']!.defaultBranch, 'trunk');
|
||||
expect(config.projects['sample']!.sessionPrefix, 'ao');
|
||||
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
|
||||
expect(config.projects['sample']!.issueAssistant.enabled, isFalse);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Reject unsupported AO fields
|
||||
/// Given an AO style config that includes an unsupported field
|
||||
/// Scenario: Reject snake_case config keys
|
||||
/// Given a config that still uses snake_case keys
|
||||
/// When loading the app config from disk
|
||||
/// Then loading fails with a clear config error
|
||||
/// ```
|
||||
test('Reject unsupported AO fields', () async {
|
||||
// Given an AO style config that includes an unsupported field.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-ao-invalid-');
|
||||
test('Reject snake_case config keys', () async {
|
||||
// Given a config that still uses snake_case keys.
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'cws-snake-case-invalid-',
|
||||
);
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
worktreeDir: ~/.worktrees
|
||||
worktree_dir: ~/.worktrees
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
tracker:
|
||||
plugin: github
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
|
|
@ -254,7 +256,7 @@ projects:
|
|||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('Unsupported config field: projects.sample.tracker'),
|
||||
contains('worktree_dir'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -294,5 +296,110 @@ projects:
|
|||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Generate a starter config that parses as valid YAML
|
||||
/// Given the built-in initial config renderer
|
||||
/// When writing the generated starter config to disk
|
||||
/// Then the file parses through the normal app config loader
|
||||
/// And the required starter values stay available to users
|
||||
/// ```
|
||||
test('Generate a starter config that parses as valid YAML', () async {
|
||||
// Given the built-in initial config renderer.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-init-config-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
final contents = AppConfig.renderInitialConfig();
|
||||
|
||||
// When writing the generated starter config to disk.
|
||||
await configFile.writeAsString(contents);
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
|
||||
// Then the file parses through the normal app config loader.
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
|
||||
// And the required starter values stay available to users.
|
||||
expect(
|
||||
config.dataDir,
|
||||
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
||||
);
|
||||
expect(
|
||||
config.worktreeDir,
|
||||
p.join(Platform.environment['HOME']!, '.worktrees'),
|
||||
);
|
||||
expect(config.projects['sample']!.repo, 'owner/repo');
|
||||
expect(
|
||||
config.projects['sample']!.path,
|
||||
p.join(Platform.environment['HOME']!, 'path/to/repo'),
|
||||
);
|
||||
expect(config.projects['sample']!.defaultBranch, 'main');
|
||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||
'@codex',
|
||||
'@helper',
|
||||
]);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Parse uncommented optional starter fields
|
||||
/// Given a generated starter config with optional example lines uncommented
|
||||
/// When loading that config through the normal parser
|
||||
/// Then the uncommented optional values are accepted
|
||||
/// And the parsed config reflects the example optional settings
|
||||
/// ```
|
||||
test('Parse uncommented optional starter fields', () async {
|
||||
// Given a generated starter config with optional example lines uncommented.
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'cws-init-optional-',
|
||||
);
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
final contents = AppConfig.renderInitialConfig()
|
||||
.replaceFirst(
|
||||
' # capabilities: ["planning", "bug_verification"]',
|
||||
' capabilities: ["planning", "bug_verification"]',
|
||||
)
|
||||
.replaceFirst(
|
||||
' # commentTag: code-work-spawner',
|
||||
' commentTag: code-work-spawner',
|
||||
)
|
||||
.replaceFirst(' # responders:', ' responders:')
|
||||
.replaceFirst(' # - id: codex', ' - id: codex')
|
||||
.replaceFirst(' # command: codex', ' command: codex')
|
||||
.replaceFirst(
|
||||
' # args: ["exec", "--json"]',
|
||||
' args: ["exec", "--json"]',
|
||||
)
|
||||
.replaceFirst(' # timeout: 10m', ' timeout: 10m')
|
||||
.replaceFirst(' # issueAssistant:', ' issueAssistant:')
|
||||
.replaceFirst(' # enabled: true', ' enabled: true')
|
||||
.replaceFirst(
|
||||
' # mentionTriggers: ["@helper"]',
|
||||
' mentionTriggers: ["@helper"]',
|
||||
)
|
||||
.replaceFirst(
|
||||
' # sessionPrefix: sample',
|
||||
' sessionPrefix: sample',
|
||||
);
|
||||
await configFile.writeAsString(contents);
|
||||
|
||||
// When loading that config through the normal parser.
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
|
||||
// Then the uncommented optional values are accepted.
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
|
||||
// And the parsed config reflects the example optional settings.
|
||||
expect(config.defaults.commentTag, 'code-work-spawner');
|
||||
expect(config.defaults.responders.single.id, 'codex');
|
||||
expect(
|
||||
config.defaults.capabilities,
|
||||
containsAll({
|
||||
AssistantCapability.planning,
|
||||
AssistantCapability.bugVerification,
|
||||
}),
|
||||
);
|
||||
expect(config.projects['sample']!.sessionPrefix, 'sample');
|
||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||
'@helper',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
/// ```gherkin
|
||||
/// Feature: Init-config CLI
|
||||
///
|
||||
/// As a maintainer setting up code_work_spawner
|
||||
/// I want a starter config command that can print or write a valid template
|
||||
/// So that I can bootstrap configuration without reading the code first
|
||||
/// ```
|
||||
group('code_work_spawner init-config', () {
|
||||
/// ```gherkin
|
||||
/// Scenario: Print the starter config to stdout
|
||||
/// Given the CLI is executed with the init-config command
|
||||
/// When the process completes successfully
|
||||
/// Then stdout contains the starter config content
|
||||
/// And the printed config is valid when written to disk and loaded
|
||||
/// ```
|
||||
test('Print the starter config to stdout', () async {
|
||||
// Given the CLI is executed with the init-config command.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-cli-stdout-');
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
final result = await Process.run(Platform.resolvedExecutable, [
|
||||
'run',
|
||||
'bin/code_work_spawner.dart',
|
||||
'init-config',
|
||||
], workingDirectory: Directory.current.path);
|
||||
|
||||
// When the process completes successfully.
|
||||
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||
|
||||
// Then stdout contains the starter config content.
|
||||
final stdoutText = result.stdout as String;
|
||||
expect(stdoutText, contains('dataDir:'));
|
||||
expect(stdoutText, contains('# responders:'));
|
||||
expect(stdoutText, contains('projects:'));
|
||||
|
||||
// And the printed config is valid when written to disk and loaded.
|
||||
await configFile.writeAsString(stdoutText);
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Refuse overwrite unless force is provided
|
||||
/// Given an existing output file for the init-config command
|
||||
/// When the CLI writes without the force flag and then with the force flag
|
||||
/// Then the first run fails with an overwrite error
|
||||
/// And the forced run replaces the file with a valid starter config
|
||||
/// ```
|
||||
test('Refuse overwrite unless force is provided', () async {
|
||||
// Given an existing output file for the init-config command.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-cli-force-');
|
||||
final outputFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
await outputFile.writeAsString('original');
|
||||
|
||||
// When the CLI writes without the force flag and then with the force flag.
|
||||
final withoutForce = await Process.run(Platform.resolvedExecutable, [
|
||||
'run',
|
||||
'bin/code_work_spawner.dart',
|
||||
'init-config',
|
||||
'--output',
|
||||
outputFile.path,
|
||||
], workingDirectory: Directory.current.path);
|
||||
final withForce = await Process.run(Platform.resolvedExecutable, [
|
||||
'run',
|
||||
'bin/code_work_spawner.dart',
|
||||
'init-config',
|
||||
'--output',
|
||||
outputFile.path,
|
||||
'--force',
|
||||
], workingDirectory: Directory.current.path);
|
||||
|
||||
// Then the first run fails with an overwrite error.
|
||||
expect(withoutForce.exitCode, 2);
|
||||
expect(
|
||||
withoutForce.stderr as String,
|
||||
contains('Refusing to overwrite existing file'),
|
||||
);
|
||||
|
||||
// And the forced run replaces the file with a valid starter config.
|
||||
expect(withForce.exitCode, 0, reason: '${withForce.stderr}');
|
||||
final contents = await outputFile.readAsString();
|
||||
expect(contents, contains('defaults:'));
|
||||
expect(contents, contains('projects:'));
|
||||
final config = await AppConfig.load(outputFile.path);
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue