import 'dart:convert'; import 'dart:io'; import 'package:git/git.dart'; import 'package:path/path.dart' as p; 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' "$*" >> ''' '"${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=}" > ''' '"${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 Process.run('chmod', ['+x', ghScript.path]); } 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' "$*" >> ''' '"${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 "${failureStateFile.path}" ]]; then') ..writeln(' touch "${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 Process.run('chmod', ['+x', ghScript.path]); } 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' "$*" >> ''' '"${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=}" > ''' '"${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 Process.run('chmod', ['+x', teaScript.path]); } 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 Process.run('chmod', ['+x', responderScript.path]); } 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 Process.run('chmod', ['+x', responderScript.path]); } Future writeDelayedLoggingResponderScript( File responderScript, { required File eventLog, required Duration delay, required Map response, }) async { await responderScript.writeAsString('''#!/usr/bin/env bash set -euo pipefail printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}" cat >/dev/null sleep ${delay.inSeconds} printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(date +%s)" >> "${eventLog.path}" cat <<'JSON' ${jsonEncode(response)} JSON '''); await Process.run('chmod', ['+x', responderScript.path]); } 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, ); } }