forked from bkinnightskytw/code_work_spawner
Merge pull request #46 from existedinnettw/feat/windows
This commit is contained in:
commit
566c9defa0
|
|
@ -13,3 +13,6 @@ agent-orchestrator.yaml
|
|||
!/skills/dart-gherkin-tests
|
||||
*.g.dart
|
||||
**/*.exe
|
||||
/CWS*.yaml
|
||||
/CWS*.yml
|
||||
dart_test.yaml
|
||||
|
|
|
|||
|
|
@ -79,3 +79,8 @@ Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub,
|
|||
[`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject.
|
||||
This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses
|
||||
`worktreeDir` for bug-verification worktrees.
|
||||
|
||||
## tea + windows
|
||||
|
||||
The `tea` CLI has known severe issues on Windows due to lipgloss/v2 query console color. We had bypass it by read config file of `tea` directly, but only token login is supported. And need to config a default login through `tea login default <LOGIN>`.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../config/app_logger.dart';
|
|||
import 'cli_responder.dart';
|
||||
import '../config/config.dart';
|
||||
import 'database.dart';
|
||||
import 'process_launcher.dart';
|
||||
import '../issue_tracker/issue_event_source/base.dart';
|
||||
import '../issue_tracker/issue_event_source/gitea_gosmee.dart';
|
||||
import '../issue_tracker/issue_event_source/gitlab_gosmee.dart';
|
||||
|
|
@ -46,12 +47,17 @@ class IssueAssistantApp {
|
|||
String glabCommand = 'glab',
|
||||
String gosmeeCommand = 'gosmee',
|
||||
String teaCommand = 'tea',
|
||||
CliResponderRunner? responderRunner,
|
||||
ExternalCommandExecutor? commandExecutor,
|
||||
}) async {
|
||||
final database = AppDatabase.open(databasePath);
|
||||
final externalCommandExecutor =
|
||||
commandExecutor ?? const DefaultExternalCommandExecutor();
|
||||
final issueTrackerClient = IssueTrackerClient(
|
||||
ghCommand: ghCommand,
|
||||
glabCommand: glabCommand,
|
||||
teaCommand: teaCommand,
|
||||
commandExecutor: externalCommandExecutor,
|
||||
);
|
||||
final pollingIssueEventSource = PollingIssueEventSource(
|
||||
database: database,
|
||||
|
|
@ -71,26 +77,30 @@ class IssueAssistantApp {
|
|||
IssueTrackerEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
|
||||
issueTrackerClient: issueTrackerClient,
|
||||
gosmeeCommand: gosmeeCommand,
|
||||
commandExecutor: externalCommandExecutor,
|
||||
logger: _logger,
|
||||
),
|
||||
IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource(
|
||||
issueTrackerClient: issueTrackerClient,
|
||||
gosmeeCommand: gosmeeCommand,
|
||||
commandExecutor: externalCommandExecutor,
|
||||
logger: _logger,
|
||||
),
|
||||
IssueTrackerEventSourceKind.ghWebhookForward:
|
||||
GitHubWebhookForwardIssueEventSource(
|
||||
issueTrackerClient: issueTrackerClient,
|
||||
commandExecutor: externalCommandExecutor,
|
||||
logger: _logger,
|
||||
),
|
||||
IssueTrackerEventSourceKind.teaGosmee: GiteaGosmeeIssueEventSource(
|
||||
issueTrackerClient: issueTrackerClient,
|
||||
gosmeeCommand: gosmeeCommand,
|
||||
commandExecutor: externalCommandExecutor,
|
||||
logger: _logger,
|
||||
),
|
||||
},
|
||||
),
|
||||
responderRunner: CliResponderRunner(),
|
||||
responderRunner: responderRunner ?? CliResponderRunner(),
|
||||
workspaceManager: WorkspaceManager(worktreeRoot: config.worktreeDir),
|
||||
notifierRegistryFactory: (project) => NotifierRegistry.fromConfigs(
|
||||
project.issueAssistant.notifications,
|
||||
|
|
|
|||
|
|
@ -1,49 +1,164 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../config/app_logger.dart';
|
||||
import 'package:executable/executable.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class _ResolvedCommand {
|
||||
_ResolvedCommand({
|
||||
class ResolvedExternalCommand {
|
||||
ResolvedExternalCommand({
|
||||
required this.executable,
|
||||
required this.arguments,
|
||||
required this.usesBashShim,
|
||||
required this.runInShell,
|
||||
});
|
||||
|
||||
final String executable;
|
||||
final List<String> arguments;
|
||||
final bool usesBashShim;
|
||||
final bool runInShell;
|
||||
}
|
||||
|
||||
_ResolvedCommand _resolveCommand(String executable, List<String> arguments) {
|
||||
final extension = p.extension(executable).toLowerCase();
|
||||
final shouldUseBash = extension.isEmpty || extension == '.sh';
|
||||
final AppLogger _processLauncherLogger = AppLogger(
|
||||
'code_work_spawner.process_launcher',
|
||||
);
|
||||
|
||||
if (Platform.isWindows && shouldUseBash && File(executable).existsSync()) {
|
||||
final bashScriptPath = _toBashPath(executable);
|
||||
return _ResolvedCommand(
|
||||
executable: 'wsl.exe',
|
||||
arguments: ['--exec', 'bash', bashScriptPath, ...arguments],
|
||||
usesBashShim: true,
|
||||
abstract interface class ExternalCommandExecutor {
|
||||
Future<ProcessResult> run(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
bool includeParentEnvironment,
|
||||
Duration? timeout,
|
||||
});
|
||||
|
||||
Future<StartedExternalCommand> start(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
bool includeParentEnvironment,
|
||||
bool runInShell,
|
||||
ProcessStartMode mode,
|
||||
});
|
||||
}
|
||||
|
||||
abstract interface class StartedExternalCommand {
|
||||
Stream<List<int>> get stdout;
|
||||
Stream<List<int>> get stderr;
|
||||
Future<int> get exitCode;
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]);
|
||||
}
|
||||
|
||||
class DefaultExternalCommandExecutor implements ExternalCommandExecutor {
|
||||
const DefaultExternalCommandExecutor();
|
||||
|
||||
@override
|
||||
Future<ProcessResult> run(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
bool includeParentEnvironment = true,
|
||||
Duration? timeout,
|
||||
}) {
|
||||
return runExternalCommand(
|
||||
executable,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeParentEnvironment: includeParentEnvironment,
|
||||
timeout: timeout,
|
||||
);
|
||||
}
|
||||
|
||||
return _ResolvedCommand(
|
||||
executable: executable,
|
||||
@override
|
||||
Future<StartedExternalCommand> start(
|
||||
String executable,
|
||||
List<String> arguments, {
|
||||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
bool includeParentEnvironment = true,
|
||||
bool runInShell = false,
|
||||
ProcessStartMode mode = ProcessStartMode.normal,
|
||||
}) async {
|
||||
return ProcessStartedExternalCommand(
|
||||
await startExternalCommand(
|
||||
executable,
|
||||
arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: environment,
|
||||
includeParentEnvironment: includeParentEnvironment,
|
||||
runInShell: runInShell,
|
||||
mode: mode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessStartedExternalCommand implements StartedExternalCommand {
|
||||
ProcessStartedExternalCommand(this._process);
|
||||
|
||||
final Process _process;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stdout => _process.stdout;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stderr => _process.stderr;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _process.exitCode;
|
||||
|
||||
@override
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
||||
return _process.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedExternalCommand resolveExternalCommandForCurrentPlatform(
|
||||
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,
|
||||
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) {
|
||||
final normalized = windowsPath.replaceAll('\\', '/');
|
||||
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized);
|
||||
if (drivePrefixMatch == null) {
|
||||
return normalized;
|
||||
}
|
||||
bool _requiresWindowsShell(String extension) {
|
||||
return extension == '.bat' || extension == '.cmd';
|
||||
}
|
||||
|
||||
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase();
|
||||
final rest = drivePrefixMatch.group(2)!;
|
||||
return '/mnt/$driveLetter/$rest';
|
||||
String? _resolveLocalExecutablePath(String executable) {
|
||||
final localFile = File(executable);
|
||||
if (!localFile.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
return localFile.path;
|
||||
}
|
||||
|
||||
String? _resolveExecutablePathOnPath(String executable) {
|
||||
if (_looksLikePathReference(executable)) {
|
||||
return null;
|
||||
}
|
||||
return Executable(executable).findSync();
|
||||
}
|
||||
|
||||
bool _looksLikePathReference(String executable) {
|
||||
return executable.contains(r'\') ||
|
||||
executable.contains('/') ||
|
||||
executable.startsWith('.');
|
||||
}
|
||||
|
||||
Future<ProcessResult> runExternalCommand(
|
||||
|
|
@ -52,20 +167,102 @@ Future<ProcessResult> runExternalCommand(
|
|||
String? workingDirectory,
|
||||
Map<String, String>? environment,
|
||||
bool includeParentEnvironment = true,
|
||||
}) {
|
||||
final resolved = _resolveCommand(executable, arguments);
|
||||
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand(
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
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,
|
||||
usesBashShim: resolved.usesBashShim,
|
||||
runInShell: resolved.runInShell,
|
||||
timeout: timeout,
|
||||
);
|
||||
return Process.run(
|
||||
resolved.executable,
|
||||
resolved.arguments,
|
||||
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: effectiveEnvironment,
|
||||
environment: environment,
|
||||
includeParentEnvironment: includeParentEnvironment,
|
||||
runInShell: runInShell,
|
||||
);
|
||||
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(
|
||||
|
|
@ -77,42 +274,17 @@ Future<Process> startExternalCommand(
|
|||
bool runInShell = false,
|
||||
ProcessStartMode mode = ProcessStartMode.normal,
|
||||
}) {
|
||||
final resolved = _resolveCommand(executable, arguments);
|
||||
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand(
|
||||
environment: environment,
|
||||
includeParentEnvironment: includeParentEnvironment,
|
||||
usesBashShim: resolved.usesBashShim,
|
||||
final resolved = resolveExternalCommandForCurrentPlatform(
|
||||
executable,
|
||||
arguments,
|
||||
);
|
||||
return Process.start(
|
||||
resolved.executable,
|
||||
resolved.arguments,
|
||||
workingDirectory: workingDirectory,
|
||||
environment: effectiveEnvironment,
|
||||
environment: environment,
|
||||
includeParentEnvironment: includeParentEnvironment,
|
||||
runInShell: runInShell,
|
||||
runInShell: runInShell || resolved.runInShell,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:github/github.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import '../config/app_environment.dart';
|
||||
import '../config/app_logger.dart';
|
||||
import '../config/config_schema.dart';
|
||||
import '../core/process_launcher.dart';
|
||||
import 'models.dart';
|
||||
|
|
@ -15,12 +18,21 @@ class IssueTrackerClient {
|
|||
this.glabCommand = 'glab',
|
||||
this.teaCommand = 'tea',
|
||||
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 glabCommand;
|
||||
final String teaCommand;
|
||||
final String opCommand;
|
||||
final Duration commandTimeout;
|
||||
final AppLogger _logger;
|
||||
final ExternalCommandExecutor _commandExecutor;
|
||||
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
|
||||
<IssueTrackerProvider, Future<String?>>{};
|
||||
final Map<String, Future<_OpenProjectProjectRef>> _openProjectProjectFutures =
|
||||
|
|
@ -81,18 +93,14 @@ class IssueTrackerClient {
|
|||
{'per_page': '100'},
|
||||
),
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(
|
||||
IssueTrackerProvider.gitea => await _runGiteaJson(
|
||||
method: 'GET',
|
||||
path: _buildApiPath(
|
||||
issue.requiredRepoSlug,
|
||||
['issues', '${issue.number}', 'comments'],
|
||||
{'limit': '100'},
|
||||
),
|
||||
'-r',
|
||||
issue.requiredRepoSlug.fullName,
|
||||
]),
|
||||
),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'GET',
|
||||
path: '/api/v3/work_packages/${issue.number}/activities',
|
||||
|
|
@ -157,20 +165,15 @@ class IssueTrackerClient {
|
|||
'-f',
|
||||
'body=$body',
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(parseRepositorySlug(trackerProject), [
|
||||
IssueTrackerProvider.gitea => await _runGiteaJson(
|
||||
method: 'POST',
|
||||
path: _buildApiPath(parseRepositorySlug(trackerProject), [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'comments',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
'-r',
|
||||
trackerProject,
|
||||
]),
|
||||
body: <String, Object?>{'body': body},
|
||||
),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'POST',
|
||||
path: '/api/v3/work_packages/$issueNumber/activities',
|
||||
|
|
@ -241,7 +244,7 @@ class IssueTrackerClient {
|
|||
return switch (provider) {
|
||||
IssueTrackerProvider.github => 'gh',
|
||||
IssueTrackerProvider.gitlab => 'glab',
|
||||
IssueTrackerProvider.gitea => 'tea',
|
||||
IssueTrackerProvider.gitea => 'gitea',
|
||||
IssueTrackerProvider.openproject => opCommand,
|
||||
};
|
||||
}
|
||||
|
|
@ -250,43 +253,23 @@ class IssueTrackerClient {
|
|||
required RepositorySlug repo,
|
||||
required String url,
|
||||
}) async {
|
||||
final result = await _runCommand(teaCommand, [
|
||||
'webhooks',
|
||||
'create',
|
||||
'--type',
|
||||
'gitea',
|
||||
'--events',
|
||||
'issues,issue_comment',
|
||||
'--active',
|
||||
'-o',
|
||||
'json',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
url,
|
||||
]);
|
||||
final stdoutText = result.stdout.toString().trim();
|
||||
if (stdoutText.isEmpty) {
|
||||
throw FormatException(
|
||||
'tea webhooks create returned an empty response for repo '
|
||||
'${repo.fullName}.',
|
||||
final json = await _runGiteaJson(
|
||||
method: 'POST',
|
||||
path: _buildApiPath(repo, ['hooks']),
|
||||
body: <String, Object?>{
|
||||
'type': 'gitea',
|
||||
'config': <String, Object?>{'url': url, 'content_type': 'json'},
|
||||
'events': <String>['issues', 'issue_comment'],
|
||||
'active': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
'tea webhooks create returned an unexpected response for repo '
|
||||
'${repo.fullName}. stdout=${_summarizeOutput(stdoutText)}',
|
||||
'Gitea API webhook create returned an unexpected response for repo '
|
||||
'${repo.fullName}.',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -362,14 +345,10 @@ class IssueTrackerClient {
|
|||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
}) async {
|
||||
await _runTeaJson([
|
||||
'webhooks',
|
||||
'delete',
|
||||
'--confirm',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
'$webhookId',
|
||||
]);
|
||||
await _runGiteaJson(
|
||||
method: 'DELETE',
|
||||
path: _buildApiPath(repo, ['hooks', '$webhookId']),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteGitLabWebhook({
|
||||
|
|
@ -404,6 +383,14 @@ class IssueTrackerClient {
|
|||
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
for (var page = 1; true; page += 1) {
|
||||
_traceRequest(
|
||||
provider: IssueTrackerProvider.github,
|
||||
requestKind: 'search-issues',
|
||||
trackerProject: repoList.map((repo) => repo.fullName).join(','),
|
||||
page: page,
|
||||
since: since,
|
||||
requestPath: 'search/issues',
|
||||
);
|
||||
final json = await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
|
|
@ -453,6 +440,14 @@ class IssueTrackerClient {
|
|||
'page': '$page',
|
||||
if (since != null) 'updated_after': since.toUtc().toIso8601String(),
|
||||
};
|
||||
_traceRequest(
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
requestKind: 'list-issues',
|
||||
trackerProject: trackerProject,
|
||||
page: page,
|
||||
since: since,
|
||||
requestPath: _buildGitLabApiPath(trackerProject, ['issues'], query),
|
||||
);
|
||||
final json = await _runGlabJson([
|
||||
'api',
|
||||
'-X',
|
||||
|
|
@ -490,23 +485,25 @@ class IssueTrackerClient {
|
|||
'page': '$page',
|
||||
if (since != null) 'since': since.toUtc().toIso8601String(),
|
||||
};
|
||||
final json = await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(repo, ['issues'], query),
|
||||
'-r',
|
||||
repo.fullName,
|
||||
]);
|
||||
final requestPath = _buildApiPath(repo, ['issues'], query);
|
||||
_traceRequest(
|
||||
provider: IssueTrackerProvider.gitea,
|
||||
requestKind: 'list-issues',
|
||||
trackerProject: repo.fullName,
|
||||
page: page,
|
||||
since: since,
|
||||
requestPath: requestPath,
|
||||
);
|
||||
final json = await _runGiteaJson(method: 'GET', path: requestPath);
|
||||
if (json is Map<String, dynamic>) {
|
||||
throw FormatException(
|
||||
'tea api returned an error while listing Gitea issues for '
|
||||
'Gitea API returned an error while listing issues for '
|
||||
'${repo.fullName}: ${_describeJsonObject(json)}',
|
||||
);
|
||||
}
|
||||
if (json != null && json is! List<dynamic>) {
|
||||
throw FormatException(
|
||||
'tea api returned ${json.runtimeType} while listing Gitea issues '
|
||||
'Gitea API returned ${json.runtimeType} while listing issues '
|
||||
'for ${repo.fullName}; expected a JSON array.',
|
||||
);
|
||||
}
|
||||
|
|
@ -579,7 +576,10 @@ class IssueTrackerClient {
|
|||
final json = switch (provider) {
|
||||
IssueTrackerProvider.github => await _runGhJson(['api', 'user']),
|
||||
IssueTrackerProvider.gitlab => await _runGlabJson(['api', 'user']),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']),
|
||||
IssueTrackerProvider.gitea => await _runGiteaJson(
|
||||
method: 'GET',
|
||||
path: '/user',
|
||||
),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'GET',
|
||||
path: '/api/v3/users/me',
|
||||
|
|
@ -604,8 +604,102 @@ class IssueTrackerClient {
|
|||
return _runJson(glabCommand, arguments);
|
||||
}
|
||||
|
||||
Future<Object?> _runTeaJson(List<String> arguments) {
|
||||
return _runJson(teaCommand, arguments);
|
||||
Future<Object?> _runGiteaJson({
|
||||
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({
|
||||
|
|
@ -659,12 +753,31 @@ class IssueTrackerClient {
|
|||
final result = await _runCommand(command, arguments);
|
||||
final stdoutText = result.stdout.toString().trim();
|
||||
if (stdoutText.isEmpty) {
|
||||
_traceCommandResult(
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
state: 'empty-stdout',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return jsonDecode(stdoutText);
|
||||
final json = jsonDecode(stdoutText);
|
||||
_traceCommandResult(
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
state: 'json-parsed',
|
||||
detail: _describeJsonPayload(json),
|
||||
);
|
||||
return json;
|
||||
} on FormatException catch (error) {
|
||||
_traceCommandResult(
|
||||
command: command,
|
||||
arguments: arguments,
|
||||
state: 'json-parse-failed',
|
||||
detail:
|
||||
'${error.message} stdout=${_summarizeOutput(stdoutText, maxLength: 120)}',
|
||||
);
|
||||
throw FormatException(
|
||||
'Failed to parse JSON from `$command ${arguments.join(' ')}`: '
|
||||
'${error.message}. stdout=${_summarizeOutput(stdoutText)}',
|
||||
|
|
@ -676,7 +789,41 @@ class IssueTrackerClient {
|
|||
String command,
|
||||
List<String> arguments,
|
||||
) async {
|
||||
final result = await runExternalCommand(command, arguments);
|
||||
final renderedCommand = _renderCommand(command, arguments);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_logger.debug(
|
||||
'command start timeout_ms=${commandTimeout.inMilliseconds} command=$renderedCommand',
|
||||
);
|
||||
late final ProcessResult result;
|
||||
try {
|
||||
result = await _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) {
|
||||
throw ProcessException(
|
||||
command,
|
||||
|
|
@ -688,12 +835,19 @@ class IssueTrackerClient {
|
|||
return result;
|
||||
}
|
||||
|
||||
int? _extractWebhookIdFromTeaOutput(String stdoutText) {
|
||||
final match = RegExp(
|
||||
r'Webhook created successfully \(ID:\s*(\d+)\)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(stdoutText);
|
||||
return match == null ? null : int.parse(match.group(1)!);
|
||||
String _renderCommand(String command, List<String> arguments) {
|
||||
final parts = <String>[command, ...arguments];
|
||||
return parts.map(_shellQuote).join(' ');
|
||||
}
|
||||
|
||||
String _shellQuote(String value) {
|
||||
if (value.isEmpty) {
|
||||
return '""';
|
||||
}
|
||||
if (!value.contains(RegExp(r'[\s"]'))) {
|
||||
return value;
|
||||
}
|
||||
return '"${value.replaceAll('"', r'\"')}"';
|
||||
}
|
||||
|
||||
String _describeJsonObject(Map<String, dynamic> json) {
|
||||
|
|
@ -722,6 +876,47 @@ class IssueTrackerClient {
|
|||
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(
|
||||
RepositorySlug repo,
|
||||
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 {
|
||||
final environment = AppEnvironment.variables;
|
||||
final host = environment['OP_CLI_HOST']?.trim();
|
||||
|
|
@ -864,6 +1239,18 @@ class _OpenProjectConfig {
|
|||
final String token;
|
||||
}
|
||||
|
||||
class _GiteaConfig {
|
||||
const _GiteaConfig({
|
||||
required this.host,
|
||||
required this.token,
|
||||
required this.insecure,
|
||||
});
|
||||
|
||||
final Uri host;
|
||||
final String token;
|
||||
final bool insecure;
|
||||
}
|
||||
|
||||
class _OpenProjectProjectRef {
|
||||
const _OpenProjectProjectRef({required this.id, required this.name});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
|||
GiteaGosmeeIssueEventSource({
|
||||
required this.issueTrackerClient,
|
||||
required this.gosmeeCommand,
|
||||
required this.commandExecutor,
|
||||
required AppLogger logger,
|
||||
}) : _logger = logger;
|
||||
|
||||
|
|
@ -22,10 +23,11 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
|||
|
||||
final IssueTrackerClient issueTrackerClient;
|
||||
final String gosmeeCommand;
|
||||
final ExternalCommandExecutor commandExecutor;
|
||||
final AppLogger _logger;
|
||||
final Map<String, _QueuedGiteaIssue> _queuedIssues = {};
|
||||
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, RepositorySlug> _repoSlugsByProject = {};
|
||||
|
||||
|
|
@ -109,7 +111,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
|||
'gitea/gosmee project=${project.key} start_client '
|
||||
'command="$gosmeeCommand client $publicUrl $localUrl"',
|
||||
);
|
||||
final process = await startExternalCommand(gosmeeCommand, [
|
||||
final process = await commandExecutor.start(gosmeeCommand, [
|
||||
'client',
|
||||
publicUrl,
|
||||
localUrl,
|
||||
|
|
@ -286,7 +288,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
|||
}
|
||||
|
||||
Future<String> _createGosmeeChannelUrl(String localUrl) async {
|
||||
final result = await runExternalCommand(gosmeeCommand, [
|
||||
final result = await commandExecutor.run(gosmeeCommand, [
|
||||
'--output',
|
||||
'json',
|
||||
'client',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
|||
GitHubGosmeeIssueEventSource({
|
||||
required this.issueTrackerClient,
|
||||
required this.gosmeeCommand,
|
||||
required this.commandExecutor,
|
||||
required AppLogger logger,
|
||||
}) : _logger = logger;
|
||||
|
||||
|
|
@ -22,10 +23,11 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
|||
|
||||
final IssueTrackerClient issueTrackerClient;
|
||||
final String gosmeeCommand;
|
||||
final ExternalCommandExecutor commandExecutor;
|
||||
final AppLogger _logger;
|
||||
final Map<String, _QueuedGitHubIssue> _queuedIssues = {};
|
||||
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, RepositorySlug> _repoSlugsByProject = {};
|
||||
|
||||
|
|
@ -109,7 +111,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
|||
'github/gosmee project=${project.key} start_client '
|
||||
'command="$gosmeeCommand client $publicUrl $localUrl"',
|
||||
);
|
||||
final process = await startExternalCommand(gosmeeCommand, [
|
||||
final process = await commandExecutor.start(gosmeeCommand, [
|
||||
'client',
|
||||
publicUrl,
|
||||
localUrl,
|
||||
|
|
@ -286,7 +288,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
|||
}
|
||||
|
||||
Future<String> _createGosmeeChannelUrl(String localUrl) async {
|
||||
final result = await runExternalCommand(gosmeeCommand, [
|
||||
final result = await commandExecutor.run(gosmeeCommand, [
|
||||
'--output',
|
||||
'json',
|
||||
'client',
|
||||
|
|
|
|||
|
|
@ -12,16 +12,18 @@ import 'base.dart';
|
|||
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||
GitHubWebhookForwardIssueEventSource({
|
||||
required this.issueTrackerClient,
|
||||
required this.commandExecutor,
|
||||
required AppLogger logger,
|
||||
}) : _logger = logger;
|
||||
|
||||
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||
|
||||
final IssueTrackerClient issueTrackerClient;
|
||||
final ExternalCommandExecutor commandExecutor;
|
||||
final AppLogger _logger;
|
||||
final Map<String, _QueuedWebhookIssue> _queuedIssues = {};
|
||||
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||
final Map<String, Process> _forwardProcesses = {};
|
||||
final Map<String, StartedExternalCommand> _forwardProcesses = {};
|
||||
|
||||
HttpServer? _server;
|
||||
Timer? _flushTimer;
|
||||
|
|
@ -82,7 +84,8 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
|||
'github/webhook-forward project=${project.key} start_forwarder '
|
||||
'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"',
|
||||
);
|
||||
final process = await startExternalCommand(issueTrackerClient.ghCommand, [
|
||||
final process = await commandExecutor
|
||||
.start(issueTrackerClient.ghCommand, [
|
||||
'webhook',
|
||||
'forward',
|
||||
'--events=issues,issue_comment',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
|
|||
GitLabGosmeeIssueEventSource({
|
||||
required this.issueTrackerClient,
|
||||
required this.gosmeeCommand,
|
||||
required this.commandExecutor,
|
||||
required AppLogger logger,
|
||||
}) : _logger = logger;
|
||||
|
||||
|
|
@ -20,10 +21,11 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
|
|||
|
||||
final IssueTrackerClient issueTrackerClient;
|
||||
final String gosmeeCommand;
|
||||
final ExternalCommandExecutor commandExecutor;
|
||||
final AppLogger _logger;
|
||||
final Map<String, _QueuedGitLabIssue> _queuedIssues = {};
|
||||
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, String> _trackerProjectsByProject = {};
|
||||
|
||||
|
|
@ -107,7 +109,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
|
|||
'gitlab/gosmee project=${project.key} start_client '
|
||||
'command="$gosmeeCommand client $publicUrl $localUrl"',
|
||||
);
|
||||
final process = await startExternalCommand(gosmeeCommand, [
|
||||
final process = await commandExecutor.start(gosmeeCommand, [
|
||||
'client',
|
||||
publicUrl,
|
||||
localUrl,
|
||||
|
|
@ -324,7 +326,7 @@ class GitLabGosmeeIssueEventSource implements IssueEventSource {
|
|||
}
|
||||
|
||||
Future<String> _createGosmeeChannelUrl(String localUrl) async {
|
||||
final result = await runExternalCommand(gosmeeCommand, [
|
||||
final result = await commandExecutor.run(gosmeeCommand, [
|
||||
'--output',
|
||||
'json',
|
||||
'client',
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ dependencies:
|
|||
git: ^2.3.2
|
||||
nyxx: ^6.8.1
|
||||
dart_eval: ^0.8.4
|
||||
executable: ^1.4.1
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.6.0
|
||||
drift_dev: ^2.28.1
|
||||
json_serializable: ">=6.11.1 <6.12.0"
|
||||
lints: ^6.0.0
|
||||
mocktail: ^1.0.5
|
||||
test: ^1.25.6
|
||||
|
||||
platforms:
|
||||
|
|
|
|||
|
|
@ -146,6 +146,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -285,6 +287,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -367,6 +371,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -210,6 +212,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -308,6 +312,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -437,6 +443,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -537,6 +545,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
final logMessages = <String>[];
|
||||
void captureLog(AppLogRecord record) {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -234,24 +236,21 @@ projects:
|
|||
await initGitRepo(repoDir);
|
||||
|
||||
// And Gitea issue data containing a comment with an explicit assistant mention.
|
||||
final teaScript = File(p.join(sandbox.path, 'tea'));
|
||||
final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl'));
|
||||
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||
await writeFakeTeaScript(
|
||||
teaScript: teaScript,
|
||||
final giteaServer = await FakeGiteaApiServer.start(
|
||||
issueListResponsesByRepo: {
|
||||
'owner/sample': [
|
||||
{
|
||||
'index': 12,
|
||||
'number': 12,
|
||||
'title': 'Need architecture help',
|
||||
'body': 'Please review the API layering.',
|
||||
'state': 'open',
|
||||
'url': 'https://gitea.example.test/owner/sample/issues/12',
|
||||
'html_url': 'https://gitea.example.test/owner/sample/issues/12',
|
||||
'updated_at': '2026-04-04T12:00:00Z',
|
||||
'poster': {'login': 'reporter'},
|
||||
'user': {'login': 'reporter'},
|
||||
'labels': [
|
||||
{'name': 'help'},
|
||||
],
|
||||
'pull_request': null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -261,22 +260,28 @@ projects:
|
|||
{
|
||||
'id': 50,
|
||||
'body': '@helper can you plan the architecture?',
|
||||
'url':
|
||||
'html_url':
|
||||
'https://gitea.example.test/owner/sample/issues/12#issuecomment-50',
|
||||
'created_at': '2026-04-04T12:00:00Z',
|
||||
'updated_at': '2026-04-04T12:00:00Z',
|
||||
'poster': {'login': 'reporter'},
|
||||
'user': {'login': 'reporter'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCommentIssueNumbersByRepo: {
|
||||
'owner/sample': {12},
|
||||
postCommentResponsesByRepo: {
|
||||
'owner/sample': {
|
||||
12: {
|
||||
'id': 701,
|
||||
'body': 'posted',
|
||||
'html_url':
|
||||
'https://gitea.example.test/owner/sample/issues/12#issuecomment-701',
|
||||
},
|
||||
},
|
||||
},
|
||||
teaLog: teaLog,
|
||||
postedBodyFile: postedBody,
|
||||
viewerLogin: 'tea-octocat',
|
||||
);
|
||||
addTearDown(giteaServer.close);
|
||||
|
||||
// And a responder that emits a planning reply.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
|
|
@ -307,37 +312,46 @@ projects:
|
|||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
defaultBranch: main
|
||||
''');
|
||||
await File(p.join(sandbox.path, '.env')).writeAsString('''
|
||||
CWS_GITEA_HOST=${giteaServer.baseUrl}
|
||||
CWS_GITEA_TOKEN=test-token
|
||||
''');
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
teaCommand: teaScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
await app.runOnce();
|
||||
await app.close();
|
||||
|
||||
// Then it reads issue comments and posts exactly one reply through tea for that issue.
|
||||
final logLines = await teaLog.readAsLines();
|
||||
// Then it reads issue comments and posts exactly one reply through the Gitea API for that issue.
|
||||
expect(
|
||||
logLines
|
||||
giteaServer.requestPaths
|
||||
.where(
|
||||
(line) => line.contains('repos/owner/sample/issues?state=open'),
|
||||
(path) => path.contains('/api/v1/repos/owner/sample/issues?'),
|
||||
)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
logLines
|
||||
giteaServer.requestPaths
|
||||
.where(
|
||||
(line) => line.contains('repos/owner/sample/issues/12/comments'),
|
||||
(path) => path.contains(
|
||||
'/api/v1/repos/owner/sample/issues/12/comments',
|
||||
),
|
||||
)
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
final postedComment = await postedBody.readAsString();
|
||||
expect(postedComment, contains('via `tea`'));
|
||||
final postedComment =
|
||||
(jsonDecode(giteaServer.postedBodies.single)
|
||||
as Map<String, dynamic>)['body']
|
||||
as String;
|
||||
expect(postedComment, contains('via `gitea`'));
|
||||
expect(postedComment, contains('@tea-octocat'));
|
||||
expect(
|
||||
postedComment,
|
||||
|
|
@ -409,6 +423,8 @@ OP_CLI_TOKEN=test-token
|
|||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -84,6 +84,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
final logMessages = <String>[];
|
||||
void captureLog(AppLogRecord record) {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ projects:
|
|||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
gosmeeCommand: gosmeeScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the long-running app starts and receives the forwarded webhook.
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ projects:
|
|||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
glabCommand: glabScript.path,
|
||||
gosmeeCommand: gosmeeScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the long-running app starts and receives the forwarded webhook.
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ void registerIssueAssistantAppRunGosmeeTests() {
|
|||
/// Scenario: Reply to a mention received through tea/gosmee
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And a gosmee script that forwards one Gitea issue_comment webhook event
|
||||
/// And a tea script that can manage webhooks and post issue comments
|
||||
/// And a Gitea API fixture that can manage webhooks and post issue comments
|
||||
/// And a responder that emits a planning reply
|
||||
/// And an orchestrator-style config that uses tea/gosmee
|
||||
/// When the long-running app starts and receives the forwarded webhook
|
||||
/// Then it reads issue comments and posts exactly one reply for that issue
|
||||
/// And the tea webhook lifecycle commands are invoked for the Gitea project
|
||||
/// And the Gitea webhook lifecycle API is invoked for the project
|
||||
/// ```
|
||||
test('Reply to a mention received through tea/gosmee', () async {
|
||||
// Given a temporary project checkout that can be used as the local repo path.
|
||||
|
|
@ -70,62 +70,26 @@ void registerIssueAssistantAppRunGosmeeTests() {
|
|||
gosmeeLog: gosmeeLog,
|
||||
);
|
||||
|
||||
// And a tea script that can manage webhooks and post issue comments.
|
||||
final teaScript = File(p.join(sandbox.path, 'tea'));
|
||||
final teaLog = File(p.join(sandbox.path, 'tea-log.jsonl'));
|
||||
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||
await teaScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}"
|
||||
|
||||
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'login': 'tea-octocat'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then
|
||||
cat <<'JSON'
|
||||
[]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments?limit=100" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode(comments)}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then
|
||||
for arg in "\$@"; do
|
||||
if [[ "\$arg" == body=* ]]; then
|
||||
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}"
|
||||
fi
|
||||
done
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'id': 801, 'url': 'https://gitea.example.test/comment/801', 'body': 'posted'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'id': 81})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected tea args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(teaScript);
|
||||
// And a Gitea API fixture for issue reads and replies.
|
||||
final giteaServer = await FakeGiteaApiServer.start(
|
||||
issueListResponsesByRepo: {
|
||||
'owner/sample': const <Map<String, Object?>>[],
|
||||
},
|
||||
commentResponsesByRepo: {
|
||||
'owner/sample': {12: comments},
|
||||
},
|
||||
postCommentResponsesByRepo: {
|
||||
'owner/sample': {
|
||||
12: {
|
||||
'id': 801,
|
||||
'body': 'posted',
|
||||
'html_url': 'https://gitea.example.test/comment/801',
|
||||
},
|
||||
},
|
||||
},
|
||||
viewerLogin: 'tea-octocat',
|
||||
);
|
||||
addTearDown(giteaServer.close);
|
||||
|
||||
// And a responder that emits a planning reply.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
|
|
@ -156,18 +120,23 @@ projects:
|
|||
type: tea/gosmee
|
||||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
''');
|
||||
await File(p.join(sandbox.path, '.env')).writeAsString('''
|
||||
CWS_GITEA_HOST=${giteaServer.baseUrl}
|
||||
CWS_GITEA_TOKEN=test-token
|
||||
''');
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
gosmeeCommand: gosmeeScript.path,
|
||||
teaCommand: teaScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the long-running app starts and receives the forwarded webhook.
|
||||
final runFuture = app.run();
|
||||
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (await postedBody.exists()) {
|
||||
if (giteaServer.postedBodies.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
|
@ -176,38 +145,31 @@ projects:
|
|||
await runFuture;
|
||||
|
||||
// Then it reads issue comments and posts exactly one reply for that issue.
|
||||
expect(await postedBody.exists(), isTrue);
|
||||
final postedComment = await postedBody.readAsString();
|
||||
expect(giteaServer.postedBodies, hasLength(1));
|
||||
final postedComment =
|
||||
(jsonDecode(giteaServer.postedBodies.single)
|
||||
as Map<String, dynamic>)['body']
|
||||
as String;
|
||||
expect(
|
||||
postedComment,
|
||||
contains('Plan:\n- consume gosmee issue_comment events'),
|
||||
);
|
||||
expect(postedComment, contains('@tea-octocat'));
|
||||
|
||||
// And the tea webhook lifecycle commands are invoked for the Gitea project.
|
||||
final teaLogLines = await teaLog.readAsLines();
|
||||
// And the Gitea webhook lifecycle API is invoked for the project.
|
||||
expect(giteaServer.createdWebhooks, hasLength(1));
|
||||
expect(
|
||||
teaLogLines.any(
|
||||
(line) => line.contains(
|
||||
'webhooks create --type gitea --events issues,issue_comment --active -o json -r owner/sample https://gosmee.example.test/sample',
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
teaLogLines.any(
|
||||
(line) =>
|
||||
line.contains('webhooks delete --confirm -r owner/sample 81'),
|
||||
),
|
||||
isTrue,
|
||||
(giteaServer.createdWebhooks.single['config'] as Map)['url'],
|
||||
'https://gosmee.example.test/sample',
|
||||
);
|
||||
expect(giteaServer.deletedWebhookIds, contains(81));
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Reconcile a missed Gitea gosmee event through polling
|
||||
/// Given a temporary project checkout that can be used as the local repo path
|
||||
/// And a gosmee script that starts forwarding without delivering an event
|
||||
/// And a tea script that returns a new issue only on a later reconciliation poll
|
||||
/// And a Gitea API fixture that returns a new issue only on a later reconciliation poll
|
||||
/// And a responder that emits a planning reply
|
||||
/// And an orchestrator-style config that uses tea/gosmee with continuous reconciliation
|
||||
/// When the long-running app starts and enough time passes for reconciliation polling
|
||||
|
|
@ -225,31 +187,12 @@ projects:
|
|||
// And a gosmee script that starts forwarding without delivering an event.
|
||||
final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
|
||||
final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl'));
|
||||
await gosmeeScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >> "${bashScriptPath(gosmeeLog.path)}"
|
||||
registerFakeGosmeeCommand(
|
||||
command: gosmeeScript.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 = {
|
||||
'index': 18,
|
||||
'title': 'Need reconciliation after a missed webhook',
|
||||
|
|
@ -271,70 +214,30 @@ exit 1
|
|||
'poster': {'login': 'reporter'},
|
||||
},
|
||||
];
|
||||
await teaScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}"
|
||||
|
||||
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'login': 'tea-octocat'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then
|
||||
count=0
|
||||
if [[ -f "${bashScriptPath(issueListCount.path)}" ]]; then
|
||||
count="\$(cat "${bashScriptPath(issueListCount.path)}")"
|
||||
fi
|
||||
count=\$((count + 1))
|
||||
printf '%s' "\$count" > "${bashScriptPath(issueListCount.path)}"
|
||||
if [[ "\$count" -eq 1 ]]; then
|
||||
cat <<'JSON'
|
||||
[]
|
||||
JSON
|
||||
else
|
||||
cat <<'JSON'
|
||||
${jsonEncode([issue])}
|
||||
JSON
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?limit=100" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode(comments)}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then
|
||||
for arg in "\$@"; do
|
||||
if [[ "\$arg" == body=* ]]; then
|
||||
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}"
|
||||
fi
|
||||
done
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'id': 802, 'url': 'https://gitea.example.test/comment/802', 'body': 'posted'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'id': 82})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "webhooks" && "\$2" == "delete" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected tea args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(teaScript);
|
||||
// And a Gitea API fixture that returns a new issue only on a later reconciliation poll.
|
||||
final giteaServer = await FakeGiteaApiServer.start(
|
||||
issueListResponseSequenceByRepo: {
|
||||
'owner/sample': [
|
||||
const <Map<String, Object?>>[],
|
||||
[issue],
|
||||
],
|
||||
},
|
||||
commentResponsesByRepo: {
|
||||
'owner/sample': {18: comments},
|
||||
},
|
||||
postCommentResponsesByRepo: {
|
||||
'owner/sample': {
|
||||
18: {
|
||||
'id': 802,
|
||||
'body': 'posted',
|
||||
'html_url': 'https://gitea.example.test/comment/802',
|
||||
},
|
||||
},
|
||||
},
|
||||
viewerLogin: 'tea-octocat',
|
||||
);
|
||||
addTearDown(giteaServer.close);
|
||||
|
||||
// And a responder that emits a planning reply.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
|
|
@ -366,18 +269,23 @@ projects:
|
|||
reconcileInterval: 1s
|
||||
repo: owner/sample
|
||||
path: ${repoDir.path}
|
||||
''');
|
||||
await File(p.join(sandbox.path, '.env')).writeAsString('''
|
||||
CWS_GITEA_HOST=${giteaServer.baseUrl}
|
||||
CWS_GITEA_TOKEN=test-token
|
||||
''');
|
||||
final app = await IssueAssistantApp.open(
|
||||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
gosmeeCommand: gosmeeScript.path,
|
||||
teaCommand: teaScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||
final runFuture = app.run();
|
||||
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||
if (await postedBody.exists()) {
|
||||
if (giteaServer.postedBodies.isNotEmpty) {
|
||||
break;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
|
@ -386,21 +294,19 @@ projects:
|
|||
await runFuture;
|
||||
|
||||
// Then it posts exactly one reply for the issue discovered by polling.
|
||||
expect(await postedBody.exists(), isTrue);
|
||||
final postedComment = await postedBody.readAsString();
|
||||
expect(giteaServer.postedBodies, hasLength(1));
|
||||
final postedComment =
|
||||
(jsonDecode(giteaServer.postedBodies.single)
|
||||
as Map<String, dynamic>)['body']
|
||||
as String;
|
||||
expect(
|
||||
postedComment,
|
||||
contains('Plan:\n- reconcile missed gosmee events through polling'),
|
||||
);
|
||||
|
||||
// And the app performs more than one Gitea issue listing request while running.
|
||||
final teaLogLines = await teaLog.readAsLines();
|
||||
expect(
|
||||
teaLogLines
|
||||
.where(
|
||||
(line) => line.contains('repos/owner/sample/issues?state=open'),
|
||||
)
|
||||
.length,
|
||||
giteaServer.issueListRequestCount('owner/sample'),
|
||||
greaterThanOrEqualTo(2),
|
||||
);
|
||||
final gosmeeLogLines = await gosmeeLog.readAsLines();
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// 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.
|
||||
final ghScript = File(p.join(sandbox.path, 'gh'));
|
||||
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then
|
||||
echo "Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)" >&2
|
||||
while true; do
|
||||
sleep 60
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'login': 'octocat'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
|
||||
cat <<'JSON'
|
||||
{"total_count":0,"incomplete_results":false,"items":[]}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected gh args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(ghScript);
|
||||
fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async {
|
||||
if (arguments.length >= 2 &&
|
||||
arguments[0] == 'api' &&
|
||||
arguments[1] == 'user') {
|
||||
return ProcessResult(0, 0, '{"login":"octocat"}', '');
|
||||
}
|
||||
if (arguments.length >= 4 &&
|
||||
arguments[0] == 'api' &&
|
||||
arguments[3] == 'search/issues') {
|
||||
return ProcessResult(
|
||||
0,
|
||||
0,
|
||||
'{"total_count":0,"incomplete_results":false,"items":[]}',
|
||||
'',
|
||||
);
|
||||
}
|
||||
return ProcessResult(
|
||||
0,
|
||||
1,
|
||||
'',
|
||||
'unexpected gh args: ${arguments.join(' ')}\n',
|
||||
);
|
||||
};
|
||||
fakeStartedCommandHandlers[ghScript.path] = (arguments) async {
|
||||
final process = FakeStartedExternalCommand()
|
||||
..addStderr(
|
||||
'Error: error creating webhook: HTTP 422: Validation Failed (https://api.github.com/repos/existedinnettw/code_work_spawner/hooks)\n',
|
||||
);
|
||||
return process;
|
||||
};
|
||||
|
||||
// And an orchestrator-style config that uses gh/webhook-forward.
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
|
|
@ -200,6 +204,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
final records = <AppLogRecord>[];
|
||||
void captureLog(AppLogRecord record) {
|
||||
|
|
@ -284,69 +290,85 @@ projects:
|
|||
'user': {'login': 'reporter'},
|
||||
},
|
||||
];
|
||||
await ghScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "\$*" >> "${bashScriptPath(ghLog.path)}"
|
||||
|
||||
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
|
||||
cat <<'JSON'
|
||||
${jsonEncode({'login': 'octocat'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
|
||||
count=0
|
||||
if [[ -f "${bashScriptPath(searchCountFile.path)}" ]]; then
|
||||
count="\$(cat "${bashScriptPath(searchCountFile.path)}")"
|
||||
fi
|
||||
count=\$((count + 1))
|
||||
printf '%s' "\$count" > "${bashScriptPath(searchCountFile.path)}"
|
||||
if [[ "\$count" -eq 1 ]]; then
|
||||
cat <<'JSON'
|
||||
{"total_count":0,"incomplete_results":false,"items":[]}
|
||||
JSON
|
||||
else
|
||||
cat <<'JSON'
|
||||
${jsonEncode({
|
||||
fakeCommandHandlers[ghScript.path] = (arguments, {timeout}) async {
|
||||
await ghLog.writeAsString(
|
||||
'${arguments.join(' ')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
if (arguments.length >= 2 &&
|
||||
arguments[0] == 'api' &&
|
||||
arguments[1] == 'user') {
|
||||
return ProcessResult(0, 0, '{"login":"octocat"}', '');
|
||||
}
|
||||
final path = arguments.length > 3 ? arguments[3] : '';
|
||||
if (arguments.length >= 4 &&
|
||||
arguments[0] == 'api' &&
|
||||
path == 'search/issues') {
|
||||
var count = 0;
|
||||
if (await searchCountFile.exists()) {
|
||||
count = int.parse(await searchCountFile.readAsString());
|
||||
}
|
||||
count += 1;
|
||||
await searchCountFile.writeAsString(count.toString());
|
||||
return ProcessResult(
|
||||
0,
|
||||
0,
|
||||
jsonEncode(
|
||||
count == 1
|
||||
? {
|
||||
'total_count': 0,
|
||||
'incomplete_results': false,
|
||||
'items': <Object?>[],
|
||||
}
|
||||
: {
|
||||
'total_count': 1,
|
||||
'incomplete_results': false,
|
||||
'items': [searchItem],
|
||||
})}
|
||||
JSON
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments?per_page=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': 703, 'html_url': 'https://example.test/comment/703', 'body': 'posted'})}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "\$1" == "webhook" && "\$2" == "forward" ]]; then
|
||||
while true; do
|
||||
sleep 60
|
||||
done
|
||||
fi
|
||||
|
||||
echo "unexpected gh args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(ghScript);
|
||||
},
|
||||
),
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (arguments.length >= 4 &&
|
||||
arguments[0] == 'api' &&
|
||||
path == 'repos/owner/sample/issues/18/comments?per_page=100') {
|
||||
return ProcessResult(0, 0, jsonEncode(comments), '');
|
||||
}
|
||||
if (arguments.length >= 4 &&
|
||||
arguments[0] == 'api' &&
|
||||
path == 'repos/owner/sample/issues/18/comments') {
|
||||
final body = arguments
|
||||
.where((argument) => argument.startsWith('body='))
|
||||
.map((argument) => argument.substring('body='.length))
|
||||
.firstOrNull;
|
||||
if (body != null) {
|
||||
await postedBody.writeAsString(body);
|
||||
}
|
||||
return ProcessResult(
|
||||
0,
|
||||
0,
|
||||
jsonEncode({
|
||||
'id': 703,
|
||||
'html_url': 'https://example.test/comment/703',
|
||||
'body': 'posted',
|
||||
}),
|
||||
'',
|
||||
);
|
||||
}
|
||||
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.
|
||||
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
|
||||
|
|
@ -383,6 +405,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
|
|
@ -144,7 +146,10 @@ void main() {
|
|||
postedBodyFile: postedBodyFile,
|
||||
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.
|
||||
final issues = await client.fetchUpdatedIssuesForRepos(
|
||||
|
|
@ -189,72 +194,282 @@ void main() {
|
|||
/// Feature: Gitea tracker client integration
|
||||
///
|
||||
/// As a maintainer debugging Gitea tracker failures
|
||||
/// I want tea-specific API and webhook errors to preserve the original cause
|
||||
/// So that misconfigured repos and tea output changes are easy to trace
|
||||
/// I want issue operations to use the Gitea HTTP API directly
|
||||
/// So that issue polling and replies do not depend on `tea api`
|
||||
/// ```
|
||||
group('IssueTrackerClient Gitea', () {
|
||||
setUp(() {
|
||||
AppEnvironment.resetForTest();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
AppEnvironment.resetForTest();
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Read a webhook id from tea plain-text success output
|
||||
/// Given a fake tea executable whose webhook create command prints plain text instead of JSON
|
||||
/// Scenario: Create a Gitea webhook through the HTTP API
|
||||
/// Given a local Gitea API server that accepts webhook creation requests
|
||||
/// When the tracker client creates a Gitea webhook
|
||||
/// Then it still extracts and returns the created webhook id
|
||||
/// Then it returns the created webhook id
|
||||
/// And the webhook options are sent to the Gitea API
|
||||
/// ```
|
||||
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
|
||||
printf '%s\n' 'Webhook created successfully (ID: 40)'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected tea args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(teaScript);
|
||||
final client = IssueTrackerClient(teaCommand: teaScript.path);
|
||||
test('Create a Gitea webhook through the HTTP API', () async {
|
||||
// Given a local Gitea API server that accepts webhook creation requests.
|
||||
final giteaServer = await FakeGiteaApiServer.start();
|
||||
_configureGiteaTestEnvironmentFromBaseUrl(giteaServer.baseUrl);
|
||||
final client = IssueTrackerClient();
|
||||
|
||||
try {
|
||||
// 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);
|
||||
// Then it returns the created webhook id.
|
||||
expect(webhookId, 81);
|
||||
|
||||
// And the webhook options are sent to the Gitea API.
|
||||
expect(giteaServer.createdWebhooks, hasLength(1));
|
||||
expect(giteaServer.createdWebhooks.single['type'], 'gitea');
|
||||
expect(giteaServer.createdWebhooks.single['active'], isTrue);
|
||||
expect(giteaServer.createdWebhooks.single['events'], [
|
||||
'issues',
|
||||
'issue_comment',
|
||||
]);
|
||||
expect(
|
||||
(giteaServer.createdWebhooks.single['config'] as Map)['url'],
|
||||
'https://gosmee.example.test/sample',
|
||||
);
|
||||
} finally {
|
||||
await giteaServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Surface Gitea API error details when issue listing returns an error object
|
||||
/// Given a fake tea executable whose issue list request returns a Gitea error object
|
||||
/// 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,
|
||||
trackerProjects: const <String>['owner/sample'],
|
||||
since: null,
|
||||
);
|
||||
final thread = await client.fetchThread(
|
||||
provider: IssueTrackerProvider.gitea,
|
||||
trackerProject: 'owner/sample',
|
||||
issue: issues.single,
|
||||
);
|
||||
final login = await client.getAuthenticatedLogin(
|
||||
provider: IssueTrackerProvider.gitea,
|
||||
);
|
||||
final posted = await client.createIssueComment(
|
||||
provider: IssueTrackerProvider.gitea,
|
||||
trackerProject: 'owner/sample',
|
||||
issueNumber: 12,
|
||||
body: 'Reply from gitea api',
|
||||
);
|
||||
|
||||
// Then the Gitea issue payload is normalized into the shared issue summary model.
|
||||
expect(login, 'gitea-user');
|
||||
expect(issues.single.trackerProject, 'owner/sample');
|
||||
expect(issues.single.number, 12);
|
||||
expect(issues.single.state, 'open');
|
||||
expect(issues.single.labels, ['enhancement']);
|
||||
expect(thread.comments.single.url, contains('#issuecomment-501'));
|
||||
|
||||
// And the posted comment response is returned to the caller.
|
||||
expect(postedBody, 'Reply from gitea api');
|
||||
expect(posted.id, 901);
|
||||
expect(posted.url, contains('#issuecomment-901'));
|
||||
expect(posted.body, 'posted');
|
||||
} finally {
|
||||
await server.close(force: true);
|
||||
}
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Reuse a tea login from the Windows app data config path
|
||||
/// Given a tea config file under LOCALAPPDATA with a default Gitea login
|
||||
/// When the tracker client resolves the authenticated Gitea login
|
||||
/// Then it calls the Gitea HTTP API using the host and token from that config file
|
||||
/// ```
|
||||
test('Reuse a tea login from the Windows app data config path', () async {
|
||||
// Given a tea config file under LOCALAPPDATA with a default Gitea login.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-tea-config-');
|
||||
final localAppData = Directory(p.join(sandbox.path, 'AppData', 'Local'));
|
||||
final teaConfigDir = Directory(p.join(localAppData.path, 'tea'));
|
||||
await teaConfigDir.create(recursive: true);
|
||||
|
||||
late final HttpServer server;
|
||||
server = await _startGiteaApiServer((request) async {
|
||||
expect(request.uri.path, '/api/v1/user');
|
||||
expect(
|
||||
request.headers.value(HttpHeaders.authorizationHeader),
|
||||
'token config-token',
|
||||
);
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write(jsonEncode(<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 object',
|
||||
'Surface Gitea API error details when issue listing returns an error response',
|
||||
() async {
|
||||
// Given a fake tea executable whose issue list request returns a Gitea error object.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-');
|
||||
final teaScript = File(p.join(sandbox.path, 'tea'));
|
||||
await teaScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then
|
||||
cat <<'JSON'
|
||||
{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"}
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected tea args: \$*" >&2
|
||||
exit 1
|
||||
''');
|
||||
await makeScriptExecutable(teaScript);
|
||||
final client = IssueTrackerClient(teaCommand: teaScript.path);
|
||||
// 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,
|
||||
|
|
@ -270,14 +485,114 @@ exit 1
|
|||
(error) => error.message,
|
||||
'message',
|
||||
allOf(
|
||||
contains('owner/sample'),
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -252,6 +254,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
|
@ -18,15 +19,15 @@ Future<void> writeFakeGhScript({
|
|||
String? viewerLogin,
|
||||
bool failViewerLookup = false,
|
||||
}) async {
|
||||
final normalizedIssueListResponsesByRepo =
|
||||
final issueLists =
|
||||
issueListResponsesByRepo ??
|
||||
<String, List<Map<String, Object?>>>{'owner/sample': issueListResponse};
|
||||
final normalizedCommentResponsesByRepo =
|
||||
final comments =
|
||||
commentResponsesByRepo ??
|
||||
<String, Map<int, List<Map<String, Object?>>>>{
|
||||
'owner/sample': commentResponses,
|
||||
};
|
||||
final normalizedPostCommentIssueNumbersByRepo =
|
||||
final postIssueNumbers =
|
||||
postCommentIssueNumbersByRepo ??
|
||||
<String, Set<int>>{
|
||||
'owner/sample': {
|
||||
|
|
@ -35,7 +36,7 @@ Future<void> writeFakeGhScript({
|
|||
},
|
||||
};
|
||||
final searchItems =
|
||||
normalizedIssueListResponsesByRepo.entries
|
||||
issueLists.entries
|
||||
.expand(
|
||||
(entry) => entry.value.map(
|
||||
(issue) => <String, Object?>{
|
||||
|
|
@ -53,168 +54,28 @@ Future<void> writeFakeGhScript({
|
|||
final openSearchItems = searchItems
|
||||
.where((item) => item['state'] == 'open')
|
||||
.toList(growable: false);
|
||||
final allSearchResponse = jsonEncode({
|
||||
|
||||
_registerFakeGhCommand(
|
||||
command: ghScript.path,
|
||||
ghLog: ghLog,
|
||||
failViewerLookup: failViewerLookup,
|
||||
viewerLogin: viewerLogin,
|
||||
searchResponse: {
|
||||
'total_count': searchItems.length,
|
||||
'incomplete_results': false,
|
||||
'items': searchItems,
|
||||
});
|
||||
final openSearchResponse = jsonEncode({
|
||||
},
|
||||
openSearchResponse: {
|
||||
'total_count': openSearchItems.length,
|
||||
'incomplete_results': false,
|
||||
'items': openSearchItems,
|
||||
});
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
if (ghLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(ghLog.path)}"',
|
||||
},
|
||||
issueLists: issueLists,
|
||||
comments: comments,
|
||||
postIssueNumbers: postIssueNumbers,
|
||||
postedBodyFile: postedBodyFile,
|
||||
reactionBodyFile: reactionBodyFile,
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
if (failViewerLookup) {
|
||||
buffer
|
||||
..writeln(r''' echo "viewer lookup failed" >&2''')
|
||||
..writeln(' exit 1')
|
||||
..writeln('fi');
|
||||
} else {
|
||||
buffer
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
|
||||
..writeln(r''' query=""''')
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' if [[ "$arg" == q=* ]]; then''')
|
||||
..writeln(r''' query="${arg#q=}"''')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(r''' done''')
|
||||
..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(openSearchResponse)
|
||||
..writeln('JSON')
|
||||
..writeln(r''' else''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(allSearchResponse)
|
||||
..writeln('JSON')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
for (final entry in normalizedIssueListResponsesByRepo.entries) {
|
||||
final repo = entry.key;
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(entry.value))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
|
||||
for (final repoEntry in normalizedCommentResponsesByRepo.entries) {
|
||||
final repo = repoEntry.key;
|
||||
for (final entry in repoEntry.value.entries) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(entry.value))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
}
|
||||
|
||||
for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) {
|
||||
final repo = repoEntry.key;
|
||||
for (final issueNumber in repoEntry.value) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
|
||||
)
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' :''');
|
||||
if (postedBodyFile != null) {
|
||||
buffer
|
||||
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||
..writeln(
|
||||
r''' printf '%s' "${arg#body=}" > '''
|
||||
'"${bashScriptPath(postedBodyFile.path)}"',
|
||||
)
|
||||
..writeln(r''' fi''');
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
'id': 700,
|
||||
'html_url': 'https://example.test/comment/700',
|
||||
'body': 'posted',
|
||||
}),
|
||||
)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
}
|
||||
|
||||
for (final repoEntry in normalizedCommentResponsesByRepo.entries) {
|
||||
final repo = repoEntry.key;
|
||||
for (final issueEntry in repoEntry.value.entries) {
|
||||
for (final comment in issueEntry.value) {
|
||||
final commentId = comment['id'];
|
||||
if (commentId is! int) {
|
||||
continue;
|
||||
}
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/comments/$commentId/reactions" ]]; then',
|
||||
)
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' :''');
|
||||
if (reactionBodyFile != null) {
|
||||
buffer
|
||||
..writeln(r''' if [[ "$arg" == content=* ]]; then''')
|
||||
..writeln(
|
||||
r''' printf '%s' "${arg#content=}" > '''
|
||||
'"${bashScriptPath(reactionBodyFile.path)}"',
|
||||
)
|
||||
..writeln(r''' fi''');
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'id': 990, 'content': 'eyes'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await ghScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(ghScript);
|
||||
}
|
||||
|
||||
Future<void> writeFlakySearchGhScript({
|
||||
|
|
@ -232,68 +93,20 @@ Future<void> writeFlakySearchGhScript({
|
|||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
final searchResponse = jsonEncode({
|
||||
|
||||
_registerFakeGhCommand(
|
||||
command: ghScript.path,
|
||||
ghLog: ghLog,
|
||||
searchResponse: {
|
||||
'total_count': searchItems.length,
|
||||
'incomplete_results': false,
|
||||
'items': searchItems,
|
||||
});
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
if (ghLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(ghLog.path)}"',
|
||||
},
|
||||
flakySearchStateFile: failureStateFile,
|
||||
issueLists: const {},
|
||||
comments: {'owner/sample': commentResponses},
|
||||
postIssueNumbers: const {},
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'login': 'test-user'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
|
||||
..writeln(
|
||||
' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then',
|
||||
)
|
||||
..writeln(' touch "${bashScriptPath(failureStateFile.path)}"')
|
||||
..writeln(r''' echo "error connecting to api.github.com" >&2''')
|
||||
..writeln(
|
||||
r''' echo "check your internet connection or https://githubstatus.com" >&2''',
|
||||
)
|
||||
..writeln(' exit 1')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(searchResponse)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
for (final entry in commentResponses.entries) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(entry.value))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await ghScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(ghScript);
|
||||
}
|
||||
|
||||
Future<void> writeWebhookForwardGhScript({
|
||||
|
|
@ -307,12 +120,6 @@ Future<void> writeWebhookForwardGhScript({
|
|||
String? viewerLogin,
|
||||
}) async {
|
||||
final issueNumber = issue['number'] as int;
|
||||
final payload = {
|
||||
'action': 'created',
|
||||
'issue': issue,
|
||||
'comment': comments.last,
|
||||
'repository': {'full_name': repo},
|
||||
};
|
||||
final searchItems = issueListResponse
|
||||
.map(
|
||||
(searchIssue) => <String, Object?>{
|
||||
|
|
@ -321,109 +128,31 @@ Future<void> writeWebhookForwardGhScript({
|
|||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
final searchResponse = jsonEncode({
|
||||
|
||||
_registerFakeGhCommand(
|
||||
command: ghScript.path,
|
||||
ghLog: ghLog,
|
||||
viewerLogin: viewerLogin,
|
||||
searchResponse: {
|
||||
'total_count': searchItems.length,
|
||||
'incomplete_results': false,
|
||||
'items': searchItems,
|
||||
});
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
if (ghLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(ghLog.path)}"',
|
||||
},
|
||||
issueLists: const {},
|
||||
comments: {
|
||||
repo: {issueNumber: comments},
|
||||
},
|
||||
postIssueNumbers: {
|
||||
repo: {issueNumber},
|
||||
},
|
||||
postedBodyFile: postedBodyFile,
|
||||
webhookPayload: {
|
||||
'action': 'created',
|
||||
'issue': issue,
|
||||
'comment': comments.last,
|
||||
'repository': {'full_name': repo},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
buffer
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(searchResponse)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(comments))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
|
||||
)
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' :''');
|
||||
if (postedBodyFile != null) {
|
||||
buffer
|
||||
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||
..writeln(
|
||||
r''' printf '%s' "${arg#body=}" > '''
|
||||
'"${bashScriptPath(postedBodyFile.path)}"',
|
||||
)
|
||||
..writeln(r''' fi''');
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
'id': 702,
|
||||
'html_url': 'https://example.test/comment/702',
|
||||
'body': 'posted',
|
||||
}),
|
||||
)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "webhook" && "$2" == "forward" ]]; then''')
|
||||
..writeln(r''' url=""''')
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' if [[ "$arg" == --url=* ]]; then''')
|
||||
..writeln(r''' url="${arg#--url=}"''')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(r''' done''')
|
||||
..writeln(r''' if [[ -z "$url" ]]; then''')
|
||||
..writeln(r''' echo "missing --url" >&2''')
|
||||
..writeln(' exit 1')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(r''' curl -sS -X POST \''')
|
||||
..writeln(r''' -H "Content-Type: application/json" \''')
|
||||
..writeln(r''' -H "X-GitHub-Event: issue_comment" \''')
|
||||
..writeln(r''' --data-binary @- "$url" <<'JSON' >/dev/null''')
|
||||
..writeln(jsonEncode(payload))
|
||||
..writeln('JSON')
|
||||
..writeln(r''' while true; do''')
|
||||
..writeln(r''' sleep 60''')
|
||||
..writeln(r''' done''')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await ghScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(ghScript);
|
||||
}
|
||||
|
||||
Future<void> writeGitHubGosmeeGhScript({
|
||||
|
|
@ -445,114 +174,234 @@ Future<void> writeGitHubGosmeeGhScript({
|
|||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
final searchResponse = jsonEncode({
|
||||
|
||||
_registerFakeGhCommand(
|
||||
command: ghScript.path,
|
||||
ghLog: ghLog,
|
||||
viewerLogin: viewerLogin,
|
||||
searchResponse: {
|
||||
'total_count': searchItems.length,
|
||||
'incomplete_results': false,
|
||||
'items': searchItems,
|
||||
});
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
},
|
||||
issueLists: const {},
|
||||
comments: {
|
||||
repo: {issueNumber: comments},
|
||||
},
|
||||
postIssueNumbers: {
|
||||
repo: {issueNumber},
|
||||
},
|
||||
postedBodyFile: postedBodyFile,
|
||||
webhookIdsByRepo: {repo: webhookId},
|
||||
);
|
||||
}
|
||||
|
||||
void _registerFakeGhCommand({
|
||||
required String command,
|
||||
required Map<String, Object?> searchResponse,
|
||||
required Map<String, List<Map<String, Object?>>> issueLists,
|
||||
required Map<String, Map<int, List<Map<String, Object?>>>> comments,
|
||||
required Map<String, Set<int>> postIssueNumbers,
|
||||
File? ghLog,
|
||||
bool failViewerLookup = false,
|
||||
String? viewerLogin,
|
||||
Map<String, Object?>? openSearchResponse,
|
||||
File? postedBodyFile,
|
||||
File? reactionBodyFile,
|
||||
File? flakySearchStateFile,
|
||||
Map<String, Object?>? webhookPayload,
|
||||
Map<String, int>? webhookIdsByRepo,
|
||||
}) {
|
||||
if (webhookPayload != null) {
|
||||
fakeStartedCommandHandlers[command] = (arguments) async {
|
||||
if (ghLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(ghLog.path)}"',
|
||||
await _appendLine(ghLog, '${arguments.join(' ')}\n');
|
||||
}
|
||||
final process = FakeStartedExternalCommand();
|
||||
final targetUrl = _argumentValue(arguments, '--url=');
|
||||
if (targetUrl != null) {
|
||||
unawaited(_postGitHubWebhook(targetUrl, webhookPayload));
|
||||
}
|
||||
return process;
|
||||
};
|
||||
}
|
||||
|
||||
fakeCommandHandlers[command] = (arguments, {timeout}) async {
|
||||
if (ghLog != null) {
|
||||
await _appendLine(ghLog, '${arguments.join(' ')}\n');
|
||||
}
|
||||
|
||||
ProcessResult result(
|
||||
Object? stdout, {
|
||||
int exitCode = 0,
|
||||
String stderr = '',
|
||||
}) {
|
||||
final stdoutText = stdout is String ? stdout : jsonEncode(stdout);
|
||||
return ProcessResult(0, exitCode, stdoutText, stderr);
|
||||
}
|
||||
|
||||
if (_argsMatch(arguments, ['api', 'user'])) {
|
||||
if (failViewerLookup) {
|
||||
return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
|
||||
}
|
||||
return result({'login': viewerLogin ?? 'test-user'});
|
||||
}
|
||||
|
||||
if (_argsMatchAt(arguments, 0, 'api') &&
|
||||
_argsMatchAt(arguments, 3, 'search/issues')) {
|
||||
if (flakySearchStateFile != null &&
|
||||
!await flakySearchStateFile.exists()) {
|
||||
await flakySearchStateFile.writeAsString('failed-once');
|
||||
return result(
|
||||
'',
|
||||
exitCode: 1,
|
||||
stderr:
|
||||
'error connecting to api.github.com\ncheck your internet connection or https://githubstatus.com\n',
|
||||
);
|
||||
}
|
||||
final query = arguments
|
||||
.where((argument) => argument.startsWith('q='))
|
||||
.map((argument) => argument.substring(2))
|
||||
.firstOrNull;
|
||||
return result(
|
||||
query?.contains('state:open') ?? false
|
||||
? (openSearchResponse ?? searchResponse)
|
||||
: searchResponse,
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
buffer
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(searchResponse)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/$issueNumber/comments?per_page=100" ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(comments))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln('if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks" ]]; then')
|
||||
..writeln(r''' active_flag=""''')
|
||||
..writeln(r''' for ((i = 1; i <= $#; i += 1)); do''')
|
||||
..writeln(r''' if [[ "${!i}" == "active=true" ]]; then''')
|
||||
..writeln(r''' prev_index=$((i - 1))''')
|
||||
..writeln(r''' active_flag="${!prev_index}"''')
|
||||
..writeln(r''' break''')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(r''' done''')
|
||||
..writeln(
|
||||
r''' if [[ "$active_flag" != "-F" && "$active_flag" != "--field" ]]; then''',
|
||||
)
|
||||
..writeln(
|
||||
r''' echo "expected active=true to be sent with -F/--field, got: $*" >&2''',
|
||||
)
|
||||
..writeln(r''' exit 1''')
|
||||
..writeln(r''' fi''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'id': webhookId}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"repos/$repo/issues/$issueNumber/comments" ]]; then',
|
||||
)
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' :''');
|
||||
if (postedBodyFile != null) {
|
||||
buffer
|
||||
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||
..writeln(
|
||||
r''' printf '%s' "${arg#body=}" > '''
|
||||
'"${bashScriptPath(postedBodyFile.path)}"',
|
||||
)
|
||||
..writeln(r''' fi''');
|
||||
for (final entry in issueLists.entries) {
|
||||
final path = 'repos/${entry.key}/issues?';
|
||||
if (_argsMatchAt(arguments, 0, 'api') &&
|
||||
arguments.length > 3 &&
|
||||
arguments[3].startsWith(path)) {
|
||||
return result(entry.value);
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
'id': 703,
|
||||
'html_url': 'https://example.test/comment/703',
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == "repos/$repo/hooks/$webhookId" ]]; then',
|
||||
)
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected gh args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await ghScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(ghScript);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ Future<void> writeFakeGlabScript({
|
|||
bool failViewerLookup = false,
|
||||
Map<String, int>? webhookIdsByRepo,
|
||||
}) async {
|
||||
final normalizedPostCommentIssueNumbersByRepo =
|
||||
final postIssueNumbers =
|
||||
postCommentIssueNumbersByRepo ??
|
||||
<String, Set<int>>{
|
||||
for (final entry in issueListResponsesByRepo.entries)
|
||||
|
|
@ -24,123 +24,110 @@ Future<void> writeFakeGlabScript({
|
|||
.whereType<int>()
|
||||
.toSet(),
|
||||
};
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
fakeCommandHandlers[glabScript.path] = (arguments, {timeout}) async {
|
||||
if (glabLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(glabLog.path)}"',
|
||||
await glabLog.writeAsString(
|
||||
'${arguments.join(' ')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
ProcessResult result(
|
||||
Object? stdout, {
|
||||
int exitCode = 0,
|
||||
String stderr = '',
|
||||
}) {
|
||||
return ProcessResult(
|
||||
0,
|
||||
exitCode,
|
||||
stdout is String ? stdout : jsonEncode(stdout),
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) {
|
||||
if (failViewerLookup) {
|
||||
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');
|
||||
return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
|
||||
}
|
||||
return result({'username': viewerLogin ?? 'glab-user'});
|
||||
}
|
||||
|
||||
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');
|
||||
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) {
|
||||
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');
|
||||
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 normalizedPostCommentIssueNumbersByRepo.entries) {
|
||||
for (final repoEntry in postIssueNumbers.entries) {
|
||||
final repo = Uri.encodeComponent(repoEntry.key);
|
||||
for (final issueNumber in repoEntry.value) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"projects/$repo/issues/$issueNumber/notes" ]]; 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''');
|
||||
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);
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
return result({
|
||||
'id': 901,
|
||||
'body': 'posted',
|
||||
'noteable_url':
|
||||
'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);
|
||||
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');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected glab args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await glabScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(glabScript);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
|
@ -9,55 +10,94 @@ Future<void> writeGosmeeForwardScript({
|
|||
required Map<String, Object?> payload,
|
||||
File? gosmeeLog,
|
||||
}) async {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
if (gosmeeLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(gosmeeLog.path)}"',
|
||||
registerFakeGosmeeCommand(
|
||||
command: gosmeeScript.path,
|
||||
publicUrl: publicUrl,
|
||||
payload: payload,
|
||||
gosmeeLog: gosmeeLog,
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(
|
||||
r'''if [[ "$1" == "--output" && "$2" == "json" && "$3" == "client" && "$4" == "--new-url" ]]; then''',
|
||||
)
|
||||
..writeln(
|
||||
r''' printf '%s\n' '''
|
||||
"'$publicUrl'",
|
||||
)
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
|
||||
buffer
|
||||
..writeln(r'''if [[ "$1" == "client" ]]; then''')
|
||||
..writeln(r''' url="$2"''')
|
||||
..writeln(r''' target="$3"''')
|
||||
..writeln(
|
||||
r''' if [[ "$url" != '''
|
||||
"'$publicUrl'"
|
||||
r''' ]]; then''',
|
||||
)
|
||||
..writeln(r''' echo "unexpected gosmee url: $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-Gitea-Event: issue_comment" \''')
|
||||
..writeln(r''' --data-binary @- "$target" <<'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 gosmee args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await gosmeeScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(gosmeeScript);
|
||||
}
|
||||
|
||||
void registerFakeGosmeeCommand({
|
||||
required String command,
|
||||
required String publicUrl,
|
||||
Map<String, Object?>? payload,
|
||||
File? gosmeeLog,
|
||||
String webhookHeader = 'X-Gitea-Event',
|
||||
}) {
|
||||
fakeCommandHandlers[command] = (arguments, {timeout}) async {
|
||||
if (gosmeeLog != null) {
|
||||
await _appendLine(gosmeeLog, '${arguments.join(' ')}\n');
|
||||
}
|
||||
if (_matchesNewUrl(arguments)) {
|
||||
return ProcessResult(0, 0, publicUrl, '');
|
||||
}
|
||||
return ProcessResult(
|
||||
0,
|
||||
1,
|
||||
'',
|
||||
'unexpected gosmee args: ${arguments.join(' ')}\n',
|
||||
);
|
||||
};
|
||||
|
||||
fakeStartedCommandHandlers[command] = (arguments) async {
|
||||
if (gosmeeLog != null) {
|
||||
await _appendLine(gosmeeLog, '${arguments.join(' ')}\n');
|
||||
}
|
||||
final process = FakeStartedExternalCommand();
|
||||
if (arguments.length >= 3 &&
|
||||
arguments[0] == 'client' &&
|
||||
arguments[1] == publicUrl) {
|
||||
if (payload != null) {
|
||||
unawaited(_postWebhook(arguments[2], payload, webhookHeader));
|
||||
}
|
||||
return process;
|
||||
}
|
||||
process.addStderr('unexpected gosmee args: ${arguments.join(' ')}\n');
|
||||
process.complete(1);
|
||||
return process;
|
||||
};
|
||||
}
|
||||
|
||||
bool _matchesNewUrl(List<String> arguments) {
|
||||
return arguments.length >= 4 &&
|
||||
arguments[0] == '--output' &&
|
||||
arguments[1] == 'json' &&
|
||||
arguments[2] == 'client' &&
|
||||
arguments[3] == '--new-url';
|
||||
}
|
||||
|
||||
Future<void> _postWebhook(
|
||||
String targetUrl,
|
||||
Map<String, Object?> payload,
|
||||
String webhookHeader,
|
||||
) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.postUrl(Uri.parse(targetUrl));
|
||||
request.headers.contentType = ContentType.json;
|
||||
request.headers.set(webhookHeader, 'issue_comment');
|
||||
request.write(jsonEncode(payload));
|
||||
await request.close();
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _appendLine(File file, String line) async {
|
||||
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||
RandomAccessFile? log;
|
||||
try {
|
||||
log = file.openSync(mode: FileMode.append);
|
||||
log.lockSync(FileLock.exclusive);
|
||||
log.writeStringSync(line);
|
||||
log.unlockSync();
|
||||
return;
|
||||
} on FileSystemException {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
} finally {
|
||||
log?.closeSync();
|
||||
}
|
||||
}
|
||||
await file.writeAsString(line, mode: FileMode.append);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import 'dart:convert';
|
||||
import 'dart: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(
|
||||
File responderScript,
|
||||
|
|
@ -9,33 +16,34 @@ Future<void> writeResponderScript(
|
|||
String stderr = '',
|
||||
int exitCode = 0,
|
||||
}) async {
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat >/dev/null
|
||||
${stderr.isEmpty ? '' : "printf '%s\\n' ${jsonEncode(stderr)} >&2"}
|
||||
cat <<'JSON'
|
||||
${jsonEncode(response)}
|
||||
JSON
|
||||
exit $exitCode
|
||||
''');
|
||||
await makeScriptExecutable(responderScript);
|
||||
_fakeResponders[responderScript.path] = _FakeResponder(
|
||||
response: response,
|
||||
stderr: stderr,
|
||||
exitCode: exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> writeCapturingResponderScript(
|
||||
File responderScript,
|
||||
Map<String, Object?> response, {
|
||||
File? cwdCapture,
|
||||
File? stdinCapture,
|
||||
}) async {
|
||||
_fakeResponders[responderScript.path] = _FakeResponder(
|
||||
response: response,
|
||||
cwdCapture: cwdCapture,
|
||||
stdinCapture: stdinCapture,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> writeJsonlResponderScript(
|
||||
File responderScript,
|
||||
Map<String, Object?> response,
|
||||
) async {
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cat >/dev/null
|
||||
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);
|
||||
_fakeResponders[responderScript.path] = _FakeResponder(
|
||||
response: response,
|
||||
emitsJsonl: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> writeDelayedLoggingResponderScript(
|
||||
|
|
@ -44,21 +52,142 @@ Future<void> writeDelayedLoggingResponderScript(
|
|||
required Duration delay,
|
||||
required Map<String, Object?> response,
|
||||
}) async {
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
timestamp_ms() {
|
||||
python3 - <<'PY'
|
||||
import time
|
||||
print(int(time.time() * 1000))
|
||||
PY
|
||||
_fakeResponders[responderScript.path] = _FakeResponder(
|
||||
response: response,
|
||||
eventLog: eventLog,
|
||||
delay: delay,
|
||||
);
|
||||
}
|
||||
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
|
||||
cat >/dev/null
|
||||
sleep ${delay.inSeconds}
|
||||
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
|
||||
cat <<'JSON'
|
||||
${jsonEncode(response)}
|
||||
JSON
|
||||
''');
|
||||
await makeScriptExecutable(responderScript);
|
||||
|
||||
class FakeResponderRunner extends CliResponderRunner {
|
||||
FakeResponderRunner(this._responders);
|
||||
|
||||
final Map<String, _FakeResponder> _responders;
|
||||
|
||||
@override
|
||||
Future<ResponderResult> run({
|
||||
required CliResponderConfig responder,
|
||||
required ProjectConfig project,
|
||||
required IssueThread thread,
|
||||
required String prompt,
|
||||
required String workspacePath,
|
||||
}) async {
|
||||
final fake = _responders[responder.command];
|
||||
if (fake == null) {
|
||||
throw StateError(
|
||||
'No fake responder registered for command ${responder.command}.',
|
||||
);
|
||||
}
|
||||
|
||||
final context = <String, String>{
|
||||
'prompt': prompt,
|
||||
'repo': project.repo,
|
||||
'project_key': project.key,
|
||||
'project_path': project.path,
|
||||
'workspace_path': workspacePath,
|
||||
'issue_number': thread.issue.number.toString(),
|
||||
'thread_json': encodePrettyJson(thread.toJson()),
|
||||
};
|
||||
final stdinPayload = renderTemplate(responder.stdinTemplate, context);
|
||||
|
||||
await fake.run(
|
||||
issueNumber: thread.issue.number,
|
||||
stdinPayload: stdinPayload,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
final rawStdout = fake.rawStdout;
|
||||
if (fake.exitCode != 0) {
|
||||
throw CliResponderExecutionException(
|
||||
responderId: responder.id,
|
||||
command: responder.command,
|
||||
arguments: responder.args,
|
||||
exitCode: fake.exitCode,
|
||||
rawStdout: rawStdout,
|
||||
rawStderr: fake.stderr,
|
||||
);
|
||||
}
|
||||
|
||||
return ResponderResult.fromJson(
|
||||
fake.response,
|
||||
responderId: responder.id,
|
||||
rawStdout: rawStdout,
|
||||
rawStderr: fake.stderr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeResponder {
|
||||
_FakeResponder({
|
||||
required this.response,
|
||||
this.stderr = '',
|
||||
this.exitCode = 0,
|
||||
this.emitsJsonl = false,
|
||||
this.eventLog,
|
||||
this.delay = Duration.zero,
|
||||
this.cwdCapture,
|
||||
this.stdinCapture,
|
||||
});
|
||||
|
||||
final Map<String, Object?> response;
|
||||
final String stderr;
|
||||
final int exitCode;
|
||||
final bool emitsJsonl;
|
||||
final File? eventLog;
|
||||
final Duration delay;
|
||||
final File? cwdCapture;
|
||||
final File? stdinCapture;
|
||||
|
||||
String get rawStdout {
|
||||
if (!emitsJsonl) {
|
||||
return jsonEncode(response);
|
||||
}
|
||||
return [
|
||||
jsonEncode({'type': 'thread.started', 'thread_id': 'test-thread'}),
|
||||
jsonEncode({'type': 'turn.started'}),
|
||||
jsonEncode({
|
||||
'type': 'item.completed',
|
||||
'item': {
|
||||
'id': 'item_0',
|
||||
'type': 'agent_message',
|
||||
'text': jsonEncode(response),
|
||||
},
|
||||
}),
|
||||
jsonEncode({'type': 'turn.completed'}),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
Future<void> run({
|
||||
required int issueNumber,
|
||||
required String stdinPayload,
|
||||
required String workspacePath,
|
||||
}) async {
|
||||
await cwdCapture?.writeAsString(workspacePath);
|
||||
await stdinCapture?.writeAsString(stdinPayload);
|
||||
if (eventLog != null) {
|
||||
await _appendEvent('start', issueNumber);
|
||||
await Future<void>.delayed(delay);
|
||||
await _appendEvent('end', issueNumber);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _appendEvent(String phase, int issueNumber) async {
|
||||
final timestampMs = DateTime.now().microsecondsSinceEpoch ~/ 1000;
|
||||
final line = '$phase:$issueNumber:$timestampMs\n';
|
||||
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||
RandomAccessFile? log;
|
||||
try {
|
||||
log = eventLog!.openSync(mode: FileMode.append);
|
||||
log.lockSync(FileLock.exclusive);
|
||||
log.writeStringSync(line);
|
||||
log.unlockSync();
|
||||
return;
|
||||
} on FileSystemException {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
} finally {
|
||||
log?.closeSync();
|
||||
}
|
||||
}
|
||||
await eventLog!.writeAsString(line, mode: FileMode.append);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Future<void> writeFakeTeaScript({
|
|||
String? viewerLogin,
|
||||
bool failViewerLookup = false,
|
||||
}) async {
|
||||
final normalizedPostCommentIssueNumbersByRepo =
|
||||
final postIssueNumbers =
|
||||
postCommentIssueNumbersByRepo ??
|
||||
<String, Set<int>>{
|
||||
for (final entry in issueListResponsesByRepo.entries)
|
||||
|
|
@ -23,103 +23,92 @@ Future<void> writeFakeTeaScript({
|
|||
.whereType<int>()
|
||||
.toSet(),
|
||||
};
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
fakeCommandHandlers[teaScript.path] = (arguments, {timeout}) async {
|
||||
if (teaLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(teaLog.path)}"',
|
||||
await teaLog.writeAsString(
|
||||
'${arguments.join(' ')}\n',
|
||||
mode: FileMode.append,
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
ProcessResult result(
|
||||
Object? stdout, {
|
||||
int exitCode = 0,
|
||||
String stderr = '',
|
||||
}) {
|
||||
return ProcessResult(
|
||||
0,
|
||||
exitCode,
|
||||
stdout is String ? stdout : jsonEncode(stdout),
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
if (_matches(arguments, 0, 'api') && _matches(arguments, 1, 'user')) {
|
||||
if (failViewerLookup) {
|
||||
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');
|
||||
return result('', exitCode: 1, stderr: 'viewer lookup failed\n');
|
||||
}
|
||||
return result({'login': viewerLogin ?? 'tea-user'});
|
||||
}
|
||||
|
||||
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');
|
||||
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) {
|
||||
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');
|
||||
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 normalizedPostCommentIssueNumbersByRepo.entries) {
|
||||
final repo = repoEntry.key;
|
||||
for (final repoEntry in postIssueNumbers.entries) {
|
||||
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''');
|
||||
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);
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
return result({
|
||||
'id': 701,
|
||||
'url': 'https://gitea.example.test/comment/701',
|
||||
'body': 'posted',
|
||||
}),
|
||||
)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected tea args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await teaScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(teaScript);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,119 @@
|
|||
import 'dart:async';
|
||||
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 {
|
||||
if (Platform.isWindows) {
|
||||
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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export 'support/script_utils.dart';
|
|||
export 'support/fake_gh.dart';
|
||||
export 'support/fake_tea.dart';
|
||||
export 'support/fake_glab.dart';
|
||||
export 'support/fake_gitea_api.dart';
|
||||
export 'support/fake_responders.dart';
|
||||
export 'support/fake_gosmee.dart';
|
||||
export 'support/git_utils.dart';
|
||||
|
|
|
|||
|
|
@ -76,15 +76,16 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
|
|||
final responderScript = File(
|
||||
p.join(sandbox.path, 'responder-capture.sh'),
|
||||
);
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
pwd > "${bashScriptPath(cwdCapture.path)}"
|
||||
cat > "${bashScriptPath(stdinCapture.path)}"
|
||||
cat <<'JSON'
|
||||
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
|
||||
JSON
|
||||
''');
|
||||
await makeScriptExecutable(responderScript);
|
||||
await writeCapturingResponderScript(
|
||||
responderScript,
|
||||
{
|
||||
'decision': 'no_reply',
|
||||
'mode': 'planning',
|
||||
'summary': 'captured mention context',
|
||||
},
|
||||
cwdCapture: cwdCapture,
|
||||
stdinCapture: stdinCapture,
|
||||
);
|
||||
|
||||
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||
final configFile = File(
|
||||
|
|
@ -110,6 +111,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -117,7 +120,7 @@ projects:
|
|||
await app.close();
|
||||
|
||||
// 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)),
|
||||
|
|
@ -201,15 +204,12 @@ projects:
|
|||
final responderScript = File(
|
||||
p.join(sandbox.path, 'planning-responder.sh'),
|
||||
);
|
||||
await responderScript.writeAsString('''#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
pwd > "${bashScriptPath(cwdCapture.path)}"
|
||||
cat >/dev/null
|
||||
cat <<'JSON'
|
||||
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"}
|
||||
JSON
|
||||
''');
|
||||
await makeScriptExecutable(responderScript);
|
||||
await writeCapturingResponderScript(responderScript, {
|
||||
'decision': 'reply',
|
||||
'mode': 'planning',
|
||||
'markdown': 'planning reply',
|
||||
'summary': 'used project path',
|
||||
}, cwdCapture: cwdCapture);
|
||||
|
||||
// And an orchestrator-style config pointing to the fake repo and responder.
|
||||
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||
|
|
@ -234,6 +234,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
@ -241,7 +243,7 @@ projects:
|
|||
await app.close();
|
||||
|
||||
// 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.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),
|
||||
|
|
@ -331,6 +333,8 @@ projects:
|
|||
config: await AppConfig.load(configFile.path),
|
||||
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||
ghCommand: ghScript.path,
|
||||
responderRunner: fakeResponderRunner(),
|
||||
commandExecutor: fakeExternalCommandExecutor(),
|
||||
);
|
||||
|
||||
// When the app processes one polling cycle.
|
||||
|
|
|
|||
|
|
@ -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)}...';
|
||||
}
|
||||
Loading…
Reference in New Issue