From d18bbaed883243adfac0f391f89f43697ba54e93 Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 10 Apr 2026 01:59:34 +0800 Subject: [PATCH] feat: add GitLab tracker and SCM support (#35) Add glab-backed GitLab issue polling, login lookup, and comment posting so GitLab projects can be handled alongside GitHub, Gitea, and OpenProject. Also wire GitLab through SCM selection, config defaults, docs, and examples so issue #35 can be configured end to end. --- README.md | 11 +- bin/code_work_spawner.dart | 6 + examples/README.md | 14 +- examples/simple-gitlab.yaml | 30 + lib/src/config.dart | 42 +- lib/src/config_schema.dart | 4 +- lib/src/issue_assistant_app.dart | 3 + lib/src/issue_event_source/base.dart | 2 + lib/src/issue_tracker/client.dart | 725 ++++++++++++++++ lib/src/issue_tracker/models.dart | 398 +++++++++ lib/src/issue_tracker_client.dart | 963 +-------------------- lib/src/scm_client.dart | 3 + test/config/app_config_test.dart | 35 + test/config/init_config_cli_test.dart | 1 + test/issue_tracker_client_test.dart | 135 +++ test/test_support.dart | 122 +++ test/workspace/workspace_manager_test.dart | 53 ++ 17 files changed, 1569 insertions(+), 978 deletions(-) create mode 100644 examples/simple-gitlab.yaml create mode 100644 lib/src/issue_tracker/client.dart create mode 100644 lib/src/issue_tracker/models.dart 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'); + }); }); }