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
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
## 1.0.0
|
||||
|
||||
- Initial version.
|
||||
102
README.md
102
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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