Merge pull request #34 from existedinnettw/feat/32

feat: support OpenProject issue tracker
This commit is contained in:
existedinnettw 2026-04-10 01:32:59 +08:00 committed by GitHub
commit df54c8ec5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 987 additions and 79 deletions

View File

@ -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`

View File

@ -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.

View File

@ -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

View File

@ -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<String, dynamic> 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<String, dynamic> root) {
void normalizeProjectTracker(Map<String, dynamic> project) {
final repo = project['repo'];
if (repo != null && repo is! String) {
project['repo'] = repo.toString();
}
final rawIssueTracker = project['issueTracker'];
Map<String, dynamic>? normalizedIssueTracker;

View File

@ -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 }

View File

@ -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,
);

View File

@ -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) {

View File

@ -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<String, dynamic> &&
(issueNode['pull_request'] == null)) {
final issue = GitHubIssueSummary.fromRepositoryJson(
project.repoSlug,
project.requiredRepoSlug,
issueNode,
);
_log(

View File

@ -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<String, dynamic> &&
(issueNode['pull_request'] == null)) {
final issue = GitHubIssueSummary.fromRepositoryJson(
project.repoSlug,
project.requiredRepoSlug,
issueNode,
);
_log(

View File

@ -204,7 +204,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
issueNode is Map<String, dynamic> &&
(issueNode['pull_request'] == null)) {
final issue = GitHubIssueSummary.fromRepositoryJson(
project.repoSlug,
project.requiredRepoSlug,
issueNode,
);
_log(

View File

@ -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<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
<IssueTrackerProvider, Future<String?>>{};
final Map<String, Future<_OpenProjectProjectRef>> _openProjectProjectFutures =
<String, Future<_OpenProjectProjectRef>>{};
static const int _pageSize = 100;
Future<List<GitHubIssueSummary>> fetchUpdatedIssuesForRepos({
required IssueTrackerProvider provider,
required Iterable<RepositorySlug> repos,
required Iterable<String> 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<IssueThread> 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<String, dynamic>)['_embedded']
as Map<String, dynamic>?)?['elements']
as List<dynamic>?) ??
const <dynamic>[])
.cast<Map<String, dynamic>>()
: (commentJson as List<dynamic>).cast<Map<String, dynamic>>();
return IssueThread(
issue: issue,
comments: (comments as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(GitHubIssueComment.fromJson)
comments: comments
.map(
provider == IssueTrackerProvider.openproject
? (json) => GitHubIssueComment.fromOpenProjectJson(
json,
openProjectConfig!.host,
)
: GitHubIssueComment.fromJson,
)
.toList(growable: false),
);
}
Future<PostedComment> 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: <String, Object?>{
'comment': <String, Object?>{'raw': body},
},
),
};
final map = json as Map<String, dynamic>;
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<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
required Iterable<RepositorySlug> repos,
required Iterable<String> trackerProjects,
required DateTime? since,
}) async {
final repoList = repos.toList(growable: false);
final repoList = trackerProjects
.map(_parseRepositorySlug)
.toList(growable: false);
if (repoList.isEmpty) {
return const <GitHubIssueSummary>[];
}
@ -257,12 +313,12 @@ class IssueTrackerClient {
}
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
required Iterable<RepositorySlug> repos,
required Iterable<String> trackerProjects,
required DateTime? since,
}) async {
final issues = <GitHubIssueSummary>[];
for (final repo in repos) {
for (final repo in trackerProjects.map(_parseRepositorySlug)) {
for (var page = 1; true; page += 1) {
final query = <String, String>{
'state': 'open',
@ -295,21 +351,71 @@ class IssueTrackerClient {
return issues;
}
Future<List<GitHubIssueSummary>> _fetchUpdatedOpenProjectIssues({
required Iterable<String> trackerProjects,
required DateTime? since,
}) async {
final issues = <GitHubIssueSummary>[];
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: <String, String>{
'pageSize': '-1',
'filters': jsonEncode(<Map<String, Object?>>[
<String, Object?>{
'status': <String, Object?>{
'operator': 'o',
'values': const <String>[],
},
},
]),
},
);
final embedded =
(json as Map<String, dynamic>)['_embedded'] as Map<String, dynamic>?;
final items =
(embedded?['elements'] as List<dynamic>? ?? const <dynamic>[])
.cast<Map<String, dynamic>>();
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<String?> _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<String, dynamic>) {
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<Object?> _runGhJson(List<String> arguments) {
@ -320,6 +426,53 @@ class IssueTrackerClient {
return _runJson(teaCommand, arguments);
}
Future<Object?> _runOpenProjectJson({
required String method,
required String path,
Map<String, String>? 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<Object?> _runJson(String command, List<String> 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<String, String>? 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<String, String> 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: <String, String>{
'pageSize': '-1',
'filters': jsonEncode(<Map<String, Object?>>[
<String, Object?>{
'typeahead': <String, Object?>{
'operator': '**',
'values': <String>[projectRef],
},
},
]),
},
);
final embedded =
(json as Map<String, dynamic>)['_embedded'] as Map<String, dynamic>?;
final projects =
(embedded?['elements'] as List<dynamic>? ?? const <dynamic>[])
.cast<Map<String, dynamic>>();
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<dynamic>? ?? 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<String, dynamic> json,
Uri host,
) {
final embedded = json['_embedded'] as Map<String, dynamic>?;
final description =
embedded?['description'] as Map<String, dynamic>? ??
json['description'] as Map<String, dynamic>?;
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<String, dynamic>?)?['author'],
) ??
'',
labels: const <String>[],
);
}
factory GitHubIssueSummary.fromSearchJson(Map<String, dynamic> 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<String, dynamic> 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<dynamic>? ?? const <dynamic>[])
.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<String, Object?> toJson() => {
'repo': repoSlug.fullName,
'repo': trackerProject,
'number': number,
'title': title,
'body': body,
@ -463,6 +778,22 @@ class GitHubIssueComment {
);
}
factory GitHubIssueComment.fromOpenProjectJson(
Map<String, dynamic> json,
Uri host,
) {
return GitHubIssueComment(
id: _readInt(json, 'id'),
body: _readOpenProjectCommentBody(json) ?? '',
userLogin:
_readLinkTitle((json['_links'] as Map<String, dynamic>?)?['user']) ??
'',
createdAt: DateTime.parse(_readString(json, 'createdAt')),
updatedAt: DateTime.parse(_readString(json, 'updatedAt')),
url: _resolveUrl(_readUrl(json), host),
);
}
Map<String, Object?> toJson() => {
'id': id,
'body': body,
@ -529,6 +860,7 @@ String _readUrl(Map<String, dynamic> json) {
return json['html_url'] as String? ??
json['url'] as String? ??
json['htmlUrl'] as String? ??
_readLinkHref((json['_links'] as Map<String, dynamic>?)?['self']) ??
'';
}
@ -549,3 +881,81 @@ String? _readLogin(Object? userNode) {
}
return null;
}
String? _readLinkTitle(Object? linkNode) {
if (linkNode is Map<String, dynamic>) {
final title = linkNode['title']?.toString().trim();
if (title != null && title.isNotEmpty) {
return title;
}
}
return null;
}
String? _readLinkHref(Object? linkNode) {
if (linkNode is Map<String, dynamic>) {
final href = linkNode['href']?.toString().trim();
if (href != null && href.isNotEmpty) {
return href;
}
}
return null;
}
String? _readOpenProjectCommentBody(Map<String, dynamic> json) {
final comment = json['comment'];
if (comment is Map<String, dynamic>) {
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;
}

View File

@ -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<FormatException>().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

View File

@ -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<String, dynamic>)['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<String> requestPaths = <String>[];
final List<String> postedBodies = <String>[];
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<void> close() => _server.close(force: true);
}
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
registerIssueAssistantAppRunOnceMultiProjectTests();

View File

@ -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'),
<String, dynamic>{
'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': <String, dynamic>{'login': 'reporter'},
'labels': <Map<String, String>>[
<String, String>{'name': 'help'},
],
},
);
// When the summary is serialized and read back through fromJson.
final restored = GitHubIssueSummary.fromJson(
original.toJson().cast<String, dynamic>(),
);
// 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');
});
});
}