From 200992d67b6c89f45254a265abcff89d44b1936c Mon Sep 17 00:00:00 2001 From: insleker Date: Sun, 5 Apr 2026 13:55:54 +0800 Subject: [PATCH] feat: enable aggregate adjacent github polling --- examples/README.md | 2 +- lib/src/github_client.dart | 117 ++++++++++++--- lib/src/issue_assistant_app.dart | 222 ++++++++++++++++++++--------- test/issue_assistant_app_test.dart | 158 ++++++++++++++++++++ test/test_support.dart | 129 ++++++++++++----- 5 files changed, 499 insertions(+), 129 deletions(-) diff --git a/examples/README.md b/examples/README.md index a474e1b..829c369 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,6 @@ account. Those markers support template variables such as `{{gh_login}}`, `{{comment_tag}}`, and `{{fingerprint}}`. Set either template to an empty string to disable that section. -Start from [`examples/simple-github.yaml`](examples/simple-github.yaml). This +Start from [`examples/simple-github.yaml`](simple-github.yaml). This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for bug-verification worktrees. diff --git a/lib/src/github_client.dart b/lib/src/github_client.dart index 03f0757..491156d 100644 --- a/lib/src/github_client.dart +++ b/lib/src/github_client.dart @@ -8,31 +8,56 @@ class GitHubClient { final String ghCommand; Future? _authenticatedLoginFuture; + static const int _pageSize = 100; - Future> fetchUpdatedIssues({ - required RepositorySlug repo, + Future> fetchUpdatedIssuesForRepos({ + required Iterable repos, required DateTime? since, }) async { - final query = { - 'state': 'all', - 'sort': 'updated', - 'direction': 'asc', - 'per_page': '100', - if (since != null) 'since': since.toUtc().toIso8601String(), - }; + final repoList = repos.toList(growable: false); + if (repoList.isEmpty) { + return const []; + } - final json = await _runJson([ - 'api', - '-X', - 'GET', - _buildApiPath(repo, ['issues'], query), - ]); + final queryParts = [ + for (final repo in repoList) 'repo:${repo.fullName}', + 'is:issue', + if (since != null) 'updated:>=${since.toUtc().toIso8601String()}', + ]; - return (json as List) - .cast>() - .where((item) => !item.containsKey('pull_request')) - .map(GitHubIssueSummary.fromJson) - .toList(growable: false); + final issues = []; + for (var page = 1; true; page += 1) { + final json = await _runJson([ + 'api', + '-X', + 'GET', + 'search/issues', + '-f', + 'q=${queryParts.join(' ')}', + '-f', + 'sort=updated', + '-f', + 'order=asc', + '-f', + 'per_page=$_pageSize', + '-f', + 'page=$page', + ]); + final items = + ((json as Map)['items'] as List? ?? + const []) + .cast>(); + issues.addAll( + items + .where((item) => !item.containsKey('pull_request')) + .map(GitHubIssueSummary.fromSearchJson), + ); + if (items.length < _pageSize) { + break; + } + } + + return issues; } Future fetchThread({ @@ -129,6 +154,7 @@ class GitHubClient { class GitHubIssueSummary { GitHubIssueSummary({ + required this.repoSlug, required this.number, required this.title, required this.body, @@ -139,6 +165,7 @@ class GitHubIssueSummary { required this.labels, }); + final RepositorySlug repoSlug; final int number; final String title; final String body; @@ -148,9 +175,58 @@ class GitHubIssueSummary { final String userLogin; final List labels; + factory GitHubIssueSummary.fromRepositoryJson( + RepositorySlug repo, + Map json, + ) { + final labelsNode = json['labels'] as List? ?? const []; + return GitHubIssueSummary( + repoSlug: repo, + number: json['number'] as int, + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + state: json['state'] as String? ?? 'open', + url: json['html_url'] as String? ?? '', + updatedAt: DateTime.parse(json['updated_at'] as String), + userLogin: + (json['user'] as Map? ?? const {})['login'] + as String? ?? + 'unknown', + labels: labelsNode + .map((label) => (label as Map)['name'].toString()) + .toList(growable: false), + ); + } + + factory GitHubIssueSummary.fromSearchJson(Map json) { + final repositoryUrl = json['repository_url'] as String?; + if (repositoryUrl == null || repositoryUrl.isEmpty) { + throw const FormatException( + 'Missing repository_url in GitHub search result item.', + ); + } + return GitHubIssueSummary.fromRepositoryJson( + _parseRepositorySlugFromRepositoryUrl(repositoryUrl), + json, + ); + } + + static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) { + final uri = Uri.parse(value); + final segments = uri.pathSegments; + final reposIndex = segments.indexOf('repos'); + if (reposIndex == -1 || reposIndex + 2 >= segments.length) { + throw FormatException( + 'Invalid repository_url in GitHub search result item: "$value"', + ); + } + return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]); + } + factory GitHubIssueSummary.fromJson(Map json) { final labelsNode = json['labels'] as List? ?? const []; return GitHubIssueSummary( + repoSlug: RepositorySlug('', ''), number: json['number'] as int, title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', @@ -168,6 +244,7 @@ class GitHubIssueSummary { } Map toJson() => { + 'repo': repoSlug.fullName, 'number': number, 'title': title, 'body': body, diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index daa32a0..bab83c5 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -13,6 +13,7 @@ import 'workspace_manager.dart'; class IssueAssistantApp { static final Logger _logger = Logger('code_work_spawner.app'); + static const Duration _pollCoalescingWindow = Duration(seconds: 2); IssueAssistantApp._({ required this.config, @@ -29,6 +30,7 @@ class IssueAssistantApp { final WorkspaceManager workspaceManager; final Map _projectPollStates = {}; final Set> _activePolls = >{}; + Timer? _schedulerTimer; Completer? _runCompleter; bool _isClosing = false; bool _isClosed = false; @@ -67,33 +69,37 @@ class IssueAssistantApp { final completer = Completer(); _runCompleter = completer; + final now = DateTime.now(); for (final project in enabledProjects) { - final state = _projectPollStates.putIfAbsent( + _projectPollStates.putIfAbsent( project.key, - () => _ProjectPollState(project: project), + () => _ProjectPollState(project: project, nextDueAt: now), ); - unawaited(_triggerProjectPoll(project, source: 'startup')); - state.timer = Timer.periodic(project.issueAssistant.pollInterval, (_) { - unawaited(_triggerProjectPoll(project, source: 'interval')); - }); } + unawaited(_triggerDueProjectPolls(source: 'startup')); try { await completer.future; } finally { - _cancelProjectPollTimers(); + _cancelScheduler(); } } Future runOnce() async { _log('starting poll cycle projects=${config.projects.length}'); + final enabledProjects = []; for (final project in config.projects.values) { if (!project.issueAssistant.enabled) { _log('skip project=${project.key} reason=issue_assistant_disabled'); continue; } - await _pollProject(project); + enabledProjects.add(project); } + if (enabledProjects.isEmpty) { + _log('no enabled projects configured'); + return; + } + await _pollProjects(enabledProjects); _log('poll cycle completed'); } @@ -103,7 +109,7 @@ class IssueAssistantApp { } _isClosing = true; - _cancelProjectPollTimers(); + _cancelScheduler(); _completeRunIfPending(); await Future.wait(_activePolls.toList(growable: false)); await database.close(); @@ -111,50 +117,63 @@ class IssueAssistantApp { _isClosing = false; } - Future _triggerProjectPoll( - ProjectConfig project, { - required String source, - }) async { + Future _triggerDueProjectPolls({required String source}) async { if (_isClosing || _isClosed) { return; } - final state = _projectPollStates.putIfAbsent( - project.key, - () => _ProjectPollState(project: project), - ); - if (state.isRunning) { - _log( - 'skip poll project=${project.key} reason=already_running source=$source', - ); + final now = DateTime.now(); + final dueStates = _projectPollStates.values + .where((state) => !state.isRunning && !state.nextDueAt.isAfter(now)) + .toList(growable: false); + if (dueStates.isEmpty) { + _scheduleNextPoll(); return; } late final Future pollFuture; pollFuture = () async { - state.isRunning = true; + for (final state in dueStates) { + state.isRunning = true; + } final stopwatch = Stopwatch()..start(); - _log('poll tick project=${project.key} source=$source'); + final projects = dueStates + .map((state) => state.project) + .toList(growable: false); + _log( + 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")}', + ); try { - await _pollProject(project); + await _pollProjects(projects); stopwatch.stop(); + final batchCompletedAt = DateTime.now(); + for (final state in dueStates) { + while (!state.nextDueAt.isAfter(batchCompletedAt)) { + state.nextDueAt = state.nextDueAt.add( + state.project.issueAssistant.pollInterval, + ); + } + } _log( - 'poll tick project=${project.key} source=$source ' + 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")} ' 'duration_ms=${stopwatch.elapsedMilliseconds}', ); } catch (error, stackTrace) { stopwatch.stop(); _logError( - 'poll tick project=${project.key} source=$source ' + 'poll tick source=$source projects=${projects.map((p) => p.key).join(",")} ' 'duration_ms=${stopwatch.elapsedMilliseconds} error=$error', ); - _cancelProjectPollTimers(); + _cancelScheduler(); if (!(_runCompleter?.isCompleted ?? true)) { _runCompleter!.completeError(error, stackTrace); } } finally { - state.isRunning = false; + for (final state in dueStates) { + state.isRunning = false; + } _activePolls.remove(pollFuture); + _scheduleNextPoll(); } }(); @@ -162,11 +181,36 @@ class IssueAssistantApp { await pollFuture; } - void _cancelProjectPollTimers() { - for (final state in _projectPollStates.values) { - state.timer?.cancel(); - state.timer = null; + void _cancelScheduler() { + _schedulerTimer?.cancel(); + _schedulerTimer = null; + } + + void _scheduleNextPoll() { + if (_isClosing || _isClosed || _runCompleter == null) { + return; } + _cancelScheduler(); + + DateTime? nextDueAt; + for (final state in _projectPollStates.values) { + if (state.isRunning) { + continue; + } + if (nextDueAt == null || state.nextDueAt.isBefore(nextDueAt)) { + nextDueAt = state.nextDueAt; + } + } + if (nextDueAt == null) { + return; + } + + final scheduledAt = nextDueAt.add(_pollCoalescingWindow); + final delay = scheduledAt.difference(DateTime.now()); + _schedulerTimer = Timer( + delay.isNegative ? Duration.zero : delay, + () => unawaited(_triggerDueProjectPolls(source: 'interval')), + ); } void _completeRunIfPending() { @@ -176,59 +220,95 @@ class IssueAssistantApp { } } - Future _pollProject(ProjectConfig project) async { - final pollRunId = await database.startPollRun(project.key); - try { + Future _pollProjects(List projects) async { + final pollRunIds = {}; + final projectsByRepo = { + for (final project in projects) project.repoSlug.fullName: project, + }; + final sinceByProject = {}; + final latestSeenByProject = {}; + + for (final project in projects) { + pollRunIds[project.key] = await database.startPollRun(project.key); final repoDirectory = Directory(project.path); final repoExists = await repoDirectory.exists(); final projectState = await database.findProjectState(project.key); final since = projectState?.lastSeenUpdatedAt == null ? null : DateTime.parse(projectState!.lastSeenUpdatedAt!); + sinceByProject[project.key] = since; + latestSeenByProject[project.key] = since; _log( 'poll project=${project.key} repo=${project.repo} ' 'path=${project.path} path_exists=$repoExists ' 'since=${since?.toUtc().toIso8601String() ?? "initial"}', ); - final issues = await githubClient.fetchUpdatedIssues( - repo: project.repoSlug, - since: since, - ); - _log('project=${project.key} fetched_issues=${issues.length}'); + } - final latestSeen = issues.map((issue) => issue.updatedAt).fold( - since, - (current, next) { - if (current == null || next.isAfter(current)) { - return next; - } - return current; - }, + try { + final batchSince = sinceByProject.values + .whereType() + .fold(null, (current, next) { + if (current == null || next.isBefore(current)) { + return next; + } + return current; + }); + final issues = await githubClient.fetchUpdatedIssuesForRepos( + repos: projects.map((project) => project.repoSlug), + since: batchSince, ); + final issuesByProject = >{ + for (final project in projects) project.key: [], + }; + for (final issue in issues) { + final project = projectsByRepo[issue.repoSlug.fullName]; + if (project == null) { + continue; + } + final since = sinceByProject[project.key]; + if (since != null && !issue.updatedAt.isAfter(since)) { + continue; + } + issuesByProject[project.key]!.add(issue); + final latestSeen = latestSeenByProject[project.key]; + if (latestSeen == null || issue.updatedAt.isAfter(latestSeen)) { + latestSeenByProject[project.key] = issue.updatedAt; + } + } - _log( - 'project=${project.key} issue_queue=${issues.length} ' - 'max_concurrent=${project.issueAssistant.maxConcurrentIssues}', - ); - await _processIssuesConcurrently(project, issues); + for (final project in projects) { + final projectIssues = issuesByProject[project.key]!; + _log('project=${project.key} fetched_issues=${projectIssues.length}'); + _log( + 'project=${project.key} issue_queue=${projectIssues.length} ' + 'max_concurrent=${project.issueAssistant.maxConcurrentIssues}', + ); + await _processIssuesConcurrently(project, projectIssues); - await database.upsertProjectState( - projectKey: project.key, - repo: project.repo, - lastSeenUpdatedAt: latestSeen, - ); - await database.finishPollRun(pollRunId: pollRunId, success: true); - _log( - 'poll project=${project.key} completed latest_seen=' - '${latestSeen?.toUtc().toIso8601String() ?? "unchanged"}', - ); + await database.upsertProjectState( + projectKey: project.key, + repo: project.repo, + lastSeenUpdatedAt: latestSeenByProject[project.key], + ); + await database.finishPollRun( + pollRunId: pollRunIds.remove(project.key)!, + success: true, + ); + _log( + 'poll project=${project.key} completed latest_seen=' + '${latestSeenByProject[project.key]?.toUtc().toIso8601String() ?? "unchanged"}', + ); + } } catch (error) { - await database.finishPollRun( - pollRunId: pollRunId, - success: false, - error: error.toString(), - ); - _log('poll project=${project.key} failed error=$error'); + for (final entry in pollRunIds.entries) { + await database.finishPollRun( + pollRunId: entry.value, + success: false, + error: error.toString(), + ); + _log('poll project=${entry.key} failed error=$error'); + } rethrow; } } @@ -624,9 +704,9 @@ class IssueAssistantApp { } class _ProjectPollState { - _ProjectPollState({required this.project}); + _ProjectPollState({required this.project, required this.nextDueAt}); final ProjectConfig project; - Timer? timer; + DateTime nextDueAt; bool isRunning = false; } diff --git a/test/issue_assistant_app_test.dart b/test/issue_assistant_app_test.dart index fa4ae4f..02cb267 100644 --- a/test/issue_assistant_app_test.dart +++ b/test/issue_assistant_app_test.dart @@ -1129,5 +1129,163 @@ projects: expect(jobs, hasLength(1)); expect(jobs.single.status, JobStatus.skipped); }); + + /// ```gherkin + /// Scenario: Aggregate one search request across multiple enabled projects + /// Given two temporary project checkouts that can be used as local repo paths + /// And GitHub issue data containing an explicit assistant mention in each repository + /// And a responder that emits a planning reply + /// And an orchestrator-style config with both repositories enabled + /// When the app processes one polling cycle + /// Then it fetches issue updates through one aggregate GitHub search request + /// And it still posts one reply in each repository + /// ``` + test('Aggregate one search request across multiple enabled projects', () async { + // Given two temporary project checkouts that can be used as local repo paths. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-aggregate-search-', + ); + final repoOneDir = await Directory( + p.join(sandbox.path, 'repo-one'), + ).create(); + final repoTwoDir = await Directory( + p.join(sandbox.path, 'repo-two'), + ).create(); + await initGitRepo(repoOneDir); + await initGitRepo(repoTwoDir); + + // And GitHub issue data containing an explicit assistant mention in each repository. + final ghScript = File(p.join(sandbox.path, 'gh')); + final ghLog = File(p.join(sandbox.path, 'gh-log.jsonl')); + await writeFakeGhScript( + ghScript: ghScript, + issueListResponse: const [], + commentResponses: const {}, + issueListResponsesByRepo: { + 'owner/repo-one': [ + { + 'number': 11, + 'title': 'Repo one mention', + 'body': 'first repo', + 'state': 'open', + 'html_url': 'https://example.test/owner/repo-one/issues/11', + 'updated_at': '2026-04-04T17:00:00Z', + 'user': {'login': 'reporter-one'}, + 'labels': const [], + }, + ], + 'owner/repo-two': [ + { + 'number': 22, + 'title': 'Repo two mention', + 'body': 'second repo', + 'state': 'open', + 'html_url': 'https://example.test/owner/repo-two/issues/22', + 'updated_at': '2026-04-04T17:01:00Z', + 'user': {'login': 'reporter-two'}, + 'labels': const [], + }, + ], + }, + commentResponsesByRepo: { + 'owner/repo-one': { + 11: [ + { + 'id': 110, + 'body': '@helper please review repo one', + 'html_url': + 'https://example.test/owner/repo-one/issues/11#issuecomment-110', + 'created_at': '2026-04-04T17:00:00Z', + 'updated_at': '2026-04-04T17:00:00Z', + 'user': {'login': 'reporter-one'}, + }, + ], + }, + 'owner/repo-two': { + 22: [ + { + 'id': 220, + 'body': '@helper please review repo two', + 'html_url': + 'https://example.test/owner/repo-two/issues/22#issuecomment-220', + 'created_at': '2026-04-04T17:01:00Z', + 'updated_at': '2026-04-04T17:01:00Z', + 'user': {'login': 'reporter-two'}, + }, + ], + }, + }, + postCommentIssueNumbersByRepo: { + 'owner/repo-one': {11}, + 'owner/repo-two': {22}, + }, + ghLog: ghLog, + ); + + // And a responder that emits a planning reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'aggregate reply', + 'summary': 'posted planning advice', + }); + + // And an orchestrator-style config with both repositories enabled. + final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 30s + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} +projects: + repoOne: + repo: owner/repo-one + path: ${repoOneDir.path} + repoTwo: + repo: owner/repo-two + path: ${repoTwoDir.path} +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ghCommand: ghScript.path, + ); + + // When the app processes one polling cycle. + await app.runOnce(); + await app.close(); + + // Then it fetches issue updates through one aggregate GitHub search request. + final logLines = await ghLog.readAsLines(); + expect( + logLines.where((line) => line.contains('search/issues')).length, + 1, + ); + + // And it still posts one reply in each repository. + expect( + logLines + .where( + (line) => + line.contains('repos/owner/repo-one/issues/11/comments'), + ) + .length, + 2, + ); + expect( + logLines + .where( + (line) => + line.contains('repos/owner/repo-two/issues/22/comments'), + ) + .length, + 2, + ); + }); }); } diff --git a/test/test_support.dart b/test/test_support.dart index 402373f..8f26d74 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -8,13 +8,49 @@ 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 buffer = StringBuffer() ..writeln('#!/usr/bin/env bash') ..writeln('set -euo pipefail'); @@ -42,20 +78,24 @@ Future writeFakeGhScript({ } buffer - ..writeln( - r'''if [[ "$1" == "api" && "$4" == repos/owner/sample/issues\?* ]]; then''', - ) + ..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''') ..writeln(" cat <<'JSON'") - ..writeln(jsonEncode(issueListResponse)) + ..writeln( + jsonEncode({ + 'total_count': searchItems.length, + 'incomplete_results': false, + 'items': searchItems, + }), + ) ..writeln('JSON') ..writeln(' exit 0') ..writeln('fi'); - for (final entry in commentResponses.entries) { + for (final entry in normalizedIssueListResponsesByRepo.entries) { + final repo = entry.key; buffer ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/owner/sample/issues/${entry.key}/comments?per_page=100" ]]; then', + 'if [[ "\$1" == "api" && "\$4" == repos/$repo/issues\\?* ]]; then', ) ..writeln(" cat <<'JSON'") ..writeln(jsonEncode(entry.value)) @@ -64,40 +104,55 @@ Future writeFakeGhScript({ ..writeln('fi'); } - final commentIssueNumbers = {...?postCommentIssueNumbers}; - if (postCommentIssueNumber != null) { - commentIssueNumbers.add(postCommentIssueNumber); - } - for (final issueNumber in commentIssueNumbers) { - buffer - ..writeln( - 'if [[ "\$1" == "api" && "\$4" == ' - '"repos/owner/sample/issues/$issueNumber/comments" ]]; then', - ) - ..writeln(r''' for arg in "$@"; do''') - ..writeln(r''' :'''); - if (postedBodyFile != null) { + for (final repoEntry in normalizedCommentResponsesByRepo.entries) { + final repo = repoEntry.key; + for (final entry in repoEntry.value.entries) { buffer - ..writeln(r''' if [[ "$arg" == body=* ]]; then''') ..writeln( - r''' printf '%s' "${arg#body=}" > ''' - '"${postedBodyFile.path}"', + 'if [[ "\$1" == "api" && "\$4" == ' + '"repos/$repo/issues/${entry.key}/comments?per_page=100" ]]; then', ) - ..writeln(r''' fi'''); + ..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''' 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