feat: 1st commit
This commit is contained in:
parent
8ce1cefe39
commit
da87c670ae
|
|
@ -1,29 +1,6 @@
|
||||||
# See https://www.dartlang.org/guides/libraries/private-files
|
# https://dart.dev/guides/libraries/private-files
|
||||||
|
# Created by `dart pub`
|
||||||
# Files and directories created by pub
|
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.packages
|
|
||||||
build/
|
|
||||||
# If you're building an application, you may want to check-in your pubspec.lock
|
|
||||||
pubspec.lock
|
pubspec.lock
|
||||||
|
.code_work_spawner.sqlite3
|
||||||
# Directory created by dartdoc
|
agent-orchestrator.yaml
|
||||||
# If you don't generate documentation locally you can remove this line.
|
|
||||||
doc/api/
|
|
||||||
|
|
||||||
# dotenv environment variables file
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# Avoid committing generated Javascript files:
|
|
||||||
*.dart.js
|
|
||||||
# Produced by the --dump-info flag.
|
|
||||||
*.info.json
|
|
||||||
# When generated by dart2js. Don't specify *.js if your
|
|
||||||
# project includes source files written in JavaScript.
|
|
||||||
*.js
|
|
||||||
*.js_
|
|
||||||
*.js.deps
|
|
||||||
*.js.map
|
|
||||||
|
|
||||||
.flutter-plugins
|
|
||||||
.flutter-plugins-dependencies
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial version.
|
||||||
102
README.md
102
README.md
|
|
@ -1,2 +1,102 @@
|
||||||
# code_work_spawner
|
# code_work_spawner
|
||||||
spawn multi agents to complete coding work
|
|
||||||
|
GitHub issue thread assistant with Agent Orchestrator style config.
|
||||||
|
|
||||||
|
The app polls configured GitHub repositories with `gh`, reads issue threads and
|
||||||
|
issue comments, and uses configured CLI responders such as `codex`,
|
||||||
|
`opencode`, or `copilot-cli` to reply when:
|
||||||
|
|
||||||
|
- a user explicitly mentions the assistant
|
||||||
|
- a new comment changes the thread enough that the responder decides a reply is
|
||||||
|
useful
|
||||||
|
|
||||||
|
It supports multi-repo configuration, responder fallback chains, Drift-backed
|
||||||
|
SQLite state, and BDD-style Dart tests.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Agent Orchestrator style YAML config with `defaults` and `projects`
|
||||||
|
- Multiple repositories in one process
|
||||||
|
- Comment-triggered issue assistance
|
||||||
|
- Planning / architecture guidance in issue threads
|
||||||
|
- Bug verification flows in isolated temporary git worktrees
|
||||||
|
- CLI-based responder fallback chain
|
||||||
|
- Durable state with `drift` + SQLite
|
||||||
|
- BDD-style tests using `package:test` with Given / When / Then wording
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Dart SDK 3.11+
|
||||||
|
- `gh` CLI authenticated for the target repositories
|
||||||
|
- Local checkouts for configured repositories
|
||||||
|
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
Create a local `agent-orchestrator.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper", "@codex"]
|
||||||
|
commentTag: code-work-spawner
|
||||||
|
prompt: |
|
||||||
|
You are helping on {{repo}}.
|
||||||
|
Trigger: {{trigger}}
|
||||||
|
Thread:
|
||||||
|
{{thread_json}}
|
||||||
|
responders:
|
||||||
|
- id: codex
|
||||||
|
command: codex
|
||||||
|
args: ["exec", "--json-output"]
|
||||||
|
timeout: 10m
|
||||||
|
- id: opencode
|
||||||
|
command: opencode
|
||||||
|
timeout: 10m
|
||||||
|
|
||||||
|
projects:
|
||||||
|
my-app:
|
||||||
|
repo: owner/my-app
|
||||||
|
path: ~/code/my-app
|
||||||
|
defaultBranch: main
|
||||||
|
sessionPrefix: app
|
||||||
|
```
|
||||||
|
|
||||||
|
Project entries can override `issueAssistant` fields when a repo needs a
|
||||||
|
different prompt, mention triggers, or responder chain.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Single poll cycle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run bin/code_work_spawner.dart --once
|
||||||
|
```
|
||||||
|
|
||||||
|
Long-running poller:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run bin/code_work_spawner.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Override paths when needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run bin/code_work_spawner.dart \
|
||||||
|
--config /path/to/agent-orchestrator.yaml \
|
||||||
|
--db /path/to/state.sqlite3 \
|
||||||
|
--gh-command /usr/bin/gh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart test
|
||||||
|
dart analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
## ref
|
||||||
|
|
||||||
|
[agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# This file configures the static analysis results for your project (errors,
|
||||||
|
# warnings, and lints).
|
||||||
|
#
|
||||||
|
# This enables the 'recommended' set of lints from `package:lints`.
|
||||||
|
# This set helps identify many issues that may lead to problems when running
|
||||||
|
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||||
|
# style and format.
|
||||||
|
#
|
||||||
|
# If you want a smaller set of lints you can change this to specify
|
||||||
|
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||||
|
# (the recommended set includes the core lints).
|
||||||
|
# The core lints are also what is used by pub.dev for scoring packages.
|
||||||
|
|
||||||
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
|
# Uncomment the following section to specify additional rules.
|
||||||
|
|
||||||
|
# linter:
|
||||||
|
# rules:
|
||||||
|
# - camel_case_types
|
||||||
|
|
||||||
|
# analyzer:
|
||||||
|
# exclude:
|
||||||
|
# - path/to/excluded/files/**
|
||||||
|
|
||||||
|
# For more information about the core and recommended set of lints, see
|
||||||
|
# https://dart.dev/go/core-lints
|
||||||
|
|
||||||
|
# For additional information about configuring this file, see
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:args/args.dart';
|
||||||
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> arguments) async {
|
||||||
|
final parser = ArgParser()
|
||||||
|
..addFlag(
|
||||||
|
'once',
|
||||||
|
abbr: '1',
|
||||||
|
help: 'Run a single poll cycle and exit.',
|
||||||
|
negatable: false,
|
||||||
|
)
|
||||||
|
..addOption(
|
||||||
|
'config',
|
||||||
|
abbr: 'c',
|
||||||
|
help: 'Path to agent-orchestrator.yaml.',
|
||||||
|
defaultsTo: 'agent-orchestrator.yaml',
|
||||||
|
)
|
||||||
|
..addOption(
|
||||||
|
'db',
|
||||||
|
help: 'Path to the SQLite database file.',
|
||||||
|
defaultsTo: '.code_work_spawner.sqlite3',
|
||||||
|
)
|
||||||
|
..addOption(
|
||||||
|
'gh-command',
|
||||||
|
help: 'Path to the gh executable.',
|
||||||
|
defaultsTo: 'gh',
|
||||||
|
)
|
||||||
|
..addFlag('help', abbr: 'h', negatable: false);
|
||||||
|
|
||||||
|
final results = parser.parse(arguments);
|
||||||
|
if (results['help'] as bool) {
|
||||||
|
stdout.writeln(parser.usage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final config = await AppConfig.load(results['config'] as String);
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: config,
|
||||||
|
databasePath: results['db'] as String,
|
||||||
|
ghCommand: results['gh-command'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (results['once'] as bool) {
|
||||||
|
await app.runOnce();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.run();
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# arch
|
||||||
|
|
||||||
|
|
||||||
|
## coding flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
n4["agents implement"]
|
||||||
|
n5["agents create worktree, branch"]
|
||||||
|
n5
|
||||||
|
n5 --> n4
|
||||||
|
n6["create PR"]
|
||||||
|
n8["human merge"]
|
||||||
|
n9@{ shape: "diam", label: "human review" }
|
||||||
|
n5["agents create worktree, branch"]
|
||||||
|
n10@{ shape: "diam", label: "infra CI" }
|
||||||
|
n4
|
||||||
|
n10
|
||||||
|
n10 -->|"y"| n9
|
||||||
|
n9 -->|"y"| n8
|
||||||
|
n4 --> n6
|
||||||
|
n6["upsert PR"] --> n10
|
||||||
|
n10 -->|"n"| n4
|
||||||
|
n9 -->|"n"| n4
|
||||||
|
style n1 fill:#5CE1E6
|
||||||
|
style n7 fill:#C1FF72
|
||||||
|
style n9 fill:#C1FF72
|
||||||
|
style n8 fill:#C1FF72
|
||||||
|
style n3 fill:#5CE1E6
|
||||||
|
subgraph s1["planing stage"]
|
||||||
|
n3["issue session in chat"]
|
||||||
|
n11["agents propose plan"]
|
||||||
|
n2["agents propose plan"]
|
||||||
|
n7@{ shape: "diam", label: "user review plan" }
|
||||||
|
n1["create issue"]
|
||||||
|
end
|
||||||
|
n1
|
||||||
|
n1
|
||||||
|
n7
|
||||||
|
n7
|
||||||
|
n7
|
||||||
|
n7 -->|"y"| n5
|
||||||
|
n7
|
||||||
|
n2 --> n7
|
||||||
|
n7 -->|"n: human prompt"| n2
|
||||||
|
n1 --> n2
|
||||||
|
n2 -->|"split to issues"| n1
|
||||||
|
n11 --> n7
|
||||||
|
n7 -->|"n: human prompt"| n11
|
||||||
|
n11
|
||||||
|
n11
|
||||||
|
n3 --> n11
|
||||||
|
n11 -->|"split to issue sessions"| n3
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## arch
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
n3["ssh"]
|
||||||
|
subgraph s3["my pc"]
|
||||||
|
subgraph s4["spawner"]
|
||||||
|
n7["db for cache"]
|
||||||
|
n6["config file"]
|
||||||
|
n2["codex2"]
|
||||||
|
n5["codex1"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
s2
|
||||||
|
n3 --> s3
|
||||||
|
s2 -->|"poll through API to execute job"| s4
|
||||||
|
s4 -->|"report execute status through API"| s2
|
||||||
|
n1@{ label: "Rectangle" }
|
||||||
|
n1["runners dashboard"]
|
||||||
|
s3
|
||||||
|
n1 --> s4
|
||||||
|
subgraph s5["github"]
|
||||||
|
subgraph s2["middle record server"]
|
||||||
|
n4["db<br>save tmux session id, or codex session id, success/fail..."]
|
||||||
|
end
|
||||||
|
s1["workflow dashboard"]
|
||||||
|
end
|
||||||
|
s1 --> s2
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export 'src/config.dart';
|
||||||
|
export 'src/database.dart';
|
||||||
|
export 'src/github_client.dart';
|
||||||
|
export 'src/issue_assistant_app.dart';
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'config.dart';
|
||||||
|
import 'github_client.dart';
|
||||||
|
|
||||||
|
class CliResponderRunner {
|
||||||
|
Future<ResponderResult> run({
|
||||||
|
required CliResponderConfig responder,
|
||||||
|
required ProjectConfig project,
|
||||||
|
required IssueThread thread,
|
||||||
|
required String prompt,
|
||||||
|
required bool forceReply,
|
||||||
|
required String workspacePath,
|
||||||
|
}) async {
|
||||||
|
final context = <String, String>{
|
||||||
|
'prompt': prompt,
|
||||||
|
'repo': project.repo,
|
||||||
|
'project_key': project.key,
|
||||||
|
'project_path': project.path,
|
||||||
|
'workspace_path': workspacePath,
|
||||||
|
'issue_number': thread.issue.number.toString(),
|
||||||
|
'thread_json': encodePrettyJson(thread.toJson()),
|
||||||
|
'force_reply': forceReply.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final stdinPayload = renderTemplate(responder.stdinTemplate, context);
|
||||||
|
final process = await Process.start(
|
||||||
|
responder.command,
|
||||||
|
responder.args,
|
||||||
|
workingDirectory: workspacePath,
|
||||||
|
environment: <String, String>{
|
||||||
|
...Platform.environment,
|
||||||
|
...responder.env,
|
||||||
|
'CWS_PROJECT_KEY': project.key,
|
||||||
|
'CWS_REPO': project.repo,
|
||||||
|
'CWS_PROJECT_PATH': project.path,
|
||||||
|
'CWS_WORKSPACE_PATH': workspacePath,
|
||||||
|
'CWS_ISSUE_NUMBER': thread.issue.number.toString(),
|
||||||
|
'CWS_FORCE_REPLY': forceReply.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
process.stdin.write(stdinPayload);
|
||||||
|
await process.stdin.close();
|
||||||
|
|
||||||
|
final stdoutFuture = process.stdout.transform(utf8.decoder).join();
|
||||||
|
final stderrFuture = process.stderr.transform(utf8.decoder).join();
|
||||||
|
|
||||||
|
late final int exitCode;
|
||||||
|
try {
|
||||||
|
exitCode = await process.exitCode.timeout(responder.timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
process.kill(ProcessSignal.sigkill);
|
||||||
|
throw TimeoutException(
|
||||||
|
'Responder "${responder.id}" timed out after ${responder.timeout}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stdoutText = await stdoutFuture;
|
||||||
|
final stderrText = await stderrFuture;
|
||||||
|
if (exitCode != 0) {
|
||||||
|
throw ProcessException(
|
||||||
|
responder.command,
|
||||||
|
responder.args,
|
||||||
|
stderrText.isEmpty ? stdoutText : stderrText,
|
||||||
|
exitCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = _decodeJson(stdoutText);
|
||||||
|
return ResponderResult.fromJson(
|
||||||
|
decoded,
|
||||||
|
responderId: responder.id,
|
||||||
|
rawStdout: stdoutText,
|
||||||
|
rawStderr: stderrText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeJson(String stdout) {
|
||||||
|
final trimmed = stdout.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw const FormatException('Responder returned empty stdout.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (jsonDecode(trimmed) as Map).cast<String, dynamic>();
|
||||||
|
} on FormatException {
|
||||||
|
final first = trimmed.indexOf('{');
|
||||||
|
final last = trimmed.lastIndexOf('}');
|
||||||
|
if (first == -1 || last <= first) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
final snippet = trimmed.substring(first, last + 1);
|
||||||
|
return (jsonDecode(snippet) as Map).cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResponderResult {
|
||||||
|
ResponderResult({
|
||||||
|
required this.responderId,
|
||||||
|
required this.decision,
|
||||||
|
required this.mode,
|
||||||
|
required this.markdown,
|
||||||
|
required this.summary,
|
||||||
|
required this.rawStdout,
|
||||||
|
required this.rawStderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String responderId;
|
||||||
|
final String decision;
|
||||||
|
final String mode;
|
||||||
|
final String markdown;
|
||||||
|
final String summary;
|
||||||
|
final String rawStdout;
|
||||||
|
final String rawStderr;
|
||||||
|
|
||||||
|
bool get shouldReply => decision == 'reply';
|
||||||
|
|
||||||
|
factory ResponderResult.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required String responderId,
|
||||||
|
required String rawStdout,
|
||||||
|
required String rawStderr,
|
||||||
|
}) {
|
||||||
|
final decision = json['decision']?.toString() ?? 'no_reply';
|
||||||
|
final mode = json['mode']?.toString() ?? 'planning';
|
||||||
|
final markdown = json['markdown']?.toString() ?? '';
|
||||||
|
final summary = json['summary']?.toString() ?? '';
|
||||||
|
if (decision != 'reply' && decision != 'no_reply') {
|
||||||
|
throw FormatException('Invalid decision "$decision".');
|
||||||
|
}
|
||||||
|
return ResponderResult(
|
||||||
|
responderId: responderId,
|
||||||
|
decision: decision,
|
||||||
|
mode: mode,
|
||||||
|
markdown: markdown,
|
||||||
|
summary: summary,
|
||||||
|
rawStdout: rawStdout,
|
||||||
|
rawStderr: rawStderr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
|
class AppConfig {
|
||||||
|
AppConfig({
|
||||||
|
required this.configPath,
|
||||||
|
required this.defaults,
|
||||||
|
required this.projects,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String configPath;
|
||||||
|
final IssueAssistantConfig defaults;
|
||||||
|
final Map<String, ProjectConfig> projects;
|
||||||
|
|
||||||
|
static Future<AppConfig> load(String path) async {
|
||||||
|
final file = File(path);
|
||||||
|
final contents = await file.readAsString();
|
||||||
|
final yaml = loadYaml(contents);
|
||||||
|
if (yaml is! YamlMap) {
|
||||||
|
throw const FormatException('Config root must be a YAML map.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final defaultsMap = _asMap(yaml['defaults']);
|
||||||
|
final defaultAssistantMap = _asMap(defaultsMap['issueAssistant']);
|
||||||
|
final defaults = IssueAssistantConfig.fromMap(defaultAssistantMap);
|
||||||
|
|
||||||
|
final projectsNode = yaml['projects'];
|
||||||
|
if (projectsNode is! YamlMap || projectsNode.isEmpty) {
|
||||||
|
throw const FormatException('projects must be a non-empty map.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final projects = <String, ProjectConfig>{};
|
||||||
|
for (final entry in projectsNode.entries) {
|
||||||
|
final key = entry.key.toString();
|
||||||
|
final value = _asMap(entry.value);
|
||||||
|
projects[key] = ProjectConfig.fromMap(
|
||||||
|
key: key,
|
||||||
|
map: value,
|
||||||
|
defaultAssistant: defaults,
|
||||||
|
defaultAssistantMap: defaultAssistantMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppConfig(
|
||||||
|
configPath: p.normalize(p.absolute(path)),
|
||||||
|
defaults: defaults,
|
||||||
|
projects: projects,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _asMap(Object? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is YamlMap) {
|
||||||
|
return value.map(
|
||||||
|
(key, dynamicValue) => MapEntry(key.toString(), dynamicValue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw FormatException('Expected map, got ${value.runtimeType}.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectConfig {
|
||||||
|
ProjectConfig({
|
||||||
|
required this.key,
|
||||||
|
required this.repo,
|
||||||
|
required this.path,
|
||||||
|
required this.defaultBranch,
|
||||||
|
required this.sessionPrefix,
|
||||||
|
required this.issueAssistant,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String key;
|
||||||
|
final String repo;
|
||||||
|
final String path;
|
||||||
|
final String defaultBranch;
|
||||||
|
final String sessionPrefix;
|
||||||
|
final IssueAssistantConfig issueAssistant;
|
||||||
|
|
||||||
|
factory ProjectConfig.fromMap({
|
||||||
|
required String key,
|
||||||
|
required Map<String, dynamic> map,
|
||||||
|
required IssueAssistantConfig defaultAssistant,
|
||||||
|
required Map<String, dynamic> defaultAssistantMap,
|
||||||
|
}) {
|
||||||
|
final assistantMap = AppConfig._asMap(map['issueAssistant']);
|
||||||
|
final mergedAssistantMap = <String, dynamic>{
|
||||||
|
...defaultAssistantMap,
|
||||||
|
...assistantMap,
|
||||||
|
};
|
||||||
|
return ProjectConfig(
|
||||||
|
key: key,
|
||||||
|
repo: map['repo']?.toString() ?? _missing('projects.$key.repo'),
|
||||||
|
path: _expandHome(
|
||||||
|
map['path']?.toString() ?? _missing('projects.$key.path'),
|
||||||
|
),
|
||||||
|
defaultBranch:
|
||||||
|
map['defaultBranch']?.toString() ??
|
||||||
|
map['default_branch']?.toString() ??
|
||||||
|
'main',
|
||||||
|
sessionPrefix:
|
||||||
|
map['sessionPrefix']?.toString() ??
|
||||||
|
map['session_prefix']?.toString() ??
|
||||||
|
_deriveSessionPrefix(key),
|
||||||
|
issueAssistant: assistantMap.isEmpty
|
||||||
|
? defaultAssistant
|
||||||
|
: IssueAssistantConfig.fromMap(mergedAssistantMap),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _missing(String field) {
|
||||||
|
throw FormatException('Missing required config field: $field');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssueAssistantConfig {
|
||||||
|
IssueAssistantConfig({
|
||||||
|
required this.enabled,
|
||||||
|
required this.pollInterval,
|
||||||
|
required this.mentionTriggers,
|
||||||
|
required this.prompt,
|
||||||
|
required this.responders,
|
||||||
|
required this.capabilities,
|
||||||
|
required this.commentTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool enabled;
|
||||||
|
final Duration pollInterval;
|
||||||
|
final List<String> mentionTriggers;
|
||||||
|
final String prompt;
|
||||||
|
final List<CliResponderConfig> responders;
|
||||||
|
final Set<AssistantCapability> capabilities;
|
||||||
|
final String commentTag;
|
||||||
|
|
||||||
|
factory IssueAssistantConfig.fromMap(Map<String, dynamic> map) {
|
||||||
|
final respondersNode = map['responders'];
|
||||||
|
final responders = respondersNode is List
|
||||||
|
? respondersNode
|
||||||
|
.map((item) => CliResponderConfig.fromMap(AppConfig._asMap(item)))
|
||||||
|
.toList(growable: false)
|
||||||
|
: const <CliResponderConfig>[];
|
||||||
|
|
||||||
|
final capabilitiesNode = map['capabilities'];
|
||||||
|
final capabilities = capabilitiesNode is List
|
||||||
|
? capabilitiesNode
|
||||||
|
.map((item) => AssistantCapability.parse(item.toString()))
|
||||||
|
.toSet()
|
||||||
|
: {AssistantCapability.planning, AssistantCapability.bugVerification};
|
||||||
|
|
||||||
|
return IssueAssistantConfig(
|
||||||
|
enabled: map['enabled'] as bool? ?? true,
|
||||||
|
pollInterval: _parseDuration(map['pollInterval']?.toString() ?? '5m'),
|
||||||
|
mentionTriggers:
|
||||||
|
(map['mentionTriggers'] as List?)
|
||||||
|
?.map((item) => item.toString())
|
||||||
|
.toList(growable: false) ??
|
||||||
|
const <String>[],
|
||||||
|
prompt: map['prompt']?.toString() ?? _defaultPrompt,
|
||||||
|
responders: responders,
|
||||||
|
capabilities: capabilities,
|
||||||
|
commentTag: map['commentTag']?.toString() ?? 'code-work-spawner',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
static Duration _parseDuration(String input) {
|
||||||
|
final match = RegExp(r'^(\d+)([smhd])$').firstMatch(input.trim());
|
||||||
|
if (match == null) {
|
||||||
|
throw FormatException('Invalid duration: $input');
|
||||||
|
}
|
||||||
|
|
||||||
|
final value = int.parse(match.group(1)!);
|
||||||
|
return switch (match.group(2)!) {
|
||||||
|
's' => Duration(seconds: value),
|
||||||
|
'm' => Duration(minutes: value),
|
||||||
|
'h' => Duration(hours: value),
|
||||||
|
'd' => Duration(days: value),
|
||||||
|
_ => throw FormatException('Invalid duration unit: $input'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static const String _defaultPrompt = '''
|
||||||
|
You are a GitHub issue thread assistant.
|
||||||
|
|
||||||
|
You will receive repository metadata, the current issue thread as JSON, and the latest trigger context.
|
||||||
|
|
||||||
|
Decide whether to reply. Rules:
|
||||||
|
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
||||||
|
- If the discussion already has a good enough answer, return "no_reply".
|
||||||
|
- Supported modes are "planning" and "bug_verification".
|
||||||
|
- For planning requests, provide practical architecture and implementation guidance.
|
||||||
|
- For bug_verification requests, you may inspect the local repository and run temporary tests in the current workspace. Do not create commits or persistent artifacts.
|
||||||
|
- Always return strict JSON only.
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"decision": "reply" | "no_reply",
|
||||||
|
"mode": "planning" | "bug_verification",
|
||||||
|
"markdown": "markdown response, omitted for no_reply",
|
||||||
|
"summary": "short internal summary"
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
class CliResponderConfig {
|
||||||
|
CliResponderConfig({
|
||||||
|
required this.id,
|
||||||
|
required this.command,
|
||||||
|
required this.args,
|
||||||
|
required this.env,
|
||||||
|
required this.stdinTemplate,
|
||||||
|
required this.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String command;
|
||||||
|
final List<String> args;
|
||||||
|
final Map<String, String> env;
|
||||||
|
final String stdinTemplate;
|
||||||
|
final Duration timeout;
|
||||||
|
|
||||||
|
factory CliResponderConfig.fromMap(Map<String, dynamic> map) {
|
||||||
|
final id = map['id']?.toString();
|
||||||
|
final command = map['command']?.toString();
|
||||||
|
if (id == null || id.isEmpty) {
|
||||||
|
throw const FormatException('Responder id is required.');
|
||||||
|
}
|
||||||
|
if (command == null || command.isEmpty) {
|
||||||
|
throw FormatException('Responder "$id" must define command.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final args =
|
||||||
|
(map['args'] as List?)
|
||||||
|
?.map((item) => item.toString())
|
||||||
|
.toList(growable: false) ??
|
||||||
|
const <String>[];
|
||||||
|
final env = AppConfig._asMap(
|
||||||
|
map['env'],
|
||||||
|
).map((key, value) => MapEntry(key, value.toString()));
|
||||||
|
|
||||||
|
return CliResponderConfig(
|
||||||
|
id: id,
|
||||||
|
command: command,
|
||||||
|
args: args,
|
||||||
|
env: env,
|
||||||
|
stdinTemplate: map['stdinTemplate']?.toString() ?? '{{prompt}}',
|
||||||
|
timeout: IssueAssistantConfig._parseDuration(
|
||||||
|
map['timeout']?.toString() ?? '10m',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssistantCapability {
|
||||||
|
planning,
|
||||||
|
bugVerification;
|
||||||
|
|
||||||
|
static AssistantCapability parse(String input) {
|
||||||
|
return switch (input) {
|
||||||
|
'planning' => AssistantCapability.planning,
|
||||||
|
'bugVerification' ||
|
||||||
|
'bug_verification' => AssistantCapability.bugVerification,
|
||||||
|
_ => throw FormatException('Unsupported capability: $input'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String renderTemplate(String template, Map<String, String> values) {
|
||||||
|
var output = template;
|
||||||
|
for (final entry in values.entries) {
|
||||||
|
output = output.replaceAll('{{${entry.key}}}', entry.value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodePrettyJson(Object value) {
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
return encoder.convert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _expandHome(String path) {
|
||||||
|
if (!path.startsWith('~/')) {
|
||||||
|
return p.normalize(p.absolute(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
final home = Platform.environment['HOME'];
|
||||||
|
if (home == null || home.isEmpty) {
|
||||||
|
throw const FileSystemException('HOME is not set.');
|
||||||
|
}
|
||||||
|
return p.normalize(p.join(home, path.substring(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deriveSessionPrefix(String key) {
|
||||||
|
if (key.contains('-')) {
|
||||||
|
return key
|
||||||
|
.split('-')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.contains('_')) {
|
||||||
|
return key
|
||||||
|
.split('_')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.substring(0, key.length < 3 ? key.length : 3).toLowerCase();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
class ProjectStates extends Table {
|
||||||
|
TextColumn get projectKey => text()();
|
||||||
|
TextColumn get repo => text()();
|
||||||
|
TextColumn get lastSeenUpdatedAt => text().nullable()();
|
||||||
|
TextColumn get updatedAt => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column<Object>> get primaryKey => {projectKey};
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssueThreadStates extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get projectKey => text()();
|
||||||
|
IntColumn get issueNumber => integer()();
|
||||||
|
TextColumn get fingerprint => text()();
|
||||||
|
TextColumn get lastEvaluatedFingerprint => text().nullable()();
|
||||||
|
TextColumn get lastDecision => text().nullable()();
|
||||||
|
IntColumn get lastCommentId => integer().nullable()();
|
||||||
|
TextColumn get lastCommentUrl => text().nullable()();
|
||||||
|
TextColumn get updatedAt => text()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Set<Column<Object>>> get uniqueKeys => [
|
||||||
|
{projectKey, issueNumber},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class Jobs extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get projectKey => text()();
|
||||||
|
IntColumn get issueNumber => integer()();
|
||||||
|
TextColumn get fingerprint => text()();
|
||||||
|
TextColumn get trigger => text()();
|
||||||
|
TextColumn get status => text()();
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
TextColumn get updatedAt => text()();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExecutionRuns extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
IntColumn get jobId => integer()();
|
||||||
|
TextColumn get responderId => text()();
|
||||||
|
TextColumn get mode => text()();
|
||||||
|
TextColumn get decision => text()();
|
||||||
|
BoolColumn get success => boolean()();
|
||||||
|
TextColumn get workspacePath => text().nullable()();
|
||||||
|
TextColumn get stdout => text()();
|
||||||
|
TextColumn get stderr => text()();
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
}
|
||||||
|
|
||||||
|
class Comments extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get projectKey => text()();
|
||||||
|
IntColumn get issueNumber => integer()();
|
||||||
|
TextColumn get fingerprint => text()();
|
||||||
|
IntColumn get githubCommentId => integer()();
|
||||||
|
TextColumn get githubCommentUrl => text()();
|
||||||
|
TextColumn get body => text()();
|
||||||
|
TextColumn get createdAt => text()();
|
||||||
|
}
|
||||||
|
|
||||||
|
class PollRuns extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get projectKey => text()();
|
||||||
|
TextColumn get startedAt => text()();
|
||||||
|
TextColumn get finishedAt => text().nullable()();
|
||||||
|
BoolColumn get success => boolean().nullable()();
|
||||||
|
TextColumn get error => text().nullable()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [
|
||||||
|
ProjectStates,
|
||||||
|
IssueThreadStates,
|
||||||
|
Jobs,
|
||||||
|
ExecutionRuns,
|
||||||
|
Comments,
|
||||||
|
PollRuns,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase._(super.e);
|
||||||
|
|
||||||
|
factory AppDatabase.open(String path) {
|
||||||
|
return AppDatabase._(
|
||||||
|
NativeDatabase.createInBackground(File(p.absolute(path))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
Future<ProjectState?> findProjectState(String projectKey) {
|
||||||
|
return (select(
|
||||||
|
projectStates,
|
||||||
|
)..where((tbl) => tbl.projectKey.equals(projectKey))).getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertProjectState({
|
||||||
|
required String projectKey,
|
||||||
|
required String repo,
|
||||||
|
required DateTime? lastSeenUpdatedAt,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now().toUtc().toIso8601String();
|
||||||
|
return into(projectStates).insertOnConflictUpdate(
|
||||||
|
ProjectStatesCompanion.insert(
|
||||||
|
projectKey: projectKey,
|
||||||
|
repo: repo,
|
||||||
|
lastSeenUpdatedAt: Value(lastSeenUpdatedAt?.toIso8601String()),
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IssueThreadState?> findIssueThread(
|
||||||
|
String projectKey,
|
||||||
|
int issueNumber,
|
||||||
|
) {
|
||||||
|
return (select(issueThreadStates)
|
||||||
|
..where((tbl) => tbl.projectKey.equals(projectKey))
|
||||||
|
..where((tbl) => tbl.issueNumber.equals(issueNumber)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertIssueThread({
|
||||||
|
required String projectKey,
|
||||||
|
required int issueNumber,
|
||||||
|
required String fingerprint,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
String? lastEvaluatedFingerprint,
|
||||||
|
String? lastDecision,
|
||||||
|
int? lastCommentId,
|
||||||
|
String? lastCommentUrl,
|
||||||
|
}) async {
|
||||||
|
final existing = await findIssueThread(projectKey, issueNumber);
|
||||||
|
final companion = IssueThreadStatesCompanion(
|
||||||
|
projectKey: Value(projectKey),
|
||||||
|
issueNumber: Value(issueNumber),
|
||||||
|
fingerprint: Value(fingerprint),
|
||||||
|
lastEvaluatedFingerprint: Value(lastEvaluatedFingerprint),
|
||||||
|
lastDecision: Value(lastDecision),
|
||||||
|
lastCommentId: Value(lastCommentId),
|
||||||
|
lastCommentUrl: Value(lastCommentUrl),
|
||||||
|
updatedAt: Value(updatedAt.toUtc().toIso8601String()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
await into(issueThreadStates).insert(
|
||||||
|
IssueThreadStatesCompanion.insert(
|
||||||
|
projectKey: projectKey,
|
||||||
|
issueNumber: issueNumber,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
updatedAt: updatedAt.toUtc().toIso8601String(),
|
||||||
|
lastEvaluatedFingerprint: Value(lastEvaluatedFingerprint),
|
||||||
|
lastDecision: Value(lastDecision),
|
||||||
|
lastCommentId: Value(lastCommentId),
|
||||||
|
lastCommentUrl: Value(lastCommentUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (update(
|
||||||
|
issueThreadStates,
|
||||||
|
)..where((tbl) => tbl.id.equals(existing.id))).write(companion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> createJob({
|
||||||
|
required String projectKey,
|
||||||
|
required int issueNumber,
|
||||||
|
required String fingerprint,
|
||||||
|
required String trigger,
|
||||||
|
required String status,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now().toUtc().toIso8601String();
|
||||||
|
return into(jobs).insert(
|
||||||
|
JobsCompanion.insert(
|
||||||
|
projectKey: projectKey,
|
||||||
|
issueNumber: issueNumber,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
trigger: trigger,
|
||||||
|
status: status,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateJobStatus(int jobId, String status) {
|
||||||
|
return (update(jobs)..where((tbl) => tbl.id.equals(jobId))).write(
|
||||||
|
JobsCompanion(
|
||||||
|
status: Value(status),
|
||||||
|
updatedAt: Value(DateTime.now().toUtc().toIso8601String()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addExecutionRun({
|
||||||
|
required int jobId,
|
||||||
|
required String responderId,
|
||||||
|
required String mode,
|
||||||
|
required String decision,
|
||||||
|
required bool success,
|
||||||
|
required String workspacePath,
|
||||||
|
required String stdout,
|
||||||
|
required String stderr,
|
||||||
|
}) {
|
||||||
|
return into(executionRuns).insert(
|
||||||
|
ExecutionRunsCompanion.insert(
|
||||||
|
jobId: jobId,
|
||||||
|
responderId: responderId,
|
||||||
|
mode: mode,
|
||||||
|
decision: decision,
|
||||||
|
success: success,
|
||||||
|
workspacePath: Value(workspacePath),
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
createdAt: DateTime.now().toUtc().toIso8601String(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addCommentRecord({
|
||||||
|
required String projectKey,
|
||||||
|
required int issueNumber,
|
||||||
|
required String fingerprint,
|
||||||
|
required int githubCommentId,
|
||||||
|
required String githubCommentUrl,
|
||||||
|
required String body,
|
||||||
|
}) {
|
||||||
|
return into(comments).insert(
|
||||||
|
CommentsCompanion.insert(
|
||||||
|
projectKey: projectKey,
|
||||||
|
issueNumber: issueNumber,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
githubCommentId: githubCommentId,
|
||||||
|
githubCommentUrl: githubCommentUrl,
|
||||||
|
body: body,
|
||||||
|
createdAt: DateTime.now().toUtc().toIso8601String(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> startPollRun(String projectKey) {
|
||||||
|
return into(pollRuns).insert(
|
||||||
|
PollRunsCompanion.insert(
|
||||||
|
projectKey: projectKey,
|
||||||
|
startedAt: DateTime.now().toUtc().toIso8601String(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> finishPollRun({
|
||||||
|
required int pollRunId,
|
||||||
|
required bool success,
|
||||||
|
String? error,
|
||||||
|
}) {
|
||||||
|
return (update(pollRuns)..where((tbl) => tbl.id.equals(pollRunId))).write(
|
||||||
|
PollRunsCompanion(
|
||||||
|
finishedAt: Value(DateTime.now().toUtc().toIso8601String()),
|
||||||
|
success: Value(success),
|
||||||
|
error: Value(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,227 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class GitHubClient {
|
||||||
|
GitHubClient({this.ghCommand = 'gh'});
|
||||||
|
|
||||||
|
final String ghCommand;
|
||||||
|
|
||||||
|
Future<List<GitHubIssueSummary>> fetchUpdatedIssues({
|
||||||
|
required String repo,
|
||||||
|
required DateTime? since,
|
||||||
|
}) async {
|
||||||
|
final query = <String, String>{
|
||||||
|
'state': 'all',
|
||||||
|
'sort': 'updated',
|
||||||
|
'direction': 'asc',
|
||||||
|
'per_page': '100',
|
||||||
|
if (since != null) 'since': since.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final json = await _runJson([
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'GET',
|
||||||
|
'repos/$repo/issues${_encodeQuery(query)}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (json as List<dynamic>)
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.where((item) => !item.containsKey('pull_request'))
|
||||||
|
.map(GitHubIssueSummary.fromJson)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IssueThread> fetchThread({
|
||||||
|
required String repo,
|
||||||
|
required GitHubIssueSummary issue,
|
||||||
|
}) async {
|
||||||
|
final comments = await _runJson([
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'GET',
|
||||||
|
'repos/$repo/issues/${issue.number}/comments?per_page=100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return IssueThread(
|
||||||
|
issue: issue,
|
||||||
|
comments: (comments as List<dynamic>)
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map(GitHubIssueComment.fromJson)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PostedComment> createIssueComment({
|
||||||
|
required String repo,
|
||||||
|
required int issueNumber,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
final json = await _runJson([
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'POST',
|
||||||
|
'repos/$repo/issues/$issueNumber/comments',
|
||||||
|
'-f',
|
||||||
|
'body=$body',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final map = json as Map<String, dynamic>;
|
||||||
|
return PostedComment(
|
||||||
|
id: map['id'] as int,
|
||||||
|
url: map['html_url'] as String? ?? '',
|
||||||
|
body: map['body'] as String? ?? body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Object?> _runJson(List<String> arguments) async {
|
||||||
|
final result = await Process.run(ghCommand, arguments);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw ProcessException(
|
||||||
|
ghCommand,
|
||||||
|
arguments,
|
||||||
|
'${result.stdout}\n${result.stderr}',
|
||||||
|
result.exitCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stdoutText = result.stdout.toString().trim();
|
||||||
|
if (stdoutText.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return jsonDecode(stdoutText);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodeQuery(Map<String, String> query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final encoded = query.entries
|
||||||
|
.map(
|
||||||
|
(entry) =>
|
||||||
|
'${Uri.encodeQueryComponent(entry.key)}='
|
||||||
|
'${Uri.encodeQueryComponent(entry.value)}',
|
||||||
|
)
|
||||||
|
.join('&');
|
||||||
|
return '?$encoded';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GitHubIssueSummary {
|
||||||
|
GitHubIssueSummary({
|
||||||
|
required this.number,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.state,
|
||||||
|
required this.url,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.userLogin,
|
||||||
|
required this.labels,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int number;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String state;
|
||||||
|
final String url;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final String userLogin;
|
||||||
|
final List<String> labels;
|
||||||
|
|
||||||
|
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
||||||
|
return GitHubIssueSummary(
|
||||||
|
number: json['number'] as int,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
state: json['state'] as String? ?? 'open',
|
||||||
|
url: json['html_url'] as String? ?? '',
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
userLogin:
|
||||||
|
(json['user'] as Map<String, dynamic>? ?? const {})['login']
|
||||||
|
as String? ??
|
||||||
|
'unknown',
|
||||||
|
labels: labelsNode
|
||||||
|
.map((label) => (label as Map<String, dynamic>)['name'].toString())
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'number': number,
|
||||||
|
'title': title,
|
||||||
|
'body': body,
|
||||||
|
'state': state,
|
||||||
|
'url': url,
|
||||||
|
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||||
|
'user_login': userLogin,
|
||||||
|
'labels': labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class GitHubIssueComment {
|
||||||
|
GitHubIssueComment({
|
||||||
|
required this.id,
|
||||||
|
required this.body,
|
||||||
|
required this.userLogin,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String body;
|
||||||
|
final String userLogin;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GitHubIssueComment(
|
||||||
|
id: json['id'] as int,
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
userLogin:
|
||||||
|
(json['user'] as Map<String, dynamic>? ?? const {})['login']
|
||||||
|
as String? ??
|
||||||
|
'unknown',
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
url: json['html_url'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'body': body,
|
||||||
|
'user_login': userLogin,
|
||||||
|
'created_at': createdAt.toUtc().toIso8601String(),
|
||||||
|
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class IssueThread {
|
||||||
|
IssueThread({required this.issue, required this.comments});
|
||||||
|
|
||||||
|
final GitHubIssueSummary issue;
|
||||||
|
final List<GitHubIssueComment> comments;
|
||||||
|
|
||||||
|
bool get isOpen => issue.state == 'open';
|
||||||
|
|
||||||
|
GitHubIssueComment? get latestComment =>
|
||||||
|
comments.isEmpty ? null : comments.last;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'issue': issue.toJson(),
|
||||||
|
'comments': comments.map((comment) => comment.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PostedComment {
|
||||||
|
PostedComment({required this.id, required this.url, required this.body});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String url;
|
||||||
|
final String body;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
import 'cli_responder.dart';
|
||||||
|
import 'config.dart';
|
||||||
|
import 'database.dart';
|
||||||
|
import 'github_client.dart';
|
||||||
|
import 'workspace_manager.dart';
|
||||||
|
|
||||||
|
class IssueAssistantApp {
|
||||||
|
IssueAssistantApp._({
|
||||||
|
required this.config,
|
||||||
|
required this.database,
|
||||||
|
required this.githubClient,
|
||||||
|
required this.responderRunner,
|
||||||
|
required this.workspaceManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AppConfig config;
|
||||||
|
final AppDatabase database;
|
||||||
|
final GitHubClient githubClient;
|
||||||
|
final CliResponderRunner responderRunner;
|
||||||
|
final WorkspaceManager workspaceManager;
|
||||||
|
|
||||||
|
static Future<IssueAssistantApp> open({
|
||||||
|
required AppConfig config,
|
||||||
|
required String databasePath,
|
||||||
|
String ghCommand = 'gh',
|
||||||
|
}) async {
|
||||||
|
final database = AppDatabase.open(databasePath);
|
||||||
|
return IssueAssistantApp._(
|
||||||
|
config: config,
|
||||||
|
database: database,
|
||||||
|
githubClient: GitHubClient(ghCommand: ghCommand),
|
||||||
|
responderRunner: CliResponderRunner(),
|
||||||
|
workspaceManager: WorkspaceManager(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
while (true) {
|
||||||
|
await runOnce();
|
||||||
|
await Future<void>.delayed(_smallestPollInterval());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runOnce() async {
|
||||||
|
for (final project in config.projects.values) {
|
||||||
|
if (!project.issueAssistant.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await _pollProject(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => database.close();
|
||||||
|
|
||||||
|
Duration _smallestPollInterval() {
|
||||||
|
return config.projects.values
|
||||||
|
.where((project) => project.issueAssistant.enabled)
|
||||||
|
.map((project) => project.issueAssistant.pollInterval)
|
||||||
|
.fold<Duration>(
|
||||||
|
const Duration(minutes: 5),
|
||||||
|
(current, next) => next < current ? next : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollProject(ProjectConfig project) async {
|
||||||
|
final pollRunId = await database.startPollRun(project.key);
|
||||||
|
try {
|
||||||
|
final projectState = await database.findProjectState(project.key);
|
||||||
|
final since = projectState?.lastSeenUpdatedAt == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(projectState!.lastSeenUpdatedAt!);
|
||||||
|
final issues = await githubClient.fetchUpdatedIssues(
|
||||||
|
repo: project.repo,
|
||||||
|
since: since,
|
||||||
|
);
|
||||||
|
|
||||||
|
DateTime? latestSeen = since;
|
||||||
|
for (final issue in issues) {
|
||||||
|
if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) {
|
||||||
|
latestSeen = issue.updatedAt;
|
||||||
|
}
|
||||||
|
await _processIssue(project, issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.upsertProjectState(
|
||||||
|
projectKey: project.key,
|
||||||
|
repo: project.repo,
|
||||||
|
lastSeenUpdatedAt: latestSeen,
|
||||||
|
);
|
||||||
|
await database.finishPollRun(pollRunId: pollRunId, success: true);
|
||||||
|
} catch (error) {
|
||||||
|
await database.finishPollRun(
|
||||||
|
pollRunId: pollRunId,
|
||||||
|
success: false,
|
||||||
|
error: error.toString(),
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processIssue(
|
||||||
|
ProjectConfig project,
|
||||||
|
GitHubIssueSummary issue,
|
||||||
|
) async {
|
||||||
|
final thread = await githubClient.fetchThread(
|
||||||
|
repo: project.repo,
|
||||||
|
issue: issue,
|
||||||
|
);
|
||||||
|
final fingerprint = _fingerprintThread(thread);
|
||||||
|
final record = await database.findIssueThread(project.key, issue.number);
|
||||||
|
if (record?.lastEvaluatedFingerprint == fingerprint) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trigger = _determineTrigger(project, thread);
|
||||||
|
final jobId = await database.createJob(
|
||||||
|
projectKey: project.key,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
trigger: trigger,
|
||||||
|
status: 'queued',
|
||||||
|
);
|
||||||
|
|
||||||
|
await database.upsertIssueThread(
|
||||||
|
projectKey: project.key,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
updatedAt: issue.updatedAt,
|
||||||
|
lastEvaluatedFingerprint: record?.lastEvaluatedFingerprint,
|
||||||
|
lastDecision: record?.lastDecision,
|
||||||
|
lastCommentId: record?.lastCommentId,
|
||||||
|
lastCommentUrl: record?.lastCommentUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
await database.updateJobStatus(jobId, 'running');
|
||||||
|
final forceReply = trigger == 'mention';
|
||||||
|
final prompt = _buildPrompt(
|
||||||
|
project: project,
|
||||||
|
thread: thread,
|
||||||
|
trigger: trigger,
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponderResult? selectedResult;
|
||||||
|
String? workspacePath;
|
||||||
|
Object? lastError;
|
||||||
|
|
||||||
|
for (final responder in project.issueAssistant.responders) {
|
||||||
|
final needsWorktree = _looksLikeBugVerification(thread);
|
||||||
|
workspacePath = needsWorktree
|
||||||
|
? await workspaceManager.createEphemeralWorktree(project.path)
|
||||||
|
: project.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await responderRunner.run(
|
||||||
|
responder: responder,
|
||||||
|
project: project,
|
||||||
|
thread: thread,
|
||||||
|
prompt: prompt,
|
||||||
|
forceReply: forceReply,
|
||||||
|
workspacePath: workspacePath,
|
||||||
|
);
|
||||||
|
await database.addExecutionRun(
|
||||||
|
jobId: jobId,
|
||||||
|
responderId: result.responderId,
|
||||||
|
mode: result.mode,
|
||||||
|
decision: result.decision,
|
||||||
|
success: true,
|
||||||
|
workspacePath: workspacePath,
|
||||||
|
stdout: result.rawStdout,
|
||||||
|
stderr: result.rawStderr,
|
||||||
|
);
|
||||||
|
selectedResult = result;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await database.addExecutionRun(
|
||||||
|
jobId: jobId,
|
||||||
|
responderId: responder.id,
|
||||||
|
mode: 'error',
|
||||||
|
decision: 'no_reply',
|
||||||
|
success: false,
|
||||||
|
workspacePath: workspacePath,
|
||||||
|
stdout: '',
|
||||||
|
stderr: error.toString(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (workspacePath != project.path) {
|
||||||
|
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedResult == null) {
|
||||||
|
await database.updateJobStatus(jobId, 'failed');
|
||||||
|
throw StateError(
|
||||||
|
'All responders failed for ${project.key}#${issue.number}: $lastError',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedResult.shouldReply) {
|
||||||
|
await database.upsertIssueThread(
|
||||||
|
projectKey: project.key,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
updatedAt: issue.updatedAt,
|
||||||
|
lastEvaluatedFingerprint: fingerprint,
|
||||||
|
lastDecision: selectedResult.decision,
|
||||||
|
lastCommentId: record?.lastCommentId,
|
||||||
|
lastCommentUrl: record?.lastCommentUrl,
|
||||||
|
);
|
||||||
|
await database.updateJobStatus(jobId, 'completed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final commentBody = _decorateComment(
|
||||||
|
tag: project.issueAssistant.commentTag,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
markdown: selectedResult.markdown,
|
||||||
|
);
|
||||||
|
final posted = await githubClient.createIssueComment(
|
||||||
|
repo: project.repo,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
body: commentBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
await database.addCommentRecord(
|
||||||
|
projectKey: project.key,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
githubCommentId: posted.id,
|
||||||
|
githubCommentUrl: posted.url,
|
||||||
|
body: posted.body,
|
||||||
|
);
|
||||||
|
await database.upsertIssueThread(
|
||||||
|
projectKey: project.key,
|
||||||
|
issueNumber: issue.number,
|
||||||
|
fingerprint: fingerprint,
|
||||||
|
updatedAt: issue.updatedAt,
|
||||||
|
lastEvaluatedFingerprint: fingerprint,
|
||||||
|
lastDecision: selectedResult.decision,
|
||||||
|
lastCommentId: posted.id,
|
||||||
|
lastCommentUrl: posted.url,
|
||||||
|
);
|
||||||
|
await database.updateJobStatus(jobId, 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildPrompt({
|
||||||
|
required ProjectConfig project,
|
||||||
|
required IssueThread thread,
|
||||||
|
required String trigger,
|
||||||
|
}) {
|
||||||
|
final payload = <String, String>{
|
||||||
|
'repo': project.repo,
|
||||||
|
'project_key': project.key,
|
||||||
|
'project_path': project.path,
|
||||||
|
'issue_number': thread.issue.number.toString(),
|
||||||
|
'issue_title': thread.issue.title,
|
||||||
|
'issue_body': thread.issue.body,
|
||||||
|
'issue_url': thread.issue.url,
|
||||||
|
'thread_json': const JsonEncoder.withIndent(
|
||||||
|
' ',
|
||||||
|
).convert(thread.toJson()),
|
||||||
|
'mention_triggers': project.issueAssistant.mentionTriggers.join(', '),
|
||||||
|
'trigger': trigger,
|
||||||
|
'capabilities': project.issueAssistant.capabilities
|
||||||
|
.map((capability) => capability.name)
|
||||||
|
.join(', '),
|
||||||
|
};
|
||||||
|
return renderTemplate(project.issueAssistant.prompt, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _determineTrigger(ProjectConfig project, IssueThread thread) {
|
||||||
|
final latestText = thread.latestComment?.body ?? thread.issue.body;
|
||||||
|
final latestAuthor =
|
||||||
|
thread.latestComment?.userLogin ?? thread.issue.userLogin;
|
||||||
|
final latestTextLower = latestText.toLowerCase();
|
||||||
|
final mention = project.issueAssistant.mentionTriggers.any(
|
||||||
|
(trigger) => latestTextLower.contains(trigger.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (mention && !_looksLikeBot(project, latestAuthor)) {
|
||||||
|
return 'mention';
|
||||||
|
}
|
||||||
|
return 'update';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeBot(ProjectConfig project, String login) {
|
||||||
|
final lower = login.toLowerCase();
|
||||||
|
if (lower.endsWith('[bot]')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return lower.contains(project.issueAssistant.commentTag.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeBugVerification(IssueThread thread) {
|
||||||
|
final text = [
|
||||||
|
thread.issue.title,
|
||||||
|
thread.issue.body,
|
||||||
|
if (thread.latestComment != null) thread.latestComment!.body,
|
||||||
|
].join('\n').toLowerCase();
|
||||||
|
return text.contains('bug') &&
|
||||||
|
(text.contains('verify') ||
|
||||||
|
text.contains('test') ||
|
||||||
|
text.contains('reproduce'));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fingerprintThread(IssueThread thread) {
|
||||||
|
final bytes = utf8.encode(jsonEncode(thread.toJson()));
|
||||||
|
return sha256.convert(bytes).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decorateComment({
|
||||||
|
required String tag,
|
||||||
|
required String fingerprint,
|
||||||
|
required String markdown,
|
||||||
|
}) {
|
||||||
|
return '$markdown\n\n<!-- $tag:$fingerprint -->';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
class WorkspaceManager {
|
||||||
|
Future<String> createEphemeralWorktree(String projectPath) async {
|
||||||
|
final parent = await Directory.systemTemp.createTemp('cws-worktree-');
|
||||||
|
final workspacePath = p.join(parent.path, 'repo');
|
||||||
|
final result = await Process.run('git', [
|
||||||
|
'-C',
|
||||||
|
projectPath,
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
'--detach',
|
||||||
|
workspacePath,
|
||||||
|
'HEAD',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw ProcessException(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
projectPath,
|
||||||
|
'worktree',
|
||||||
|
'add',
|
||||||
|
'--detach',
|
||||||
|
workspacePath,
|
||||||
|
'HEAD',
|
||||||
|
],
|
||||||
|
'${result.stdout}\n${result.stderr}',
|
||||||
|
result.exitCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspacePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disposeEphemeralWorktree(String workspacePath) async {
|
||||||
|
final result = await Process.run('git', [
|
||||||
|
'-C',
|
||||||
|
workspacePath,
|
||||||
|
'worktree',
|
||||||
|
'remove',
|
||||||
|
'--force',
|
||||||
|
workspacePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
final directory = Directory(p.dirname(workspacePath));
|
||||||
|
if (await directory.exists()) {
|
||||||
|
await directory.delete(recursive: true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tempRoot = Directory(p.dirname(workspacePath));
|
||||||
|
if (await tempRoot.exists()) {
|
||||||
|
await tempRoot.delete(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
name: code_work_spawner
|
||||||
|
description: GitHub issue thread assistant with Agent Orchestrator style config.
|
||||||
|
version: 1.0.0
|
||||||
|
# repository: https://github.com/my_org/my_repo
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.11.4
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
args: ^2.7.0
|
||||||
|
crypto: ^3.0.6
|
||||||
|
drift: ^2.28.2
|
||||||
|
file: ^7.0.1
|
||||||
|
path: ^1.9.0
|
||||||
|
sqlite3: ^2.9.3
|
||||||
|
yaml: ^3.1.3
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
build_runner: ^2.6.0
|
||||||
|
drift_dev: ^2.28.1
|
||||||
|
lints: ^6.0.0
|
||||||
|
test: ^1.25.6
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
|
||||||
|
group('AppConfig.load', () {
|
||||||
|
test(
|
||||||
|
'given AO style config when loading then defaults and project overrides are merged',
|
||||||
|
() async {
|
||||||
|
// Given a config file with shared defaults and a project override.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-config-');
|
||||||
|
final configFile = File(
|
||||||
|
p.join(tempDir.path, 'agent-orchestrator.yaml'),
|
||||||
|
);
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
prompt: "default {{repo}}"
|
||||||
|
responders:
|
||||||
|
- id: fallback
|
||||||
|
command: responder
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
defaultBranch: main
|
||||||
|
issueAssistant:
|
||||||
|
prompt: "project {{project_key}}"
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When the app config is loaded from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the project inherits defaults and overrides only the prompt.
|
||||||
|
expect(config.projects['sample'], isNotNull);
|
||||||
|
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||||
|
'@helper',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.prompt,
|
||||||
|
'project {{project_key}}',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.responders.single.id,
|
||||||
|
'fallback',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('IssueAssistantApp.runOnce', () {
|
||||||
|
test(
|
||||||
|
'given an explicit mention when polling then the app posts the responder reply once',
|
||||||
|
() async {
|
||||||
|
// Given a fake gh client and a fake responder that emits a planning reply.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-run-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 12,
|
||||||
|
'title': 'Need architecture help',
|
||||||
|
'body': 'Please review the API layering.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/12',
|
||||||
|
'updated_at': '2026-04-04T12:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': [
|
||||||
|
{'name': 'help'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 50,
|
||||||
|
'body': '@helper can you plan the architecture?',
|
||||||
|
'html_url': 'https://example.test/issues/12#issuecomment-50',
|
||||||
|
'created_at': '2026-04-04T12:00:00Z',
|
||||||
|
'updated_at': '2026-04-04T12:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final replyJson = jsonEncode({
|
||||||
|
'decision': 'reply',
|
||||||
|
'mode': 'planning',
|
||||||
|
'markdown': 'Plan:\n- separate service and persistence',
|
||||||
|
'summary': 'posted planning advice',
|
||||||
|
});
|
||||||
|
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "\$*" >> "${ghLog.path}"
|
||||||
|
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(issueData)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?per_page=100" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(commentsData)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
{"id": 700, "html_url": "https://example.test/comment/700", "body": "posted"}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "unexpected gh args: \$*" >&2
|
||||||
|
exit 1
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat >/dev/null
|
||||||
|
cat <<'JSON'
|
||||||
|
$replyJson
|
||||||
|
JSON
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
defaultBranch: main
|
||||||
|
''');
|
||||||
|
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: config,
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the app processes a single polling cycle.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then a GitHub comment is posted for the mentioned issue.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) =>
|
||||||
|
line.contains('repos/owner/sample/issues/12/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'given a thread update without a useful delta when polling then the app records no reply',
|
||||||
|
() async {
|
||||||
|
// Given a fake gh client and a responder that explicitly returns no_reply.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-no-reply-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 42,
|
||||||
|
'title': 'Existing discussion',
|
||||||
|
'body': 'Initial context',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/42',
|
||||||
|
'updated_at': '2026-04-04T13:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 99,
|
||||||
|
'body': 'I think this is already covered.',
|
||||||
|
'html_url': 'https://example.test/issues/42#issuecomment-99',
|
||||||
|
'created_at': '2026-04-04T13:00:00Z',
|
||||||
|
'updated_at': '2026-04-04T13:00:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final noReplyJson = jsonEncode({
|
||||||
|
'decision': 'no_reply',
|
||||||
|
'mode': 'planning',
|
||||||
|
'summary': 'thread already resolved',
|
||||||
|
});
|
||||||
|
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?* ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(issueData)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/42/comments?per_page=100" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
${jsonEncode(commentsData)}
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "unexpected gh args: \$*" >&2
|
||||||
|
exit 1
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', ghScript.path]);
|
||||||
|
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cat >/dev/null
|
||||||
|
cat <<'JSON'
|
||||||
|
$noReplyJson
|
||||||
|
JSON
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
|
||||||
|
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 a poll cycle evaluates the unchanged discussion.
|
||||||
|
await app.runOnce();
|
||||||
|
final thread = await app.database.findIssueThread('sample', 42);
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then the issue is marked as evaluated without storing a comment.
|
||||||
|
expect(thread, isNotNull);
|
||||||
|
expect(thread!.lastDecision, 'no_reply');
|
||||||
|
expect(thread.lastCommentId, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initGitRepo(Directory directory) async {
|
||||||
|
await Process.run('git', ['init'], workingDirectory: directory.path);
|
||||||
|
await Process.run('git', [
|
||||||
|
'config',
|
||||||
|
'user.email',
|
||||||
|
'test@example.com',
|
||||||
|
], workingDirectory: directory.path);
|
||||||
|
await Process.run('git', [
|
||||||
|
'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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue