refactor: remove shell-dependent test seams from CLI integration paths

The Windows process launcher no longer needs Bash/PowerShell mediation, and the event-source tests now exercise CLI boundaries through an injectable executor instead of generated scripts. This keeps production behavior on real external commands while letting tests model long-running process streams and kill semantics in Dart.

Constraint: Tests must not mock CLIs by writing generated Dart or shell scripts and executing them.
Rejected: Use generated Dart script fixtures | still depends on subprocess execution for mocks.
Rejected: Use only mocktail mocks for process handles | stream, kill, and exit behavior are clearer as a small stateful fake.
Confidence: high
Scope-risk: moderate
Directive: Keep long-running CLI test behavior behind ExternalCommandExecutor rather than reintroducing script fixtures.
Tested: dart analyze; dart run tool\\validate_gherkin_test_format.dart; dart test
Not-tested: Real gh/gosmee webhook forwarding against live services
This commit is contained in:
insleker 2026-04-11 15:34:20 +08:00
parent 041225b2aa
commit c55643337c
28 changed files with 1232 additions and 1200 deletions

3
.gitignore vendored
View File

@ -13,3 +13,6 @@ agent-orchestrator.yaml
!/skills/dart-gherkin-tests !/skills/dart-gherkin-tests
*.g.dart *.g.dart
**/*.exe **/*.exe
/CWS*.yaml
/CWS*.yml
dart_test.yaml

View File

@ -7,6 +7,7 @@ import '../config/app_logger.dart';
import 'cli_responder.dart'; import 'cli_responder.dart';
import '../config/config.dart'; import '../config/config.dart';
import 'database.dart'; import 'database.dart';
import 'process_launcher.dart';
import '../issue_tracker/issue_event_source/base.dart'; import '../issue_tracker/issue_event_source/base.dart';
import '../issue_tracker/issue_event_source/gitea_gosmee.dart'; import '../issue_tracker/issue_event_source/gitea_gosmee.dart';
import '../issue_tracker/issue_event_source/gitlab_gosmee.dart'; import '../issue_tracker/issue_event_source/gitlab_gosmee.dart';
@ -46,12 +47,17 @@ class IssueAssistantApp {
String glabCommand = 'glab', String glabCommand = 'glab',
String gosmeeCommand = 'gosmee', String gosmeeCommand = 'gosmee',
String teaCommand = 'tea', String teaCommand = 'tea',
CliResponderRunner? responderRunner,
ExternalCommandExecutor? commandExecutor,
}) async { }) async {
final database = AppDatabase.open(databasePath); final database = AppDatabase.open(databasePath);
final externalCommandExecutor =
commandExecutor ?? const DefaultExternalCommandExecutor();
final issueTrackerClient = IssueTrackerClient( final issueTrackerClient = IssueTrackerClient(
ghCommand: ghCommand, ghCommand: ghCommand,
glabCommand: glabCommand, glabCommand: glabCommand,
teaCommand: teaCommand, teaCommand: teaCommand,
commandExecutor: externalCommandExecutor,
); );
final pollingIssueEventSource = PollingIssueEventSource( final pollingIssueEventSource = PollingIssueEventSource(
database: database, database: database,
@ -71,26 +77,30 @@ class IssueAssistantApp {
IssueTrackerEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( IssueTrackerEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand, gosmeeCommand: gosmeeCommand,
commandExecutor: externalCommandExecutor,
logger: _logger, logger: _logger,
), ),
IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource( IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand, gosmeeCommand: gosmeeCommand,
commandExecutor: externalCommandExecutor,
logger: _logger, logger: _logger,
), ),
IssueTrackerEventSourceKind.ghWebhookForward: IssueTrackerEventSourceKind.ghWebhookForward:
GitHubWebhookForwardIssueEventSource( GitHubWebhookForwardIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
commandExecutor: externalCommandExecutor,
logger: _logger, logger: _logger,
), ),
IssueTrackerEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource( IssueTrackerEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource(
issueTrackerClient: issueTrackerClient, issueTrackerClient: issueTrackerClient,
gosmeeCommand: gosmeeCommand, gosmeeCommand: gosmeeCommand,
commandExecutor: externalCommandExecutor,
logger: _logger, logger: _logger,
), ),
}, },
), ),
responderRunner: CliResponderRunner(), responderRunner: responderRunner ?? CliResponderRunner(),
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir), workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs( notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
project.issueAssistant.notifications, project.issueAssistant.notifications,

View File

@ -10,13 +10,11 @@ class ResolvedExternalCommand {
ResolvedExternalCommand({ ResolvedExternalCommand({
required this.executable, required this.executable,
required this.arguments, required this.arguments,
required this.usesBashShim,
required this.runInShell, required this.runInShell,
}); });
final String executable; final String executable;
final List<String> arguments; final List<String> arguments;
final bool usesBashShim;
final bool runInShell; final bool runInShell;
} }
@ -24,6 +22,100 @@ final AppLogger _processLauncherLogger = AppLogger(
'code_work_spawner.process_launcher', 'code_work_spawner.process_launcher',
); );
abstract interface class ExternalCommandExecutor {
Future<ProcessResult> run(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment,
Duration? timeout,
});
Future<StartedExternalCommand> start(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment,
bool runInShell,
ProcessStartMode mode,
});
}
abstract interface class StartedExternalCommand {
Stream<List<int>> get stdout;
Stream<List<int>> get stderr;
Future<int> get exitCode;
bool kill([ProcessSignal signal = ProcessSignal.sigterm]);
}
class DefaultExternalCommandExecutor implements ExternalCommandExecutor {
const DefaultExternalCommandExecutor();
@override
Future<ProcessResult> run(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
Duration? timeout,
}) {
return runExternalCommand(
executable,
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
timeout: timeout,
);
}
@override
Future<StartedExternalCommand> start(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool runInShell = false,
ProcessStartMode mode = ProcessStartMode.normal,
}) async {
return ProcessStartedExternalCommand(
await startExternalCommand(
executable,
arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
mode: mode,
),
);
}
}
class ProcessStartedExternalCommand implements StartedExternalCommand {
ProcessStartedExternalCommand(this._process);
final Process _process;
@override
Stream<List<int>> get stdout => _process.stdout;
@override
Stream<List<int>> get stderr => _process.stderr;
@override
Future<int> get exitCode => _process.exitCode;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
return _process.kill(signal);
}
}
ResolvedExternalCommand resolveExternalCommandForCurrentPlatform( ResolvedExternalCommand resolveExternalCommandForCurrentPlatform(
String executable, String executable,
List<String> arguments, { List<String> arguments, {
@ -35,27 +127,9 @@ ResolvedExternalCommand resolveExternalCommandForCurrentPlatform(
localExecutablePath ?? _resolveExecutablePathOnPath(executable); localExecutablePath ?? _resolveExecutablePathOnPath(executable);
final effectiveExecutable = resolvedExecutablePath ?? executable; final effectiveExecutable = resolvedExecutablePath ?? executable;
final extension = p.extension(effectiveExecutable).toLowerCase(); final extension = p.extension(effectiveExecutable).toLowerCase();
final shouldUseBash = extension.isEmpty || extension == '.sh';
if (isWindowsPlatform && shouldUseBash && localExecutablePath != null) {
final bashScriptPath = _toBashPath(localExecutablePath);
_processLauncherLogger.debug(
'resolved WSL bash shim executable=$localExecutablePath '
'bash_script=$bashScriptPath '
'args=${_renderLauncherArguments(arguments)}',
);
return ResolvedExternalCommand(
executable: 'wsl.exe',
arguments: ['--exec', 'bash', bashScriptPath, ...arguments],
usesBashShim: true,
runInShell: false,
);
}
return ResolvedExternalCommand( return ResolvedExternalCommand(
executable: effectiveExecutable, executable: effectiveExecutable,
arguments: arguments, arguments: arguments,
usesBashShim: false,
// Only batch files require shell mediation on Windows. Native executables // Only batch files require shell mediation on Windows. Native executables
// such as Scoop shims should run directly to avoid hanging shell wrappers. // such as Scoop shims should run directly to avoid hanging shell wrappers.
runInShell: isWindowsPlatform && _requiresWindowsShell(extension), runInShell: isWindowsPlatform && _requiresWindowsShell(extension),
@ -87,18 +161,6 @@ bool _looksLikePathReference(String executable) {
executable.startsWith('.'); executable.startsWith('.');
} }
String _toBashPath(String windowsPath) {
final normalized = windowsPath.replaceAll('\\', '/');
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized);
if (drivePrefixMatch == null) {
return normalized;
}
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase();
final rest = drivePrefixMatch.group(2)!;
return '/mnt/$driveLetter/$rest';
}
Future<ProcessResult> runExternalCommand( Future<ProcessResult> runExternalCommand(
String executable, String executable,
List<String> arguments, { List<String> arguments, {
@ -111,67 +173,26 @@ Future<ProcessResult> runExternalCommand(
executable, executable,
arguments, arguments,
); );
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( const mode = 'process-start';
environment: environment,
includeParentEnvironment: includeParentEnvironment,
usesBashShim: resolved.usesBashShim,
);
final mode = Platform.isWindows && !resolved.usesBashShim
? 'windows-wrapper'
: 'process-run';
_processLauncherLogger.debug( _processLauncherLogger.debug(
'launch mode=$mode executable=${resolved.executable} ' 'launch mode=$mode executable=${resolved.executable} '
'requested_executable=$executable run_in_shell=${resolved.runInShell} ' 'requested_executable=$executable run_in_shell=${resolved.runInShell} '
'uses_bash_shim=${resolved.usesBashShim} '
'working_directory=${workingDirectory ?? "(inherit)"} ' 'working_directory=${workingDirectory ?? "(inherit)"} '
'timeout_ms=${timeout?.inMilliseconds ?? 0} ' 'timeout_ms=${timeout?.inMilliseconds ?? 0} '
'args=${_renderLauncherArguments(resolved.arguments)}', 'args=${_renderLauncherArguments(resolved.arguments)}',
); );
if (Platform.isWindows && !resolved.usesBashShim) { final stopwatch = Stopwatch()..start();
_processLauncherLogger.debug( try {
'process_run in runExternalCommand - windows wrapper', _processLauncherLogger.debug('process_start in runExternalCommand');
); final result = await _runExternalCommandWithCapturedOutput(
return _runExternalCommandViaWindowsWrapper(
executable: resolved.executable, executable: resolved.executable,
arguments: resolved.arguments, arguments: resolved.arguments,
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
environment: effectiveEnvironment, environment: environment,
includeParentEnvironment: includeParentEnvironment, includeParentEnvironment: includeParentEnvironment,
runInShell: resolved.runInShell,
timeout: timeout, timeout: timeout,
); );
}
final stopwatch = Stopwatch()..start();
try {
_processLauncherLogger.debug('process_run in runExternalCommand');
final result = timeout == null
? await Process.run(
resolved.executable,
resolved.arguments,
workingDirectory: workingDirectory,
environment: effectiveEnvironment,
includeParentEnvironment: includeParentEnvironment,
runInShell: resolved.runInShell,
stdoutEncoding: utf8,
stderrEncoding: utf8,
)
: await Process.run(
resolved.executable,
resolved.arguments,
workingDirectory: workingDirectory,
environment: effectiveEnvironment,
includeParentEnvironment: includeParentEnvironment,
runInShell: resolved.runInShell,
stdoutEncoding: utf8,
stderrEncoding: utf8,
).timeout(
timeout,
onTimeout: () {
throw TimeoutException(
'Command timed out after ${timeout.inMilliseconds}ms.',
timeout,
);
},
);
stopwatch.stop(); stopwatch.stop();
_processLauncherLogger.debug( _processLauncherLogger.debug(
'finished mode=$mode exit_code=${result.exitCode} ' 'finished mode=$mode exit_code=${result.exitCode} '
@ -189,155 +210,47 @@ Future<ProcessResult> runExternalCommand(
} }
} }
Future<ProcessResult> _runExternalCommandViaWindowsWrapper({ Future<ProcessResult> _runExternalCommandWithCapturedOutput({
required String executable, required String executable,
required List<String> arguments, required List<String> arguments,
required String? workingDirectory, required String? workingDirectory,
required Map<String, String>? environment, required Map<String, String>? environment,
required bool includeParentEnvironment, required bool includeParentEnvironment,
required bool runInShell,
required Duration? timeout, required Duration? timeout,
}) async { }) async {
const timeoutExitCode = 124; final process = await Process.start(
final sandbox = await Directory.systemTemp.createTemp( executable,
'cws-process-launcher-', arguments,
);
final stdoutPath = p.join(sandbox.path, 'stdout.txt');
final stderrPath = p.join(sandbox.path, 'stderr.txt');
final scriptPath = p.join(sandbox.path, 'run.ps1');
final script = File(scriptPath);
_processLauncherLogger.debug('after fopen');
try {
await script.writeAsString(
_buildWindowsWrapperScript(
executable: executable,
arguments: arguments,
workingDirectory: workingDirectory,
stdoutPath: stdoutPath,
stderrPath: stderrPath,
timeout: timeout,
),
);
final stopwatch = Stopwatch()..start();
_processLauncherLogger.debug(
'wrapper start executable=$executable '
'script=${script.path} stdout_path=$stdoutPath stderr_path=$stderrPath',
);
final wrapperResult = await Process.run(
'powershell.exe',
<String>[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script.path,
],
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
environment: environment, environment: environment,
includeParentEnvironment: includeParentEnvironment, includeParentEnvironment: includeParentEnvironment,
stdoutEncoding: utf8, runInShell: runInShell,
stderrEncoding: utf8,
); );
stopwatch.stop(); final stdoutFuture = process.stdout.transform(utf8.decoder).join();
final stdoutText = await _readWrapperOutput(stdoutPath); final stderrFuture = process.stderr.transform(utf8.decoder).join();
final stderrText = await _readWrapperOutput(stderrPath); try {
_processLauncherLogger.debug( final exitCode = timeout == null
'wrapper finished exit_code=${wrapperResult.exitCode} ' ? await process.exitCode
'duration_ms=${stopwatch.elapsedMilliseconds} ' : await process.exitCode.timeout(
'stdout_len=${stdoutText.length} stderr_len=${stderrText.length} ' timeout,
'wrapper_stdout_len=${wrapperResult.stdout.toString().length} ' onTimeout: () {
'wrapper_stderr_len=${wrapperResult.stderr.toString().length}', process.kill(ProcessSignal.sigkill);
);
if (wrapperResult.exitCode == timeoutExitCode) {
throw TimeoutException( throw TimeoutException(
'Command timed out after ${timeout!.inMilliseconds}ms.', 'Command timed out after ${timeout.inMilliseconds}ms.',
timeout, timeout,
); );
} },
return ProcessResult(
wrapperResult.pid,
wrapperResult.exitCode,
stdoutText,
stderrText.isEmpty ? wrapperResult.stderr : stderrText,
); );
} finally { final stdoutText = await stdoutFuture;
await sandbox.delete(recursive: true); final stderrText = await stderrFuture;
return ProcessResult(process.pid, exitCode, stdoutText, stderrText);
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
rethrow;
} }
} }
String _buildWindowsWrapperScript({
required String executable,
required List<String> arguments,
required String? workingDirectory,
required String stdoutPath,
required String stderrPath,
required Duration? timeout,
}) {
final timeoutMilliseconds = timeout?.inMilliseconds ?? 0;
return '''
\$ErrorActionPreference = 'Stop'
\$stdoutPath = ${_powerShellSingleQuoted(stdoutPath)}
\$stderrPath = ${_powerShellSingleQuoted(stderrPath)}
try {
\$commandPath = ${_powerShellSingleQuoted(executable)}
\$startProcess = @{
FilePath = \$commandPath
ArgumentList = ${_renderPowerShellArray(arguments)}
PassThru = \$true
WindowStyle = 'Hidden'
RedirectStandardOutput = \$stdoutPath
RedirectStandardError = \$stderrPath
}
if (${workingDirectory == null ? '\$false' : '\$true'}) {
\$startProcess.WorkingDirectory = ${_powerShellSingleQuoted(workingDirectory ?? '')}
}
\$process = Start-Process @startProcess
\$timedOut = \$false
if ($timeoutMilliseconds -gt 0) {
if (-not \$process.WaitForExit($timeoutMilliseconds)) {
\$timedOut = \$true
try {
Stop-Process -Id \$process.Id -Force
} catch {
}
}
} else {
\$process.WaitForExit()
}
if (\$timedOut) {
exit 124
}
exit \$process.ExitCode
} catch {
Set-Content -LiteralPath \$stderrPath -Value \$_.ToString() -Encoding utf8
exit 127
}
''';
}
Future<String> _readWrapperOutput(String path) async {
final file = File(path);
if (!await file.exists()) {
return '';
}
return file.readAsString();
}
String _renderPowerShellArray(List<String> arguments) {
final renderedArguments = arguments.map(_powerShellSingleQuoted).join(', ');
return '@($renderedArguments)';
}
String _powerShellSingleQuoted(String value) {
return "'${value.replaceAll("'", "''")}'";
}
String _renderLauncherArguments(List<String> arguments) { String _renderLauncherArguments(List<String> arguments) {
return arguments.map(_summarizeLauncherArgument).join(' '); return arguments.map(_summarizeLauncherArgument).join(' ');
} }
@ -365,41 +278,13 @@ Future<Process> startExternalCommand(
executable, executable,
arguments, arguments,
); );
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand(
environment: environment,
includeParentEnvironment: includeParentEnvironment,
usesBashShim: resolved.usesBashShim,
);
return Process.start( return Process.start(
resolved.executable, resolved.executable,
resolved.arguments, resolved.arguments,
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
environment: effectiveEnvironment, environment: environment,
includeParentEnvironment: includeParentEnvironment, includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell || resolved.runInShell, runInShell: runInShell || resolved.runInShell,
mode: mode, mode: mode,
); );
} }
Map<String, String>? _effectiveEnvironmentForResolvedCommand({
required Map<String, String>? environment,
required bool includeParentEnvironment,
required bool usesBashShim,
}) {
if (!usesBashShim || environment == null || environment.isEmpty) {
return environment;
}
final effectiveEnvironment = Map<String, String>.from(environment);
final existingWslenvFromProcess = includeParentEnvironment
? Platform.environment['WSLENV']
: null;
final existingWslenv =
effectiveEnvironment['WSLENV'] ?? existingWslenvFromProcess;
final forwardedKeys = <String>{
...?existingWslenv?.split(':').where((entry) => entry.isNotEmpty),
...effectiveEnvironment.keys.where((key) => key != 'WSLENV'),
};
effectiveEnvironment['WSLENV'] = forwardedKeys.join(':');
return effectiveEnvironment;
}

View File

@ -20,8 +20,11 @@ class IssueTrackerClient {
this.opCommand = 'op', this.opCommand = 'op',
Duration? commandTimeout, Duration? commandTimeout,
AppLogger? logger, AppLogger? logger,
ExternalCommandExecutor? commandExecutor,
}) : commandTimeout = commandTimeout ?? const Duration(seconds: 15), }) : commandTimeout = commandTimeout ?? const Duration(seconds: 15),
_logger = logger ?? AppLogger('code_work_spawner.issue_tracker'); _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'),
_commandExecutor =
commandExecutor ?? const DefaultExternalCommandExecutor();
final String ghCommand; final String ghCommand;
final String glabCommand; final String glabCommand;
@ -29,6 +32,7 @@ class IssueTrackerClient {
final String opCommand; final String opCommand;
final Duration commandTimeout; final Duration commandTimeout;
final AppLogger _logger; final AppLogger _logger;
final ExternalCommandExecutor _commandExecutor;
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures = final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
<IssueTrackerProvider, Future<String?>>{}; <IssueTrackerProvider, Future<String?>>{};
final Map<String, Future<_OpenProjectProjectRef>> _openProjectProjectFutures = final Map<String, Future<_OpenProjectProjectRef>> _openProjectProjectFutures =
@ -792,7 +796,7 @@ class IssueTrackerClient {
); );
late final ProcessResult result; late final ProcessResult result;
try { try {
result = await runExternalCommand( result = await _commandExecutor.run(
command, command,
arguments, arguments,
timeout: commandTimeout, timeout: commandTimeout,

View File

@ -15,6 +15,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
GiteaGosmeeIssueEventSource({ GiteaGosmeeIssueEventSource({
required this.issueTrackerClient, required this.issueTrackerClient,
required this.gosmeeCommand, required this.gosmeeCommand,
required this.commandExecutor,
required AppLogger logger, required AppLogger logger,
}) : _logger = logger; }) : _logger = logger;
@ -22,10 +23,11 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
final IssueTrackerClient issueTrackerClient; final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand; final String gosmeeCommand;
final ExternalCommandExecutor commandExecutor;
final AppLogger _logger; final AppLogger _logger;
final Map<String, _QueuedGiteaIssue> _queuedIssues = {}; final Map<String, _QueuedGiteaIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{}; final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {}; final Map<String, StartedExternalCommand> _gosmeeProcesses = {};
final Map<String, int> _webhookIdsByProject = {}; final Map<String, int> _webhookIdsByProject = {};
final Map<String, RepositorySlug> _repoSlugsByProject = {}; final Map<String, RepositorySlug> _repoSlugsByProject = {};
@ -109,7 +111,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
'gitea/gosmee project=${project.key} start_client ' 'gitea/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"', 'command="$gosmeeCommand client $publicUrl $localUrl"',
); );
final process = await startExternalCommand(gosmeeCommand, [ final process = await commandExecutor.start(gosmeeCommand, [
'client', 'client',
publicUrl, publicUrl,
localUrl, localUrl,
@ -286,7 +288,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
} }
Future<String> _createGosmeeChannelUrl(String localUrl) async { Future<String> _createGosmeeChannelUrl(String localUrl) async {
final result = await runExternalCommand(gosmeeCommand, [ final result = await commandExecutor.run(gosmeeCommand, [
'--output', '--output',
'json', 'json',
'client', 'client',

View File

@ -15,6 +15,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
GitHubGosmeeIssueEventSource({ GitHubGosmeeIssueEventSource({
required this.issueTrackerClient, required this.issueTrackerClient,
required this.gosmeeCommand, required this.gosmeeCommand,
required this.commandExecutor,
required AppLogger logger, required AppLogger logger,
}) : _logger = logger; }) : _logger = logger;
@ -22,10 +23,11 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
final IssueTrackerClient issueTrackerClient; final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand; final String gosmeeCommand;
final ExternalCommandExecutor commandExecutor;
final AppLogger _logger; final AppLogger _logger;
final Map<String, _QueuedGitHubIssue> _queuedIssues = {}; final Map<String, _QueuedGitHubIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{}; final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {}; final Map<String, StartedExternalCommand> _gosmeeProcesses = {};
final Map<String, int> _webhookIdsByProject = {}; final Map<String, int> _webhookIdsByProject = {};
final Map<String, RepositorySlug> _repoSlugsByProject = {}; final Map<String, RepositorySlug> _repoSlugsByProject = {};
@ -109,7 +111,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
'github/gosmee project=${project.key} start_client ' 'github/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"', 'command="$gosmeeCommand client $publicUrl $localUrl"',
); );
final process = await startExternalCommand(gosmeeCommand, [ final process = await commandExecutor.start(gosmeeCommand, [
'client', 'client',
publicUrl, publicUrl,
localUrl, localUrl,
@ -286,7 +288,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
} }
Future<String> _createGosmeeChannelUrl(String localUrl) async { Future<String> _createGosmeeChannelUrl(String localUrl) async {
final result = await runExternalCommand(gosmeeCommand, [ final result = await commandExecutor.run(gosmeeCommand, [
'--output', '--output',
'json', 'json',
'client', 'client',

View File

@ -12,16 +12,18 @@ import 'base.dart';
class GitHubWebhookForwardIssueEventSource implements IssueEventSource { class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
GitHubWebhookForwardIssueEventSource({ GitHubWebhookForwardIssueEventSource({
required this.issueTrackerClient, required this.issueTrackerClient,
required this.commandExecutor,
required AppLogger logger, required AppLogger logger,
}) : _logger = logger; }) : _logger = logger;
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500); static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
final IssueTrackerClient issueTrackerClient; final IssueTrackerClient issueTrackerClient;
final ExternalCommandExecutor commandExecutor;
final AppLogger _logger; final AppLogger _logger;
final Map<String, _QueuedWebhookIssue> _queuedIssues = {}; final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{}; final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _forwardProcesses = {}; final Map<String, StartedExternalCommand> _forwardProcesses = {};
HttpServer? _server; HttpServer? _server;
Timer? _flushTimer; Timer? _flushTimer;
@ -82,7 +84,8 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
'github/webhook-forward project=${project.key} start_forwarder ' 'github/webhook-forward project=${project.key} start_forwarder '
'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"', 'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"',
); );
final process = await startExternalCommand(issueTrackerClient.ghCommand, [ final process = await commandExecutor
.start(issueTrackerClient.ghCommand, [
'webhook', 'webhook',
'forward', 'forward',
'--events=issues,issue_comment', '--events=issues,issue_comment',

View File

@ -13,6 +13,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
GitLabGosmeeIssueEventSource({ GitLabGosmeeIssueEventSource({
required this.issueTrackerClient, required this.issueTrackerClient,
required this.gosmeeCommand, required this.gosmeeCommand,
required this.commandExecutor,
required AppLogger logger, required AppLogger logger,
}) : _logger = logger; }) : _logger = logger;
@ -20,10 +21,11 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
final IssueTrackerClient issueTrackerClient; final IssueTrackerClient issueTrackerClient;
final String gosmeeCommand; final String gosmeeCommand;
final ExternalCommandExecutor commandExecutor;
final AppLogger _logger; final AppLogger _logger;
final Map<String, _QueuedGitLabIssue> _queuedIssues = {}; final Map<String, _QueuedGitLabIssue> _queuedIssues = {};
final Set<Future<void>> _activeDispatches = <Future<void>>{}; final Set<Future<void>> _activeDispatches = <Future<void>>{};
final Map<String, Process> _gosmeeProcesses = {}; final Map<String, StartedExternalCommand> _gosmeeProcesses = {};
final Map<String, int> _webhookIdsByProject = {}; final Map<String, int> _webhookIdsByProject = {};
final Map<String, String> _trackerProjectsByProject = {}; final Map<String, String> _trackerProjectsByProject = {};
@ -107,7 +109,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
'gitlab/gosmee project=${project.key} start_client ' 'gitlab/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"', 'command="$gosmeeCommand client $publicUrl $localUrl"',
); );
final process = await startExternalCommand(gosmeeCommand, [ final process = await commandExecutor.start(gosmeeCommand, [
'client', 'client',
publicUrl, publicUrl,
localUrl, localUrl,
@ -324,7 +326,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
} }
Future<String> _createGosmeeChannelUrl(String localUrl) async { Future<String> _createGosmeeChannelUrl(String localUrl) async {
final result = await runExternalCommand(gosmeeCommand, [ final result = await commandExecutor.run(gosmeeCommand, [
'--output', '--output',
'json', 'json',
'client', 'client',

View File

@ -30,6 +30,7 @@ dev_dependencies:
drift_dev: ^2.28.1 drift_dev: ^2.28.1
json_serializable: ">=6.11.1 <6.12.0" json_serializable: ">=6.11.1 <6.12.0"
lints: ^6.0.0 lints: ^6.0.0
mocktail: ^1.0.5
test: ^1.25.6 test: ^1.25.6
platforms: platforms:

View File

@ -146,6 +146,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -285,6 +287,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -367,6 +371,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.

View File

@ -102,6 +102,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -210,6 +212,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -308,6 +312,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -437,6 +443,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -537,6 +545,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
final logMessages = <String>[]; final logMessages = <String>[];
void captureLog(AppLogRecord record) { void captureLog(AppLogRecord record) {

View File

@ -173,6 +173,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -318,6 +320,8 @@ CWS_GITEA_TOKEN=test-token
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -419,6 +423,8 @@ OP_CLI_TOKEN=test-token
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.

View File

@ -36,7 +36,6 @@ void main() {
// And it does not force the command to run in a shell. // And it does not force the command to run in a shell.
expect(resolved.runInShell, isFalse); expect(resolved.runInShell, isFalse);
expect(resolved.usesBashShim, isFalse);
}); });
/// ```gherkin /// ```gherkin
@ -62,24 +61,21 @@ void main() {
// And it marks the command to run in a shell. // And it marks the command to run in a shell.
expect(resolved.runInShell, isTrue); expect(resolved.runInShell, isTrue);
expect(resolved.usesBashShim, isFalse);
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Wrap local extensionless scripts through WSL bash on Windows /// Scenario: Leave local Bash scripts unresolved on Windows
/// Given a local extensionless script file on Windows /// Given a local Bash script file on Windows
/// When the process launcher resolves the command /// When the process launcher resolves the command
/// Then it swaps the executable to wsl.exe /// Then it leaves the script path unchanged
/// And it forwards the script path as a bash path /// And it does not add a WSL wrapper
/// ``` /// ```
test( test('Leave local Bash scripts unresolved on Windows', () async {
'Wrap local extensionless scripts through WSL bash on Windows', // Given a local Bash script file on Windows.
() async {
// Given a local extensionless script file on Windows.
final sandbox = await Directory.systemTemp.createTemp( final sandbox = await Directory.systemTemp.createTemp(
'cws-process-launcher-', 'cws-process-launcher-',
); );
final script = File(p.join(sandbox.path, 'fake-tea')); final script = File(p.join(sandbox.path, 'fake-tea.sh'));
await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n'); await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n');
try { try {
@ -90,19 +86,15 @@ void main() {
isWindows: true, isWindows: true,
); );
// Then it swaps the executable to wsl.exe. // Then it leaves the script path unchanged.
expect(resolved.executable, 'wsl.exe'); expect(resolved.executable, script.path);
expect(resolved.usesBashShim, isTrue);
// And it forwards the script path as a bash path. // And it does not add a WSL wrapper.
expect(resolved.runInShell, isFalse); expect(resolved.runInShell, isFalse);
expect(resolved.arguments.first, '--exec'); expect(resolved.arguments, const <String>['api']);
expect(resolved.arguments[1], 'bash');
expect(resolved.arguments[2], startsWith('/mnt/'));
} finally { } finally {
await sandbox.delete(recursive: true); await sandbox.delete(recursive: true);
} }
}, });
);
}); });
} }

View File

@ -84,6 +84,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
final logMessages = <String>[]; final logMessages = <String>[];
void captureLog(AppLogRecord record) { void captureLog(AppLogRecord record) {

View File

@ -120,6 +120,8 @@ projects:
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and receives the forwarded webhook. // When the long-running app starts and receives the forwarded webhook.

View File

@ -134,6 +134,8 @@ projects:
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
glabCommand: glabScript.path, glabCommand: glabScript.path,
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and receives the forwarded webhook. // When the long-running app starts and receives the forwarded webhook.

View File

@ -129,6 +129,8 @@ CWS_GITEA_TOKEN=test-token
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and receives the forwarded webhook. // When the long-running app starts and receives the forwarded webhook.
@ -185,25 +187,11 @@ CWS_GITEA_TOKEN=test-token
// And a gosmee script that starts forwarding without delivering an event. // And a gosmee script that starts forwarding without delivering an event.
final gosmeeScript = File(p.join(sandbox.path, 'gosmee')); final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl')); final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl'));
await gosmeeScript.writeAsString('''#!/usr/bin/env bash registerFakeGosmeeCommand(
set -euo pipefail command: gosmeeScript.path,
printf '%s\n' "\$*" >> "${bashScriptPath(gosmeeLog.path)}" publicUrl: 'https://gosmee.example.test/reconcile',
gosmeeLog: gosmeeLog,
if [[ "\$1" == "--output" && "\$2" == "json" && "\$3" == "client" && "\$4" == "--new-url" ]]; then );
printf '%s\n' 'https://gosmee.example.test/reconcile'
exit 0
fi
if [[ "\$1" == "client" ]]; then
while true; do
sleep 60
done
fi
echo "unexpected gosmee args: \$*" >&2
exit 1
''');
await makeScriptExecutable(gosmeeScript);
final issue = { final issue = {
'index': 18, 'index': 18,
@ -290,6 +278,8 @@ CWS_GITEA_TOKEN=test-token
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and enough time passes for reconciliation polling. // When the long-running app starts and enough time passes for reconciliation polling.

View File

@ -100,6 +100,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and receives the forwarded webhook. // When the long-running app starts and receives the forwarded webhook.
@ -152,34 +154,36 @@ projects:
// And a gh script that writes a webhook creation failure to stderr while forwarding. // And a gh script that writes a webhook creation failure to stderr while forwarding.
final ghScript = File(p.join(sandbox.path, 'gh')); final ghScript = File(p.join(sandbox.path, 'gh'));
await ghScript.writeAsString('''#!/usr/bin/env bash fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async {
set -euo pipefail if (arguments.length >= 2 &&
arguments[0] == 'api' &&
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then arguments[1] == 'user') {
echo "Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)" >&2 return ProcessResult(0, 0, '{"login":"octocat"}', '');
while true; do }
sleep 60 if (arguments.length >= 4 &&
done arguments[0] == 'api' &&
fi arguments[3] == 'search/issues') {
return ProcessResult(
if [[ "\$1" == "api" && "\$2" == "user" ]]; then 0,
cat <<'JSON' 0,
${jsonEncode({'login': 'octocat'})} '{"total_count":0,"incomplete_results":false,"items":[]}',
JSON '',
exit 0 );
fi }
return ProcessResult(
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then 0,
cat <<'JSON' 1,
{"total_count":0,"incomplete_results":false,"items":[]} '',
JSON 'unexpected gh args: ${arguments.join(' ')}\n',
exit 0 );
fi };
fakeStartedCommandHandlers[ghScript.path] = (arguments) async {
echo "unexpected gh args: \$*" >&2 final process = FakeStartedExternalCommand()
exit 1 ..addStderr(
'''); 'Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)\n',
await makeScriptExecutable(ghScript); );
return process;
};
// And an orchestrator-style config that uses gh/webhook-forward. // And an orchestrator-style config that uses gh/webhook-forward.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
@ -200,6 +204,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
final records = <AppLogRecord>[]; final records = <AppLogRecord>[];
void captureLog(AppLogRecord record) { void captureLog(AppLogRecord record) {
@ -284,69 +290,85 @@ projects:
'user': {'login': 'reporter'}, 'user': {'login': 'reporter'},
}, },
]; ];
await ghScript.writeAsString('''#!/usr/bin/env bash fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async {
set -euo pipefail await ghLog.writeAsString(
printf '%s\n' "\$*" >> "${bashScriptPath(ghLog.path)}" '${arguments.join(' ')}\n',
mode: FileMode.append,
if [[ "\$1" == "api" && "\$2" == "user" ]]; then );
cat <<'JSON' if (arguments.length >= 2 &&
${jsonEncode({'login': 'octocat'})} arguments[0] == 'api' &&
JSON arguments[1] == 'user') {
exit 0 return ProcessResult(0, 0, '{"login":"octocat"}', '');
fi }
final path = arguments.length > 3 ? arguments[3] : '';
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then if (arguments.length >= 4 &&
count=0 arguments[0] == 'api' &&
if [[ -f "${bashScriptPath(searchCountFile.path)}" ]]; then path == 'search/issues') {
count="\$(cat "${bashScriptPath(searchCountFile.path)}")" var count = 0;
fi if (await searchCountFile.exists()) {
count=\$((count + 1)) count = int.parse(await searchCountFile.readAsString());
printf '%s' "\$count" > "${bashScriptPath(searchCountFile.path)}" }
if [[ "\$count" -eq 1 ]]; then count += 1;
cat <<'JSON' await searchCountFile.writeAsString(count.toString());
{"total_count":0,"incomplete_results":false,"items":[]} return ProcessResult(
JSON 0,
else 0,
cat <<'JSON' jsonEncode(
${jsonEncode({ count == 1
? {
'total_count': 0,
'incomplete_results': false,
'items': <Object?>[],
}
: {
'total_count': 1, 'total_count': 1,
'incomplete_results': false, 'incomplete_results': false,
'items': [searchItem], 'items': [searchItem],
})} },
JSON ),
fi '',
exit 0 );
fi }
if (arguments.length >= 4 &&
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?per_page=100" ]]; then arguments[0] == 'api' &&
cat <<'JSON' path == 'repos/owner/sample/issues/18/comments?per_page=100') {
${jsonEncode(comments)} return ProcessResult(0, 0, jsonEncode(comments), '');
JSON }
exit 0 if (arguments.length >= 4 &&
fi arguments[0] == 'api' &&
path == 'repos/owner/sample/issues/18/comments') {
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then final body = arguments
for arg in "\$@"; do .where((argument) => argument.startsWith('body='))
if [[ "\$arg" == body=* ]]; then .map((argument) => argument.substring('body='.length))
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}" .firstOrNull;
fi if (body != null) {
done await postedBody.writeAsString(body);
cat <<'JSON' }
${jsonEncode({'id': 703, 'html_url': 'https://example.test/comment/703', 'body': 'posted'})} return ProcessResult(
JSON 0,
exit 0 0,
fi jsonEncode({
'id': 703,
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then 'html_url': 'https://example.test/comment/703',
while true; do 'body': 'posted',
sleep 60 }),
done '',
fi );
}
echo "unexpected gh args: \$*" >&2 return ProcessResult(
exit 1 0,
'''); 1,
await makeScriptExecutable(ghScript); '',
'unexpected gh args: ${arguments.join(' ')}\n',
);
};
fakeStartedCommandHandlers[ghScript.path] = (arguments) async {
await ghLog.writeAsString(
'${arguments.join(' ')}\n',
mode: FileMode.append,
);
return FakeStartedExternalCommand();
};
// And a responder that emits a planning reply. // And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh')); final responderScript = File(p.join(sandbox.path, 'responder.sh'));
@ -383,6 +405,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the long-running app starts and enough time passes for reconciliation polling. // When the long-running app starts and enough time passes for reconciliation polling.

View File

@ -146,7 +146,10 @@ void main() {
postedBodyFile: postedBodyFile, postedBodyFile: postedBodyFile,
viewerLogin: 'glab-user', viewerLogin: 'glab-user',
); );
final client = IssueTrackerClient(glabCommand: glabScript.path); final client = IssueTrackerClient(
glabCommand: glabScript.path,
commandExecutor: fakeExternalCommandExecutor(),
);
// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment. // When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment.
final issues = await client.fetchUpdatedIssuesForRepos( final issues = await client.fetchUpdatedIssuesForRepos(

View File

@ -120,6 +120,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -252,6 +254,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -18,15 +19,15 @@ Future<void> writeFakeGhScript({
String? viewerLogin, String? viewerLogin,
bool failViewerLookup = false, bool failViewerLookup = false,
}) async { }) async {
final normalizedIssueListResponsesByRepo = final issueLists =
issueListResponsesByRepo ?? issueListResponsesByRepo ??
<String, List<Map<String, Object?>>>{'owner/sample': issueListResponse}; <String, List<Map<String, Object?>>>{'owner/sample': issueListResponse};
final normalizedCommentResponsesByRepo = final comments =
commentResponsesByRepo ?? commentResponsesByRepo ??
<String, Map<int, List<Map<String, Object?>>>>{ <String, Map<int, List<Map<String, Object?>>>>{
'owner/sample': commentResponses, 'owner/sample': commentResponses,
}; };
final normalizedPostCommentIssueNumbersByRepo = final postIssueNumbers =
postCommentIssueNumbersByRepo ?? postCommentIssueNumbersByRepo ??
<String, Set<int>>{ <String, Set<int>>{
'owner/sample': { 'owner/sample': {
@ -35,7 +36,7 @@ Future<void> writeFakeGhScript({
}, },
}; };
final searchItems = final searchItems =
normalizedIssueListResponsesByRepo.entries issueLists.entries
.expand( .expand(
(entry) => entry.value.map( (entry) => entry.value.map(
(issue) => <String, Object?>{ (issue) => <String, Object?>{
@ -53,170 +54,30 @@ Future<void> writeFakeGhScript({
final openSearchItems = searchItems final openSearchItems = searchItems
.where((item) => item['state'] == 'open') .where((item) => item['state'] == 'open')
.toList(growable: false); .toList(growable: false);
final allSearchResponse = jsonEncode({
_registerFakeGhCommand(
command: ghScript.path,
ghLog: ghLog,
failViewerLookup: failViewerLookup,
viewerLogin: viewerLogin,
searchResponse: {
'total_count': searchItems.length, 'total_count': searchItems.length,
'incomplete_results': false, 'incomplete_results': false,
'items': searchItems, 'items': searchItems,
}); },
final openSearchResponse = jsonEncode({ openSearchResponse: {
'total_count': openSearchItems.length, 'total_count': openSearchItems.length,
'incomplete_results': false, 'incomplete_results': false,
'items': openSearchItems, 'items': openSearchItems,
}); },
issueLists: issueLists,
final buffer = StringBuffer() comments: comments,
..writeln('#!/usr/bin/env bash') postIssueNumbers: postIssueNumbers,
..writeln('set -euo pipefail'); postedBodyFile: postedBodyFile,
reactionBodyFile: reactionBodyFile,
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${bashScriptPath(ghLog.path)}"',
); );
} }
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
if (failViewerLookup) {
buffer
..writeln(r''' echo "viewer lookup failed" >&2''')
..writeln(' exit 1')
..writeln('fi');
} else {
buffer
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
buffer
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
..writeln(r''' query=""''')
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' if [[ "$arg" == q=* ]]; then''')
..writeln(r''' query="${arg#q=}"''')
..writeln(r''' fi''')
..writeln(r''' done''')
..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''')
..writeln(" cat <<'JSON'")
..writeln(openSearchResponse)
..writeln('JSON')
..writeln(r''' else''')
..writeln(" cat <<'JSON'")
..writeln(allSearchResponse)
..writeln('JSON')
..writeln(r''' fi''')
..writeln(' exit 0')
..writeln('fi');
for (final entry in normalizedIssueListResponsesByRepo.entries) {
final repo = entry.key;
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then',
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(entry.value))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
for (final repoEntry in normalizedCommentResponsesByRepo.entries) {
final repo = repoEntry.key;
for (final entry in repoEntry.value.entries) {
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then',
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(entry.value))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
}
for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) {
final repo = repoEntry.key;
for (final issueNumber in repoEntry.value) {
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
)
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' :''');
if (postedBodyFile != null) {
buffer
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
buffer
..writeln(r''' done''')
..writeln(" cat <<'JSON'")
..writeln(
jsonEncode({
'id': 700,
'html_url': 'https://example.test/comment/700',
'body': 'posted',
}),
)
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
}
for (final repoEntry in normalizedCommentResponsesByRepo.entries) {
final repo = repoEntry.key;
for (final issueEntry in repoEntry.value.entries) {
for (final comment in issueEntry.value) {
final commentId = comment['id'];
if (commentId is! int) {
continue;
}
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/comments/$commentId/reactions" ]]; then',
)
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' :''');
if (reactionBodyFile != null) {
buffer
..writeln(r''' if [[ "$arg" == content=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#content=}" > '''
'"${bashScriptPath(reactionBodyFile.path)}"',
)
..writeln(r''' fi''');
}
buffer
..writeln(r''' done''')
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'id': 990, 'content': 'eyes'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
}
}
buffer
..writeln(r'''echo "unexpected gh args: $*" >&2''')
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await makeScriptExecutable(ghScript);
}
Future<void> writeFlakySearchGhScript({ Future<void> writeFlakySearchGhScript({
required File ghScript, required File ghScript,
required File failureStateFile, required File failureStateFile,
@ -232,70 +93,22 @@ Future<void> writeFlakySearchGhScript({
}, },
) )
.toList(growable: false); .toList(growable: false);
final searchResponse = jsonEncode({
_registerFakeGhCommand(
command: ghScript.path,
ghLog: ghLog,
searchResponse: {
'total_count': searchItems.length, 'total_count': searchItems.length,
'incomplete_results': false, 'incomplete_results': false,
'items': searchItems, 'items': searchItems,
}); },
flakySearchStateFile: failureStateFile,
final buffer = StringBuffer() issueLists: const {},
..writeln('#!/usr/bin/env bash') comments: {'owner/sample': commentResponses},
..writeln('set -euo pipefail'); postIssueNumbers: const {},
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${bashScriptPath(ghLog.path)}"',
); );
} }
buffer
..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''')
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'login': 'test-user'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
..writeln(
' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then',
)
..writeln(' touch "${bashScriptPath(failureStateFile.path)}"')
..writeln(r''' echo "error connecting to api.github.com" >&2''')
..writeln(
r''' echo "check your internet connection or https://githubstatus.com" >&2''',
)
..writeln(' exit 1')
..writeln(r''' fi''')
..writeln(" cat <<'JSON'")
..writeln(searchResponse)
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
for (final entry in commentResponses.entries) {
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then',
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(entry.value))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
}
buffer
..writeln(r'''echo "unexpected gh args: $*" >&2''')
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await makeScriptExecutable(ghScript);
}
Future<void> writeWebhookForwardGhScript({ Future<void> writeWebhookForwardGhScript({
required File ghScript, required File ghScript,
required String repo, required String repo,
@ -307,12 +120,6 @@ Future<void> writeWebhookForwardGhScript({
String? viewerLogin, String? viewerLogin,
}) async { }) async {
final issueNumber = issue['number'] as int; final issueNumber = issue['number'] as int;
final payload = {
'action': 'created',
'issue': issue,
'comment': comments.last,
'repository': {'full_name': repo},
};
final searchItems = issueListResponse final searchItems = issueListResponse
.map( .map(
(searchIssue) => <String, Object?>{ (searchIssue) => <String, Object?>{
@ -321,111 +128,33 @@ Future<void> writeWebhookForwardGhScript({
}, },
) )
.toList(growable: false); .toList(growable: false);
final searchResponse = jsonEncode({
_registerFakeGhCommand(
command: ghScript.path,
ghLog: ghLog,
viewerLogin: viewerLogin,
searchResponse: {
'total_count': searchItems.length, 'total_count': searchItems.length,
'incomplete_results': false, 'incomplete_results': false,
'items': searchItems, 'items': searchItems,
}); },
issueLists: const {},
final buffer = StringBuffer() comments: {
..writeln('#!/usr/bin/env bash') repo: {issueNumber: comments},
..writeln('set -euo pipefail'); },
postIssueNumbers: {
if (ghLog != null) { repo: {issueNumber},
buffer.writeln( },
r'''printf '%s\n' "$*" >> ''' postedBodyFile: postedBodyFile,
'"${bashScriptPath(ghLog.path)}"', webhookPayload: {
'action': 'created',
'issue': issue,
'comment': comments.last,
'repository': {'full_name': repo},
},
); );
} }
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
buffer
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
..writeln(" cat <<'JSON'")
..writeln(searchResponse)
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then',
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(comments))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
)
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' :''');
if (postedBodyFile != null) {
buffer
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
buffer
..writeln(r''' done''')
..writeln(" cat <<'JSON'")
..writeln(
jsonEncode({
'id': 702,
'html_url': 'https://example.test/comment/702',
'body': 'posted',
}),
)
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(r'''if [[ "$1" == "webhook" && "$2" == "forward" ]]; then''')
..writeln(r''' url=""''')
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' if [[ "$arg" == --url=* ]]; then''')
..writeln(r''' url="${arg#--url=}"''')
..writeln(r''' fi''')
..writeln(r''' done''')
..writeln(r''' if [[ -z "$url" ]]; then''')
..writeln(r''' echo "missing --url" >&2''')
..writeln(' exit 1')
..writeln(r''' fi''')
..writeln(r''' curl -sS -X POST \''')
..writeln(r''' -H "Content-Type: application/json" \''')
..writeln(r''' -H "X-GitHub-Event: issue_comment" \''')
..writeln(r''' --data-binary @- "$url" <<'JSON' >/dev/null''')
..writeln(jsonEncode(payload))
..writeln('JSON')
..writeln(r''' while true; do''')
..writeln(r''' sleep 60''')
..writeln(r''' done''')
..writeln('fi');
buffer
..writeln(r'''echo "unexpected gh args: $*" >&2''')
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await makeScriptExecutable(ghScript);
}
Future<void> writeGitHubGosmeeGhScript({ Future<void> writeGitHubGosmeeGhScript({
required File ghScript, required File ghScript,
required String repo, required String repo,
@ -445,114 +174,234 @@ Future<void> writeGitHubGosmeeGhScript({
}, },
) )
.toList(growable: false); .toList(growable: false);
final searchResponse = jsonEncode({
_registerFakeGhCommand(
command: ghScript.path,
ghLog: ghLog,
viewerLogin: viewerLogin,
searchResponse: {
'total_count': searchItems.length, 'total_count': searchItems.length,
'incomplete_results': false, 'incomplete_results': false,
'items': searchItems, 'items': searchItems,
}); },
final buffer = StringBuffer() issueLists: const {},
..writeln('#!/usr/bin/env bash') comments: {
..writeln('set -euo pipefail'); repo: {issueNumber: comments},
},
if (ghLog != null) { postIssueNumbers: {
buffer.writeln( repo: {issueNumber},
r'''printf '%s\n' "$*" >> ''' },
'"${bashScriptPath(ghLog.path)}"', postedBodyFile: postedBodyFile,
webhookIdsByRepo: {repo: webhookId},
); );
} }
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); void _registerFakeGhCommand({
buffer required String command,
..writeln(" cat <<'JSON'") required Map<String, Object?> searchResponse,
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) required Map<String, List<Map<String, Object?>>> issueLists,
..writeln('JSON') required Map<String, Map<int, List<Map<String, Object?>>>> comments,
..writeln(' exit 0') required Map<String, Set<int>> postIssueNumbers,
..writeln('fi'); File? ghLog,
bool failViewerLookup = false,
buffer String? viewerLogin,
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') Map<String, Object?>? openSearchResponse,
..writeln(" cat <<'JSON'") File? postedBodyFile,
..writeln(searchResponse) File? reactionBodyFile,
..writeln('JSON') File? flakySearchStateFile,
..writeln(' exit 0') Map<String, Object?>? webhookPayload,
..writeln('fi'); Map<String, int>? webhookIdsByRepo,
}) {
buffer if (webhookPayload != null) {
..writeln( fakeStartedCommandHandlers[command] = (arguments) async {
'if [[ "\$1" == "api" && "\$4" == ' if (ghLog != null) {
'"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then', await _appendLine(ghLog, '${arguments.join(' ')}\n');
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(comments))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln('if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks" ]]; then')
..writeln(r''' active_flag=""''')
..writeln(r''' for ((i = 1; i <= $#; i += 1)); do''')
..writeln(r''' if [[ "${!i}" == "active=true" ]]; then''')
..writeln(r''' prev_index=$((i - 1))''')
..writeln(r''' active_flag="${!prev_index}"''')
..writeln(r''' break''')
..writeln(r''' fi''')
..writeln(r''' done''')
..writeln(
r''' if [[ "$active_flag" != "-F" && "$active_flag" != "--field" ]]; then''',
)
..writeln(
r''' echo "expected active=true to be sent with -F/--field, got: $*" >&2''',
)
..writeln(r''' exit 1''')
..writeln(r''' fi''')
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'id': webhookId}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
buffer
..writeln(
'if [[ "\$1" == "api" && "\$4" == '
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
)
..writeln(r''' for arg in "$@"; do''')
..writeln(r''' :''');
if (postedBodyFile != null) {
buffer
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
} }
buffer final process = FakeStartedExternalCommand();
..writeln(r''' done''') final targetUrl = _argumentValue(arguments, '--url=');
..writeln(" cat <<'JSON'") if (targetUrl != null) {
..writeln( unawaited(_postGitHubWebhook(targetUrl, webhookPayload));
jsonEncode({ }
'id': 703, return process;
'html_url': 'https://example.test/comment/703', };
}
fakeCommandHandlers[command] = (arguments, {timeout}) async {
if (ghLog != null) {
await _appendLine(ghLog, '${arguments.join(' ')}\n');
}
ProcessResult result(
Object? stdout, {
int exitCode = 0,
String stderr = '',
}) {
final stdoutText = stdout is String ? stdout : jsonEncode(stdout);
return ProcessResult(0, exitCode, stdoutText, stderr);
}
if (_argsMatch(arguments, ['api', 'user'])) {
if (failViewerLookup) {
return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
}
return result({'login': viewerLogin ?? 'test-user'});
}
if (_argsMatchAt(arguments, 0, 'api') &&
_argsMatchAt(arguments, 3, 'search/issues')) {
if (flakySearchStateFile != null &&
!await flakySearchStateFile.exists()) {
await flakySearchStateFile.writeAsString('failed-once');
return result(
'',
exitCode: 1,
stderr:
'error connecting to api.github.com\ncheck your internet connection or https://githubstatus.com\n',
);
}
final query = arguments
.where((argument) => argument.startsWith('q='))
.map((argument) => argument.substring(2))
.firstOrNull;
return result(
query?.contains('state:open') ?? false
? (openSearchResponse ?? searchResponse)
: searchResponse,
);
}
for (final entry in issueLists.entries) {
final path = 'repos/${entry.key}/issues?';
if (_argsMatchAt(arguments, 0, 'api') &&
arguments.length > 3 &&
arguments[3].startsWith(path)) {
return result(entry.value);
}
}
for (final repoEntry in comments.entries) {
for (final entry in repoEntry.value.entries) {
if (_argsMatchAt(arguments, 0, 'api') &&
_argsMatchAt(
arguments,
3,
'repos/${repoEntry.key}/issues/${entry.key}/comments?per_page=100',
)) {
return result(entry.value);
}
}
}
for (final repoEntry in postIssueNumbers.entries) {
for (final issueNumber in repoEntry.value) {
if (_argsMatchAt(arguments, 0, 'api') &&
_argsMatchAt(
arguments,
3,
'repos/${repoEntry.key}/issues/$issueNumber/comments',
)) {
final body = _argumentValue(arguments, 'body=');
if (postedBodyFile != null && body != null) {
await postedBodyFile.writeAsString(body);
}
return result({
'id': 700,
'html_url': 'https://example.test/comment/700',
'body': 'posted', 'body': 'posted',
}), });
) }
..writeln('JSON') }
..writeln(' exit 0') }
..writeln('fi');
for (final repoEntry in comments.entries) {
buffer for (final issueEntry in repoEntry.value.entries) {
..writeln( for (final comment in issueEntry.value) {
'if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks/$webhookId" ]]; then', final commentId = comment['id'];
) if (commentId is int &&
..writeln(' exit 0') _argsMatchAt(arguments, 0, 'api') &&
..writeln('fi'); _argsMatchAt(
arguments,
buffer 3,
..writeln(r'''echo "unexpected gh args: $*" >&2''') 'repos/${repoEntry.key}/issues/comments/$commentId/reactions',
..writeln('exit 1'); )) {
final content = _argumentValue(arguments, 'content=');
await ghScript.writeAsString(buffer.toString()); if (reactionBodyFile != null && content != null) {
await makeScriptExecutable(ghScript); await reactionBodyFile.writeAsString(content);
}
return result({'id': 990, 'content': 'eyes'});
}
}
}
}
for (final entry in (webhookIdsByRepo ?? const <String, int>{}).entries) {
final hooksPath = 'repos/${entry.key}/hooks';
if (_argsMatchAt(arguments, 0, 'api') &&
_argsMatchAt(arguments, 3, hooksPath)) {
return result({'id': entry.value});
}
if (_argsMatchAt(arguments, 0, 'api') &&
_argsMatchAt(arguments, 3, '$hooksPath/${entry.value}')) {
return result(null);
}
}
return result(
'',
exitCode: 1,
stderr: 'unexpected gh args: ${arguments.join(' ')}\n',
);
};
}
Future<void> _postGitHubWebhook(
String targetUrl,
Map<String, Object?> webhookPayload,
) async {
final client = HttpClient();
try {
final request = await client.postUrl(Uri.parse(targetUrl));
request.headers.contentType = ContentType.json;
request.headers.set('X-GitHub-Event', 'issue_comment');
request.write(jsonEncode(webhookPayload));
await request.close();
} finally {
client.close(force: true);
}
}
bool _argsMatch(List<String> arguments, List<String> expected) =>
arguments.length >= expected.length &&
Iterable.generate(
expected.length,
).every((index) => arguments[index] == expected[index]);
bool _argsMatchAt(List<String> arguments, int index, String expected) =>
arguments.length > index && arguments[index] == expected;
String? _argumentValue(List<String> arguments, String prefix) {
for (final argument in arguments) {
if (argument.startsWith(prefix)) {
return argument.substring(prefix.length);
}
}
return null;
}
Future<void> _appendLine(File file, String line) async {
for (var attempt = 0; attempt < 200; attempt += 1) {
RandomAccessFile? log;
try {
log = file.openSync(mode: FileMode.append);
log.lockSync(FileLock.exclusive);
log.writeStringSync(line);
log.unlockSync();
return;
} on FileSystemException {
await Future<void>.delayed(const Duration(milliseconds: 5));
} finally {
log?.closeSync();
}
}
await file.writeAsString(line, mode: FileMode.append);
} }

View File

@ -15,7 +15,7 @@ Future<void> writeFakeGlabScript({
bool failViewerLookup = false, bool failViewerLookup = false,
Map<String, int>? webhookIdsByRepo, Map<String, int>? webhookIdsByRepo,
}) async { }) async {
final normalizedPostCommentIssueNumbersByRepo = final postIssueNumbers =
postCommentIssueNumbersByRepo ?? postCommentIssueNumbersByRepo ??
<String, Set<int>>{ <String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries) for (final entry in issueListResponsesByRepo.entries)
@ -24,123 +24,110 @@ Future<void> writeFakeGlabScript({
.whereType<int>() .whereType<int>()
.toSet(), .toSet(),
}; };
fakeCommandHandlers[glabScript.path] = (arguments, {timeout}) async {
final buffer = StringBuffer()
..writeln('#!/usr/bin/env bash')
..writeln('set -euo pipefail');
if (glabLog != null) { if (glabLog != null) {
buffer.writeln( await glabLog.writeAsString(
r'''printf '%s\n' "$*" >> ''' '${arguments.join(' ')}\n',
'"${bashScriptPath(glabLog.path)}"', mode: FileMode.append,
); );
} }
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); ProcessResult result(
Object? stdout, {
int exitCode = 0,
String stderr = '',
}) {
return ProcessResult(
0,
exitCode,
stdout is String ? stdout : jsonEncode(stdout),
stderr,
);
}
if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) {
if (failViewerLookup) { if (failViewerLookup) {
buffer return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
..writeln(r''' echo "viewer lookup failed" >&2''') }
..writeln(' exit 1') return result({'username': viewerLogin ?? 'glab-user'});
..writeln('fi');
} else {
buffer
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'username': viewerLogin ?? 'glab-user'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
for (final entry in issueListResponsesByRepo.entries) { for (final entry in issueListResponsesByRepo.entries) {
final repo = Uri.encodeComponent(entry.key); final repo = Uri.encodeComponent(entry.key);
buffer if (_matches(arguments, 0, 'api') &&
..writeln( arguments.length > 3 &&
r'''if [[ "$1" == "api" && "$4" == "projects/''' arguments[3].startsWith('projects/$repo/issues?state=opened')) {
'$repo' return result(entry.value);
r'''/issues?state=opened"* ]]; then''', }
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(entry.value))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
for (final repoEntry in commentResponsesByRepo.entries) { for (final repoEntry in commentResponsesByRepo.entries) {
final repo = Uri.encodeComponent(repoEntry.key); final repo = Uri.encodeComponent(repoEntry.key);
for (final entry in repoEntry.value.entries) { for (final entry in repoEntry.value.entries) {
buffer if (_matches(arguments, 0, 'api') &&
..writeln( _matches(
'if [[ "\$1" == "api" && "\$4" == ' arguments,
'"projects/$repo/issues/${entry.key}/notes?per_page=100" ]]; then', 3,
) 'projects/$repo/issues/${entry.key}/notes?per_page=100',
..writeln(" cat <<'JSON'") )) {
..writeln(jsonEncode(entry.value)) return result(entry.value);
..writeln('JSON') }
..writeln(' exit 0')
..writeln('fi');
} }
} }
for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { for (final repoEntry in postIssueNumbers.entries) {
final repo = Uri.encodeComponent(repoEntry.key); final repo = Uri.encodeComponent(repoEntry.key);
for (final issueNumber in repoEntry.value) { for (final issueNumber in repoEntry.value) {
buffer if (_matches(arguments, 0, 'api') &&
..writeln( _matches(
'if [[ "\$1" == "api" && "\$4" == ' arguments,
'"projects/$repo/issues/$issueNumber/notes" ]]; then', 3,
) 'projects/$repo/issues/$issueNumber/notes',
..writeln(r''' for arg in "$@"; do''') )) {
..writeln(r''' :'''); final body = _valueArg(arguments, 'body=');
if (postedBodyFile != null) { if (postedBodyFile != null && body != null) {
buffer await postedBodyFile.writeAsString(body);
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
} }
buffer return result({
..writeln(r''' done''')
..writeln(" cat <<'JSON'")
..writeln(
jsonEncode({
'id': 901, 'id': 901,
'body': 'posted', 'body': 'posted',
'noteable_url': 'noteable_url':
'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber', 'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber',
}), });
) }
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
} }
for (final entry in (webhookIdsByRepo ?? const <String, int>{}).entries) { for (final entry in (webhookIdsByRepo ?? const <String, int>{}).entries) {
final repo = Uri.encodeComponent(entry.key); final repo = Uri.encodeComponent(entry.key);
buffer if (_matches(arguments, 0, 'api') &&
..writeln( _matches(arguments, 2, 'POST') &&
'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks" && "\$3" == "POST" ]]; then', _matches(arguments, 3, 'projects/$repo/hooks')) {
) return result({'id': entry.value});
..writeln(" cat <<'JSON'") }
..writeln(jsonEncode({'id': entry.value})) if (_matches(arguments, 0, 'api') &&
..writeln('JSON') _matches(arguments, 2, 'DELETE') &&
..writeln(' exit 0') _matches(arguments, 3, 'projects/$repo/hooks/${entry.value}')) {
..writeln('fi') return result(null);
..writeln( }
'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks/${entry.value}" && "\$3" == "DELETE" ]]; then',
)
..writeln(" printf '%s\\n' 'null'")
..writeln(' exit 0')
..writeln('fi');
} }
buffer return result(
..writeln(r'''echo "unexpected glab args: $*" >&2''') '',
..writeln('exit 1'); exitCode: 1,
stderr: 'unexpected glab args: ${arguments.join(' ')}\n',
await glabScript.writeAsString(buffer.toString()); );
await makeScriptExecutable(glabScript); };
}
bool _matches(List<String> arguments, int index, String value) =>
arguments.length > index && arguments[index] == value;
String? _valueArg(List<String> arguments, String prefix) {
for (final argument in arguments) {
if (argument.startsWith(prefix)) {
return argument.substring(prefix.length);
}
}
return null;
} }

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@ -9,55 +10,94 @@ Future<void> writeGosmeeForwardScript({
required Map<String, Object?> payload, required Map<String, Object?> payload,
File? gosmeeLog, File? gosmeeLog,
}) async { }) async {
final buffer = StringBuffer() registerFakeGosmeeCommand(
..writeln('#!/usr/bin/env bash') command: gosmeeScript.path,
..writeln('set -euo pipefail'); publicUrl: publicUrl,
payload: payload,
if (gosmeeLog != null) { gosmeeLog: gosmeeLog,
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${bashScriptPath(gosmeeLog.path)}"',
); );
} }
buffer void registerFakeGosmeeCommand({
..writeln( required String command,
r'''if [[ "$1" == "--output" && "$2" == "json" && "$3" == "client" && "$4" == "--new-url" ]]; then''', required String publicUrl,
) Map<String, Object?>? payload,
..writeln( File? gosmeeLog,
r''' printf '%s\n' ''' String webhookHeader = 'X-Gitea-Event',
"'$publicUrl'", }) {
) fakeCommandHandlers[command] = (arguments, {timeout}) async {
..writeln(' exit 0') if (gosmeeLog != null) {
..writeln('fi'); await _appendLine(gosmeeLog, '${arguments.join(' ')}\n');
}
buffer if (_matchesNewUrl(arguments)) {
..writeln(r'''if [[ "$1" == "client" ]]; then''') return ProcessResult(0, 0, publicUrl, '');
..writeln(r''' url="$2"''') }
..writeln(r''' target="$3"''') return ProcessResult(
..writeln( 0,
r''' if [[ "$url" != ''' 1,
"'$publicUrl'" '',
r''' ]]; then''', 'unexpected gosmee args: ${arguments.join(' ')}\n',
) );
..writeln(r''' echo "unexpected gosmee url: $url" >&2''') };
..writeln(' exit 1')
..writeln(r''' fi''') fakeStartedCommandHandlers[command] = (arguments) async {
..writeln(r''' curl -sS -X POST \''') if (gosmeeLog != null) {
..writeln(r''' -H "Content-Type: application/json" \''') await _appendLine(gosmeeLog, '${arguments.join(' ')}\n');
..writeln(r''' -H "X-Gitea-Event: issue_comment" \''') }
..writeln(r''' --data-binary @- "$target" <<'JSON' >/dev/null''') final process = FakeStartedExternalCommand();
..writeln(jsonEncode(payload)) if (arguments.length >= 3 &&
..writeln('JSON') arguments[0] == 'client' &&
..writeln(r''' while true; do''') arguments[1] == publicUrl) {
..writeln(r''' sleep 60''') if (payload != null) {
..writeln(r''' done''') unawaited(_postWebhook(arguments[2], payload, webhookHeader));
..writeln('fi'); }
return process;
buffer }
..writeln(r'''echo "unexpected gosmee args: $*" >&2''') process.addStderr('unexpected gosmee args: ${arguments.join(' ')}\n');
..writeln('exit 1'); process.complete(1);
return process;
await gosmeeScript.writeAsString(buffer.toString()); };
await makeScriptExecutable(gosmeeScript); }
bool _matchesNewUrl(List<String> arguments) {
return arguments.length >= 4 &&
arguments[0] == '--output' &&
arguments[1] == 'json' &&
arguments[2] == 'client' &&
arguments[3] == '--new-url';
}
Future<void> _postWebhook(
String targetUrl,
Map<String, Object?> payload,
String webhookHeader,
) async {
final client = HttpClient();
try {
final request = await client.postUrl(Uri.parse(targetUrl));
request.headers.contentType = ContentType.json;
request.headers.set(webhookHeader, 'issue_comment');
request.write(jsonEncode(payload));
await request.close();
} finally {
client.close(force: true);
}
}
Future<void> _appendLine(File file, String line) async {
for (var attempt = 0; attempt < 200; attempt += 1) {
RandomAccessFile? log;
try {
log = file.openSync(mode: FileMode.append);
log.lockSync(FileLock.exclusive);
log.writeStringSync(line);
log.unlockSync();
return;
} on FileSystemException {
await Future<void>.delayed(const Duration(milliseconds: 5));
} finally {
log?.closeSync();
}
}
await file.writeAsString(line, mode: FileMode.append);
} }

View File

@ -1,7 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'script_utils.dart'; import 'package:code_work_spawner/src/config/config.dart';
import 'package:code_work_spawner/src/core/cli_responder.dart';
import 'package:code_work_spawner/src/issue_tracker/issue_tracker_client.dart';
final Map<String, _FakeResponder> _fakeResponders = {};
CliResponderRunner fakeResponderRunner() =>
FakeResponderRunner(Map<String, _FakeResponder>.from(_fakeResponders));
Future<void> writeResponderScript( Future<void> writeResponderScript(
File responderScript, File responderScript,
@ -9,33 +16,34 @@ Future<void> writeResponderScript(
String stderr = '', String stderr = '',
int exitCode = 0, int exitCode = 0,
}) async { }) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
cat >/dev/null stderr: stderr,
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"} exitCode: exitCode,
cat <<'JSON' );
${jsonEncode(response)} }
JSON
exit $exitCode Future<void> writeCapturingResponderScript(
'''); File responderScript,
await makeScriptExecutable(responderScript); Map<String, Object?> response, {
File? cwdCapture,
File? stdinCapture,
}) async {
_fakeResponders[responderScript.path] = _FakeResponder(
response: response,
cwdCapture: cwdCapture,
stdinCapture: stdinCapture,
);
} }
Future<void> writeJsonlResponderScript( Future<void> writeJsonlResponderScript(
File responderScript, File responderScript,
Map<String, Object?> response, Map<String, Object?> response,
) async { ) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
cat >/dev/null emitsJsonl: true,
cat <<'JSON' );
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await makeScriptExecutable(responderScript);
} }
Future<void> writeDelayedLoggingResponderScript( Future<void> writeDelayedLoggingResponderScript(
@ -44,21 +52,142 @@ Future<void> writeDelayedLoggingResponderScript(
required Duration delay, required Duration delay,
required Map<String, Object?> response, required Map<String, Object?> response,
}) async { }) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
timestamp_ms() { eventLog: eventLog,
python3 - <<'PY' delay: delay,
import time );
print(int(time.time() * 1000)) }
PY
class FakeResponderRunner extends CliResponderRunner {
FakeResponderRunner(this._responders);
final Map<String, _FakeResponder> _responders;
@override
Future<ResponderResult> run({
required CliResponderConfig responder,
required ProjectConfig project,
required IssueThread thread,
required String prompt,
required String workspacePath,
}) async {
final fake = _responders[responder.command];
if (fake == null) {
throw StateError(
'No fake responder registered for command ${responder.command}.',
);
}
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()),
};
final stdinPayload = renderTemplate(responder.stdinTemplate, context);
await fake.run(
issueNumber: thread.issue.number,
stdinPayload: stdinPayload,
workspacePath: workspacePath,
);
final rawStdout = fake.rawStdout;
if (fake.exitCode != 0) {
throw CliResponderExecutionException(
responderId: responder.id,
command: responder.command,
arguments: responder.args,
exitCode: fake.exitCode,
rawStdout: rawStdout,
rawStderr: fake.stderr,
);
}
return ResponderResult.fromJson(
fake.response,
responderId: responder.id,
rawStdout: rawStdout,
rawStderr: fake.stderr,
);
}
}
class _FakeResponder {
_FakeResponder({
required this.response,
this.stderr = '',
this.exitCode = 0,
this.emitsJsonl = false,
this.eventLog,
this.delay = Duration.zero,
this.cwdCapture,
this.stdinCapture,
});
final Map<String, Object?> response;
final String stderr;
final int exitCode;
final bool emitsJsonl;
final File? eventLog;
final Duration delay;
final File? cwdCapture;
final File? stdinCapture;
String get rawStdout {
if (!emitsJsonl) {
return jsonEncode(response);
}
return [
jsonEncode({'type': 'thread.started', 'thread_id': 'test-thread'}),
jsonEncode({'type': 'turn.started'}),
jsonEncode({
'type': 'item.completed',
'item': {
'id': 'item_0',
'type': 'agent_message',
'text': jsonEncode(response),
},
}),
jsonEncode({'type': 'turn.completed'}),
'',
].join('\n');
}
Future<void> run({
required int issueNumber,
required String stdinPayload,
required String workspacePath,
}) async {
await cwdCapture?.writeAsString(workspacePath);
await stdinCapture?.writeAsString(stdinPayload);
if (eventLog != null) {
await _appendEvent('start', issueNumber);
await Future<void>.delayed(delay);
await _appendEvent('end', issueNumber);
}
}
Future<void> _appendEvent(String phase, int issueNumber) async {
final timestampMs = DateTime.now().microsecondsSinceEpoch ~/ 1000;
final line = '$phase:$issueNumber:$timestampMs\n';
for (var attempt = 0; attempt < 200; attempt += 1) {
RandomAccessFile? log;
try {
log = eventLog!.openSync(mode: FileMode.append);
log.lockSync(FileLock.exclusive);
log.writeStringSync(line);
log.unlockSync();
return;
} on FileSystemException {
await Future<void>.delayed(const Duration(milliseconds: 5));
} finally {
log?.closeSync();
}
}
await eventLog!.writeAsString(line, mode: FileMode.append);
} }
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
cat >/dev/null
sleep ${delay.inSeconds}
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
cat <<'JSON'
${jsonEncode(response)}
JSON
''');
await makeScriptExecutable(responderScript);
} }

View File

@ -14,7 +14,7 @@ Future<void> writeFakeTeaScript({
String? viewerLogin, String? viewerLogin,
bool failViewerLookup = false, bool failViewerLookup = false,
}) async { }) async {
final normalizedPostCommentIssueNumbersByRepo = final postIssueNumbers =
postCommentIssueNumbersByRepo ?? postCommentIssueNumbersByRepo ??
<String, Set<int>>{ <String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries) for (final entry in issueListResponsesByRepo.entries)
@ -23,103 +23,92 @@ Future<void> writeFakeTeaScript({
.whereType<int>() .whereType<int>()
.toSet(), .toSet(),
}; };
fakeCommandHandlers[teaScript.path] = (arguments, {timeout}) async {
final buffer = StringBuffer()
..writeln('#!/usr/bin/env bash')
..writeln('set -euo pipefail');
if (teaLog != null) { if (teaLog != null) {
buffer.writeln( await teaLog.writeAsString(
r'''printf '%s\n' "$*" >> ''' '${arguments.join(' ')}\n',
'"${bashScriptPath(teaLog.path)}"', mode: FileMode.append,
); );
} }
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); ProcessResult result(
Object? stdout, {
int exitCode = 0,
String stderr = '',
}) {
return ProcessResult(
0,
exitCode,
stdout is String ? stdout : jsonEncode(stdout),
stderr,
);
}
if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) {
if (failViewerLookup) { if (failViewerLookup) {
buffer return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
..writeln(r''' echo "viewer lookup failed" >&2''') }
..writeln(' exit 1') return result({'login': viewerLogin ?? 'tea-user'});
..writeln('fi');
} else {
buffer
..writeln(" cat <<'JSON'")
..writeln(jsonEncode({'login': viewerLogin ?? 'tea-user'}))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
for (final entry in issueListResponsesByRepo.entries) { for (final entry in issueListResponsesByRepo.entries) {
final repo = entry.key; if (_matches(arguments, 0, 'api') &&
buffer arguments.length > 3 &&
..writeln( arguments[3].startsWith('repos/${entry.key}/issues?state=open')) {
r'''if [[ "$1" == "api" && "$4" == "repos/''' return result(entry.value);
'$repo' }
r'''/issues?state=open"* ]]; then''',
)
..writeln(" cat <<'JSON'")
..writeln(jsonEncode(entry.value))
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
for (final repoEntry in commentResponsesByRepo.entries) { for (final repoEntry in commentResponsesByRepo.entries) {
final repo = repoEntry.key;
for (final entry in repoEntry.value.entries) { for (final entry in repoEntry.value.entries) {
buffer if (_matches(arguments, 0, 'api') &&
..writeln( _matches(
'if [[ "\$1" == "api" && "\$4" == ' arguments,
'"repos/$repo/issues/${entry.key}/comments?limit=100" ]]; then', 3,
) 'repos/${repoEntry.key}/issues/${entry.key}/comments?limit=100',
..writeln(" cat <<'JSON'") )) {
..writeln(jsonEncode(entry.value)) return result(entry.value);
..writeln('JSON') }
..writeln(' exit 0')
..writeln('fi');
} }
} }
for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { for (final repoEntry in postIssueNumbers.entries) {
final repo = repoEntry.key;
for (final issueNumber in repoEntry.value) { for (final issueNumber in repoEntry.value) {
buffer if (_matches(arguments, 0, 'api') &&
..writeln( _matches(
'if [[ "\$1" == "api" && "\$4" == ' arguments,
'"repos/$repo/issues/$issueNumber/comments" ]]; then', 3,
) 'repos/${repoEntry.key}/issues/$issueNumber/comments',
..writeln(r''' for arg in "$@"; do''') )) {
..writeln(r''' :'''); final body = _valueArg(arguments, 'body=');
if (postedBodyFile != null) { if (postedBodyFile != null && body != null) {
buffer await postedBodyFile.writeAsString(body);
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
} }
buffer return result({
..writeln(r''' done''')
..writeln(" cat <<'JSON'")
..writeln(
jsonEncode({
'id': 701, 'id': 701,
'url': 'https://gitea.example.test/comment/701', 'url': 'https://gitea.example.test/comment/701',
'body': 'posted', 'body': 'posted',
}), });
) }
..writeln('JSON')
..writeln(' exit 0')
..writeln('fi');
} }
} }
buffer return result(
..writeln(r'''echo "unexpected tea args: $*" >&2''') '',
..writeln('exit 1'); exitCode: 1,
stderr: 'unexpected tea args: ${arguments.join(' ')}\n',
await teaScript.writeAsString(buffer.toString()); );
await makeScriptExecutable(teaScript); };
}
bool _matches(List<String> arguments, int index, String value) =>
arguments.length > index && arguments[index] == value;
String? _valueArg(List<String> arguments, String prefix) {
for (final argument in arguments) {
if (argument.startsWith(prefix)) {
return argument.substring(prefix.length);
}
}
return null;
} }

View File

@ -1,5 +1,119 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:code_work_spawner/src/core/process_launcher.dart';
import 'package:mocktail/mocktail.dart';
typedef FakeCommandHandler =
Future<ProcessResult> Function(List<String> arguments, {Duration? timeout});
typedef FakeStartedCommandHandler =
Future<StartedExternalCommand> Function(List<String> arguments);
final Map<String, FakeCommandHandler> fakeCommandHandlers = {};
final Map<String, FakeStartedCommandHandler> fakeStartedCommandHandlers = {};
class MockExternalCommandExecutor extends Mock
implements ExternalCommandExecutor {}
ExternalCommandExecutor fakeExternalCommandExecutor() {
_registerMocktailFallbackValues();
final executor = MockExternalCommandExecutor();
when(
() => executor.run(
any(),
any<List<String>>(),
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
includeParentEnvironment: any(named: 'includeParentEnvironment'),
timeout: any(named: 'timeout'),
),
).thenAnswer((invocation) {
final command = invocation.positionalArguments[0] as String;
final arguments = invocation.positionalArguments[1] as List<String>;
final timeout = invocation.namedArguments[#timeout] as Duration?;
final handler = fakeCommandHandlers[command];
if (handler == null) {
throw StateError('No fake command registered for $command.');
}
return handler(arguments, timeout: timeout);
});
when(
() => executor.start(
any(),
any<List<String>>(),
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
includeParentEnvironment: any(named: 'includeParentEnvironment'),
runInShell: any(named: 'runInShell'),
mode: any(named: 'mode'),
),
).thenAnswer((invocation) {
final command = invocation.positionalArguments[0] as String;
final arguments = invocation.positionalArguments[1] as List<String>;
final handler = fakeStartedCommandHandlers[command];
if (handler == null) {
throw StateError('No fake started command registered for $command.');
}
return handler(arguments);
});
return executor;
}
class FakeStartedExternalCommand implements StartedExternalCommand {
final StreamController<List<int>> _stdoutController = StreamController();
final StreamController<List<int>> _stderrController = StreamController();
final Completer<int> _exitCodeCompleter = Completer<int>();
@override
Stream<List<int>> get stdout => _stdoutController.stream;
@override
Stream<List<int>> get stderr => _stderrController.stream;
@override
Future<int> get exitCode => _exitCodeCompleter.future;
void addStdout(String text) {
if (!_stdoutController.isClosed) {
_stdoutController.add(systemEncoding.encode(text));
}
}
void addStderr(String text) {
if (!_stderrController.isClosed) {
_stderrController.add(systemEncoding.encode(text));
}
}
void complete([int exitCode = 0]) {
if (!_exitCodeCompleter.isCompleted) {
_exitCodeCompleter.complete(exitCode);
}
_stdoutController.close();
_stderrController.close();
}
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
complete();
return true;
}
}
bool _fallbackValuesRegistered = false;
void _registerMocktailFallbackValues() {
if (_fallbackValuesRegistered) {
return;
}
_fallbackValuesRegistered = true;
registerFallbackValue(<String>[]);
registerFallbackValue(<String, String>{});
registerFallbackValue(Duration.zero);
registerFallbackValue(ProcessStartMode.normal);
}
Future<void> makeScriptExecutable(File script) async { Future<void> makeScriptExecutable(File script) async {
if (Platform.isWindows) { if (Platform.isWindows) {
return; return;
@ -15,35 +129,3 @@ Future<void> makeScriptExecutable(File script) async {
); );
} }
} }
String bashScriptPath(String hostPath) {
if (!Platform.isWindows) {
return hostPath;
}
final normalized = hostPath.replaceAll('\\', '/');
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized);
if (drivePrefixMatch == null) {
return normalized;
}
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase();
final rest = drivePrefixMatch.group(2)!;
return '/mnt/$driveLetter/$rest';
}
String hostPathFromScript(String scriptPath) {
if (!Platform.isWindows) {
return scriptPath;
}
final trimmed = scriptPath.trim();
final mntPathMatch = RegExp(r'^/mnt/([a-zA-Z])/(.*)$').firstMatch(trimmed);
if (mntPathMatch == null) {
return trimmed;
}
final driveLetter = mntPathMatch.group(1)!.toUpperCase();
final rest = mntPathMatch.group(2)!.replaceAll('/', '\\');
return '$driveLetter:\\$rest';
}

View File

@ -76,15 +76,16 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
final responderScript = File( final responderScript = File(
p.join(sandbox.path, 'responder-capture.sh'), p.join(sandbox.path, 'responder-capture.sh'),
); );
await responderScript.writeAsString('''#!/usr/bin/env bash await writeCapturingResponderScript(
set -euo pipefail responderScript,
pwd > "${bashScriptPath(cwdCapture.path)}" {
cat > "${bashScriptPath(stdinCapture.path)}" 'decision': 'no_reply',
cat <<'JSON' 'mode': 'planning',
{"decision":"no_reply","mode":"planning","summary":"captured mention context"} 'summary': 'captured mention context',
JSON },
'''); cwdCapture: cwdCapture,
await makeScriptExecutable(responderScript); stdinCapture: stdinCapture,
);
// And an orchestrator-style config pointing to the fake repo and responder. // And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File( final configFile = File(
@ -110,6 +111,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -117,7 +120,7 @@ projects:
await app.close(); await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout. // Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = hostPathFromScript(await cwdCapture.readAsString()); final capturedCwd = await cwdCapture.readAsString();
expect( expect(
p.normalize(capturedCwd.trim()), p.normalize(capturedCwd.trim()),
isNot(p.normalize(repoDir.path)), isNot(p.normalize(repoDir.path)),
@ -201,15 +204,12 @@ projects:
final responderScript = File( final responderScript = File(
p.join(sandbox.path, 'planning-responder.sh'), p.join(sandbox.path, 'planning-responder.sh'),
); );
await responderScript.writeAsString('''#!/usr/bin/env bash await writeCapturingResponderScript(responderScript, {
set -euo pipefail 'decision': 'reply',
pwd > "${bashScriptPath(cwdCapture.path)}" 'mode': 'planning',
cat >/dev/null 'markdown': 'planning reply',
cat <<'JSON' 'summary': 'used project path',
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"} }, cwdCapture: cwdCapture);
JSON
''');
await makeScriptExecutable(responderScript);
// And an orchestrator-style config pointing to the fake repo and responder. // And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
@ -234,6 +234,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -241,7 +243,7 @@ projects:
await app.close(); await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout. // Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = hostPathFromScript(await cwdCapture.readAsString()); final capturedCwd = await cwdCapture.readAsString();
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path))); expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
expect( expect(
p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())), p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
@ -331,6 +333,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.

View File

@ -68,7 +68,6 @@ Future<void> main(List<String> arguments) async {
'executable': resolved.executable, 'executable': resolved.executable,
'arguments': resolved.arguments, 'arguments': resolved.arguments,
'run_in_shell': resolved.runInShell, 'run_in_shell': resolved.runInShell,
'uses_bash_shim': resolved.usesBashShim,
}, },
'results': <Object?>[], 'results': <Object?>[],
}; };