import 'dart:async'; 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, File? reactionBodyFile, String? viewerLogin, bool failViewerLookup = false, }) async { final issueLists = issueListResponsesByRepo ?? >>{'owner/sample': issueListResponse}; final comments = commentResponsesByRepo ?? >>>{ 'owner/sample': commentResponses, }; final postIssueNumbers = postCommentIssueNumbersByRepo ?? >{ 'owner/sample': { ...?postCommentIssueNumbers, ...?postCommentIssueNumber == null ? null : {postCommentIssueNumber}, }, }; final searchItems = issueLists.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); _registerFakeGhCommand( command: ghScript.path, ghLog: ghLog, failViewerLookup: failViewerLookup, viewerLogin: viewerLogin, searchResponse: { 'total_count': searchItems.length, 'incomplete_results': false, 'items': searchItems, }, openSearchResponse: { 'total_count': openSearchItems.length, 'incomplete_results': false, 'items': openSearchItems, }, issueLists: issueLists, comments: comments, postIssueNumbers: postIssueNumbers, postedBodyFile: postedBodyFile, reactionBodyFile: reactionBodyFile, ); } 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); _registerFakeGhCommand( command: ghScript.path, ghLog: ghLog, searchResponse: { 'total_count': searchItems.length, 'incomplete_results': false, 'items': searchItems, }, flakySearchStateFile: failureStateFile, issueLists: const {}, comments: {'owner/sample': commentResponses}, postIssueNumbers: const {}, ); } 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 searchItems = issueListResponse .map( (searchIssue) => { ...searchIssue, 'repository_url': 'https://api.github.com/repos/$repo', }, ) .toList(growable: false); _registerFakeGhCommand( command: ghScript.path, ghLog: ghLog, viewerLogin: viewerLogin, searchResponse: { 'total_count': searchItems.length, 'incomplete_results': false, 'items': searchItems, }, issueLists: const {}, comments: { repo: {issueNumber: comments}, }, postIssueNumbers: { repo: {issueNumber}, }, postedBodyFile: postedBodyFile, webhookPayload: { 'action': 'created', 'issue': issue, 'comment': comments.last, 'repository': {'full_name': repo}, }, ); } 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); _registerFakeGhCommand( command: ghScript.path, ghLog: ghLog, viewerLogin: viewerLogin, searchResponse: { 'total_count': searchItems.length, 'incomplete_results': false, 'items': searchItems, }, issueLists: const {}, comments: { repo: {issueNumber: comments}, }, postIssueNumbers: { repo: {issueNumber}, }, postedBodyFile: postedBodyFile, webhookIdsByRepo: {repo: webhookId}, ); } void _registerFakeGhCommand({ required String command, required Map searchResponse, required Map>> issueLists, required Map>>> comments, required Map> postIssueNumbers, File? ghLog, bool failViewerLookup = false, String? viewerLogin, Map? openSearchResponse, File? postedBodyFile, File? reactionBodyFile, File? flakySearchStateFile, Map? webhookPayload, Map? webhookIdsByRepo, }) { if (webhookPayload != null) { fakeStartedCommandHandlers[command] = (arguments) async { if (ghLog != null) { 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, ); } 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); } } for (final repoEntry in comments.entries) { for (final entry in repoEntry.value.entries) { if (_argsMatchAt(arguments, 0, 'api') && _argsMatchAt( arguments, 3, 'repos/${repoEntry.key}/issues/${entry.key}/comments?per_page=100', )) { return result(entry.value); } } } for (final repoEntry in postIssueNumbers.entries) { for (final issueNumber in repoEntry.value) { if (_argsMatchAt(arguments, 0, 'api') && _argsMatchAt( arguments, 3, 'repos/${repoEntry.key}/issues/$issueNumber/comments', )) { final body = _argumentValue(arguments, 'body='); if (postedBodyFile != null && body != null) { await postedBodyFile.writeAsString(body); } return result({ 'id': 700, 'html_url': 'https://example.test/comment/700', 'body': 'posted', }); } } } for (final repoEntry in comments.entries) { for (final issueEntry in repoEntry.value.entries) { for (final comment in issueEntry.value) { final commentId = comment['id']; if (commentId is int && _argsMatchAt(arguments, 0, 'api') && _argsMatchAt( arguments, 3, 'repos/${repoEntry.key}/issues/comments/$commentId/reactions', )) { final content = _argumentValue(arguments, 'content='); if (reactionBodyFile != null && content != null) { await reactionBodyFile.writeAsString(content); } return result({'id': 990, 'content': 'eyes'}); } } } } for (final entry in (webhookIdsByRepo ?? const {}).entries) { final hooksPath = 'repos/${entry.key}/hooks'; if (_argsMatchAt(arguments, 0, 'api') && _argsMatchAt(arguments, 3, hooksPath)) { return result({'id': entry.value}); } if (_argsMatchAt(arguments, 0, 'api') && _argsMatchAt(arguments, 3, '$hooksPath/${entry.value}')) { return result(null); } } return result( '', exitCode: 1, stderr: 'unexpected gh args: ${arguments.join(' ')}\n', ); }; } Future _postGitHubWebhook( String targetUrl, Map 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.add(utf8.encode(jsonEncode(webhookPayload))); await request.close(); } finally { client.close(force: true); } } bool _argsMatch(List arguments, List expected) => arguments.length >= expected.length && Iterable.generate( expected.length, ).every((index) => arguments[index] == expected[index]); bool _argsMatchAt(List arguments, int index, String expected) => arguments.length > index && arguments[index] == expected; String? _argumentValue(List arguments, String prefix) { for (final argument in arguments) { if (argument.startsWith(prefix)) { return argument.substring(prefix.length); } } return null; } Future _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.delayed(const Duration(milliseconds: 5)); } finally { log?.closeSync(); } } await file.writeAsString(line, mode: FileMode.append); }