forked from bkinnightskytw/code_work_spawner
Merge pull request #34 from existedinnettw/feat/32
feat: support OpenProject issue tracker
This commit is contained in:
commit
df54c8ec5f
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
Repository issue thread assistant with an Agent Orchestrator style YAML config.
|
Repository issue thread assistant with an Agent Orchestrator style YAML config.
|
||||||
|
|
||||||
It consumes issue tracker events for configured GitHub or Gitea repositories,
|
It consumes issue tracker events for configured GitHub, Gitea, or OpenProject
|
||||||
reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
projects, reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
||||||
`copilot-cli` to decide when to reply. It supports
|
`copilot-cli` to decide when to reply. It supports
|
||||||
multi-repo config, responder fallback chains, and optional Discord webhook and
|
multi-repo config, responder fallback chains, and optional Discord webhook and
|
||||||
desktop notifications. Repository access is configured separately through each
|
desktop notifications. Repository access is configured separately through each
|
||||||
|
|
@ -12,7 +12,7 @@ independently.
|
||||||
|
|
||||||
## Highlights
|
## 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
|
- Trigger replies from mentions or meaningful thread updates
|
||||||
- Run planning and bug-verification flows in isolated worktrees
|
- Run planning and bug-verification flows in isolated worktrees
|
||||||
- Send optional Discord or desktop notifications for assistant events
|
- Send optional Discord or desktop notifications for assistant events
|
||||||
|
|
@ -20,7 +20,7 @@ independently.
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Dart SDK
|
- 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
|
- Local checkouts for configured repositories
|
||||||
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
- 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
|
Each project also has a separate SCM config. `issueTracker.provider` selects
|
||||||
the issue tracker integration, while `scm.provider` selects how temporary repo
|
the issue tracker integration, while `scm.provider` selects how temporary repo
|
||||||
workspaces are prepared for responders. Legacy top-level project `provider` is
|
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
|
still accepted during load for backward compatibility. When `scm` is omitted
|
||||||
matching provider for existing GitHub and Gitea configs.
|
it defaults to a matching provider for existing GitHub and Gitea configs.
|
||||||
|
|
||||||
GitHub projects default to `gh/polling`, and Gitea projects default to
|
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
|
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
|
||||||
`tea/gosmee`.
|
`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
|
`gh/gosmee` creates a temporary GitHub webhook that targets a generated gosmee
|
||||||
URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and
|
URL. `gh/webhook-forward` starts `gh webhook forward` for `issues` and
|
||||||
`issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that
|
`issue_comment` events. `tea/gosmee` creates a temporary Gitea webhook that
|
||||||
|
|
@ -62,7 +67,8 @@ Currently supported fields are:
|
||||||
- `username`
|
- `username`
|
||||||
- `avatarUrl`
|
- `avatarUrl`
|
||||||
|
|
||||||
Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub or
|
Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub,
|
||||||
[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea. This repo uses
|
[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea, or
|
||||||
`dataDir`, `worktreeDir`, and `projects`, and uses `worktreeDir` for
|
[`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject.
|
||||||
bug-verification worktrees.
|
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 IssueTrackerProvider provider;
|
||||||
final ScmConfig scm;
|
final ScmConfig scm;
|
||||||
final String repo;
|
final String repo;
|
||||||
final RepositorySlug repoSlug;
|
final RepositorySlug? repoSlug;
|
||||||
final String path;
|
final String path;
|
||||||
final String defaultBranch;
|
final String defaultBranch;
|
||||||
final String sessionPrefix;
|
final String sessionPrefix;
|
||||||
|
|
@ -206,9 +206,16 @@ class ProjectConfig {
|
||||||
document.issueTracker?.provider ??
|
document.issueTracker?.provider ??
|
||||||
document.provider ??
|
document.provider ??
|
||||||
IssueTrackerProvider.github;
|
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(
|
final scm = ScmConfig.fromDocument(
|
||||||
document.scm,
|
document.scm,
|
||||||
fallbackProvider: _defaultScmProviderForTracker(provider),
|
fallbackProvider: fallbackScmProvider,
|
||||||
);
|
);
|
||||||
var issueAssistant = defaultAssistant.merge(document.issueAssistant);
|
var issueAssistant = defaultAssistant.merge(document.issueAssistant);
|
||||||
if (document.issueAssistant?.eventSource?.type == null &&
|
if (document.issueAssistant?.eventSource?.type == null &&
|
||||||
|
|
@ -217,9 +224,14 @@ class ProjectConfig {
|
||||||
eventSource:
|
eventSource:
|
||||||
(issueAssistant.eventSource as PollingIssueEventSourceConfig)
|
(issueAssistant.eventSource as PollingIssueEventSourceConfig)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
type: provider == IssueTrackerProvider.github
|
type: switch (provider) {
|
||||||
? IssueAssistantEventSourceKind.ghPolling
|
IssueTrackerProvider.github =>
|
||||||
: IssueAssistantEventSourceKind.teaPolling,
|
IssueAssistantEventSourceKind.ghPolling,
|
||||||
|
IssueTrackerProvider.gitea =>
|
||||||
|
IssueAssistantEventSourceKind.teaPolling,
|
||||||
|
IssueTrackerProvider.openproject =>
|
||||||
|
IssueAssistantEventSourceKind.opPolling,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +260,20 @@ class ProjectConfig {
|
||||||
'requires issueTracker.provider: gitea.',
|
'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(
|
return ProjectConfig(
|
||||||
|
|
@ -255,7 +281,11 @@ class ProjectConfig {
|
||||||
provider: provider,
|
provider: provider,
|
||||||
scm: scm,
|
scm: scm,
|
||||||
repo: repo,
|
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),
|
path: _expandHome(path),
|
||||||
defaultBranch: document.defaultBranch ?? 'main',
|
defaultBranch: document.defaultBranch ?? 'main',
|
||||||
sessionPrefix: document.sessionPrefix ?? _deriveSessionPrefix(key),
|
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(
|
static RepositorySlug _parseRepositorySlug(
|
||||||
String value, {
|
String value, {
|
||||||
required String field,
|
required String field,
|
||||||
|
|
@ -284,9 +324,13 @@ class ScmConfig {
|
||||||
|
|
||||||
factory ScmConfig.fromDocument(
|
factory ScmConfig.fromDocument(
|
||||||
ScmConfigDocument? document, {
|
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) {
|
return switch (value) {
|
||||||
IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
|
IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
|
||||||
IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
|
IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
|
||||||
|
IssueAssistantEventSourceKind.opPolling => 'op/polling',
|
||||||
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee',
|
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee',
|
||||||
IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
|
IssueAssistantEventSourceKind.ghWebhookForward => 'gh/webhook-forward',
|
||||||
IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee',
|
IssueAssistantEventSourceKind.teaGosmee => 'tea/gosmee',
|
||||||
|
|
@ -598,6 +643,7 @@ bool _isPollingEventSource(IssueAssistantEventSourceKind value) {
|
||||||
return switch (value) {
|
return switch (value) {
|
||||||
IssueAssistantEventSourceKind.ghPolling => true,
|
IssueAssistantEventSourceKind.ghPolling => true,
|
||||||
IssueAssistantEventSourceKind.teaPolling => true,
|
IssueAssistantEventSourceKind.teaPolling => true,
|
||||||
|
IssueAssistantEventSourceKind.opPolling => true,
|
||||||
IssueAssistantEventSourceKind.ghGosmee => false,
|
IssueAssistantEventSourceKind.ghGosmee => false,
|
||||||
IssueAssistantEventSourceKind.ghWebhookForward => false,
|
IssueAssistantEventSourceKind.ghWebhookForward => false,
|
||||||
IssueAssistantEventSourceKind.teaGosmee => 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) {
|
return switch (provider) {
|
||||||
IssueTrackerProvider.github => ScmProvider.github,
|
IssueTrackerProvider.github => ScmProvider.github,
|
||||||
IssueTrackerProvider.gitea => ScmProvider.gitea,
|
IssueTrackerProvider.gitea => ScmProvider.gitea,
|
||||||
|
IssueTrackerProvider.openproject => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
|
void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
|
||||||
void normalizeProjectTracker(Map<String, dynamic> project) {
|
void normalizeProjectTracker(Map<String, dynamic> project) {
|
||||||
|
final repo = project['repo'];
|
||||||
|
if (repo != null && repo is! String) {
|
||||||
|
project['repo'] = repo.toString();
|
||||||
|
}
|
||||||
|
|
||||||
final rawIssueTracker = project['issueTracker'];
|
final rawIssueTracker = project['issueTracker'];
|
||||||
Map<String, dynamic>? normalizedIssueTracker;
|
Map<String, dynamic>? normalizedIssueTracker;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,8 @@ enum IssueAssistantEventSourceKind {
|
||||||
ghPolling,
|
ghPolling,
|
||||||
@JsonValue('tea/polling')
|
@JsonValue('tea/polling')
|
||||||
teaPolling,
|
teaPolling,
|
||||||
|
@JsonValue('op/polling')
|
||||||
|
opPolling,
|
||||||
@JsonValue('gh/gosmee')
|
@JsonValue('gh/gosmee')
|
||||||
ghGosmee,
|
ghGosmee,
|
||||||
@JsonValue('gh/webhook-forward')
|
@JsonValue('gh/webhook-forward')
|
||||||
|
|
@ -268,7 +270,7 @@ enum IssueAssistantReconciliationKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum IssueTrackerProvider { github, gitea }
|
enum IssueTrackerProvider { github, gitea, openproject }
|
||||||
|
|
||||||
@JsonEnum(fieldRename: FieldRename.snake)
|
@JsonEnum(fieldRename: FieldRename.snake)
|
||||||
enum ScmProvider { github, gitea, gitlab }
|
enum ScmProvider { github, gitea, gitlab }
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class IssueAssistantApp {
|
||||||
sources: {
|
sources: {
|
||||||
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
|
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
|
||||||
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
|
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
|
||||||
|
IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource,
|
||||||
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
|
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
|
||||||
issueTrackerClient: issueTrackerClient,
|
issueTrackerClient: issueTrackerClient,
|
||||||
gosmeeCommand: gosmeeCommand,
|
gosmeeCommand: gosmeeCommand,
|
||||||
|
|
@ -177,7 +178,7 @@ class IssueAssistantApp {
|
||||||
final notifierRegistry = notifierRegistryFactory(project);
|
final notifierRegistry = notifierRegistryFactory(project);
|
||||||
final thread = await issueTrackerClient.fetchThread(
|
final thread = await issueTrackerClient.fetchThread(
|
||||||
provider: project.provider,
|
provider: project.provider,
|
||||||
repo: project.repoSlug,
|
trackerProject: project.repo,
|
||||||
issue: issue,
|
issue: issue,
|
||||||
);
|
);
|
||||||
final fingerprint = _fingerprintThread(thread);
|
final fingerprint = _fingerprintThread(thread);
|
||||||
|
|
@ -412,7 +413,7 @@ class IssueAssistantApp {
|
||||||
);
|
);
|
||||||
final posted = await issueTrackerClient.createIssueComment(
|
final posted = await issueTrackerClient.createIssueComment(
|
||||||
provider: project.provider,
|
provider: project.provider,
|
||||||
repo: project.repoSlug,
|
trackerProject: project.repo,
|
||||||
issueNumber: issue.number,
|
issueNumber: issue.number,
|
||||||
body: commentBody,
|
body: commentBody,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
|
||||||
|
|
||||||
import '../app_logger.dart';
|
import '../app_logger.dart';
|
||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../database.dart';
|
import '../database.dart';
|
||||||
|
|
@ -113,6 +111,8 @@ class RoutedIssueEventSource implements IssueEventSource {
|
||||||
yield IssueAssistantEventSourceKind.ghPolling;
|
yield IssueAssistantEventSourceKind.ghPolling;
|
||||||
case IssueAssistantEventSourceKind.teaPolling:
|
case IssueAssistantEventSourceKind.teaPolling:
|
||||||
yield IssueAssistantEventSourceKind.teaPolling;
|
yield IssueAssistantEventSourceKind.teaPolling;
|
||||||
|
case IssueAssistantEventSourceKind.opPolling:
|
||||||
|
yield IssueAssistantEventSourceKind.opPolling;
|
||||||
case IssueAssistantEventSourceKind.ghGosmee:
|
case IssueAssistantEventSourceKind.ghGosmee:
|
||||||
if (_shouldRunContinuousReconciliation(project)) {
|
if (_shouldRunContinuousReconciliation(project)) {
|
||||||
yield IssueAssistantEventSourceKind.ghPolling;
|
yield IssueAssistantEventSourceKind.ghPolling;
|
||||||
|
|
@ -348,12 +348,12 @@ class PollingIssueEventSource implements IssueEventSource {
|
||||||
|
|
||||||
final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos(
|
final issues = await issueTrackerClient.fetchUpdatedIssuesForRepos(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
repos: providerProjects.map((project) => project.repoSlug),
|
trackerProjects: providerProjects.map((project) => project.repo),
|
||||||
since: providerSince,
|
since: providerSince,
|
||||||
);
|
);
|
||||||
for (final issue in issues) {
|
for (final issue in issues) {
|
||||||
final project =
|
final project =
|
||||||
projectsByRepo[_providerRepoKey(provider, issue.repoSlug)];
|
projectsByRepo[_providerRepoKey(provider, issue.trackerProject)];
|
||||||
if (project == null) {
|
if (project == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -461,14 +461,14 @@ class PollingIssueEventSource implements IssueEventSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
String _projectRepoKey(ProjectConfig project) {
|
String _projectRepoKey(ProjectConfig project) {
|
||||||
return _providerRepoKey(project.provider, project.repoSlug);
|
return _providerRepoKey(project.provider, project.repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _providerRepoKey(
|
String _providerRepoKey(
|
||||||
IssueTrackerProvider provider,
|
IssueTrackerProvider provider,
|
||||||
RepositorySlug repoSlug,
|
String trackerProject,
|
||||||
) {
|
) {
|
||||||
return '${provider.name}:${repoSlug.fullName}';
|
return '${provider.name}:$trackerProject';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _log(String message) {
|
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) project.repo: project,
|
||||||
};
|
};
|
||||||
for (final project in giteaProjects) {
|
for (final project in giteaProjects) {
|
||||||
_repoSlugsByProject[project.key] = project.repoSlug;
|
_repoSlugsByProject[project.key] = project.requiredRepoSlug;
|
||||||
}
|
}
|
||||||
server.listen(
|
server.listen(
|
||||||
(request) => unawaited(
|
(request) => unawaited(
|
||||||
|
|
@ -96,7 +96,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
'public_url=$publicUrl local_url=$localUrl',
|
'public_url=$publicUrl local_url=$localUrl',
|
||||||
);
|
);
|
||||||
final webhookId = await issueTrackerClient.createGiteaIssueWebhook(
|
final webhookId = await issueTrackerClient.createGiteaIssueWebhook(
|
||||||
repo: project.repoSlug,
|
repo: project.requiredRepoSlug,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
);
|
);
|
||||||
_webhookIdsByProject[project.key] = webhookId;
|
_webhookIdsByProject[project.key] = webhookId;
|
||||||
|
|
@ -249,7 +249,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
final issue = GitHubIssueSummary.fromRepositoryJson(
|
final issue = GitHubIssueSummary.fromRepositoryJson(
|
||||||
project.repoSlug,
|
project.requiredRepoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
_log(
|
_log(
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
for (final project in githubProjects) project.repo: project,
|
for (final project in githubProjects) project.repo: project,
|
||||||
};
|
};
|
||||||
for (final project in githubProjects) {
|
for (final project in githubProjects) {
|
||||||
_repoSlugsByProject[project.key] = project.repoSlug;
|
_repoSlugsByProject[project.key] = project.requiredRepoSlug;
|
||||||
}
|
}
|
||||||
server.listen(
|
server.listen(
|
||||||
(request) => unawaited(
|
(request) => unawaited(
|
||||||
|
|
@ -96,7 +96,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
'public_url=$publicUrl local_url=$localUrl',
|
'public_url=$publicUrl local_url=$localUrl',
|
||||||
);
|
);
|
||||||
final webhookId = await issueTrackerClient.createGitHubIssueWebhook(
|
final webhookId = await issueTrackerClient.createGitHubIssueWebhook(
|
||||||
repo: project.repoSlug,
|
repo: project.requiredRepoSlug,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
);
|
);
|
||||||
_webhookIdsByProject[project.key] = webhookId;
|
_webhookIdsByProject[project.key] = webhookId;
|
||||||
|
|
@ -249,7 +249,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
final issue = GitHubIssueSummary.fromRepositoryJson(
|
final issue = GitHubIssueSummary.fromRepositoryJson(
|
||||||
project.repoSlug,
|
project.requiredRepoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
_log(
|
_log(
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
|
||||||
issueNode is Map<String, dynamic> &&
|
issueNode is Map<String, dynamic> &&
|
||||||
(issueNode['pull_request'] == null)) {
|
(issueNode['pull_request'] == null)) {
|
||||||
final issue = GitHubIssueSummary.fromRepositoryJson(
|
final issue = GitHubIssueSummary.fromRepositoryJson(
|
||||||
project.repoSlug,
|
project.requiredRepoSlug,
|
||||||
issueNode,
|
issueNode,
|
||||||
);
|
);
|
||||||
_log(
|
_log(
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,45 @@ import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:github/github.dart';
|
import 'package:github/github.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'app_environment.dart';
|
||||||
import 'process_launcher.dart';
|
import 'process_launcher.dart';
|
||||||
|
|
||||||
import 'config_schema.dart';
|
import 'config_schema.dart';
|
||||||
|
|
||||||
class IssueTrackerClient {
|
class IssueTrackerClient {
|
||||||
IssueTrackerClient({this.ghCommand = 'gh', this.teaCommand = 'tea'});
|
IssueTrackerClient({
|
||||||
|
this.ghCommand = 'gh',
|
||||||
|
this.teaCommand = 'tea',
|
||||||
|
this.opCommand = 'op',
|
||||||
|
});
|
||||||
|
|
||||||
final String ghCommand;
|
final String ghCommand;
|
||||||
final String teaCommand;
|
final String teaCommand;
|
||||||
|
final String opCommand;
|
||||||
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
|
final Map<IssueTrackerProvider, Future<String?>> _authenticatedLoginFutures =
|
||||||
<IssueTrackerProvider, Future<String?>>{};
|
<IssueTrackerProvider, Future<String?>>{};
|
||||||
|
final Map<String, Future<_OpenProjectProjectRef>> _openProjectProjectFutures =
|
||||||
|
<String, Future<_OpenProjectProjectRef>>{};
|
||||||
static const int _pageSize = 100;
|
static const int _pageSize = 100;
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> fetchUpdatedIssuesForRepos({
|
Future<List<GitHubIssueSummary>> fetchUpdatedIssuesForRepos({
|
||||||
required IssueTrackerProvider provider,
|
required IssueTrackerProvider provider,
|
||||||
required Iterable<RepositorySlug> repos,
|
required Iterable<String> trackerProjects,
|
||||||
required DateTime? since,
|
required DateTime? since,
|
||||||
}) {
|
}) {
|
||||||
return switch (provider) {
|
return switch (provider) {
|
||||||
IssueTrackerProvider.github => _fetchUpdatedGitHubIssues(
|
IssueTrackerProvider.github => _fetchUpdatedGitHubIssues(
|
||||||
repos: repos,
|
trackerProjects: trackerProjects,
|
||||||
since: since,
|
since: since,
|
||||||
),
|
),
|
||||||
IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues(
|
IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues(
|
||||||
repos: repos,
|
trackerProjects: trackerProjects,
|
||||||
|
since: since,
|
||||||
|
),
|
||||||
|
IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues(
|
||||||
|
trackerProjects: trackerProjects,
|
||||||
since: since,
|
since: since,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -35,16 +48,19 @@ class IssueTrackerClient {
|
||||||
|
|
||||||
Future<IssueThread> fetchThread({
|
Future<IssueThread> fetchThread({
|
||||||
required IssueTrackerProvider provider,
|
required IssueTrackerProvider provider,
|
||||||
required RepositorySlug repo,
|
required String trackerProject,
|
||||||
required GitHubIssueSummary issue,
|
required GitHubIssueSummary issue,
|
||||||
}) async {
|
}) async {
|
||||||
final comments = switch (provider) {
|
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||||
|
? await _loadOpenProjectConfig()
|
||||||
|
: null;
|
||||||
|
final commentJson = switch (provider) {
|
||||||
IssueTrackerProvider.github => await _runGhJson([
|
IssueTrackerProvider.github => await _runGhJson([
|
||||||
'api',
|
'api',
|
||||||
'-X',
|
'-X',
|
||||||
'GET',
|
'GET',
|
||||||
_buildApiPath(
|
_buildApiPath(
|
||||||
repo,
|
issue.requiredRepoSlug,
|
||||||
['issues', '${issue.number}', 'comments'],
|
['issues', '${issue.number}', 'comments'],
|
||||||
{'per_page': '100'},
|
{'per_page': '100'},
|
||||||
),
|
),
|
||||||
|
|
@ -54,36 +70,60 @@ class IssueTrackerClient {
|
||||||
'-X',
|
'-X',
|
||||||
'GET',
|
'GET',
|
||||||
_buildApiPath(
|
_buildApiPath(
|
||||||
repo,
|
issue.requiredRepoSlug,
|
||||||
['issues', '${issue.number}', 'comments'],
|
['issues', '${issue.number}', 'comments'],
|
||||||
{'limit': '100'},
|
{'limit': '100'},
|
||||||
),
|
),
|
||||||
'-r',
|
'-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(
|
return IssueThread(
|
||||||
issue: issue,
|
issue: issue,
|
||||||
comments: (comments as List<dynamic>)
|
comments: comments
|
||||||
.cast<Map<String, dynamic>>()
|
.map(
|
||||||
.map(GitHubIssueComment.fromJson)
|
provider == IssueTrackerProvider.openproject
|
||||||
|
? (json) => GitHubIssueComment.fromOpenProjectJson(
|
||||||
|
json,
|
||||||
|
openProjectConfig!.host,
|
||||||
|
)
|
||||||
|
: GitHubIssueComment.fromJson,
|
||||||
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PostedComment> createIssueComment({
|
Future<PostedComment> createIssueComment({
|
||||||
required IssueTrackerProvider provider,
|
required IssueTrackerProvider provider,
|
||||||
required RepositorySlug repo,
|
required String trackerProject,
|
||||||
required int issueNumber,
|
required int issueNumber,
|
||||||
required String body,
|
required String body,
|
||||||
}) async {
|
}) async {
|
||||||
|
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||||
|
? await _loadOpenProjectConfig()
|
||||||
|
: null;
|
||||||
final json = switch (provider) {
|
final json = switch (provider) {
|
||||||
IssueTrackerProvider.github => await _runGhJson([
|
IssueTrackerProvider.github => await _runGhJson([
|
||||||
'api',
|
'api',
|
||||||
'-X',
|
'-X',
|
||||||
'POST',
|
'POST',
|
||||||
_buildApiPath(repo, ['issues', '$issueNumber', 'comments']),
|
_buildApiPath(_parseRepositorySlug(trackerProject), [
|
||||||
|
'issues',
|
||||||
|
'$issueNumber',
|
||||||
|
'comments',
|
||||||
|
]),
|
||||||
'-f',
|
'-f',
|
||||||
'body=$body',
|
'body=$body',
|
||||||
]),
|
]),
|
||||||
|
|
@ -91,19 +131,32 @@ class IssueTrackerClient {
|
||||||
'api',
|
'api',
|
||||||
'-X',
|
'-X',
|
||||||
'POST',
|
'POST',
|
||||||
_buildApiPath(repo, ['issues', '$issueNumber', 'comments']),
|
_buildApiPath(_parseRepositorySlug(trackerProject), [
|
||||||
|
'issues',
|
||||||
|
'$issueNumber',
|
||||||
|
'comments',
|
||||||
|
]),
|
||||||
'-f',
|
'-f',
|
||||||
'body=$body',
|
'body=$body',
|
||||||
'-r',
|
'-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>;
|
final map = json as Map<String, dynamic>;
|
||||||
return PostedComment(
|
return PostedComment(
|
||||||
id: _readInt(map, 'id'),
|
id: _readInt(map, 'id'),
|
||||||
url: _readUrl(map),
|
url: provider == IssueTrackerProvider.openproject
|
||||||
body: map['body'] as String? ?? body,
|
? _resolveUrl(_readUrl(map), openProjectConfig!.host)
|
||||||
|
: _readUrl(map),
|
||||||
|
body: map['body'] as String? ?? _readOpenProjectCommentBody(map) ?? body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +173,7 @@ class IssueTrackerClient {
|
||||||
return switch (provider) {
|
return switch (provider) {
|
||||||
IssueTrackerProvider.github => 'gh',
|
IssueTrackerProvider.github => 'gh',
|
||||||
IssueTrackerProvider.gitea => 'tea',
|
IssueTrackerProvider.gitea => 'tea',
|
||||||
|
IssueTrackerProvider.openproject => opCommand,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,10 +260,12 @@ class IssueTrackerClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
||||||
required Iterable<RepositorySlug> repos,
|
required Iterable<String> trackerProjects,
|
||||||
required DateTime? since,
|
required DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
final repoList = repos.toList(growable: false);
|
final repoList = trackerProjects
|
||||||
|
.map(_parseRepositorySlug)
|
||||||
|
.toList(growable: false);
|
||||||
if (repoList.isEmpty) {
|
if (repoList.isEmpty) {
|
||||||
return const <GitHubIssueSummary>[];
|
return const <GitHubIssueSummary>[];
|
||||||
}
|
}
|
||||||
|
|
@ -257,12 +313,12 @@ class IssueTrackerClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
|
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
|
||||||
required Iterable<RepositorySlug> repos,
|
required Iterable<String> trackerProjects,
|
||||||
required DateTime? since,
|
required DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
final issues = <GitHubIssueSummary>[];
|
final issues = <GitHubIssueSummary>[];
|
||||||
|
|
||||||
for (final repo in repos) {
|
for (final repo in trackerProjects.map(_parseRepositorySlug)) {
|
||||||
for (var page = 1; true; page += 1) {
|
for (var page = 1; true; page += 1) {
|
||||||
final query = <String, String>{
|
final query = <String, String>{
|
||||||
'state': 'open',
|
'state': 'open',
|
||||||
|
|
@ -295,21 +351,71 @@ class IssueTrackerClient {
|
||||||
return issues;
|
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({
|
Future<String?> _loadAuthenticatedLogin({
|
||||||
required IssueTrackerProvider provider,
|
required IssueTrackerProvider provider,
|
||||||
}) async {
|
}) async {
|
||||||
final json = switch (provider) {
|
final json = switch (provider) {
|
||||||
IssueTrackerProvider.github => await _runGhJson(['api', 'user']),
|
IssueTrackerProvider.github => await _runGhJson(['api', 'user']),
|
||||||
IssueTrackerProvider.gitea => await _runTeaJson(['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>) {
|
if (json is! Map<String, dynamic>) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final login = json['login']?.toString().trim();
|
final login =
|
||||||
if (login == null || login.isEmpty) {
|
json['login']?.toString().trim() ??
|
||||||
return null;
|
json['name']?.toString().trim() ??
|
||||||
}
|
json['firstName']?.toString().trim();
|
||||||
return login;
|
return login == null || login.isEmpty ? null : login;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Object?> _runGhJson(List<String> arguments) {
|
Future<Object?> _runGhJson(List<String> arguments) {
|
||||||
|
|
@ -320,6 +426,53 @@ class IssueTrackerClient {
|
||||||
return _runJson(teaCommand, arguments);
|
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 {
|
Future<Object?> _runJson(String command, List<String> arguments) async {
|
||||||
final result = await runExternalCommand(command, arguments);
|
final result = await runExternalCommand(command, arguments);
|
||||||
if (result.exitCode != 0) {
|
if (result.exitCode != 0) {
|
||||||
|
|
@ -348,10 +501,121 @@ class IssueTrackerClient {
|
||||||
queryParameters: query.isEmpty ? null : query,
|
queryParameters: query.isEmpty ? null : query,
|
||||||
).toString();
|
).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 {
|
class GitHubIssueSummary {
|
||||||
GitHubIssueSummary({
|
GitHubIssueSummary({
|
||||||
|
required this.trackerProject,
|
||||||
required this.repoSlug,
|
required this.repoSlug,
|
||||||
required this.number,
|
required this.number,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
|
@ -363,7 +627,8 @@ class GitHubIssueSummary {
|
||||||
required this.labels,
|
required this.labels,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RepositorySlug repoSlug;
|
final String trackerProject;
|
||||||
|
final RepositorySlug? repoSlug;
|
||||||
final int number;
|
final int number;
|
||||||
final String title;
|
final String title;
|
||||||
final String body;
|
final String body;
|
||||||
|
|
@ -379,6 +644,7 @@ class GitHubIssueSummary {
|
||||||
) {
|
) {
|
||||||
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
||||||
return GitHubIssueSummary(
|
return GitHubIssueSummary(
|
||||||
|
trackerProject: repo.fullName,
|
||||||
repoSlug: repo,
|
repoSlug: repo,
|
||||||
number: _readInt(json, 'number', fallbackKey: 'index'),
|
number: _readInt(json, 'number', fallbackKey: 'index'),
|
||||||
title: json['title'] as String? ?? '',
|
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) {
|
factory GitHubIssueSummary.fromSearchJson(Map<String, dynamic> json) {
|
||||||
final repositoryUrl = json['repository_url'] as String?;
|
final repositoryUrl = json['repository_url'] as String?;
|
||||||
if (repositoryUrl == null || repositoryUrl.isEmpty) {
|
if (repositoryUrl == null || repositoryUrl.isEmpty) {
|
||||||
|
|
@ -407,23 +700,45 @@ class GitHubIssueSummary {
|
||||||
}
|
}
|
||||||
|
|
||||||
static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) {
|
static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) {
|
||||||
final uri = Uri.parse(value);
|
return parseRepositorySlugFromRepositoryUrl(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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
|
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() => {
|
Map<String, Object?> toJson() => {
|
||||||
'repo': repoSlug.fullName,
|
'repo': trackerProject,
|
||||||
'number': number,
|
'number': number,
|
||||||
'title': title,
|
'title': title,
|
||||||
'body': body,
|
'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() => {
|
Map<String, Object?> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
'body': body,
|
'body': body,
|
||||||
|
|
@ -529,6 +860,7 @@ String _readUrl(Map<String, dynamic> json) {
|
||||||
return json['html_url'] as String? ??
|
return json['html_url'] as String? ??
|
||||||
json['url'] as String? ??
|
json['url'] as String? ??
|
||||||
json['htmlUrl'] as String? ??
|
json['htmlUrl'] as String? ??
|
||||||
|
_readLinkHref((json['_links'] as Map<String, dynamic>?)?['self']) ??
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,3 +881,81 @@ String? _readLogin(Object? userNode) {
|
||||||
}
|
}
|
||||||
return null;
|
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.
|
// Then the project keeps inherited defaults that were not overridden.
|
||||||
expect(config.projects['sample'], isNotNull);
|
expect(config.projects['sample'], isNotNull);
|
||||||
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
|
||||||
expect(config.projects['sample']!.repoSlug.owner, 'owner');
|
expect(config.projects['sample']!.repoSlug!.owner, 'owner');
|
||||||
expect(config.projects['sample']!.repoSlug.name, 'sample');
|
expect(config.projects['sample']!.repoSlug!.name, 'sample');
|
||||||
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [
|
||||||
'@helper',
|
'@helper',
|
||||||
]);
|
]);
|
||||||
|
|
@ -434,13 +434,132 @@ projects:
|
||||||
expect(config.projects['sample']!.scm.provider, ScmProvider.gitea);
|
expect(config.projects['sample']!.scm.provider, ScmProvider.gitea);
|
||||||
|
|
||||||
// And the repository slug still parses normally.
|
// And the repository slug still parses normally.
|
||||||
expect(config.projects['sample']!.repoSlug.fullName, 'owner/sample');
|
expect(config.projects['sample']!.repoSlug!.fullName, 'owner/sample');
|
||||||
expect(
|
expect(
|
||||||
config.projects['sample']!.issueAssistant.eventSource.type,
|
config.projects['sample']!.issueAssistant.eventSource.type,
|
||||||
IssueAssistantEventSourceKind.teaPolling,
|
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
|
/// ```gherkin
|
||||||
/// Scenario: Load gh/gosmee as the issue event source
|
/// Scenario: Load gh/gosmee as the issue event source
|
||||||
/// Given a GitHub project config that opts into gh/gosmee
|
/// Given a GitHub project config that opts into gh/gosmee
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||||
|
|
@ -335,9 +337,242 @@ projects:
|
||||||
contains('Plan:\n- keep provider-specific commands isolated'),
|
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() {
|
void main() {
|
||||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
registerIssueAssistantAppRunOnceMultiProjectTests();
|
registerIssueAssistantAppRunOnceMultiProjectTests();
|
||||||
|
|
|
||||||
|
|
@ -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