refactor: improve repo structure

This commit is contained in:
insleker 2026-04-10 10:45:48 +08:00
parent 2bdf75d2ad
commit 19af0bbd1b
32 changed files with 1190 additions and 1071 deletions

View File

@ -3,3 +3,5 @@
- read `README.md` for general information - read `README.md` for general information
run `pnpx skills experimental_install`, `lefthook install` before development at first time. 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 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

View File

@ -1,11 +1,11 @@
export 'src/comment_templates.dart'; export 'src/config/comment_templates.dart';
export 'src/config.dart'; export 'src/config/config.dart';
export 'src/database.dart'; export 'src/core/database.dart';
export 'src/app_logger.dart'; export 'src/config/app_logger.dart';
export 'src/app_environment.dart'; export 'src/config/app_environment.dart';
export 'src/issue_event_source/base.dart'; export 'src/issue_tracker/issue_event_source/base.dart';
export 'src/issue_tracker_client.dart'; export 'src/issue_tracker/issue_tracker_client.dart';
export 'src/issue_assistant_app.dart'; export 'src/core/issue_assistant_app.dart';
export 'src/notifier.dart'; export 'src/notifier/notifier.dart';
export 'src/scm_client.dart'; export 'src/workspace/scm_client.dart';
export 'src/workspace_manager.dart'; export 'src/workspace/workspace_manager.dart';

View File

@ -2,8 +2,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'config.dart'; import '../config/config.dart';
import 'issue_tracker_client.dart'; import '../issue_tracker/issue_tracker_client.dart';
import 'process_launcher.dart'; import 'process_launcher.dart';
class CliResponderRunner { class CliResponderRunner {

View File

@ -3,17 +3,17 @@ import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'app_logger.dart'; import '../config/app_logger.dart';
import 'cli_responder.dart'; import 'cli_responder.dart';
import 'config.dart'; import '../config/config.dart';
import 'database.dart'; import 'database.dart';
import 'issue_event_source/base.dart'; import '../issue_tracker/issue_event_source/base.dart';
import 'issue_event_source/gitea_gosmee.dart'; import '../issue_tracker/issue_event_source/gitea_gosmee.dart';
import 'issue_event_source/github_gosmee.dart'; import '../issue_tracker/issue_event_source/github_gosmee.dart';
import 'issue_event_source/github_webhook_forward.dart'; import '../issue_tracker/issue_event_source/github_webhook_forward.dart';
import 'issue_tracker_client.dart'; import '../issue_tracker/issue_tracker_client.dart';
import 'notifier.dart'; import '../notifier/notifier.dart';
import 'workspace_manager.dart'; import '../workspace/workspace_manager.dart';
class IssueAssistantApp { class IssueAssistantApp {
static final AppLogger _logger = AppLogger('code_work_spawner.app'); static final AppLogger _logger = AppLogger('code_work_spawner.app');
@ -333,7 +333,7 @@ class IssueAssistantApp {
stdout: rawStdout, stdout: rawStdout,
stderr: rawStderr, stderr: rawStderr,
); );
_log( _logError(
'responder=${responder.id} issue=${project.key}#${issue.number} ' 'responder=${responder.id} issue=${project.key}#${issue.number} '
'failed error=$error', 'failed error=$error',
); );

View File

@ -4,9 +4,9 @@ import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../app_environment.dart'; import '../config/app_environment.dart';
import '../config_schema.dart'; import '../config/config_schema.dart';
import '../process_launcher.dart'; import '../core/process_launcher.dart';
import 'models.dart'; import 'models.dart';
class IssueTrackerClient { class IssueTrackerClient {

View File

@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import '../app_logger.dart'; import '../../config/app_logger.dart';
import '../config.dart'; import '../../config/config.dart';
import '../database.dart'; import '../../core/database.dart';
import '../issue_tracker_client.dart'; import '../client.dart';
import '../models.dart';
typedef IssueEventBatchHandler = Future<void> Function(IssueEventBatch batch); typedef IssueEventBatchHandler = Future<void> Function(IssueEventBatch batch);

View File

@ -4,10 +4,11 @@ import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import '../app_logger.dart'; import '../../config/app_logger.dart';
import '../config.dart'; import '../../config/config.dart';
import '../issue_tracker_client.dart'; import '../client.dart';
import '../process_launcher.dart'; import '../models.dart';
import '../../core/process_launcher.dart';
import 'base.dart'; import 'base.dart';
class GiteaGosmeeIssueEventSource implements IssueEventSource { class GiteaGosmeeIssueEventSource implements IssueEventSource {

View File

@ -4,10 +4,11 @@ import 'dart:io';
import 'package:github/github.dart'; import 'package:github/github.dart';
import '../app_logger.dart'; import '../../config/app_logger.dart';
import '../config.dart'; import '../../config/config.dart';
import '../issue_tracker_client.dart'; import '../client.dart';
import '../process_launcher.dart'; import '../models.dart';
import '../../core/process_launcher.dart';
import 'base.dart'; import 'base.dart';
class GitHubGosmeeIssueEventSource implements IssueEventSource { class GitHubGosmeeIssueEventSource implements IssueEventSource {

View File

@ -2,10 +2,11 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import '../app_logger.dart'; import '../../config/app_logger.dart';
import '../config.dart'; import '../../config/config.dart';
import '../issue_tracker_client.dart'; import '../client.dart';
import '../process_launcher.dart'; import '../models.dart';
import '../../core/process_launcher.dart';
import 'base.dart'; import 'base.dart';
class GitHubWebhookForwardIssueEventSource implements IssueEventSource { class GitHubWebhookForwardIssueEventSource implements IssueEventSource {

View File

@ -0,0 +1,2 @@
export 'client.dart';
export 'models.dart';

View File

@ -1,2 +0,0 @@
export 'issue_tracker/client.dart';
export 'issue_tracker/models.dart';

View File

@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'config.dart'; import '../config/config.dart';
typedef NotificationLogger = void Function(String message); typedef NotificationLogger = void Function(String message);
typedef DesktopNotificationCommandRunner = typedef DesktopNotificationCommandRunner =

View File

@ -3,8 +3,8 @@ import 'dart:io';
import 'package:git/git.dart'; import 'package:git/git.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'app_environment.dart'; import '../config/app_environment.dart';
import 'config.dart'; import '../config/config.dart';
abstract interface class ScmClient { abstract interface class ScmClient {
Future<String> createEphemeralWorkspace({ Future<String> createEphemeralWorkspace({

View File

@ -1,4 +1,4 @@
import 'config.dart'; import '../config/config.dart';
import 'scm_client.dart'; import 'scm_client.dart';
class WorkspaceManager { class WorkspaceManager {

View File

@ -349,7 +349,7 @@
"eval-driven-dev": { "eval-driven-dev": {
"source": "github/awesome-copilot", "source": "github/awesome-copilot",
"sourceType": "github", "sourceType": "github",
"computedHash": "0b2cf0b8a57cf332eecb36ca00c6bb0636a3b0ecfc1f79f28b1fd4bab7f8d4fb" "computedHash": "1b5d8a5edb5c3540cd260e9ac71394077f814151b71fc343afb6ffd7e15c21b9"
}, },
"excalidraw-diagram-generator": { "excalidraw-diagram-generator": {
"source": "github/awesome-copilot", "source": "github/awesome-copilot",
@ -944,7 +944,7 @@
"worktrunk": { "worktrunk": {
"source": "max-sixty/worktrunk", "source": "max-sixty/worktrunk",
"sourceType": "github", "sourceType": "github",
"computedHash": "d480468e16c7de39a121e6e760788357bb178495f8912b05f05f55977468d72e" "computedHash": "e95e53e4ef26baddf9efb6b6c4e08276384edba9d7e9269d61a11f2c6871c6d5"
}, },
"write-coding-standards-from-file": { "write-coding-standards-from-file": {
"source": "github/awesome-copilot", "source": "github/awesome-copilot",

View File

@ -4,6 +4,8 @@ import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../test_support.dart';
void main() { void main() {
/// ```gherkin /// ```gherkin
/// Feature: Init-config CLI /// Feature: Init-config CLI
@ -21,21 +23,16 @@ void main() {
/// ``` /// ```
test('Show init-config in top-level help', () async { test('Show init-config in top-level help', () async {
// Given the CLI is executed with the top-level help flag. // Given the CLI is executed with the top-level help flag.
final result = await Process.run(Platform.resolvedExecutable, [ final result = await runCli(['--help']);
'run',
'bin/code_work_spawner.dart',
'--help',
], workingDirectory: Directory.current.path);
// When the process completes successfully. // 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. // Then stdout lists the init-config command in the available commands section.
final stdoutText = result.stdout as String; expect(result.stdout, contains('Available commands:'));
expect(stdoutText, contains('Available commands:')); expect(result.stdout, contains('init-config'));
expect(stdoutText, contains('init-config')); expect(result.stdout, contains('--glab-command'));
expect(stdoutText, contains('--glab-command')); expect(result.stdout, contains('--gosmee-command'));
expect(stdoutText, contains('--gosmee-command'));
}); });
/// ```gherkin /// ```gherkin
@ -49,29 +46,27 @@ void main() {
// Given the CLI is executed with the init-config command. // Given the CLI is executed with the init-config command.
final sandbox = await Directory.systemTemp.createTemp('cws-cli-stdout-'); final sandbox = await Directory.systemTemp.createTemp('cws-cli-stdout-');
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml')); final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
final result = await Process.run(Platform.resolvedExecutable, [ final result = await runCli(['init-config']);
'run',
'bin/code_work_spawner.dart',
'init-config',
], workingDirectory: Directory.current.path);
// When the process completes successfully. // 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. // Then stdout contains the starter config content.
final stdoutText = result.stdout as String; expect(result.stdout, contains('dataDir:'));
expect(stdoutText, contains('dataDir:')); expect(result.stdout, contains('# responders:'));
expect(stdoutText, contains('# responders:')); expect(result.stdout, contains('# notifications:'));
expect(stdoutText, contains('# notifications:')); expect(
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}')); result.stdout,
expect(stdoutText, contains('# - type: desktop')); contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'),
expect(stdoutText, contains('issueTracker:')); );
expect(stdoutText, contains('provider: github')); expect(result.stdout, contains('# - type: desktop'));
expect(stdoutText, contains('scm:')); expect(result.stdout, contains('issueTracker:'));
expect(stdoutText, contains('projects:')); 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. // 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); final config = await AppConfig.load(configFile.path);
expect(config.projects['sample'], isNotNull); expect(config.projects['sample'], isNotNull);
}); });
@ -90,31 +85,27 @@ void main() {
await outputFile.writeAsString('original'); await outputFile.writeAsString('original');
// When the CLI writes without the force flag and then with the force flag. // When the CLI writes without the force flag and then with the force flag.
final withoutForce = await Process.run(Platform.resolvedExecutable, [ final withoutForce = await runCli([
'run',
'bin/code_work_spawner.dart',
'init-config', 'init-config',
'--output', '--output',
outputFile.path, outputFile.path,
], workingDirectory: Directory.current.path); ]);
final withForce = await Process.run(Platform.resolvedExecutable, [ final withForce = await runCli([
'run',
'bin/code_work_spawner.dart',
'init-config', 'init-config',
'--output', '--output',
outputFile.path, outputFile.path,
'--force', '--force',
], workingDirectory: Directory.current.path); ]);
// Then the first run fails with an overwrite error. // Then the first run fails with an overwrite error.
expect(withoutForce.exitCode, 2); expect(withoutForce.exitCode, 2);
expect( expect(
withoutForce.stderr as String, withoutForce.stderr,
contains('Refusing to overwrite existing file'), contains('Refusing to overwrite existing file'),
); );
// And the forced run replaces the file with a valid starter config. // 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(); final contents = await outputFile.readAsString();
expect(contents, contains('defaults:')); expect(contents, contains('defaults:'));
expect(contents, contains('issueTracker:')); expect(contents, contains('issueTracker:'));

View File

@ -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<CliResult> runCli(List<String> args) async {
final out = _CapturingStdout();
final err = _CapturingStdout();
exitCode = 0;
try {
await IOOverrides.runZoned<Future<void>>(
() => _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<dynamic> objects, [String separator = '']) =>
_buffer.writeAll(objects, separator);
@override
void writeCharCode(int charCode) => _buffer.writeCharCode(charCode);
@override
void add(List<int> data) => _buffer.write(utf8.decode(data));
@override
void addError(Object error, [StackTrace? stackTrace]) {}
@override
Future<void> addStream(Stream<List<int>> stream) async {
await for (final chunk in stream) {
_buffer.write(utf8.decode(chunk));
}
}
@override
Future<void> flush() => Future.value();
@override
Future<void> close() => Future.value();
@override
Future<void> get done => Future.value();
}

522
test/support/fake_gh.dart Normal file
View File

@ -0,0 +1,522 @@
import 'dart:convert';
import 'dart:io';
import 'script_utils.dart';
Future<void> writeFakeGhScript({
required File ghScript,
required List<Map<String, Object?>> issueListResponse,
required Map<int, List<Map<String, Object?>>> commentResponses,
Map<String, List<Map<String, Object?>>>? issueListResponsesByRepo,
Map<String, Map<int, List<Map<String, Object?>>>>? commentResponsesByRepo,
File? ghLog,
int? postCommentIssueNumber,
Set<int>? postCommentIssueNumbers,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedIssueListResponsesByRepo =
issueListResponsesByRepo ??
<String, List<Map<String, Object?>>>{'owner/sample': issueListResponse};
final normalizedCommentResponsesByRepo =
commentResponsesByRepo ??
<String, Map<int, List<Map<String, Object?>>>>{
'owner/sample': commentResponses,
};
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
'owner/sample': {
...?postCommentIssueNumbers,
...?postCommentIssueNumber == null ? null : {postCommentIssueNumber},
},
};
final searchItems =
normalizedIssueListResponsesByRepo.entries
.expand(
(entry) => entry.value.map(
(issue) => <String, Object?>{
...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<void> writeFlakySearchGhScript({
required File ghScript,
required File failureStateFile,
required List<Map<String, Object?>> issueListResponse,
required Map<int, List<Map<String, Object?>>> commentResponses,
File? ghLog,
}) async {
final searchItems = issueListResponse
.map(
(issue) => <String, Object?>{
...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<void> writeWebhookForwardGhScript({
required File ghScript,
required String repo,
required Map<String, Object?> issue,
required List<Map<String, Object?>> comments,
List<Map<String, Object?>> 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) => <String, Object?>{
...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<void> writeGitHubGosmeeGhScript({
required File ghScript,
required String repo,
required int webhookId,
required int issueNumber,
required List<Map<String, Object?>> comments,
List<Map<String, Object?>> issueListResponse = const [],
File? ghLog,
File? postedBodyFile,
String? viewerLogin,
}) async {
final searchItems = issueListResponse
.map(
(issue) => <String, Object?>{
...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);
}

126
test/support/fake_glab.dart Normal file
View File

@ -0,0 +1,126 @@
import 'dart:convert';
import 'dart:io';
import 'script_utils.dart';
Future<void> writeFakeGlabScript({
required File glabScript,
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
required Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo,
File? glabLog,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries)
entry.key: entry.value
.map((issue) => issue['iid'])
.whereType<int>()
.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);
}

View File

@ -0,0 +1,63 @@
import 'dart:convert';
import 'dart:io';
import 'script_utils.dart';
Future<void> writeGosmeeForwardScript({
required File gosmeeScript,
required String publicUrl,
required Map<String, Object?> payload,
File? gosmeeLog,
}) async {
final buffer = StringBuffer()
..writeln('#!/usr/bin/env bash')
..writeln('set -euo pipefail');
if (gosmeeLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${bashScriptPath(gosmeeLog.path)}"',
);
}
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);
}

View File

@ -0,0 +1,64 @@
import 'dart:convert';
import 'dart:io';
import 'script_utils.dart';
Future<void> writeResponderScript(
File responderScript,
Map<String, Object?> 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<void> writeJsonlResponderScript(
File responderScript,
Map<String, Object?> response,
) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
cat <<'JSON'
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await makeScriptExecutable(responderScript);
}
Future<void> writeDelayedLoggingResponderScript(
File responderScript, {
required File eventLog,
required Duration delay,
required Map<String, Object?> response,
}) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
timestamp_ms() {
python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
}
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);
}

125
test/support/fake_tea.dart Normal file
View File

@ -0,0 +1,125 @@
import 'dart:convert';
import 'dart:io';
import 'script_utils.dart';
Future<void> writeFakeTeaScript({
required File teaScript,
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
required Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo,
File? teaLog,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries)
entry.key: entry.value
.map((issue) => issue['index'] ?? issue['number'])
.whereType<int>()
.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);
}

View File

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:git/git.dart';
import 'package:path/path.dart' as p;
Future<void> 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<String> 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<void> runGit(
List<String> arguments, {
required String workingDirectory,
}) async {
final environment = Map<String, String>.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,
);
}
}

View File

@ -0,0 +1,49 @@
import 'dart:io';
Future<void> 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';
}

View File

@ -1,983 +1,8 @@
import 'dart:convert'; export 'support/script_utils.dart';
import 'dart:io'; export 'support/fake_gh.dart';
export 'support/fake_tea.dart';
import 'package:git/git.dart'; export 'support/fake_glab.dart';
import 'package:path/path.dart' as p; export 'support/fake_responders.dart';
export 'support/fake_gosmee.dart';
Future<void> makeScriptExecutable(File script) async { export 'support/git_utils.dart';
if (Platform.isWindows) { export 'support/cli_utils.dart';
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<void> writeFakeGhScript({
required File ghScript,
required List<Map<String, Object?>> issueListResponse,
required Map<int, List<Map<String, Object?>>> commentResponses,
Map<String, List<Map<String, Object?>>>? issueListResponsesByRepo,
Map<String, Map<int, List<Map<String, Object?>>>>? commentResponsesByRepo,
File? ghLog,
int? postCommentIssueNumber,
Set<int>? postCommentIssueNumbers,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedIssueListResponsesByRepo =
issueListResponsesByRepo ??
<String, List<Map<String, Object?>>>{'owner/sample': issueListResponse};
final normalizedCommentResponsesByRepo =
commentResponsesByRepo ??
<String, Map<int, List<Map<String, Object?>>>>{
'owner/sample': commentResponses,
};
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
'owner/sample': {
...?postCommentIssueNumbers,
...?postCommentIssueNumber == null ? null : {postCommentIssueNumber},
},
};
final searchItems =
normalizedIssueListResponsesByRepo.entries
.expand(
(entry) => entry.value.map(
(issue) => <String, Object?>{
...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<void> writeFlakySearchGhScript({
required File ghScript,
required File failureStateFile,
required List<Map<String, Object?>> issueListResponse,
required Map<int, List<Map<String, Object?>>> commentResponses,
File? ghLog,
}) async {
final searchItems = issueListResponse
.map(
(issue) => <String, Object?>{
...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<void> writeFakeTeaScript({
required File teaScript,
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
required Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo,
File? teaLog,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries)
entry.key: entry.value
.map((issue) => issue['index'] ?? issue['number'])
.whereType<int>()
.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<void> writeFakeGlabScript({
required File glabScript,
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
required Map<String, Map<int, List<Map<String, Object?>>>>
commentResponsesByRepo,
File? glabLog,
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
File? postedBodyFile,
String? viewerLogin,
bool failViewerLookup = false,
}) async {
final normalizedPostCommentIssueNumbersByRepo =
postCommentIssueNumbersByRepo ??
<String, Set<int>>{
for (final entry in issueListResponsesByRepo.entries)
entry.key: entry.value
.map((issue) => issue['iid'])
.whereType<int>()
.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<void> writeResponderScript(
File responderScript,
Map<String, Object?> 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<void> writeJsonlResponderScript(
File responderScript,
Map<String, Object?> response,
) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
cat <<'JSON'
{"type":"thread.started","thread_id":"test-thread"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":${jsonEncode(jsonEncode(response))}}}
{"type":"turn.completed"}
JSON
''');
await makeScriptExecutable(responderScript);
}
Future<void> writeDelayedLoggingResponderScript(
File responderScript, {
required File eventLog,
required Duration delay,
required Map<String, Object?> response,
}) async {
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
timestamp_ms() {
python3 - <<'PY'
import time
print(int(time.time() * 1000))
PY
}
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<void> writeWebhookForwardGhScript({
required File ghScript,
required String repo,
required Map<String, Object?> issue,
required List<Map<String, Object?>> comments,
List<Map<String, Object?>> 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) => <String, Object?>{
...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<void> writeGitHubGosmeeGhScript({
required File ghScript,
required String repo,
required int webhookId,
required int issueNumber,
required List<Map<String, Object?>> comments,
List<Map<String, Object?>> issueListResponse = const [],
File? ghLog,
File? postedBodyFile,
String? viewerLogin,
}) async {
final searchItems = issueListResponse
.map(
(issue) => <String, Object?>{
...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<void> writeGosmeeForwardScript({
required File gosmeeScript,
required String publicUrl,
required Map<String, Object?> payload,
File? gosmeeLog,
}) async {
final buffer = StringBuffer()
..writeln('#!/usr/bin/env bash')
..writeln('set -euo pipefail');
if (gosmeeLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${bashScriptPath(gosmeeLog.path)}"',
);
}
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<void> 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<String> 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<void> runGit(
List<String> arguments, {
required String workingDirectory,
}) async {
final environment = Map<String, String>.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,
);
}
}