diff --git a/examples/README.md b/examples/README.md index c75939b..ba3c370 100644 --- a/examples/README.md +++ b/examples/README.md @@ -79,3 +79,8 @@ Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, [`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject. This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. + +## tea + windows + +The `tea` CLI has known severe issues on Windows due to lipgloss/v2 query console color. We had bypass it by read config file of `tea` directly, but only token login is supported. And need to config a default login through `tea login default `. + diff --git a/lib/src/core/process_launcher.dart b/lib/src/core/process_launcher.dart index 2b32daa..097cb1d 100644 --- a/lib/src/core/process_launcher.dart +++ b/lib/src/core/process_launcher.dart @@ -1,39 +1,92 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import '../config/app_logger.dart'; +import 'package:executable/executable.dart'; import 'package:path/path.dart' as p; -class _ResolvedCommand { - _ResolvedCommand({ +class ResolvedExternalCommand { + ResolvedExternalCommand({ required this.executable, required this.arguments, required this.usesBashShim, + required this.runInShell, }); final String executable; final List arguments; final bool usesBashShim; + final bool runInShell; } -_ResolvedCommand _resolveCommand(String executable, List arguments) { - final extension = p.extension(executable).toLowerCase(); +final AppLogger _processLauncherLogger = AppLogger( + 'code_work_spawner.process_launcher', +); + +ResolvedExternalCommand resolveExternalCommandForCurrentPlatform( + String executable, + List arguments, { + bool? isWindows, +}) { + final isWindowsPlatform = isWindows ?? Platform.isWindows; + final localExecutablePath = _resolveLocalExecutablePath(executable); + final resolvedExecutablePath = + localExecutablePath ?? _resolveExecutablePathOnPath(executable); + final effectiveExecutable = resolvedExecutablePath ?? executable; + final extension = p.extension(effectiveExecutable).toLowerCase(); final shouldUseBash = extension.isEmpty || extension == '.sh'; - if (Platform.isWindows && shouldUseBash && File(executable).existsSync()) { - final bashScriptPath = _toBashPath(executable); - return _ResolvedCommand( + 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 _ResolvedCommand( - executable: executable, + return ResolvedExternalCommand( + executable: effectiveExecutable, arguments: arguments, usesBashShim: false, + // Only batch files require shell mediation on Windows. Native executables + // such as Scoop shims should run directly to avoid hanging shell wrappers. + runInShell: isWindowsPlatform && _requiresWindowsShell(extension), ); } +bool _requiresWindowsShell(String extension) { + return extension == '.bat' || extension == '.cmd'; +} + +String? _resolveLocalExecutablePath(String executable) { + final localFile = File(executable); + if (!localFile.existsSync()) { + return null; + } + return localFile.path; +} + +String? _resolveExecutablePathOnPath(String executable) { + if (_looksLikePathReference(executable)) { + return null; + } + return Executable(executable).findSync(); +} + +bool _looksLikePathReference(String executable) { + return executable.contains(r'\') || + executable.contains('/') || + executable.startsWith('.'); +} + String _toBashPath(String windowsPath) { final normalized = windowsPath.replaceAll('\\', '/'); final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized); @@ -52,20 +105,251 @@ Future runExternalCommand( String? workingDirectory, Map? environment, bool includeParentEnvironment = true, -}) { - final resolved = _resolveCommand(executable, arguments); + Duration? timeout, +}) async { + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + arguments, + ); final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( environment: environment, includeParentEnvironment: includeParentEnvironment, usesBashShim: resolved.usesBashShim, ); - return Process.run( - resolved.executable, - resolved.arguments, - workingDirectory: workingDirectory, - environment: effectiveEnvironment, - includeParentEnvironment: includeParentEnvironment, + final mode = Platform.isWindows && !resolved.usesBashShim + ? 'windows-wrapper' + : 'process-run'; + _processLauncherLogger.debug( + 'launch mode=$mode executable=${resolved.executable} ' + 'requested_executable=$executable run_in_shell=${resolved.runInShell} ' + 'uses_bash_shim=${resolved.usesBashShim} ' + 'working_directory=${workingDirectory ?? "(inherit)"} ' + 'timeout_ms=${timeout?.inMilliseconds ?? 0} ' + 'args=${_renderLauncherArguments(resolved.arguments)}', ); + if (Platform.isWindows && !resolved.usesBashShim) { + _processLauncherLogger.debug( + 'process_run in runExternalCommand - windows wrapper', + ); + return _runExternalCommandViaWindowsWrapper( + executable: resolved.executable, + arguments: resolved.arguments, + workingDirectory: workingDirectory, + environment: effectiveEnvironment, + includeParentEnvironment: includeParentEnvironment, + 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(); + _processLauncherLogger.debug( + 'finished mode=$mode exit_code=${result.exitCode} ' + 'duration_ms=${stopwatch.elapsedMilliseconds} ' + 'stdout_len=${result.stdout.toString().length} ' + 'stderr_len=${result.stderr.toString().length}', + ); + return result; + } on TimeoutException { + stopwatch.stop(); + _processLauncherLogger.debug( + 'timeout mode=$mode duration_ms=${stopwatch.elapsedMilliseconds}', + ); + rethrow; + } +} + +Future _runExternalCommandViaWindowsWrapper({ + required String executable, + required List arguments, + required String? workingDirectory, + required Map? environment, + required bool includeParentEnvironment, + required Duration? timeout, +}) async { + const timeoutExitCode = 124; + final sandbox = await Directory.systemTemp.createTemp( + 'cws-process-launcher-', + ); + 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', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + script.path, + ], + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); + stopwatch.stop(); + final stdoutText = await _readWrapperOutput(stdoutPath); + final stderrText = await _readWrapperOutput(stderrPath); + _processLauncherLogger.debug( + 'wrapper finished exit_code=${wrapperResult.exitCode} ' + 'duration_ms=${stopwatch.elapsedMilliseconds} ' + 'stdout_len=${stdoutText.length} stderr_len=${stderrText.length} ' + 'wrapper_stdout_len=${wrapperResult.stdout.toString().length} ' + 'wrapper_stderr_len=${wrapperResult.stderr.toString().length}', + ); + if (wrapperResult.exitCode == timeoutExitCode) { + throw TimeoutException( + 'Command timed out after ${timeout!.inMilliseconds}ms.', + timeout, + ); + } + return ProcessResult( + wrapperResult.pid, + wrapperResult.exitCode, + stdoutText, + stderrText.isEmpty ? wrapperResult.stderr : stderrText, + ); + } finally { + await sandbox.delete(recursive: true); + } +} + +String _buildWindowsWrapperScript({ + required String executable, + required List 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 _readWrapperOutput(String path) async { + final file = File(path); + if (!await file.exists()) { + return ''; + } + return file.readAsString(); +} + +String _renderPowerShellArray(List arguments) { + final renderedArguments = arguments.map(_powerShellSingleQuoted).join(', '); + return '@($renderedArguments)'; +} + +String _powerShellSingleQuoted(String value) { + return "'${value.replaceAll("'", "''")}'"; +} + +String _renderLauncherArguments(List arguments) { + return arguments.map(_summarizeLauncherArgument).join(' '); +} + +String _summarizeLauncherArgument(String value) { + if (value.startsWith('body=')) { + return 'body='; + } + if (value.length <= 120) { + return value; + } + return '${value.substring(0, 117)}...'; } Future startExternalCommand( @@ -77,7 +361,10 @@ Future startExternalCommand( bool runInShell = false, ProcessStartMode mode = ProcessStartMode.normal, }) { - final resolved = _resolveCommand(executable, arguments); + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + arguments, + ); final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( environment: environment, includeParentEnvironment: includeParentEnvironment, @@ -89,7 +376,7 @@ Future startExternalCommand( workingDirectory: workingDirectory, environment: effectiveEnvironment, includeParentEnvironment: includeParentEnvironment, - runInShell: runInShell, + runInShell: runInShell || resolved.runInShell, mode: mode, ); } diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index 7f12b3f..887827e 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:github/github.dart'; import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; import '../config/app_environment.dart'; +import '../config/app_logger.dart'; import '../config/config_schema.dart'; import '../core/process_launcher.dart'; import 'models.dart'; @@ -15,12 +18,17 @@ class IssueTrackerClient { this.glabCommand = 'glab', this.teaCommand = 'tea', this.opCommand = 'op', - }); + Duration? commandTimeout, + AppLogger? logger, + }) : commandTimeout = commandTimeout ?? const Duration(seconds: 15), + _logger = logger ?? AppLogger('code_work_spawner.issue_tracker'); final String ghCommand; final String glabCommand; final String teaCommand; final String opCommand; + final Duration commandTimeout; + final AppLogger _logger; final Map> _authenticatedLoginFutures = >{}; final Map> _openProjectProjectFutures = @@ -81,18 +89,14 @@ class IssueTrackerClient { {'per_page': '100'}, ), ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath( + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'GET', + path: _buildApiPath( issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'limit': '100'}, ), - '-r', - issue.requiredRepoSlug.fullName, - ]), + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/work_packages/${issue.number}/activities', @@ -157,20 +161,15 @@ class IssueTrackerClient { '-f', 'body=$body', ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'POST', - _buildApiPath(parseRepositorySlug(trackerProject), [ + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'POST', + path: _buildApiPath(parseRepositorySlug(trackerProject), [ 'issues', '$issueNumber', 'comments', ]), - '-f', - 'body=$body', - '-r', - trackerProject, - ]), + body: {'body': body}, + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'POST', path: '/api/v3/work_packages/$issueNumber/activities', @@ -241,7 +240,7 @@ class IssueTrackerClient { return switch (provider) { IssueTrackerProvider.github => 'gh', IssueTrackerProvider.gitlab => 'glab', - IssueTrackerProvider.gitea => 'tea', + IssueTrackerProvider.gitea => 'gitea', IssueTrackerProvider.openproject => opCommand, }; } @@ -250,43 +249,23 @@ class IssueTrackerClient { required RepositorySlug repo, required String url, }) async { - final result = await _runCommand(teaCommand, [ - 'webhooks', - 'create', - '--type', - 'gitea', - '--events', - 'issues,issue_comment', - '--active', - '-o', - 'json', - '-r', - repo.fullName, - url, - ]); - final stdoutText = result.stdout.toString().trim(); - if (stdoutText.isEmpty) { - throw FormatException( - 'tea webhooks create returned an empty response for repo ' - '${repo.fullName}.', - ); - } - - try { - final json = jsonDecode(stdoutText); - if (json is Map) { - return readIntField(json, 'id'); - } - } on FormatException { - final webhookId = _extractWebhookIdFromTeaOutput(stdoutText); - if (webhookId != null) { - return webhookId; - } + final json = await _runGiteaJson( + method: 'POST', + path: _buildApiPath(repo, ['hooks']), + body: { + 'type': 'gitea', + 'config': {'url': url, 'content_type': 'json'}, + 'events': ['issues', 'issue_comment'], + 'active': true, + }, + ); + if (json is Map) { + return readIntField(json, 'id'); } throw FormatException( - 'tea webhooks create returned an unexpected response for repo ' - '${repo.fullName}. stdout=${_summarizeOutput(stdoutText)}', + 'Gitea API webhook create returned an unexpected response for repo ' + '${repo.fullName}.', ); } @@ -362,14 +341,10 @@ class IssueTrackerClient { required RepositorySlug repo, required int webhookId, }) async { - await _runTeaJson([ - 'webhooks', - 'delete', - '--confirm', - '-r', - repo.fullName, - '$webhookId', - ]); + await _runGiteaJson( + method: 'DELETE', + path: _buildApiPath(repo, ['hooks', '$webhookId']), + ); } Future deleteGitLabWebhook({ @@ -404,6 +379,14 @@ class IssueTrackerClient { final issues = []; for (var page = 1; true; page += 1) { + _traceRequest( + provider: IssueTrackerProvider.github, + requestKind: 'search-issues', + trackerProject: repoList.map((repo) => repo.fullName).join(','), + page: page, + since: since, + requestPath: 'search/issues', + ); final json = await _runGhJson([ 'api', '-X', @@ -453,6 +436,14 @@ class IssueTrackerClient { 'page': '$page', if (since != null) 'updated_after': since.toUtc().toIso8601String(), }; + _traceRequest( + provider: IssueTrackerProvider.gitlab, + requestKind: 'list-issues', + trackerProject: trackerProject, + page: page, + since: since, + requestPath: _buildGitLabApiPath(trackerProject, ['issues'], query), + ); final json = await _runGlabJson([ 'api', '-X', @@ -490,23 +481,25 @@ class IssueTrackerClient { 'page': '$page', if (since != null) 'since': since.toUtc().toIso8601String(), }; - final json = await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath(repo, ['issues'], query), - '-r', - repo.fullName, - ]); + final requestPath = _buildApiPath(repo, ['issues'], query); + _traceRequest( + provider: IssueTrackerProvider.gitea, + requestKind: 'list-issues', + trackerProject: repo.fullName, + page: page, + since: since, + requestPath: requestPath, + ); + final json = await _runGiteaJson(method: 'GET', path: requestPath); if (json is Map) { throw FormatException( - 'tea api returned an error while listing Gitea issues for ' + 'Gitea API returned an error while listing issues for ' '${repo.fullName}: ${_describeJsonObject(json)}', ); } if (json != null && json is! List) { throw FormatException( - 'tea api returned ${json.runtimeType} while listing Gitea issues ' + 'Gitea API returned ${json.runtimeType} while listing issues ' 'for ${repo.fullName}; expected a JSON array.', ); } @@ -579,7 +572,10 @@ class IssueTrackerClient { final json = switch (provider) { IssueTrackerProvider.github => await _runGhJson(['api', 'user']), IssueTrackerProvider.gitlab => await _runGlabJson(['api', 'user']), - IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']), + IssueTrackerProvider.gitea => await _runGiteaJson( + method: 'GET', + path: '/user', + ), IssueTrackerProvider.openproject => await _runOpenProjectJson( method: 'GET', path: '/api/v3/users/me', @@ -604,8 +600,102 @@ class IssueTrackerClient { return _runJson(glabCommand, arguments); } - Future _runTeaJson(List arguments) { - return _runJson(teaCommand, arguments); + Future _runGiteaJson({ + required String method, + required String path, + Map? queryParameters, + Object? body, + }) async { + final config = await _loadGiteaConfig(); + final client = HttpClient(); + if (config.insecure) { + client.badCertificateCallback = (_, _, _) => true; + } + final uri = _buildGiteaUri( + config.host, + path, + queryParameters: queryParameters, + ); + final stopwatch = Stopwatch()..start(); + _logger.debug( + 'gitea request start timeout_ms=${commandTimeout.inMilliseconds} ' + 'method=$method uri=$uri', + ); + + try { + final result = + await (() async { + final request = await client.openUrl(method, uri); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set( + HttpHeaders.authorizationHeader, + 'token ${config.token}', + ); + if (body != null) { + request.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json', + ); + request.write(jsonEncode(body)); + } + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + return (response: response, body: responseBody); + })().timeout( + commandTimeout, + onTimeout: () { + throw TimeoutException( + 'Gitea API request timed out after ${commandTimeout.inMilliseconds}ms.', + commandTimeout, + ); + }, + ); + + stopwatch.stop(); + _logger.debug( + 'gitea request finished duration_ms=${stopwatch.elapsedMilliseconds} ' + 'status_code=${result.response.statusCode} method=$method uri=$uri', + ); + final trimmed = result.body.trim(); + final parsedJson = trimmed.isEmpty ? null : jsonDecode(trimmed); + if (result.response.statusCode < 200 || + result.response.statusCode >= 300) { + if (parsedJson is Map) { + throw FormatException( + 'Gitea API request failed ' + '(${result.response.statusCode} ${result.response.reasonPhrase}): ' + '${_describeJsonObject(parsedJson)}', + ); + } + throw HttpException( + 'Gitea API request failed ' + '(${result.response.statusCode} ${result.response.reasonPhrase}): ' + '${_summarizeOutput(result.body)}', + uri: uri, + ); + } + return parsedJson; + } on TimeoutException catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'gitea request timeout duration_ms=${stopwatch.elapsedMilliseconds} ' + 'timeout_ms=${commandTimeout.inMilliseconds} method=$method uri=$uri', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'gitea request failed duration_ms=${stopwatch.elapsedMilliseconds} ' + 'method=$method uri=$uri', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } finally { + client.close(force: true); + } } Future _runOpenProjectJson({ @@ -659,12 +749,31 @@ class IssueTrackerClient { final result = await _runCommand(command, arguments); final stdoutText = result.stdout.toString().trim(); if (stdoutText.isEmpty) { + _traceCommandResult( + command: command, + arguments: arguments, + state: 'empty-stdout', + ); return null; } try { - return jsonDecode(stdoutText); + final json = jsonDecode(stdoutText); + _traceCommandResult( + command: command, + arguments: arguments, + state: 'json-parsed', + detail: _describeJsonPayload(json), + ); + return json; } on FormatException catch (error) { + _traceCommandResult( + command: command, + arguments: arguments, + state: 'json-parse-failed', + detail: + '${error.message} stdout=${_summarizeOutput(stdoutText, maxLength: 120)}', + ); throw FormatException( 'Failed to parse JSON from `$command ${arguments.join(' ')}`: ' '${error.message}. stdout=${_summarizeOutput(stdoutText)}', @@ -676,7 +785,41 @@ class IssueTrackerClient { String command, List arguments, ) async { - final result = await runExternalCommand(command, arguments); + final renderedCommand = _renderCommand(command, arguments); + final stopwatch = Stopwatch()..start(); + _logger.debug( + 'command start timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', + ); + late final ProcessResult result; + try { + result = await runExternalCommand( + command, + arguments, + timeout: commandTimeout, + ); + } on TimeoutException catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'command timeout duration_ms=${stopwatch.elapsedMilliseconds} ' + 'timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } catch (error, stackTrace) { + stopwatch.stop(); + _logger.error( + 'command failed duration_ms=${stopwatch.elapsedMilliseconds} command=$renderedCommand', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + stopwatch.stop(); + _logger.debug( + 'command finished duration_ms=${stopwatch.elapsedMilliseconds} ' + 'exit_code=${result.exitCode} command=$renderedCommand', + ); if (result.exitCode != 0) { throw ProcessException( command, @@ -688,12 +831,19 @@ class IssueTrackerClient { return result; } - int? _extractWebhookIdFromTeaOutput(String stdoutText) { - final match = RegExp( - r'Webhook created successfully \(ID:\s*(\d+)\)', - caseSensitive: false, - ).firstMatch(stdoutText); - return match == null ? null : int.parse(match.group(1)!); + String _renderCommand(String command, List arguments) { + final parts = [command, ...arguments]; + return parts.map(_shellQuote).join(' '); + } + + String _shellQuote(String value) { + if (value.isEmpty) { + return '""'; + } + if (!value.contains(RegExp(r'[\s"]'))) { + return value; + } + return '"${value.replaceAll('"', r'\"')}"'; } String _describeJsonObject(Map json) { @@ -722,6 +872,47 @@ class IssueTrackerClient { return '${singleLine.substring(0, maxLength)}...'; } + String _describeJsonPayload(Object? json) { + return switch (json) { + List list => 'type=list length=${list.length}', + Map map => + 'type=object keys=${map.keys.take(6).join(",")}', + null => 'type=null', + _ => 'type=${json.runtimeType}', + }; + } + + void _traceRequest({ + required IssueTrackerProvider provider, + required String requestKind, + required String trackerProject, + required int page, + required DateTime? since, + required String requestPath, + }) { + _logger.debug( + 'trace request provider=${provider.name} kind=$requestKind ' + 'tracker_project=$trackerProject page=$page ' + 'since=${since?.toUtc().toIso8601String() ?? "(none)"} ' + 'path=$requestPath', + ); + } + + void _traceCommandResult({ + required String command, + required List arguments, + required String state, + String? detail, + }) { + final parts = [ + 'trace command_result state=$state', + 'command=$command', + 'args=${arguments.map(_shellQuote).join(" ")}', + if (detail != null && detail.isNotEmpty) 'detail=$detail', + ]; + _logger.debug(parts.join(' ')); + } + String _buildApiPath( RepositorySlug repo, List tailSegments, [ @@ -761,6 +952,186 @@ class IssueTrackerClient { ); } + Uri _buildGiteaUri( + Uri host, + String path, { + Map? queryParameters, + }) { + final relativeUri = Uri.parse(path); + final normalizedPath = relativeUri.path.startsWith('/') + ? relativeUri.path + : '/${relativeUri.path}'; + final effectiveQueryParameters = + queryParameters ?? + (relativeUri.hasQuery ? relativeUri.queryParameters : null); + return host.replace( + path: p.posix.normalize( + '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}/api/v1$normalizedPath', + ), + queryParameters: + effectiveQueryParameters == null || effectiveQueryParameters.isEmpty + ? null + : effectiveQueryParameters, + ); + } + + Future<_GiteaConfig> _loadGiteaConfig() async { + final environment = AppEnvironment.variables; + final host = + environment['CWS_GITEA_HOST']?.trim() ?? + environment['GITEA_HOST']?.trim(); + final token = + environment['CWS_GITEA_TOKEN']?.trim() ?? + environment['GITEA_TOKEN']?.trim(); + final insecureValue = + environment['CWS_GITEA_INSECURE']?.trim() ?? + environment['GITEA_INSECURE']?.trim(); + if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { + return _GiteaConfig( + host: Uri.parse(host), + token: token, + insecure: _parseBooleanFlag(insecureValue), + ); + } + + final loginName = + environment['CWS_GITEA_LOGIN']?.trim() ?? + environment['GITEA_LOGIN']?.trim() ?? + environment['TEA_LOGIN']?.trim(); + final configPath = _teaConfigPath(environment); + final file = File(configPath); + if (!await file.exists()) { + throw const FileSystemException( + 'Gitea config not found. Set CWS_GITEA_HOST/CWS_GITEA_TOKEN or configure tea first.', + ); + } + + final document = loadYaml(await file.readAsString()); + final logins = + (document is YamlMap ? document['logins'] : null) as YamlList?; + if (logins == null || logins.isEmpty) { + throw FileSystemException( + 'No tea logins found in Gitea config at $configPath.', + ); + } + + final loginMaps = logins + .whereType() + .map((entry) => Map.from(entry)) + .toList(growable: false); + final selectedLogin = _selectTeaLogin(loginMaps, loginName); + final loginHost = selectedLogin['url']?.toString().trim(); + final loginToken = selectedLogin['token']?.toString().trim(); + if (loginHost == null || + loginHost.isEmpty || + loginToken == null || + loginToken.isEmpty) { + throw FileSystemException( + 'Selected tea login in $configPath is missing url or token.', + ); + } + + return _GiteaConfig( + host: Uri.parse(loginHost), + token: loginToken, + insecure: _parseBooleanFlag(selectedLogin['insecure']?.toString()), + ); + } + + Map _selectTeaLogin( + List> logins, + String? requestedLoginName, + ) { + if (requestedLoginName != null && requestedLoginName.isNotEmpty) { + final matchedLogin = logins + .where((login) { + final name = login['name']?.toString().trim(); + return name != null && + name.toLowerCase() == requestedLoginName.toLowerCase(); + }) + .toList(growable: false); + if (matchedLogin.length == 1) { + return matchedLogin.single; + } + throw FileSystemException( + 'tea login "$requestedLoginName" was not found or was ambiguous.', + ); + } + + final defaultLogins = logins + .where((login) { + final value = login['default']; + return value == true || value?.toString().toLowerCase() == 'true'; + }) + .toList(growable: false); + if (defaultLogins.length == 1) { + return defaultLogins.single; + } + if (logins.length == 1) { + return logins.single; + } + throw const FileSystemException( + 'Multiple tea logins found. Set CWS_GITEA_LOGIN, GITEA_LOGIN, or TEA_LOGIN to choose one.', + ); + } + + String _teaConfigPath(Map environment) { + final explicitPath = + environment['CWS_GITEA_CONFIG_PATH']?.trim() ?? + environment['TEA_CONFIG_PATH']?.trim(); + if (explicitPath != null && explicitPath.isNotEmpty) { + return explicitPath; + } + + final candidates = []; + final localAppData = environment['LOCALAPPDATA']?.trim(); + if (localAppData != null && localAppData.isNotEmpty) { + candidates.add(p.join(localAppData, 'tea', 'config.yml')); + } + final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); + if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { + candidates.add(p.join(xdgConfigHome, 'tea', 'config.yml')); + } + final userProfile = environment['USERPROFILE']?.trim(); + if (userProfile != null && userProfile.isNotEmpty) { + candidates.add( + p.join(userProfile, 'AppData', 'Local', 'tea', 'config.yml'), + ); + candidates.add(p.join(userProfile, '.tea', 'tea.yml')); + } + final home = environment['HOME']?.trim(); + if (home != null && home.isNotEmpty) { + candidates.add(p.join(home, '.config', 'tea', 'config.yml')); + candidates.add(p.join(home, '.tea', 'tea.yml')); + } + if (candidates.isEmpty) { + throw const FileSystemException( + 'HOME is not set and LOCALAPPDATA/XDG_CONFIG_HOME/USERPROFILE are unset. Cannot locate tea config.', + ); + } + + for (final candidate in candidates) { + if (File(candidate).existsSync()) { + return candidate; + } + } + return candidates.first; + } + + bool _parseBooleanFlag(String? value) { + if (value == null) { + return false; + } + switch (value.trim().toLowerCase()) { + case '1': + case 'true': + case 'yes': + case 'on': + return true; + } + return false; + } + Future<_OpenProjectConfig> _loadOpenProjectConfig() async { final environment = AppEnvironment.variables; final host = environment['OP_CLI_HOST']?.trim(); @@ -864,6 +1235,18 @@ class _OpenProjectConfig { final String token; } +class _GiteaConfig { + const _GiteaConfig({ + required this.host, + required this.token, + required this.insecure, + }); + + final Uri host; + final String token; + final bool insecure; +} + class _OpenProjectProjectRef { const _OpenProjectProjectRef({required this.id, required this.name}); diff --git a/pubspec.yaml b/pubspec.yaml index b64e2c9..f27152d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: git: ^2.3.2 nyxx: ^6.8.1 dart_eval: ^0.8.4 + executable: ^1.4.1 dev_dependencies: build_runner: ^2.6.0 diff --git a/test/core/multi_project_test.dart b/test/core/multi_project_test.dart index b2ca8ea..aac6b81 100644 --- a/test/core/multi_project_test.dart +++ b/test/core/multi_project_test.dart @@ -234,24 +234,21 @@ projects: await initGitRepo(repoDir); // And Gitea issue data containing a comment with an explicit assistant mention. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - await writeFakeTeaScript( - teaScript: teaScript, + final giteaServer = await FakeGiteaApiServer.start( issueListResponsesByRepo: { 'owner/sample': [ { - 'index': 12, + 'number': 12, 'title': 'Need architecture help', 'body': 'Please review the API layering.', 'state': 'open', - 'url': 'https://gitea.example.test/owner/sample/issues/12', + 'html_url': 'https://gitea.example.test/owner/sample/issues/12', 'updated_at': '2026-04-04T12:00:00Z', - 'poster': {'login': 'reporter'}, + 'user': {'login': 'reporter'}, 'labels': [ {'name': 'help'}, ], + 'pull_request': null, }, ], }, @@ -261,22 +258,28 @@ projects: { 'id': 50, 'body': '@helper can you plan the architecture?', - 'url': + 'html_url': 'https://gitea.example.test/owner/sample/issues/12#issuecomment-50', 'created_at': '2026-04-04T12:00:00Z', 'updated_at': '2026-04-04T12:00:00Z', - 'poster': {'login': 'reporter'}, + 'user': {'login': 'reporter'}, }, ], }, }, - postCommentIssueNumbersByRepo: { - 'owner/sample': {12}, + postCommentResponsesByRepo: { + 'owner/sample': { + 12: { + 'id': 701, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-701', + }, + }, }, - teaLog: teaLog, - postedBodyFile: postedBody, viewerLogin: 'tea-octocat', ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -307,37 +310,44 @@ projects: repo: owner/sample path: ${repoDir.path} defaultBranch: main +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), - teaCommand: teaScript.path, ); // When the app processes one polling cycle. await app.runOnce(); await app.close(); - // Then it reads issue comments and posts exactly one reply through tea for that issue. - final logLines = await teaLog.readAsLines(); + // Then it reads issue comments and posts exactly one reply through the Gitea API for that issue. expect( - logLines + giteaServer.requestPaths .where( - (line) => line.contains('repos/owner/sample/issues?state=open'), + (path) => path.contains('/api/v1/repos/owner/sample/issues?'), ) .length, 1, ); expect( - logLines + giteaServer.requestPaths .where( - (line) => line.contains('repos/owner/sample/issues/12/comments'), + (path) => path.contains( + '/api/v1/repos/owner/sample/issues/12/comments', + ), ) .length, 2, ); - final postedComment = await postedBody.readAsString(); - expect(postedComment, contains('via `tea`')); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; + expect(postedComment, contains('via `gitea`')); expect(postedComment, contains('@tea-octocat')); expect( postedComment, diff --git a/test/core/process_launcher_test.dart b/test/core/process_launcher_test.dart new file mode 100644 index 0000000..a676d9d --- /dev/null +++ b/test/core/process_launcher_test.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:code_work_spawner/src/core/process_launcher.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Process launcher command resolution + /// + /// As a maintainer running the app on Windows + /// I want native executables to avoid unnecessary shell wrappers + /// So that tracker CLIs do not hang before returning their output + /// ``` + group('resolveExternalCommandForCurrentPlatform', () { + /// ```gherkin + /// Scenario: Execute Windows native executables directly + /// Given a Windows executable path for a native CLI + /// When the process launcher resolves the command + /// Then it keeps the original executable path + /// And it does not force the command to run in a shell + /// ``` + test('Execute Windows native executables directly', () { + // Given a Windows executable path for a native CLI. + const executable = r'C:\Users\bensung\scoop\shims\tea.exe'; + + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + const ['--version'], + isWindows: true, + ); + + // Then it keeps the original executable path. + expect(resolved.executable, executable); + + // And it does not force the command to run in a shell. + expect(resolved.runInShell, isFalse); + expect(resolved.usesBashShim, isFalse); + }); + + /// ```gherkin + /// Scenario: Keep Windows batch files on the shell path + /// Given a Windows command script path + /// When the process launcher resolves the command + /// Then it leaves the script path unchanged + /// And it marks the command to run in a shell + /// ``` + test('Keep Windows batch files on the shell path', () { + // Given a Windows command script path. + const executable = r'C:\tools\helper.cmd'; + + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + executable, + const ['sync'], + isWindows: true, + ); + + // Then it leaves the script path unchanged. + expect(resolved.executable, executable); + + // And it marks the command to run in a shell. + expect(resolved.runInShell, isTrue); + expect(resolved.usesBashShim, isFalse); + }); + + /// ```gherkin + /// Scenario: Wrap local extensionless scripts through WSL bash on Windows + /// Given a local extensionless script file on Windows + /// When the process launcher resolves the command + /// Then it swaps the executable to wsl.exe + /// And it forwards the script path as a bash path + /// ``` + test( + 'Wrap local extensionless scripts through WSL bash on Windows', + () async { + // Given a local extensionless script file on Windows. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-process-launcher-', + ); + final script = File(p.join(sandbox.path, 'fake-tea')); + await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n'); + + try { + // When the process launcher resolves the command. + final resolved = resolveExternalCommandForCurrentPlatform( + script.path, + const ['api'], + isWindows: true, + ); + + // Then it swaps the executable to wsl.exe. + expect(resolved.executable, 'wsl.exe'); + expect(resolved.usesBashShim, isTrue); + + // And it forwards the script path as a bash path. + expect(resolved.runInShell, isFalse); + expect(resolved.arguments.first, '--exec'); + expect(resolved.arguments[1], 'bash'); + expect(resolved.arguments[2], startsWith('/mnt/')); + } finally { + await sandbox.delete(recursive: true); + } + }, + ); + }); +} diff --git a/test/issue_event_source/gosmee_test.dart b/test/issue_event_source/gosmee_test.dart index a082997..a6dcaeb 100644 --- a/test/issue_event_source/gosmee_test.dart +++ b/test/issue_event_source/gosmee_test.dart @@ -21,12 +21,12 @@ void registerIssueAssistantAppRunGosmeeTests() { /// Scenario: Reply to a mention received through tea/gosmee /// Given a temporary project checkout that can be used as the local repo path /// And a gosmee script that forwards one Gitea issue_comment webhook event - /// And a tea script that can manage webhooks and post issue comments + /// And a Gitea API fixture that can manage webhooks and post issue comments /// And a responder that emits a planning reply /// And an orchestrator-style config that uses tea/gosmee /// When the long-running app starts and receives the forwarded webhook /// Then it reads issue comments and posts exactly one reply for that issue - /// And the tea webhook lifecycle commands are invoked for the Gitea project + /// And the Gitea webhook lifecycle API is invoked for the project /// ``` test('Reply to a mention received through tea/gosmee', () async { // Given a temporary project checkout that can be used as the local repo path. @@ -70,62 +70,26 @@ void registerIssueAssistantAppRunGosmeeTests() { gosmeeLog: gosmeeLog, ); - // And a tea script that can manage webhooks and post issue comments. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}" - -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'tea-octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then - cat <<'JSON' -[] -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?limit=100" ]]; then - cat <<'JSON' -${jsonEncode(comments)} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then - for arg in "\$@"; do - if [[ "\$arg" == body=* ]]; then - printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}" - fi - done - cat <<'JSON' -${jsonEncode({'id': 801, 'url': 'https://gitea.example.test/comment/801', 'body': 'posted'})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - cat <<'JSON' -${jsonEncode({'id': 81})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); + // And a Gitea API fixture for issue reads and replies. + final giteaServer = await FakeGiteaApiServer.start( + issueListResponsesByRepo: { + 'owner/sample': const >[], + }, + commentResponsesByRepo: { + 'owner/sample': {12: comments}, + }, + postCommentResponsesByRepo: { + 'owner/sample': { + 12: { + 'id': 801, + 'body': 'posted', + 'html_url': 'https://gitea.example.test/comment/801', + }, + }, + }, + viewerLogin: 'tea-octocat', + ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -156,18 +120,21 @@ projects: type: tea/gosmee repo: owner/sample path: ${repoDir.path} +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, - teaCommand: teaScript.path, ); // When the long-running app starts and receives the forwarded webhook. final runFuture = app.run(); for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { + if (giteaServer.postedBodies.isNotEmpty) { break; } await Future.delayed(const Duration(milliseconds: 100)); @@ -176,38 +143,31 @@ projects: await runFuture; // Then it reads issue comments and posts exactly one reply for that issue. - expect(await postedBody.exists(), isTrue); - final postedComment = await postedBody.readAsString(); + expect(giteaServer.postedBodies, hasLength(1)); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; expect( postedComment, contains('Plan:\n- consume gosmee issue_comment events'), ); expect(postedComment, contains('@tea-octocat')); - // And the tea webhook lifecycle commands are invoked for the Gitea project. - final teaLogLines = await teaLog.readAsLines(); + // And the Gitea webhook lifecycle API is invoked for the project. + expect(giteaServer.createdWebhooks, hasLength(1)); expect( - teaLogLines.any( - (line) => line.contains( - 'webhooks create --type gitea --events issues,issue_comment --active -o json -r owner/sample https://gosmee.example.test/sample', - ), - ), - isTrue, - ); - expect( - teaLogLines.any( - (line) => - line.contains('webhooks delete --confirm -r owner/sample 81'), - ), - isTrue, + (giteaServer.createdWebhooks.single['config'] as Map)['url'], + 'https://gosmee.example.test/sample', ); + expect(giteaServer.deletedWebhookIds, contains(81)); }); /// ```gherkin /// Scenario: Reconcile a missed Gitea gosmee event through polling /// Given a temporary project checkout that can be used as the local repo path /// And a gosmee script that starts forwarding without delivering an event - /// And a tea script that returns a new issue only on a later reconciliation poll + /// And a Gitea API fixture that returns a new issue only on a later reconciliation poll /// And a responder that emits a planning reply /// And an orchestrator-style config that uses tea/gosmee with continuous reconciliation /// When the long-running app starts and enough time passes for reconciliation polling @@ -245,11 +205,6 @@ exit 1 '''); await makeScriptExecutable(gosmeeScript); - // And a tea script that returns a new issue only on a later reconciliation poll. - final teaScript = File(p.join(sandbox.path, 'tea')); - final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); - final postedBody = File(p.join(sandbox.path, 'posted-body.md')); - final issueListCount = File(p.join(sandbox.path, 'issue-list-count')); final issue = { 'index': 18, 'title': 'Need reconciliation after a missed webhook', @@ -271,70 +226,30 @@ exit 1 'poster': {'login': 'reporter'}, }, ]; - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail - printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}" -if [[ "\$1" == "api" && "\$2" == "user" ]]; then - cat <<'JSON' -${jsonEncode({'login': 'tea-octocat'})} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then - count=0 - if [[ -f "${bashScriptPath(issueListCount.path)}" ]]; then - count="\$(cat "${bashScriptPath(issueListCount.path)}")" - fi - count=\$((count + 1)) - printf '%s' "\$count" > "${bashScriptPath(issueListCount.path)}" - if [[ "\$count" -eq 1 ]]; then - cat <<'JSON' -[] -JSON - else - cat <<'JSON' -${jsonEncode([issue])} -JSON - fi - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?limit=100" ]]; then - cat <<'JSON' -${jsonEncode(comments)} -JSON - exit 0 -fi - -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then - for arg in "\$@"; do - if [[ "\$arg" == body=* ]]; then - printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}" - fi - done - cat <<'JSON' -${jsonEncode({'id': 802, 'url': 'https://gitea.example.test/comment/802', 'body': 'posted'})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - cat <<'JSON' -${jsonEncode({'id': 82})} -JSON - exit 0 -fi - -if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); + // And a Gitea API fixture that returns a new issue only on a later reconciliation poll. + final giteaServer = await FakeGiteaApiServer.start( + issueListResponseSequenceByRepo: { + 'owner/sample': [ + const >[], + [issue], + ], + }, + commentResponsesByRepo: { + 'owner/sample': {18: comments}, + }, + postCommentResponsesByRepo: { + 'owner/sample': { + 18: { + 'id': 802, + 'body': 'posted', + 'html_url': 'https://gitea.example.test/comment/802', + }, + }, + }, + viewerLogin: 'tea-octocat', + ); + addTearDown(giteaServer.close); // And a responder that emits a planning reply. final responderScript = File(p.join(sandbox.path, 'responder.sh')); @@ -366,18 +281,21 @@ projects: reconcileInterval: 1s repo: owner/sample path: ${repoDir.path} +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +CWS_GITEA_HOST=${giteaServer.baseUrl} +CWS_GITEA_TOKEN=test-token '''); final app = await IssueAssistantApp.open( config: await AppConfig.load(configFile.path), databasePath: p.join(sandbox.path, 'state.sqlite3'), gosmeeCommand: gosmeeScript.path, - teaCommand: teaScript.path, ); // When the long-running app starts and enough time passes for reconciliation polling. final runFuture = app.run(); for (var attempt = 0; attempt < 200; attempt += 1) { - if (await postedBody.exists()) { + if (giteaServer.postedBodies.isNotEmpty) { break; } await Future.delayed(const Duration(milliseconds: 100)); @@ -386,21 +304,19 @@ projects: await runFuture; // Then it posts exactly one reply for the issue discovered by polling. - expect(await postedBody.exists(), isTrue); - final postedComment = await postedBody.readAsString(); + expect(giteaServer.postedBodies, hasLength(1)); + final postedComment = + (jsonDecode(giteaServer.postedBodies.single) + as Map)['body'] + as String; expect( postedComment, contains('Plan:\n- reconcile missed gosmee events through polling'), ); // And the app performs more than one Gitea issue listing request while running. - final teaLogLines = await teaLog.readAsLines(); expect( - teaLogLines - .where( - (line) => line.contains('repos/owner/sample/issues?state=open'), - ) - .length, + giteaServer.issueListRequestCount('owner/sample'), greaterThanOrEqualTo(2), ); final gosmeeLogLines = await gosmeeLog.readAsLines(); diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart index a63d294..90ed317 100644 --- a/test/issue_tracker_client_test.dart +++ b/test/issue_tracker_client_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; @@ -189,95 +191,405 @@ void main() { /// Feature: Gitea tracker client integration /// /// As a maintainer debugging Gitea tracker failures - /// I want tea-specific API and webhook errors to preserve the original cause - /// So that misconfigured repos and tea output changes are easy to trace + /// I want issue operations to use the Gitea HTTP API directly + /// So that issue polling and replies do not depend on `tea api` /// ``` group('IssueTrackerClient Gitea', () { - /// ```gherkin - /// Scenario: Read a webhook id from tea plain-text success output - /// Given a fake tea executable whose webhook create command prints plain text instead of JSON - /// When the tracker client creates a Gitea webhook - /// Then it still extracts and returns the created webhook id - /// ``` - test('Read a webhook id from tea plain-text success output', () async { - // Given a fake tea executable whose webhook create command prints plain text instead of JSON. - final sandbox = await Directory.systemTemp.createTemp('cws-tea-webhook-'); - final teaScript = File(p.join(sandbox.path, 'tea')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail + setUp(() { + AppEnvironment.resetForTest(); + }); -if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then - printf '%s\n' 'Webhook created successfully (ID: 40)' - exit 0 -fi - -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); - final client = IssueTrackerClient(teaCommand: teaScript.path); - - // When the tracker client creates a Gitea webhook. - final webhookId = await client.createGiteaIssueWebhook( - repo: RepositorySlug('owner', 'sample'), - url: 'https://gosmee.example.test/sample', - ); - - // Then it still extracts and returns the created webhook id. - expect(webhookId, 40); + tearDown(() { + AppEnvironment.resetForTest(); }); /// ```gherkin - /// Scenario: Surface Gitea API error details when issue listing returns an error object - /// Given a fake tea executable whose issue list request returns a Gitea error object - /// When the tracker client fetches updated Gitea issues - /// Then it throws a format error that includes the repo and API error details + /// Scenario: Create a Gitea webhook through the HTTP API + /// Given a local Gitea API server that accepts webhook creation requests + /// When the tracker client creates a Gitea webhook + /// Then it returns the created webhook id + /// And the webhook options are sent to the Gitea API /// ``` - test( - 'Surface Gitea API error details when issue listing returns an error object', - () async { - // Given a fake tea executable whose issue list request returns a Gitea error object. - final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-'); - final teaScript = File(p.join(sandbox.path, 'tea')); - await teaScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail + test('Create a Gitea webhook through the HTTP API', () async { + // Given a local Gitea API server that accepts webhook creation requests. + final giteaServer = await FakeGiteaApiServer.start(); + _configureGiteaTestEnvironmentFromBaseUrl(giteaServer.baseUrl); + final client = IssueTrackerClient(); -if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then - cat <<'JSON' -{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"} -JSON - exit 0 -fi + try { + // When the tracker client creates a Gitea webhook. + final webhookId = await client.createGiteaIssueWebhook( + repo: RepositorySlug('owner', 'sample'), + url: 'https://gosmee.example.test/sample', + ); -echo "unexpected tea args: \$*" >&2 -exit 1 -'''); - await makeScriptExecutable(teaScript); - final client = IssueTrackerClient(teaCommand: teaScript.path); + // Then it returns the created webhook id. + expect(webhookId, 81); - // When the tracker client fetches updated Gitea issues. - final future = client.fetchUpdatedIssuesForRepos( + // And the webhook options are sent to the Gitea API. + expect(giteaServer.createdWebhooks, hasLength(1)); + expect(giteaServer.createdWebhooks.single['type'], 'gitea'); + expect(giteaServer.createdWebhooks.single['active'], isTrue); + expect(giteaServer.createdWebhooks.single['events'], [ + 'issues', + 'issue_comment', + ]); + expect( + (giteaServer.createdWebhooks.single['config'] as Map)['url'], + 'https://gosmee.example.test/sample', + ); + } finally { + await giteaServer.close(); + } + }); + + /// ```gherkin + /// Scenario: Fetch Gitea issues, comments, and login through the HTTP API + /// Given a local Gitea API server with one issue and one comment + /// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment + /// Then the Gitea issue payload is normalized into the shared issue summary model + /// And the posted comment response is returned to the caller + /// ``` + test('Fetch Gitea issues, comments, and login through the HTTP API', () async { + // Given a local Gitea API server with one issue and one comment. + String? postedBody; + final server = await _startGiteaApiServer((request) async { + if (request.uri.path == '/api/v1/repos/owner/sample/issues' && + request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode(>[ + { + 'number': 12, + 'title': 'Need Gitea support', + 'body': 'Please add direct API support.', + 'state': 'open', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': >[ + {'name': 'enhancement'}, + ], + 'pull_request': null, + }, + ]), + ); + await request.response.close(); + return; + } + + if (request.uri.path == + '/api/v1/repos/owner/sample/issues/12/comments' && + request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write( + jsonEncode(>[ + { + 'id': 501, + 'body': 'Working on it.', + 'created_at': '2026-04-04T12:05:00Z', + 'updated_at': '2026-04-04T12:06:00Z', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-501', + 'user': {'login': 'maintainer'}, + }, + ]), + ); + await request.response.close(); + return; + } + + if (request.uri.path == '/api/v1/user' && request.method == 'GET') { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'login': 'gitea-user'})); + await request.response.close(); + return; + } + + if (request.uri.path == + '/api/v1/repos/owner/sample/issues/12/comments' && + request.method == 'POST') { + postedBody = + (jsonDecode(await utf8.decodeStream(request)) + as Map)['body'] + as String?; + request.response + ..statusCode = HttpStatus.created + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'id': 901, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/owner/sample/issues/12#issuecomment-901', + }), + ); + await request.response.close(); + return; + } + + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.json + ..write(jsonEncode({'message': 'not found'})); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final client = IssueTrackerClient(); + + try { + // When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment. + final issues = await client.fetchUpdatedIssuesForRepos( provider: IssueTrackerProvider.gitea, trackerProjects: const ['owner/sample'], since: null, ); + final thread = await client.fetchThread( + provider: IssueTrackerProvider.gitea, + trackerProject: 'owner/sample', + issue: issues.single, + ); + final login = await client.getAuthenticatedLogin( + provider: IssueTrackerProvider.gitea, + ); + final posted = await client.createIssueComment( + provider: IssueTrackerProvider.gitea, + trackerProject: 'owner/sample', + issueNumber: 12, + body: 'Reply from gitea api', + ); - // Then it throws a format error that includes the repo and API error details. - await expectLater( - future, - throwsA( - isA().having( - (error) => error.message, - 'message', - allOf( - contains('owner/sample'), - contains('GetUserByName'), - contains('user redirect does not exist'), + // Then the Gitea issue payload is normalized into the shared issue summary model. + expect(login, 'gitea-user'); + expect(issues.single.trackerProject, 'owner/sample'); + expect(issues.single.number, 12); + expect(issues.single.state, 'open'); + expect(issues.single.labels, ['enhancement']); + expect(thread.comments.single.url, contains('#issuecomment-501')); + + // And the posted comment response is returned to the caller. + expect(postedBody, 'Reply from gitea api'); + expect(posted.id, 901); + expect(posted.url, contains('#issuecomment-901')); + expect(posted.body, 'posted'); + } finally { + await server.close(force: true); + } + }); + + /// ```gherkin + /// Scenario: Reuse a tea login from the Windows app data config path + /// Given a tea config file under LOCALAPPDATA with a default Gitea login + /// When the tracker client resolves the authenticated Gitea login + /// Then it calls the Gitea HTTP API using the host and token from that config file + /// ``` + test('Reuse a tea login from the Windows app data config path', () async { + // Given a tea config file under LOCALAPPDATA with a default Gitea login. + final sandbox = await Directory.systemTemp.createTemp('cws-tea-config-'); + final localAppData = Directory(p.join(sandbox.path, 'AppData', 'Local')); + final teaConfigDir = Directory(p.join(localAppData.path, 'tea')); + await teaConfigDir.create(recursive: true); + + late final HttpServer server; + server = await _startGiteaApiServer((request) async { + expect(request.uri.path, '/api/v1/user'); + expect( + request.headers.value(HttpHeaders.authorizationHeader), + 'token config-token', + ); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode({'login': 'config-user'})); + await request.response.close(); + }); + + await File(p.join(teaConfigDir.path, 'config.yml')).writeAsString(''' +logins: + - name: company + url: http://${server.address.host}:${server.port} + token: config-token + default: true + insecure: true +'''); + AppEnvironment.resetForTest({ + 'LOCALAPPDATA': localAppData.path, + }); + final client = IssueTrackerClient(); + + try { + // When the tracker client resolves the authenticated Gitea login. + final login = await client.getAuthenticatedLogin( + provider: IssueTrackerProvider.gitea, + ); + + // Then it calls the Gitea HTTP API using the host and token from that config file. + expect(login, 'config-user'); + } finally { + await server.close(force: true); + await sandbox.delete(recursive: true); + } + }); + + /// ```gherkin + /// Scenario: Surface Gitea API error details when issue listing returns an error response + /// Given a local Gitea API server whose issue list request returns a Gitea error object + /// When the tracker client fetches updated Gitea issues + /// Then it throws a format error that includes the repo and API error details + /// ``` + test( + 'Surface Gitea API error details when issue listing returns an error response', + () async { + // Given a local Gitea API server whose issue list request returns a Gitea error object. + final server = await _startGiteaApiServer((request) async { + request.response + ..statusCode = HttpStatus.notFound + ..headers.contentType = ContentType.json + ..write( + jsonEncode({ + 'errors': [ + 'user redirect does not exist [name: owner]', + ], + 'message': 'GetUserByName', + 'url': 'https://gitea.example.test/api/swagger', + }), + ); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final client = IssueTrackerClient(); + + try { + // When the tracker client fetches updated Gitea issues. + final future = client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitea, + trackerProjects: const ['owner/sample'], + since: null, + ); + + // Then it throws a format error that includes the repo and API error details. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.message, + 'message', + allOf( + contains('GetUserByName'), + contains('user redirect does not exist'), + contains('404'), + ), ), ), + ); + } finally { + await server.close(force: true); + } + }, + ); + + /// ```gherkin + /// Scenario: Fail fast and log when a Gitea API request exceeds the timeout + /// Given a local Gitea API server whose issue list request never returns promptly + /// When the tracker client fetches updated Gitea issues with a short timeout + /// Then it throws a timeout instead of waiting indefinitely + /// And the tracker logger records the timed out request + /// ``` + test( + 'Fail fast and log when a Gitea API request exceeds the timeout', + () async { + // Given a local Gitea API server whose issue list request never returns promptly. + final server = await _startGiteaApiServer((request) async { + await Future.delayed(const Duration(seconds: 5)); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write('[]'); + await request.response.close(); + }); + _configureGiteaTestEnvironment(server); + final logMessages = []; + void listener(AppLogRecord record) { + if (record.loggerName == 'code_work_spawner.issue_tracker') { + logMessages.add(record.message); + } + } + + AppLogger.addListener(listener); + final client = IssueTrackerClient( + commandTimeout: const Duration(milliseconds: 200), + ); + + try { + // When the tracker client fetches updated Gitea issues with a short timeout. + final future = client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitea, + trackerProjects: const ['owner/sample'], + since: null, + ); + + // Then it throws a timeout instead of waiting indefinitely. + await expectLater( + future, + throwsA( + isA().having( + (error) => error.duration, + 'duration', + const Duration(milliseconds: 200), + ), + ), + ); + } finally { + AppLogger.removeListener(listener); + await server.close(force: true); + } + + // And the tracker logger records the timed out request. + expect( + logMessages, + contains( + allOf( + contains('gitea request timeout'), + contains('timeout_ms=200'), + contains('/api/v1/repos/owner/sample/issues'), + ), ), ); }, ); }); } + +void _configureGiteaTestEnvironment(HttpServer server) { + _configureGiteaTestEnvironmentFromBaseUrl( + 'http://${server.address.host}:${server.port}', + ); +} + +void _configureGiteaTestEnvironmentFromBaseUrl(String baseUrl) { + AppEnvironment.resetForTest({ + ...Platform.environment, + 'CWS_GITEA_HOST': baseUrl, + 'CWS_GITEA_TOKEN': 'test-token', + 'CWS_GITEA_INSECURE': 'false', + }); +} + +Future _startGiteaApiServer( + Future Function(HttpRequest request) handler, +) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + unawaited(() async { + await for (final request in server) { + await handler(request); + } + }()); + return server; +} diff --git a/test/support/fake_gitea_api.dart b/test/support/fake_gitea_api.dart new file mode 100644 index 0000000..91ebf59 --- /dev/null +++ b/test/support/fake_gitea_api.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +class FakeGiteaApiServer { + FakeGiteaApiServer._({ + required HttpServer server, + required this.issueListResponsesByRepo, + required this.issueListResponseSequenceByRepo, + required this.commentResponsesByRepo, + required this.postCommentResponsesByRepo, + required this.viewerLogin, + required this.token, + }) : _server = server; + + final HttpServer _server; + final Map>> issueListResponsesByRepo; + final Map>>> + issueListResponseSequenceByRepo; + final Map>>> + commentResponsesByRepo; + final Map>> postCommentResponsesByRepo; + final String viewerLogin; + final String token; + final List requestPaths = []; + final List postedBodies = []; + final List> createdWebhooks = >[]; + final List deletedWebhookIds = []; + final Map _issueListRequestCounts = {}; + + String get baseUrl => 'http://${_server.address.host}:${_server.port}'; + + int issueListRequestCount(String repo) => _issueListRequestCounts[repo] ?? 0; + + static Future start({ + Map>> issueListResponsesByRepo = + const >>{}, + Map>>> + issueListResponseSequenceByRepo = + const >>>{}, + Map>>> commentResponsesByRepo = + const >>>{}, + Map>> postCommentResponsesByRepo = + const >>{}, + String viewerLogin = 'tea-octocat', + String token = 'test-token', + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fixture = FakeGiteaApiServer._( + server: server, + issueListResponsesByRepo: issueListResponsesByRepo, + issueListResponseSequenceByRepo: issueListResponseSequenceByRepo, + commentResponsesByRepo: commentResponsesByRepo, + postCommentResponsesByRepo: postCommentResponsesByRepo, + viewerLogin: viewerLogin, + token: token, + ); + unawaited( + server.forEach((request) async { + await fixture._handleRequest(request); + }), + ); + return fixture; + } + + Future _handleRequest(HttpRequest request) async { + requestPaths.add(request.uri.toString()); + if (request.headers.value(HttpHeaders.authorizationHeader) != + 'token $token') { + request.response.statusCode = HttpStatus.unauthorized; + await request.response.close(); + return; + } + + final response = request.response..headers.contentType = ContentType.json; + if (request.method == 'GET' && request.uri.path == '/api/v1/user') { + response.write(jsonEncode({'login': viewerLogin})); + await response.close(); + return; + } + + final segments = request.uri.pathSegments; + if (segments.length >= 6 && + segments[0] == 'api' && + segments[1] == 'v1' && + segments[2] == 'repos') { + final repo = '${segments[3]}/${segments[4]}'; + if (segments.length == 6 && + segments[5] == 'hooks' && + request.method == 'POST') { + final body = await utf8.decodeStream(request); + createdWebhooks.add(jsonDecode(body) as Map); + response.statusCode = HttpStatus.created; + response.write(jsonEncode({'id': 81})); + await response.close(); + return; + } + + if (segments.length == 7 && + segments[5] == 'hooks' && + request.method == 'DELETE') { + deletedWebhookIds.add(int.parse(segments[6])); + response.statusCode = HttpStatus.noContent; + await response.close(); + return; + } + + if (segments.length == 6 && + segments[5] == 'issues' && + request.method == 'GET') { + final requestCount = (_issueListRequestCounts[repo] ?? 0) + 1; + _issueListRequestCounts[repo] = requestCount; + final sequence = issueListResponseSequenceByRepo[repo]; + final payload = sequence == null || sequence.isEmpty + ? issueListResponsesByRepo[repo] ?? const >[] + : sequence[(requestCount - 1).clamp(0, sequence.length - 1)]; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + + if (segments.length == 8 && + segments[5] == 'issues' && + segments[7] == 'comments') { + final issueNumber = int.parse(segments[6]); + if (request.method == 'GET') { + final payload = + commentResponsesByRepo[repo]?[issueNumber] ?? + const >[]; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + + if (request.method == 'POST') { + final body = await utf8.decodeStream(request); + postedBodies.add(body); + final payload = + postCommentResponsesByRepo[repo]?[issueNumber] ?? + { + 'id': 700, + 'body': 'posted', + 'html_url': + 'https://gitea.example.test/$repo/issues/$issueNumber#issuecomment-700', + }; + response.statusCode = HttpStatus.created; + response.write(jsonEncode(payload)); + await response.close(); + return; + } + } + } + + response.statusCode = HttpStatus.notFound; + response.write(jsonEncode({'message': 'not found'})); + await response.close(); + } + + Future close() => _server.close(force: true); +} diff --git a/test/test_support.dart b/test/test_support.dart index aea7198..97861a6 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -2,6 +2,7 @@ export 'support/script_utils.dart'; export 'support/fake_gh.dart'; export 'support/fake_tea.dart'; export 'support/fake_glab.dart'; +export 'support/fake_gitea_api.dart'; export 'support/fake_responders.dart'; export 'support/fake_gosmee.dart'; export 'support/git_utils.dart'; diff --git a/tool/process_mode_probe.dart b/tool/process_mode_probe.dart new file mode 100644 index 0000000..4279ac1 --- /dev/null +++ b/tool/process_mode_probe.dart @@ -0,0 +1,292 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:code_work_spawner/src/core/process_launcher.dart'; + +// The file is used to probe different modes of running external processes in Dart + +// Usage example: +// Running command with Process.runSync: tea api -X GET "repos/bkinnightskytw/code_work_spawner/issues?state=open&limit=100&page=1&since=2026-04-10T06%3A06%3A25.000Z" -r bkinnightskytw/code_work_spawner +// `dart run .\tool\process_mode_probe.dart --mode process-run-sync --command tea --arg api --arg -X --arg GET --arg '"repos/bkinnightskytw/code_work_spawner/issues?state=open&limit=100&page=1"' --arg -r --arg 'bkinnightskytw/code_work_spawner'` +// `dart run .\tool\process_mode_probe.dart --mode process-run-sync --command tea --arg login --arg default` +// Aware that `tea` has known severe issues on Windows due to lipgloss/v2 query console color. +// + +Future main(List arguments) async { + final parser = ArgParser() + ..addOption( + 'command', + help: 'Executable to run, for example `tea` or `gh`.', + mandatory: true, + ) + ..addMultiOption( + 'arg', + help: 'Repeat for each argument that should be passed to the command.', + ) + ..addOption( + 'mode', + help: + 'Comma-separated modes: all, launcher, process-run, process-run-sync, process-start, powershell-command.', + defaultsTo: 'all', + ) + ..addOption( + 'timeout-ms', + help: 'Per-mode timeout in milliseconds.', + defaultsTo: '5000', + ) + ..addOption( + 'working-directory', + help: 'Optional working directory for the command.', + ); + + // add help + parser.addFlag( + 'help', + abbr: 'h', + help: 'Show this help message.', + negatable: false, + ); + + final results = parser.parse(arguments); + final command = results['command'] as String; + final args = (results['arg'] as List).toList(growable: false); + final timeout = Duration( + milliseconds: int.parse(results['timeout-ms'] as String), + ); + final workingDirectory = results['working-directory'] as String?; + final selectedModes = _parseModes(results['mode'] as String); + final resolved = resolveExternalCommandForCurrentPlatform(command, args); + + final payload = { + 'command': command, + 'arguments': args, + 'timeout_ms': timeout.inMilliseconds, + 'working_directory': workingDirectory, + 'resolved': { + 'executable': resolved.executable, + 'arguments': resolved.arguments, + 'run_in_shell': resolved.runInShell, + 'uses_bash_shim': resolved.usesBashShim, + }, + 'results': [], + }; + final resultList = payload['results'] as List; + + for (final mode in selectedModes) { + resultList.add( + await _runMode( + mode, + command: command, + arguments: args, + timeout: timeout, + workingDirectory: workingDirectory, + ), + ); + } + + stdout.writeln(const JsonEncoder.withIndent(' ').convert(payload)); +} + +List _parseModes(String configuredModes) { + final trimmedModes = configuredModes + .split(',') + .map((mode) => mode.trim()) + .where((mode) => mode.isNotEmpty) + .toList(growable: false); + if (trimmedModes.contains('all')) { + return const [ + 'launcher', + 'process-run', + 'process-run-sync', + 'process-start', + 'powershell-command', + ]; + } + return trimmedModes; +} + +Future> _runMode( + String mode, { + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final stopwatch = Stopwatch()..start(); + try { + final result = switch (mode) { + 'launcher' => await runExternalCommand( + command, + arguments, + workingDirectory: workingDirectory, + timeout: timeout, + ), + 'process-run' => + await Process.run( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + timeout, + onTimeout: () { + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ), + 'process-start' => await _runWithProcessStart( + command: command, + arguments: arguments, + timeout: timeout, + workingDirectory: workingDirectory, + ), + 'process-run-sync' => _runWithProcessRunSync( + command: command, + arguments: arguments, + workingDirectory: workingDirectory, + ), + 'powershell-command' => await _runViaPowerShellCommand( + command: command, + arguments: arguments, + timeout: timeout, + workingDirectory: workingDirectory, + ), + _ => throw ArgumentError('Unsupported mode: $mode'), + }; + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'success', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'exit_code': result.exitCode, + 'stdout_length': result.stdout.toString().length, + 'stderr_length': result.stderr.toString().length, + 'stdout_preview': _preview(result.stdout.toString()), + 'stderr_preview': _preview(result.stderr.toString()), + }; + } on TimeoutException catch (error) { + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'timeout', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'message': error.message, + }; + } catch (error, stackTrace) { + stopwatch.stop(); + return { + 'mode': mode, + 'status': 'error', + 'duration_ms': stopwatch.elapsedMilliseconds, + 'error': error.toString(), + 'stack_preview': _preview(stackTrace.toString()), + }; + } +} + +Future _runWithProcessStart({ + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final process = await Process.start( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + ); + await process.stdin.close(); + final stdoutFuture = process.stdout.transform(utf8.decoder).join(); + final stderrFuture = process.stderr.transform(utf8.decoder).join(); + final exitCode = await process.exitCode.timeout( + timeout, + onTimeout: () { + process.kill(); + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ); + return ProcessResult( + process.pid, + exitCode, + await stdoutFuture, + await stderrFuture, + ); +} + +ProcessResult _runWithProcessRunSync({ + required String command, + required List arguments, + required String? workingDirectory, +}) { + // print + print( + 'Running command with Process.runSync: $command ${arguments.join(' ')}', + ); + return Process.runSync( + command, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ); +} + +Future _runViaPowerShellCommand({ + required String command, + required List arguments, + required Duration timeout, + required String? workingDirectory, +}) async { + final powerShellScript = r''' +$ErrorActionPreference = 'Stop' +$command = $args[0] +$commandArgs = if ($args.Length -gt 1) { $args[1..($args.Length - 1)] } else { @() } +& $command @commandArgs +if ($null -ne $LASTEXITCODE) { + exit $LASTEXITCODE +} +'''; + + return Process.run( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + powerShellScript, + command, + ...arguments, + ], + workingDirectory: workingDirectory, + runInShell: false, + stdoutEncoding: utf8, + stderrEncoding: utf8, + ).timeout( + timeout, + onTimeout: () { + throw TimeoutException( + 'Command timed out after ${timeout.inMilliseconds}ms.', + timeout, + ); + }, + ); +} + +String _preview(String text, {int maxLength = 200}) { + final singleLine = text.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return '${singleLine.substring(0, maxLength)}...'; +}