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:
parent
041225b2aa
commit
c55643337c
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,168 +54,28 @@ 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({
|
||||||
|
|
@ -232,68 +93,20 @@ 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({
|
||||||
|
|
@ -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,109 +128,31 @@ 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({
|
||||||
|
|
@ -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},
|
||||||
|
},
|
||||||
|
postIssueNumbers: {
|
||||||
|
repo: {issueNumber},
|
||||||
|
},
|
||||||
|
postedBodyFile: postedBodyFile,
|
||||||
|
webhookIdsByRepo: {repo: webhookId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _registerFakeGhCommand({
|
||||||
|
required String command,
|
||||||
|
required Map<String, Object?> searchResponse,
|
||||||
|
required Map<String, List<Map<String, Object?>>> issueLists,
|
||||||
|
required Map<String, Map<int, List<Map<String, Object?>>>> comments,
|
||||||
|
required Map<String, Set<int>> postIssueNumbers,
|
||||||
|
File? ghLog,
|
||||||
|
bool failViewerLookup = false,
|
||||||
|
String? viewerLogin,
|
||||||
|
Map<String, Object?>? openSearchResponse,
|
||||||
|
File? postedBodyFile,
|
||||||
|
File? reactionBodyFile,
|
||||||
|
File? flakySearchStateFile,
|
||||||
|
Map<String, Object?>? webhookPayload,
|
||||||
|
Map<String, int>? webhookIdsByRepo,
|
||||||
|
}) {
|
||||||
|
if (webhookPayload != null) {
|
||||||
|
fakeStartedCommandHandlers[command] = (arguments) async {
|
||||||
if (ghLog != null) {
|
if (ghLog != null) {
|
||||||
buffer.writeln(
|
await _appendLine(ghLog, '${arguments.join(' ')}\n');
|
||||||
r'''printf '%s\n' "$*" >> '''
|
}
|
||||||
'"${bashScriptPath(ghLog.path)}"',
|
final process = FakeStartedExternalCommand();
|
||||||
|
final targetUrl = _argumentValue(arguments, '--url=');
|
||||||
|
if (targetUrl != null) {
|
||||||
|
unawaited(_postGitHubWebhook(targetUrl, webhookPayload));
|
||||||
|
}
|
||||||
|
return process;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
for (final entry in issueLists.entries) {
|
||||||
buffer
|
final path = 'repos/${entry.key}/issues?';
|
||||||
..writeln(" cat <<'JSON'")
|
if (_argsMatchAt(arguments, 0, 'api') &&
|
||||||
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
arguments.length > 3 &&
|
||||||
..writeln('JSON')
|
arguments[3].startsWith(path)) {
|
||||||
..writeln(' exit 0')
|
return result(entry.value);
|
||||||
..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/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
|
}
|
||||||
..writeln(r''' done''')
|
|
||||||
..writeln(" cat <<'JSON'")
|
for (final repoEntry in comments.entries) {
|
||||||
..writeln(
|
for (final entry in repoEntry.value.entries) {
|
||||||
jsonEncode({
|
if (_argsMatchAt(arguments, 0, 'api') &&
|
||||||
'id': 703,
|
_argsMatchAt(
|
||||||
'html_url': 'https://example.test/comment/703',
|
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');
|
|
||||||
|
|
||||||
buffer
|
for (final repoEntry in comments.entries) {
|
||||||
..writeln(
|
for (final issueEntry in repoEntry.value.entries) {
|
||||||
'if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks/$webhookId" ]]; then',
|
for (final comment in issueEntry.value) {
|
||||||
)
|
final commentId = comment['id'];
|
||||||
..writeln(' exit 0')
|
if (commentId is int &&
|
||||||
..writeln('fi');
|
_argsMatchAt(arguments, 0, 'api') &&
|
||||||
|
_argsMatchAt(
|
||||||
|
arguments,
|
||||||
|
3,
|
||||||
|
'repos/${repoEntry.key}/issues/comments/$commentId/reactions',
|
||||||
|
)) {
|
||||||
|
final content = _argumentValue(arguments, 'content=');
|
||||||
|
if (reactionBodyFile != null && content != null) {
|
||||||
|
await reactionBodyFile.writeAsString(content);
|
||||||
|
}
|
||||||
|
return result({'id': 990, 'content': 'eyes'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buffer
|
for (final entry in (webhookIdsByRepo ?? const <String, int>{}).entries) {
|
||||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
final hooksPath = 'repos/${entry.key}/hooks';
|
||||||
..writeln('exit 1');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ghScript.writeAsString(buffer.toString());
|
return result(
|
||||||
await makeScriptExecutable(ghScript);
|
'',
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
|
|
||||||
cat >/dev/null
|
class FakeResponderRunner extends CliResponderRunner {
|
||||||
sleep ${delay.inSeconds}
|
FakeResponderRunner(this._responders);
|
||||||
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
|
|
||||||
cat <<'JSON'
|
final Map<String, _FakeResponder> _responders;
|
||||||
${jsonEncode(response)}
|
|
||||||
JSON
|
@override
|
||||||
''');
|
Future<ResponderResult> run({
|
||||||
await makeScriptExecutable(responderScript);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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?>[],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue