fix: issue responder context and mention replies
This commit is contained in:
parent
42b89719fd
commit
558db6991c
|
|
@ -74,7 +74,7 @@ AO-style `dataDir`, `worktreeDir`, and core `projects` fields, and uses
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
```
|
```
|
||||||
|
|
||||||
Single poll cycle:
|
Single poll cycle:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
# sources:
|
||||||
|
builders:
|
||||||
|
drift_dev:
|
||||||
|
generate_for:
|
||||||
|
- lib/src/database.dart
|
||||||
|
# json_serializable:
|
||||||
|
# generate_for:
|
||||||
|
# - lib/**/model/*.dart
|
||||||
|
|
@ -9,7 +9,7 @@ defaults:
|
||||||
issueAssistant:
|
issueAssistant:
|
||||||
enabled: true
|
enabled: true
|
||||||
pollInterval: 5m
|
pollInterval: 5m
|
||||||
mentionTriggers: ["@codex"]
|
mentionTriggers: ["@codex", "@helper"]
|
||||||
responders:
|
responders:
|
||||||
# Replace this with a responder available on your machine.
|
# Replace this with a responder available on your machine.
|
||||||
- id: codex
|
- id: codex
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,13 @@ class CliResponderRunner {
|
||||||
final stdoutText = await stdoutFuture;
|
final stdoutText = await stdoutFuture;
|
||||||
final stderrText = await stderrFuture;
|
final stderrText = await stderrFuture;
|
||||||
if (exitCode != 0) {
|
if (exitCode != 0) {
|
||||||
throw ProcessException(
|
throw CliResponderExecutionException(
|
||||||
responder.command,
|
responderId: responder.id,
|
||||||
responder.args,
|
command: responder.command,
|
||||||
stderrText.isEmpty ? stdoutText : stderrText,
|
arguments: responder.args,
|
||||||
exitCode,
|
exitCode: exitCode,
|
||||||
|
rawStdout: stdoutText,
|
||||||
|
rawStderr: stderrText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +144,31 @@ class CliResponderRunner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CliResponderExecutionException implements Exception {
|
||||||
|
CliResponderExecutionException({
|
||||||
|
required this.responderId,
|
||||||
|
required this.command,
|
||||||
|
required this.arguments,
|
||||||
|
required this.exitCode,
|
||||||
|
required this.rawStdout,
|
||||||
|
required this.rawStderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String responderId;
|
||||||
|
final String command;
|
||||||
|
final List<String> arguments;
|
||||||
|
final int exitCode;
|
||||||
|
final String rawStdout;
|
||||||
|
final String rawStderr;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final details = rawStderr.isEmpty ? rawStdout : rawStderr;
|
||||||
|
return 'CliResponderExecutionException(responderId: $responderId, '
|
||||||
|
'command: $command, exitCode: $exitCode, details: $details)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ResponderResult {
|
class ResponderResult {
|
||||||
ResponderResult({
|
ResponderResult({
|
||||||
required this.responderId,
|
required this.responderId,
|
||||||
|
|
@ -163,6 +190,23 @@ class ResponderResult {
|
||||||
|
|
||||||
bool get shouldReply => decision == 'reply';
|
bool get shouldReply => decision == 'reply';
|
||||||
|
|
||||||
|
ResponderResult forceReply() {
|
||||||
|
final fallbackMarkdown = markdown.isNotEmpty
|
||||||
|
? markdown
|
||||||
|
: summary.isNotEmpty
|
||||||
|
? summary
|
||||||
|
: 'I reviewed the latest mention but could not produce a detailed response.';
|
||||||
|
return ResponderResult(
|
||||||
|
responderId: responderId,
|
||||||
|
decision: 'reply',
|
||||||
|
mode: mode,
|
||||||
|
markdown: fallbackMarkdown,
|
||||||
|
summary: summary,
|
||||||
|
rawStdout: rawStdout,
|
||||||
|
rawStderr: rawStderr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory ResponderResult.fromJson(
|
factory ResponderResult.fromJson(
|
||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
required String responderId,
|
required String responderId,
|
||||||
|
|
|
||||||
|
|
@ -315,8 +315,6 @@ class IssueAssistantConfig {
|
||||||
static const String _defaultPrompt = '''
|
static const String _defaultPrompt = '''
|
||||||
You are a GitHub issue thread assistant.
|
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:
|
Decide whether to reply. Rules:
|
||||||
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
- If the latest trigger explicitly mentions the assistant, always return "reply".
|
||||||
- If the discussion already has a good enough answer, return "no_reply".
|
- If the discussion already has a good enough answer, return "no_reply".
|
||||||
|
|
@ -332,6 +330,23 @@ JSON schema:
|
||||||
"markdown": "markdown response, omitted for no_reply",
|
"markdown": "markdown response, omitted for no_reply",
|
||||||
"summary": "short internal summary"
|
"summary": "short internal summary"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Repository metadata:
|
||||||
|
- repo: {{repo}}
|
||||||
|
- project_key: {{project_key}}
|
||||||
|
- project_path: {{project_path}}
|
||||||
|
- issue_number: {{issue_number}}
|
||||||
|
- issue_title: {{issue_title}}
|
||||||
|
- issue_url: {{issue_url}}
|
||||||
|
- mention_triggers: {{mention_triggers}}
|
||||||
|
- trigger: {{trigger}}
|
||||||
|
- capabilities: {{capabilities}}
|
||||||
|
|
||||||
|
Issue body:
|
||||||
|
{{issue_body}}
|
||||||
|
|
||||||
|
Current issue thread JSON:
|
||||||
|
{{thread_json}}
|
||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,9 +197,9 @@ class IssueAssistantApp {
|
||||||
|
|
||||||
for (final responder in project.issueAssistant.responders) {
|
for (final responder in project.issueAssistant.responders) {
|
||||||
final needsWorktree = _looksLikeBugVerification(thread);
|
final needsWorktree = _looksLikeBugVerification(thread);
|
||||||
workspacePath = needsWorktree
|
workspacePath = await workspaceManager.createEphemeralWorktree(
|
||||||
? await workspaceManager.createEphemeralWorktree(project.path)
|
project.path,
|
||||||
: project.path;
|
);
|
||||||
_log(
|
_log(
|
||||||
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
'run responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
'mode_hint=${needsWorktree ? "bug_verification" : "planning"} '
|
||||||
|
|
@ -225,15 +225,43 @@ class IssueAssistantApp {
|
||||||
stdout: result.rawStdout,
|
stdout: result.rawStdout,
|
||||||
stderr: result.rawStderr,
|
stderr: result.rawStderr,
|
||||||
);
|
);
|
||||||
|
_logResponderOutput(
|
||||||
|
responderId: responder.id,
|
||||||
|
issueKey: '${project.key}#${issue.number}',
|
||||||
|
stdout: result.rawStdout,
|
||||||
|
stderr: result.rawStderr,
|
||||||
|
);
|
||||||
_log(
|
_log(
|
||||||
'responder=${responder.id}b issue=${project.key}#${issue.number}, '
|
'responder=${responder.id} issue=${project.key}#${issue.number}, '
|
||||||
'title="${thread.issue.title}", '
|
'title="${thread.issue.title}", '
|
||||||
'decision=${result.decision}, mode=${result.mode}',
|
'decision=${result.decision}, mode=${result.mode}',
|
||||||
);
|
);
|
||||||
selectedResult = result;
|
selectedResult = forceReply && !result.shouldReply
|
||||||
|
? result.forceReply()
|
||||||
|
: result;
|
||||||
|
if (forceReply && !result.shouldReply) {
|
||||||
|
_log(
|
||||||
|
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
|
'override=force_reply original_decision=${result.decision}',
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
final rawStdout = switch (error) {
|
||||||
|
CliResponderExecutionException(:final rawStdout) => rawStdout,
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
final rawStderr = switch (error) {
|
||||||
|
CliResponderExecutionException(:final rawStderr) => rawStderr,
|
||||||
|
_ => error.toString(),
|
||||||
|
};
|
||||||
|
_logResponderOutput(
|
||||||
|
responderId: responder.id,
|
||||||
|
issueKey: '${project.key}#${issue.number}',
|
||||||
|
stdout: rawStdout,
|
||||||
|
stderr: rawStderr,
|
||||||
|
);
|
||||||
_log(
|
_log(
|
||||||
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
'responder=${responder.id} issue=${project.key}#${issue.number} '
|
||||||
'failed error=$error',
|
'failed error=$error',
|
||||||
|
|
@ -245,11 +273,11 @@ class IssueAssistantApp {
|
||||||
decision: 'no_reply',
|
decision: 'no_reply',
|
||||||
success: false,
|
success: false,
|
||||||
workspacePath: workspacePath,
|
workspacePath: workspacePath,
|
||||||
stdout: '',
|
stdout: rawStdout,
|
||||||
stderr: error.toString(),
|
stderr: rawStderr,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (workspacePath != project.path) {
|
if (workspacePath != null) {
|
||||||
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
await workspaceManager.disposeEphemeralWorktree(workspacePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -393,4 +421,18 @@ class IssueAssistantApp {
|
||||||
void _logError(String message) {
|
void _logError(String message) {
|
||||||
_logger.severe(message);
|
_logger.severe(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _logResponderOutput({
|
||||||
|
required String responderId,
|
||||||
|
required String issueKey,
|
||||||
|
required String stdout,
|
||||||
|
required String stderr,
|
||||||
|
}) {
|
||||||
|
if (stdout.isNotEmpty) {
|
||||||
|
_log('responder=$responderId issue=$issueKey stream=stdout\n$stdout');
|
||||||
|
}
|
||||||
|
if (stderr.isNotEmpty) {
|
||||||
|
_log('responder=$responderId issue=$issueKey stream=stderr\n$stderr');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
|
@ -424,6 +425,219 @@ projects:
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Log raw responder output for debugging
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And GitHub issue data containing an explicit assistant mention
|
||||||
|
/// And a responder that writes stderr diagnostics alongside a no_reply JSON payload
|
||||||
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
|
/// When the app processes one polling cycle while app logs are captured
|
||||||
|
/// Then the application logs include the responder stdout payload
|
||||||
|
/// And the application logs include the responder stderr diagnostics
|
||||||
|
/// ```
|
||||||
|
test('Log raw responder output for debugging', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp('cws-log-output-');
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 21,
|
||||||
|
'title': 'Need responder diagnostics',
|
||||||
|
'body': 'Inspect the responder output.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/21',
|
||||||
|
'updated_at': '2026-04-04T14:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 210,
|
||||||
|
'body': '@helper please explain why you skipped this',
|
||||||
|
'html_url': 'https://example.test/issues/21#issuecomment-210',
|
||||||
|
'created_at': '2026-04-04T14:10:00Z',
|
||||||
|
'updated_at': '2026-04-04T14:10:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await _writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {21: commentsData},
|
||||||
|
postCommentIssueNumber: 21,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that writes stderr diagnostics alongside a no_reply JSON payload.
|
||||||
|
final responderScript = File(p.join(sandbox.path, 'responder-debug.sh'));
|
||||||
|
await _writeResponderScript(responderScript, {
|
||||||
|
'decision': 'no_reply',
|
||||||
|
'mode': 'planning',
|
||||||
|
'summary': 'diagnostic skip',
|
||||||
|
}, stderr: 'codex diagnostic trace');
|
||||||
|
|
||||||
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
final logMessages = <String>[];
|
||||||
|
final previousLevel = Logger.root.level;
|
||||||
|
Logger.root.level = Level.INFO;
|
||||||
|
final subscription = Logger.root.onRecord.listen((record) {
|
||||||
|
logMessages.add(record.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the app processes one polling cycle while app logs are captured.
|
||||||
|
await app.runOnce();
|
||||||
|
await subscription.cancel();
|
||||||
|
Logger.root.level = previousLevel;
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then the application logs include the responder stdout payload.
|
||||||
|
expect(logMessages.join('\n'), contains('"decision":"no_reply"'));
|
||||||
|
|
||||||
|
// And the application logs include the responder stderr diagnostics.
|
||||||
|
expect(logMessages.join('\n'), contains('codex diagnostic trace'));
|
||||||
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Run mention replies from an ephemeral worktree with issue context
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And GitHub issue data containing an explicit assistant mention
|
||||||
|
/// And a responder that records its working directory and stdin before returning no_reply
|
||||||
|
/// And an orchestrator-style config pointing to the fake repo and responder
|
||||||
|
/// When the app processes one polling cycle
|
||||||
|
/// Then the responder runs from an ephemeral worktree instead of the source checkout
|
||||||
|
/// And the responder stdin includes the latest issue comment text
|
||||||
|
/// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant
|
||||||
|
/// ```
|
||||||
|
test('Run mention replies from an ephemeral worktree with issue context', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-mention-worktree-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await _initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And GitHub issue data containing an explicit assistant mention.
|
||||||
|
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||||
|
final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl'));
|
||||||
|
final issueData = [
|
||||||
|
{
|
||||||
|
'number': 24,
|
||||||
|
'title': 'Need your thoughts',
|
||||||
|
'body': 'Please review the CLI workflow.',
|
||||||
|
'state': 'open',
|
||||||
|
'html_url': 'https://example.test/issues/24',
|
||||||
|
'updated_at': '2026-04-04T15:30:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
'labels': const [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
final commentsData = [
|
||||||
|
{
|
||||||
|
'id': 240,
|
||||||
|
'body': '@helper give me your though about this CLI flow',
|
||||||
|
'html_url': 'https://example.test/issues/24#issuecomment-240',
|
||||||
|
'created_at': '2026-04-04T15:30:00Z',
|
||||||
|
'updated_at': '2026-04-04T15:30:00Z',
|
||||||
|
'user': {'login': 'reporter'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await _writeFakeGhScript(
|
||||||
|
ghScript: ghScript,
|
||||||
|
issueListResponse: issueData,
|
||||||
|
commentResponses: {24: commentsData},
|
||||||
|
ghLog: ghLog,
|
||||||
|
postCommentIssueNumber: 24,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a responder that records its working directory and stdin before returning no_reply.
|
||||||
|
final cwdCapture = File(p.join(sandbox.path, 'responder.cwd'));
|
||||||
|
final stdinCapture = File(p.join(sandbox.path, 'responder.stdin'));
|
||||||
|
final responderScript = File(
|
||||||
|
p.join(sandbox.path, 'responder-capture.sh'),
|
||||||
|
);
|
||||||
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
pwd > "${cwdCapture.path}"
|
||||||
|
cat > "${stdinCapture.path}"
|
||||||
|
cat <<'JSON'
|
||||||
|
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
|
||||||
|
JSON
|
||||||
|
''');
|
||||||
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
|
|
||||||
|
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
repo: owner/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
ghCommand: ghScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the app processes one polling cycle.
|
||||||
|
await app.runOnce();
|
||||||
|
await app.close();
|
||||||
|
|
||||||
|
// Then the responder runs from an ephemeral worktree instead of the source checkout.
|
||||||
|
final capturedCwd = await cwdCapture.readAsString();
|
||||||
|
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
|
||||||
|
|
||||||
|
// And the responder stdin includes the latest issue comment text.
|
||||||
|
final capturedStdin = await stdinCapture.readAsString();
|
||||||
|
expect(
|
||||||
|
capturedStdin,
|
||||||
|
contains('@helper give me your though about this CLI flow'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the app still posts a GitHub comment because the thread explicitly mentioned the assistant.
|
||||||
|
final logLines = await ghLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
logLines
|
||||||
|
.where(
|
||||||
|
(line) => line.contains('repos/owner/sample/issues/24/comments'),
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Scenario: Accept codex style jsonl responder output
|
/// Scenario: Accept codex style jsonl responder output
|
||||||
/// Given a temporary project checkout that can be used as the local repo path
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
|
@ -671,14 +885,18 @@ Future<void> _writeFakeGhScript({
|
||||||
|
|
||||||
Future<void> _writeResponderScript(
|
Future<void> _writeResponderScript(
|
||||||
File responderScript,
|
File responderScript,
|
||||||
Map<String, Object?> response,
|
Map<String, Object?> response, {
|
||||||
) async {
|
String stderr = '',
|
||||||
|
int exitCode = 0,
|
||||||
|
}) async {
|
||||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cat >/dev/null
|
cat >/dev/null
|
||||||
|
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"}
|
||||||
cat <<'JSON'
|
cat <<'JSON'
|
||||||
${jsonEncode(response)}
|
${jsonEncode(response)}
|
||||||
JSON
|
JSON
|
||||||
|
exit $exitCode
|
||||||
''');
|
''');
|
||||||
await Process.run('chmod', ['+x', responderScript.path]);
|
await Process.run('chmod', ['+x', responderScript.path]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue