feat: 1st commit

This commit is contained in:
insleker 2026-04-04 18:52:01 +08:00
parent 8ce1cefe39
commit da87c670ae
16 changed files with 6488 additions and 28 deletions

31
.gitignore vendored
View File

@ -1,29 +1,6 @@
# See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
.packages
build/
# If you're building an application, you may want to check-in your pubspec.lock
pubspec.lock
# Directory created by dartdoc
# 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
.code_work_spawner.sqlite3
agent-orchestrator.yaml

3
CHANGELOG.md Normal file
View File

@ -0,0 +1,3 @@
## 1.0.0
- Initial version.

102
README.md
View File

@ -1,2 +1,102 @@
# 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)

30
analysis_options.yaml Normal file
View File

@ -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

View File

@ -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();
}
}

93
docs/arch.md Normal file
View File

@ -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
```

View File

@ -0,0 +1,4 @@
export 'src/config.dart';
export 'src/database.dart';
export 'src/github_client.dart';
export 'src/issue_assistant_app.dart';

145
lib/src/cli_responder.dart Normal file
View File

@ -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,
);
}
}

321
lib/src/config.dart Normal file
View File

@ -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();
}

275
lib/src/database.dart Normal file
View File

@ -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),
),
);
}
}

4519
lib/src/database.g.dart Normal file

File diff suppressed because it is too large Load Diff

227
lib/src/github_client.dart Normal file
View File

@ -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;
}

View File

@ -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 -->';
}
}

View File

@ -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);
}
}
}

22
pubspec.yaml Normal file
View File

@ -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

View File

@ -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);
}