Merge pull request #46 from existedinnettw/feat/windows

This commit is contained in:
existedinnettw 2026-04-11 15:41:35 +08:00 committed by GitHub
commit 566c9defa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2820 additions and 1313 deletions

3
.gitignore vendored
View File

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

View File

@ -79,3 +79,8 @@ Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub,
[`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject. [`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject.
This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses
`worktreeDir` for bug-verification worktrees. `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 <LOGIN>`.

View File

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

View File

@ -1,49 +1,164 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import '../config/app_logger.dart';
import 'package:executable/executable.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class _ResolvedCommand { class ResolvedExternalCommand {
_ResolvedCommand({ ResolvedExternalCommand({
required this.executable, required this.executable,
required this.arguments, required this.arguments,
required this.usesBashShim, required this.runInShell,
}); });
final String executable; final String executable;
final List<String> arguments; final List<String> arguments;
final bool usesBashShim; final bool runInShell;
} }
_ResolvedCommand _resolveCommand(String executable, List<String> arguments) { final AppLogger _processLauncherLogger = AppLogger(
final extension = p.extension(executable).toLowerCase(); 'code_work_spawner.process_launcher',
final shouldUseBash = extension.isEmpty || extension == '.sh'; );
if (Platform.isWindows && shouldUseBash && File(executable).existsSync()) { abstract interface class ExternalCommandExecutor {
final bashScriptPath = _toBashPath(executable); Future<ProcessResult> run(
return _ResolvedCommand( String executable,
executable: 'wsl.exe', List<String> arguments, {
arguments: ['--exec', 'bash', bashScriptPath, ...arguments], String? workingDirectory,
usesBashShim: true, 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,
); );
} }
return _ResolvedCommand( @override
executable: executable, 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(
String executable,
List<String> 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();
return ResolvedExternalCommand(
executable: effectiveExecutable,
arguments: arguments, 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),
); );
} }
String _toBashPath(String windowsPath) { bool _requiresWindowsShell(String extension) {
final normalized = windowsPath.replaceAll('\\', '/'); return extension == '.bat' || extension == '.cmd';
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized); }
if (drivePrefixMatch == null) {
return normalized;
}
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase(); String? _resolveLocalExecutablePath(String executable) {
final rest = drivePrefixMatch.group(2)!; final localFile = File(executable);
return '/mnt/$driveLetter/$rest'; 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('.');
} }
Future<ProcessResult> runExternalCommand( Future<ProcessResult> runExternalCommand(
@ -52,20 +167,102 @@ Future<ProcessResult> runExternalCommand(
String? workingDirectory, String? workingDirectory,
Map<String, String>? environment, Map<String, String>? environment,
bool includeParentEnvironment = true, bool includeParentEnvironment = true,
}) { Duration? timeout,
final resolved = _resolveCommand(executable, arguments); }) async {
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( final resolved = resolveExternalCommandForCurrentPlatform(
executable,
arguments,
);
const mode = 'process-start';
_processLauncherLogger.debug(
'launch mode=$mode executable=${resolved.executable} '
'requested_executable=$executable run_in_shell=${resolved.runInShell} '
'working_directory=${workingDirectory ?? "(inherit)"} '
'timeout_ms=${timeout?.inMilliseconds ?? 0} '
'args=${_renderLauncherArguments(resolved.arguments)}',
);
final stopwatch = Stopwatch()..start();
try {
_processLauncherLogger.debug('process_start in runExternalCommand');
final result = await _runExternalCommandWithCapturedOutput(
executable: resolved.executable,
arguments: resolved.arguments,
workingDirectory: workingDirectory,
environment: environment,
includeParentEnvironment: includeParentEnvironment,
runInShell: resolved.runInShell,
timeout: 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<ProcessResult> _runExternalCommandWithCapturedOutput({
required String executable,
required List<String> arguments,
required String? workingDirectory,
required Map<String, String>? environment,
required bool includeParentEnvironment,
required bool runInShell,
required Duration? timeout,
}) async {
final process = await Process.start(
executable,
arguments,
workingDirectory: workingDirectory,
environment: environment, environment: environment,
includeParentEnvironment: includeParentEnvironment, includeParentEnvironment: includeParentEnvironment,
usesBashShim: resolved.usesBashShim, runInShell: runInShell,
);
return Process.run(
resolved.executable,
resolved.arguments,
workingDirectory: workingDirectory,
environment: effectiveEnvironment,
includeParentEnvironment: includeParentEnvironment,
); );
final stdoutFuture = process.stdout.transform(utf8.decoder).join();
final stderrFuture = process.stderr.transform(utf8.decoder).join();
try {
final exitCode = timeout == null
? await process.exitCode
: await process.exitCode.timeout(
timeout,
onTimeout: () {
process.kill(ProcessSignal.sigkill);
throw TimeoutException(
'Command timed out after ${timeout.inMilliseconds}ms.',
timeout,
);
},
);
final stdoutText = await stdoutFuture;
final stderrText = await stderrFuture;
return ProcessResult(process.pid, exitCode, stdoutText, stderrText);
} on TimeoutException {
process.kill(ProcessSignal.sigkill);
rethrow;
}
}
String _renderLauncherArguments(List<String> arguments) {
return arguments.map(_summarizeLauncherArgument).join(' ');
}
String _summarizeLauncherArgument(String value) {
if (value.startsWith('body=')) {
return 'body=<redacted>';
}
if (value.length <= 120) {
return value;
}
return '${value.substring(0, 117)}...';
} }
Future<Process> startExternalCommand( Future<Process> startExternalCommand(
@ -77,42 +274,17 @@ Future<Process> startExternalCommand(
bool runInShell = false, bool runInShell = false,
ProcessStartMode mode = ProcessStartMode.normal, ProcessStartMode mode = ProcessStartMode.normal,
}) { }) {
final resolved = _resolveCommand(executable, arguments); final resolved = resolveExternalCommandForCurrentPlatform(
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand( executable,
environment: environment, arguments,
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, runInShell: runInShell || resolved.runInShell,
mode: mode, mode: mode,
); );
} }
Map<String, String>? _effectiveEnvironmentForResolvedCommand({
required Map<String, String>? environment,
required bool includeParentEnvironment,
required bool usesBashShim,
}) {
if (!usesBashShim || environment == null || environment.isEmpty) {
return environment;
}
final effectiveEnvironment = Map<String, String>.from(environment);
final existingWslenvFromProcess = includeParentEnvironment
? Platform.environment['WSLENV']
: null;
final existingWslenv =
effectiveEnvironment['WSLENV'] ?? existingWslenvFromProcess;
final forwardedKeys = <String>{
...?existingWslenv?.split(':').where((entry) => entry.isNotEmpty),
...effectiveEnvironment.keys.where((key) => key != 'WSLENV'),
};
effectiveEnvironment['WSLENV'] = forwardedKeys.join(':');
return effectiveEnvironment;
}

View File

@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
import '../config/app_environment.dart'; import '../config/app_environment.dart';
import '../config/app_logger.dart';
import '../config/config_schema.dart'; import '../config/config_schema.dart';
import '../core/process_launcher.dart'; import '../core/process_launcher.dart';
import 'models.dart'; import 'models.dart';
@ -15,12 +18,21 @@ class IssueTrackerClient {
this.glabCommand = 'glab', this.glabCommand = 'glab',
this.teaCommand = 'tea', this.teaCommand = 'tea',
this.opCommand = 'op', this.opCommand = 'op',
}); Duration? commandTimeout,
AppLogger? logger,
ExternalCommandExecutor? commandExecutor,
}) : commandTimeout = commandTimeout ?? const Duration(seconds: 15),
_logger = logger ?? AppLogger('code_work_spawner.issue_tracker'),
_commandExecutor =
commandExecutor ?? const DefaultExternalCommandExecutor();
final String ghCommand; final String ghCommand;
final String glabCommand; final String glabCommand;
final String teaCommand; final String teaCommand;
final String opCommand; final String opCommand;
final Duration commandTimeout;
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 =
@ -81,18 +93,14 @@ class IssueTrackerClient {
{'per_page': '100'}, {'per_page': '100'},
), ),
]), ]),
IssueTrackerProvider.gitea => await _runTeaJson([ IssueTrackerProvider.gitea => await _runGiteaJson(
'api', method: 'GET',
'-X', path: _buildApiPath(
'GET',
_buildApiPath(
issue.requiredRepoSlug, issue.requiredRepoSlug,
['issues', '${issue.number}', 'comments'], ['issues', '${issue.number}', 'comments'],
{'limit': '100'}, {'limit': '100'},
), ),
'-r', ),
issue.requiredRepoSlug.fullName,
]),
IssueTrackerProvider.openproject => await _runOpenProjectJson( IssueTrackerProvider.openproject => await _runOpenProjectJson(
method: 'GET', method: 'GET',
path: '/api/v3/work_packages/${issue.number}/activities', path: '/api/v3/work_packages/${issue.number}/activities',
@ -157,20 +165,15 @@ class IssueTrackerClient {
'-f', '-f',
'body=$body', 'body=$body',
]), ]),
IssueTrackerProvider.gitea => await _runTeaJson([ IssueTrackerProvider.gitea => await _runGiteaJson(
'api', method: 'POST',
'-X', path: _buildApiPath(parseRepositorySlug(trackerProject), [
'POST',
_buildApiPath(parseRepositorySlug(trackerProject), [
'issues', 'issues',
'$issueNumber', '$issueNumber',
'comments', 'comments',
]), ]),
'-f', body: <String, Object?>{'body': body},
'body=$body', ),
'-r',
trackerProject,
]),
IssueTrackerProvider.openproject => await _runOpenProjectJson( IssueTrackerProvider.openproject => await _runOpenProjectJson(
method: 'POST', method: 'POST',
path: '/api/v3/work_packages/$issueNumber/activities', path: '/api/v3/work_packages/$issueNumber/activities',
@ -241,7 +244,7 @@ class IssueTrackerClient {
return switch (provider) { return switch (provider) {
IssueTrackerProvider.github => 'gh', IssueTrackerProvider.github => 'gh',
IssueTrackerProvider.gitlab => 'glab', IssueTrackerProvider.gitlab => 'glab',
IssueTrackerProvider.gitea => 'tea', IssueTrackerProvider.gitea => 'gitea',
IssueTrackerProvider.openproject => opCommand, IssueTrackerProvider.openproject => opCommand,
}; };
} }
@ -250,43 +253,23 @@ class IssueTrackerClient {
required RepositorySlug repo, required RepositorySlug repo,
required String url, required String url,
}) async { }) async {
final result = await _runCommand(teaCommand, [ final json = await _runGiteaJson(
'webhooks', method: 'POST',
'create', path: _buildApiPath(repo, ['hooks']),
'--type', body: <String, Object?>{
'gitea', 'type': 'gitea',
'--events', 'config': <String, Object?>{'url': url, 'content_type': 'json'},
'issues,issue_comment', 'events': <String>['issues', 'issue_comment'],
'--active', 'active': true,
'-o', },
'json', );
'-r', if (json is Map<String, dynamic>) {
repo.fullName, return readIntField(json, 'id');
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<String, dynamic>) {
return readIntField(json, 'id');
}
} on FormatException {
final webhookId = _extractWebhookIdFromTeaOutput(stdoutText);
if (webhookId != null) {
return webhookId;
}
} }
throw FormatException( throw FormatException(
'tea webhooks create returned an unexpected response for repo ' 'Gitea API webhook create returned an unexpected response for repo '
'${repo.fullName}. stdout=${_summarizeOutput(stdoutText)}', '${repo.fullName}.',
); );
} }
@ -362,14 +345,10 @@ class IssueTrackerClient {
required RepositorySlug repo, required RepositorySlug repo,
required int webhookId, required int webhookId,
}) async { }) async {
await _runTeaJson([ await _runGiteaJson(
'webhooks', method: 'DELETE',
'delete', path: _buildApiPath(repo, ['hooks', '$webhookId']),
'--confirm', );
'-r',
repo.fullName,
'$webhookId',
]);
} }
Future<void> deleteGitLabWebhook({ Future<void> deleteGitLabWebhook({
@ -404,6 +383,14 @@ class IssueTrackerClient {
final issues = <GitHubIssueSummary>[]; final issues = <GitHubIssueSummary>[];
for (var page = 1; true; page += 1) { 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([ final json = await _runGhJson([
'api', 'api',
'-X', '-X',
@ -453,6 +440,14 @@ class IssueTrackerClient {
'page': '$page', 'page': '$page',
if (since != null) 'updated_after': since.toUtc().toIso8601String(), 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([ final json = await _runGlabJson([
'api', 'api',
'-X', '-X',
@ -490,23 +485,25 @@ class IssueTrackerClient {
'page': '$page', 'page': '$page',
if (since != null) 'since': since.toUtc().toIso8601String(), if (since != null) 'since': since.toUtc().toIso8601String(),
}; };
final json = await _runTeaJson([ final requestPath = _buildApiPath(repo, ['issues'], query);
'api', _traceRequest(
'-X', provider: IssueTrackerProvider.gitea,
'GET', requestKind: 'list-issues',
_buildApiPath(repo, ['issues'], query), trackerProject: repo.fullName,
'-r', page: page,
repo.fullName, since: since,
]); requestPath: requestPath,
);
final json = await _runGiteaJson(method: 'GET', path: requestPath);
if (json is Map<String, dynamic>) { if (json is Map<String, dynamic>) {
throw FormatException( 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)}', '${repo.fullName}: ${_describeJsonObject(json)}',
); );
} }
if (json != null && json is! List<dynamic>) { if (json != null && json is! List<dynamic>) {
throw FormatException( 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.', 'for ${repo.fullName}; expected a JSON array.',
); );
} }
@ -579,7 +576,10 @@ class IssueTrackerClient {
final json = switch (provider) { final json = switch (provider) {
IssueTrackerProvider.github => await _runGhJson(['api', 'user']), IssueTrackerProvider.github => await _runGhJson(['api', 'user']),
IssueTrackerProvider.gitlab => await _runGlabJson(['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( IssueTrackerProvider.openproject => await _runOpenProjectJson(
method: 'GET', method: 'GET',
path: '/api/v3/users/me', path: '/api/v3/users/me',
@ -604,8 +604,102 @@ class IssueTrackerClient {
return _runJson(glabCommand, arguments); return _runJson(glabCommand, arguments);
} }
Future<Object?> _runTeaJson(List<String> arguments) { Future<Object?> _runGiteaJson({
return _runJson(teaCommand, arguments); required String method,
required String path,
Map<String, String>? 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<String, dynamic>) {
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<Object?> _runOpenProjectJson({ Future<Object?> _runOpenProjectJson({
@ -659,12 +753,31 @@ class IssueTrackerClient {
final result = await _runCommand(command, arguments); final result = await _runCommand(command, arguments);
final stdoutText = result.stdout.toString().trim(); final stdoutText = result.stdout.toString().trim();
if (stdoutText.isEmpty) { if (stdoutText.isEmpty) {
_traceCommandResult(
command: command,
arguments: arguments,
state: 'empty-stdout',
);
return null; return null;
} }
try { 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) { } on FormatException catch (error) {
_traceCommandResult(
command: command,
arguments: arguments,
state: 'json-parse-failed',
detail:
'${error.message} stdout=${_summarizeOutput(stdoutText, maxLength: 120)}',
);
throw FormatException( throw FormatException(
'Failed to parse JSON from `$command ${arguments.join(' ')}`: ' 'Failed to parse JSON from `$command ${arguments.join(' ')}`: '
'${error.message}. stdout=${_summarizeOutput(stdoutText)}', '${error.message}. stdout=${_summarizeOutput(stdoutText)}',
@ -676,7 +789,41 @@ class IssueTrackerClient {
String command, String command,
List<String> arguments, List<String> arguments,
) async { ) 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 _commandExecutor.run(
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) { if (result.exitCode != 0) {
throw ProcessException( throw ProcessException(
command, command,
@ -688,12 +835,19 @@ class IssueTrackerClient {
return result; return result;
} }
int? _extractWebhookIdFromTeaOutput(String stdoutText) { String _renderCommand(String command, List<String> arguments) {
final match = RegExp( final parts = <String>[command, ...arguments];
r'Webhook created successfully \(ID:\s*(\d+)\)', return parts.map(_shellQuote).join(' ');
caseSensitive: false, }
).firstMatch(stdoutText);
return match == null ? null : int.parse(match.group(1)!); String _shellQuote(String value) {
if (value.isEmpty) {
return '""';
}
if (!value.contains(RegExp(r'[\s"]'))) {
return value;
}
return '"${value.replaceAll('"', r'\"')}"';
} }
String _describeJsonObject(Map<String, dynamic> json) { String _describeJsonObject(Map<String, dynamic> json) {
@ -722,6 +876,47 @@ class IssueTrackerClient {
return '${singleLine.substring(0, maxLength)}...'; return '${singleLine.substring(0, maxLength)}...';
} }
String _describeJsonPayload(Object? json) {
return switch (json) {
List<dynamic> list => 'type=list length=${list.length}',
Map<String, dynamic> 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<String> arguments,
required String state,
String? detail,
}) {
final parts = <String>[
'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( String _buildApiPath(
RepositorySlug repo, RepositorySlug repo,
List<String> tailSegments, [ List<String> tailSegments, [
@ -761,6 +956,186 @@ class IssueTrackerClient {
); );
} }
Uri _buildGiteaUri(
Uri host,
String path, {
Map<String, String>? 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<YamlMap>()
.map((entry) => Map<String, Object?>.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<String, Object?> _selectTeaLogin(
List<Map<String, Object?>> 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<String, String> environment) {
final explicitPath =
environment['CWS_GITEA_CONFIG_PATH']?.trim() ??
environment['TEA_CONFIG_PATH']?.trim();
if (explicitPath != null && explicitPath.isNotEmpty) {
return explicitPath;
}
final candidates = <String>[];
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 { Future<_OpenProjectConfig> _loadOpenProjectConfig() async {
final environment = AppEnvironment.variables; final environment = AppEnvironment.variables;
final host = environment['OP_CLI_HOST']?.trim(); final host = environment['OP_CLI_HOST']?.trim();
@ -864,6 +1239,18 @@ class _OpenProjectConfig {
final String token; 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 { class _OpenProjectProjectRef {
const _OpenProjectProjectRef({required this.id, required this.name}); const _OpenProjectProjectRef({required this.id, required this.name});

View File

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

View File

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

View File

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

View File

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

View File

@ -23,12 +23,14 @@ dependencies:
git: ^2.3.2 git: ^2.3.2
nyxx: ^6.8.1 nyxx: ^6.8.1
dart_eval: ^0.8.4 dart_eval: ^0.8.4
executable: ^1.4.1
dev_dependencies: dev_dependencies:
build_runner: ^2.6.0 build_runner: ^2.6.0
drift_dev: ^2.28.1 drift_dev: ^2.28.1
json_serializable: ">=6.11.1 <6.12.0" json_serializable: ">=6.11.1 <6.12.0"
lints: ^6.0.0 lints: ^6.0.0
mocktail: ^1.0.5
test: ^1.25.6 test: ^1.25.6
platforms: platforms:

View File

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

View File

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

View File

@ -173,6 +173,8 @@ projects:
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
ghCommand: ghScript.path, ghCommand: ghScript.path,
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
@ -234,24 +236,21 @@ projects:
await initGitRepo(repoDir); await initGitRepo(repoDir);
// And Gitea issue data containing a comment with an explicit assistant mention. // And Gitea issue data containing a comment with an explicit assistant mention.
final teaScript = File(p.join(sandbox.path, 'tea')); final giteaServer = await FakeGiteaApiServer.start(
final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl'));
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
await writeFakeTeaScript(
teaScript: teaScript,
issueListResponsesByRepo: { issueListResponsesByRepo: {
'owner/sample': [ 'owner/sample': [
{ {
'index': 12, 'number': 12,
'title': 'Need architecture help', 'title': 'Need architecture help',
'body': 'Please review the API layering.', 'body': 'Please review the API layering.',
'state': 'open', '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', 'updated_at': '2026-04-04T12:00:00Z',
'poster': {'login': 'reporter'}, 'user': {'login': 'reporter'},
'labels': [ 'labels': [
{'name': 'help'}, {'name': 'help'},
], ],
'pull_request': null,
}, },
], ],
}, },
@ -261,22 +260,28 @@ projects:
{ {
'id': 50, 'id': 50,
'body': '@helper can you plan the architecture?', 'body': '@helper can you plan the architecture?',
'url': 'html_url':
'https://gitea.example.test/owner/sample/issues/12#issuecomment-50', 'https://gitea.example.test/owner/sample/issues/12#issuecomment-50',
'created_at': '2026-04-04T12:00:00Z', 'created_at': '2026-04-04T12:00:00Z',
'updated_at': '2026-04-04T12:00:00Z', 'updated_at': '2026-04-04T12:00:00Z',
'poster': {'login': 'reporter'}, 'user': {'login': 'reporter'},
}, },
], ],
}, },
}, },
postCommentIssueNumbersByRepo: { postCommentResponsesByRepo: {
'owner/sample': {12}, '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', viewerLogin: 'tea-octocat',
); );
addTearDown(giteaServer.close);
// 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'));
@ -307,37 +312,46 @@ projects:
repo: owner/sample repo: owner/sample
path: ${repoDir.path} path: ${repoDir.path}
defaultBranch: main 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( 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'),
teaCommand: teaScript.path, responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.
await app.runOnce(); await app.runOnce();
await app.close(); await app.close();
// Then it reads issue comments and posts exactly one reply through tea for that issue. // Then it reads issue comments and posts exactly one reply through the Gitea API for that issue.
final logLines = await teaLog.readAsLines();
expect( expect(
logLines giteaServer.requestPaths
.where( .where(
(line) => line.contains('repos/owner/sample/issues?state=open'), (path) => path.contains('/api/v1/repos/owner/sample/issues?'),
) )
.length, .length,
1, 1,
); );
expect( expect(
logLines giteaServer.requestPaths
.where( .where(
(line) => line.contains('repos/owner/sample/issues/12/comments'), (path) => path.contains(
'/api/v1/repos/owner/sample/issues/12/comments',
),
) )
.length, .length,
2, 2,
); );
final postedComment = await postedBody.readAsString(); final postedComment =
expect(postedComment, contains('via `tea`')); (jsonDecode(giteaServer.postedBodies.single)
as Map<String, dynamic>)['body']
as String;
expect(postedComment, contains('via `gitea`'));
expect(postedComment, contains('@tea-octocat')); expect(postedComment, contains('@tea-octocat'));
expect( expect(
postedComment, postedComment,
@ -409,6 +423,8 @@ OP_CLI_TOKEN=test-token
final app = await IssueAssistantApp.open( final app = await IssueAssistantApp.open(
config: await AppConfig.load(configFile.path), config: await AppConfig.load(configFile.path),
databasePath: p.join(sandbox.path, 'state.sqlite3'), databasePath: p.join(sandbox.path, 'state.sqlite3'),
responderRunner: fakeResponderRunner(),
commandExecutor: fakeExternalCommandExecutor(),
); );
// When the app processes one polling cycle. // When the app processes one polling cycle.

View File

@ -0,0 +1,100 @@
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 <String>['--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);
});
/// ```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 <String>['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);
});
/// ```gherkin
/// Scenario: Leave local Bash scripts unresolved on Windows
/// Given a local Bash script file on Windows
/// When the process launcher resolves the command
/// Then it leaves the script path unchanged
/// And it does not add a WSL wrapper
/// ```
test('Leave local Bash scripts unresolved on Windows', () async {
// Given a local Bash script file on Windows.
final sandbox = await Directory.systemTemp.createTemp(
'cws-process-launcher-',
);
final script = File(p.join(sandbox.path, 'fake-tea.sh'));
await script.writeAsString('#!/usr/bin/env bash\nprintf \'[]\\n\'\n');
try {
// When the process launcher resolves the command.
final resolved = resolveExternalCommandForCurrentPlatform(
script.path,
const <String>['api'],
isWindows: true,
);
// Then it leaves the script path unchanged.
expect(resolved.executable, script.path);
// And it does not add a WSL wrapper.
expect(resolved.runInShell, isFalse);
expect(resolved.arguments, const <String>['api']);
} finally {
await sandbox.delete(recursive: true);
}
});
});
}

View File

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

View File

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

View File

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

View File

@ -21,12 +21,12 @@ void registerIssueAssistantAppRunGosmeeTests() {
/// Scenario: Reply to a mention received through tea/gosmee /// Scenario: Reply to a mention received through tea/gosmee
/// Given a temporary project checkout that can be used as the local repo path /// Given a temporary project checkout that can be used as the local repo path
/// And a gosmee script that forwards one Gitea issue_comment webhook event /// 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 a responder that emits a planning reply
/// And an orchestrator-style config that uses tea/gosmee /// And an orchestrator-style config that uses tea/gosmee
/// When the long-running app starts and receives the forwarded webhook /// When the long-running app starts and receives the forwarded webhook
/// Then it reads issue comments and posts exactly one reply for that issue /// 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 { test('Reply to a mention received through tea/gosmee', () async {
// Given a temporary project checkout that can be used as the local repo path. // Given a temporary project checkout that can be used as the local repo path.
@ -70,62 +70,26 @@ void registerIssueAssistantAppRunGosmeeTests() {
gosmeeLog: gosmeeLog, gosmeeLog: gosmeeLog,
); );
// And a tea script that can manage webhooks and post issue comments. // And a Gitea API fixture for issue reads and replies.
final teaScript = File(p.join(sandbox.path, 'tea')); final giteaServer = await FakeGiteaApiServer.start(
final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl')); issueListResponsesByRepo: {
final postedBody = File(p.join(sandbox.path, 'posted-body.md')); 'owner/sample': const <Map<String, Object?>>[],
await teaScript.writeAsString('''#!/usr/bin/env bash },
set -euo pipefail commentResponsesByRepo: {
printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}" 'owner/sample': {12: comments},
},
if [[ "\$1" == "api" && "\$2" == "user" ]]; then postCommentResponsesByRepo: {
cat <<'JSON' 'owner/sample': {
${jsonEncode({'login': 'tea-octocat'})} 12: {
JSON 'id': 801,
exit 0 'body': 'posted',
fi 'html_url': 'https://gitea.example.test/comment/801',
},
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then },
cat <<'JSON' },
[] viewerLogin: 'tea-octocat',
JSON );
exit 0 addTearDown(giteaServer.close);
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 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'));
@ -156,18 +120,23 @@ projects:
type: tea/gosmee type: tea/gosmee
repo: owner/sample repo: owner/sample
path: ${repoDir.path} 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( 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'),
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
teaCommand: teaScript.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.
final runFuture = app.run(); final runFuture = app.run();
for (var attempt = 0; attempt < 200; attempt += 1) { for (var attempt = 0; attempt < 200; attempt += 1) {
if (await postedBody.exists()) { if (giteaServer.postedBodies.isNotEmpty) {
break; break;
} }
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
@ -176,38 +145,31 @@ projects:
await runFuture; await runFuture;
// Then it reads issue comments and posts exactly one reply for that issue. // Then it reads issue comments and posts exactly one reply for that issue.
expect(await postedBody.exists(), isTrue); expect(giteaServer.postedBodies, hasLength(1));
final postedComment = await postedBody.readAsString(); final postedComment =
(jsonDecode(giteaServer.postedBodies.single)
as Map<String, dynamic>)['body']
as String;
expect( expect(
postedComment, postedComment,
contains('Plan:\n- consume gosmee issue_comment events'), contains('Plan:\n- consume gosmee issue_comment events'),
); );
expect(postedComment, contains('@tea-octocat')); expect(postedComment, contains('@tea-octocat'));
// And the tea webhook lifecycle commands are invoked for the Gitea project. // And the Gitea webhook lifecycle API is invoked for the project.
final teaLogLines = await teaLog.readAsLines(); expect(giteaServer.createdWebhooks, hasLength(1));
expect( expect(
teaLogLines.any( (giteaServer.createdWebhooks.single['config'] as Map)['url'],
(line) => line.contains( 'https://gosmee.example.test/sample',
'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,
); );
expect(giteaServer.deletedWebhookIds, contains(81));
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Reconcile a missed Gitea gosmee event through polling /// Scenario: Reconcile a missed Gitea gosmee event through polling
/// Given a temporary project checkout that can be used as the local repo path /// Given a temporary project checkout that can be used as the local repo path
/// And a gosmee script that starts forwarding without delivering an event /// 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 a responder that emits a planning reply
/// And an orchestrator-style config that uses tea/gosmee with continuous reconciliation /// 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 /// When the long-running app starts and enough time passes for reconciliation polling
@ -225,31 +187,12 @@ projects:
// 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);
// 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 = { final issue = {
'index': 18, 'index': 18,
'title': 'Need reconciliation after a missed webhook', 'title': 'Need reconciliation after a missed webhook',
@ -271,70 +214,30 @@ exit 1
'poster': {'login': 'reporter'}, '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 // And a Gitea API fixture that returns a new issue only on a later reconciliation poll.
cat <<'JSON' final giteaServer = await FakeGiteaApiServer.start(
${jsonEncode({'login': 'tea-octocat'})} issueListResponseSequenceByRepo: {
JSON 'owner/sample': [
exit 0 const <Map<String, Object?>>[],
fi [issue],
],
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then },
count=0 commentResponsesByRepo: {
if [[ -f "${bashScriptPath(issueListCount.path)}" ]]; then 'owner/sample': {18: comments},
count="\$(cat "${bashScriptPath(issueListCount.path)}")" },
fi postCommentResponsesByRepo: {
count=\$((count + 1)) 'owner/sample': {
printf '%s' "\$count" > "${bashScriptPath(issueListCount.path)}" 18: {
if [[ "\$count" -eq 1 ]]; then 'id': 802,
cat <<'JSON' 'body': 'posted',
[] 'html_url': 'https://gitea.example.test/comment/802',
JSON },
else },
cat <<'JSON' },
${jsonEncode([issue])} viewerLogin: 'tea-octocat',
JSON );
fi addTearDown(giteaServer.close);
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 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'));
@ -366,18 +269,23 @@ projects:
reconcileInterval: 1s reconcileInterval: 1s
repo: owner/sample repo: owner/sample
path: ${repoDir.path} 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( 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'),
gosmeeCommand: gosmeeScript.path, gosmeeCommand: gosmeeScript.path,
teaCommand: teaScript.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.
final runFuture = app.run(); final runFuture = app.run();
for (var attempt = 0; attempt < 200; attempt += 1) { for (var attempt = 0; attempt < 200; attempt += 1) {
if (await postedBody.exists()) { if (giteaServer.postedBodies.isNotEmpty) {
break; break;
} }
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
@ -386,21 +294,19 @@ projects:
await runFuture; await runFuture;
// Then it posts exactly one reply for the issue discovered by polling. // Then it posts exactly one reply for the issue discovered by polling.
expect(await postedBody.exists(), isTrue); expect(giteaServer.postedBodies, hasLength(1));
final postedComment = await postedBody.readAsString(); final postedComment =
(jsonDecode(giteaServer.postedBodies.single)
as Map<String, dynamic>)['body']
as String;
expect( expect(
postedComment, postedComment,
contains('Plan:\n- reconcile missed gosmee events through polling'), contains('Plan:\n- reconcile missed gosmee events through polling'),
); );
// And the app performs more than one Gitea issue listing request while running. // And the app performs more than one Gitea issue listing request while running.
final teaLogLines = await teaLog.readAsLines();
expect( expect(
teaLogLines giteaServer.issueListRequestCount('owner/sample'),
.where(
(line) => line.contains('repos/owner/sample/issues?state=open'),
)
.length,
greaterThanOrEqualTo(2), greaterThanOrEqualTo(2),
); );
final gosmeeLogLines = await gosmeeLog.readAsLines(); final gosmeeLogLines = await gosmeeLog.readAsLines();

View File

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

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:code_work_spawner/code_work_spawner.dart';
@ -144,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(
@ -189,95 +194,405 @@ void main() {
/// Feature: Gitea tracker client integration /// Feature: Gitea tracker client integration
/// ///
/// As a maintainer debugging Gitea tracker failures /// As a maintainer debugging Gitea tracker failures
/// I want tea-specific API and webhook errors to preserve the original cause /// I want issue operations to use the Gitea HTTP API directly
/// So that misconfigured repos and tea output changes are easy to trace /// So that issue polling and replies do not depend on `tea api`
/// ``` /// ```
group('IssueTrackerClient Gitea', () { group('IssueTrackerClient Gitea', () {
/// ```gherkin setUp(() {
/// Scenario: Read a webhook id from tea plain-text success output AppEnvironment.resetForTest();
/// 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
if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then tearDown(() {
printf '%s\n' 'Webhook created successfully (ID: 40)' AppEnvironment.resetForTest();
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);
}); });
/// ```gherkin /// ```gherkin
/// Scenario: Surface Gitea API error details when issue listing returns an error object /// Scenario: Create a Gitea webhook through the HTTP API
/// Given a fake tea executable whose issue list request returns a Gitea error object /// Given a local Gitea API server that accepts webhook creation requests
/// When the tracker client fetches updated Gitea issues /// When the tracker client creates a Gitea webhook
/// Then it throws a format error that includes the repo and API error details /// Then it returns the created webhook id
/// And the webhook options are sent to the Gitea API
/// ``` /// ```
test( test('Create a Gitea webhook through the HTTP API', () async {
'Surface Gitea API error details when issue listing returns an error object', // Given a local Gitea API server that accepts webhook creation requests.
() async { final giteaServer = await FakeGiteaApiServer.start();
// Given a fake tea executable whose issue list request returns a Gitea error object. _configureGiteaTestEnvironmentFromBaseUrl(giteaServer.baseUrl);
final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-'); final client = IssueTrackerClient();
final teaScript = File(p.join(sandbox.path, 'tea'));
await teaScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then try {
cat <<'JSON' // When the tracker client creates a Gitea webhook.
{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"} final webhookId = await client.createGiteaIssueWebhook(
JSON repo: RepositorySlug('owner', 'sample'),
exit 0 url: 'https://gosmee.example.test/sample',
fi );
echo "unexpected tea args: \$*" >&2 // Then it returns the created webhook id.
exit 1 expect(webhookId, 81);
''');
await makeScriptExecutable(teaScript);
final client = IssueTrackerClient(teaCommand: teaScript.path);
// When the tracker client fetches updated Gitea issues. // And the webhook options are sent to the Gitea API.
final future = client.fetchUpdatedIssuesForRepos( 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(<Map<String, Object?>>[
<String, Object?>{
'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': <String, Object?>{'login': 'reporter'},
'labels': <Map<String, Object?>>[
<String, Object?>{'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(<Map<String, Object?>>[
<String, Object?>{
'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': <String, Object?>{'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(<String, Object?>{'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<String, dynamic>)['body']
as String?;
request.response
..statusCode = HttpStatus.created
..headers.contentType = ContentType.json
..write(
jsonEncode(<String, Object?>{
'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(<String, Object?>{'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, provider: IssueTrackerProvider.gitea,
trackerProjects: const <String>['owner/sample'], trackerProjects: const <String>['owner/sample'],
since: null, 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. // Then the Gitea issue payload is normalized into the shared issue summary model.
await expectLater( expect(login, 'gitea-user');
future, expect(issues.single.trackerProject, 'owner/sample');
throwsA( expect(issues.single.number, 12);
isA<FormatException>().having( expect(issues.single.state, 'open');
(error) => error.message, expect(issues.single.labels, ['enhancement']);
'message', expect(thread.comments.single.url, contains('#issuecomment-501'));
allOf(
contains('owner/sample'), // And the posted comment response is returned to the caller.
contains('GetUserByName'), expect(postedBody, 'Reply from gitea api');
contains('user redirect does not exist'), 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(<String, Object?>{'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(<String, String>{
'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(<String, Object?>{
'errors': <String>[
'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 <String>['owner/sample'],
since: null,
);
// Then it throws a format error that includes the repo and API error details.
await expectLater(
future,
throwsA(
isA<FormatException>().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<void>.delayed(const Duration(seconds: 5));
request.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write('[]');
await request.response.close();
});
_configureGiteaTestEnvironment(server);
final logMessages = <String>[];
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 <String>['owner/sample'],
since: null,
);
// Then it throws a timeout instead of waiting indefinitely.
await expectLater(
future,
throwsA(
isA<TimeoutException>().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(<String, String>{
...Platform.environment,
'CWS_GITEA_HOST': baseUrl,
'CWS_GITEA_TOKEN': 'test-token',
'CWS_GITEA_INSECURE': 'false',
});
}
Future<HttpServer> _startGiteaApiServer(
Future<void> 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;
}

View File

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

View File

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

View File

@ -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<String, List<Map<String, Object?>>> issueListResponsesByRepo;
final Map<String, List<List<Map<String, Object?>>>>
issueListResponseSequenceByRepo;
final Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo;
final Map<String, Map<int, Map<String, Object?>>> postCommentResponsesByRepo;
final String viewerLogin;
final String token;
final List<String> requestPaths = <String>[];
final List<String> postedBodies = <String>[];
final List<Map<String, dynamic>> createdWebhooks = <Map<String, dynamic>>[];
final List<int> deletedWebhookIds = <int>[];
final Map<String, int> _issueListRequestCounts = <String, int>{};
String get baseUrl => 'http://${_server.address.host}:${_server.port}';
int issueListRequestCount(String repo) => _issueListRequestCounts[repo] ?? 0;
static Future<FakeGiteaApiServer> start({
Map<String, List<Map<String, Object?>>> issueListResponsesByRepo =
const <String, List<Map<String, Object?>>>{},
Map<String, List<List<Map<String, Object?>>>>
issueListResponseSequenceByRepo =
const <String, List<List<Map<String, Object?>>>>{},
Map<String, Map<int, List<Map<String, Object?>>>> commentResponsesByRepo =
const <String, Map<int, List<Map<String, Object?>>>>{},
Map<String, Map<int, Map<String, Object?>>> postCommentResponsesByRepo =
const <String, Map<int, Map<String, Object?>>>{},
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<void> _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(<String, Object?>{'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<String, dynamic>);
response.statusCode = HttpStatus.created;
response.write(jsonEncode(<String, Object?>{'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 <Map<String, Object?>>[]
: 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 <Map<String, Object?>>[];
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] ??
<String, Object?>{
'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(<String, Object?>{'message': 'not found'}));
await response.close();
}
Future<void> close() => _server.close(force: true);
}

View File

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

View File

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

View File

@ -1,7 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'script_utils.dart'; import 'package:code_work_spawner/src/config/config.dart';
import 'package:code_work_spawner/src/core/cli_responder.dart';
import 'package:code_work_spawner/src/issue_tracker/issue_tracker_client.dart';
final Map<String, _FakeResponder> _fakeResponders = {};
CliResponderRunner fakeResponderRunner() =>
FakeResponderRunner(Map<String, _FakeResponder>.from(_fakeResponders));
Future<void> writeResponderScript( Future<void> writeResponderScript(
File responderScript, File responderScript,
@ -9,33 +16,34 @@ Future<void> writeResponderScript(
String stderr = '', String stderr = '',
int exitCode = 0, int exitCode = 0,
}) async { }) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
cat >/dev/null stderr: stderr,
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"} exitCode: exitCode,
cat <<'JSON' );
${jsonEncode(response)} }
JSON
exit $exitCode Future<void> writeCapturingResponderScript(
'''); File responderScript,
await makeScriptExecutable(responderScript); Map<String, Object?> response, {
File? cwdCapture,
File? stdinCapture,
}) async {
_fakeResponders[responderScript.path] = _FakeResponder(
response: response,
cwdCapture: cwdCapture,
stdinCapture: stdinCapture,
);
} }
Future<void> writeJsonlResponderScript( Future<void> writeJsonlResponderScript(
File responderScript, File responderScript,
Map<String, Object?> response, Map<String, Object?> response,
) async { ) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
cat >/dev/null emitsJsonl: true,
cat <<'JSON' );
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await makeScriptExecutable(responderScript);
} }
Future<void> writeDelayedLoggingResponderScript( Future<void> writeDelayedLoggingResponderScript(
@ -44,21 +52,142 @@ Future<void> writeDelayedLoggingResponderScript(
required Duration delay, required Duration delay,
required Map<String, Object?> response, required Map<String, Object?> response,
}) async { }) async {
await responderScript.writeAsString('''#!/usr/bin/env bash _fakeResponders[responderScript.path] = _FakeResponder(
set -euo pipefail response: response,
timestamp_ms() { eventLog: eventLog,
python3 - <<'PY' delay: delay,
import time );
print(int(time.time() * 1000))
PY
} }
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);
}
} }

View File

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

View File

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

View File

@ -2,6 +2,7 @@ export 'support/script_utils.dart';
export 'support/fake_gh.dart'; export 'support/fake_gh.dart';
export 'support/fake_tea.dart'; export 'support/fake_tea.dart';
export 'support/fake_glab.dart'; export 'support/fake_glab.dart';
export 'support/fake_gitea_api.dart';
export 'support/fake_responders.dart'; export 'support/fake_responders.dart';
export 'support/fake_gosmee.dart'; export 'support/fake_gosmee.dart';
export 'support/git_utils.dart'; export 'support/git_utils.dart';

View File

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

View File

@ -0,0 +1,291 @@
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<void> main(List<String> 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<String>).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 = <String, Object?>{
'command': command,
'arguments': args,
'timeout_ms': timeout.inMilliseconds,
'working_directory': workingDirectory,
'resolved': <String, Object?>{
'executable': resolved.executable,
'arguments': resolved.arguments,
'run_in_shell': resolved.runInShell,
},
'results': <Object?>[],
};
final resultList = payload['results'] as List<Object?>;
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<String> _parseModes(String configuredModes) {
final trimmedModes = configuredModes
.split(',')
.map((mode) => mode.trim())
.where((mode) => mode.isNotEmpty)
.toList(growable: false);
if (trimmedModes.contains('all')) {
return const <String>[
'launcher',
'process-run',
'process-run-sync',
'process-start',
'powershell-command',
];
}
return trimmedModes;
}
Future<Map<String, Object?>> _runMode(
String mode, {
required String command,
required List<String> 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 <String, Object?>{
'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 <String, Object?>{
'mode': mode,
'status': 'timeout',
'duration_ms': stopwatch.elapsedMilliseconds,
'message': error.message,
};
} catch (error, stackTrace) {
stopwatch.stop();
return <String, Object?>{
'mode': mode,
'status': 'error',
'duration_ms': stopwatch.elapsedMilliseconds,
'error': error.toString(),
'stack_preview': _preview(stackTrace.toString()),
};
}
}
Future<ProcessResult> _runWithProcessStart({
required String command,
required List<String> 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<String> 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<ProcessResult> _runViaPowerShellCommand({
required String command,
required List<String> 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',
<String>[
'-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)}...';
}