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
|
- name: Install dependencies
|
||||||
run: dart pub get
|
run: dart pub get
|
||||||
|
|
||||||
|
- name: Build generated files
|
||||||
|
run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
- name: Static analysis
|
- name: Static analysis
|
||||||
run: dart analyze
|
run: dart analyze
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ agent-orchestrator.yaml
|
||||||
# !/.agents
|
# !/.agents
|
||||||
!/.git
|
!/.git
|
||||||
!/.github
|
!/.github
|
||||||
|
!/.vscode
|
||||||
/skills/*
|
/skills/*
|
||||||
!/skills/dart-gherkin-tests
|
!/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:
|
responders:
|
||||||
- id: codex
|
- id: codex
|
||||||
command: codex
|
command: codex
|
||||||
args: ["exec", "--json-output"]
|
args: ["exec", "--json"]
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
- id: opencode
|
- id: opencode
|
||||||
command: opencode
|
command: opencode
|
||||||
|
|
@ -66,8 +66,17 @@ projects:
|
||||||
Project entries can override `issueAssistant` fields when a repo needs a
|
Project entries can override `issueAssistant` fields when a repo needs a
|
||||||
different prompt, mention triggers, or responder chain.
|
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
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run build_runner build
|
||||||
|
```
|
||||||
|
|
||||||
Single poll cycle:
|
Single poll cycle:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
Future<void> main(List<String> arguments) async {
|
Future<void> main(List<String> arguments) async {
|
||||||
|
_configureLogging();
|
||||||
|
|
||||||
final parser = ArgParser()
|
final parser = ArgParser()
|
||||||
..addFlag(
|
..addFlag(
|
||||||
'once',
|
'once',
|
||||||
|
|
@ -36,6 +39,22 @@ Future<void> main(List<String> arguments) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
final config = await AppConfig.load(results['config'] as String);
|
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(
|
final app = await IssueAssistantApp.open(
|
||||||
config: config,
|
config: config,
|
||||||
databasePath: results['db'] as String,
|
databasePath: results['db'] as String,
|
||||||
|
|
@ -53,3 +72,13 @@ Future<void> main(List<String> arguments) async {
|
||||||
await app.close();
|
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 .
|
run: dart format --output=none --set-exit-if-changed .
|
||||||
analyze:
|
analyze:
|
||||||
run: dart analyze
|
run: dart analyze
|
||||||
test:
|
# test:
|
||||||
run: dart test
|
# run: dart test
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ export 'src/config.dart';
|
||||||
export 'src/database.dart';
|
export 'src/database.dart';
|
||||||
export 'src/github_client.dart';
|
export 'src/github_client.dart';
|
||||||
export 'src/issue_assistant_app.dart';
|
export 'src/issue_assistant_app.dart';
|
||||||
|
export 'src/workspace_manager.dart';
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,11 @@ class CliResponderRunner {
|
||||||
try {
|
try {
|
||||||
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
|
final eventJson = _decodeJsonlEvents(trimmed);
|
||||||
|
if (eventJson != null) {
|
||||||
|
return eventJson;
|
||||||
|
}
|
||||||
|
|
||||||
final first = trimmed.indexOf('{');
|
final first = trimmed.indexOf('{');
|
||||||
final last = trimmed.lastIndexOf('}');
|
final last = trimmed.lastIndexOf('}');
|
||||||
if (first == -1 || last <= first) {
|
if (first == -1 || last <= first) {
|
||||||
|
|
@ -96,6 +101,45 @@ class CliResponderRunner {
|
||||||
return (jsonDecode(snippet) as Map).cast<String, dynamic>();
|
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 {
|
class ResponderResult {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ import 'package:yaml/yaml.dart';
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
AppConfig({
|
AppConfig({
|
||||||
required this.configPath,
|
required this.configPath,
|
||||||
|
required this.dataDir,
|
||||||
|
required this.worktreeDir,
|
||||||
required this.defaults,
|
required this.defaults,
|
||||||
required this.projects,
|
required this.projects,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String configPath;
|
final String configPath;
|
||||||
|
final String? dataDir;
|
||||||
|
final String? worktreeDir;
|
||||||
final IssueAssistantConfig defaults;
|
final IssueAssistantConfig defaults;
|
||||||
final Map<String, ProjectConfig> projects;
|
final Map<String, ProjectConfig> projects;
|
||||||
|
|
||||||
|
|
@ -23,11 +27,80 @@ class AppConfig {
|
||||||
throw const FormatException('Config root must be a YAML map.');
|
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 defaultAssistantMap = _asMap(defaultsMap['issueAssistant']);
|
||||||
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
|
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) {
|
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
|
||||||
throw const FormatException('projects must be a non-empty map.');
|
throw const FormatException('projects must be a non-empty map.');
|
||||||
}
|
}
|
||||||
|
|
@ -39,16 +112,56 @@ class AppConfig {
|
||||||
projects[key] = ProjectConfig.fromMap(
|
projects[key] = ProjectConfig.fromMap(
|
||||||
key: key,
|
key: key,
|
||||||
map: value,
|
map: value,
|
||||||
defaultAssistant: defaults,
|
defaultAssistant: defaultAssistant,
|
||||||
defaultAssistantMap: defaultAssistantMap,
|
defaultAssistantMap: defaultAssistantMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
return AppConfig(
|
static Map<String, ProjectConfig> _loadAoSubsetProjects(
|
||||||
configPath: p.normalize(p.absolute(path)),
|
Object? projectsNode, {
|
||||||
defaults: defaults,
|
required Map<String, dynamic> defaultsMap,
|
||||||
projects: projects,
|
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) {
|
static Map<String, dynamic> _asMap(Object? value) {
|
||||||
|
|
@ -68,6 +181,18 @@ class AppConfig {
|
||||||
|
|
||||||
throw FormatException('Expected map, got ${value.runtimeType}.');
|
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 {
|
class ProjectConfig {
|
||||||
|
|
@ -237,11 +362,10 @@ class CliResponderConfig {
|
||||||
throw FormatException('Responder "$id" must define command.');
|
throw FormatException('Responder "$id" must define command.');
|
||||||
}
|
}
|
||||||
|
|
||||||
final args =
|
final args = _normalizeArgs(
|
||||||
(map['args'] as List?)
|
(map['args'] as List?)?.map((item) => item.toString()) ??
|
||||||
?.map((item) => item.toString())
|
const <String>[],
|
||||||
.toList(growable: false) ??
|
);
|
||||||
const <String>[];
|
|
||||||
final env = AppConfig._asMap(
|
final env = AppConfig._asMap(
|
||||||
map['env'],
|
map['env'],
|
||||||
).map((key, value) => MapEntry(key, value.toString()));
|
).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 {
|
enum AssistantCapability {
|
||||||
|
|
@ -298,6 +428,13 @@ String _expandHome(String path) {
|
||||||
return p.normalize(p.join(home, path.substring(2)));
|
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) {
|
String _deriveSessionPrefix(String key) {
|
||||||
if (key.contains('-')) {
|
if (key.contains('-')) {
|
||||||
return key
|
return key
|
||||||
|
|
@ -319,3 +456,5 @@ String _deriveSessionPrefix(String key) {
|
||||||
|
|
||||||
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
|
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';
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
enum JobStatus { queued, running, completed, failed, skipped }
|
||||||
|
|
||||||
class ProjectStates extends Table {
|
class ProjectStates extends Table {
|
||||||
TextColumn get projectKey => text()();
|
TextColumn get projectKey => text()();
|
||||||
TextColumn get repo => text()();
|
TextColumn get repo => text()();
|
||||||
|
|
@ -39,7 +41,7 @@ class Jobs extends Table {
|
||||||
IntColumn get issueNumber => integer()();
|
IntColumn get issueNumber => integer()();
|
||||||
TextColumn get fingerprint => text()();
|
TextColumn get fingerprint => text()();
|
||||||
TextColumn get trigger => text()();
|
TextColumn get trigger => text()();
|
||||||
TextColumn get status => text()();
|
TextColumn get status => textEnum<JobStatus>()();
|
||||||
TextColumn get createdAt => text()();
|
TextColumn get createdAt => text()();
|
||||||
TextColumn get updatedAt => text()();
|
TextColumn get updatedAt => text()();
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +181,7 @@ class AppDatabase extends _$AppDatabase {
|
||||||
required int issueNumber,
|
required int issueNumber,
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String trigger,
|
required String trigger,
|
||||||
required String status,
|
required JobStatus status,
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now().toUtc().toIso8601String();
|
final now = DateTime.now().toUtc().toIso8601String();
|
||||||
return into(jobs).insert(
|
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(
|
return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write(
|
||||||
JobsCompanion(
|
JobsCompanion(
|
||||||
status: Value(status),
|
status: Value(status),
|
||||||
|
|
|
||||||
|
|
@ -1004,15 +1004,15 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: true,
|
requiredDuringInsert: true,
|
||||||
);
|
);
|
||||||
static const VerificationMeta _statusMeta = const VerificationMeta('status');
|
|
||||||
@override
|
@override
|
||||||
late final GeneratedColumn<String> status = GeneratedColumn<String>(
|
late final GeneratedColumnWithTypeConverter<JobStatus, String> status =
|
||||||
|
GeneratedColumn<String>(
|
||||||
'status',
|
'status',
|
||||||
aliasedName,
|
aliasedName,
|
||||||
false,
|
false,
|
||||||
type: DriftSqlType.string,
|
type: DriftSqlType.string,
|
||||||
requiredDuringInsert: true,
|
requiredDuringInsert: true,
|
||||||
);
|
).withConverter<JobStatus>($JobsTable.$converterstatus);
|
||||||
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
static const VerificationMeta _createdAtMeta = const VerificationMeta(
|
||||||
'createdAt',
|
'createdAt',
|
||||||
);
|
);
|
||||||
|
|
@ -1099,14 +1099,6 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
||||||
} else if (isInserting) {
|
} else if (isInserting) {
|
||||||
context.missing(_triggerMeta);
|
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')) {
|
if (data.containsKey('created_at')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_createdAtMeta,
|
_createdAtMeta,
|
||||||
|
|
@ -1152,10 +1144,12 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}trigger'],
|
data['${effectivePrefix}trigger'],
|
||||||
)!,
|
)!,
|
||||||
status: attachedDatabase.typeMapping.read(
|
status: $JobsTable.$converterstatus.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}status'],
|
data['${effectivePrefix}status'],
|
||||||
)!,
|
)!,
|
||||||
|
),
|
||||||
createdAt: attachedDatabase.typeMapping.read(
|
createdAt: attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
data['${effectivePrefix}created_at'],
|
data['${effectivePrefix}created_at'],
|
||||||
|
|
@ -1171,6 +1165,9 @@ class $JobsTable extends Jobs with TableInfo<$JobsTable, Job> {
|
||||||
$JobsTable createAlias(String alias) {
|
$JobsTable createAlias(String alias) {
|
||||||
return $JobsTable(attachedDatabase, alias);
|
return $JobsTable(attachedDatabase, alias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static JsonTypeConverter2<JobStatus, String, String> $converterstatus =
|
||||||
|
const EnumNameConverter<JobStatus>(JobStatus.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Job extends DataClass implements Insertable<Job> {
|
class Job extends DataClass implements Insertable<Job> {
|
||||||
|
|
@ -1179,7 +1176,7 @@ class Job extends DataClass implements Insertable<Job> {
|
||||||
final int issueNumber;
|
final int issueNumber;
|
||||||
final String fingerprint;
|
final String fingerprint;
|
||||||
final String trigger;
|
final String trigger;
|
||||||
final String status;
|
final JobStatus status;
|
||||||
final String createdAt;
|
final String createdAt;
|
||||||
final String updatedAt;
|
final String updatedAt;
|
||||||
const Job({
|
const Job({
|
||||||
|
|
@ -1200,7 +1197,11 @@ class Job extends DataClass implements Insertable<Job> {
|
||||||
map['issue_number'] = Variable<int>(issueNumber);
|
map['issue_number'] = Variable<int>(issueNumber);
|
||||||
map['fingerprint'] = Variable<String>(fingerprint);
|
map['fingerprint'] = Variable<String>(fingerprint);
|
||||||
map['trigger'] = Variable<String>(trigger);
|
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['created_at'] = Variable<String>(createdAt);
|
||||||
map['updated_at'] = Variable<String>(updatedAt);
|
map['updated_at'] = Variable<String>(updatedAt);
|
||||||
return map;
|
return map;
|
||||||
|
|
@ -1230,7 +1231,9 @@ class Job extends DataClass implements Insertable<Job> {
|
||||||
issueNumber: serializer.fromJson<int>(json['issueNumber']),
|
issueNumber: serializer.fromJson<int>(json['issueNumber']),
|
||||||
fingerprint: serializer.fromJson<String>(json['fingerprint']),
|
fingerprint: serializer.fromJson<String>(json['fingerprint']),
|
||||||
trigger: serializer.fromJson<String>(json['trigger']),
|
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']),
|
createdAt: serializer.fromJson<String>(json['createdAt']),
|
||||||
updatedAt: serializer.fromJson<String>(json['updatedAt']),
|
updatedAt: serializer.fromJson<String>(json['updatedAt']),
|
||||||
);
|
);
|
||||||
|
|
@ -1244,7 +1247,9 @@ class Job extends DataClass implements Insertable<Job> {
|
||||||
'issueNumber': serializer.toJson<int>(issueNumber),
|
'issueNumber': serializer.toJson<int>(issueNumber),
|
||||||
'fingerprint': serializer.toJson<String>(fingerprint),
|
'fingerprint': serializer.toJson<String>(fingerprint),
|
||||||
'trigger': serializer.toJson<String>(trigger),
|
'trigger': serializer.toJson<String>(trigger),
|
||||||
'status': serializer.toJson<String>(status),
|
'status': serializer.toJson<String>(
|
||||||
|
$JobsTable.$converterstatus.toJson(status),
|
||||||
|
),
|
||||||
'createdAt': serializer.toJson<String>(createdAt),
|
'createdAt': serializer.toJson<String>(createdAt),
|
||||||
'updatedAt': serializer.toJson<String>(updatedAt),
|
'updatedAt': serializer.toJson<String>(updatedAt),
|
||||||
};
|
};
|
||||||
|
|
@ -1256,7 +1261,7 @@ class Job extends DataClass implements Insertable<Job> {
|
||||||
int? issueNumber,
|
int? issueNumber,
|
||||||
String? fingerprint,
|
String? fingerprint,
|
||||||
String? trigger,
|
String? trigger,
|
||||||
String? status,
|
JobStatus? status,
|
||||||
String? createdAt,
|
String? createdAt,
|
||||||
String? updatedAt,
|
String? updatedAt,
|
||||||
}) => Job(
|
}) => Job(
|
||||||
|
|
@ -1334,7 +1339,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
||||||
final Value<int> issueNumber;
|
final Value<int> issueNumber;
|
||||||
final Value<String> fingerprint;
|
final Value<String> fingerprint;
|
||||||
final Value<String> trigger;
|
final Value<String> trigger;
|
||||||
final Value<String> status;
|
final Value<JobStatus> status;
|
||||||
final Value<String> createdAt;
|
final Value<String> createdAt;
|
||||||
final Value<String> updatedAt;
|
final Value<String> updatedAt;
|
||||||
const JobsCompanion({
|
const JobsCompanion({
|
||||||
|
|
@ -1353,7 +1358,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
||||||
required int issueNumber,
|
required int issueNumber,
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String trigger,
|
required String trigger,
|
||||||
required String status,
|
required JobStatus status,
|
||||||
required String createdAt,
|
required String createdAt,
|
||||||
required String updatedAt,
|
required String updatedAt,
|
||||||
}) : projectKey = Value(projectKey),
|
}) : projectKey = Value(projectKey),
|
||||||
|
|
@ -1391,7 +1396,7 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
||||||
Value<int>? issueNumber,
|
Value<int>? issueNumber,
|
||||||
Value<String>? fingerprint,
|
Value<String>? fingerprint,
|
||||||
Value<String>? trigger,
|
Value<String>? trigger,
|
||||||
Value<String>? status,
|
Value<JobStatus>? status,
|
||||||
Value<String>? createdAt,
|
Value<String>? createdAt,
|
||||||
Value<String>? updatedAt,
|
Value<String>? updatedAt,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1426,7 +1431,9 @@ class JobsCompanion extends UpdateCompanion<Job> {
|
||||||
map['trigger'] = Variable<String>(trigger.value);
|
map['trigger'] = Variable<String>(trigger.value);
|
||||||
}
|
}
|
||||||
if (status.present) {
|
if (status.present) {
|
||||||
map['status'] = Variable<String>(status.value);
|
map['status'] = Variable<String>(
|
||||||
|
$JobsTable.$converterstatus.toSql(status.value),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (createdAt.present) {
|
if (createdAt.present) {
|
||||||
map['created_at'] = Variable<String>(createdAt.value);
|
map['created_at'] = Variable<String>(createdAt.value);
|
||||||
|
|
@ -3500,7 +3507,7 @@ typedef $$JobsTableCreateCompanionBuilder =
|
||||||
required int issueNumber,
|
required int issueNumber,
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String trigger,
|
required String trigger,
|
||||||
required String status,
|
required JobStatus status,
|
||||||
required String createdAt,
|
required String createdAt,
|
||||||
required String updatedAt,
|
required String updatedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -3511,7 +3518,7 @@ typedef $$JobsTableUpdateCompanionBuilder =
|
||||||
Value<int> issueNumber,
|
Value<int> issueNumber,
|
||||||
Value<String> fingerprint,
|
Value<String> fingerprint,
|
||||||
Value<String> trigger,
|
Value<String> trigger,
|
||||||
Value<String> status,
|
Value<JobStatus> status,
|
||||||
Value<String> createdAt,
|
Value<String> createdAt,
|
||||||
Value<String> updatedAt,
|
Value<String> updatedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -3549,9 +3556,10 @@ class $$JobsTableFilterComposer extends Composer<_$AppDatabase, $JobsTable> {
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
ColumnFilters<String> get status => $composableBuilder(
|
ColumnWithTypeConverterFilters<JobStatus, JobStatus, String> get status =>
|
||||||
|
$composableBuilder(
|
||||||
column: $table.status,
|
column: $table.status,
|
||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnWithTypeConverterFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
ColumnFilters<String> get createdAt => $composableBuilder(
|
ColumnFilters<String> get createdAt => $composableBuilder(
|
||||||
|
|
@ -3644,7 +3652,7 @@ class $$JobsTableAnnotationComposer
|
||||||
GeneratedColumn<String> get trigger =>
|
GeneratedColumn<String> get trigger =>
|
||||||
$composableBuilder(column: $table.trigger, builder: (column) => column);
|
$composableBuilder(column: $table.trigger, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<String> get status =>
|
GeneratedColumnWithTypeConverter<JobStatus, String> get status =>
|
||||||
$composableBuilder(column: $table.status, builder: (column) => column);
|
$composableBuilder(column: $table.status, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumn<String> get createdAt =>
|
GeneratedColumn<String> get createdAt =>
|
||||||
|
|
@ -3687,7 +3695,7 @@ class $$JobsTableTableManager
|
||||||
Value<int> issueNumber = const Value.absent(),
|
Value<int> issueNumber = const Value.absent(),
|
||||||
Value<String> fingerprint = const Value.absent(),
|
Value<String> fingerprint = const Value.absent(),
|
||||||
Value<String> trigger = 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> createdAt = const Value.absent(),
|
||||||
Value<String> updatedAt = const Value.absent(),
|
Value<String> updatedAt = const Value.absent(),
|
||||||
}) => JobsCompanion(
|
}) => JobsCompanion(
|
||||||
|
|
@ -3707,7 +3715,7 @@ class $$JobsTableTableManager
|
||||||
required int issueNumber,
|
required int issueNumber,
|
||||||
required String fingerprint,
|
required String fingerprint,
|
||||||
required String trigger,
|
required String trigger,
|
||||||
required String status,
|
required JobStatus status,
|
||||||
required String createdAt,
|
required String createdAt,
|
||||||
required String updatedAt,
|
required String updatedAt,
|
||||||
}) => JobsCompanion.insert(
|
}) => JobsCompanion.insert(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
import 'cli_responder.dart';
|
import 'cli_responder.dart';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
|
|
@ -10,6 +12,8 @@ import 'github_client.dart';
|
||||||
import 'workspace_manager.dart';
|
import 'workspace_manager.dart';
|
||||||
|
|
||||||
class IssueAssistantApp {
|
class IssueAssistantApp {
|
||||||
|
static final Logger _logger = Logger('code_work_spawner.app');
|
||||||
|
|
||||||
IssueAssistantApp._({
|
IssueAssistantApp._({
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.database,
|
required this.database,
|
||||||
|
|
@ -35,7 +39,7 @@ class IssueAssistantApp {
|
||||||
database: database,
|
database: database,
|
||||||
githubClient: GitHubClient(ghCommand: ghCommand),
|
githubClient: GitHubClient(ghCommand: ghCommand),
|
||||||
responderRunner: CliResponderRunner(),
|
responderRunner: CliResponderRunner(),
|
||||||
workspaceManager: WorkspaceManager(),
|
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,12 +51,15 @@ class IssueAssistantApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> runOnce() async {
|
Future<void> runOnce() async {
|
||||||
|
_log('starting poll cycle projects=${config.projects.length}');
|
||||||
for (final project in config.projects.values) {
|
for (final project in config.projects.values) {
|
||||||
if (!project.issueAssistant.enabled) {
|
if (!project.issueAssistant.enabled) {
|
||||||
|
_log('skip project=${project.key} reason=issue_assistant_disabled');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _pollProject(project);
|
await _pollProject(project);
|
||||||
}
|
}
|
||||||
|
_log('poll cycle completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> close() => database.close();
|
Future<void> close() => database.close();
|
||||||
|
|
@ -70,14 +77,22 @@ class IssueAssistantApp {
|
||||||
Future<void> _pollProject(ProjectConfig project) async {
|
Future<void> _pollProject(ProjectConfig project) async {
|
||||||
final pollRunId = await database.startPollRun(project.key);
|
final pollRunId = await database.startPollRun(project.key);
|
||||||
try {
|
try {
|
||||||
|
final repoDirectory = Directory(project.path);
|
||||||
|
final repoExists = await repoDirectory.exists();
|
||||||
final projectState = await database.findProjectState(project.key);
|
final projectState = await database.findProjectState(project.key);
|
||||||
final since = projectState?.lastSeenUpdatedAt == null
|
final since = projectState?.lastSeenUpdatedAt == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(projectState!.lastSeenUpdatedAt!);
|
: 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(
|
final issues = await githubClient.fetchUpdatedIssues(
|
||||||
repo: project.repo,
|
repo: project.repo,
|
||||||
since: since,
|
since: since,
|
||||||
);
|
);
|
||||||
|
_log('project=${project.key} fetched_issues=${issues.length}');
|
||||||
|
|
||||||
DateTime? latestSeen = since;
|
DateTime? latestSeen = since;
|
||||||
for (final issue in issues) {
|
for (final issue in issues) {
|
||||||
|
|
@ -93,12 +108,17 @@ class IssueAssistantApp {
|
||||||
lastSeenUpdatedAt: latestSeen,
|
lastSeenUpdatedAt: latestSeen,
|
||||||
);
|
);
|
||||||
await database.finishPollRun(pollRunId: pollRunId, success: true);
|
await database.finishPollRun(pollRunId: pollRunId, success: true);
|
||||||
|
_log(
|
||||||
|
'poll project=${project.key} completed latest_seen='
|
||||||
|
'${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}',
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await database.finishPollRun(
|
await database.finishPollRun(
|
||||||
pollRunId: pollRunId,
|
pollRunId: pollRunId,
|
||||||
success: false,
|
success: false,
|
||||||
error: error.toString(),
|
error: error.toString(),
|
||||||
);
|
);
|
||||||
|
_log('poll project=${project.key} failed error=$error');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,16 +134,23 @@ class IssueAssistantApp {
|
||||||
final fingerprint = _fingerprintThread(thread);
|
final fingerprint = _fingerprintThread(thread);
|
||||||
final record = await database.findIssueThread(project.key, issue.number);
|
final record = await database.findIssueThread(project.key, issue.number);
|
||||||
if (record?.lastEvaluatedFingerprint == fingerprint) {
|
if (record?.lastEvaluatedFingerprint == fingerprint) {
|
||||||
|
_log(
|
||||||
|
'skip issue=${project.key}#${issue.number} reason=fingerprint_unchanged',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final trigger = _determineTrigger(project, thread);
|
final trigger = _determineTrigger(project, thread);
|
||||||
|
_log(
|
||||||
|
'evaluate issue=${project.key}#${issue.number} '
|
||||||
|
'comments=${thread.comments.length} trigger=$trigger',
|
||||||
|
);
|
||||||
final jobId = await database.createJob(
|
final jobId = await database.createJob(
|
||||||
projectKey: project.key,
|
projectKey: project.key,
|
||||||
issueNumber: issue.number,
|
issueNumber: issue.number,
|
||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
trigger: trigger,
|
trigger: trigger,
|
||||||
status: 'queued',
|
status: JobStatus.queued,
|
||||||
);
|
);
|
||||||
|
|
||||||
await database.upsertIssueThread(
|
await database.upsertIssueThread(
|
||||||
|
|
@ -137,7 +164,7 @@ class IssueAssistantApp {
|
||||||
lastCommentUrl: record?.lastCommentUrl,
|
lastCommentUrl: record?.lastCommentUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
await database.updateJobStatus(jobId, 'running');
|
await database.updateJobStatus(jobId, JobStatus.running);
|
||||||
final forceReply = trigger == 'mention';
|
final forceReply = trigger == 'mention';
|
||||||
final prompt = _buildPrompt(
|
final prompt = _buildPrompt(
|
||||||
project: project,
|
project: project,
|
||||||
|
|
@ -145,6 +172,25 @@ class IssueAssistantApp {
|
||||||
trigger: trigger,
|
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;
|
ResponderResult? selectedResult;
|
||||||
String? workspacePath;
|
String? workspacePath;
|
||||||
Object? lastError;
|
Object? lastError;
|
||||||
|
|
@ -154,6 +200,11 @@ class IssueAssistantApp {
|
||||||
workspacePath = needsWorktree
|
workspacePath = needsWorktree
|
||||||
? await workspaceManager.createEphemeralWorktree(project.path)
|
? await workspaceManager.createEphemeralWorktree(project.path)
|
||||||
: project.path;
|
: project.path;
|
||||||
|
_log(
|
||||||
|
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
|
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||||
|
'workspace=$workspacePath',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await responderRunner.run(
|
final result = await responderRunner.run(
|
||||||
|
|
@ -174,10 +225,19 @@ class IssueAssistantApp {
|
||||||
stdout: result.rawStdout,
|
stdout: result.rawStdout,
|
||||||
stderr: result.rawStderr,
|
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;
|
selectedResult = result;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
_log(
|
||||||
|
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
|
'failed error=$error',
|
||||||
|
);
|
||||||
await database.addExecutionRun(
|
await database.addExecutionRun(
|
||||||
jobId: jobId,
|
jobId: jobId,
|
||||||
responderId: responder.id,
|
responderId: responder.id,
|
||||||
|
|
@ -196,7 +256,10 @@ class IssueAssistantApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedResult == null) {
|
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(
|
throw StateError(
|
||||||
'All responders failed for ${project.key}#${issue.number}: $lastError',
|
'All responders failed for ${project.key}#${issue.number}: $lastError',
|
||||||
);
|
);
|
||||||
|
|
@ -213,7 +276,8 @@ class IssueAssistantApp {
|
||||||
lastCommentId: record?.lastCommentId,
|
lastCommentId: record?.lastCommentId,
|
||||||
lastCommentUrl: record?.lastCommentUrl,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +310,8 @@ class IssueAssistantApp {
|
||||||
lastCommentId: posted.id,
|
lastCommentId: posted.id,
|
||||||
lastCommentUrl: posted.url,
|
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({
|
String _buildPrompt({
|
||||||
|
|
@ -320,4 +385,12 @@ class IssueAssistantApp {
|
||||||
}) {
|
}) {
|
||||||
return '$markdown\n\n<!-- $tag:$fingerprint -->';
|
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;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
class WorkspaceManager {
|
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 {
|
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 workspacePath = p.join(parent.path, 'repo');
|
||||||
final result = await Process.run('git', [
|
final command = [
|
||||||
'-C',
|
'-C',
|
||||||
projectPath,
|
projectPath,
|
||||||
'worktree',
|
'worktree',
|
||||||
|
|
@ -14,20 +28,13 @@ class WorkspaceManager {
|
||||||
'--detach',
|
'--detach',
|
||||||
workspacePath,
|
workspacePath,
|
||||||
'HEAD',
|
'HEAD',
|
||||||
]);
|
];
|
||||||
|
final result = await _runGit(command);
|
||||||
|
|
||||||
if (result.exitCode != 0) {
|
if (result.exitCode != 0) {
|
||||||
throw ProcessException(
|
throw ProcessException(
|
||||||
'git',
|
'git',
|
||||||
[
|
command,
|
||||||
'-C',
|
|
||||||
projectPath,
|
|
||||||
'worktree',
|
|
||||||
'add',
|
|
||||||
'--detach',
|
|
||||||
workspacePath,
|
|
||||||
'HEAD',
|
|
||||||
],
|
|
||||||
'${result.stdout}\n${result.stderr}',
|
'${result.stdout}\n${result.stderr}',
|
||||||
result.exitCode,
|
result.exitCode,
|
||||||
);
|
);
|
||||||
|
|
@ -36,8 +43,23 @@ class WorkspaceManager {
|
||||||
return workspacePath;
|
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 {
|
Future<void> disposeEphemeralWorktree(String workspacePath) async {
|
||||||
final result = await Process.run('git', [
|
final result = await _runGit([
|
||||||
'-C',
|
'-C',
|
||||||
workspacePath,
|
workspacePath,
|
||||||
'worktree',
|
'worktree',
|
||||||
|
|
@ -59,4 +81,15 @@ class WorkspaceManager {
|
||||||
await tempRoot.delete(recursive: true);
|
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
|
path: ^1.9.0
|
||||||
sqlite3: ^2.9.3
|
sqlite3: ^2.9.3
|
||||||
yaml: ^3.1.3
|
yaml: ^3.1.3
|
||||||
|
result_dart: ^2.1.1
|
||||||
|
dotenv: ^4.2.0
|
||||||
|
logging: ^1.3.0
|
||||||
|
meta: ^1.16.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.6.0
|
build_runner: ^2.6.0
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,161 @@ projects:
|
||||||
'project {{project_key}}',
|
'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
|
/// ```gherkin
|
||||||
|
|
@ -268,6 +423,181 @@ projects:
|
||||||
expect(thread.lastCommentId, isNull);
|
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]);
|
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 {
|
Future<void> _initGitRepo(Directory directory) async {
|
||||||
await Process.run('git', ['init'], workingDirectory: directory.path);
|
await _runGit(['init'], workingDirectory: directory.path);
|
||||||
await Process.run('git', [
|
await _runGit([
|
||||||
'config',
|
'config',
|
||||||
'user.email',
|
'user.email',
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
], workingDirectory: directory.path);
|
], workingDirectory: directory.path);
|
||||||
await Process.run('git', [
|
await _runGit([
|
||||||
'config',
|
'config',
|
||||||
'user.name',
|
'user.name',
|
||||||
'Test User',
|
'Test User',
|
||||||
], workingDirectory: directory.path);
|
], workingDirectory: directory.path);
|
||||||
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
|
await File(p.join(directory.path, 'README.md')).writeAsString('repo');
|
||||||
await Process.run('git', ['add', '.'], workingDirectory: directory.path);
|
await _runGit(['add', '.'], workingDirectory: directory.path);
|
||||||
await Process.run('git', [
|
await _runGit(['commit', '-m', 'init'], workingDirectory: directory.path);
|
||||||
'commit',
|
}
|
||||||
'-m',
|
|
||||||
'init',
|
const Set<String> _gitContextEnvironmentKeys = {
|
||||||
], workingDirectory: directory.path);
|
'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