diff --git a/README.md b/README.md index eb3772e..a1e723d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Repository issue thread assistant with an Agent Orchestrator style YAML config. -It consumes issue tracker events for configured GitHub, Gitea, or OpenProject +It consumes issue tracker events for configured GitHub, GitLab, Gitea, or OpenProject projects, reads issue threads, and uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide when to reply. It supports multi-repo config, responder fallback chains, and optional Discord webhook and @@ -12,7 +12,7 @@ independently. ## Highlights -- Consume GitHub, Gitea, and OpenProject issue events across multiple projects +- Consume GitHub, GitLab, Gitea, and OpenProject issue events across multiple projects - Trigger replies from mentions or meaningful thread updates - Run planning and bug-verification flows in isolated worktrees - Send optional Discord or desktop notifications for assistant events @@ -20,7 +20,7 @@ independently. ## Requirements - Dart SDK -- `gh`, `tea`, and/or OpenProject API credentials authenticated for the target projects +- `gh`, `glab`, `tea`, and/or OpenProject API credentials authenticated for the target projects - Local checkouts for configured repositories - Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli` @@ -28,7 +28,9 @@ independently. Create a local `agent-orchestrator.yaml` and use [`examples/README.md`](examples/README.md) plus -[`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference. +[`examples/simple-github.yaml`](examples/simple-github.yaml), +[`examples/simple-gitlab.yaml`](examples/simple-gitlab.yaml), or +[`examples/simple-gitea.yaml`](examples/simple-gitea.yaml) as the reference. Environment placeholders such as `${CWS_DISCORD_WEBHOOK}` are resolved from the process environment and from a `.env` file placed next to the selected config @@ -71,6 +73,7 @@ dart run bin/code_work_spawner.dart \ --config /path/to/agent-orchestrator.yaml \ --db /path/to/state.sqlite3 \ --gh-command /usr/bin/gh \ + --glab-command /usr/bin/glab \ --tea-command /usr/bin/tea ``` diff --git a/bin/code_work_spawner.dart b/bin/code_work_spawner.dart index 907ea5f..a2b6852 100644 --- a/bin/code_work_spawner.dart +++ b/bin/code_work_spawner.dart @@ -43,6 +43,11 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { help: 'Path to the gh executable.', defaultsTo: 'gh', ) + ..addOption( + 'glab-command', + help: 'Path to the glab executable.', + defaultsTo: 'glab', + ) ..addOption( 'tea-command', help: 'Path to the tea executable.', @@ -99,6 +104,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner { config: config, databasePath: topLevelResults['db'] as String, ghCommand: topLevelResults['gh-command'] as String, + glabCommand: topLevelResults['glab-command'] as String, gosmeeCommand: topLevelResults['gosmee-command'] as String, teaCommand: topLevelResults['tea-command'] as String, ); diff --git a/examples/README.md b/examples/README.md index e92abc9..5b6eda5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,13 +10,14 @@ Each project also has a separate SCM config. `issueTracker.provider` selects the issue tracker integration, while `scm.provider` selects how temporary repo workspaces are prepared for responders. Legacy top-level project `provider` is still accepted during load for backward compatibility. When `scm` is omitted -it defaults to a matching provider for existing GitHub and Gitea configs. +it defaults to a matching provider for existing GitHub, GitLab, and Gitea configs. -GitHub projects default to `gh/polling`, and Gitea projects default to -`tea/polling`. OpenProject projects default to `op/polling` and currently only -support polling. Because OpenProject issue tracking is not tied to a repository -host, OpenProject projects must set `scm.provider` explicitly. GitHub projects -can switch their long-running runtime to +GitHub projects default to `gh/polling`, GitLab projects default to +`glab/polling`, and Gitea projects default to `tea/polling`. OpenProject +projects default to `op/polling` and currently only support polling. GitLab and +OpenProject currently support polling only. Because OpenProject issue tracking +is not tied to a repository host, OpenProject projects must set +`scm.provider` explicitly. GitHub projects can switch their long-running runtime to `gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to `tea/gosmee`. For OpenProject, `repo` may be either a numeric project id or an exact project @@ -68,6 +69,7 @@ Currently supported fields are: - `avatarUrl` Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, +[`examples/simple-gitlab.yaml`](simple-gitlab.yaml) for GitLab, [`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea, or [`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject. This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses diff --git a/examples/simple-gitlab.yaml b/examples/simple-gitlab.yaml new file mode 100644 index 0000000..7d014ca --- /dev/null +++ b/examples/simple-gitlab.yaml @@ -0,0 +1,30 @@ +# Minimal setup for a single GitLab repo with GitLab Issues. +# Replies require at least one configured responder. + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees + +defaults: + issueAssistant: + enabled: true + # GitLab currently supports polling. + eventSource: + type: glab/polling + pollInterval: 30s + maxConcurrentIssues: 3 + mentionTriggers: ["@codex", "@helper"] + responders: + - id: codex + command: codex + args: ["exec", "--json"] + timeout: 10m + +projects: + sample-gitlab: + issueTracker: + provider: gitlab + scm: + provider: gitlab + repo: group/subgroup/repo + path: ~/repo + defaultBranch: main diff --git a/lib/src/config.dart b/lib/src/config.dart index 5d40412..02b16e4 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -206,6 +206,9 @@ class ProjectConfig { document.issueTracker?.provider ?? document.provider ?? IssueTrackerProvider.github; + if (provider == IssueTrackerProvider.gitlab) { + _validateGitLabProjectPath(repo, field: 'projects.$key.repo'); + } final fallbackScmProvider = _defaultScmProviderForTracker(provider); if (document.scm?.provider == null && fallbackScmProvider == null) { throw FormatException( @@ -227,6 +230,8 @@ class ProjectConfig { type: switch (provider) { IssueTrackerProvider.github => IssueAssistantEventSourceKind.ghPolling, + IssueTrackerProvider.gitlab => + IssueAssistantEventSourceKind.glabPolling, IssueTrackerProvider.gitea => IssueAssistantEventSourceKind.teaPolling, IssueTrackerProvider.openproject => @@ -246,6 +251,13 @@ class ProjectConfig { 'requires issueTracker.provider: github.', ); } + case IssueAssistantEventSourceKind.glabPolling: + if (provider != IssueTrackerProvider.gitlab) { + throw FormatException( + 'projects.$key.issueAssistant.eventSource glab/polling ' + 'requires issueTracker.provider: gitlab.', + ); + } case IssueAssistantEventSourceKind.teaPolling: if (provider != IssueTrackerProvider.gitea) { throw FormatException( @@ -284,6 +296,7 @@ class ProjectConfig { repoSlug: switch (provider) { IssueTrackerProvider.github || IssueTrackerProvider.gitea => _parseRepositorySlug(repo, field: 'projects.$key.repo'), + IssueTrackerProvider.gitlab => null, IssueTrackerProvider.openproject => null, }, path: _expandHome(path), @@ -315,6 +328,19 @@ class ProjectConfig { } return RepositorySlug(parts[0], parts[1]); } + + static void _validateGitLabProjectPath( + String value, { + required String field, + }) { + final parts = value.split('/'); + if (parts.length < 2 || parts.any((part) => part.trim().isEmpty)) { + throw FormatException( + 'Invalid GitLab project path for $field: "$value". ' + 'Expected group/project or group/subgroup/project.', + ); + } + } } class ScmConfig { @@ -631,6 +657,7 @@ class _IssueAssistantConfigResolver { String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => 'gh/polling', + IssueAssistantEventSourceKind.glabPolling => 'glab/polling', IssueAssistantEventSourceKind.teaPolling => 'tea/polling', IssueAssistantEventSourceKind.opPolling => 'op/polling', IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee', @@ -642,6 +669,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { bool _isPollingEventSource(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => true, + IssueAssistantEventSourceKind.glabPolling => true, IssueAssistantEventSourceKind.teaPolling => true, IssueAssistantEventSourceKind.opPolling => true, IssueAssistantEventSourceKind.ghGosmee => false, @@ -885,6 +913,7 @@ void _assertCamelCaseConfigKeys(Map root) { ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) { return switch (provider) { IssueTrackerProvider.github => ScmProvider.github, + IssueTrackerProvider.gitlab => ScmProvider.gitlab, IssueTrackerProvider.gitea => ScmProvider.gitea, IssueTrackerProvider.openproject => null, }; @@ -979,12 +1008,13 @@ void _normalizeLegacyEventSourceConfig(Map root) { } normalizeIssueAssistant( issueAssistant, - defaultType: - ((project['issueTracker'] as Map?)?['provider'] - as String?) == - 'gitea' - ? IssueAssistantEventSourceKind.teaPolling - : IssueAssistantEventSourceKind.ghPolling, + defaultType: switch ((project['issueTracker'] + as Map?)?['provider'] + as String?) { + 'gitlab' => IssueAssistantEventSourceKind.glabPolling, + 'gitea' => IssueAssistantEventSourceKind.teaPolling, + _ => IssueAssistantEventSourceKind.ghPolling, + }, ); } } diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index fe6123e..3a4b207 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -250,6 +250,8 @@ enum AssistantCapability { planning, bugVerification } enum IssueAssistantEventSourceKind { @JsonValue('gh/polling') ghPolling, + @JsonValue('glab/polling') + glabPolling, @JsonValue('tea/polling') teaPolling, @JsonValue('op/polling') @@ -270,7 +272,7 @@ enum IssueAssistantReconciliationKind { } @JsonEnum(fieldRename: FieldRename.snake) -enum IssueTrackerProvider { github, gitea, openproject } +enum IssueTrackerProvider { github, gitlab, gitea, openproject } @JsonEnum(fieldRename: FieldRename.snake) enum ScmProvider { github, gitea, gitlab } diff --git a/lib/src/issue_assistant_app.dart b/lib/src/issue_assistant_app.dart index d4df0ff..1e9002b 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -42,12 +42,14 @@ class IssueAssistantApp { required AppConfig config, required String databasePath, String ghCommand = 'gh', + String glabCommand = 'glab', String gosmeeCommand = 'gosmee', String teaCommand = 'tea', }) async { final database = AppDatabase.open(databasePath); final issueTrackerClient = IssueTrackerClient( ghCommand: ghCommand, + glabCommand: glabCommand, teaCommand: teaCommand, ); final pollingIssueEventSource = PollingIssueEventSource( @@ -62,6 +64,7 @@ class IssueAssistantApp { issueEventSource: RoutedIssueEventSource( sources: { IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, + IssueAssistantEventSourceKind.glabPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( diff --git a/lib/src/issue_event_source/base.dart b/lib/src/issue_event_source/base.dart index 91b5df8..8241d2a 100644 --- a/lib/src/issue_event_source/base.dart +++ b/lib/src/issue_event_source/base.dart @@ -109,6 +109,8 @@ class RoutedIssueEventSource implements IssueEventSource { switch (project.issueAssistant.eventSource.type) { case IssueAssistantEventSourceKind.ghPolling: yield IssueAssistantEventSourceKind.ghPolling; + case IssueAssistantEventSourceKind.glabPolling: + yield IssueAssistantEventSourceKind.glabPolling; case IssueAssistantEventSourceKind.teaPolling: yield IssueAssistantEventSourceKind.teaPolling; case IssueAssistantEventSourceKind.opPolling: diff --git a/lib/src/issue_tracker/client.dart b/lib/src/issue_tracker/client.dart new file mode 100644 index 0000000..f46fc4c --- /dev/null +++ b/lib/src/issue_tracker/client.dart @@ -0,0 +1,725 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:github/github.dart'; +import 'package:path/path.dart' as p; + +import '../app_environment.dart'; +import '../config_schema.dart'; +import '../process_launcher.dart'; +import 'models.dart'; + +class IssueTrackerClient { + IssueTrackerClient({ + this.ghCommand = 'gh', + this.glabCommand = 'glab', + this.teaCommand = 'tea', + this.opCommand = 'op', + }); + + final String ghCommand; + final String glabCommand; + final String teaCommand; + final String opCommand; + final Map> _authenticatedLoginFutures = + >{}; + final Map> _openProjectProjectFutures = + >{}; + static const int _pageSize = 100; + + Future> fetchUpdatedIssuesForRepos({ + required IssueTrackerProvider provider, + required Iterable trackerProjects, + required DateTime? since, + }) { + return switch (provider) { + IssueTrackerProvider.github => _fetchUpdatedGitHubIssues( + trackerProjects: trackerProjects, + since: since, + ), + IssueTrackerProvider.gitlab => _fetchUpdatedGitLabIssues( + trackerProjects: trackerProjects, + since: since, + ), + IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues( + trackerProjects: trackerProjects, + since: since, + ), + IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues( + trackerProjects: trackerProjects, + since: since, + ), + }; + } + + Future fetchThread({ + required IssueTrackerProvider provider, + required String trackerProject, + required GitHubIssueSummary issue, + }) async { + final openProjectConfig = provider == IssueTrackerProvider.openproject + ? await _loadOpenProjectConfig() + : null; + final commentJson = switch (provider) { + IssueTrackerProvider.github => await _runGhJson([ + 'api', + '-X', + 'GET', + _buildApiPath( + issue.requiredRepoSlug, + ['issues', '${issue.number}', 'comments'], + {'per_page': '100'}, + ), + ]), + IssueTrackerProvider.gitlab => await _runGlabJson([ + 'api', + '-X', + 'GET', + _buildGitLabApiPath( + trackerProject, + ['issues', '${issue.number}', 'notes'], + {'per_page': '100'}, + ), + ]), + IssueTrackerProvider.gitea => await _runTeaJson([ + 'api', + '-X', + 'GET', + _buildApiPath( + issue.requiredRepoSlug, + ['issues', '${issue.number}', 'comments'], + {'limit': '100'}, + ), + '-r', + issue.requiredRepoSlug.fullName, + ]), + IssueTrackerProvider.openproject => await _runOpenProjectJson( + method: 'GET', + path: '/api/v3/work_packages/${issue.number}/activities', + ), + }; + final comments = provider == IssueTrackerProvider.openproject + ? ((((commentJson as Map)['_embedded'] + as Map?)?['elements'] + as List?) ?? + const []) + .cast>() + : (commentJson as List).cast>(); + + return IssueThread( + issue: issue, + comments: comments + .map(switch (provider) { + IssueTrackerProvider.openproject => + (json) => GitHubIssueComment.fromOpenProjectJson( + json, + openProjectConfig!.host, + ), + IssueTrackerProvider.gitlab => + (json) => GitHubIssueComment.fromGitLabJson(json, issue.url), + _ => GitHubIssueComment.fromJson, + }) + .toList(growable: false), + ); + } + + Future createIssueComment({ + required IssueTrackerProvider provider, + required String trackerProject, + required int issueNumber, + required String body, + }) async { + final openProjectConfig = provider == IssueTrackerProvider.openproject + ? await _loadOpenProjectConfig() + : null; + final json = switch (provider) { + IssueTrackerProvider.github => await _runGhJson([ + 'api', + '-X', + 'POST', + _buildApiPath(parseRepositorySlug(trackerProject), [ + 'issues', + '$issueNumber', + 'comments', + ]), + '-f', + 'body=$body', + ]), + IssueTrackerProvider.gitlab => await _runGlabJson([ + 'api', + '-X', + 'POST', + _buildGitLabApiPath(trackerProject, [ + 'issues', + '$issueNumber', + 'notes', + ]), + '-f', + 'body=$body', + ]), + IssueTrackerProvider.gitea => await _runTeaJson([ + 'api', + '-X', + 'POST', + _buildApiPath(parseRepositorySlug(trackerProject), [ + 'issues', + '$issueNumber', + 'comments', + ]), + '-f', + 'body=$body', + '-r', + trackerProject, + ]), + IssueTrackerProvider.openproject => await _runOpenProjectJson( + method: 'POST', + path: '/api/v3/work_packages/$issueNumber/activities', + body: { + 'comment': {'raw': body}, + }, + ), + }; + + final map = json as Map; + return PostedComment( + id: readIntField(map, 'id'), + url: switch (provider) { + IssueTrackerProvider.openproject => resolveTrackerUrl( + readUrlField(map), + openProjectConfig!.host, + ), + IssueTrackerProvider.gitlab => GitHubIssueComment.buildGitLabNoteUrl( + issueUrl: '', + json: map, + ), + _ => readUrlField(map), + }, + body: map['body'] as String? ?? readOpenProjectCommentBody(map) ?? body, + ); + } + + Future getAuthenticatedLogin({ + required IssueTrackerProvider provider, + }) { + return _authenticatedLoginFutures.putIfAbsent( + provider, + () => _loadAuthenticatedLogin(provider: provider), + ); + } + + String trackerCliName(IssueTrackerProvider provider) { + return switch (provider) { + IssueTrackerProvider.github => 'gh', + IssueTrackerProvider.gitlab => 'glab', + IssueTrackerProvider.gitea => 'tea', + IssueTrackerProvider.openproject => opCommand, + }; + } + + Future createGiteaIssueWebhook({ + required RepositorySlug repo, + required String url, + }) async { + final json = await _runTeaJson([ + 'webhooks', + 'create', + '--type', + 'gitea', + '--events', + 'issues,issue_comment', + '--active', + '-o', + 'json', + '-r', + repo.fullName, + url, + ]); + if (json is! Map) { + throw const FormatException( + 'tea webhooks create returned an unexpected response.', + ); + } + return readIntField(json, 'id'); + } + + Future createGitHubIssueWebhook({ + required RepositorySlug repo, + required String url, + }) async { + final json = await _runGhJson([ + 'api', + '-X', + 'POST', + _buildApiPath(repo, ['hooks']), + '-f', + 'name=web', + '-F', + 'active=true', + '-f', + 'events[]=issues', + '-f', + 'events[]=issue_comment', + '-f', + 'config[url]=$url', + '-f', + 'config[content_type]=json', + ]); + if (json is! Map) { + throw const FormatException( + 'gh api webhook create returned an unexpected response.', + ); + } + return readIntField(json, 'id'); + } + + Future deleteGitHubWebhook({ + required RepositorySlug repo, + required int webhookId, + }) async { + await _runGhJson([ + 'api', + '-X', + 'DELETE', + _buildApiPath(repo, ['hooks', '$webhookId']), + ]); + } + + Future deleteGiteaWebhook({ + required RepositorySlug repo, + required int webhookId, + }) async { + await _runTeaJson([ + 'webhooks', + 'delete', + '--confirm', + '-r', + repo.fullName, + '$webhookId', + ]); + } + + Future> _fetchUpdatedGitHubIssues({ + required Iterable trackerProjects, + required DateTime? since, + }) async { + final repoList = trackerProjects + .map(parseRepositorySlug) + .toList(growable: false); + if (repoList.isEmpty) { + return const []; + } + + final queryParts = [ + for (final repo in repoList) 'repo:${repo.fullName}', + 'is:issue', + 'state:open', + if (since != null) 'updated:>=${since.toUtc().toIso8601String()}', + ]; + + final issues = []; + for (var page = 1; true; page += 1) { + final json = await _runGhJson([ + 'api', + '-X', + 'GET', + 'search/issues', + '-f', + 'q=${queryParts.join(' ')}', + '-f', + 'sort=updated', + '-f', + 'order=asc', + '-f', + 'per_page=$_pageSize', + '-f', + 'page=$page', + ]); + final items = + ((json as Map)['items'] as List? ?? + const []) + .cast>(); + issues.addAll( + items + .where((item) => !item.containsKey('pull_request')) + .map(GitHubIssueSummary.fromSearchJson), + ); + if (items.length < _pageSize) { + break; + } + } + + return issues; + } + + Future> _fetchUpdatedGitLabIssues({ + required Iterable trackerProjects, + required DateTime? since, + }) async { + final issues = []; + + for (final trackerProject in trackerProjects) { + for (var page = 1; true; page += 1) { + final query = { + 'state': 'opened', + 'order_by': 'updated_at', + 'sort': 'asc', + 'per_page': '$_pageSize', + 'page': '$page', + if (since != null) 'updated_after': since.toUtc().toIso8601String(), + }; + final json = await _runGlabJson([ + 'api', + '-X', + 'GET', + _buildGitLabApiPath(trackerProject, ['issues'], query), + ]); + final items = (json as List? ?? const []) + .cast>(); + issues.addAll( + items.map( + (item) => GitHubIssueSummary.fromGitLabJson(trackerProject, item), + ), + ); + if (items.length < _pageSize) { + break; + } + } + } + + issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + return issues; + } + + Future> _fetchUpdatedGiteaIssues({ + required Iterable trackerProjects, + required DateTime? since, + }) async { + final issues = []; + + for (final repo in trackerProjects.map(parseRepositorySlug)) { + for (var page = 1; true; page += 1) { + final query = { + 'state': 'open', + 'limit': '$_pageSize', + 'page': '$page', + if (since != null) 'since': since.toUtc().toIso8601String(), + }; + final json = await _runTeaJson([ + 'api', + '-X', + 'GET', + _buildApiPath(repo, ['issues'], query), + '-r', + repo.fullName, + ]); + final items = (json as List? ?? const []) + .cast>(); + issues.addAll( + items + .where((item) => item['pull_request'] == null) + .map((item) => GitHubIssueSummary.fromRepositoryJson(repo, item)), + ); + if (items.length < _pageSize) { + break; + } + } + } + + issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + return issues; + } + + Future> _fetchUpdatedOpenProjectIssues({ + required Iterable trackerProjects, + required DateTime? since, + }) async { + final issues = []; + + for (final trackerProject in trackerProjects) { + final project = await _resolveOpenProjectProjectRef(trackerProject); + final config = await _loadOpenProjectConfig(); + final json = await _runOpenProjectJson( + method: 'GET', + path: '/api/v3/projects/${project.id}/work_packages', + queryParameters: { + 'pageSize': '-1', + 'filters': jsonEncode(>[ + { + 'status': { + 'operator': 'o', + 'values': const [], + }, + }, + ]), + }, + ); + final embedded = + (json as Map)['_embedded'] as Map?; + final items = + (embedded?['elements'] as List? ?? const []) + .cast>(); + issues.addAll( + items + .map( + (item) => GitHubIssueSummary.fromOpenProjectJson( + trackerProject, + item, + config.host, + ), + ) + .where((issue) => since == null || issue.updatedAt.isAfter(since)), + ); + } + + issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + return issues; + } + + Future _loadAuthenticatedLogin({ + required IssueTrackerProvider provider, + }) async { + final json = switch (provider) { + IssueTrackerProvider.github => await _runGhJson(['api', 'user']), + IssueTrackerProvider.gitlab => await _runGlabJson(['api', 'user']), + IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']), + IssueTrackerProvider.openproject => await _runOpenProjectJson( + method: 'GET', + path: '/api/v3/users/me', + ), + }; + if (json is! Map) { + return null; + } + final login = + json['login']?.toString().trim() ?? + json['username']?.toString().trim() ?? + json['name']?.toString().trim() ?? + json['firstName']?.toString().trim(); + return login == null || login.isEmpty ? null : login; + } + + Future _runGhJson(List arguments) { + return _runJson(ghCommand, arguments); + } + + Future _runGlabJson(List arguments) { + return _runJson(glabCommand, arguments); + } + + Future _runTeaJson(List arguments) { + return _runJson(teaCommand, arguments); + } + + Future _runOpenProjectJson({ + required String method, + required String path, + Map? queryParameters, + Object? body, + }) async { + final config = await _loadOpenProjectConfig(); + final client = HttpClient(); + try { + final request = await client.openUrl( + method, + _buildOpenProjectUri( + config.host, + path, + queryParameters: queryParameters, + ), + ); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + request.headers.set( + HttpHeaders.authorizationHeader, + 'Basic ${base64Encode(utf8.encode('apikey:${config.token}'))}', + ); + if (body != null) { + request.write(jsonEncode(body)); + } + + final response = await request.close(); + final responseBody = await utf8.decodeStream(response); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'OpenProject API request failed ' + '(${response.statusCode} ${response.reasonPhrase}): $responseBody', + uri: request.uri, + ); + } + + final trimmed = responseBody.trim(); + if (trimmed.isEmpty) { + return null; + } + return jsonDecode(trimmed); + } finally { + client.close(force: true); + } + } + + Future _runJson(String command, List arguments) async { + final result = await runExternalCommand(command, arguments); + if (result.exitCode != 0) { + throw ProcessException( + command, + arguments, + '${result.stdout}\n${result.stderr}', + result.exitCode, + ); + } + + final stdoutText = result.stdout.toString().trim(); + if (stdoutText.isEmpty) { + return null; + } + return jsonDecode(stdoutText); + } + + String _buildApiPath( + RepositorySlug repo, + List tailSegments, [ + Map query = const {}, + ]) { + return Uri( + pathSegments: ['repos', repo.owner, repo.name, ...tailSegments], + queryParameters: query.isEmpty ? null : query, + ).toString(); + } + + String _buildGitLabApiPath( + String projectPath, + List tailSegments, [ + Map query = const {}, + ]) { + final path = + 'projects/${Uri.encodeComponent(projectPath)}/${tailSegments.join('/')}'; + if (query.isEmpty) { + return path; + } + return Uri(path: path, queryParameters: query).toString(); + } + + Uri _buildOpenProjectUri( + Uri host, + String path, { + Map? queryParameters, + }) { + return host.replace( + path: p.posix.normalize( + '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}$path', + ), + queryParameters: queryParameters == null || queryParameters.isEmpty + ? null + : queryParameters, + ); + } + + Future<_OpenProjectConfig> _loadOpenProjectConfig() async { + final environment = AppEnvironment.variables; + final host = environment['OP_CLI_HOST']?.trim(); + final token = environment['OP_CLI_TOKEN']?.trim(); + if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { + return _OpenProjectConfig(host: Uri.parse(host), token: token); + } + + final configPath = _openProjectConfigPath(environment); + final file = File(configPath); + if (!await file.exists()) { + throw const FileSystemException( + 'OpenProject config not found. Set OP_CLI_HOST and OP_CLI_TOKEN or run `op login` first.', + ); + } + + final parts = (await file.readAsString()).trim().split(RegExp(r'\s+')); + if (parts.length != 2) { + throw FileSystemException( + 'Invalid OpenProject config file at $configPath. Run `op login` again.', + ); + } + return _OpenProjectConfig(host: Uri.parse(parts[0]), token: parts[1]); + } + + String _openProjectConfigPath(Map environment) { + final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); + if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { + return p.join(xdgConfigHome, 'openproject', 'config'); + } + final home = environment['HOME']?.trim(); + if (home == null || home.isEmpty) { + throw const FileSystemException( + 'HOME is not set and XDG_CONFIG_HOME is unset. Cannot locate OpenProject config.', + ); + } + return p.join(home, '.config', 'openproject', 'config'); + } + + Future<_OpenProjectProjectRef> _resolveOpenProjectProjectRef( + String projectRef, + ) { + return _openProjectProjectFutures.putIfAbsent( + projectRef, + () => _loadOpenProjectProjectRef(projectRef), + ); + } + + Future<_OpenProjectProjectRef> _loadOpenProjectProjectRef( + String projectRef, + ) async { + final projectId = int.tryParse(projectRef); + if (projectId != null && projectId > 0) { + return _OpenProjectProjectRef(id: projectId, name: projectRef); + } + + final json = await _runOpenProjectJson( + method: 'GET', + path: '/api/v3/projects', + queryParameters: { + 'pageSize': '-1', + 'filters': jsonEncode(>[ + { + 'typeahead': { + 'operator': '**', + 'values': [projectRef], + }, + }, + ]), + }, + ); + final embedded = + (json as Map)['_embedded'] as Map?; + final projects = + (embedded?['elements'] as List? ?? const []) + .cast>(); + final exactMatches = projects + .where((project) { + final name = project['name']?.toString().trim(); + return name != null && name.toLowerCase() == projectRef.toLowerCase(); + }) + .toList(growable: false); + + if (exactMatches.length != 1) { + throw FormatException( + 'OpenProject project "$projectRef" did not resolve to exactly one project.', + ); + } + + return _OpenProjectProjectRef( + id: readIntField(exactMatches.single, 'id'), + name: exactMatches.single['name']?.toString() ?? projectRef, + ); + } +} + +class _OpenProjectConfig { + const _OpenProjectConfig({required this.host, required this.token}); + + final Uri host; + final String token; +} + +class _OpenProjectProjectRef { + const _OpenProjectProjectRef({required this.id, required this.name}); + + final int id; + final String name; +} diff --git a/lib/src/issue_tracker/models.dart b/lib/src/issue_tracker/models.dart new file mode 100644 index 0000000..efe372d --- /dev/null +++ b/lib/src/issue_tracker/models.dart @@ -0,0 +1,398 @@ +import 'package:github/github.dart'; + +class GitHubIssueSummary { + GitHubIssueSummary({ + required this.trackerProject, + required this.repoSlug, + required this.number, + required this.title, + required this.body, + required this.state, + required this.url, + required this.updatedAt, + required this.userLogin, + required this.labels, + }); + + final String trackerProject; + final RepositorySlug? repoSlug; + final int number; + final String title; + final String body; + final String state; + final String url; + final DateTime updatedAt; + final String userLogin; + final List labels; + + factory GitHubIssueSummary.fromRepositoryJson( + RepositorySlug repo, + Map json, + ) { + final labelsNode = json['labels'] as List? ?? const []; + return GitHubIssueSummary( + trackerProject: repo.fullName, + repoSlug: repo, + number: readIntField(json, 'number', fallbackKey: 'index'), + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + state: json['state'] as String? ?? 'open', + url: readUrlField(json), + updatedAt: DateTime.parse(readStringField(json, 'updated_at')), + userLogin: + readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '', + labels: labelsNode + .map( + (label) => readStringField(label as Map, 'name'), + ) + .toList(growable: false), + ); + } + + factory GitHubIssueSummary.fromOpenProjectJson( + String trackerProject, + Map json, + Uri host, + ) { + final embedded = json['_embedded'] as Map?; + final description = + embedded?['description'] as Map? ?? + json['description'] as Map?; + return GitHubIssueSummary( + trackerProject: trackerProject, + repoSlug: null, + number: readIntField(json, 'id'), + title: json['subject'] as String? ?? '', + body: description?['raw'] as String? ?? '', + state: 'open', + url: resolveTrackerUrl(readUrlField(json), host), + updatedAt: DateTime.parse(readStringField(json, 'updatedAt')), + userLogin: + readLinkTitleField( + (json['_links'] as Map?)?['author'], + ) ?? + '', + labels: const [], + ); + } + + factory GitHubIssueSummary.fromGitLabJson( + String trackerProject, + Map json, + ) { + final labelsNode = json['labels'] as List? ?? const []; + return GitHubIssueSummary( + trackerProject: trackerProject, + repoSlug: null, + number: readIntField(json, 'iid'), + title: json['title'] as String? ?? '', + body: json['description'] as String? ?? '', + state: json['state'] as String? ?? 'opened', + url: readUrlField(json), + updatedAt: DateTime.parse(readStringField(json, 'updated_at')), + userLogin: readLoginField(json['author']) ?? '', + labels: labelsNode + .map((label) => label.toString()) + .toList(growable: false), + ); + } + + factory GitHubIssueSummary.fromSearchJson(Map json) { + final repositoryUrl = json['repository_url'] as String?; + if (repositoryUrl == null || repositoryUrl.isEmpty) { + throw const FormatException( + 'Missing repository_url in GitHub search result item.', + ); + } + return GitHubIssueSummary.fromRepositoryJson( + parseRepositorySlugFromRepositoryUrl(repositoryUrl), + json, + ); + } + + factory GitHubIssueSummary.fromJson(Map json) { + final trackerProject = json['repo'] as String? ?? ''; + RepositorySlug? repoSlug; + try { + repoSlug = parseRepositorySlug(trackerProject); + } on FormatException { + repoSlug = null; + } + return GitHubIssueSummary( + trackerProject: trackerProject, + repoSlug: repoSlug, + number: readIntField(json, 'number'), + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + state: json['state'] as String? ?? 'open', + url: readStringField(json, 'url'), + updatedAt: DateTime.parse(readStringField(json, 'updated_at')), + userLogin: json['user_login'] as String? ?? '', + labels: (json['labels'] as List? ?? const []) + .map((label) => label.toString()) + .toList(growable: false), + ); + } + + RepositorySlug get requiredRepoSlug { + final slug = repoSlug; + if (slug == null) { + throw StateError( + 'Tracker project "$trackerProject" does not use a repository slug.', + ); + } + return slug; + } + + Map toJson() => { + 'repo': trackerProject, + 'number': number, + 'title': title, + 'body': body, + 'state': state, + 'url': url, + 'updated_at': updatedAt.toUtc().toIso8601String(), + 'user_login': userLogin, + 'labels': labels, + }; +} + +class GitHubIssueComment { + GitHubIssueComment({ + required this.id, + required this.body, + required this.userLogin, + required this.createdAt, + required this.updatedAt, + required this.url, + }); + + final int id; + final String body; + final String userLogin; + final DateTime createdAt; + final DateTime updatedAt; + final String url; + + factory GitHubIssueComment.fromJson(Map json) { + return GitHubIssueComment( + id: readIntField(json, 'id'), + body: json['body'] as String? ?? '', + userLogin: + readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '', + createdAt: DateTime.parse(readStringField(json, 'created_at')), + updatedAt: DateTime.parse(readStringField(json, 'updated_at')), + url: readUrlField(json), + ); + } + + factory GitHubIssueComment.fromOpenProjectJson( + Map json, + Uri host, + ) { + return GitHubIssueComment( + id: readIntField(json, 'id'), + body: readOpenProjectCommentBody(json) ?? '', + userLogin: + readLinkTitleField( + (json['_links'] as Map?)?['user'], + ) ?? + '', + createdAt: DateTime.parse(readStringField(json, 'createdAt')), + updatedAt: DateTime.parse(readStringField(json, 'updatedAt')), + url: resolveTrackerUrl(readUrlField(json), host), + ); + } + + factory GitHubIssueComment.fromGitLabJson( + Map json, + String issueUrl, + ) { + return GitHubIssueComment( + id: readIntField(json, 'id'), + body: json['body'] as String? ?? '', + userLogin: readLoginField(json['author']) ?? '', + createdAt: DateTime.parse(readStringField(json, 'created_at')), + updatedAt: DateTime.parse(readStringField(json, 'updated_at')), + url: buildGitLabNoteUrl(issueUrl: issueUrl, json: json), + ); + } + + static String buildGitLabNoteUrl({ + required String issueUrl, + required Map json, + }) { + final noteUrl = readOptionalStringField(json, 'web_url'); + if (noteUrl != null && noteUrl.isNotEmpty) { + return noteUrl; + } + final resolvedIssueUrl = + readOptionalStringField(json, 'noteable_url') ?? + readOptionalStringField(json, 'issue_web_url') ?? + issueUrl; + if (resolvedIssueUrl.isEmpty) { + return resolvedIssueUrl; + } + return '$resolvedIssueUrl#note_${readIntField(json, "id")}'; + } + + Map toJson() => { + 'id': id, + 'body': body, + 'user_login': userLogin, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + 'url': url, + }; +} + +class IssueThread { + IssueThread({required this.issue, required this.comments}); + + final GitHubIssueSummary issue; + final List comments; + + bool get isOpen => issue.state == 'open' || issue.state == 'opened'; + + GitHubIssueComment? get latestComment => + comments.isEmpty ? null : comments.last; + + Map toJson() => { + 'issue': issue.toJson(), + 'comments': comments.map((comment) => comment.toJson()).toList(), + }; +} + +class PostedComment { + PostedComment({required this.id, required this.url, required this.body}); + + final int id; + final String url; + final String body; +} + +int readIntField(Map json, String key, {String? fallbackKey}) { + final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); + if (value is int) { + return value; + } + if (value is String) { + return int.parse(value); + } + throw FormatException( + 'Missing integer field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', + ); +} + +String readStringField( + Map json, + String key, { + String? fallbackKey, +}) { + final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException( + 'Missing string field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', + ); +} + +String? readOptionalStringField(Map json, String key) { + final value = json[key]; + if (value is String && value.isNotEmpty) { + return value; + } + return null; +} + +String readUrlField(Map json) { + return json['html_url'] as String? ?? + json['web_url'] as String? ?? + json['url'] as String? ?? + json['htmlUrl'] as String? ?? + readLinkHrefField((json['_links'] as Map?)?['self']) ?? + ''; +} + +String? readLoginField(Object? userNode) { + if (userNode is Map) { + final login = userNode['login']?.toString().trim(); + if (login != null && login.isNotEmpty) { + return login; + } + final userName = userNode['user_name']?.toString().trim(); + if (userName != null && userName.isNotEmpty) { + return userName; + } + final username = userNode['username']?.toString().trim(); + if (username != null && username.isNotEmpty) { + return username; + } + } + return null; +} + +String? readLinkTitleField(Object? linkNode) { + if (linkNode is Map) { + final title = linkNode['title']?.toString().trim(); + if (title != null && title.isNotEmpty) { + return title; + } + } + return null; +} + +String? readLinkHrefField(Object? linkNode) { + if (linkNode is Map) { + final href = linkNode['href']?.toString().trim(); + if (href != null && href.isNotEmpty) { + return href; + } + } + return null; +} + +String? readOpenProjectCommentBody(Map json) { + final comment = json['comment']; + if (comment is Map) { + final raw = comment['raw']?.toString(); + if (raw != null && raw.isNotEmpty) { + return raw; + } + } + return null; +} + +String resolveTrackerUrl(String value, Uri host) { + if (value.isEmpty) { + return value; + } + final uri = Uri.parse(value); + if (uri.hasScheme) { + return value; + } + return host.resolveUri(uri).toString(); +} + +RepositorySlug parseRepositorySlug(String value) { + final parts = value.split('/'); + if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { + throw FormatException( + 'Invalid repository slug "$value". Expected owner/repo.', + ); + } + return RepositorySlug(parts[0], parts[1]); +} + +RepositorySlug parseRepositorySlugFromRepositoryUrl(String value) { + final uri = Uri.parse(value); + final segments = uri.pathSegments; + final reposIndex = segments.indexOf('repos'); + if (reposIndex == -1 || reposIndex + 2 >= segments.length) { + throw FormatException( + 'Invalid repository_url in GitHub search result item: "$value"', + ); + } + return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]); +} diff --git a/lib/src/issue_tracker_client.dart b/lib/src/issue_tracker_client.dart index a1a42e7..78f2afc 100644 --- a/lib/src/issue_tracker_client.dart +++ b/lib/src/issue_tracker_client.dart @@ -1,961 +1,2 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:github/github.dart'; -import 'package:path/path.dart' as p; - -import 'app_environment.dart'; -import 'process_launcher.dart'; - -import 'config_schema.dart'; - -class IssueTrackerClient { - IssueTrackerClient({ - this.ghCommand = 'gh', - this.teaCommand = 'tea', - this.opCommand = 'op', - }); - - final String ghCommand; - final String teaCommand; - final String opCommand; - final Map> _authenticatedLoginFutures = - >{}; - final Map> _openProjectProjectFutures = - >{}; - static const int _pageSize = 100; - - Future> fetchUpdatedIssuesForRepos({ - required IssueTrackerProvider provider, - required Iterable trackerProjects, - required DateTime? since, - }) { - return switch (provider) { - IssueTrackerProvider.github => _fetchUpdatedGitHubIssues( - trackerProjects: trackerProjects, - since: since, - ), - IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues( - trackerProjects: trackerProjects, - since: since, - ), - IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues( - trackerProjects: trackerProjects, - since: since, - ), - }; - } - - Future fetchThread({ - required IssueTrackerProvider provider, - required String trackerProject, - required GitHubIssueSummary issue, - }) async { - final openProjectConfig = provider == IssueTrackerProvider.openproject - ? await _loadOpenProjectConfig() - : null; - final commentJson = switch (provider) { - IssueTrackerProvider.github => await _runGhJson([ - 'api', - '-X', - 'GET', - _buildApiPath( - issue.requiredRepoSlug, - ['issues', '${issue.number}', 'comments'], - {'per_page': '100'}, - ), - ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath( - issue.requiredRepoSlug, - ['issues', '${issue.number}', 'comments'], - {'limit': '100'}, - ), - '-r', - issue.requiredRepoSlug.fullName, - ]), - IssueTrackerProvider.openproject => await _runOpenProjectJson( - method: 'GET', - path: '/api/v3/work_packages/${issue.number}/activities', - ), - }; - final comments = provider == IssueTrackerProvider.openproject - ? ((((commentJson as Map)['_embedded'] - as Map?)?['elements'] - as List?) ?? - const []) - .cast>() - : (commentJson as List).cast>(); - - return IssueThread( - issue: issue, - comments: comments - .map( - provider == IssueTrackerProvider.openproject - ? (json) => GitHubIssueComment.fromOpenProjectJson( - json, - openProjectConfig!.host, - ) - : GitHubIssueComment.fromJson, - ) - .toList(growable: false), - ); - } - - Future createIssueComment({ - required IssueTrackerProvider provider, - required String trackerProject, - required int issueNumber, - required String body, - }) async { - final openProjectConfig = provider == IssueTrackerProvider.openproject - ? await _loadOpenProjectConfig() - : null; - final json = switch (provider) { - IssueTrackerProvider.github => await _runGhJson([ - 'api', - '-X', - 'POST', - _buildApiPath(_parseRepositorySlug(trackerProject), [ - 'issues', - '$issueNumber', - 'comments', - ]), - '-f', - 'body=$body', - ]), - IssueTrackerProvider.gitea => await _runTeaJson([ - 'api', - '-X', - 'POST', - _buildApiPath(_parseRepositorySlug(trackerProject), [ - 'issues', - '$issueNumber', - 'comments', - ]), - '-f', - 'body=$body', - '-r', - trackerProject, - ]), - IssueTrackerProvider.openproject => await _runOpenProjectJson( - method: 'POST', - path: '/api/v3/work_packages/$issueNumber/activities', - body: { - 'comment': {'raw': body}, - }, - ), - }; - - final map = json as Map; - return PostedComment( - id: _readInt(map, 'id'), - url: provider == IssueTrackerProvider.openproject - ? _resolveUrl(_readUrl(map), openProjectConfig!.host) - : _readUrl(map), - body: map['body'] as String? ?? _readOpenProjectCommentBody(map) ?? body, - ); - } - - Future getAuthenticatedLogin({ - required IssueTrackerProvider provider, - }) { - return _authenticatedLoginFutures.putIfAbsent( - provider, - () => _loadAuthenticatedLogin(provider: provider), - ); - } - - String trackerCliName(IssueTrackerProvider provider) { - return switch (provider) { - IssueTrackerProvider.github => 'gh', - IssueTrackerProvider.gitea => 'tea', - IssueTrackerProvider.openproject => opCommand, - }; - } - - Future createGiteaIssueWebhook({ - required RepositorySlug repo, - required String url, - }) async { - final json = await _runTeaJson([ - 'webhooks', - 'create', - '--type', - 'gitea', - '--events', - 'issues,issue_comment', - '--active', - '-o', - 'json', - '-r', - repo.fullName, - url, - ]); - if (json is! Map) { - throw const FormatException( - 'tea webhooks create returned an unexpected response.', - ); - } - return _readInt(json, 'id'); - } - - Future createGitHubIssueWebhook({ - required RepositorySlug repo, - required String url, - }) async { - final json = await _runGhJson([ - 'api', - '-X', - 'POST', - _buildApiPath(repo, ['hooks']), - '-f', - 'name=web', - '-F', - 'active=true', - '-f', - 'events[]=issues', - '-f', - 'events[]=issue_comment', - '-f', - 'config[url]=$url', - '-f', - 'config[content_type]=json', - ]); - if (json is! Map) { - throw const FormatException( - 'gh api webhook create returned an unexpected response.', - ); - } - return _readInt(json, 'id'); - } - - Future deleteGitHubWebhook({ - required RepositorySlug repo, - required int webhookId, - }) async { - await _runGhJson([ - 'api', - '-X', - 'DELETE', - _buildApiPath(repo, ['hooks', '$webhookId']), - ]); - } - - Future deleteGiteaWebhook({ - required RepositorySlug repo, - required int webhookId, - }) async { - await _runTeaJson([ - 'webhooks', - 'delete', - '--confirm', - '-r', - repo.fullName, - '$webhookId', - ]); - } - - Future> _fetchUpdatedGitHubIssues({ - required Iterable trackerProjects, - required DateTime? since, - }) async { - final repoList = trackerProjects - .map(_parseRepositorySlug) - .toList(growable: false); - if (repoList.isEmpty) { - return const []; - } - - final queryParts = [ - for (final repo in repoList) 'repo:${repo.fullName}', - 'is:issue', - 'state:open', - if (since != null) 'updated:>=${since.toUtc().toIso8601String()}', - ]; - - final issues = []; - for (var page = 1; true; page += 1) { - final json = await _runGhJson([ - 'api', - '-X', - 'GET', - 'search/issues', - '-f', - 'q=${queryParts.join(' ')}', - '-f', - 'sort=updated', - '-f', - 'order=asc', - '-f', - 'per_page=$_pageSize', - '-f', - 'page=$page', - ]); - final items = - ((json as Map)['items'] as List? ?? - const []) - .cast>(); - issues.addAll( - items - .where((item) => !item.containsKey('pull_request')) - .map(GitHubIssueSummary.fromSearchJson), - ); - if (items.length < _pageSize) { - break; - } - } - - return issues; - } - - Future> _fetchUpdatedGiteaIssues({ - required Iterable trackerProjects, - required DateTime? since, - }) async { - final issues = []; - - for (final repo in trackerProjects.map(_parseRepositorySlug)) { - for (var page = 1; true; page += 1) { - final query = { - 'state': 'open', - 'limit': '$_pageSize', - 'page': '$page', - if (since != null) 'since': since.toUtc().toIso8601String(), - }; - final json = await _runTeaJson([ - 'api', - '-X', - 'GET', - _buildApiPath(repo, ['issues'], query), - '-r', - repo.fullName, - ]); - final items = (json as List? ?? const []) - .cast>(); - issues.addAll( - items - .where((item) => item['pull_request'] == null) - .map((item) => GitHubIssueSummary.fromRepositoryJson(repo, item)), - ); - if (items.length < _pageSize) { - break; - } - } - } - - issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); - return issues; - } - - Future> _fetchUpdatedOpenProjectIssues({ - required Iterable trackerProjects, - required DateTime? since, - }) async { - final issues = []; - - for (final trackerProject in trackerProjects) { - final project = await _resolveOpenProjectProjectRef(trackerProject); - final config = await _loadOpenProjectConfig(); - final json = await _runOpenProjectJson( - method: 'GET', - path: '/api/v3/projects/${project.id}/work_packages', - queryParameters: { - 'pageSize': '-1', - 'filters': jsonEncode(>[ - { - 'status': { - 'operator': 'o', - 'values': const [], - }, - }, - ]), - }, - ); - final embedded = - (json as Map)['_embedded'] as Map?; - final items = - (embedded?['elements'] as List? ?? const []) - .cast>(); - issues.addAll( - items - .map( - (item) => GitHubIssueSummary.fromOpenProjectJson( - trackerProject, - item, - config.host, - ), - ) - .where((issue) => since == null || issue.updatedAt.isAfter(since)), - ); - } - - issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); - return issues; - } - - Future _loadAuthenticatedLogin({ - required IssueTrackerProvider provider, - }) async { - final json = switch (provider) { - IssueTrackerProvider.github => await _runGhJson(['api', 'user']), - IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']), - IssueTrackerProvider.openproject => await _runOpenProjectJson( - method: 'GET', - path: '/api/v3/users/me', - ), - }; - if (json is! Map) { - return null; - } - final login = - json['login']?.toString().trim() ?? - json['name']?.toString().trim() ?? - json['firstName']?.toString().trim(); - return login == null || login.isEmpty ? null : login; - } - - Future _runGhJson(List arguments) { - return _runJson(ghCommand, arguments); - } - - Future _runTeaJson(List arguments) { - return _runJson(teaCommand, arguments); - } - - Future _runOpenProjectJson({ - required String method, - required String path, - Map? queryParameters, - Object? body, - }) async { - final config = await _loadOpenProjectConfig(); - final client = HttpClient(); - try { - final request = await client.openUrl( - method, - _buildOpenProjectUri( - config.host, - path, - queryParameters: queryParameters, - ), - ); - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - request.headers.set( - HttpHeaders.authorizationHeader, - 'Basic ${base64Encode(utf8.encode('apikey:${config.token}'))}', - ); - if (body != null) { - request.write(jsonEncode(body)); - } - - final response = await request.close(); - final responseBody = await utf8.decodeStream(response); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException( - 'OpenProject API request failed ' - '(${response.statusCode} ${response.reasonPhrase}): $responseBody', - uri: request.uri, - ); - } - - final trimmed = responseBody.trim(); - if (trimmed.isEmpty) { - return null; - } - return jsonDecode(trimmed); - } finally { - client.close(force: true); - } - } - - Future _runJson(String command, List arguments) async { - final result = await runExternalCommand(command, arguments); - if (result.exitCode != 0) { - throw ProcessException( - command, - arguments, - '${result.stdout}\n${result.stderr}', - result.exitCode, - ); - } - - final stdoutText = result.stdout.toString().trim(); - if (stdoutText.isEmpty) { - return null; - } - return jsonDecode(stdoutText); - } - - String _buildApiPath( - RepositorySlug repo, - List tailSegments, [ - Map query = const {}, - ]) { - return Uri( - pathSegments: ['repos', repo.owner, repo.name, ...tailSegments], - queryParameters: query.isEmpty ? null : query, - ).toString(); - } - - Uri _buildOpenProjectUri( - Uri host, - String path, { - Map? queryParameters, - }) { - return host.replace( - path: p.posix.normalize( - '${host.path.endsWith('/') ? host.path.substring(0, host.path.length - 1) : host.path}$path', - ), - queryParameters: queryParameters == null || queryParameters.isEmpty - ? null - : queryParameters, - ); - } - - Future<_OpenProjectConfig> _loadOpenProjectConfig() async { - final environment = AppEnvironment.variables; - final host = environment['OP_CLI_HOST']?.trim(); - final token = environment['OP_CLI_TOKEN']?.trim(); - if (host != null && host.isNotEmpty && token != null && token.isNotEmpty) { - return _OpenProjectConfig(host: Uri.parse(host), token: token); - } - - final configPath = _openProjectConfigPath(environment); - final file = File(configPath); - if (!await file.exists()) { - throw const FileSystemException( - 'OpenProject config not found. Set OP_CLI_HOST and OP_CLI_TOKEN or run `op login` first.', - ); - } - - final parts = (await file.readAsString()).trim().split(RegExp(r'\s+')); - if (parts.length != 2) { - throw FileSystemException( - 'Invalid OpenProject config file at $configPath. Run `op login` again.', - ); - } - return _OpenProjectConfig(host: Uri.parse(parts[0]), token: parts[1]); - } - - String _openProjectConfigPath(Map environment) { - final xdgConfigHome = environment['XDG_CONFIG_HOME']?.trim(); - if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) { - return p.join(xdgConfigHome, 'openproject', 'config'); - } - final home = environment['HOME']?.trim(); - if (home == null || home.isEmpty) { - throw const FileSystemException( - 'HOME is not set and XDG_CONFIG_HOME is unset. Cannot locate OpenProject config.', - ); - } - return p.join(home, '.config', 'openproject', 'config'); - } - - Future<_OpenProjectProjectRef> _resolveOpenProjectProjectRef( - String projectRef, - ) { - return _openProjectProjectFutures.putIfAbsent( - projectRef, - () => _loadOpenProjectProjectRef(projectRef), - ); - } - - Future<_OpenProjectProjectRef> _loadOpenProjectProjectRef( - String projectRef, - ) async { - final projectId = int.tryParse(projectRef); - if (projectId != null && projectId > 0) { - return _OpenProjectProjectRef(id: projectId, name: projectRef); - } - - final json = await _runOpenProjectJson( - method: 'GET', - path: '/api/v3/projects', - queryParameters: { - 'pageSize': '-1', - 'filters': jsonEncode(>[ - { - 'typeahead': { - 'operator': '**', - 'values': [projectRef], - }, - }, - ]), - }, - ); - final embedded = - (json as Map)['_embedded'] as Map?; - final projects = - (embedded?['elements'] as List? ?? const []) - .cast>(); - final exactMatches = projects - .where((project) { - final name = project['name']?.toString().trim(); - return name != null && name.toLowerCase() == projectRef.toLowerCase(); - }) - .toList(growable: false); - - if (exactMatches.length != 1) { - throw FormatException( - 'OpenProject project "$projectRef" did not resolve to exactly one project.', - ); - } - - return _OpenProjectProjectRef( - id: _readInt(exactMatches.single, 'id'), - name: exactMatches.single['name']?.toString() ?? projectRef, - ); - } -} - -class GitHubIssueSummary { - GitHubIssueSummary({ - required this.trackerProject, - required this.repoSlug, - required this.number, - required this.title, - required this.body, - required this.state, - required this.url, - required this.updatedAt, - required this.userLogin, - required this.labels, - }); - - final String trackerProject; - final RepositorySlug? repoSlug; - final int number; - final String title; - final String body; - final String state; - final String url; - final DateTime updatedAt; - final String userLogin; - final List labels; - - factory GitHubIssueSummary.fromRepositoryJson( - RepositorySlug repo, - Map json, - ) { - final labelsNode = json['labels'] as List? ?? const []; - return GitHubIssueSummary( - trackerProject: repo.fullName, - repoSlug: repo, - number: _readInt(json, 'number', fallbackKey: 'index'), - title: json['title'] as String? ?? '', - body: json['body'] as String? ?? '', - state: json['state'] as String? ?? 'open', - url: _readUrl(json), - updatedAt: DateTime.parse(_readString(json, 'updated_at')), - userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '', - labels: labelsNode - .map((label) => _readString(label as Map, 'name')) - .toList(growable: false), - ); - } - - factory GitHubIssueSummary.fromOpenProjectJson( - String trackerProject, - Map json, - Uri host, - ) { - final embedded = json['_embedded'] as Map?; - final description = - embedded?['description'] as Map? ?? - json['description'] as Map?; - return GitHubIssueSummary( - trackerProject: trackerProject, - repoSlug: null, - number: _readInt(json, 'id'), - title: json['subject'] as String? ?? '', - body: description?['raw'] as String? ?? '', - state: 'open', - url: _resolveUrl(_readUrl(json), host), - updatedAt: DateTime.parse(_readString(json, 'updatedAt')), - userLogin: - _readLinkTitle( - (json['_links'] as Map?)?['author'], - ) ?? - '', - labels: const [], - ); - } - - factory GitHubIssueSummary.fromSearchJson(Map json) { - final repositoryUrl = json['repository_url'] as String?; - if (repositoryUrl == null || repositoryUrl.isEmpty) { - throw const FormatException( - 'Missing repository_url in GitHub search result item.', - ); - } - return GitHubIssueSummary.fromRepositoryJson( - _parseRepositorySlugFromRepositoryUrl(repositoryUrl), - json, - ); - } - - static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) { - return parseRepositorySlugFromRepositoryUrl(value); - } - - factory GitHubIssueSummary.fromJson(Map json) { - final trackerProject = json['repo'] as String? ?? ''; - RepositorySlug? repoSlug; - try { - repoSlug = _parseRepositorySlug(trackerProject); - } on FormatException { - repoSlug = null; - } - return GitHubIssueSummary( - trackerProject: trackerProject, - repoSlug: repoSlug, - number: _readInt(json, 'number'), - title: json['title'] as String? ?? '', - body: json['body'] as String? ?? '', - state: json['state'] as String? ?? 'open', - url: _readString(json, 'url'), - updatedAt: DateTime.parse(_readString(json, 'updated_at')), - userLogin: json['user_login'] as String? ?? '', - labels: (json['labels'] as List? ?? const []) - .map((label) => label.toString()) - .toList(growable: false), - ); - } - - RepositorySlug get requiredRepoSlug { - final slug = repoSlug; - if (slug == null) { - throw StateError( - 'Tracker project "$trackerProject" does not use a repository slug.', - ); - } - return slug; - } - - Map toJson() => { - 'repo': trackerProject, - 'number': number, - 'title': title, - 'body': body, - 'state': state, - 'url': url, - 'updated_at': updatedAt.toUtc().toIso8601String(), - 'user_login': userLogin, - 'labels': labels, - }; -} - -class GitHubIssueComment { - GitHubIssueComment({ - required this.id, - required this.body, - required this.userLogin, - required this.createdAt, - required this.updatedAt, - required this.url, - }); - - final int id; - final String body; - final String userLogin; - final DateTime createdAt; - final DateTime updatedAt; - final String url; - - factory GitHubIssueComment.fromJson(Map json) { - return GitHubIssueComment( - id: _readInt(json, 'id'), - body: json['body'] as String? ?? '', - userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '', - createdAt: DateTime.parse(_readString(json, 'created_at')), - updatedAt: DateTime.parse(_readString(json, 'updated_at')), - url: _readUrl(json), - ); - } - - factory GitHubIssueComment.fromOpenProjectJson( - Map json, - Uri host, - ) { - return GitHubIssueComment( - id: _readInt(json, 'id'), - body: _readOpenProjectCommentBody(json) ?? '', - userLogin: - _readLinkTitle((json['_links'] as Map?)?['user']) ?? - '', - createdAt: DateTime.parse(_readString(json, 'createdAt')), - updatedAt: DateTime.parse(_readString(json, 'updatedAt')), - url: _resolveUrl(_readUrl(json), host), - ); - } - - Map toJson() => { - 'id': id, - 'body': body, - 'user_login': userLogin, - 'created_at': createdAt.toUtc().toIso8601String(), - 'updated_at': updatedAt.toUtc().toIso8601String(), - 'url': url, - }; -} - -class IssueThread { - IssueThread({required this.issue, required this.comments}); - - final GitHubIssueSummary issue; - final List comments; - - bool get isOpen => issue.state == 'open'; - - GitHubIssueComment? get latestComment => - comments.isEmpty ? null : comments.last; - - Map toJson() => { - 'issue': issue.toJson(), - 'comments': comments.map((comment) => comment.toJson()).toList(), - }; -} - -class PostedComment { - PostedComment({required this.id, required this.url, required this.body}); - - final int id; - final String url; - final String body; -} - -int _readInt(Map json, String key, {String? fallbackKey}) { - final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); - if (value is int) { - return value; - } - if (value is String) { - return int.parse(value); - } - throw FormatException( - 'Missing integer field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', - ); -} - -String _readString( - Map json, - String key, { - String? fallbackKey, -}) { - final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]); - if (value is String && value.isNotEmpty) { - return value; - } - throw FormatException( - 'Missing string field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.', - ); -} - -String _readUrl(Map json) { - return json['html_url'] as String? ?? - json['url'] as String? ?? - json['htmlUrl'] as String? ?? - _readLinkHref((json['_links'] as Map?)?['self']) ?? - ''; -} - -String? _readLogin(Object? userNode) { - if (userNode is Map) { - final login = userNode['login']?.toString().trim(); - if (login != null && login.isNotEmpty) { - return login; - } - final userName = userNode['user_name']?.toString().trim(); - if (userName != null && userName.isNotEmpty) { - return userName; - } - final username = userNode['username']?.toString().trim(); - if (username != null && username.isNotEmpty) { - return username; - } - } - return null; -} - -String? _readLinkTitle(Object? linkNode) { - if (linkNode is Map) { - final title = linkNode['title']?.toString().trim(); - if (title != null && title.isNotEmpty) { - return title; - } - } - return null; -} - -String? _readLinkHref(Object? linkNode) { - if (linkNode is Map) { - final href = linkNode['href']?.toString().trim(); - if (href != null && href.isNotEmpty) { - return href; - } - } - return null; -} - -String? _readOpenProjectCommentBody(Map json) { - final comment = json['comment']; - if (comment is Map) { - final raw = comment['raw']?.toString(); - if (raw != null && raw.isNotEmpty) { - return raw; - } - } - return null; -} - -String _resolveUrl(String value, Uri host) { - if (value.isEmpty) { - return value; - } - final uri = Uri.parse(value); - if (uri.hasScheme) { - return value; - } - return host.resolveUri(uri).toString(); -} - -RepositorySlug _parseRepositorySlug(String value) { - final parts = value.split('/'); - if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) { - throw FormatException( - 'Invalid repository slug "$value". Expected owner/repo.', - ); - } - return RepositorySlug(parts[0], parts[1]); -} - -RepositorySlug parseRepositorySlugFromRepositoryUrl(String value) { - final uri = Uri.parse(value); - final segments = uri.pathSegments; - final reposIndex = segments.indexOf('repos'); - if (reposIndex == -1 || reposIndex + 2 >= segments.length) { - throw FormatException( - 'Invalid repository_url in GitHub search result item: "$value"', - ); - } - return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]); -} - -class _OpenProjectConfig { - const _OpenProjectConfig({required this.host, required this.token}); - - final Uri host; - final String token; -} - -class _OpenProjectProjectRef { - const _OpenProjectProjectRef({required this.id, required this.name}); - - final int id; - final String name; -} +export 'issue_tracker/client.dart'; +export 'issue_tracker/models.dart'; diff --git a/lib/src/scm_client.dart b/lib/src/scm_client.dart index b5f64cb..022f6f5 100644 --- a/lib/src/scm_client.dart +++ b/lib/src/scm_client.dart @@ -17,6 +17,8 @@ abstract interface class ScmClient { class GitHubScmClient extends _GitWorktreeScmClient {} +class GitLabScmClient extends _GitWorktreeScmClient {} + class GiteaScmClient extends _GitWorktreeScmClient {} abstract class _GitWorktreeScmClient implements ScmClient { @@ -117,5 +119,6 @@ abstract class _GitWorktreeScmClient implements ScmClient { Map defaultScmClients() => { ScmProvider.github: GitHubScmClient(), + ScmProvider.gitlab: GitLabScmClient(), ScmProvider.gitea: GiteaScmClient(), }; diff --git a/test/config/app_config_test.dart b/test/config/app_config_test.dart index 8858c6b..d198f78 100644 --- a/test/config/app_config_test.dart +++ b/test/config/app_config_test.dart @@ -441,6 +441,41 @@ projects: ); }); + /// ```gherkin + /// Scenario: Load an explicit GitLab issue tracker provider + /// Given a config whose project sets issueTracker.provider to gitlab + /// When loading the app config from disk + /// Then the project keeps the configured GitLab provider + /// And the project uses GitLab polling and GitLab SCM by default + /// ``` + test('Load an explicit GitLab issue tracker provider', () async { + // Given a config whose project sets issueTracker.provider to gitlab. + final tempDir = await Directory.systemTemp.createTemp('cws-gitlab-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + provider: gitlab + 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 the configured GitLab provider. + expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab); + + // And the project uses GitLab polling and GitLab SCM by default. + expect(config.projects['sample']!.scm.provider, ScmProvider.gitlab); + expect( + config.projects['sample']!.issueAssistant.eventSource.type, + IssueAssistantEventSourceKind.glabPolling, + ); + expect(config.projects['sample']!.repo, 'group/subgroup/sample'); + }); + /// ```gherkin /// Scenario: Load an explicit OpenProject issue tracker provider /// Given a config whose project sets issueTracker.provider to openproject diff --git a/test/config/init_config_cli_test.dart b/test/config/init_config_cli_test.dart index bfe92c2..e75fee3 100644 --- a/test/config/init_config_cli_test.dart +++ b/test/config/init_config_cli_test.dart @@ -34,6 +34,7 @@ void main() { final stdoutText = result.stdout as String; expect(stdoutText, contains('Available commands:')); expect(stdoutText, contains('init-config')); + expect(stdoutText, contains('--glab-command')); expect(stdoutText, contains('--gosmee-command')); }); diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart index 050f4c6..d42d134 100644 --- a/test/issue_tracker_client_test.dart +++ b/test/issue_tracker_client_test.dart @@ -1,7 +1,12 @@ +import 'dart:io'; + import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:github/github.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; +import 'test_support.dart'; + void main() { /// ```gherkin /// Feature: Issue tracker payload mapping @@ -48,5 +53,135 @@ void main() { expect(restored.requiredRepoSlug.owner, 'owner'); expect(restored.requiredRepoSlug.name, 'sample'); }); + + /// ```gherkin + /// Scenario: Preserve GitLab project paths without forcing owner/repo slugs + /// Given a GitLab issue summary created from a namespaced GitLab project payload + /// When the summary is serialized and read back through fromJson + /// Then the original GitLab project path remains available on the deserialized summary + /// And the deserialized summary does not invent a two-segment repository slug + /// ``` + test('Preserve GitLab project paths without forcing owner/repo slugs', () { + // Given a GitLab issue summary created from a namespaced GitLab project payload. + final original = GitHubIssueSummary.fromGitLabJson( + 'group/subgroup/sample', + { + 'iid': 12, + 'title': 'Need GitLab support', + 'description': 'Please add glab support.', + 'state': 'opened', + 'web_url': + 'https://gitlab.example.test/group/subgroup/sample/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'author': {'username': 'reporter'}, + 'labels': ['enhancement'], + }, + ); + + // When the summary is serialized and read back through fromJson. + final restored = GitHubIssueSummary.fromJson( + original.toJson().cast(), + ); + + // Then the original GitLab project path remains available on the deserialized summary. + expect(restored.trackerProject, 'group/subgroup/sample'); + + // And the deserialized summary does not invent a two-segment repository slug. + expect(restored.repoSlug, isNull); + }); + }); + + /// ```gherkin + /// Feature: GitLab tracker client integration + /// + /// As a maintainer adding GitLab issue tracking + /// I want the tracker client to talk to glab and normalize GitLab payloads + /// So that polling, thread loading, and comment posting work like the existing providers + /// ``` + group('IssueTrackerClient GitLab', () { + /// ```gherkin + /// Scenario: Fetch GitLab issues, comments, and login through glab + /// Given a fake glab executable with one GitLab issue and one note + /// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment + /// Then the GitLab issue payload is normalized into the shared issue summary model + /// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL + /// ``` + test('Fetch GitLab issues, comments, and login through glab', () async { + // Given a fake glab executable with one GitLab issue and one note. + final sandbox = await Directory.systemTemp.createTemp('cws-glab-'); + final glabScript = File(p.join(sandbox.path, 'glab')); + final postedBodyFile = File(p.join(sandbox.path, 'posted-body.txt')); + await writeFakeGlabScript( + glabScript: glabScript, + issueListResponsesByRepo: >>{ + 'group/subgroup/sample': >[ + { + 'iid': 12, + 'title': 'Need GitLab support', + 'description': 'Please add glab support.', + 'state': 'opened', + 'web_url': + 'https://gitlab.example.test/group/subgroup/sample/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'author': {'username': 'reporter'}, + 'labels': ['enhancement'], + }, + ], + }, + commentResponsesByRepo: >>>{ + 'group/subgroup/sample': >>{ + 12: >[ + { + 'id': 501, + 'body': 'Working on it.', + 'created_at': '2026-04-04T12:05:00Z', + 'updated_at': '2026-04-04T12:06:00Z', + 'author': {'username': 'maintainer'}, + }, + ], + }, + }, + postedBodyFile: postedBodyFile, + viewerLogin: 'glab-user', + ); + final client = IssueTrackerClient(glabCommand: glabScript.path); + + // When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment. + final issues = await client.fetchUpdatedIssuesForRepos( + provider: IssueTrackerProvider.gitlab, + trackerProjects: const ['group/subgroup/sample'], + since: null, + ); + final thread = await client.fetchThread( + provider: IssueTrackerProvider.gitlab, + trackerProject: 'group/subgroup/sample', + issue: issues.single, + ); + final login = await client.getAuthenticatedLogin( + provider: IssueTrackerProvider.gitlab, + ); + final posted = await client.createIssueComment( + provider: IssueTrackerProvider.gitlab, + trackerProject: 'group/subgroup/sample', + issueNumber: 12, + body: 'Reply from glab', + ); + + // Then the GitLab issue payload is normalized into the shared issue summary model. + expect(client.trackerCliName(IssueTrackerProvider.gitlab), 'glab'); + expect(login, 'glab-user'); + expect(issues.single.trackerProject, 'group/subgroup/sample'); + expect(issues.single.repoSlug, isNull); + expect(issues.single.number, 12); + expect(issues.single.state, 'opened'); + expect(issues.single.labels, ['enhancement']); + + // And GitLab note URLs fall back to the issue note anchor when the API omits a web URL. + expect(thread.comments.single.url, endsWith('/issues/12#note_501')); + expect(await postedBodyFile.readAsString(), 'Reply from glab'); + expect(posted.id, 901); + expect(posted.url, endsWith('/issues/12#note_901')); + expect(posted.body, 'posted'); + }); }); } diff --git a/test/test_support.dart b/test/test_support.dart index f166fe6..45913e3 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -430,6 +430,128 @@ Future writeFakeTeaScript({ await makeScriptExecutable(teaScript); } +Future writeFakeGlabScript({ + required File glabScript, + required Map>> issueListResponsesByRepo, + required Map>>> + commentResponsesByRepo, + File? glabLog, + Map>? postCommentIssueNumbersByRepo, + File? postedBodyFile, + String? viewerLogin, + bool failViewerLookup = false, +}) async { + final normalizedPostCommentIssueNumbersByRepo = + postCommentIssueNumbersByRepo ?? + >{ + for (final entry in issueListResponsesByRepo.entries) + entry.key: entry.value + .map((issue) => issue['iid']) + .whereType() + .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 writeResponderScript( File responderScript, Map response, { diff --git a/test/workspace/workspace_manager_test.dart b/test/workspace/workspace_manager_test.dart index 5b32619..ac50e6b 100644 --- a/test/workspace/workspace_manager_test.dart +++ b/test/workspace/workspace_manager_test.dart @@ -102,6 +102,59 @@ void main() { expect(client.createdWorkspaceRoot, '/tmp/worktrees'); expect(client.disposedWorkspacePath, '/tmp/workspaces/sample'); }); + + /// ```gherkin + /// Scenario: Route project workspaces through the GitLab SCM client + /// Given a workspace manager with a fake GitLab SCM client + /// And a project config that selects GitLab as its SCM provider + /// When creating and disposing an ephemeral workspace for that project + /// Then the workspace manager delegates both operations to the GitLab SCM client + /// ``` + test('Route project workspaces through the GitLab SCM client', () async { + // Given a workspace manager with a fake GitLab SCM client. + final client = _RecordingScmClient(); + final manager = WorkspaceManager( + worktreeRoot: '/tmp/worktrees', + clients: {ScmProvider.gitlab: client}, + ); + + // And a project config that selects GitLab as its SCM provider. + final project = ProjectConfig( + key: 'sample', + provider: IssueTrackerProvider.gitlab, + scm: const ScmConfig(provider: ScmProvider.gitlab), + repo: 'group/subgroup/sample', + repoSlug: null, + path: '/tmp/source-repo', + defaultBranch: 'main', + sessionPrefix: 'sam', + issueAssistant: IssueAssistantConfig( + enabled: true, + eventSource: const PollingIssueEventSourceConfig( + type: IssueAssistantEventSourceKind.glabPolling, + pollInterval: Duration(seconds: 30), + ), + maxConcurrentIssues: 3, + mentionTriggers: const ['@helper'], + prompt: defaultIssueAssistantPrompt, + responders: const [], + capabilities: {AssistantCapability.planning}, + notifications: const [], + commentTag: 'code-work-spawner', + commentPrefixTemplate: defaultCommentPrefixTemplate, + commentFooterTemplate: defaultCommentFooterTemplate, + ), + ); + + // When creating and disposing an ephemeral workspace for that project. + final workspacePath = await manager.createEphemeralWorkspace(project); + await manager.disposeEphemeralWorkspace(project, workspacePath); + + // Then the workspace manager delegates both operations to the GitLab SCM client. + expect(client.createdProjectPath, '/tmp/source-repo'); + expect(client.createdWorkspaceRoot, '/tmp/worktrees'); + expect(client.disposedWorkspacePath, '/tmp/workspaces/sample'); + }); }); }