Merge pull request #40 from existedinnettw/feat/37
feat: add glab/gosmee GitLab channel support
This commit is contained in:
commit
4da82d7d49
|
|
@ -3,7 +3,7 @@ targets:
|
||||||
builders:
|
builders:
|
||||||
drift_dev:
|
drift_dev:
|
||||||
generate_for:
|
generate_for:
|
||||||
- lib/src/database.dart
|
- lib/src/core/database.dart
|
||||||
json_serializable:
|
json_serializable:
|
||||||
generate_for:
|
generate_for:
|
||||||
- lib/src/config_schema.dart
|
- lib/src/config/config_schema.dart
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,20 @@ matching provider for existing GitHub, GitLab, and Gitea configs.
|
||||||
|
|
||||||
GitHub projects default to `gh/polling`, GitLab projects default to
|
GitHub projects default to `gh/polling`, GitLab projects default to
|
||||||
`glab/polling`, and Gitea projects default to `tea/polling`. OpenProject
|
`glab/polling`, and Gitea projects default to `tea/polling`. OpenProject
|
||||||
projects default to `op/polling` and currently only support polling. GitLab and
|
projects default to `op/polling` and currently only support polling. Because
|
||||||
OpenProject currently support polling only. Because OpenProject issue tracking
|
OpenProject issue tracking
|
||||||
is not tied to a repository host, OpenProject projects must set
|
is not tied to a repository host, OpenProject projects must set
|
||||||
`scm.provider` explicitly. GitHub projects can switch their long-running runtime to
|
`scm.provider` explicitly. GitHub projects can switch their long-running runtime to
|
||||||
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
|
`gh/gosmee` or `gh/webhook-forward`, GitLab projects can switch to
|
||||||
`tea/gosmee`.
|
`glab/gosmee`, and Gitea projects can switch to `tea/gosmee`.
|
||||||
For OpenProject, `repo` may be either a numeric project id or an exact project
|
For OpenProject, `repo` may be either a numeric project id or an exact project
|
||||||
name.
|
name.
|
||||||
`gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee
|
`gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee
|
||||||
URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and
|
URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and
|
||||||
`issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that
|
`issue_comment` events. `glab/gosmee` creates a temporary GitLab webhook that
|
||||||
targets a generated gosmee URL. Channel-based event sources always run one
|
targets a generated gosmee URL for issue and note events. `tea/gosmee` creates
|
||||||
|
a temporary Gitea webhook that targets a generated gosmee URL. Channel-based
|
||||||
|
event sources always run one
|
||||||
startup reconciliation poll. Set `reconciliation: continuous` plus
|
startup reconciliation poll. Set `reconciliation: continuous` plus
|
||||||
`reconcileInterval` to keep periodic reconciliation active for missed-event
|
`reconcileInterval` to keep periodic reconciliation active for missed-event
|
||||||
recovery, or use `reconciliation: startup-only` to skip background polling
|
recovery, or use `reconciliation: startup-only` to skip background polling
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,14 @@ projects:
|
||||||
sample-gitlab:
|
sample-gitlab:
|
||||||
issueTracker:
|
issueTracker:
|
||||||
provider: gitlab
|
provider: gitlab
|
||||||
# GitLab currently supports polling.
|
# Use glab/gosmee for long-running runs.
|
||||||
eventSource:
|
eventSource:
|
||||||
type: glab/polling
|
type: glab/polling
|
||||||
pollInterval: 30s
|
pollInterval: 30s
|
||||||
|
# eventSource:
|
||||||
|
# type: glab/gosmee
|
||||||
|
# reconciliation: continuous
|
||||||
|
# reconcileInterval: 30s
|
||||||
scm:
|
scm:
|
||||||
provider: gitlab
|
provider: gitlab
|
||||||
repo: group/subgroup/repo
|
repo: group/subgroup/repo
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,7 @@ String _eventSourceConfigValue(IssueTrackerEventSourceKind value) {
|
||||||
IssueTrackerEventSourceKind.teaPolling => 'tea/polling',
|
IssueTrackerEventSourceKind.teaPolling => 'tea/polling',
|
||||||
IssueTrackerEventSourceKind.opPolling => 'op/polling',
|
IssueTrackerEventSourceKind.opPolling => 'op/polling',
|
||||||
IssueTrackerEventSourceKind.ghGosmee => 'gh/gosmee',
|
IssueTrackerEventSourceKind.ghGosmee => 'gh/gosmee',
|
||||||
|
IssueTrackerEventSourceKind.glabGosmee => 'glab/gosmee',
|
||||||
IssueTrackerEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
|
IssueTrackerEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
|
||||||
IssueTrackerEventSourceKind.teaGosmee => 'tea/gosmee',
|
IssueTrackerEventSourceKind.teaGosmee => 'tea/gosmee',
|
||||||
};
|
};
|
||||||
|
|
@ -697,6 +698,7 @@ bool _isPollingEventSource(IssueTrackerEventSourceKind value) {
|
||||||
IssueTrackerEventSourceKind.teaPolling => true,
|
IssueTrackerEventSourceKind.teaPolling => true,
|
||||||
IssueTrackerEventSourceKind.opPolling => true,
|
IssueTrackerEventSourceKind.opPolling => true,
|
||||||
IssueTrackerEventSourceKind.ghGosmee => false,
|
IssueTrackerEventSourceKind.ghGosmee => false,
|
||||||
|
IssueTrackerEventSourceKind.glabGosmee => false,
|
||||||
IssueTrackerEventSourceKind.ghWebhookForward => false,
|
IssueTrackerEventSourceKind.ghWebhookForward => false,
|
||||||
IssueTrackerEventSourceKind.teaGosmee => false,
|
IssueTrackerEventSourceKind.teaGosmee => false,
|
||||||
};
|
};
|
||||||
|
|
@ -959,6 +961,7 @@ IssueTrackerProvider _issueTrackerProviderForEventSource(
|
||||||
IssueTrackerEventSourceKind.teaPolling => IssueTrackerProvider.gitea,
|
IssueTrackerEventSourceKind.teaPolling => IssueTrackerProvider.gitea,
|
||||||
IssueTrackerEventSourceKind.opPolling => IssueTrackerProvider.openproject,
|
IssueTrackerEventSourceKind.opPolling => IssueTrackerProvider.openproject,
|
||||||
IssueTrackerEventSourceKind.ghGosmee => IssueTrackerProvider.github,
|
IssueTrackerEventSourceKind.ghGosmee => IssueTrackerProvider.github,
|
||||||
|
IssueTrackerEventSourceKind.glabGosmee => IssueTrackerProvider.gitlab,
|
||||||
IssueTrackerEventSourceKind.ghWebhookForward => IssueTrackerProvider.github,
|
IssueTrackerEventSourceKind.ghWebhookForward => IssueTrackerProvider.github,
|
||||||
IssueTrackerEventSourceKind.teaGosmee => IssueTrackerProvider.gitea,
|
IssueTrackerEventSourceKind.teaGosmee => IssueTrackerProvider.gitea,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,8 @@ enum IssueTrackerEventSourceKind {
|
||||||
opPolling,
|
opPolling,
|
||||||
@JsonValue('gh/gosmee')
|
@JsonValue('gh/gosmee')
|
||||||
ghGosmee,
|
ghGosmee,
|
||||||
|
@JsonValue('glab/gosmee')
|
||||||
|
glabGosmee,
|
||||||
@JsonValue('gh/webhook-forward')
|
@JsonValue('gh/webhook-forward')
|
||||||
ghWebhookForward,
|
ghWebhookForward,
|
||||||
@JsonValue('tea/gosmee')
|
@JsonValue('tea/gosmee')
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../config/config.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import '../issue_tracker/issue_event_source/base.dart';
|
import '../issue_tracker/issue_event_source/base.dart';
|
||||||
import '../issue_tracker/issue_event_source/gitea_gosmee.dart';
|
import '../issue_tracker/issue_event_source/gitea_gosmee.dart';
|
||||||
|
import '../issue_tracker/issue_event_source/gitlab_gosmee.dart';
|
||||||
import '../issue_tracker/issue_event_source/github_gosmee.dart';
|
import '../issue_tracker/issue_event_source/github_gosmee.dart';
|
||||||
import '../issue_tracker/issue_event_source/github_webhook_forward.dart';
|
import '../issue_tracker/issue_event_source/github_webhook_forward.dart';
|
||||||
import '../issue_tracker/issue_tracker_client.dart';
|
import '../issue_tracker/issue_tracker_client.dart';
|
||||||
|
|
@ -72,6 +73,11 @@ class IssueAssistantApp {
|
||||||
gosmeeCommand: gosmeeCommand,
|
gosmeeCommand: gosmeeCommand,
|
||||||
logger: _logger,
|
logger: _logger,
|
||||||
),
|
),
|
||||||
|
IssueTrackerEventSourceKind.glabGosmee: GitLabGosmeeIssueEventSource(
|
||||||
|
issueTrackerClient: issueTrackerClient,
|
||||||
|
gosmeeCommand: gosmeeCommand,
|
||||||
|
logger: _logger,
|
||||||
|
),
|
||||||
IssueTrackerEventSourceKind.ghWebhookForward:
|
IssueTrackerEventSourceKind.ghWebhookForward:
|
||||||
GitHubWebhookForwardIssueEventSource(
|
GitHubWebhookForwardIssueEventSource(
|
||||||
issueTrackerClient: issueTrackerClient,
|
issueTrackerClient: issueTrackerClient,
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,32 @@ class IssueTrackerClient {
|
||||||
return readIntField(json, 'id');
|
return readIntField(json, 'id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<int> createGitLabIssueWebhook({
|
||||||
|
required String trackerProject,
|
||||||
|
required String url,
|
||||||
|
}) async {
|
||||||
|
final json = await _runGlabJson([
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'POST',
|
||||||
|
_buildGitLabApiPath(trackerProject, ['hooks']),
|
||||||
|
'-f',
|
||||||
|
'url=$url',
|
||||||
|
'-f',
|
||||||
|
'issues_events=true',
|
||||||
|
'-f',
|
||||||
|
'note_events=true',
|
||||||
|
'-f',
|
||||||
|
'push_events=false',
|
||||||
|
]);
|
||||||
|
if (json is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException(
|
||||||
|
'glab api webhook create returned an unexpected response.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return readIntField(json, 'id');
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteGitHubWebhook({
|
Future<void> deleteGitHubWebhook({
|
||||||
required RepositorySlug repo,
|
required RepositorySlug repo,
|
||||||
required int webhookId,
|
required int webhookId,
|
||||||
|
|
@ -298,6 +324,18 @@ class IssueTrackerClient {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteGitLabWebhook({
|
||||||
|
required String trackerProject,
|
||||||
|
required int webhookId,
|
||||||
|
}) async {
|
||||||
|
await _runGlabJson([
|
||||||
|
'api',
|
||||||
|
'-X',
|
||||||
|
'DELETE',
|
||||||
|
_buildGitLabApiPath(trackerProject, ['hooks', '$webhookId']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
||||||
required Iterable<String> trackerProjects,
|
required Iterable<String> trackerProjects,
|
||||||
required DateTime? since,
|
required DateTime? since,
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,11 @@ class RoutedIssueEventSource implements IssueEventSource {
|
||||||
yield IssueTrackerEventSourceKind.ghPolling;
|
yield IssueTrackerEventSourceKind.ghPolling;
|
||||||
}
|
}
|
||||||
yield IssueTrackerEventSourceKind.ghGosmee;
|
yield IssueTrackerEventSourceKind.ghGosmee;
|
||||||
|
case IssueTrackerEventSourceKind.glabGosmee:
|
||||||
|
if (_shouldRunContinuousReconciliation(project)) {
|
||||||
|
yield IssueTrackerEventSourceKind.glabPolling;
|
||||||
|
}
|
||||||
|
yield IssueTrackerEventSourceKind.glabGosmee;
|
||||||
case IssueTrackerEventSourceKind.ghWebhookForward:
|
case IssueTrackerEventSourceKind.ghWebhookForward:
|
||||||
if (_shouldRunContinuousReconciliation(project)) {
|
if (_shouldRunContinuousReconciliation(project)) {
|
||||||
yield IssueTrackerEventSourceKind.ghPolling;
|
yield IssueTrackerEventSourceKind.ghPolling;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../config/app_logger.dart';
|
||||||
|
import '../../config/config.dart';
|
||||||
|
import '../../core/process_launcher.dart';
|
||||||
|
import '../client.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base.dart';
|
||||||
|
|
||||||
|
class GitLabGosmeeIssueEventSource implements IssueEventSource {
|
||||||
|
GitLabGosmeeIssueEventSource({
|
||||||
|
required this.issueTrackerClient,
|
||||||
|
required this.gosmeeCommand,
|
||||||
|
required AppLogger logger,
|
||||||
|
}) : _logger = logger;
|
||||||
|
|
||||||
|
static const Duration _webhookCoalescingWindow = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
final IssueTrackerClient issueTrackerClient;
|
||||||
|
final String gosmeeCommand;
|
||||||
|
final AppLogger _logger;
|
||||||
|
final Map<String, _QueuedGitLabIssue> _queuedIssues = {};
|
||||||
|
final Set<Future<void>> _activeDispatches = <Future<void>>{};
|
||||||
|
final Map<String, Process> _gosmeeProcesses = {};
|
||||||
|
final Map<String, int> _webhookIdsByProject = {};
|
||||||
|
final Map<String, String> _trackerProjectsByProject = {};
|
||||||
|
|
||||||
|
HttpServer? _server;
|
||||||
|
Timer? _flushTimer;
|
||||||
|
Completer<void>? _runCompleter;
|
||||||
|
bool _isClosing = false;
|
||||||
|
bool _isClosed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> run({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
if (_isClosed) {
|
||||||
|
throw StateError('IssueEventSource is already closed.');
|
||||||
|
}
|
||||||
|
if (_runCompleter != null) {
|
||||||
|
return _runCompleter!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final gitLabProjects = projects
|
||||||
|
.where((project) => project.provider == IssueTrackerProvider.gitlab)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (gitLabProjects.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_runCompleter = completer;
|
||||||
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
|
_server = server;
|
||||||
|
final repoToProject = {
|
||||||
|
for (final project in gitLabProjects) project.repo: project,
|
||||||
|
};
|
||||||
|
for (final project in gitLabProjects) {
|
||||||
|
_trackerProjectsByProject[project.key] = project.repo;
|
||||||
|
}
|
||||||
|
server.listen(
|
||||||
|
(request) => unawaited(
|
||||||
|
_handleWebhookRequest(
|
||||||
|
request: request,
|
||||||
|
repoToProject: repoToProject,
|
||||||
|
onBatch: onBatch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
|
_logError('gitlab/gosmee server error=$error');
|
||||||
|
if (!_isClosing && !completer.isCompleted) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final localUrl =
|
||||||
|
'http://${server.address.address}:${server.port}/gitlab/webhooks';
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee listening local_url=$localUrl '
|
||||||
|
'projects=${gitLabProjects.map((project) => project.key).join(",")}',
|
||||||
|
);
|
||||||
|
for (final project in gitLabProjects) {
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${project.key} create_channel '
|
||||||
|
'command="$gosmeeCommand --output json client --new-url $localUrl"',
|
||||||
|
);
|
||||||
|
final publicUrl = await _createGosmeeChannelUrl(localUrl);
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${project.key} channel_ready '
|
||||||
|
'public_url=$publicUrl local_url=$localUrl',
|
||||||
|
);
|
||||||
|
final webhookId = await issueTrackerClient.createGitLabIssueWebhook(
|
||||||
|
trackerProject: project.repo,
|
||||||
|
url: publicUrl,
|
||||||
|
);
|
||||||
|
_webhookIdsByProject[project.key] = webhookId;
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${project.key} webhook_registered '
|
||||||
|
'webhook_id=$webhookId repo=${project.repo}',
|
||||||
|
);
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${project.key} start_client '
|
||||||
|
'command="$gosmeeCommand client $publicUrl $localUrl"',
|
||||||
|
);
|
||||||
|
final process = await startExternalCommand(gosmeeCommand, [
|
||||||
|
'client',
|
||||||
|
publicUrl,
|
||||||
|
localUrl,
|
||||||
|
]);
|
||||||
|
_gosmeeProcesses[project.key] = process;
|
||||||
|
_pipeProcessLogs(
|
||||||
|
project: project,
|
||||||
|
stream: process.stdout,
|
||||||
|
level: 'stdout',
|
||||||
|
);
|
||||||
|
_pipeProcessLogs(
|
||||||
|
project: project,
|
||||||
|
stream: process.stderr,
|
||||||
|
level: 'stderr',
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
process.exitCode.then((exitCode) {
|
||||||
|
_gosmeeProcesses.remove(project.key);
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee process project=${project.key} exit_code=$exitCode',
|
||||||
|
);
|
||||||
|
if (!_isClosing && !completer.isCompleted) {
|
||||||
|
completer.completeError(
|
||||||
|
StateError(
|
||||||
|
'gosmee client exited for project=${project.key} '
|
||||||
|
'with code $exitCode.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await completer.future;
|
||||||
|
} finally {
|
||||||
|
await close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> runOnce({
|
||||||
|
required List<ProjectConfig> projects,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
throw UnsupportedError(
|
||||||
|
'gitlab/gosmee does not support runOnce; use polling instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {
|
||||||
|
if (_isClosed || _isClosing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isClosing = true;
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
final completer = _runCompleter;
|
||||||
|
if (completer != null && !completer.isCompleted) {
|
||||||
|
completer.complete();
|
||||||
|
}
|
||||||
|
_runCompleter = null;
|
||||||
|
|
||||||
|
final server = _server;
|
||||||
|
_server = null;
|
||||||
|
await server?.close(force: true);
|
||||||
|
|
||||||
|
final processes = _gosmeeProcesses.values.toList(growable: false);
|
||||||
|
_gosmeeProcesses.clear();
|
||||||
|
for (final process in processes) {
|
||||||
|
process.kill();
|
||||||
|
}
|
||||||
|
for (final process in processes) {
|
||||||
|
await process.exitCode.catchError((_) => 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final webhookIds = Map<String, int>.from(_webhookIdsByProject);
|
||||||
|
_webhookIdsByProject.clear();
|
||||||
|
for (final entry in webhookIds.entries) {
|
||||||
|
try {
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${entry.key} webhook_delete '
|
||||||
|
'webhook_id=${entry.value}',
|
||||||
|
);
|
||||||
|
await issueTrackerClient.deleteGitLabWebhook(
|
||||||
|
trackerProject: _trackerProjectsByProject[entry.key]!,
|
||||||
|
webhookId: entry.value,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
_logError(
|
||||||
|
'gitlab/gosmee delete webhook project=${entry.key} '
|
||||||
|
'webhook_id=${entry.value} error=$error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_trackerProjectsByProject.clear();
|
||||||
|
|
||||||
|
await _flushQueuedIssues();
|
||||||
|
await Future.wait(_activeDispatches.toList(growable: false));
|
||||||
|
|
||||||
|
_isClosed = true;
|
||||||
|
_isClosing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleWebhookRequest({
|
||||||
|
required HttpRequest request,
|
||||||
|
required Map<String, ProjectConfig> repoToProject,
|
||||||
|
required IssueEventBatchHandler onBatch,
|
||||||
|
}) async {
|
||||||
|
if (request.method != 'POST') {
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee webhook ignored method=${request.method} '
|
||||||
|
'path=${request.uri.path}',
|
||||||
|
);
|
||||||
|
request.response
|
||||||
|
..statusCode = HttpStatus.methodNotAllowed
|
||||||
|
..write('Only POST is supported.');
|
||||||
|
await request.response.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final payloadText = await utf8.decoder.bind(request).join();
|
||||||
|
final payload = jsonDecode(payloadText);
|
||||||
|
if (payload is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('Webhook payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
final projectNode = payload['project'];
|
||||||
|
final fullName = projectNode is Map<String, dynamic>
|
||||||
|
? projectNode['path_with_namespace']?.toString()
|
||||||
|
: null;
|
||||||
|
final project = fullName == null ? null : repoToProject[fullName];
|
||||||
|
final issue = _parseIssueFromPayload(payload, trackerProject: fullName);
|
||||||
|
final objectKind = payload['object_kind']?.toString() ?? 'unknown';
|
||||||
|
final action = _webhookAction(payload);
|
||||||
|
if (project != null && issue != null) {
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee webhook accepted project=${project.key} '
|
||||||
|
'repo=$fullName object_kind=$objectKind action=$action '
|
||||||
|
'issue=${issue.number} state=${issue.state}',
|
||||||
|
);
|
||||||
|
if (issue.state == 'open' || issue.state == 'opened') {
|
||||||
|
_queuedIssues['${project.key}#${issue.number}'] = _QueuedGitLabIssue(
|
||||||
|
project: project,
|
||||||
|
issue: issue,
|
||||||
|
onBatch: onBatch,
|
||||||
|
);
|
||||||
|
_scheduleFlush();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee webhook ignored repo=${fullName ?? "(missing)"} '
|
||||||
|
'object_kind=$objectKind action=$action matched_project=${project != null} '
|
||||||
|
'has_issue=${issue != null}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.response.statusCode = HttpStatus.accepted;
|
||||||
|
await request.response.close();
|
||||||
|
} catch (error) {
|
||||||
|
_logError('gitlab/gosmee payload error=$error');
|
||||||
|
request.response
|
||||||
|
..statusCode = HttpStatus.badRequest
|
||||||
|
..write('Invalid webhook payload.');
|
||||||
|
await request.response.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GitHubIssueSummary? _parseIssueFromPayload(
|
||||||
|
Map<String, dynamic> payload, {
|
||||||
|
required String? trackerProject,
|
||||||
|
}) {
|
||||||
|
if (trackerProject == null || trackerProject.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final issueNode = payload['issue'];
|
||||||
|
if (issueNode is Map<String, dynamic>) {
|
||||||
|
return GitHubIssueSummary.fromGitLabJson(trackerProject, issueNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
final objectAttributes = payload['object_attributes'];
|
||||||
|
if (objectAttributes is! Map<String, dynamic>) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final issueJson = <String, dynamic>{
|
||||||
|
...objectAttributes,
|
||||||
|
if (!objectAttributes.containsKey('description') &&
|
||||||
|
payload['description'] != null)
|
||||||
|
'description': payload['description'],
|
||||||
|
if (!objectAttributes.containsKey('author') && payload['user'] != null)
|
||||||
|
'author': payload['user'],
|
||||||
|
if (!objectAttributes.containsKey('web_url') &&
|
||||||
|
objectAttributes['url'] != null)
|
||||||
|
'web_url': objectAttributes['url'],
|
||||||
|
};
|
||||||
|
if (!issueJson.containsKey('iid') || !issueJson.containsKey('updated_at')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return GitHubIssueSummary.fromGitLabJson(trackerProject, issueJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _webhookAction(Map<String, dynamic> payload) {
|
||||||
|
final objectAttributes = payload['object_attributes'];
|
||||||
|
if (objectAttributes is Map<String, dynamic>) {
|
||||||
|
final action = objectAttributes['action']?.toString().trim();
|
||||||
|
if (action != null && action.isNotEmpty) {
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload['action']?.toString() ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _createGosmeeChannelUrl(String localUrl) async {
|
||||||
|
final result = await runExternalCommand(gosmeeCommand, [
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
'client',
|
||||||
|
'--new-url',
|
||||||
|
localUrl,
|
||||||
|
]);
|
||||||
|
if (result.exitCode != 0) {
|
||||||
|
throw ProcessException(
|
||||||
|
gosmeeCommand,
|
||||||
|
['--output', 'json', 'client', '--new-url', localUrl],
|
||||||
|
'${result.stdout}\n${result.stderr}',
|
||||||
|
result.exitCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stdoutText = result.stdout.toString().trim();
|
||||||
|
if (stdoutText.isEmpty) {
|
||||||
|
throw const FormatException('gosmee did not print a public URL.');
|
||||||
|
}
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee channel_raw_output local_url=$localUrl stdout=$stdoutText',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(stdoutText);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final url = decoded['url']?.toString().trim();
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
throw const FormatException(
|
||||||
|
'gosmee JSON output did not contain a valid public URL.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (decoded is String) {
|
||||||
|
final url = decoded.trim();
|
||||||
|
if (url.isEmpty) {
|
||||||
|
throw const FormatException('gosmee did not print a public URL.');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
if (Uri.tryParse(stdoutText)?.hasScheme ?? false) {
|
||||||
|
return stdoutText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw const FormatException('gosmee did not print a valid public URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleFlush() {
|
||||||
|
_flushTimer ??= Timer(_webhookCoalescingWindow, () {
|
||||||
|
_flushTimer = null;
|
||||||
|
unawaited(_flushQueuedIssues());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushQueuedIssues() async {
|
||||||
|
if (_queuedIssues.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queuedIssues = _queuedIssues.values.toList(growable: false);
|
||||||
|
_queuedIssues.clear();
|
||||||
|
final groupedIssues = <String, _QueuedGitLabBatch>{};
|
||||||
|
for (final queuedIssue in queuedIssues) {
|
||||||
|
groupedIssues.update(
|
||||||
|
queuedIssue.project.key,
|
||||||
|
(existing) => existing.add(queuedIssue.issue),
|
||||||
|
ifAbsent: () => _QueuedGitLabBatch(
|
||||||
|
project: queuedIssue.project,
|
||||||
|
onBatch: queuedIssue.onBatch,
|
||||||
|
issues: [queuedIssue.issue],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final Future<void> dispatchFuture;
|
||||||
|
dispatchFuture = () async {
|
||||||
|
for (final batch in groupedIssues.values) {
|
||||||
|
batch.issues.sort(
|
||||||
|
(left, right) => left.updatedAt.compareTo(right.updatedAt),
|
||||||
|
);
|
||||||
|
_log(
|
||||||
|
'gitlab/gosmee project=${batch.project.key} '
|
||||||
|
'fetched_issues=${batch.issues.length}',
|
||||||
|
);
|
||||||
|
await batch.onBatch(
|
||||||
|
IssueEventBatch(
|
||||||
|
project: batch.project,
|
||||||
|
issues: batch.issues,
|
||||||
|
latestSeenUpdatedAt: batch.issues.last.updatedAt,
|
||||||
|
sourceKind: 'gitlab/gosmee',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
_activeDispatches.add(dispatchFuture);
|
||||||
|
try {
|
||||||
|
await dispatchFuture;
|
||||||
|
} finally {
|
||||||
|
_activeDispatches.remove(dispatchFuture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pipeProcessLogs({
|
||||||
|
required ProjectConfig project,
|
||||||
|
required Stream<List<int>> stream,
|
||||||
|
required String level,
|
||||||
|
}) {
|
||||||
|
utf8.decoder.bind(stream).transform(const LineSplitter()).listen((line) {
|
||||||
|
final message = 'gitlab/gosmee project=${project.key} $level=$line';
|
||||||
|
if (level == 'stderr') {
|
||||||
|
_logger.error(message);
|
||||||
|
} else {
|
||||||
|
_logger.info(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String message) => _logger.info(message);
|
||||||
|
|
||||||
|
void _logError(String message) => _logger.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedGitLabIssue {
|
||||||
|
_QueuedGitLabIssue({
|
||||||
|
required this.project,
|
||||||
|
required this.issue,
|
||||||
|
required this.onBatch,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ProjectConfig project;
|
||||||
|
final GitHubIssueSummary issue;
|
||||||
|
final IssueEventBatchHandler onBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QueuedGitLabBatch {
|
||||||
|
_QueuedGitLabBatch({
|
||||||
|
required this.project,
|
||||||
|
required this.onBatch,
|
||||||
|
required List<GitHubIssueSummary> issues,
|
||||||
|
}) : issues = List<GitHubIssueSummary>.from(issues);
|
||||||
|
|
||||||
|
final ProjectConfig project;
|
||||||
|
final IssueEventBatchHandler onBatch;
|
||||||
|
final List<GitHubIssueSummary> issues;
|
||||||
|
|
||||||
|
_QueuedGitLabBatch add(GitHubIssueSummary issue) {
|
||||||
|
if (issues.every((existing) => existing.number != issue.number)) {
|
||||||
|
issues.add(issue);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -641,6 +641,40 @@ projects:
|
||||||
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Load glab/gosmee as the issue event source
|
||||||
|
/// Given a GitLab project config that opts into glab/gosmee
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then the project keeps glab/gosmee as its event source
|
||||||
|
/// And the GitLab provider remains valid for that project
|
||||||
|
/// ```
|
||||||
|
test('Load glab/gosmee as the issue event source', () async {
|
||||||
|
// Given a GitLab project config that opts into glab/gosmee.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp('cws-glab-gosmee-');
|
||||||
|
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
issueTracker:
|
||||||
|
eventSource:
|
||||||
|
type: glab/gosmee
|
||||||
|
repo: group/subgroup/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
''');
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final config = await AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then the project keeps glab/gosmee as its event source.
|
||||||
|
expect(
|
||||||
|
config.projects['sample']!.issueAssistant.eventSource.type,
|
||||||
|
IssueTrackerEventSourceKind.glabGosmee,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the GitLab provider remains valid for that project.
|
||||||
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab);
|
||||||
|
});
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Scenario: Load an explicit SCM provider separately from the issue tracker
|
/// Scenario: Load an explicit SCM provider separately from the issue tracker
|
||||||
/// Given a config whose project uses a Gitea issue tracker and GitHub SCM
|
/// Given a config whose project uses a Gitea issue tracker and GitHub SCM
|
||||||
|
|
@ -761,6 +795,55 @@ projects:
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reject a legacy GitHub provider that conflicts with glab/gosmee
|
||||||
|
/// Given a project config whose legacy tracker provider is github
|
||||||
|
/// And the project opts into glab/gosmee
|
||||||
|
/// When loading the app config from disk
|
||||||
|
/// Then loading fails with a clear conflict error
|
||||||
|
/// ```
|
||||||
|
test(
|
||||||
|
'Reject a legacy GitHub provider that conflicts with glab/gosmee',
|
||||||
|
() async {
|
||||||
|
// Given a project config whose legacy tracker provider is github.
|
||||||
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-glab-gosmee-github-',
|
||||||
|
);
|
||||||
|
final configFile = File(
|
||||||
|
p.join(tempDir.path, 'agent-orchestrator.yaml'),
|
||||||
|
);
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
issueTracker:
|
||||||
|
provider: github
|
||||||
|
eventSource:
|
||||||
|
type: glab/gosmee
|
||||||
|
repo: group/subgroup/sample
|
||||||
|
path: ${tempDir.path}
|
||||||
|
''');
|
||||||
|
|
||||||
|
// And the project opts into glab/gosmee.
|
||||||
|
|
||||||
|
// When loading the app config from disk.
|
||||||
|
final load = AppConfig.load(configFile.path);
|
||||||
|
|
||||||
|
// Then loading fails with a clear conflict error.
|
||||||
|
await expectLater(
|
||||||
|
load,
|
||||||
|
throwsA(
|
||||||
|
isA<FormatException>().having(
|
||||||
|
(error) => error.message,
|
||||||
|
'message',
|
||||||
|
contains(
|
||||||
|
'glab/gosmee implies issue tracker provider gitlab, but the legacy configured provider is github',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/// ```gherkin
|
/// ```gherkin
|
||||||
/// Scenario: Load gh/webhook-forward as the issue event source
|
/// Scenario: Load gh/webhook-forward as the issue event source
|
||||||
/// Given a GitHub project config that opts into gh/webhook-forward
|
/// Given a GitHub project config that opts into gh/webhook-forward
|
||||||
|
|
|
||||||
|
|
@ -108,13 +108,13 @@ void registerIssueAssistantAppRunOnceConcurrencyTests() {
|
||||||
ghLog: ghLog,
|
ghLog: ghLog,
|
||||||
);
|
);
|
||||||
|
|
||||||
// And a responder that sleeps for one second while logging each issue number.
|
// And a responder that sleeps long enough to expose overlap despite scheduler jitter.
|
||||||
final eventLog = File(p.join(sandbox.path, 'responder-events.log'));
|
final eventLog = File(p.join(sandbox.path, 'responder-events.log'));
|
||||||
final responderScript = File(p.join(sandbox.path, 'responder-slow.sh'));
|
final responderScript = File(p.join(sandbox.path, 'responder-slow.sh'));
|
||||||
await writeDelayedLoggingResponderScript(
|
await writeDelayedLoggingResponderScript(
|
||||||
responderScript,
|
responderScript,
|
||||||
eventLog: eventLog,
|
eventLog: eventLog,
|
||||||
delay: const Duration(seconds: 1),
|
delay: const Duration(seconds: 2),
|
||||||
response: {
|
response: {
|
||||||
'decision': 'reply',
|
'decision': 'reply',
|
||||||
'mode': 'planning',
|
'mode': 'planning',
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and enough time passes for a retry.
|
// When the long-running app starts and enough time passes for a retry.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 60; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
final logLines = await ghLog.exists()
|
final logLines = await ghLog.exists()
|
||||||
? await ghLog.readAsLines()
|
? await ghLog.readAsLines()
|
||||||
: const <String>[];
|
: const <String>[];
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and receives the forwarded webhook.
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (await postedBody.exists()) {
|
if (await postedBody.exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_support.dart';
|
||||||
|
|
||||||
|
void registerIssueAssistantAppRunGitLabGosmeeTests() {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Feature: GitLab gosmee issue events
|
||||||
|
///
|
||||||
|
/// As a maintainer running the long-lived assistant for GitLab
|
||||||
|
/// I want glab/gosmee events to trigger issue evaluation without relying only on polling
|
||||||
|
/// So that GitLab issue and note activity can use a channel-based source
|
||||||
|
/// ```
|
||||||
|
group('IssueAssistantApp.run gitlab gosmee', () {
|
||||||
|
/// ```gherkin
|
||||||
|
/// Scenario: Reply to a mention received through glab/gosmee
|
||||||
|
/// Given a temporary project checkout that can be used as the local repo path
|
||||||
|
/// And a gosmee script that forwards one GitLab note webhook event
|
||||||
|
/// And a glab script that can manage webhooks and post issue comments
|
||||||
|
/// And a responder that emits a planning reply
|
||||||
|
/// And an orchestrator-style config that uses glab/gosmee
|
||||||
|
/// When the long-running app starts and receives the forwarded webhook
|
||||||
|
/// Then it reads issue comments and posts exactly one reply for that issue
|
||||||
|
/// And the GitLab webhook lifecycle commands are invoked for the project
|
||||||
|
/// ```
|
||||||
|
test('Reply to a mention received through glab/gosmee', () async {
|
||||||
|
// Given a temporary project checkout that can be used as the local repo path.
|
||||||
|
final sandbox = await Directory.systemTemp.createTemp(
|
||||||
|
'cws-glab-gosmee-run-',
|
||||||
|
);
|
||||||
|
final repoDir = await Directory(p.join(sandbox.path, 'repo')).create();
|
||||||
|
await initGitRepo(repoDir);
|
||||||
|
|
||||||
|
// And a gosmee script that forwards one GitLab note webhook event.
|
||||||
|
final gosmeeScript = File(p.join(sandbox.path, 'gosmee'));
|
||||||
|
final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl'));
|
||||||
|
final issue = <String, Object?>{
|
||||||
|
'iid': 12,
|
||||||
|
'title': 'Need channel-based trigger handling',
|
||||||
|
'description': 'Please react to glab/gosmee.',
|
||||||
|
'state': 'opened',
|
||||||
|
'web_url':
|
||||||
|
'https://gitlab.example.test/group/subgroup/sample/-/issues/12',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'author': {'username': 'reporter'},
|
||||||
|
'labels': const <String>[],
|
||||||
|
};
|
||||||
|
final comments = <Map<String, Object?>>[
|
||||||
|
{
|
||||||
|
'id': 51,
|
||||||
|
'body': '@helper can you handle glab/gosmee comments?',
|
||||||
|
'created_at': '2026-04-08T04:00:00Z',
|
||||||
|
'updated_at': '2026-04-08T04:00:00Z',
|
||||||
|
'author': {'username': 'reporter'},
|
||||||
|
'noteable_url':
|
||||||
|
'https://gitlab.example.test/group/subgroup/sample/-/issues/12',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await writeGosmeeForwardScript(
|
||||||
|
gosmeeScript: gosmeeScript,
|
||||||
|
publicUrl: 'https://gosmee.example.test/gitlab-sample',
|
||||||
|
payload: {
|
||||||
|
'object_kind': 'note',
|
||||||
|
'project': {'path_with_namespace': 'group/subgroup/sample'},
|
||||||
|
'issue': issue,
|
||||||
|
'object_attributes': {
|
||||||
|
'action': 'create',
|
||||||
|
'note': comments.last['body'],
|
||||||
|
'noteable_type': 'Issue',
|
||||||
|
'url': comments.last['noteable_url'],
|
||||||
|
},
|
||||||
|
'user': {'username': 'reporter'},
|
||||||
|
},
|
||||||
|
gosmeeLog: gosmeeLog,
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a glab script that can manage webhooks and post issue comments.
|
||||||
|
final glabScript = File(p.join(sandbox.path, 'glab'));
|
||||||
|
final glabLog = File(p.join(sandbox.path, 'glab-log.jsonl'));
|
||||||
|
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
|
||||||
|
await writeFakeGlabScript(
|
||||||
|
glabScript: glabScript,
|
||||||
|
issueListResponsesByRepo: {
|
||||||
|
'group/subgroup/sample': const <Map<String, Object?>>[],
|
||||||
|
},
|
||||||
|
commentResponsesByRepo: {
|
||||||
|
'group/subgroup/sample': {12: comments},
|
||||||
|
},
|
||||||
|
postCommentIssueNumbersByRepo: {
|
||||||
|
'group/subgroup/sample': {12},
|
||||||
|
},
|
||||||
|
glabLog: glabLog,
|
||||||
|
postedBodyFile: postedBody,
|
||||||
|
viewerLogin: 'glab-user',
|
||||||
|
webhookIdsByRepo: {'group/subgroup/sample': 81},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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': 'Plan:\n- consume glab/gosmee issue note events',
|
||||||
|
'summary': 'posted GitLab channel-based planning advice',
|
||||||
|
});
|
||||||
|
|
||||||
|
// And an orchestrator-style config that uses glab/gosmee.
|
||||||
|
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
|
||||||
|
await configFile.writeAsString('''
|
||||||
|
defaults:
|
||||||
|
issueAssistant:
|
||||||
|
enabled: true
|
||||||
|
pollInterval: 5m
|
||||||
|
mentionTriggers: ["@helper"]
|
||||||
|
responders:
|
||||||
|
- id: primary
|
||||||
|
command: ${responderScript.path}
|
||||||
|
timeout: 1m
|
||||||
|
projects:
|
||||||
|
sample:
|
||||||
|
provider: gitlab
|
||||||
|
repo: group/subgroup/sample
|
||||||
|
path: ${repoDir.path}
|
||||||
|
issueAssistant:
|
||||||
|
eventSource: glab/gosmee
|
||||||
|
''');
|
||||||
|
final app = await IssueAssistantApp.open(
|
||||||
|
config: await AppConfig.load(configFile.path),
|
||||||
|
databasePath: p.join(sandbox.path, 'state.sqlite3'),
|
||||||
|
glabCommand: glabScript.path,
|
||||||
|
gosmeeCommand: gosmeeScript.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
|
final runFuture = app.run();
|
||||||
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
if (await postedBody.exists()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
await runFuture;
|
||||||
|
|
||||||
|
// Then it reads issue comments and posts exactly one reply for that issue.
|
||||||
|
expect(await postedBody.exists(), isTrue);
|
||||||
|
final postedComment = await postedBody.readAsString();
|
||||||
|
expect(
|
||||||
|
postedComment,
|
||||||
|
contains('Plan:\n- consume glab/gosmee issue note events'),
|
||||||
|
);
|
||||||
|
expect(postedComment, contains('@glab-user'));
|
||||||
|
|
||||||
|
// And the GitLab webhook lifecycle commands are invoked for the project.
|
||||||
|
final glabLogLines = await glabLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
glabLogLines.any(
|
||||||
|
(line) =>
|
||||||
|
line.contains('api -X POST') &&
|
||||||
|
line.contains('projects/group%2Fsubgroup%2Fsample/hooks') &&
|
||||||
|
line.contains('issues_events=true') &&
|
||||||
|
line.contains('note_events=true'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
glabLogLines.any(
|
||||||
|
(line) => line.contains(
|
||||||
|
'api -X DELETE projects/group%2Fsubgroup%2Fsample/hooks/81',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
final gosmeeLogLines = await gosmeeLog.readAsLines();
|
||||||
|
expect(
|
||||||
|
gosmeeLogLines.any(
|
||||||
|
(line) =>
|
||||||
|
line.contains('client https://gosmee.example.test/gitlab-sample'),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
registerIssueAssistantAppRunGitLabGosmeeTests();
|
||||||
|
}
|
||||||
|
|
@ -165,7 +165,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and receives the forwarded webhook.
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (await postedBody.exists()) {
|
if (await postedBody.exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +374,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and enough time passes for reconciliation polling.
|
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (await postedBody.exists()) {
|
if (await postedBody.exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and receives the forwarded webhook.
|
// When the long-running app starts and receives the forwarded webhook.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (await postedBody.exists()) {
|
if (await postedBody.exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +208,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and webhook-forward emits that stderr line.
|
// When the long-running app starts and webhook-forward emits that stderr line.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (records.any(
|
if (records.any(
|
||||||
(record) => record.message.contains('error creating webhook'),
|
(record) => record.message.contains('error creating webhook'),
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -384,7 +384,7 @@ projects:
|
||||||
|
|
||||||
// When the long-running app starts and enough time passes for reconciliation polling.
|
// When the long-running app starts and enough time passes for reconciliation polling.
|
||||||
final runFuture = app.run();
|
final runFuture = app.run();
|
||||||
for (var attempt = 0; attempt < 50; attempt += 1) {
|
for (var attempt = 0; attempt < 200; attempt += 1) {
|
||||||
if (await postedBody.exists()) {
|
if (await postedBody.exists()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Future<void> writeFakeGlabScript({
|
||||||
File? postedBodyFile,
|
File? postedBodyFile,
|
||||||
String? viewerLogin,
|
String? viewerLogin,
|
||||||
bool failViewerLookup = false,
|
bool failViewerLookup = false,
|
||||||
|
Map<String, int>? webhookIdsByRepo,
|
||||||
}) async {
|
}) async {
|
||||||
final normalizedPostCommentIssueNumbersByRepo =
|
final normalizedPostCommentIssueNumbersByRepo =
|
||||||
postCommentIssueNumbersByRepo ??
|
postCommentIssueNumbersByRepo ??
|
||||||
|
|
@ -117,6 +118,25 @@ Future<void> writeFakeGlabScript({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (final entry in (webhookIdsByRepo ?? const <String, int>{}).entries) {
|
||||||
|
final repo = Uri.encodeComponent(entry.key);
|
||||||
|
buffer
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks" && "\$3" == "POST" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" cat <<'JSON'")
|
||||||
|
..writeln(jsonEncode({'id': entry.value}))
|
||||||
|
..writeln('JSON')
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi')
|
||||||
|
..writeln(
|
||||||
|
'if [[ "\$1" == "api" && "\$4" == "projects/$repo/hooks/${entry.value}" && "\$3" == "DELETE" ]]; then',
|
||||||
|
)
|
||||||
|
..writeln(" printf '%s\\n' 'null'")
|
||||||
|
..writeln(' exit 0')
|
||||||
|
..writeln('fi');
|
||||||
|
}
|
||||||
|
|
||||||
buffer
|
buffer
|
||||||
..writeln(r'''echo "unexpected glab args: $*" >&2''')
|
..writeln(r'''echo "unexpected glab args: $*" >&2''')
|
||||||
..writeln('exit 1');
|
..writeln('exit 1');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue