forked from bkinnightskytw/code_work_spawner
feat: Add minimal GitHub repo configuration and issue assistant setup
fix: Export workspace manager in code work spawner feat: Enhance CLI responder to handle JSONL events feat: Implement job status enumeration in database feat: Add logging capabilities to IssueAssistantApp
This commit is contained in:
parent
aa8d122ab9
commit
42b89719fd
|
|
@ -14,6 +14,9 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
|
||||
- name: Build generated files
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Static analysis
|
||||
run: dart analyze
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ agent-orchestrator.yaml
|
|||
# !/.agents
|
||||
!/.git
|
||||
!/.github
|
||||
!/.vscode
|
||||
/skills/*
|
||||
!/skills/dart-gherkin-tests
|
||||
|
||||
*.g.dart
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"qwtel.sqlite-viewer"
|
||||
]
|
||||
}
|
||||
11
README.md
11
README.md
|
|
@ -49,7 +49,7 @@ defaults:
|
|||
responders:
|
||||
- id: codex
|
||||
command: codex
|
||||
args: ["exec", "--json-output"]
|
||||
args: ["exec", "--json"]
|
||||
timeout: 10m
|
||||
- id: opencode
|
||||
command: opencode
|
||||
|
|
@ -66,8 +66,17 @@ projects:
|
|||
Project entries can override `issueAssistant` fields when a repo needs a
|
||||
different prompt, mention triggers, or responder chain.
|
||||
|
||||
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.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
Single poll cycle:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ import 'dart:io';
|
|||
|
||||
import 'package:args/args.dart';
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> main(List<String> arguments) async {
|
||||
_configureLogging();
|
||||
|
||||
final parser = ArgParser()
|
||||
..addFlag(
|
||||
'once',
|
||||
|
|
@ -36,6 +39,22 @@ Future<void> main(List<String> arguments) async {
|
|||
}
|
||||
|
||||
final config = await AppConfig.load(results['config'] as String);
|
||||
final logger = Logger('code_work_spawner.cli');
|
||||
logger.info(
|
||||
'loaded config=${config.configPath} '
|
||||
'projects=${config.projects.length} '
|
||||
'worktreeDir=${config.worktreeDir ?? "(system temp)"}',
|
||||
);
|
||||
for (final project in config.projects.values) {
|
||||
final repoDirectory = Directory(project.path);
|
||||
logger.info(
|
||||
'project=${project.key} '
|
||||
'repo=${project.repo} '
|
||||
'path=${project.path} '
|
||||
'exists=${await repoDirectory.exists()} '
|
||||
'enabled=${project.issueAssistant.enabled}',
|
||||
);
|
||||
}
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: config,
|
||||
databasePath: results['db'] as String,
|
||||
|
|
@ -53,3 +72,13 @@ Future<void> main(List<String> arguments) async {
|
|||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
void _configureLogging() {
|
||||
hierarchicalLoggingEnabled = true;
|
||||
Logger.root.level = Level.INFO;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
stdout.writeln(
|
||||
'[${record.loggerName}] ${record.level.name}: ${record.message}',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# Minimal setup for a single GitHub repo with GitHub Issues
|
||||
# AO-compatible subset supported by code_work_spawner.
|
||||
# Replies require at least one configured responder.
|
||||
|
||||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
|
||||
defaults:
|
||||
issueAssistant:
|
||||
enabled: true
|
||||
pollInterval: 5m
|
||||
mentionTriggers: ["@codex"]
|
||||
responders:
|
||||
# Replace this with a responder available on your machine.
|
||||
- id: codex
|
||||
command: codex
|
||||
args: ["exec", "--json"]
|
||||
timeout: 10m
|
||||
|
||||
projects:
|
||||
code-work-spawner:
|
||||
repo: existedinnettw/code_work_spawner
|
||||
path: ~/code_work_spawner
|
||||
defaultBranch: main
|
||||
|
|
@ -4,5 +4,5 @@ pre-commit:
|
|||
run: dart format --output=none --set-exit-if-changed .
|
||||
analyze:
|
||||
run: dart analyze
|
||||
test:
|
||||
run: dart test
|
||||
# test:
|
||||
# run: dart test
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export 'src/config.dart';
|
|||
export 'src/database.dart';
|
||||
export 'src/github_client.dart';
|
||||
export 'src/issue_assistant_app.dart';
|
||||
export 'src/workspace_manager.dart';
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ class CliResponderRunner {
|
|||
try {
|
||||
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||
} on FormatException {
|
||||
final eventJson = _decodeJsonlEvents(trimmed);
|
||||
if (eventJson != null) {
|
||||
return eventJson;
|
||||
}
|
||||
|
||||
final first = trimmed.indexOf('{');
|
||||
final last = trimmed.lastIndexOf('}');
|
||||
if (first == -1 || last <= first) {
|
||||
|
|
@ -96,6 +101,45 @@ class CliResponderRunner {
|
|||
return (jsonDecode(snippet) as Map).cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodeJsonlEvents(String stdout) {
|
||||
Map<String, dynamic>? lastEvent;
|
||||
for (final line in const LineSplitter().convert(stdout)) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(trimmed);
|
||||
if (decoded is! Map) {
|
||||
continue;
|
||||
}
|
||||
lastEvent = decoded.cast<String, dynamic>();
|
||||
|
||||
final item = lastEvent['item'];
|
||||
if (item is Map &&
|
||||
item['type']?.toString() == 'agent_message' &&
|
||||
item['text'] is String) {
|
||||
final text = (item['text'] as String).trim();
|
||||
if (text.startsWith('{')) {
|
||||
return (jsonDecode(text) as Map).cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
} on FormatException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEvent case {'text': final String text}) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.startsWith('{')) {
|
||||
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ResponderResult {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ import 'package:yaml/yaml.dart';
|
|||
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;
|
||||
|
||||
|
|
@ -23,11 +27,80 @@ class AppConfig {
|
|||
throw const FormatException('Config root must be a YAML map.');
|
||||
}
|
||||
|
||||
final defaultsMap = _asMap(yaml['defaults']);
|
||||
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,
|
||||
);
|
||||
|
||||
final projectsNode = yaml['projects'];
|
||||
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.');
|
||||
}
|
||||
|
|
@ -39,16 +112,56 @@ class AppConfig {
|
|||
projects[key] = ProjectConfig.fromMap(
|
||||
key: key,
|
||||
map: value,
|
||||
defaultAssistant: defaults,
|
||||
defaultAssistant: defaultAssistant,
|
||||
defaultAssistantMap: defaultAssistantMap,
|
||||
);
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
return AppConfig(
|
||||
configPath: p.normalize(p.absolute(path)),
|
||||
defaults: defaults,
|
||||
projects: 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) {
|
||||
|
|
@ -68,6 +181,18 @@ class AppConfig {
|
|||
|
||||
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 {
|
||||
|
|
@ -237,11 +362,10 @@ class CliResponderConfig {
|
|||
throw FormatException('Responder "$id" must define command.');
|
||||
}
|
||||
|
||||
final args =
|
||||
(map['args'] as List?)
|
||||
?.map((item) => item.toString())
|
||||
.toList(growable: false) ??
|
||||
const <String>[];
|
||||
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()));
|
||||
|
|
@ -257,6 +381,12 @@ class CliResponderConfig {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _normalizeArgs(Iterable<String> args) {
|
||||
return args
|
||||
.map((arg) => arg == '--json-output' ? '--json' : arg)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantCapability {
|
||||
|
|
@ -298,6 +428,13 @@ String _expandHome(String path) {
|
|||
return p.normalize(p.join(home, path.substring(2)));
|
||||
}
|
||||
|
||||
String? _expandOptionalPath(String? path) {
|
||||
if (path == null || path.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _expandHome(path);
|
||||
}
|
||||
|
||||
String _deriveSessionPrefix(String key) {
|
||||
if (key.contains('-')) {
|
||||
return key
|
||||
|
|
@ -319,3 +456,5 @@ String _deriveSessionPrefix(String key) {
|
|||
|
||||
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
|
||||
}
|
||||
|
||||
enum _ConfigDialect { current, aoSubset }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
part 'database.g.dart';
|
||||
|
||||
enum JobStatus { queued, running, completed, failed, skipped }
|
||||
|
||||
class ProjectStates extends Table {
|
||||
TextColumn get projectKey => text()();
|
||||
TextColumn get repo => text()();
|
||||
|
|
@ -39,7 +41,7 @@ class Jobs extends Table {
|
|||
IntColumn get issueNumber => integer()();
|
||||
TextColumn get fingerprint => text()();
|
||||
TextColumn get trigger => text()();
|
||||
TextColumn get status => text()();
|
||||
TextColumn get status => textEnum<JobStatus>()();
|
||||
TextColumn get createdAt => text()();
|
||||
TextColumn get updatedAt => text()();
|
||||
}
|
||||
|
|
@ -179,7 +181,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
required int issueNumber,
|
||||
required String fingerprint,
|
||||
required String trigger,
|
||||
required String status,
|
||||
required JobStatus status,
|
||||
}) {
|
||||
final now = DateTime.now().toUtc().toIso8601String();
|
||||
return into(jobs).insert(
|
||||
|
|
@ -195,7 +197,7 @@ class AppDatabase extends _$AppDatabase {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> updateJobStatus(int jobId, String status) {
|
||||
Future<void> updateJobStatus(int jobId, JobStatus status) {
|
||||
return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write(
|
||||
JobsCompanion(
|
||||
status: Value(status),
|
||||
|
|
|
|||
|
|
@ -1004,15 +1004,15 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
|||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
static const VerificationMeta _statusMeta = const VerificationMeta('status');
|
||||
@override
|
||||
late final GeneratedColumn<String> status = GeneratedColumn<String>(
|
||||
late final GeneratedColumnWithTypeConverter<JobStatus, String> status =
|
||||
GeneratedColumn<String>(
|
||||
'status',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: true,
|
||||
);
|
||||
).withConverter<JobStatus>($JobsTable.$converterstatus);
|
||||
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||
'createdAt',
|
||||
);
|
||||
|
|
@ -1099,14 +1099,6 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
|||
} else if (isInserting) {
|
||||
context.missing(_triggerMeta);
|
||||
}
|
||||
if (data.containsKey('status')) {
|
||||
context.handle(
|
||||
_statusMeta,
|
||||
status.isAcceptableOrUnknown(data['status']!, _statusMeta),
|
||||
);
|
||||
} else if (isInserting) {
|
||||
context.missing(_statusMeta);
|
||||
}
|
||||
if (data.containsKey('created_at')) {
|
||||
context.handle(
|
||||
_createdAtMeta,
|
||||
|
|
@ -1152,10 +1144,12 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
|||
DriftSqlType.string,
|
||||
data['${effectivePrefix}trigger'],
|
||||
)!,
|
||||
status: attachedDatabase.typeMapping.read(
|
||||
status: $JobsTable.$converterstatus.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}status'],
|
||||
)!,
|
||||
),
|
||||
createdAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}created_at'],
|
||||
|
|
@ -1171,6 +1165,9 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
|||
$JobsTable createAlias(String alias) {
|
||||
return $JobsTable(attachedDatabase, alias);
|
||||
}
|
||||
|
||||
static JsonTypeConverter2<JobStatus, String, String> $converterstatus =
|
||||
const EnumNameConverter<JobStatus>(JobStatus.values);
|
||||
}
|
||||
|
||||
class Job extends DataClass implements Insertable<Job> {
|
||||
|
|
@ -1179,7 +1176,7 @@ class Job extends DataClass implements Insertable<Job> {
|
|||
final int issueNumber;
|
||||
final String fingerprint;
|
||||
final String trigger;
|
||||
final String status;
|
||||
final JobStatus status;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
const Job({
|
||||
|
|
@ -1200,7 +1197,11 @@ class Job extends DataClass implements Insertable<Job> {
|
|||
map['issue_number'] = Variable<int>(issueNumber);
|
||||
map['fingerprint'] = Variable<String>(fingerprint);
|
||||
map['trigger'] = Variable<String>(trigger);
|
||||
map['status'] = Variable<String>(status);
|
||||
{
|
||||
map['status'] = Variable<String>(
|
||||
$JobsTable.$converterstatus.toSql(status),
|
||||
);
|
||||
}
|
||||
map['created_at'] = Variable<String>(createdAt);
|
||||
map['updated_at'] = Variable<String>(updatedAt);
|
||||
return map;
|
||||
|
|
@ -1230,7 +1231,9 @@ class Job extends DataClass implements Insertable<Job> {
|
|||
issueNumber: serializer.fromJson<int>(json['issueNumber']),
|
||||
fingerprint: serializer.fromJson<String>(json['fingerprint']),
|
||||
trigger: serializer.fromJson<String>(json['trigger']),
|
||||
status: serializer.fromJson<String>(json['status']),
|
||||
status: $JobsTable.$converterstatus.fromJson(
|
||||
serializer.fromJson<String>(json['status']),
|
||||
),
|
||||
createdAt: serializer.fromJson<String>(json['createdAt']),
|
||||
updatedAt: serializer.fromJson<String>(json['updatedAt']),
|
||||
);
|
||||
|
|
@ -1244,7 +1247,9 @@ class Job extends DataClass implements Insertable<Job> {
|
|||
'issueNumber': serializer.toJson<int>(issueNumber),
|
||||
'fingerprint': serializer.toJson<String>(fingerprint),
|
||||
'trigger': serializer.toJson<String>(trigger),
|
||||
'status': serializer.toJson<String>(status),
|
||||
'status': serializer.toJson<String>(
|
||||
$JobsTable.$converterstatus.toJson(status),
|
||||
),
|
||||
'createdAt': serializer.toJson<String>(createdAt),
|
||||
'updatedAt': serializer.toJson<String>(updatedAt),
|
||||
};
|
||||
|
|
@ -1256,7 +1261,7 @@ class Job extends DataClass implements Insertable<Job> {
|
|||
int? issueNumber,
|
||||
String? fingerprint,
|
||||
String? trigger,
|
||||
String? status,
|
||||
JobStatus? status,
|
||||
String? createdAt,
|
||||
String? updatedAt,
|
||||
}) => Job(
|
||||
|
|
@ -1334,7 +1339,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
|||
final Value<int> issueNumber;
|
||||
final Value<String> fingerprint;
|
||||
final Value<String> trigger;
|
||||
final Value<String> status;
|
||||
final Value<JobStatus> status;
|
||||
final Value<String> createdAt;
|
||||
final Value<String> updatedAt;
|
||||
const JobsCompanion({
|
||||
|
|
@ -1353,7 +1358,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
|||
required int issueNumber,
|
||||
required String fingerprint,
|
||||
required String trigger,
|
||||
required String status,
|
||||
required JobStatus status,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
}) : projectKey = Value(projectKey),
|
||||
|
|
@ -1391,7 +1396,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
|||
Value<int>? issueNumber,
|
||||
Value<String>? fingerprint,
|
||||
Value<String>? trigger,
|
||||
Value<String>? status,
|
||||
Value<JobStatus>? status,
|
||||
Value<String>? createdAt,
|
||||
Value<String>? updatedAt,
|
||||
}) {
|
||||
|
|
@ -1426,7 +1431,9 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
|||
map['trigger'] = Variable<String>(trigger.value);
|
||||
}
|
||||
if (status.present) {
|
||||
map['status'] = Variable<String>(status.value);
|
||||
map['status'] = Variable<String>(
|
||||
$JobsTable.$converterstatus.toSql(status.value),
|
||||
);
|
||||
}
|
||||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<String>(createdAt.value);
|
||||
|
|
@ -3500,7 +3507,7 @@ typedef $$JobsTableCreateCompanionBuilder =
|
|||
required int issueNumber,
|
||||
required String fingerprint,
|
||||
required String trigger,
|
||||
required String status,
|
||||
required JobStatus status,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
});
|
||||
|
|
@ -3511,7 +3518,7 @@ typedef $$JobsTableUpdateCompanionBuilder =
|
|||
Value<int> issueNumber,
|
||||
Value<String> fingerprint,
|
||||
Value<String> trigger,
|
||||
Value<String> status,
|
||||
Value<JobStatus> status,
|
||||
Value<String> createdAt,
|
||||
Value<String> updatedAt,
|
||||
});
|
||||
|
|
@ -3549,9 +3556,10 @@ class $$JobsTableFilterComposer extends Composer<_$AppDatabase, $JobsTable> {
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get status => $composableBuilder(
|
||||
ColumnWithTypeConverterFilters<JobStatus, JobStatus, String> get status =>
|
||||
$composableBuilder(
|
||||
column: $table.status,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
builder: (column) => ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get createdAt => $composableBuilder(
|
||||
|
|
@ -3644,7 +3652,7 @@ class $$JobsTableAnnotationComposer
|
|||
GeneratedColumn<String> get trigger =>
|
||||
$composableBuilder(column: $table.trigger, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get status =>
|
||||
GeneratedColumnWithTypeConverter<JobStatus, String> get status =>
|
||||
$composableBuilder(column: $table.status, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get createdAt =>
|
||||
|
|
@ -3687,7 +3695,7 @@ class $$JobsTableTableManager
|
|||
Value<int> issueNumber = const Value.absent(),
|
||||
Value<String> fingerprint = const Value.absent(),
|
||||
Value<String> trigger = const Value.absent(),
|
||||
Value<String> status = const Value.absent(),
|
||||
Value<JobStatus> status = const Value.absent(),
|
||||
Value<String> createdAt = const Value.absent(),
|
||||
Value<String> updatedAt = const Value.absent(),
|
||||
}) => JobsCompanion(
|
||||
|
|
@ -3707,7 +3715,7 @@ class $$JobsTableTableManager
|
|||
required int issueNumber,
|
||||
required String fingerprint,
|
||||
required String trigger,
|
||||
required String status,
|
||||
required JobStatus status,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
}) => JobsCompanion.insert(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'cli_responder.dart';
|
||||
import 'config.dart';
|
||||
|
|
@ -10,6 +12,8 @@ import 'github_client.dart';
|
|||
import 'workspace_manager.dart';
|
||||
|
||||
class IssueAssistantApp {
|
||||
static final Logger _logger = Logger('code_work_spawner.app');
|
||||
|
||||
IssueAssistantApp._({
|
||||
required this.config,
|
||||
required this.database,
|
||||
|
|
@ -35,7 +39,7 @@ class IssueAssistantApp {
|
|||
database: database,
|
||||
githubClient: GitHubClient(ghCommand: ghCommand),
|
||||
responderRunner: CliResponderRunner(),
|
||||
workspaceManager: WorkspaceManager(),
|
||||
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -47,12 +51,15 @@ class IssueAssistantApp {
|
|||
}
|
||||
|
||||
Future<void> runOnce() async {
|
||||
_log('starting poll cycle projects=${config.projects.length}');
|
||||
for (final project in config.projects.values) {
|
||||
if (!project.issueAssistant.enabled) {
|
||||
_log('skip project=${project.key} reason=issue_assistant_disabled');
|
||||
continue;
|
||||
}
|
||||
await _pollProject(project);
|
||||
}
|
||||
_log('poll cycle completed');
|
||||
}
|
||||
|
||||
Future<void> close() => database.close();
|
||||
|
|
@ -70,14 +77,22 @@ class IssueAssistantApp {
|
|||
Future<void> _pollProject(ProjectConfig project) async {
|
||||
final pollRunId = await database.startPollRun(project.key);
|
||||
try {
|
||||
final repoDirectory = Directory(project.path);
|
||||
final repoExists = await repoDirectory.exists();
|
||||
final projectState = await database.findProjectState(project.key);
|
||||
final since = projectState?.lastSeenUpdatedAt == null
|
||||
? null
|
||||
: DateTime.parse(projectState!.lastSeenUpdatedAt!);
|
||||
_log(
|
||||
'poll project=${project.key} repo=${project.repo} '
|
||||
'path=${project.path} path_exists=$repoExists '
|
||||
'since=${since?.toUtc().toIso8601String() ?? "initial"}',
|
||||
);
|
||||
final issues = await githubClient.fetchUpdatedIssues(
|
||||
repo: project.repo,
|
||||
since: since,
|
||||
);
|
||||
_log('project=${project.key} fetched_issues=${issues.length}');
|
||||
|
||||
DateTime? latestSeen = since;
|
||||
for (final issue in issues) {
|
||||
|
|
@ -93,12 +108,17 @@ class IssueAssistantApp {
|
|||
lastSeenUpdatedAt: latestSeen,
|
||||
);
|
||||
await database.finishPollRun(pollRunId: pollRunId, success: true);
|
||||
_log(
|
||||
'poll project=${project.key} completed latest_seen='
|
||||
'${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}',
|
||||
);
|
||||
} catch (error) {
|
||||
await database.finishPollRun(
|
||||
pollRunId: pollRunId,
|
||||
success: false,
|
||||
error: error.toString(),
|
||||
);
|
||||
_log('poll project=${project.key} failed error=$error');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
|
@ -114,16 +134,23 @@ class IssueAssistantApp {
|
|||
final fingerprint = _fingerprintThread(thread);
|
||||
final record = await database.findIssueThread(project.key, issue.number);
|
||||
if (record?.lastEvaluatedFingerprint == fingerprint) {
|
||||
_log(
|
||||
'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final trigger = _determineTrigger(project, thread);
|
||||
_log(
|
||||
'evaluate issue=${project.key}#${issue.number} '
|
||||
'comments=${thread.comments.length} trigger=$trigger',
|
||||
);
|
||||
final jobId = await database.createJob(
|
||||
projectKey: project.key,
|
||||
issueNumber: issue.number,
|
||||
fingerprint: fingerprint,
|
||||
trigger: trigger,
|
||||
status: 'queued',
|
||||
status: JobStatus.queued,
|
||||
);
|
||||
|
||||
await database.upsertIssueThread(
|
||||
|
|
@ -137,7 +164,7 @@ class IssueAssistantApp {
|
|||
lastCommentUrl: record?.lastCommentUrl,
|
||||
);
|
||||
|
||||
await database.updateJobStatus(jobId, 'running');
|
||||
await database.updateJobStatus(jobId, JobStatus.running);
|
||||
final forceReply = trigger == 'mention';
|
||||
final prompt = _buildPrompt(
|
||||
project: project,
|
||||
|
|
@ -145,6 +172,25 @@ class IssueAssistantApp {
|
|||
trigger: trigger,
|
||||
);
|
||||
|
||||
if (project.issueAssistant.responders.isEmpty) {
|
||||
_logError(
|
||||
'issue=${project.key}#${issue.number} '
|
||||
'skipped no_responders_configured',
|
||||
);
|
||||
await database.upsertIssueThread(
|
||||
projectKey: project.key,
|
||||
issueNumber: issue.number,
|
||||
fingerprint: fingerprint,
|
||||
updatedAt: issue.updatedAt,
|
||||
lastEvaluatedFingerprint: fingerprint,
|
||||
lastDecision: record?.lastDecision,
|
||||
lastCommentId: record?.lastCommentId,
|
||||
lastCommentUrl: record?.lastCommentUrl,
|
||||
);
|
||||
await database.updateJobStatus(jobId, JobStatus.skipped);
|
||||
return;
|
||||
}
|
||||
|
||||
ResponderResult? selectedResult;
|
||||
String? workspacePath;
|
||||
Object? lastError;
|
||||
|
|
@ -154,6 +200,11 @@ class IssueAssistantApp {
|
|||
workspacePath = needsWorktree
|
||||
? await workspaceManager.createEphemeralWorktree(project.path)
|
||||
: project.path;
|
||||
_log(
|
||||
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||
'workspace=$workspacePath',
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await responderRunner.run(
|
||||
|
|
@ -174,10 +225,19 @@ class IssueAssistantApp {
|
|||
stdout: result.rawStdout,
|
||||
stderr: result.rawStderr,
|
||||
);
|
||||
_log(
|
||||
'responder=${responder.id}b issue=${project.key}#${issue.number}, '
|
||||
'title="${thread.issue.title}", '
|
||||
'decision=${result.decision}, mode=${result.mode}',
|
||||
);
|
||||
selectedResult = result;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
_log(
|
||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||
'failed error=$error',
|
||||
);
|
||||
await database.addExecutionRun(
|
||||
jobId: jobId,
|
||||
responderId: responder.id,
|
||||
|
|
@ -196,7 +256,10 @@ class IssueAssistantApp {
|
|||
}
|
||||
|
||||
if (selectedResult == null) {
|
||||
await database.updateJobStatus(jobId, 'failed');
|
||||
await database.updateJobStatus(jobId, JobStatus.failed);
|
||||
_log(
|
||||
'issue=${project.key}#${issue.number} failed no_responder_succeeded',
|
||||
);
|
||||
throw StateError(
|
||||
'All responders failed for ${project.key}#${issue.number}: $lastError',
|
||||
);
|
||||
|
|
@ -213,7 +276,8 @@ class IssueAssistantApp {
|
|||
lastCommentId: record?.lastCommentId,
|
||||
lastCommentUrl: record?.lastCommentUrl,
|
||||
);
|
||||
await database.updateJobStatus(jobId, 'completed');
|
||||
await database.updateJobStatus(jobId, JobStatus.completed);
|
||||
_log('issue=${project.key}#${issue.number} completed decision=no_reply');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +310,8 @@ class IssueAssistantApp {
|
|||
lastCommentId: posted.id,
|
||||
lastCommentUrl: posted.url,
|
||||
);
|
||||
await database.updateJobStatus(jobId, 'completed');
|
||||
await database.updateJobStatus(jobId, JobStatus.completed);
|
||||
_log('issue=${project.key}#${issue.number} posted_comment_id=${posted.id}');
|
||||
}
|
||||
|
||||
String _buildPrompt({
|
||||
|
|
@ -320,4 +385,12 @@ class IssueAssistantApp {
|
|||
}) {
|
||||
return '$markdown\n\n<!-- $tag:$fingerprint -->';
|
||||
}
|
||||
|
||||
void _log(String message) {
|
||||
_logger.info(message);
|
||||
}
|
||||
|
||||
void _logError(String message) {
|
||||
_logger.severe(message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,24 @@ import 'dart:io';
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
class WorkspaceManager {
|
||||
WorkspaceManager({this.worktreeRoot});
|
||||
|
||||
final String? worktreeRoot;
|
||||
static const Set<String> _gitContextEnvironmentKeys = {
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_COMMON_DIR',
|
||||
'GIT_DIR',
|
||||
'GIT_IMPLICIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_PREFIX',
|
||||
'GIT_WORK_TREE',
|
||||
};
|
||||
|
||||
Future<String> createEphemeralWorktree(String projectPath) async {
|
||||
final parent = await Directory.systemTemp.createTemp('cws-worktree-');
|
||||
final parent = await _createWorktreeParent();
|
||||
final workspacePath = p.join(parent.path, 'repo');
|
||||
final result = await Process.run('git', [
|
||||
final command = [
|
||||
'-C',
|
||||
projectPath,
|
||||
'worktree',
|
||||
|
|
@ -14,20 +28,13 @@ class WorkspaceManager {
|
|||
'--detach',
|
||||
workspacePath,
|
||||
'HEAD',
|
||||
]);
|
||||
];
|
||||
final result = await _runGit(command);
|
||||
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
'git',
|
||||
[
|
||||
'-C',
|
||||
projectPath,
|
||||
'worktree',
|
||||
'add',
|
||||
'--detach',
|
||||
workspacePath,
|
||||
'HEAD',
|
||||
],
|
||||
command,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
|
|
@ -36,8 +43,23 @@ class WorkspaceManager {
|
|||
return workspacePath;
|
||||
}
|
||||
|
||||
Future<Directory> _createWorktreeParent() async {
|
||||
if (worktreeRoot == null) {
|
||||
return Directory.systemTemp.createTemp('cws-worktree-');
|
||||
}
|
||||
|
||||
final root = Directory(worktreeRoot!);
|
||||
await root.create(recursive: true);
|
||||
return Directory(
|
||||
p.join(
|
||||
root.path,
|
||||
'cws-worktree-${DateTime.now().microsecondsSinceEpoch}',
|
||||
),
|
||||
)..createSync();
|
||||
}
|
||||
|
||||
Future<void> disposeEphemeralWorktree(String workspacePath) async {
|
||||
final result = await Process.run('git', [
|
||||
final result = await _runGit([
|
||||
'-C',
|
||||
workspacePath,
|
||||
'worktree',
|
||||
|
|
@ -59,4 +81,15 @@ class WorkspaceManager {
|
|||
await tempRoot.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runGit(List<String> arguments) {
|
||||
final environment = Map<String, String>.from(Platform.environment)
|
||||
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
|
||||
return Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
environment: environment,
|
||||
includeParentEnvironment: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ dependencies:
|
|||
path: ^1.9.0
|
||||
sqlite3: ^2.9.3
|
||||
yaml: ^3.1.3
|
||||
result_dart: ^2.1.1
|
||||
dotenv: ^4.2.0
|
||||
logging: ^1.3.0
|
||||
meta: ^1.16.0
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.6.0
|
||||
|
|
|
|||
|
|
@ -66,6 +66,161 @@ projects:
|
|||
'project {{project_key}}',
|
||||
);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Normalize legacy codex json flag
|
||||
/// Given an orchestrator config that still uses the legacy codex --json-output flag
|
||||
/// When loading the app config from disk
|
||||
/// Then the responder args are normalized to the current codex --json flag
|
||||
/// ```
|
||||
test('Normalize legacy codex json flag', () async {
|
||||
// Given an orchestrator config that still uses the legacy codex --json-output flag.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-codex-args-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
defaults:
|
||||
issueAssistant:
|
||||
responders:
|
||||
- id: codex
|
||||
command: codex
|
||||
args: ["exec", "--json-output"]
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
|
||||
// Then the responder args are normalized to the current codex --json flag.
|
||||
expect(config.projects['sample']!.issueAssistant.responders.single.args, [
|
||||
'exec',
|
||||
'--json',
|
||||
]);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load AO subset config with shared worktree root
|
||||
/// Given an AO style shortcut config with dataDir, worktreeDir, and project defaults
|
||||
/// 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
|
||||
/// ```
|
||||
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-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
defaults:
|
||||
defaultBranch: trunk
|
||||
sessionPrefix: ao
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
|
||||
// Then the app exposes the configured data and worktree directories.
|
||||
expect(
|
||||
config.dataDir,
|
||||
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
|
||||
);
|
||||
expect(
|
||||
config.worktreeDir,
|
||||
p.join(Platform.environment['HOME']!, '.worktrees'),
|
||||
);
|
||||
|
||||
// And each project inherits the supported shared defaults.
|
||||
expect(config.projects['sample'], isNotNull);
|
||||
expect(config.projects['sample']!.defaultBranch, 'trunk');
|
||||
expect(config.projects['sample']!.sessionPrefix, 'ao');
|
||||
expect(config.projects['sample']!.issueAssistant.enabled, isTrue);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Reject unsupported AO fields
|
||||
/// Given an AO style config that includes an unsupported field
|
||||
/// 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-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
worktreeDir: ~/.worktrees
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${tempDir.path}
|
||||
tracker:
|
||||
plugin: github
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
final load = AppConfig.load(configFile.path);
|
||||
|
||||
// Then loading fails with a clear config error.
|
||||
await expectLater(
|
||||
load,
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
contains('Unsupported config field: projects.sample.tracker'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Feature: Worktree management
|
||||
///
|
||||
/// As a maintainer running bug verification jobs
|
||||
/// I want ephemeral git worktrees to respect the configured worktree root
|
||||
/// So that temporary workspaces are created in a predictable location
|
||||
/// ```
|
||||
group('WorkspaceManager.createEphemeralWorktree', () {
|
||||
/// ```gherkin
|
||||
/// Scenario: Create and dispose a worktree under the configured root
|
||||
/// Given a git repository and a dedicated worktree root directory
|
||||
/// When creating and then disposing an ephemeral worktree
|
||||
/// Then the worktree is created beneath the configured root
|
||||
/// And cleanup removes the generated worktree directory without deleting the root
|
||||
/// ```
|
||||
test('Create and dispose a worktree under the configured root', () async {
|
||||
// Given a git repository and a dedicated worktree root directory.
|
||||
final sandbox = await Directory.systemTemp.createTemp(
|
||||
'cws-worktree-root-',
|
||||
);
|
||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
final worktreeRoot = await Directory(
|
||||
p.join(sandbox.path, 'worktrees'),
|
||||
).create();
|
||||
await _initGitRepo(repoDir);
|
||||
final manager = WorkspaceManager(worktreeRoot: worktreeRoot.path);
|
||||
|
||||
// When creating and then disposing an ephemeral worktree.
|
||||
final workspacePath = await manager.createEphemeralWorktree(repoDir.path);
|
||||
final workspaceDirectory = Directory(workspacePath);
|
||||
final parentDirectory = Directory(p.dirname(workspacePath));
|
||||
await manager.disposeEphemeralWorktree(workspacePath);
|
||||
|
||||
// Then the worktree is created beneath the configured root.
|
||||
expect(p.isWithin(worktreeRoot.path, workspacePath), isTrue);
|
||||
|
||||
// And cleanup removes the generated worktree directory without deleting the root.
|
||||
expect(await workspaceDirectory.exists(), isFalse);
|
||||
expect(await parentDirectory.exists(), isFalse);
|
||||
expect(await worktreeRoot.exists(), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
|
|
@ -268,6 +423,181 @@ projects:
|
|||
expect(thread.lastCommentId, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Accept codex style jsonl responder output
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And GitHub issue data containing an explicit assistant mention
|
||||
/// And a responder that emits codex style jsonl events instead of a single json object
|
||||
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||
/// When the app processes one polling cycle
|
||||
/// Then it extracts the final agent message and posts one reply
|
||||
/// ```
|
||||
test('Accept codex style jsonl responder output', () async {
|
||||
// Given a temporary project checkout that can be used as the local repo path.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-jsonl-run-');
|
||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
await _initGitRepo(repoDir);
|
||||
|
||||
// And GitHub issue data containing an explicit assistant mention.
|
||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||
final issueData = [
|
||||
{
|
||||
'number': 18,
|
||||
'title': 'Need help',
|
||||
'body': 'Please review the plan.',
|
||||
'state': 'open',
|
||||
'html_url': 'https://example.test/issues/18',
|
||||
'updated_at': '2026-04-04T15:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
'labels': const [],
|
||||
},
|
||||
];
|
||||
final commentsData = [
|
||||
{
|
||||
'id': 180,
|
||||
'body': '@helper can you respond?',
|
||||
'html_url': 'https://example.test/issues/18#issuecomment-180',
|
||||
'created_at': '2026-04-04T15:00:00Z',
|
||||
'updated_at': '2026-04-04T15:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
},
|
||||
];
|
||||
await _writeFakeGhScript(
|
||||
ghScript: ghScript,
|
||||
issueListResponse: issueData,
|
||||
commentResponses: {18: commentsData},
|
||||
postCommentIssueNumber: 18,
|
||||
ghLog: ghLog,
|
||||
);
|
||||
|
||||
// And a responder that emits codex style jsonl events instead of a single json object.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder-jsonl.sh'));
|
||||
await _writeJsonlResponderScript(responderScript, {
|
||||
'decision': 'reply',
|
||||
'mode': 'planning',
|
||||
'markdown': 'Codex JSONL reply',
|
||||
'summary': 'parsed event stream',
|
||||
});
|
||||
|
||||
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
defaults:
|
||||
issueAssistant:
|
||||
enabled: true
|
||||
pollInterval: 5m
|
||||
mentionTriggers: ["@helper"]
|
||||
responders:
|
||||
- id: primary
|
||||
command: ${responderScript.path}
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
''');
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
await app.runOnce();
|
||||
await app.close();
|
||||
|
||||
// Then it extracts the final agent message and posts one reply.
|
||||
final logLines = await ghLog.readAsLines();
|
||||
expect(
|
||||
logLines
|
||||
.where(
|
||||
(line) => line.contains('repos/owner/sample/issues/18/comments'),
|
||||
)
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Skip issue evaluation when no responders are configured
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And GitHub issue data with a comment that would otherwise be evaluated
|
||||
/// And an AO subset config that enables the assistant without defining responders
|
||||
/// When the app processes one polling cycle
|
||||
/// Then it completes without throwing an error
|
||||
/// And it records the issue thread as evaluated without a responder decision
|
||||
/// And it stores the job status as skipped
|
||||
/// ```
|
||||
test('Skip issue evaluation when no responders are configured', () async {
|
||||
// Given a temporary project checkout that can be used as the local repo path.
|
||||
final sandbox = await Directory.systemTemp.createTemp(
|
||||
'cws-no-responders-',
|
||||
);
|
||||
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||
await _initGitRepo(repoDir);
|
||||
|
||||
// And GitHub issue data with a comment that would otherwise be evaluated.
|
||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||
final issueData = [
|
||||
{
|
||||
'number': 7,
|
||||
'title': 'Need help',
|
||||
'body': 'Please review this thread.',
|
||||
'state': 'open',
|
||||
'html_url': 'https://example.test/issues/7',
|
||||
'updated_at': '2026-04-04T14:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
'labels': const [],
|
||||
},
|
||||
];
|
||||
final commentsData = [
|
||||
{
|
||||
'id': 70,
|
||||
'body': '@helper can you take a look?',
|
||||
'html_url': 'https://example.test/issues/7#issuecomment-70',
|
||||
'created_at': '2026-04-04T14:00:00Z',
|
||||
'updated_at': '2026-04-04T14:00:00Z',
|
||||
'user': {'login': 'reporter'},
|
||||
},
|
||||
];
|
||||
await _writeFakeGhScript(
|
||||
ghScript: ghScript,
|
||||
issueListResponse: issueData,
|
||||
commentResponses: {7: commentsData},
|
||||
);
|
||||
|
||||
// And an AO subset config that enables the assistant without defining responders.
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
''');
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
await app.runOnce();
|
||||
final thread = await app.database.findIssueThread('sample', 7);
|
||||
final jobs = await app.database.select(app.database.jobs).get();
|
||||
await app.close();
|
||||
|
||||
// Then it completes without throwing an error.
|
||||
expect(thread, isNotNull);
|
||||
|
||||
// And it records the issue thread as evaluated without a responder decision.
|
||||
expect(thread!.lastDecision, isNull);
|
||||
expect(thread.lastEvaluatedFingerprint, thread.fingerprint);
|
||||
|
||||
// And it stores the job status as skipped.
|
||||
expect(jobs, hasLength(1));
|
||||
expect(jobs.single.status, JobStatus.skipped);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -353,23 +683,62 @@ JSON
|
|||
await Process.run('chmod', ['+x', responderScript.path]);
|
||||
}
|
||||
|
||||
Future<void> _writeJsonlResponderScript(
|
||||
File responderScript,
|
||||
Map<String, Object?> response,
|
||||
) async {
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat >/dev/null
|
||||
cat <<'JSON'
|
||||
{"type":"thread.started","thread_id":"test-thread"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
|
||||
{"type":"turn.completed"}
|
||||
JSON
|
||||
''');
|
||||
await Process.run('chmod', ['+x', responderScript.path]);
|
||||
}
|
||||
|
||||
Future<void> _initGitRepo(Directory directory) async {
|
||||
await Process.run('git', ['init'], workingDirectory: directory.path);
|
||||
await Process.run('git', [
|
||||
await _runGit(['init'], workingDirectory: directory.path);
|
||||
await _runGit([
|
||||
'config',
|
||||
'user.email',
|
||||
'test@example.com',
|
||||
], workingDirectory: directory.path);
|
||||
await Process.run('git', [
|
||||
await _runGit([
|
||||
'config',
|
||||
'user.name',
|
||||
'Test User',
|
||||
], workingDirectory: directory.path);
|
||||
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
|
||||
await Process.run('git', ['add', '.'], workingDirectory: directory.path);
|
||||
await Process.run('git', [
|
||||
'commit',
|
||||
'-m',
|
||||
'init',
|
||||
], workingDirectory: directory.path);
|
||||
await _runGit(['add', '.'], workingDirectory: directory.path);
|
||||
await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
|
||||
}
|
||||
|
||||
const Set<String> _gitContextEnvironmentKeys = {
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
||||
'GIT_COMMON_DIR',
|
||||
'GIT_DIR',
|
||||
'GIT_IMPLICIT_WORK_TREE',
|
||||
'GIT_INDEX_FILE',
|
||||
'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_PREFIX',
|
||||
'GIT_WORK_TREE',
|
||||
};
|
||||
|
||||
Future<ProcessResult> _runGit(
|
||||
List<String> arguments, {
|
||||
required String workingDirectory,
|
||||
}) {
|
||||
final environment = Map<String, String>.from(Platform.environment)
|
||||
..removeWhere((key, _) => _gitContextEnvironmentKeys.contains(key));
|
||||
return Process.run(
|
||||
'git',
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeParentEnvironment: false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue