From 58b7d333cbb6fb965ba6eeac76da1dad55bec28e Mon Sep 17 00:00:00 2001 From: insleker Date: Fri, 10 Apr 2026 00:45:40 +0800 Subject: [PATCH] feat: add OpenProject issue tracker support for #32 --- README.md | 8 +- examples/README.md | 20 +- examples/simple-openproject.yaml | 31 ++ lib/src/config.dart | 70 ++- lib/src/config_schema.dart | 4 +- lib/src/issue_assistant_app.dart | 5 +- lib/src/issue_event_source/base.dart | 14 +- lib/src/issue_event_source/gitea_gosmee.dart | 6 +- lib/src/issue_event_source/github_gosmee.dart | 6 +- .../github_webhook_forward.dart | 2 +- lib/src/issue_tracker_client.dart | 488 ++++++++++++++++-- test/config/app_config_test.dart | 125 ++++- test/core/multi_project_test.dart | 235 +++++++++ test/issue_tracker_client_test.dart | 52 ++ 14 files changed, 987 insertions(+), 79 deletions(-) create mode 100644 examples/simple-openproject.yaml create mode 100644 test/issue_tracker_client_test.dart diff --git a/README.md b/README.md index 90ab126..eb3772e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Repository issue thread assistant with an Agent Orchestrator style YAML config. -It consumes issue tracker events for configured GitHub or Gitea repositories, -reads issue threads, and uses CLI responders such as `codex`, `opencode`, or +It consumes issue tracker events for configured GitHub, 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 desktop notifications. Repository access is configured separately through each @@ -12,7 +12,7 @@ independently. ## Highlights -- Consume GitHub and Gitea issue events across multiple repositories +- Consume GitHub, 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` and/or `tea` CLI authenticated for the target repositories +- `gh`, `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` diff --git a/examples/README.md b/examples/README.md index 5915052..e92abc9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,13 +9,18 @@ event source. 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. +still accepted during load for backward compatibility. When `scm` is omitted +it defaults to a matching provider for existing GitHub and Gitea configs. GitHub projects default to `gh/polling`, and Gitea projects default to -`tea/polling`. GitHub projects can switch their long-running runtime 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 `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 +name. `gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and `issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that @@ -62,7 +67,8 @@ Currently supported fields are: - `username` - `avatarUrl` -Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub or -[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea. This repo uses -`dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for -bug-verification worktrees. +Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, +[`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 +`worktreeDir` for bug-verification worktrees. diff --git a/examples/simple-openproject.yaml b/examples/simple-openproject.yaml new file mode 100644 index 0000000..81ce65e --- /dev/null +++ b/examples/simple-openproject.yaml @@ -0,0 +1,31 @@ +# Minimal setup for a single OpenProject tracker with repository workspaces +# managed through an explicit SCM provider. +# Replies require at least one configured responder. + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees + +defaults: + issueAssistant: + enabled: true + eventSource: + type: op/polling + pollInterval: 30s + maxConcurrentIssues: 3 + mentionTriggers: ["@codex", "@helper"] + responders: + - id: codex + command: codex + args: ["exec", "--json"] + timeout: 10m + +projects: + sample-openproject: + issueTracker: + provider: openproject + scm: + provider: github + # OpenProject project name or numeric project id. + repo: Demo project + path: ~/repo + defaultBranch: main diff --git a/lib/src/config.dart b/lib/src/config.dart index 05da7a1..5d40412 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -178,7 +178,7 @@ class ProjectConfig { final IssueTrackerProvider provider; final ScmConfig scm; final String repo; - final RepositorySlug repoSlug; + final RepositorySlug? repoSlug; final String path; final String defaultBranch; final String sessionPrefix; @@ -206,9 +206,16 @@ class ProjectConfig { document.issueTracker?.provider ?? document.provider ?? IssueTrackerProvider.github; + final fallbackScmProvider = _defaultScmProviderForTracker(provider); + if (document.scm?.provider == null && fallbackScmProvider == null) { + throw FormatException( + 'projects.$key.scm.provider is required for ' + 'issueTracker.provider: ${provider.name}.', + ); + } final scm = ScmConfig.fromDocument( document.scm, - fallbackProvider: _defaultScmProviderForTracker(provider), + fallbackProvider: fallbackScmProvider, ); var issueAssistant = defaultAssistant.merge(document.issueAssistant); if (document.issueAssistant?.eventSource?.type == null && @@ -217,9 +224,14 @@ class ProjectConfig { eventSource: (issueAssistant.eventSource as PollingIssueEventSourceConfig) .copyWith( - type: provider == IssueTrackerProvider.github - ? IssueAssistantEventSourceKind.ghPolling - : IssueAssistantEventSourceKind.teaPolling, + type: switch (provider) { + IssueTrackerProvider.github => + IssueAssistantEventSourceKind.ghPolling, + IssueTrackerProvider.gitea => + IssueAssistantEventSourceKind.teaPolling, + IssueTrackerProvider.openproject => + IssueAssistantEventSourceKind.opPolling, + }, ), ); } @@ -248,6 +260,20 @@ class ProjectConfig { 'requires issueTracker.provider: gitea.', ); } + case IssueAssistantEventSourceKind.opPolling: + if (provider != IssueTrackerProvider.openproject) { + throw FormatException( + 'projects.$key.issueAssistant.eventSource op/polling ' + 'requires issueTracker.provider: openproject.', + ); + } + } + + if (provider == IssueTrackerProvider.openproject && repo.trim().isEmpty) { + throw FormatException( + 'Invalid OpenProject project reference for projects.$key.repo: ' + '"$repo". Expected a project id or exact project name.', + ); } return ProjectConfig( @@ -255,7 +281,11 @@ class ProjectConfig { provider: provider, scm: scm, repo: repo, - repoSlug: _parseRepositorySlug(repo, field: 'projects.$key.repo'), + repoSlug: switch (provider) { + IssueTrackerProvider.github || IssueTrackerProvider.gitea => + _parseRepositorySlug(repo, field: 'projects.$key.repo'), + IssueTrackerProvider.openproject => null, + }, path: _expandHome(path), defaultBranch: document.defaultBranch ?? 'main', sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key), @@ -263,6 +293,16 @@ class ProjectConfig { ); } + RepositorySlug get requiredRepoSlug { + final slug = repoSlug; + if (slug == null) { + throw StateError( + 'Issue tracker provider ${provider.name} does not use a repository slug.', + ); + } + return slug; + } + static RepositorySlug _parseRepositorySlug( String value, { required String field, @@ -284,9 +324,13 @@ class ScmConfig { factory ScmConfig.fromDocument( ScmConfigDocument? document, { - required ScmProvider fallbackProvider, + ScmProvider? fallbackProvider, }) { - return ScmConfig(provider: document?.provider ?? fallbackProvider); + final provider = document?.provider ?? fallbackProvider; + if (provider == null) { + throw const FormatException('Missing required SCM provider.'); + } + return ScmConfig(provider: provider); } } @@ -588,6 +632,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => 'gh/polling', IssueAssistantEventSourceKind.teaPolling => 'tea/polling', + IssueAssistantEventSourceKind.opPolling => 'op/polling', IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee', IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward', IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee', @@ -598,6 +643,7 @@ bool _isPollingEventSource(IssueAssistantEventSourceKind value) { return switch (value) { IssueAssistantEventSourceKind.ghPolling => true, IssueAssistantEventSourceKind.teaPolling => true, + IssueAssistantEventSourceKind.opPolling => true, IssueAssistantEventSourceKind.ghGosmee => false, IssueAssistantEventSourceKind.ghWebhookForward => false, IssueAssistantEventSourceKind.teaGosmee => false, @@ -836,15 +882,21 @@ void _assertCamelCaseConfigKeys(Map root) { } } -ScmProvider _defaultScmProviderForTracker(IssueTrackerProvider provider) { +ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) { return switch (provider) { IssueTrackerProvider.github => ScmProvider.github, IssueTrackerProvider.gitea => ScmProvider.gitea, + IssueTrackerProvider.openproject => null, }; } void _normalizeLegacyEventSourceConfig(Map root) { void normalizeProjectTracker(Map project) { + final repo = project['repo']; + if (repo != null && repo is! String) { + project['repo'] = repo.toString(); + } + final rawIssueTracker = project['issueTracker']; Map? normalizedIssueTracker; diff --git a/lib/src/config_schema.dart b/lib/src/config_schema.dart index 87a74f3..fe6123e 100644 --- a/lib/src/config_schema.dart +++ b/lib/src/config_schema.dart @@ -252,6 +252,8 @@ enum IssueAssistantEventSourceKind { ghPolling, @JsonValue('tea/polling') teaPolling, + @JsonValue('op/polling') + opPolling, @JsonValue('gh/gosmee') ghGosmee, @JsonValue('gh/webhook-forward') @@ -268,7 +270,7 @@ enum IssueAssistantReconciliationKind { } @JsonEnum(fieldRename: FieldRename.snake) -enum IssueTrackerProvider { github, gitea } +enum IssueTrackerProvider { github, 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 192c36a..d4df0ff 100644 --- a/lib/src/issue_assistant_app.dart +++ b/lib/src/issue_assistant_app.dart @@ -63,6 +63,7 @@ class IssueAssistantApp { sources: { IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, + IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( issueTrackerClient: issueTrackerClient, gosmeeCommand: gosmeeCommand, @@ -177,7 +178,7 @@ class IssueAssistantApp { final notifierRegistry = notifierRegistryFactory(project); final thread = await issueTrackerClient.fetchThread( provider: project.provider, - repo: project.repoSlug, + trackerProject: project.repo, issue: issue, ); final fingerprint = _fingerprintThread(thread); @@ -412,7 +413,7 @@ class IssueAssistantApp { ); final posted = await issueTrackerClient.createIssueComment( provider: project.provider, - repo: project.repoSlug, + trackerProject: project.repo, issueNumber: issue.number, body: commentBody, ); diff --git a/lib/src/issue_event_source/base.dart b/lib/src/issue_event_source/base.dart index 6b7a72f..91b5df8 100644 --- a/lib/src/issue_event_source/base.dart +++ b/lib/src/issue_event_source/base.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:io'; -import 'package:github/github.dart'; - import '../app_logger.dart'; import '../config.dart'; import '../database.dart'; @@ -113,6 +111,8 @@ class RoutedIssueEventSource implements IssueEventSource { yield IssueAssistantEventSourceKind.ghPolling; case IssueAssistantEventSourceKind.teaPolling: yield IssueAssistantEventSourceKind.teaPolling; + case IssueAssistantEventSourceKind.opPolling: + yield IssueAssistantEventSourceKind.opPolling; case IssueAssistantEventSourceKind.ghGosmee: if (_shouldRunContinuousReconciliation(project)) { yield IssueAssistantEventSourceKind.ghPolling; @@ -348,12 +348,12 @@ class PollingIssueEventSource implements IssueEventSource { final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos( provider: provider, - repos: providerProjects.map((project) => project.repoSlug), + trackerProjects: providerProjects.map((project) => project.repo), since: providerSince, ); for (final issue in issues) { final project = - projectsByRepo[_providerRepoKey(provider, issue.repoSlug)]; + projectsByRepo[_providerRepoKey(provider, issue.trackerProject)]; if (project == null) { continue; } @@ -461,14 +461,14 @@ class PollingIssueEventSource implements IssueEventSource { } String _projectRepoKey(ProjectConfig project) { - return _providerRepoKey(project.provider, project.repoSlug); + return _providerRepoKey(project.provider, project.repo); } String _providerRepoKey( IssueTrackerProvider provider, - RepositorySlug repoSlug, + String trackerProject, ) { - return '${provider.name}:${repoSlug.fullName}'; + return '${provider.name}:$trackerProject'; } void _log(String message) { diff --git a/lib/src/issue_event_source/gitea_gosmee.dart b/lib/src/issue_event_source/gitea_gosmee.dart index 4aaaacd..82826f1 100644 --- a/lib/src/issue_event_source/gitea_gosmee.dart +++ b/lib/src/issue_event_source/gitea_gosmee.dart @@ -61,7 +61,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { for (final project in giteaProjects) project.repo: project, }; for (final project in giteaProjects) { - _repoSlugsByProject[project.key] = project.repoSlug; + _repoSlugsByProject[project.key] = project.requiredRepoSlug; } server.listen( (request) => unawaited( @@ -96,7 +96,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { 'public_url=$publicUrl local_url=$localUrl', ); final webhookId = await issueTrackerClient.createGiteaIssueWebhook( - repo: project.repoSlug, + repo: project.requiredRepoSlug, url: publicUrl, ); _webhookIdsByProject[project.key] = webhookId; @@ -249,7 +249,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource { issueNode is Map && (issueNode['pull_request'] == null)) { final issue = GitHubIssueSummary.fromRepositoryJson( - project.repoSlug, + project.requiredRepoSlug, issueNode, ); _log( diff --git a/lib/src/issue_event_source/github_gosmee.dart b/lib/src/issue_event_source/github_gosmee.dart index 214398f..b54008f 100644 --- a/lib/src/issue_event_source/github_gosmee.dart +++ b/lib/src/issue_event_source/github_gosmee.dart @@ -61,7 +61,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { for (final project in githubProjects) project.repo: project, }; for (final project in githubProjects) { - _repoSlugsByProject[project.key] = project.repoSlug; + _repoSlugsByProject[project.key] = project.requiredRepoSlug; } server.listen( (request) => unawaited( @@ -96,7 +96,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { 'public_url=$publicUrl local_url=$localUrl', ); final webhookId = await issueTrackerClient.createGitHubIssueWebhook( - repo: project.repoSlug, + repo: project.requiredRepoSlug, url: publicUrl, ); _webhookIdsByProject[project.key] = webhookId; @@ -249,7 +249,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource { issueNode is Map && (issueNode['pull_request'] == null)) { final issue = GitHubIssueSummary.fromRepositoryJson( - project.repoSlug, + project.requiredRepoSlug, issueNode, ); _log( diff --git a/lib/src/issue_event_source/github_webhook_forward.dart b/lib/src/issue_event_source/github_webhook_forward.dart index 2e13f9a..d8f0e99 100644 --- a/lib/src/issue_event_source/github_webhook_forward.dart +++ b/lib/src/issue_event_source/github_webhook_forward.dart @@ -204,7 +204,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource { issueNode is Map && (issueNode['pull_request'] == null)) { final issue = GitHubIssueSummary.fromRepositoryJson( - project.repoSlug, + project.requiredRepoSlug, issueNode, ); _log( diff --git a/lib/src/issue_tracker_client.dart b/lib/src/issue_tracker_client.dart index 83f91f3..a1a42e7 100644 --- a/lib/src/issue_tracker_client.dart +++ b/lib/src/issue_tracker_client.dart @@ -2,32 +2,45 @@ 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'}); + 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 repos, + required Iterable trackerProjects, required DateTime? since, }) { return switch (provider) { IssueTrackerProvider.github => _fetchUpdatedGitHubIssues( - repos: repos, + trackerProjects: trackerProjects, since: since, ), IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues( - repos: repos, + trackerProjects: trackerProjects, + since: since, + ), + IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues( + trackerProjects: trackerProjects, since: since, ), }; @@ -35,16 +48,19 @@ class IssueTrackerClient { Future fetchThread({ required IssueTrackerProvider provider, - required RepositorySlug repo, + required String trackerProject, required GitHubIssueSummary issue, }) async { - final comments = switch (provider) { + final openProjectConfig = provider == IssueTrackerProvider.openproject + ? await _loadOpenProjectConfig() + : null; + final commentJson = switch (provider) { IssueTrackerProvider.github => await _runGhJson([ 'api', '-X', 'GET', _buildApiPath( - repo, + issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'per_page': '100'}, ), @@ -54,36 +70,60 @@ class IssueTrackerClient { '-X', 'GET', _buildApiPath( - repo, + issue.requiredRepoSlug, ['issues', '${issue.number}', 'comments'], {'limit': '100'}, ), '-r', - repo.fullName, + 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 as List) - .cast>() - .map(GitHubIssueComment.fromJson) + comments: comments + .map( + provider == IssueTrackerProvider.openproject + ? (json) => GitHubIssueComment.fromOpenProjectJson( + json, + openProjectConfig!.host, + ) + : GitHubIssueComment.fromJson, + ) .toList(growable: false), ); } Future createIssueComment({ required IssueTrackerProvider provider, - required RepositorySlug repo, + 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(repo, ['issues', '$issueNumber', 'comments']), + _buildApiPath(_parseRepositorySlug(trackerProject), [ + 'issues', + '$issueNumber', + 'comments', + ]), '-f', 'body=$body', ]), @@ -91,19 +131,32 @@ class IssueTrackerClient { 'api', '-X', 'POST', - _buildApiPath(repo, ['issues', '$issueNumber', 'comments']), + _buildApiPath(_parseRepositorySlug(trackerProject), [ + 'issues', + '$issueNumber', + 'comments', + ]), '-f', 'body=$body', '-r', - repo.fullName, + 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: _readUrl(map), - body: map['body'] as String? ?? body, + url: provider == IssueTrackerProvider.openproject + ? _resolveUrl(_readUrl(map), openProjectConfig!.host) + : _readUrl(map), + body: map['body'] as String? ?? _readOpenProjectCommentBody(map) ?? body, ); } @@ -120,6 +173,7 @@ class IssueTrackerClient { return switch (provider) { IssueTrackerProvider.github => 'gh', IssueTrackerProvider.gitea => 'tea', + IssueTrackerProvider.openproject => opCommand, }; } @@ -206,10 +260,12 @@ class IssueTrackerClient { } Future> _fetchUpdatedGitHubIssues({ - required Iterable repos, + required Iterable trackerProjects, required DateTime? since, }) async { - final repoList = repos.toList(growable: false); + final repoList = trackerProjects + .map(_parseRepositorySlug) + .toList(growable: false); if (repoList.isEmpty) { return const []; } @@ -257,12 +313,12 @@ class IssueTrackerClient { } Future> _fetchUpdatedGiteaIssues({ - required Iterable repos, + required Iterable trackerProjects, required DateTime? since, }) async { final issues = []; - for (final repo in repos) { + for (final repo in trackerProjects.map(_parseRepositorySlug)) { for (var page = 1; true; page += 1) { final query = { 'state': 'open', @@ -295,21 +351,71 @@ class IssueTrackerClient { 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(); - if (login == null || login.isEmpty) { - return null; - } - return login; + 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) { @@ -320,6 +426,53 @@ class IssueTrackerClient { 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) { @@ -348,10 +501,121 @@ class IssueTrackerClient { 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, @@ -363,7 +627,8 @@ class GitHubIssueSummary { required this.labels, }); - final RepositorySlug repoSlug; + final String trackerProject; + final RepositorySlug? repoSlug; final int number; final String title; final String body; @@ -379,6 +644,7 @@ class GitHubIssueSummary { ) { 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? ?? '', @@ -393,6 +659,33 @@ class GitHubIssueSummary { ); } + 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) { @@ -407,23 +700,45 @@ class GitHubIssueSummary { } static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) { - final uri = Uri.parse(value); - final segments = uri.pathSegments; - final reposIndex = segments.indexOf('repos'); - if (reposIndex == -1 || reposIndex + 2 >= segments.length) { - throw FormatException( - 'Invalid repository_url in GitHub search result item: "$value"', - ); - } - return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]); + return parseRepositorySlugFromRepositoryUrl(value); } factory GitHubIssueSummary.fromJson(Map json) { - return GitHubIssueSummary.fromRepositoryJson(RepositorySlug('', ''), 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': repoSlug.fullName, + 'repo': trackerProject, 'number': number, 'title': title, 'body': body, @@ -463,6 +778,22 @@ class GitHubIssueComment { ); } + 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, @@ -529,6 +860,7 @@ String _readUrl(Map json) { return json['html_url'] as String? ?? json['url'] as String? ?? json['htmlUrl'] as String? ?? + _readLinkHref((json['_links'] as Map?)?['self']) ?? ''; } @@ -549,3 +881,81 @@ String? _readLogin(Object? userNode) { } 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; +} diff --git a/test/config/app_config_test.dart b/test/config/app_config_test.dart index 40c0125..8858c6b 100644 --- a/test/config/app_config_test.dart +++ b/test/config/app_config_test.dart @@ -88,8 +88,8 @@ projects: // Then the project keeps inherited defaults that were not overridden. expect(config.projects['sample'], isNotNull); expect(config.projects['sample']!.provider, IssueTrackerProvider.github); - expect(config.projects['sample']!.repoSlug.owner, 'owner'); - expect(config.projects['sample']!.repoSlug.name, 'sample'); + expect(config.projects['sample']!.repoSlug!.owner, 'owner'); + expect(config.projects['sample']!.repoSlug!.name, 'sample'); expect(config.projects['sample']!.issueAssistant.mentionTriggers, [ '@helper', ]); @@ -434,13 +434,132 @@ projects: expect(config.projects['sample']!.scm.provider, ScmProvider.gitea); // And the repository slug still parses normally. - expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample'); + expect(config.projects['sample']!.repoSlug!.fullName, 'owner/sample'); expect( config.projects['sample']!.issueAssistant.eventSource.type, IssueAssistantEventSourceKind.teaPolling, ); }); + /// ```gherkin + /// Scenario: Load an explicit OpenProject issue tracker provider + /// Given a config whose project sets issueTracker.provider to openproject + /// And the project declares an explicit SCM provider + /// When loading the app config from disk + /// Then the project keeps the configured OpenProject provider + /// And the project uses op/polling as its default issue event source + /// ``` + test('Load an explicit OpenProject issue tracker provider', () async { + // Given a config whose project sets issueTracker.provider to openproject. + final tempDir = await Directory.systemTemp.createTemp('cws-openproject-'); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + provider: openproject + scm: + provider: github + repo: 42 + path: ${tempDir.path} +'''); + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then the project keeps the configured OpenProject provider. + expect( + config.projects['sample']!.provider, + IssueTrackerProvider.openproject, + ); + expect(config.projects['sample']!.scm.provider, ScmProvider.github); + + // And the project uses op/polling as its default issue event source. + expect( + config.projects['sample']!.issueAssistant.eventSource.type, + IssueAssistantEventSourceKind.opPolling, + ); + }); + + /// ```gherkin + /// Scenario: Require an explicit SCM provider for OpenProject projects + /// Given a config whose project uses the OpenProject issue tracker + /// And the project omits scm.provider + /// When loading the app config from disk + /// Then loading fails with a clear SCM requirement error + /// ``` + test('Require an explicit SCM provider for OpenProject projects', () async { + // Given a config whose project uses the OpenProject issue tracker. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-openproject-missing-scm-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + provider: openproject + repo: 42 + path: ${tempDir.path} +'''); + + // And the project omits scm.provider. + + // When loading the app config from disk. + final load = AppConfig.load(configFile.path); + + // Then loading fails with a clear SCM requirement error. + await expectLater( + load, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'projects.sample.scm.provider is required for issueTracker.provider: openproject', + ), + ), + ), + ); + }); + + /// ```gherkin + /// Scenario: Accept an OpenProject project name reference + /// Given a config whose project uses the OpenProject issue tracker + /// And the project repo field is a human-readable project name + /// When loading the app config from disk + /// Then loading succeeds without requiring a numeric project id + /// ``` + test('Accept an OpenProject project name reference', () async { + // Given a config whose project uses the OpenProject issue tracker. + final tempDir = await Directory.systemTemp.createTemp( + 'cws-openproject-project-name-', + ); + final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml')); + await configFile.writeAsString(''' +projects: + sample: + issueTracker: + provider: openproject + scm: + provider: github + repo: Demo project + path: ${tempDir.path} +'''); + + // And the project repo field is a human-readable project name. + + // When loading the app config from disk. + final config = await AppConfig.load(configFile.path); + + // Then loading succeeds without requiring a numeric project id. + expect( + config.projects['sample']!.provider, + IssueTrackerProvider.openproject, + ); + expect(config.projects['sample']!.repo, 'Demo project'); + }); + /// ```gherkin /// Scenario: Load gh/gosmee as the issue event source /// Given a GitHub project config that opts into gh/gosmee diff --git a/test/core/multi_project_test.dart b/test/core/multi_project_test.dart index 166d186..c86e633 100644 --- a/test/core/multi_project_test.dart +++ b/test/core/multi_project_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:code_work_spawner/code_work_spawner.dart'; @@ -335,9 +337,242 @@ projects: contains('Plan:\n- keep provider-specific commands isolated'), ); }); + + /// ```gherkin + /// Scenario: Reply through OpenProject when a user explicitly mentions the assistant + /// Given a temporary project checkout that can be used as the local repo path + /// And an OpenProject API fixture containing a mentioned work package + /// And a responder that emits a planning reply + /// And an orchestrator-style config that uses an OpenProject project name + /// When the app processes one polling cycle + /// Then it reads work package activities and posts exactly one reply through the OpenProject API + /// ``` + test( + 'Reply through OpenProject when a user explicitly mentions the assistant', + () async { + // Given a temporary project checkout that can be used as the local repo path. + final sandbox = await Directory.systemTemp.createTemp( + 'cws-openproject-run-', + ); + final repoDir = await Directory(p.join(sandbox.path, 'repo')).create(); + await initGitRepo(repoDir); + + // And an OpenProject API fixture containing a mentioned work package. + final server = await _FakeOpenProjectServer.start(); + addTearDown(server.close); + + // And a responder that emits a planning reply. + final responderScript = File(p.join(sandbox.path, 'responder.sh')); + await writeResponderScript(responderScript, { + 'decision': 'reply', + 'mode': 'planning', + 'markdown': 'Plan:\n- isolate tracker-specific API adapters', + 'summary': 'posted planning advice', + }); + + // And an orchestrator-style config that uses an OpenProject project name. + final configFile = File( + p.join(sandbox.path, 'agent-orchestrator.yaml'), + ); + await configFile.writeAsString(''' +defaults: + issueAssistant: + enabled: true + pollInterval: 5m + mentionTriggers: ["@helper"] + responders: + - id: primary + command: ${responderScript.path} + timeout: 1m +projects: + sample: + issueTracker: + provider: openproject + scm: + provider: github + repo: Demo project + path: ${repoDir.path} + defaultBranch: main +'''); + await File(p.join(sandbox.path, '.env')).writeAsString(''' +OP_CLI_HOST=${server.baseUrl} +OP_CLI_TOKEN=test-token +'''); + final app = await IssueAssistantApp.open( + config: await AppConfig.load(configFile.path), + databasePath: p.join(sandbox.path, 'state.sqlite3'), + ); + + // When the app processes one polling cycle. + await app.runOnce(); + final thread = await app.database.findIssueThread('sample', 77); + await app.close(); + + // Then it reads work package activities and posts exactly one reply through the OpenProject API. + expect( + server.requestPaths, + contains( + '/api/v3/projects?pageSize=-1&filters=%5B%7B%22typeahead%22%3A%7B%22operator%22%3A%22%2A%2A%22%2C%22values%22%3A%5B%22Demo+project%22%5D%7D%7D%5D', + ), + ); + expect( + server.requestPaths, + contains( + '/api/v3/projects/42/work_packages?pageSize=-1&filters=%5B%7B%22status%22%3A%7B%22operator%22%3A%22o%22%2C%22values%22%3A%5B%5D%7D%7D%5D', + ), + ); + expect( + server.requestPaths, + contains('/api/v3/work_packages/77/activities'), + ); + expect(server.postedBodies, hasLength(1)); + final postedComment = + (jsonDecode(server.postedBodies.single) + as Map)['comment']['raw'] + as String; + expect(postedComment, contains('via `op`')); + expect(postedComment, contains('@OpenProject Bot')); + expect( + postedComment, + contains('Plan:\n- isolate tracker-specific API adapters'), + ); + expect(thread, isNotNull); + expect( + thread!.lastCommentUrl, + '${server.baseUrl}/api/v3/activities/701', + ); + }, + ); }); } +class _FakeOpenProjectServer { + _FakeOpenProjectServer._(this._server); + + final HttpServer _server; + final List requestPaths = []; + final List postedBodies = []; + + String get baseUrl => 'http://${_server.address.host}:${_server.port}'; + + static Future<_FakeOpenProjectServer> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fixture = _FakeOpenProjectServer._(server); + unawaited( + server.forEach((request) async { + fixture.requestPaths.add(request.uri.toString()); + final authHeader = request.headers.value( + HttpHeaders.authorizationHeader, + ); + if (authHeader != + 'Basic ${base64Encode(utf8.encode('apikey:test-token'))}') { + request.response.statusCode = HttpStatus.unauthorized; + await request.response.close(); + return; + } + + final response = request.response + ..headers.contentType = ContentType.json; + if (request.method == 'GET' && request.uri.path == '/api/v3/projects') { + response.write( + jsonEncode({ + '_embedded': { + 'elements': [ + {'id': 42, 'name': 'Demo project'}, + ], + }, + }), + ); + await response.close(); + return; + } + + if (request.method == 'GET' && + request.uri.path == '/api/v3/projects/42/work_packages') { + response.write( + jsonEncode({ + '_embedded': { + 'elements': [ + { + 'id': 77, + 'subject': 'Need OpenProject support', + 'updatedAt': '2026-04-04T12:00:00Z', + 'description': {'raw': 'Please add OpenProject support.'}, + '_links': { + 'self': { + 'href': 'https://openproject.example.test/wp/77', + }, + 'author': {'title': 'reporter'}, + }, + }, + ], + }, + }), + ); + await response.close(); + return; + } + + if (request.method == 'GET' && + request.uri.path == '/api/v3/work_packages/77/activities') { + response.write( + jsonEncode({ + '_embedded': { + 'elements': [ + { + 'id': 501, + 'comment': { + 'raw': '@helper can you plan this integration?', + }, + 'createdAt': '2026-04-04T12:00:00Z', + 'updatedAt': '2026-04-04T12:00:00Z', + '_links': { + 'self': {'href': '/api/v3/activities/501'}, + 'user': {'title': 'reporter'}, + }, + }, + ], + }, + }), + ); + await response.close(); + return; + } + + if (request.method == 'GET' && request.uri.path == '/api/v3/users/me') { + response.write(jsonEncode({'id': 1, 'name': 'OpenProject Bot'})); + await response.close(); + return; + } + + if (request.method == 'POST' && + request.uri.path == '/api/v3/work_packages/77/activities') { + final body = await utf8.decoder.bind(request).join(); + fixture.postedBodies.add(body); + response.statusCode = HttpStatus.created; + response.write( + jsonEncode({ + 'id': 701, + 'comment': {'raw': jsonDecode(body)['comment']['raw']}, + '_links': { + 'self': {'href': '/api/v3/activities/701'}, + }, + }), + ); + await response.close(); + return; + } + + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + }), + ); + return fixture; + } + + Future close() => _server.close(force: true); +} + void main() { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; registerIssueAssistantAppRunOnceMultiProjectTests(); diff --git a/test/issue_tracker_client_test.dart b/test/issue_tracker_client_test.dart new file mode 100644 index 0000000..050f4c6 --- /dev/null +++ b/test/issue_tracker_client_test.dart @@ -0,0 +1,52 @@ +import 'package:code_work_spawner/code_work_spawner.dart'; +import 'package:github/github.dart'; +import 'package:test/test.dart'; + +void main() { + /// ```gherkin + /// Feature: Issue tracker payload mapping + /// + /// As a maintainer extending tracker providers + /// I want issue summaries to serialize and deserialize consistently + /// So that stored tracker state can be reused safely across provider-specific API calls + /// ``` + group('GitHubIssueSummary.fromJson', () { + /// ```gherkin + /// Scenario: Preserve repository slugs for repository-backed trackers + /// Given a GitHub issue summary created from a repository-backed tracker payload + /// When the summary is serialized and read back through fromJson + /// Then the repository slug remains available on the deserialized summary + /// And requiredRepoSlug still returns the original owner and repository + /// ``` + test('Preserve repository slugs for repository-backed trackers', () { + // Given a GitHub issue summary created from a repository-backed tracker payload. + final original = GitHubIssueSummary.fromRepositoryJson( + RepositorySlug('owner', 'sample'), + { + 'number': 12, + 'title': 'Need architecture help', + 'body': 'Please review the API layering.', + 'state': 'open', + 'html_url': 'https://example.test/issues/12', + 'updated_at': '2026-04-04T12:00:00Z', + 'user': {'login': 'reporter'}, + 'labels': >[ + {'name': 'help'}, + ], + }, + ); + + // When the summary is serialized and read back through fromJson. + final restored = GitHubIssueSummary.fromJson( + original.toJson().cast(), + ); + + // Then the repository slug remains available on the deserialized summary. + expect(restored.repoSlug, isNotNull); + + // And requiredRepoSlug still returns the original owner and repository. + expect(restored.requiredRepoSlug.owner, 'owner'); + expect(restored.requiredRepoSlug.name, 'sample'); + }); + }); +}