feat: add OpenProject issue tracker support for #32
This commit is contained in:
parent
11ad744067
commit
58b7d333cb
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
|
|
@ -335,7 +337,240 @@ 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() {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue