diff --git a/AGENTS.md b/AGENTS.md index 6cb4989..55e9ea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,5 @@ - read `README.md` for general information run `pnpx skills experimental_install`, `lefthook install` before development at first time. - For any new or modified Dart test under `test/`, use the repo-local skill `dart-gherkin-tests`. +- For file more than 500 lines, consider to split it into multiple files for better maintainability. +- Keep refactoring e.g. `SOLID` principles diff --git a/lib/code_work_spawner.dart b/lib/code_work_spawner.dart index 97da605..a7b04b0 100644 --- a/lib/code_work_spawner.dart +++ b/lib/code_work_spawner.dart @@ -1,11 +1,11 @@ -export 'src/comment_templates.dart'; -export 'src/config.dart'; -export 'src/database.dart'; -export 'src/app_logger.dart'; -export 'src/app_environment.dart'; -export 'src/issue_event_source/base.dart'; -export 'src/issue_tracker_client.dart'; -export 'src/issue_assistant_app.dart'; -export 'src/notifier.dart'; -export 'src/scm_client.dart'; -export 'src/workspace_manager.dart'; +export 'src/config/comment_templates.dart'; +export 'src/config/config.dart'; +export 'src/core/database.dart'; +export 'src/config/app_logger.dart'; +export 'src/config/app_environment.dart'; +export 'src/issue_tracker/issue_event_source/base.dart'; +export 'src/issue_tracker/issue_tracker_client.dart'; +export 'src/core/issue_assistant_app.dart'; +export 'src/notifier/notifier.dart'; +export 'src/workspace/scm_client.dart'; +export 'src/workspace/workspace_manager.dart'; diff --git a/lib/src/app_environment.dart b/lib/src/config/app_environment.dart similarity index 100% rename from lib/src/app_environment.dart rename to lib/src/config/app_environment.dart diff --git a/lib/src/app_logger.dart b/lib/src/config/app_logger.dart similarity index 100% rename from lib/src/app_logger.dart rename to lib/src/config/app_logger.dart diff --git a/lib/src/comment_templates.dart b/lib/src/config/comment_templates.dart similarity index 100% rename from lib/src/comment_templates.dart rename to lib/src/config/comment_templates.dart diff --git a/lib/src/config.dart b/lib/src/config/config.dart similarity index 100% rename from lib/src/config.dart rename to lib/src/config/config.dart diff --git a/lib/src/config_schema.dart b/lib/src/config/config_schema.dart similarity index 100% rename from lib/src/config_schema.dart rename to lib/src/config/config_schema.dart diff --git a/lib/src/cli_responder.dart b/lib/src/core/cli_responder.dart similarity index 98% rename from lib/src/cli_responder.dart rename to lib/src/core/cli_responder.dart index 4ce889b..f6f078d 100644 --- a/lib/src/cli_responder.dart +++ b/lib/src/core/cli_responder.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'config.dart'; -import 'issue_tracker_client.dart'; +import '../config/config.dart'; +import '../issue_tracker/issue_tracker_client.dart'; import 'process_launcher.dart'; class CliResponderRunner { diff --git a/lib/src/database.dart b/lib/src/core/database.dart similarity index 100% rename from lib/src/database.dart rename to lib/src/core/database.dart diff --git a/lib/src/issue_assistant_app.dart b/lib/src/core/issue_assistant_app.dart similarity index 97% rename from lib/src/issue_assistant_app.dart rename to lib/src/core/issue_assistant_app.dart index 3de596d..8756203 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/core/issue_assistant_app.dart @@ -3,17 +3,17 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'app_logger.dart'; +import '../config/app_logger.dart'; import 'cli_responder.dart'; -import 'config.dart'; +import '../config/config.dart'; import 'database.dart'; -import 'issue_event_source/base.dart'; -import 'issue_event_source/gitea_gosmee.dart'; -import 'issue_event_source/github_gosmee.dart'; -import 'issue_event_source/github_webhook_forward.dart'; -import 'issue_tracker_client.dart'; -import 'notifier.dart'; -import 'workspace_manager.dart'; +import '../issue_tracker/issue_event_source/base.dart'; +import '../issue_tracker/issue_event_source/gitea_gosmee.dart'; +import '../issue_tracker/issue_event_source/github_gosmee.dart'; +import '../issue_tracker/issue_event_source/github_webhook_forward.dart'; +import '../issue_tracker/issue_tracker_client.dart'; +import '../notifier/notifier.dart'; +import '../workspace/workspace_manager.dart'; class IssueAssistantApp { static final AppLogger _logger = AppLogger('code_work_spawner.app'); @@ -333,7 +333,7 @@ class IssueAssistantApp { stdout: rawStdout, stderr: rawStderr, ); - _log( + _logError( 'responder=${responder.id} issue=${project.key}#${issue.number} ' 'failed error=$error', ); diff --git a/lib/src/process_launcher.dart b/lib/src/core/process_launcher.dart similarity index 100% rename from lib/src/process_launcher.dart rename to lib/src/core/process_launcher.dart diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart index f46fc4c..9d47888 100644 --- a/lib/src/issue_tracker/client.dart +++ b/lib/src/issue_tracker/client.dart @@ -4,9 +4,9 @@ import 'dart:io'; import 'package:github/github.dart'; import 'package:path/path.dart' as p; -import '../app_environment.dart'; -import '../config_schema.dart'; -import '../process_launcher.dart'; +import '../config/app_environment.dart'; +import '../config/config_schema.dart'; +import '../core/process_launcher.dart'; import 'models.dart'; class IssueTrackerClient { diff --git a/lib/src/issue_event_source/base.dart b/lib/src/issue_tracker/issue_event_source/base.dart similarity index 98% rename from lib/src/issue_event_source/base.dart rename to lib/src/issue_tracker/issue_event_source/base.dart index 4db612b..e9444bd 100644 --- a/lib/src/issue_event_source/base.dart +++ b/lib/src/issue_tracker/issue_event_source/base.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'dart:io'; -import '../app_logger.dart'; -import '../config.dart'; -import '../database.dart'; -import '../issue_tracker_client.dart'; +import '../../config/app_logger.dart'; +import '../../config/config.dart'; +import '../../core/database.dart'; +import '../client.dart'; +import '../models.dart'; typedef IssueEventBatchHandler = Future Function(IssueEventBatch batch); diff --git a/lib/src/issue_event_source/gitea_gosmee.dart b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart similarity index 98% rename from lib/src/issue_event_source/gitea_gosmee.dart rename to lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart index 82826f1..1b71917 100644 --- a/lib/src/issue_event_source/gitea_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/gitea_gosmee.dart @@ -4,10 +4,11 @@ import 'dart:io'; import 'package:github/github.dart'; -import '../app_logger.dart'; -import '../config.dart'; -import '../issue_tracker_client.dart'; -import '../process_launcher.dart'; +import '../../config/app_logger.dart'; +import '../../config/config.dart'; +import '../client.dart'; +import '../models.dart'; +import '../../core/process_launcher.dart'; import 'base.dart'; class GiteaGosmeeIssueEventSource implements IssueEventSource { diff --git a/lib/src/issue_event_source/github_gosmee.dart b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart similarity index 98% rename from lib/src/issue_event_source/github_gosmee.dart rename to lib/src/issue_tracker/issue_event_source/github_gosmee.dart index b54008f..7e1d1ee 100644 --- a/lib/src/issue_event_source/github_gosmee.dart +++ b/lib/src/issue_tracker/issue_event_source/github_gosmee.dart @@ -4,10 +4,11 @@ import 'dart:io'; import 'package:github/github.dart'; -import '../app_logger.dart'; -import '../config.dart'; -import '../issue_tracker_client.dart'; -import '../process_launcher.dart'; +import '../../config/app_logger.dart'; +import '../../config/config.dart'; +import '../client.dart'; +import '../models.dart'; +import '../../core/process_launcher.dart'; import 'base.dart'; class GitHubGosmeeIssueEventSource implements IssueEventSource { diff --git a/lib/src/issue_event_source/github_webhook_forward.dart b/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart similarity index 98% rename from lib/src/issue_event_source/github_webhook_forward.dart rename to lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart index d8f0e99..fe883c2 100644 --- a/lib/src/issue_event_source/github_webhook_forward.dart +++ b/lib/src/issue_tracker/issue_event_source/github_webhook_forward.dart @@ -2,10 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import '../app_logger.dart'; -import '../config.dart'; -import '../issue_tracker_client.dart'; -import '../process_launcher.dart'; +import '../../config/app_logger.dart'; +import '../../config/config.dart'; +import '../client.dart'; +import '../models.dart'; +import '../../core/process_launcher.dart'; import 'base.dart'; class GitHubWebhookForwardIssueEventSource implements IssueEventSource { diff --git a/lib/src/issue_tracker/issue_tracker_client.dart b/lib/src/issue_tracker/issue_tracker_client.dart new file mode 100644 index 0000000..dc9201f --- /dev/null +++ b/lib/src/issue_tracker/issue_tracker_client.dart @@ -0,0 +1,2 @@ +export 'client.dart'; +export 'models.dart'; diff --git a/lib/src/issue_tracker_client.dart b/lib/src/issue_tracker_client.dart deleted file mode 100644 index 78f2afc..0000000 --- a/lib/src/issue_tracker_client.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'issue_tracker/client.dart'; -export 'issue_tracker/models.dart'; diff --git a/lib/src/notifier.dart b/lib/src/notifier/notifier.dart similarity index 99% rename from lib/src/notifier.dart rename to lib/src/notifier/notifier.dart index b52e101..7f9ec76 100644 --- a/lib/src/notifier.dart +++ b/lib/src/notifier/notifier.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'config.dart'; +import '../config/config.dart'; typedef NotificationLogger = void Function(String message); typedef DesktopNotificationCommandRunner = diff --git a/lib/src/scm_client.dart b/lib/src/workspace/scm_client.dart similarity index 97% rename from lib/src/scm_client.dart rename to lib/src/workspace/scm_client.dart index 022f6f5..c7ac3dd 100644 --- a/lib/src/scm_client.dart +++ b/lib/src/workspace/scm_client.dart @@ -3,8 +3,8 @@ import 'dart:io'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; -import 'app_environment.dart'; -import 'config.dart'; +import '../config/app_environment.dart'; +import '../config/config.dart'; abstract interface class ScmClient { Future createEphemeralWorkspace({ diff --git a/lib/src/workspace_manager.dart b/lib/src/workspace/workspace_manager.dart similarity index 97% rename from lib/src/workspace_manager.dart rename to lib/src/workspace/workspace_manager.dart index c8eb353..c646a62 100644 --- a/lib/src/workspace_manager.dart +++ b/lib/src/workspace/workspace_manager.dart @@ -1,4 +1,4 @@ -import 'config.dart'; +import '../config/config.dart'; import 'scm_client.dart'; class WorkspaceManager { diff --git a/skills-lock.json b/skills-lock.json index 364e031..8777218 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -349,7 +349,7 @@ "eval-driven-dev": { "source": "github/awesome-copilot", "sourceType": "github", - "computedHash": "0b2cf0b8a57cf332eecb36ca00c6bb0636a3b0ecfc1f79f28b1fd4bab7f8d4fb" + "computedHash": "1b5d8a5edb5c3540cd260e9ac71394077f814151b71fc343afb6ffd7e15c21b9" }, "excalidraw-diagram-generator": { "source": "github/awesome-copilot", @@ -944,7 +944,7 @@ "worktrunk": { "source": "max-sixty/worktrunk", "sourceType": "github", - "computedHash": "d480468e16c7de39a121e6e760788357bb178495f8912b05f05f55977468d72e" + "computedHash": "e95e53e4ef26baddf9efb6b6c4e08276384edba9d7e9269d61a11f2c6871c6d5" }, "write-coding-standards-from-file": { "source": "github/awesome-copilot", diff --git a/test/config/init_config_cli_test.dart b/test/config/init_config_cli_test.dart index e75fee3..b18168a 100644 --- a/test/config/init_config_cli_test.dart +++ b/test/config/init_config_cli_test.dart @@ -4,6 +4,8 @@ import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import '../test_support.dart'; + void main() { /// ```gherkin /// Feature: Init-config CLI @@ -21,21 +23,16 @@ void main() { /// ``` test('Show init-config in top-level help', () async { // Given the CLI is executed with the top-level help flag. - final result = await Process.run(Platform.resolvedExecutable, [ - 'run', - 'bin/code_work_spawner.dart', - '--help', - ], workingDirectory: Directory.current.path); + final result = await runCli(['--help']); // When the process completes successfully. - expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(result.exitCode, 0, reason: result.stderr); // Then stdout lists the init-config command in the available commands section. - final stdoutText = result.stdout as String; - expect(stdoutText, contains('Available commands:')); - expect(stdoutText, contains('init-config')); - expect(stdoutText, contains('--glab-command')); - expect(stdoutText, contains('--gosmee-command')); + expect(result.stdout, contains('Available commands:')); + expect(result.stdout, contains('init-config')); + expect(result.stdout, contains('--glab-command')); + expect(result.stdout, contains('--gosmee-command')); }); /// ```gherkin @@ -49,29 +46,27 @@ void main() { // Given the CLI is executed with the init-config command. final sandbox = await Directory.systemTemp.createTemp('cws-cli-stdout-'); final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); - final result = await Process.run(Platform.resolvedExecutable, [ - 'run', - 'bin/code_work_spawner.dart', - 'init-config', - ], workingDirectory: Directory.current.path); + final result = await runCli(['init-config']); // When the process completes successfully. - expect(result.exitCode, 0, reason: '${result.stderr}'); + expect(result.exitCode, 0, reason: result.stderr); // Then stdout contains the starter config content. - final stdoutText = result.stdout as String; - expect(stdoutText, contains('dataDir:')); - expect(stdoutText, contains('# responders:')); - expect(stdoutText, contains('# notifications:')); - expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}')); - expect(stdoutText, contains('# - type: desktop')); - expect(stdoutText, contains('issueTracker:')); - expect(stdoutText, contains('provider: github')); - expect(stdoutText, contains('scm:')); - expect(stdoutText, contains('projects:')); + expect(result.stdout, contains('dataDir:')); + expect(result.stdout, contains('# responders:')); + expect(result.stdout, contains('# notifications:')); + expect( + result.stdout, + contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'), + ); + expect(result.stdout, contains('# - type: desktop')); + expect(result.stdout, contains('issueTracker:')); + expect(result.stdout, contains('provider: github')); + expect(result.stdout, contains('scm:')); + expect(result.stdout, contains('projects:')); // And the printed config is valid when written to disk and loaded. - await configFile.writeAsString(stdoutText); + await configFile.writeAsString(result.stdout); final config = await AppConfig.load(configFile.path); expect(config.projects['sample'], isNotNull); }); @@ -90,31 +85,27 @@ void main() { await outputFile.writeAsString('original'); // When the CLI writes without the force flag and then with the force flag. - final withoutForce = await Process.run(Platform.resolvedExecutable, [ - 'run', - 'bin/code_work_spawner.dart', + final withoutForce = await runCli([ 'init-config', '--output', outputFile.path, - ], workingDirectory: Directory.current.path); - final withForce = await Process.run(Platform.resolvedExecutable, [ - 'run', - 'bin/code_work_spawner.dart', + ]); + final withForce = await runCli([ 'init-config', '--output', outputFile.path, '--force', - ], workingDirectory: Directory.current.path); + ]); // Then the first run fails with an overwrite error. expect(withoutForce.exitCode, 2); expect( - withoutForce.stderr as String, + withoutForce.stderr, contains('Refusing to overwrite existing file'), ); // And the forced run replaces the file with a valid starter config. - expect(withForce.exitCode, 0, reason: '${withForce.stderr}'); + expect(withForce.exitCode, 0, reason: withForce.stderr); final contents = await outputFile.readAsString(); expect(contents, contains('defaults:')); expect(contents, contains('issueTracker:')); diff --git a/test/support/cli_utils.dart b/test/support/cli_utils.dart new file mode 100644 index 0000000..45ba4a6 --- /dev/null +++ b/test/support/cli_utils.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../bin/code_work_spawner.dart' as _cliEntry; + +/// Result of a [runCli] invocation. +typedef CliResult = ({int exitCode, String stdout, String stderr}); + +/// Runs the CLI [main] in-process, capturing stdout, stderr, and [exitCode]. +/// +/// Uses [IOOverrides.runZoned] so every `dart:io` write inside the CLI lands +/// in the capturing sinks rather than the real terminal. The global +/// [exitCode] is reset to 0 both before and after the call so that +/// individual tests do not pollute each other. +Future runCli(List args) async { + final out = _CapturingStdout(); + final err = _CapturingStdout(); + exitCode = 0; + try { + await IOOverrides.runZoned>( + () => _cliEntry.main(args), + stdout: () => out, + stderr: () => err, + ); + } catch (_) { + // Errors surface through exitCode / captured stderr, not as exceptions. + } + final result = (exitCode: exitCode, stdout: out.text, stderr: err.text); + exitCode = 0; + return result; +} + +/// A [Stdout] implementation that captures all written text into a buffer. +class _CapturingStdout implements Stdout { + final StringBuffer _buffer = StringBuffer(); + + String get text => _buffer.toString(); + + @override + Encoding encoding = utf8; + + @override + bool get hasTerminal => false; + + @override + int get terminalColumns => throw UnsupportedError('no terminal'); + + @override + int get terminalLines => throw UnsupportedError('no terminal'); + + @override + bool get supportsAnsiEscapes => false; + + @override + IOSink get nonBlocking => this; + + @override + String lineTerminator = '\n'; + + @override + void write(Object? object) => _buffer.write(object); + + @override + void writeln([Object? object = '']) => _buffer.writeln(object); + + @override + void writeAll(Iterable objects, [String separator = '']) => + _buffer.writeAll(objects, separator); + + @override + void writeCharCode(int charCode) => _buffer.writeCharCode(charCode); + + @override + void add(List data) => _buffer.write(utf8.decode(data)); + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async { + await for (final chunk in stream) { + _buffer.write(utf8.decode(chunk)); + } + } + + @override + Future flush() => Future.value(); + + @override + Future close() => Future.value(); + + @override + Future get done => Future.value(); +} diff --git a/test/support/fake_gh.dart b/test/support/fake_gh.dart new file mode 100644 index 0000000..c503a68 --- /dev/null +++ b/test/support/fake_gh.dart @@ -0,0 +1,522 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'script_utils.dart'; + +Future writeFakeGhScript({ + required File ghScript, + required List> issueListResponse, + required Map>> commentResponses, + Map>>? issueListResponsesByRepo, + Map>>>? commentResponsesByRepo, + File? ghLog, + int? postCommentIssueNumber, + Set? postCommentIssueNumbers, + Map>? postCommentIssueNumbersByRepo, + File? postedBodyFile, + String? viewerLogin, + bool failViewerLookup = false, +}) async { + final normalizedIssueListResponsesByRepo = + issueListResponsesByRepo ?? + >>{'owner/sample': issueListResponse}; + final normalizedCommentResponsesByRepo = + commentResponsesByRepo ?? + >>>{ + 'owner/sample': commentResponses, + }; + final normalizedPostCommentIssueNumbersByRepo = + postCommentIssueNumbersByRepo ?? + >{ + 'owner/sample': { + ...?postCommentIssueNumbers, + ...?postCommentIssueNumber == null ? null : {postCommentIssueNumber}, + }, + }; + final searchItems = + normalizedIssueListResponsesByRepo.entries + .expand( + (entry) => entry.value.map( + (issue) => { + ...issue, + 'repository_url': 'https://api.github.com/repos/${entry.key}', + }, + ), + ) + .toList(growable: false) + ..sort((left, right) { + final leftUpdatedAt = left['updated_at'] as String? ?? ''; + final rightUpdatedAt = right['updated_at'] as String? ?? ''; + return leftUpdatedAt.compareTo(rightUpdatedAt); + }); + final openSearchItems = searchItems + .where((item) => item['state'] == 'open') + .toList(growable: false); + final allSearchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + final openSearchResponse = jsonEncode({ + 'total_count': openSearchItems.length, + 'incomplete_results': false, + 'items': openSearchItems, + }); + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(ghLog.path)}"', + ); + } + + buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); + if (failViewerLookup) { + buffer + ..writeln(r''' echo "viewer lookup failed" >&2''') + ..writeln(' exit 1') + ..writeln('fi'); + } else { + buffer + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + buffer + ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') + ..writeln(r''' query=""''') + ..writeln(r''' for arg in "$@"; do''') + ..writeln(r''' if [[ "$arg" == q=* ]]; then''') + ..writeln(r''' query="${arg#q=}"''') + ..writeln(r''' fi''') + ..writeln(r''' done''') + ..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''') + ..writeln(" cat <<'JSON'") + ..writeln(openSearchResponse) + ..writeln('JSON') + ..writeln(r''' else''') + ..writeln(" cat <<'JSON'") + ..writeln(allSearchResponse) + ..writeln('JSON') + ..writeln(r''' fi''') + ..writeln(' exit 0') + ..writeln('fi'); + + for (final entry in normalizedIssueListResponsesByRepo.entries) { + final repo = entry.key; + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final repoEntry in normalizedCommentResponsesByRepo.entries) { + final repo = repoEntry.key; + for (final entry in repoEntry.value.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { + final repo = repoEntry.key; + for (final issueNumber in repoEntry.value) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/$issueNumber/comments" ]]; then', + ) + ..writeln(r''' for arg in "$@"; do''') + ..writeln(r''' :'''); + if (postedBodyFile != null) { + buffer + ..writeln(r''' if [[ "$arg" == body=* ]]; then''') + ..writeln( + r''' printf '%s' "${arg#body=}" > ''' + '"${bashScriptPath(postedBodyFile.path)}"', + ) + ..writeln(r''' fi'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln( + jsonEncode({ + 'id': 700, + 'html_url': 'https://example.test/comment/700', + 'body': 'posted', + }), + ) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + buffer + ..writeln(r'''echo "unexpected gh args: $*" >&2''') + ..writeln('exit 1'); + + await ghScript.writeAsString(buffer.toString()); + await makeScriptExecutable(ghScript); +} + +Future writeFlakySearchGhScript({ + required File ghScript, + required File failureStateFile, + required List> issueListResponse, + required Map>> commentResponses, + File? ghLog, +}) async { + final searchItems = issueListResponse + .map( + (issue) => { + ...issue, + 'repository_url': 'https://api.github.com/repos/owner/sample', + }, + ) + .toList(growable: false); + final searchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(ghLog.path)}"', + ); + } + + buffer + ..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''') + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'login': 'test-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + buffer + ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') + ..writeln( + ' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then', + ) + ..writeln(' touch "${bashScriptPath(failureStateFile.path)}"') + ..writeln(r''' echo "error connecting to api.github.com" >&2''') + ..writeln( + r''' echo "check your internet connection or https://githubstatus.com" >&2''', + ) + ..writeln(' exit 1') + ..writeln(r''' fi''') + ..writeln(" cat <<'JSON'") + ..writeln(searchResponse) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + + for (final entry in commentResponses.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + buffer + ..writeln(r'''echo "unexpected gh args: $*" >&2''') + ..writeln('exit 1'); + + await ghScript.writeAsString(buffer.toString()); + await makeScriptExecutable(ghScript); +} + +Future writeWebhookForwardGhScript({ + required File ghScript, + required String repo, + required Map issue, + required List> comments, + List> issueListResponse = const [], + File? ghLog, + File? postedBodyFile, + 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) => { + ...searchIssue, + 'repository_url': 'https://api.github.com/repos/$repo', + }, + ) + .toList(growable: false); + final searchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(ghLog.path)}"', + ); + } + + 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 writeGitHubGosmeeGhScript({ + required File ghScript, + required String repo, + required int webhookId, + required int issueNumber, + required List> comments, + List> issueListResponse = const [], + File? ghLog, + File? postedBodyFile, + String? viewerLogin, +}) async { + final searchItems = issueListResponse + .map( + (issue) => { + ...issue, + 'repository_url': 'https://api.github.com/repos/$repo', + }, + ) + .toList(growable: false); + final searchResponse = jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }); + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (ghLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(ghLog.path)}"', + ); + } + + 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'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln( + jsonEncode({ + 'id': 703, + 'html_url': 'https://example.test/comment/703', + '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'); + + buffer + ..writeln(r'''echo "unexpected gh args: $*" >&2''') + ..writeln('exit 1'); + + await ghScript.writeAsString(buffer.toString()); + await makeScriptExecutable(ghScript); +} diff --git a/test/support/fake_glab.dart b/test/support/fake_glab.dart new file mode 100644 index 0000000..5ba0e78 --- /dev/null +++ b/test/support/fake_glab.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'script_utils.dart'; + +Future writeFakeGlabScript({ + required File glabScript, + required Map>> issueListResponsesByRepo, + required Map>>> + commentResponsesByRepo, + File? glabLog, + Map>? postCommentIssueNumbersByRepo, + File? postedBodyFile, + String? viewerLogin, + bool failViewerLookup = false, +}) async { + final normalizedPostCommentIssueNumbersByRepo = + postCommentIssueNumbersByRepo ?? + >{ + for (final entry in issueListResponsesByRepo.entries) + entry.key: entry.value + .map((issue) => issue['iid']) + .whereType() + .toSet(), + }; + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (glabLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(glabLog.path)}"', + ); + } + + buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); + if (failViewerLookup) { + buffer + ..writeln(r''' echo "viewer lookup failed" >&2''') + ..writeln(' exit 1') + ..writeln('fi'); + } else { + buffer + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'username': viewerLogin ?? 'glab-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final entry in issueListResponsesByRepo.entries) { + final repo = Uri.encodeComponent(entry.key); + buffer + ..writeln( + r'''if [[ "$1" == "api" && "$4" == "projects/''' + '$repo' + r'''/issues?state=opened"* ]]; then''', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final repoEntry in commentResponsesByRepo.entries) { + final repo = Uri.encodeComponent(repoEntry.key); + for (final entry in repoEntry.value.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"projects/$repo/issues/${entry.key}/notes?per_page=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { + 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'''); + } + buffer + ..writeln(r''' done''') + ..writeln(" cat <<'JSON'") + ..writeln( + jsonEncode({ + 'id': 901, + 'body': 'posted', + 'noteable_url': + 'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber', + }), + ) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + buffer + ..writeln(r'''echo "unexpected glab args: $*" >&2''') + ..writeln('exit 1'); + + await glabScript.writeAsString(buffer.toString()); + await makeScriptExecutable(glabScript); +} diff --git a/test/support/fake_gosmee.dart b/test/support/fake_gosmee.dart new file mode 100644 index 0000000..5682cf3 --- /dev/null +++ b/test/support/fake_gosmee.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'script_utils.dart'; + +Future writeGosmeeForwardScript({ + required File gosmeeScript, + required String publicUrl, + required Map 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)}"', + ); + } + + 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); +} diff --git a/test/support/fake_responders.dart b/test/support/fake_responders.dart new file mode 100644 index 0000000..3328898 --- /dev/null +++ b/test/support/fake_responders.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'script_utils.dart'; + +Future writeResponderScript( + File responderScript, + Map response, { + 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); +} + +Future writeJsonlResponderScript( + File responderScript, + Map 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); +} + +Future writeDelayedLoggingResponderScript( + File responderScript, { + required File eventLog, + required Duration delay, + required Map response, +}) async { + await responderScript.writeAsString('''#!/usr/bin/env bash +set -euo pipefail +timestamp_ms() { + python3 - <<'PY' +import time +print(int(time.time() * 1000)) +PY +} +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); +} diff --git a/test/support/fake_tea.dart b/test/support/fake_tea.dart new file mode 100644 index 0000000..ec384a8 --- /dev/null +++ b/test/support/fake_tea.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'script_utils.dart'; + +Future writeFakeTeaScript({ + required File teaScript, + required Map>> issueListResponsesByRepo, + required Map>>> + commentResponsesByRepo, + File? teaLog, + Map>? postCommentIssueNumbersByRepo, + File? postedBodyFile, + String? viewerLogin, + bool failViewerLookup = false, +}) async { + final normalizedPostCommentIssueNumbersByRepo = + postCommentIssueNumbersByRepo ?? + >{ + for (final entry in issueListResponsesByRepo.entries) + entry.key: entry.value + .map((issue) => issue['index'] ?? issue['number']) + .whereType() + .toSet(), + }; + + final buffer = StringBuffer() + ..writeln('#!/usr/bin/env bash') + ..writeln('set -euo pipefail'); + + if (teaLog != null) { + buffer.writeln( + r'''printf '%s\n' "$*" >> ''' + '"${bashScriptPath(teaLog.path)}"', + ); + } + + buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); + if (failViewerLookup) { + buffer + ..writeln(r''' echo "viewer lookup failed" >&2''') + ..writeln(' exit 1') + ..writeln('fi'); + } else { + buffer + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode({'login': viewerLogin ?? 'tea-user'})) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final entry in issueListResponsesByRepo.entries) { + final repo = entry.key; + buffer + ..writeln( + r'''if [[ "$1" == "api" && "$4" == "repos/''' + '$repo' + r'''/issues?state=open"* ]]; then''', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + + for (final repoEntry in commentResponsesByRepo.entries) { + final repo = repoEntry.key; + for (final entry in repoEntry.value.entries) { + buffer + ..writeln( + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/${entry.key}/comments?limit=100" ]]; then', + ) + ..writeln(" cat <<'JSON'") + ..writeln(jsonEncode(entry.value)) + ..writeln('JSON') + ..writeln(' exit 0') + ..writeln('fi'); + } + } + + for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { + 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': 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); +} diff --git a/test/support/git_utils.dart b/test/support/git_utils.dart new file mode 100644 index 0000000..85de6f7 --- /dev/null +++ b/test/support/git_utils.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; + +Future initGitRepo(Directory directory) async { + await GitDir.init(directory.path, allowContent: true); + await runGit([ + 'config', + 'user.email', + 'test@example.com', + ], workingDirectory: directory.path); + await runGit([ + 'config', + 'user.name', + 'Test User', + ], workingDirectory: directory.path); + await File(p.join(directory.path, 'README.md')).writeAsString('repo'); + await runGit(['add', '.'], workingDirectory: directory.path); + await runGit(['commit', '-m', 'init'], workingDirectory: directory.path); +} + +const Set gitContextEnvironmentKeys = { + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_COMMON_DIR', + 'GIT_DIR', + 'GIT_IMPLICIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_PREFIX', + 'GIT_WORK_TREE', +}; + +Future runGit( + List arguments, { + required String workingDirectory, +}) async { + final environment = Map.from(Platform.environment) + ..removeWhere((key, _) => gitContextEnvironmentKeys.contains(key)); + final result = await Process.run( + 'git', + arguments, + workingDirectory: workingDirectory, + environment: environment, + ); + if (result.exitCode != 0) { + throw ProcessException( + 'git', + arguments, + '${result.stdout}\n${result.stderr}', + result.exitCode, + ); + } +} diff --git a/test/support/script_utils.dart b/test/support/script_utils.dart new file mode 100644 index 0000000..5630dd3 --- /dev/null +++ b/test/support/script_utils.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +Future makeScriptExecutable(File script) async { + if (Platform.isWindows) { + return; + } + + final result = await Process.run('chmod', ['+x', script.path]); + if (result.exitCode != 0) { + throw ProcessException( + 'chmod', + ['+x', script.path], + '${result.stdout}\n${result.stderr}', + result.exitCode, + ); + } +} + +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'; +} diff --git a/test/test_support.dart b/test/test_support.dart index 45913e3..aea7198 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -1,983 +1,8 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:git/git.dart'; -import 'package:path/path.dart' as p; - -Future makeScriptExecutable(File script) async { - if (Platform.isWindows) { - return; - } - - final result = await Process.run('chmod', ['+x', script.path]); - if (result.exitCode != 0) { - throw ProcessException( - 'chmod', - ['+x', script.path], - '${result.stdout}\n${result.stderr}', - result.exitCode, - ); - } -} - -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'; -} - -Future writeFakeGhScript({ - required File ghScript, - required List> issueListResponse, - required Map>> commentResponses, - Map>>? issueListResponsesByRepo, - Map>>>? commentResponsesByRepo, - File? ghLog, - int? postCommentIssueNumber, - Set? postCommentIssueNumbers, - Map>? postCommentIssueNumbersByRepo, - File? postedBodyFile, - String? viewerLogin, - bool failViewerLookup = false, -}) async { - final normalizedIssueListResponsesByRepo = - issueListResponsesByRepo ?? - >>{'owner/sample': issueListResponse}; - final normalizedCommentResponsesByRepo = - commentResponsesByRepo ?? - >>>{ - 'owner/sample': commentResponses, - }; - final normalizedPostCommentIssueNumbersByRepo = - postCommentIssueNumbersByRepo ?? - >{ - 'owner/sample': { - ...?postCommentIssueNumbers, - ...?postCommentIssueNumber == null ? null : {postCommentIssueNumber}, - }, - }; - final searchItems = - normalizedIssueListResponsesByRepo.entries - .expand( - (entry) => entry.value.map( - (issue) => { - ...issue, - 'repository_url': 'https://api.github.com/repos/${entry.key}', - }, - ), - ) - .toList(growable: false) - ..sort((left, right) { - final leftUpdatedAt = left['updated_at'] as String? ?? ''; - final rightUpdatedAt = right['updated_at'] as String? ?? ''; - return leftUpdatedAt.compareTo(rightUpdatedAt); - }); - final openSearchItems = searchItems - .where((item) => item['state'] == 'open') - .toList(growable: false); - final allSearchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final openSearchResponse = jsonEncode({ - 'total_count': openSearchItems.length, - 'incomplete_results': false, - 'items': openSearchItems, - }); - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln(r''' query=""''') - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' if [[ "$arg" == q=* ]]; then''') - ..writeln(r''' query="${arg#q=}"''') - ..writeln(r''' fi''') - ..writeln(r''' done''') - ..writeln(r''' if [[ "$query" == *"state:open"* ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(openSearchResponse) - ..writeln('JSON') - ..writeln(r''' else''') - ..writeln(" cat <<'JSON'") - ..writeln(allSearchResponse) - ..writeln('JSON') - ..writeln(r''' fi''') - ..writeln(' exit 0') - ..writeln('fi'); - - for (final entry in normalizedIssueListResponsesByRepo.entries) { - final repo = entry.key; - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in normalizedCommentResponsesByRepo.entries) { - final repo = repoEntry.key; - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - final repo = repoEntry.key; - for (final issueNumber in repoEntry.value) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { - buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') - ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${bashScriptPath(postedBodyFile.path)}"', - ) - ..writeln(r''' fi'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 700, - 'html_url': 'https://example.test/comment/700', - 'body': 'posted', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); -} - -Future writeFlakySearchGhScript({ - required File ghScript, - required File failureStateFile, - required List> issueListResponse, - required Map>> commentResponses, - File? ghLog, -}) async { - final searchItems = issueListResponse - .map( - (issue) => { - ...issue, - 'repository_url': 'https://api.github.com/repos/owner/sample', - }, - ) - .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''') - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': 'test-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - buffer - ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') - ..writeln( - ' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then', - ) - ..writeln(' touch "${bashScriptPath(failureStateFile.path)}"') - ..writeln(r''' echo "error connecting to api.github.com" >&2''') - ..writeln( - r''' echo "check your internet connection or https://githubstatus.com" >&2''', - ) - ..writeln(' exit 1') - ..writeln(r''' fi''') - ..writeln(" cat <<'JSON'") - ..writeln(searchResponse) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - - for (final entry in commentResponses.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); -} - -Future writeFakeTeaScript({ - required File teaScript, - required Map>> issueListResponsesByRepo, - required Map>>> - commentResponsesByRepo, - File? teaLog, - Map>? postCommentIssueNumbersByRepo, - File? postedBodyFile, - String? viewerLogin, - bool failViewerLookup = false, -}) async { - final normalizedPostCommentIssueNumbersByRepo = - postCommentIssueNumbersByRepo ?? - >{ - for (final entry in issueListResponsesByRepo.entries) - entry.key: entry.value - .map((issue) => issue['index'] ?? issue['number']) - .whereType() - .toSet(), - }; - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (teaLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(teaLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'login': viewerLogin ?? 'tea-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final entry in issueListResponsesByRepo.entries) { - final repo = entry.key; - buffer - ..writeln( - r'''if [[ "$1" == "api" && "$4" == "repos/''' - '$repo' - r'''/issues?state=open"* ]]; then''', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in commentResponsesByRepo.entries) { - final repo = repoEntry.key; - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/$repo/issues/${entry.key}/comments?limit=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - 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': 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); -} - -Future writeFakeGlabScript({ - required File glabScript, - required Map>> issueListResponsesByRepo, - required Map>>> - commentResponsesByRepo, - File? glabLog, - Map>? postCommentIssueNumbersByRepo, - File? postedBodyFile, - String? viewerLogin, - bool failViewerLookup = false, -}) async { - final normalizedPostCommentIssueNumbersByRepo = - postCommentIssueNumbersByRepo ?? - >{ - for (final entry in issueListResponsesByRepo.entries) - entry.key: entry.value - .map((issue) => issue['iid']) - .whereType() - .toSet(), - }; - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (glabLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(glabLog.path)}"', - ); - } - - buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then'''); - if (failViewerLookup) { - buffer - ..writeln(r''' echo "viewer lookup failed" >&2''') - ..writeln(' exit 1') - ..writeln('fi'); - } else { - buffer - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode({'username': viewerLogin ?? 'glab-user'})) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final entry in issueListResponsesByRepo.entries) { - final repo = Uri.encodeComponent(entry.key); - buffer - ..writeln( - r'''if [[ "$1" == "api" && "$4" == "projects/''' - '$repo' - r'''/issues?state=opened"* ]]; then''', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - - for (final repoEntry in commentResponsesByRepo.entries) { - final repo = Uri.encodeComponent(repoEntry.key); - for (final entry in repoEntry.value.entries) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"projects/$repo/issues/${entry.key}/notes?per_page=100" ]]; then', - ) - ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(entry.value)) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) { - 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'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 901, - 'body': 'posted', - 'noteable_url': - 'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber', - }), - ) - ..writeln('JSON') - ..writeln(' exit 0') - ..writeln('fi'); - } - } - - buffer - ..writeln(r'''echo "unexpected glab args: $*" >&2''') - ..writeln('exit 1'); - - await glabScript.writeAsString(buffer.toString()); - await makeScriptExecutable(glabScript); -} - -Future writeResponderScript( - File responderScript, - Map response, { - 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); -} - -Future writeJsonlResponderScript( - File responderScript, - Map 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); -} - -Future writeDelayedLoggingResponderScript( - File responderScript, { - required File eventLog, - required Duration delay, - required Map response, -}) async { - await responderScript.writeAsString('''#!/usr/bin/env bash -set -euo pipefail -timestamp_ms() { - python3 - <<'PY' -import time -print(int(time.time() * 1000)) -PY -} -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); -} - -Future writeWebhookForwardGhScript({ - required File ghScript, - required String repo, - required Map issue, - required List> comments, - List> issueListResponse = const [], - File? ghLog, - File? postedBodyFile, - 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) => { - ...searchIssue, - 'repository_url': 'https://api.github.com/repos/$repo', - }, - ) - .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - 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 writeGitHubGosmeeGhScript({ - required File ghScript, - required String repo, - required int webhookId, - required int issueNumber, - required List> comments, - List> issueListResponse = const [], - File? ghLog, - File? postedBodyFile, - String? viewerLogin, -}) async { - final searchItems = issueListResponse - .map( - (issue) => { - ...issue, - 'repository_url': 'https://api.github.com/repos/$repo', - }, - ) - .toList(growable: false); - final searchResponse = jsonEncode({ - 'total_count': searchItems.length, - 'incomplete_results': false, - 'items': searchItems, - }); - final buffer = StringBuffer() - ..writeln('#!/usr/bin/env bash') - ..writeln('set -euo pipefail'); - - if (ghLog != null) { - buffer.writeln( - r'''printf '%s\n' "$*" >> ''' - '"${bashScriptPath(ghLog.path)}"', - ); - } - - 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'''); - } - buffer - ..writeln(r''' done''') - ..writeln(" cat <<'JSON'") - ..writeln( - jsonEncode({ - 'id': 703, - 'html_url': 'https://example.test/comment/703', - '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'); - - buffer - ..writeln(r'''echo "unexpected gh args: $*" >&2''') - ..writeln('exit 1'); - - await ghScript.writeAsString(buffer.toString()); - await makeScriptExecutable(ghScript); -} - -Future writeGosmeeForwardScript({ - required File gosmeeScript, - required String publicUrl, - required Map 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)}"', - ); - } - - 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); -} - -Future initGitRepo(Directory directory) async { - await GitDir.init(directory.path, allowContent: true); - await runGit([ - 'config', - 'user.email', - 'test@example.com', - ], workingDirectory: directory.path); - await runGit([ - 'config', - 'user.name', - 'Test User', - ], workingDirectory: directory.path); - await File(p.join(directory.path, 'README.md')).writeAsString('repo'); - await runGit(['add', '.'], workingDirectory: directory.path); - await runGit(['commit', '-m', 'init'], workingDirectory: directory.path); -} - -const Set gitContextEnvironmentKeys = { - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_COMMON_DIR', - 'GIT_DIR', - 'GIT_IMPLICIT_WORK_TREE', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_PREFIX', - 'GIT_WORK_TREE', -}; - -Future runGit( - List arguments, { - required String workingDirectory, -}) async { - final environment = Map.from(Platform.environment) - ..removeWhere((key, _) => gitContextEnvironmentKeys.contains(key)); - final result = await Process.run( - 'git', - arguments, - workingDirectory: workingDirectory, - environment: environment, - ); - if (result.exitCode != 0) { - throw ProcessException( - 'git', - arguments, - '${result.stdout}\n${result.stderr}', - result.exitCode, - ); - } -} +export 'support/script_utils.dart'; +export 'support/fake_gh.dart'; +export 'support/fake_tea.dart'; +export 'support/fake_glab.dart'; +export 'support/fake_responders.dart'; +export 'support/fake_gosmee.dart'; +export 'support/git_utils.dart'; +export 'support/cli_utils.dart';