Merge pull request #36 from existedinnettw/feat/35
feat: add GitLab tracker and SCM support
This commit is contained in:
commit
f1eea7abd8
11
README.md
11
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Repository issue thread assistant with an Agent Orchestrator style YAML config.
|
||||
|
||||
It consumes issue tracker events for configured GitHub, Gitea, or OpenProject
|
||||
It consumes issue tracker events for configured GitHub, GitLab, Gitea, or OpenProject
|
||||
projects, reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
|
||||
`copilot-cli` to decide when to reply. It supports
|
||||
multi-repo config, responder fallback chains, and optional Discord webhook and
|
||||
|
|
@ -12,7 +12,7 @@ independently.
|
|||
|
||||
## Highlights
|
||||
|
||||
- Consume GitHub, Gitea, and OpenProject issue events across multiple projects
|
||||
- Consume GitHub, GitLab, Gitea, and OpenProject issue events across multiple projects
|
||||
- Trigger replies from mentions or meaningful thread updates
|
||||
- Run planning and bug-verification flows in isolated worktrees
|
||||
- Send optional Discord or desktop notifications for assistant events
|
||||
|
|
@ -20,7 +20,7 @@ independently.
|
|||
## Requirements
|
||||
|
||||
- Dart SDK
|
||||
- `gh`, `tea`, and/or OpenProject API credentials authenticated for the target projects
|
||||
- `gh`, `glab`, `tea`, and/or OpenProject API credentials authenticated for the target projects
|
||||
- Local checkouts for configured repositories
|
||||
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
|
||||
|
||||
|
|
@ -28,7 +28,9 @@ independently.
|
|||
|
||||
Create a local `agent-orchestrator.yaml` and use
|
||||
[`examples/README.md`](examples/README.md) plus
|
||||
[`examples/simple-github.yaml`](examples/simple-github.yaml) as the reference.
|
||||
[`examples/simple-github.yaml`](examples/simple-github.yaml),
|
||||
[`examples/simple-gitlab.yaml`](examples/simple-gitlab.yaml), or
|
||||
[`examples/simple-gitea.yaml`](examples/simple-gitea.yaml) as the reference.
|
||||
|
||||
Environment placeholders such as `${CWS_DISCORD_WEBHOOK}` are resolved from the
|
||||
process environment and from a `.env` file placed next to the selected config
|
||||
|
|
@ -71,6 +73,7 @@ dart run bin/code_work_spawner.dart \
|
|||
--config /path/to/agent-orchestrator.yaml \
|
||||
--db /path/to/state.sqlite3 \
|
||||
--gh-command /usr/bin/gh \
|
||||
--glab-command /usr/bin/glab \
|
||||
--tea-command /usr/bin/tea
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
|||
help: 'Path to the gh executable.',
|
||||
defaultsTo: 'gh',
|
||||
)
|
||||
..addOption(
|
||||
'glab-command',
|
||||
help: 'Path to the glab executable.',
|
||||
defaultsTo: 'glab',
|
||||
)
|
||||
..addOption(
|
||||
'tea-command',
|
||||
help: 'Path to the tea executable.',
|
||||
|
|
@ -99,6 +104,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
|
|||
config: config,
|
||||
databasePath: topLevelResults['db'] as String,
|
||||
ghCommand: topLevelResults['gh-command'] as String,
|
||||
glabCommand: topLevelResults['glab-command'] as String,
|
||||
gosmeeCommand: topLevelResults['gosmee-command'] as String,
|
||||
teaCommand: topLevelResults['tea-command'] as String,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,13 +10,14 @@ Each project also has a separate SCM config. `issueTracker.provider` selects
|
|||
the issue tracker integration, while `scm.provider` selects how temporary repo
|
||||
workspaces are prepared for responders. Legacy top-level project `provider` is
|
||||
still accepted during load for backward compatibility. When `scm` is omitted
|
||||
it defaults to a matching provider for existing GitHub and Gitea configs.
|
||||
it defaults to a matching provider for existing GitHub, GitLab, and Gitea configs.
|
||||
|
||||
GitHub projects default to `gh/polling`, and Gitea projects default to
|
||||
`tea/polling`. OpenProject projects default to `op/polling` and currently only
|
||||
support polling. Because OpenProject issue tracking is not tied to a repository
|
||||
host, OpenProject projects must set `scm.provider` explicitly. GitHub projects
|
||||
can switch their long-running runtime to
|
||||
GitHub projects default to `gh/polling`, GitLab projects default to
|
||||
`glab/polling`, and Gitea projects default to `tea/polling`. OpenProject
|
||||
projects default to `op/polling` and currently only support polling. GitLab and
|
||||
OpenProject currently support polling only. Because OpenProject issue tracking
|
||||
is not tied to a repository host, OpenProject projects must set
|
||||
`scm.provider` explicitly. GitHub projects can switch their long-running runtime to
|
||||
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
|
||||
`tea/gosmee`.
|
||||
For OpenProject, `repo` may be either a numeric project id or an exact project
|
||||
|
|
@ -68,6 +69,7 @@ Currently supported fields are:
|
|||
- `avatarUrl`
|
||||
|
||||
Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub,
|
||||
[`examples/simple-gitlab.yaml`](simple-gitlab.yaml) for GitLab,
|
||||
[`examples/simple-gitea.yaml`](simple-gitea.yaml) for Gitea, or
|
||||
[`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject.
|
||||
This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# Minimal setup for a single GitLab repo with GitLab Issues.
|
||||
# Replies require at least one configured responder.
|
||||
|
||||
dataDir: ~/.agent-orchestrator
|
||||
worktreeDir: ~/.worktrees
|
||||
|
||||
defaults:
|
||||
issueAssistant:
|
||||
enabled: true
|
||||
# GitLab currently supports polling.
|
||||
eventSource:
|
||||
type: glab/polling
|
||||
pollInterval: 30s
|
||||
maxConcurrentIssues: 3
|
||||
mentionTriggers: ["@codex", "@helper"]
|
||||
responders:
|
||||
- id: codex
|
||||
command: codex
|
||||
args: ["exec", "--json"]
|
||||
timeout: 10m
|
||||
|
||||
projects:
|
||||
sample-gitlab:
|
||||
issueTracker:
|
||||
provider: gitlab
|
||||
scm:
|
||||
provider: gitlab
|
||||
repo: group/subgroup/repo
|
||||
path: ~/repo
|
||||
defaultBranch: main
|
||||
|
|
@ -206,6 +206,9 @@ class ProjectConfig {
|
|||
document.issueTracker?.provider ??
|
||||
document.provider ??
|
||||
IssueTrackerProvider.github;
|
||||
if (provider == IssueTrackerProvider.gitlab) {
|
||||
_validateGitLabProjectPath(repo, field: 'projects.$key.repo');
|
||||
}
|
||||
final fallbackScmProvider = _defaultScmProviderForTracker(provider);
|
||||
if (document.scm?.provider == null && fallbackScmProvider == null) {
|
||||
throw FormatException(
|
||||
|
|
@ -227,6 +230,8 @@ class ProjectConfig {
|
|||
type: switch (provider) {
|
||||
IssueTrackerProvider.github =>
|
||||
IssueAssistantEventSourceKind.ghPolling,
|
||||
IssueTrackerProvider.gitlab =>
|
||||
IssueAssistantEventSourceKind.glabPolling,
|
||||
IssueTrackerProvider.gitea =>
|
||||
IssueAssistantEventSourceKind.teaPolling,
|
||||
IssueTrackerProvider.openproject =>
|
||||
|
|
@ -246,6 +251,13 @@ class ProjectConfig {
|
|||
'requires issueTracker.provider: github.',
|
||||
);
|
||||
}
|
||||
case IssueAssistantEventSourceKind.glabPolling:
|
||||
if (provider != IssueTrackerProvider.gitlab) {
|
||||
throw FormatException(
|
||||
'projects.$key.issueAssistant.eventSource glab/polling '
|
||||
'requires issueTracker.provider: gitlab.',
|
||||
);
|
||||
}
|
||||
case IssueAssistantEventSourceKind.teaPolling:
|
||||
if (provider != IssueTrackerProvider.gitea) {
|
||||
throw FormatException(
|
||||
|
|
@ -284,6 +296,7 @@ class ProjectConfig {
|
|||
repoSlug: switch (provider) {
|
||||
IssueTrackerProvider.github || IssueTrackerProvider.gitea =>
|
||||
_parseRepositorySlug(repo, field: 'projects.$key.repo'),
|
||||
IssueTrackerProvider.gitlab => null,
|
||||
IssueTrackerProvider.openproject => null,
|
||||
},
|
||||
path: _expandHome(path),
|
||||
|
|
@ -315,6 +328,19 @@ class ProjectConfig {
|
|||
}
|
||||
return RepositorySlug(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
static void _validateGitLabProjectPath(
|
||||
String value, {
|
||||
required String field,
|
||||
}) {
|
||||
final parts = value.split('/');
|
||||
if (parts.length < 2 || parts.any((part) => part.trim().isEmpty)) {
|
||||
throw FormatException(
|
||||
'Invalid GitLab project path for $field: "$value". '
|
||||
'Expected group/project or group/subgroup/project.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ScmConfig {
|
||||
|
|
@ -631,6 +657,7 @@ class _IssueAssistantConfigResolver {
|
|||
String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
|
||||
return switch (value) {
|
||||
IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
|
||||
IssueAssistantEventSourceKind.glabPolling => 'glab/polling',
|
||||
IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
|
||||
IssueAssistantEventSourceKind.opPolling => 'op/polling',
|
||||
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee',
|
||||
|
|
@ -642,6 +669,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
|
|||
bool _isPollingEventSource(IssueAssistantEventSourceKind value) {
|
||||
return switch (value) {
|
||||
IssueAssistantEventSourceKind.ghPolling => true,
|
||||
IssueAssistantEventSourceKind.glabPolling => true,
|
||||
IssueAssistantEventSourceKind.teaPolling => true,
|
||||
IssueAssistantEventSourceKind.opPolling => true,
|
||||
IssueAssistantEventSourceKind.ghGosmee => false,
|
||||
|
|
@ -885,6 +913,7 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
|
|||
ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => ScmProvider.github,
|
||||
IssueTrackerProvider.gitlab => ScmProvider.gitlab,
|
||||
IssueTrackerProvider.gitea => ScmProvider.gitea,
|
||||
IssueTrackerProvider.openproject => null,
|
||||
};
|
||||
|
|
@ -979,12 +1008,13 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
|
|||
}
|
||||
normalizeIssueAssistant(
|
||||
issueAssistant,
|
||||
defaultType:
|
||||
((project['issueTracker'] as Map<String, dynamic>?)?['provider']
|
||||
as String?) ==
|
||||
'gitea'
|
||||
? IssueAssistantEventSourceKind.teaPolling
|
||||
: IssueAssistantEventSourceKind.ghPolling,
|
||||
defaultType: switch ((project['issueTracker']
|
||||
as Map<String, dynamic>?)?['provider']
|
||||
as String?) {
|
||||
'gitlab' => IssueAssistantEventSourceKind.glabPolling,
|
||||
'gitea' => IssueAssistantEventSourceKind.teaPolling,
|
||||
_ => IssueAssistantEventSourceKind.ghPolling,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,8 @@ enum AssistantCapability { planning, bugVerification }
|
|||
enum IssueAssistantEventSourceKind {
|
||||
@JsonValue('gh/polling')
|
||||
ghPolling,
|
||||
@JsonValue('glab/polling')
|
||||
glabPolling,
|
||||
@JsonValue('tea/polling')
|
||||
teaPolling,
|
||||
@JsonValue('op/polling')
|
||||
|
|
@ -270,7 +272,7 @@ enum IssueAssistantReconciliationKind {
|
|||
}
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum IssueTrackerProvider { github, gitea, openproject }
|
||||
enum IssueTrackerProvider { github, gitlab, gitea, openproject }
|
||||
|
||||
@JsonEnum(fieldRename: FieldRename.snake)
|
||||
enum ScmProvider { github, gitea, gitlab }
|
||||
|
|
|
|||
|
|
@ -42,12 +42,14 @@ class IssueAssistantApp {
|
|||
required AppConfig config,
|
||||
required String databasePath,
|
||||
String ghCommand = 'gh',
|
||||
String glabCommand = 'glab',
|
||||
String gosmeeCommand = 'gosmee',
|
||||
String teaCommand = 'tea',
|
||||
}) async {
|
||||
final database = AppDatabase.open(databasePath);
|
||||
final issueTrackerClient = IssueTrackerClient(
|
||||
ghCommand: ghCommand,
|
||||
glabCommand: glabCommand,
|
||||
teaCommand: teaCommand,
|
||||
);
|
||||
final pollingIssueEventSource = PollingIssueEventSource(
|
||||
|
|
@ -62,6 +64,7 @@ class IssueAssistantApp {
|
|||
issueEventSource: RoutedIssueEventSource(
|
||||
sources: {
|
||||
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
|
||||
IssueAssistantEventSourceKind.glabPolling: pollingIssueEventSource,
|
||||
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
|
||||
IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource,
|
||||
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ class RoutedIssueEventSource implements IssueEventSource {
|
|||
switch (project.issueAssistant.eventSource.type) {
|
||||
case IssueAssistantEventSourceKind.ghPolling:
|
||||
yield IssueAssistantEventSourceKind.ghPolling;
|
||||
case IssueAssistantEventSourceKind.glabPolling:
|
||||
yield IssueAssistantEventSourceKind.glabPolling;
|
||||
case IssueAssistantEventSourceKind.teaPolling:
|
||||
yield IssueAssistantEventSourceKind.teaPolling;
|
||||
case IssueAssistantEventSourceKind.opPolling:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,725 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:github/github.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app_environment.dart';
|
||||
import '../config_schema.dart';
|
||||
import '../process_launcher.dart';
|
||||
import 'models.dart';
|
||||
|
||||
class IssueTrackerClient {
|
||||
IssueTrackerClient({
|
||||
this.ghCommand = 'gh',
|
||||
this.glabCommand = 'glab',
|
||||
this.teaCommand = 'tea',
|
||||
this.opCommand = 'op',
|
||||
});
|
||||
|
||||
final String ghCommand;
|
||||
final String glabCommand;
|
||||
final String teaCommand;
|
||||
final String opCommand;
|
||||
final Map<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<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => _fetchUpdatedGitHubIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
IssueTrackerProvider.gitlab => _fetchUpdatedGitLabIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<IssueThread> fetchThread({
|
||||
required IssueTrackerProvider provider,
|
||||
required String trackerProject,
|
||||
required GitHubIssueSummary issue,
|
||||
}) async {
|
||||
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||
? await _loadOpenProjectConfig()
|
||||
: null;
|
||||
final commentJson = switch (provider) {
|
||||
IssueTrackerProvider.github => await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(
|
||||
issue.requiredRepoSlug,
|
||||
['issues', '${issue.number}', 'comments'],
|
||||
{'per_page': '100'},
|
||||
),
|
||||
]),
|
||||
IssueTrackerProvider.gitlab => await _runGlabJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildGitLabApiPath(
|
||||
trackerProject,
|
||||
['issues', '${issue.number}', 'notes'],
|
||||
{'per_page': '100'},
|
||||
),
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(
|
||||
issue.requiredRepoSlug,
|
||||
['issues', '${issue.number}', 'comments'],
|
||||
{'limit': '100'},
|
||||
),
|
||||
'-r',
|
||||
issue.requiredRepoSlug.fullName,
|
||||
]),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'GET',
|
||||
path: '/api/v3/work_packages/${issue.number}/activities',
|
||||
),
|
||||
};
|
||||
final comments = provider == IssueTrackerProvider.openproject
|
||||
? ((((commentJson as Map<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
|
||||
.map(switch (provider) {
|
||||
IssueTrackerProvider.openproject =>
|
||||
(json) => GitHubIssueComment.fromOpenProjectJson(
|
||||
json,
|
||||
openProjectConfig!.host,
|
||||
),
|
||||
IssueTrackerProvider.gitlab =>
|
||||
(json) => GitHubIssueComment.fromGitLabJson(json, issue.url),
|
||||
_ => GitHubIssueComment.fromJson,
|
||||
})
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PostedComment> createIssueComment({
|
||||
required IssueTrackerProvider provider,
|
||||
required String trackerProject,
|
||||
required int issueNumber,
|
||||
required String body,
|
||||
}) async {
|
||||
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||
? await _loadOpenProjectConfig()
|
||||
: null;
|
||||
final json = switch (provider) {
|
||||
IssueTrackerProvider.github => await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(parseRepositorySlug(trackerProject), [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'comments',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
]),
|
||||
IssueTrackerProvider.gitlab => await _runGlabJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildGitLabApiPath(trackerProject, [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'notes',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(parseRepositorySlug(trackerProject), [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'comments',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
'-r',
|
||||
trackerProject,
|
||||
]),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'POST',
|
||||
path: '/api/v3/work_packages/$issueNumber/activities',
|
||||
body: <String, Object?>{
|
||||
'comment': <String, Object?>{'raw': body},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
final map = json as Map<String, dynamic>;
|
||||
return PostedComment(
|
||||
id: readIntField(map, 'id'),
|
||||
url: switch (provider) {
|
||||
IssueTrackerProvider.openproject => resolveTrackerUrl(
|
||||
readUrlField(map),
|
||||
openProjectConfig!.host,
|
||||
),
|
||||
IssueTrackerProvider.gitlab => GitHubIssueComment.buildGitLabNoteUrl(
|
||||
issueUrl: '',
|
||||
json: map,
|
||||
),
|
||||
_ => readUrlField(map),
|
||||
},
|
||||
body: map['body'] as String? ?? readOpenProjectCommentBody(map) ?? body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getAuthenticatedLogin({
|
||||
required IssueTrackerProvider provider,
|
||||
}) {
|
||||
return _authenticatedLoginFutures.putIfAbsent(
|
||||
provider,
|
||||
() => _loadAuthenticatedLogin(provider: provider),
|
||||
);
|
||||
}
|
||||
|
||||
String trackerCliName(IssueTrackerProvider provider) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => 'gh',
|
||||
IssueTrackerProvider.gitlab => 'glab',
|
||||
IssueTrackerProvider.gitea => 'tea',
|
||||
IssueTrackerProvider.openproject => opCommand,
|
||||
};
|
||||
}
|
||||
|
||||
Future<int> createGiteaIssueWebhook({
|
||||
required RepositorySlug repo,
|
||||
required String url,
|
||||
}) async {
|
||||
final json = await _runTeaJson([
|
||||
'webhooks',
|
||||
'create',
|
||||
'--type',
|
||||
'gitea',
|
||||
'--events',
|
||||
'issues,issue_comment',
|
||||
'--active',
|
||||
'-o',
|
||||
'json',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
url,
|
||||
]);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw const FormatException(
|
||||
'tea webhooks create returned an unexpected response.',
|
||||
);
|
||||
}
|
||||
return readIntField(json, 'id');
|
||||
}
|
||||
|
||||
Future<int> createGitHubIssueWebhook({
|
||||
required RepositorySlug repo,
|
||||
required String url,
|
||||
}) async {
|
||||
final json = await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(repo, ['hooks']),
|
||||
'-f',
|
||||
'name=web',
|
||||
'-F',
|
||||
'active=true',
|
||||
'-f',
|
||||
'events[]=issues',
|
||||
'-f',
|
||||
'events[]=issue_comment',
|
||||
'-f',
|
||||
'config[url]=$url',
|
||||
'-f',
|
||||
'config[content_type]=json',
|
||||
]);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw const FormatException(
|
||||
'gh api webhook create returned an unexpected response.',
|
||||
);
|
||||
}
|
||||
return readIntField(json, 'id');
|
||||
}
|
||||
|
||||
Future<void> deleteGitHubWebhook({
|
||||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
}) async {
|
||||
await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'DELETE',
|
||||
_buildApiPath(repo, ['hooks', '$webhookId']),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> deleteGiteaWebhook({
|
||||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
}) async {
|
||||
await _runTeaJson([
|
||||
'webhooks',
|
||||
'delete',
|
||||
'--confirm',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
'$webhookId',
|
||||
]);
|
||||
}
|
||||
|
||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
||||
required Iterable<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final repoList = trackerProjects
|
||||
.map(parseRepositorySlug)
|
||||
.toList(growable: false);
|
||||
if (repoList.isEmpty) {
|
||||
return const <GitHubIssueSummary>[];
|
||||
}
|
||||
|
||||
final queryParts = <String>[
|
||||
for (final repo in repoList) 'repo:${repo.fullName}',
|
||||
'is:issue',
|
||||
'state:open',
|
||||
if (since != null) 'updated:>=${since.toUtc().toIso8601String()}',
|
||||
];
|
||||
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
for (var page = 1; true; page += 1) {
|
||||
final json = await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'search/issues',
|
||||
'-f',
|
||||
'q=${queryParts.join(' ')}',
|
||||
'-f',
|
||||
'sort=updated',
|
||||
'-f',
|
||||
'order=asc',
|
||||
'-f',
|
||||
'per_page=$_pageSize',
|
||||
'-f',
|
||||
'page=$page',
|
||||
]);
|
||||
final items =
|
||||
((json as Map<String, dynamic>)['items'] as List<dynamic>? ??
|
||||
const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
issues.addAll(
|
||||
items
|
||||
.where((item) => !item.containsKey('pull_request'))
|
||||
.map(GitHubIssueSummary.fromSearchJson),
|
||||
);
|
||||
if (items.length < _pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGitLabIssues({
|
||||
required Iterable<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
|
||||
for (final trackerProject in trackerProjects) {
|
||||
for (var page = 1; true; page += 1) {
|
||||
final query = <String, String>{
|
||||
'state': 'opened',
|
||||
'order_by': 'updated_at',
|
||||
'sort': 'asc',
|
||||
'per_page': '$_pageSize',
|
||||
'page': '$page',
|
||||
if (since != null) 'updated_after': since.toUtc().toIso8601String(),
|
||||
};
|
||||
final json = await _runGlabJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildGitLabApiPath(trackerProject, ['issues'], query),
|
||||
]);
|
||||
final items = (json as List<dynamic>? ?? const <dynamic>[])
|
||||
.cast<Map<String, dynamic>>();
|
||||
issues.addAll(
|
||||
items.map(
|
||||
(item) => GitHubIssueSummary.fromGitLabJson(trackerProject, item),
|
||||
),
|
||||
);
|
||||
if (items.length < _pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt));
|
||||
return issues;
|
||||
}
|
||||
|
||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
|
||||
required Iterable<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
|
||||
for (final repo in trackerProjects.map(parseRepositorySlug)) {
|
||||
for (var page = 1; true; page += 1) {
|
||||
final query = <String, String>{
|
||||
'state': 'open',
|
||||
'limit': '$_pageSize',
|
||||
'page': '$page',
|
||||
if (since != null) 'since': since.toUtc().toIso8601String(),
|
||||
};
|
||||
final json = await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(repo, ['issues'], query),
|
||||
'-r',
|
||||
repo.fullName,
|
||||
]);
|
||||
final items = (json as List<dynamic>? ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
issues.addAll(
|
||||
items
|
||||
.where((item) => item['pull_request'] == null)
|
||||
.map((item) => GitHubIssueSummary.fromRepositoryJson(repo, item)),
|
||||
);
|
||||
if (items.length < _pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt));
|
||||
return issues;
|
||||
}
|
||||
|
||||
Future<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.gitlab => await _runGlabJson(['api', 'user']),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson(['api', 'user']),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'GET',
|
||||
path: '/api/v3/users/me',
|
||||
),
|
||||
};
|
||||
if (json is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final login =
|
||||
json['login']?.toString().trim() ??
|
||||
json['username']?.toString().trim() ??
|
||||
json['name']?.toString().trim() ??
|
||||
json['firstName']?.toString().trim();
|
||||
return login == null || login.isEmpty ? null : login;
|
||||
}
|
||||
|
||||
Future<Object?> _runGhJson(List<String> arguments) {
|
||||
return _runJson(ghCommand, arguments);
|
||||
}
|
||||
|
||||
Future<Object?> _runGlabJson(List<String> arguments) {
|
||||
return _runJson(glabCommand, arguments);
|
||||
}
|
||||
|
||||
Future<Object?> _runTeaJson(List<String> 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 {
|
||||
final result = await runExternalCommand(command, arguments);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
command,
|
||||
arguments,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
final stdoutText = result.stdout.toString().trim();
|
||||
if (stdoutText.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return jsonDecode(stdoutText);
|
||||
}
|
||||
|
||||
String _buildApiPath(
|
||||
RepositorySlug repo,
|
||||
List<String> tailSegments, [
|
||||
Map<String, String> query = const <String, String>{},
|
||||
]) {
|
||||
return Uri(
|
||||
pathSegments: ['repos', repo.owner, repo.name, ...tailSegments],
|
||||
queryParameters: query.isEmpty ? null : query,
|
||||
).toString();
|
||||
}
|
||||
|
||||
String _buildGitLabApiPath(
|
||||
String projectPath,
|
||||
List<String> tailSegments, [
|
||||
Map<String, String> query = const <String, String>{},
|
||||
]) {
|
||||
final path =
|
||||
'projects/${Uri.encodeComponent(projectPath)}/${tailSegments.join('/')}';
|
||||
if (query.isEmpty) {
|
||||
return path;
|
||||
}
|
||||
return Uri(path: path, queryParameters: query).toString();
|
||||
}
|
||||
|
||||
Uri _buildOpenProjectUri(
|
||||
Uri host,
|
||||
String path, {
|
||||
Map<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: readIntField(exactMatches.single, 'id'),
|
||||
name: exactMatches.single['name']?.toString() ?? projectRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenProjectConfig {
|
||||
const _OpenProjectConfig({required this.host, required this.token});
|
||||
|
||||
final Uri host;
|
||||
final String token;
|
||||
}
|
||||
|
||||
class _OpenProjectProjectRef {
|
||||
const _OpenProjectProjectRef({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
import 'package:github/github.dart';
|
||||
|
||||
class GitHubIssueSummary {
|
||||
GitHubIssueSummary({
|
||||
required this.trackerProject,
|
||||
required this.repoSlug,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.state,
|
||||
required this.url,
|
||||
required this.updatedAt,
|
||||
required this.userLogin,
|
||||
required this.labels,
|
||||
});
|
||||
|
||||
final String trackerProject;
|
||||
final RepositorySlug? repoSlug;
|
||||
final int number;
|
||||
final String title;
|
||||
final String body;
|
||||
final String state;
|
||||
final String url;
|
||||
final DateTime updatedAt;
|
||||
final String userLogin;
|
||||
final List<String> labels;
|
||||
|
||||
factory GitHubIssueSummary.fromRepositoryJson(
|
||||
RepositorySlug repo,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final labelsNode = json['labels'] as List<dynamic>? ?? const [];
|
||||
return GitHubIssueSummary(
|
||||
trackerProject: repo.fullName,
|
||||
repoSlug: repo,
|
||||
number: readIntField(json, 'number', fallbackKey: 'index'),
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
state: json['state'] as String? ?? 'open',
|
||||
url: readUrlField(json),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updated_at')),
|
||||
userLogin:
|
||||
readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '',
|
||||
labels: labelsNode
|
||||
.map(
|
||||
(label) => readStringField(label as Map<String, dynamic>, 'name'),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
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: readIntField(json, 'id'),
|
||||
title: json['subject'] as String? ?? '',
|
||||
body: description?['raw'] as String? ?? '',
|
||||
state: 'open',
|
||||
url: resolveTrackerUrl(readUrlField(json), host),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updatedAt')),
|
||||
userLogin:
|
||||
readLinkTitleField(
|
||||
(json['_links'] as Map<String, dynamic>?)?['author'],
|
||||
) ??
|
||||
'',
|
||||
labels: const <String>[],
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueSummary.fromGitLabJson(
|
||||
String trackerProject,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final labelsNode = json['labels'] as List<dynamic>? ?? const <dynamic>[];
|
||||
return GitHubIssueSummary(
|
||||
trackerProject: trackerProject,
|
||||
repoSlug: null,
|
||||
number: readIntField(json, 'iid'),
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['description'] as String? ?? '',
|
||||
state: json['state'] as String? ?? 'opened',
|
||||
url: readUrlField(json),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updated_at')),
|
||||
userLogin: readLoginField(json['author']) ?? '',
|
||||
labels: labelsNode
|
||||
.map((label) => label.toString())
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueSummary.fromSearchJson(Map<String, dynamic> json) {
|
||||
final repositoryUrl = json['repository_url'] as String?;
|
||||
if (repositoryUrl == null || repositoryUrl.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Missing repository_url in GitHub search result item.',
|
||||
);
|
||||
}
|
||||
return GitHubIssueSummary.fromRepositoryJson(
|
||||
parseRepositorySlugFromRepositoryUrl(repositoryUrl),
|
||||
json,
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueSummary.fromJson(Map<String, dynamic> json) {
|
||||
final trackerProject = json['repo'] as String? ?? '';
|
||||
RepositorySlug? repoSlug;
|
||||
try {
|
||||
repoSlug = parseRepositorySlug(trackerProject);
|
||||
} on FormatException {
|
||||
repoSlug = null;
|
||||
}
|
||||
return GitHubIssueSummary(
|
||||
trackerProject: trackerProject,
|
||||
repoSlug: repoSlug,
|
||||
number: readIntField(json, 'number'),
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
state: json['state'] as String? ?? 'open',
|
||||
url: readStringField(json, 'url'),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updated_at')),
|
||||
userLogin: json['user_login'] as String? ?? '',
|
||||
labels: (json['labels'] as List<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': trackerProject,
|
||||
'number': number,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'state': state,
|
||||
'url': url,
|
||||
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||
'user_login': userLogin,
|
||||
'labels': labels,
|
||||
};
|
||||
}
|
||||
|
||||
class GitHubIssueComment {
|
||||
GitHubIssueComment({
|
||||
required this.id,
|
||||
required this.body,
|
||||
required this.userLogin,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String body;
|
||||
final String userLogin;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String url;
|
||||
|
||||
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
|
||||
return GitHubIssueComment(
|
||||
id: readIntField(json, 'id'),
|
||||
body: json['body'] as String? ?? '',
|
||||
userLogin:
|
||||
readLoginField(json['user']) ?? readLoginField(json['poster']) ?? '',
|
||||
createdAt: DateTime.parse(readStringField(json, 'created_at')),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updated_at')),
|
||||
url: readUrlField(json),
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueComment.fromOpenProjectJson(
|
||||
Map<String, dynamic> json,
|
||||
Uri host,
|
||||
) {
|
||||
return GitHubIssueComment(
|
||||
id: readIntField(json, 'id'),
|
||||
body: readOpenProjectCommentBody(json) ?? '',
|
||||
userLogin:
|
||||
readLinkTitleField(
|
||||
(json['_links'] as Map<String, dynamic>?)?['user'],
|
||||
) ??
|
||||
'',
|
||||
createdAt: DateTime.parse(readStringField(json, 'createdAt')),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updatedAt')),
|
||||
url: resolveTrackerUrl(readUrlField(json), host),
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueComment.fromGitLabJson(
|
||||
Map<String, dynamic> json,
|
||||
String issueUrl,
|
||||
) {
|
||||
return GitHubIssueComment(
|
||||
id: readIntField(json, 'id'),
|
||||
body: json['body'] as String? ?? '',
|
||||
userLogin: readLoginField(json['author']) ?? '',
|
||||
createdAt: DateTime.parse(readStringField(json, 'created_at')),
|
||||
updatedAt: DateTime.parse(readStringField(json, 'updated_at')),
|
||||
url: buildGitLabNoteUrl(issueUrl: issueUrl, json: json),
|
||||
);
|
||||
}
|
||||
|
||||
static String buildGitLabNoteUrl({
|
||||
required String issueUrl,
|
||||
required Map<String, dynamic> json,
|
||||
}) {
|
||||
final noteUrl = readOptionalStringField(json, 'web_url');
|
||||
if (noteUrl != null && noteUrl.isNotEmpty) {
|
||||
return noteUrl;
|
||||
}
|
||||
final resolvedIssueUrl =
|
||||
readOptionalStringField(json, 'noteable_url') ??
|
||||
readOptionalStringField(json, 'issue_web_url') ??
|
||||
issueUrl;
|
||||
if (resolvedIssueUrl.isEmpty) {
|
||||
return resolvedIssueUrl;
|
||||
}
|
||||
return '$resolvedIssueUrl#note_${readIntField(json, "id")}';
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'body': body,
|
||||
'user_login': userLogin,
|
||||
'created_at': createdAt.toUtc().toIso8601String(),
|
||||
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
|
||||
class IssueThread {
|
||||
IssueThread({required this.issue, required this.comments});
|
||||
|
||||
final GitHubIssueSummary issue;
|
||||
final List<GitHubIssueComment> comments;
|
||||
|
||||
bool get isOpen => issue.state == 'open' || issue.state == 'opened';
|
||||
|
||||
GitHubIssueComment? get latestComment =>
|
||||
comments.isEmpty ? null : comments.last;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'issue': issue.toJson(),
|
||||
'comments': comments.map((comment) => comment.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class PostedComment {
|
||||
PostedComment({required this.id, required this.url, required this.body});
|
||||
|
||||
final int id;
|
||||
final String url;
|
||||
final String body;
|
||||
}
|
||||
|
||||
int readIntField(Map<String, dynamic> json, String key, {String? fallbackKey}) {
|
||||
final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]);
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return int.parse(value);
|
||||
}
|
||||
throw FormatException(
|
||||
'Missing integer field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.',
|
||||
);
|
||||
}
|
||||
|
||||
String readStringField(
|
||||
Map<String, dynamic> json,
|
||||
String key, {
|
||||
String? fallbackKey,
|
||||
}) {
|
||||
final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]);
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException(
|
||||
'Missing string field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.',
|
||||
);
|
||||
}
|
||||
|
||||
String? readOptionalStringField(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String readUrlField(Map<String, dynamic> json) {
|
||||
return json['html_url'] as String? ??
|
||||
json['web_url'] as String? ??
|
||||
json['url'] as String? ??
|
||||
json['htmlUrl'] as String? ??
|
||||
readLinkHrefField((json['_links'] as Map<String, dynamic>?)?['self']) ??
|
||||
'';
|
||||
}
|
||||
|
||||
String? readLoginField(Object? userNode) {
|
||||
if (userNode is Map<String, dynamic>) {
|
||||
final login = userNode['login']?.toString().trim();
|
||||
if (login != null && login.isNotEmpty) {
|
||||
return login;
|
||||
}
|
||||
final userName = userNode['user_name']?.toString().trim();
|
||||
if (userName != null && userName.isNotEmpty) {
|
||||
return userName;
|
||||
}
|
||||
final username = userNode['username']?.toString().trim();
|
||||
if (username != null && username.isNotEmpty) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? readLinkTitleField(Object? linkNode) {
|
||||
if (linkNode is Map<String, dynamic>) {
|
||||
final title = linkNode['title']?.toString().trim();
|
||||
if (title != null && title.isNotEmpty) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? readLinkHrefField(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 resolveTrackerUrl(String value, Uri host) {
|
||||
if (value.isEmpty) {
|
||||
return value;
|
||||
}
|
||||
final uri = Uri.parse(value);
|
||||
if (uri.hasScheme) {
|
||||
return value;
|
||||
}
|
||||
return host.resolveUri(uri).toString();
|
||||
}
|
||||
|
||||
RepositorySlug parseRepositorySlug(String value) {
|
||||
final parts = value.split('/');
|
||||
if (parts.length != 2 || parts.any((part) => part.trim().isEmpty)) {
|
||||
throw FormatException(
|
||||
'Invalid repository slug "$value". Expected owner/repo.',
|
||||
);
|
||||
}
|
||||
return RepositorySlug(parts[0], parts[1]);
|
||||
}
|
||||
|
||||
RepositorySlug parseRepositorySlugFromRepositoryUrl(String value) {
|
||||
final uri = Uri.parse(value);
|
||||
final segments = uri.pathSegments;
|
||||
final reposIndex = segments.indexOf('repos');
|
||||
if (reposIndex == -1 || reposIndex + 2 >= segments.length) {
|
||||
throw FormatException(
|
||||
'Invalid repository_url in GitHub search result item: "$value"',
|
||||
);
|
||||
}
|
||||
return RepositorySlug(segments[reposIndex + 1], segments[reposIndex + 2]);
|
||||
}
|
||||
|
|
@ -1,961 +1,2 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:github/github.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'app_environment.dart';
|
||||
import 'process_launcher.dart';
|
||||
|
||||
import 'config_schema.dart';
|
||||
|
||||
class IssueTrackerClient {
|
||||
IssueTrackerClient({
|
||||
this.ghCommand = 'gh',
|
||||
this.teaCommand = 'tea',
|
||||
this.opCommand = 'op',
|
||||
});
|
||||
|
||||
final String ghCommand;
|
||||
final String teaCommand;
|
||||
final String opCommand;
|
||||
final Map<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<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => _fetchUpdatedGitHubIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
IssueTrackerProvider.gitea => _fetchUpdatedGiteaIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
IssueTrackerProvider.openproject => _fetchUpdatedOpenProjectIssues(
|
||||
trackerProjects: trackerProjects,
|
||||
since: since,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<IssueThread> fetchThread({
|
||||
required IssueTrackerProvider provider,
|
||||
required String trackerProject,
|
||||
required GitHubIssueSummary issue,
|
||||
}) async {
|
||||
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||
? await _loadOpenProjectConfig()
|
||||
: null;
|
||||
final commentJson = switch (provider) {
|
||||
IssueTrackerProvider.github => await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(
|
||||
issue.requiredRepoSlug,
|
||||
['issues', '${issue.number}', 'comments'],
|
||||
{'per_page': '100'},
|
||||
),
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(
|
||||
issue.requiredRepoSlug,
|
||||
['issues', '${issue.number}', 'comments'],
|
||||
{'limit': '100'},
|
||||
),
|
||||
'-r',
|
||||
issue.requiredRepoSlug.fullName,
|
||||
]),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'GET',
|
||||
path: '/api/v3/work_packages/${issue.number}/activities',
|
||||
),
|
||||
};
|
||||
final comments = provider == IssueTrackerProvider.openproject
|
||||
? ((((commentJson as Map<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
|
||||
.map(
|
||||
provider == IssueTrackerProvider.openproject
|
||||
? (json) => GitHubIssueComment.fromOpenProjectJson(
|
||||
json,
|
||||
openProjectConfig!.host,
|
||||
)
|
||||
: GitHubIssueComment.fromJson,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PostedComment> createIssueComment({
|
||||
required IssueTrackerProvider provider,
|
||||
required String trackerProject,
|
||||
required int issueNumber,
|
||||
required String body,
|
||||
}) async {
|
||||
final openProjectConfig = provider == IssueTrackerProvider.openproject
|
||||
? await _loadOpenProjectConfig()
|
||||
: null;
|
||||
final json = switch (provider) {
|
||||
IssueTrackerProvider.github => await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(_parseRepositorySlug(trackerProject), [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'comments',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
]),
|
||||
IssueTrackerProvider.gitea => await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(_parseRepositorySlug(trackerProject), [
|
||||
'issues',
|
||||
'$issueNumber',
|
||||
'comments',
|
||||
]),
|
||||
'-f',
|
||||
'body=$body',
|
||||
'-r',
|
||||
trackerProject,
|
||||
]),
|
||||
IssueTrackerProvider.openproject => await _runOpenProjectJson(
|
||||
method: 'POST',
|
||||
path: '/api/v3/work_packages/$issueNumber/activities',
|
||||
body: <String, Object?>{
|
||||
'comment': <String, Object?>{'raw': body},
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
final map = json as Map<String, dynamic>;
|
||||
return PostedComment(
|
||||
id: _readInt(map, 'id'),
|
||||
url: provider == IssueTrackerProvider.openproject
|
||||
? _resolveUrl(_readUrl(map), openProjectConfig!.host)
|
||||
: _readUrl(map),
|
||||
body: map['body'] as String? ?? _readOpenProjectCommentBody(map) ?? body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> getAuthenticatedLogin({
|
||||
required IssueTrackerProvider provider,
|
||||
}) {
|
||||
return _authenticatedLoginFutures.putIfAbsent(
|
||||
provider,
|
||||
() => _loadAuthenticatedLogin(provider: provider),
|
||||
);
|
||||
}
|
||||
|
||||
String trackerCliName(IssueTrackerProvider provider) {
|
||||
return switch (provider) {
|
||||
IssueTrackerProvider.github => 'gh',
|
||||
IssueTrackerProvider.gitea => 'tea',
|
||||
IssueTrackerProvider.openproject => opCommand,
|
||||
};
|
||||
}
|
||||
|
||||
Future<int> createGiteaIssueWebhook({
|
||||
required RepositorySlug repo,
|
||||
required String url,
|
||||
}) async {
|
||||
final json = await _runTeaJson([
|
||||
'webhooks',
|
||||
'create',
|
||||
'--type',
|
||||
'gitea',
|
||||
'--events',
|
||||
'issues,issue_comment',
|
||||
'--active',
|
||||
'-o',
|
||||
'json',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
url,
|
||||
]);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw const FormatException(
|
||||
'tea webhooks create returned an unexpected response.',
|
||||
);
|
||||
}
|
||||
return _readInt(json, 'id');
|
||||
}
|
||||
|
||||
Future<int> createGitHubIssueWebhook({
|
||||
required RepositorySlug repo,
|
||||
required String url,
|
||||
}) async {
|
||||
final json = await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
_buildApiPath(repo, ['hooks']),
|
||||
'-f',
|
||||
'name=web',
|
||||
'-F',
|
||||
'active=true',
|
||||
'-f',
|
||||
'events[]=issues',
|
||||
'-f',
|
||||
'events[]=issue_comment',
|
||||
'-f',
|
||||
'config[url]=$url',
|
||||
'-f',
|
||||
'config[content_type]=json',
|
||||
]);
|
||||
if (json is! Map<String, dynamic>) {
|
||||
throw const FormatException(
|
||||
'gh api webhook create returned an unexpected response.',
|
||||
);
|
||||
}
|
||||
return _readInt(json, 'id');
|
||||
}
|
||||
|
||||
Future<void> deleteGitHubWebhook({
|
||||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
}) async {
|
||||
await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'DELETE',
|
||||
_buildApiPath(repo, ['hooks', '$webhookId']),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> deleteGiteaWebhook({
|
||||
required RepositorySlug repo,
|
||||
required int webhookId,
|
||||
}) async {
|
||||
await _runTeaJson([
|
||||
'webhooks',
|
||||
'delete',
|
||||
'--confirm',
|
||||
'-r',
|
||||
repo.fullName,
|
||||
'$webhookId',
|
||||
]);
|
||||
}
|
||||
|
||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGitHubIssues({
|
||||
required Iterable<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final repoList = trackerProjects
|
||||
.map(_parseRepositorySlug)
|
||||
.toList(growable: false);
|
||||
if (repoList.isEmpty) {
|
||||
return const <GitHubIssueSummary>[];
|
||||
}
|
||||
|
||||
final queryParts = <String>[
|
||||
for (final repo in repoList) 'repo:${repo.fullName}',
|
||||
'is:issue',
|
||||
'state:open',
|
||||
if (since != null) 'updated:>=${since.toUtc().toIso8601String()}',
|
||||
];
|
||||
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
for (var page = 1; true; page += 1) {
|
||||
final json = await _runGhJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'search/issues',
|
||||
'-f',
|
||||
'q=${queryParts.join(' ')}',
|
||||
'-f',
|
||||
'sort=updated',
|
||||
'-f',
|
||||
'order=asc',
|
||||
'-f',
|
||||
'per_page=$_pageSize',
|
||||
'-f',
|
||||
'page=$page',
|
||||
]);
|
||||
final items =
|
||||
((json as Map<String, dynamic>)['items'] as List<dynamic>? ??
|
||||
const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
issues.addAll(
|
||||
items
|
||||
.where((item) => !item.containsKey('pull_request'))
|
||||
.map(GitHubIssueSummary.fromSearchJson),
|
||||
);
|
||||
if (items.length < _pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
Future<List<GitHubIssueSummary>> _fetchUpdatedGiteaIssues({
|
||||
required Iterable<String> trackerProjects,
|
||||
required DateTime? since,
|
||||
}) async {
|
||||
final issues = <GitHubIssueSummary>[];
|
||||
|
||||
for (final repo in trackerProjects.map(_parseRepositorySlug)) {
|
||||
for (var page = 1; true; page += 1) {
|
||||
final query = <String, String>{
|
||||
'state': 'open',
|
||||
'limit': '$_pageSize',
|
||||
'page': '$page',
|
||||
if (since != null) 'since': since.toUtc().toIso8601String(),
|
||||
};
|
||||
final json = await _runTeaJson([
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
_buildApiPath(repo, ['issues'], query),
|
||||
'-r',
|
||||
repo.fullName,
|
||||
]);
|
||||
final items = (json as List<dynamic>? ?? const [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
issues.addAll(
|
||||
items
|
||||
.where((item) => item['pull_request'] == null)
|
||||
.map((item) => GitHubIssueSummary.fromRepositoryJson(repo, item)),
|
||||
);
|
||||
if (items.length < _pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.sort((left, right) => left.updatedAt.compareTo(right.updatedAt));
|
||||
return issues;
|
||||
}
|
||||
|
||||
Future<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() ??
|
||||
json['name']?.toString().trim() ??
|
||||
json['firstName']?.toString().trim();
|
||||
return login == null || login.isEmpty ? null : login;
|
||||
}
|
||||
|
||||
Future<Object?> _runGhJson(List<String> arguments) {
|
||||
return _runJson(ghCommand, arguments);
|
||||
}
|
||||
|
||||
Future<Object?> _runTeaJson(List<String> 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 {
|
||||
final result = await runExternalCommand(command, arguments);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
command,
|
||||
arguments,
|
||||
'${result.stdout}\n${result.stderr}',
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
|
||||
final stdoutText = result.stdout.toString().trim();
|
||||
if (stdoutText.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return jsonDecode(stdoutText);
|
||||
}
|
||||
|
||||
String _buildApiPath(
|
||||
RepositorySlug repo,
|
||||
List<String> tailSegments, [
|
||||
Map<String, String> query = const <String, String>{},
|
||||
]) {
|
||||
return Uri(
|
||||
pathSegments: ['repos', repo.owner, repo.name, ...tailSegments],
|
||||
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,
|
||||
required this.body,
|
||||
required this.state,
|
||||
required this.url,
|
||||
required this.updatedAt,
|
||||
required this.userLogin,
|
||||
required this.labels,
|
||||
});
|
||||
|
||||
final String trackerProject;
|
||||
final RepositorySlug? repoSlug;
|
||||
final int number;
|
||||
final String title;
|
||||
final String body;
|
||||
final String state;
|
||||
final String url;
|
||||
final DateTime updatedAt;
|
||||
final String userLogin;
|
||||
final List<String> labels;
|
||||
|
||||
factory GitHubIssueSummary.fromRepositoryJson(
|
||||
RepositorySlug repo,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
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? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
state: json['state'] as String? ?? 'open',
|
||||
url: _readUrl(json),
|
||||
updatedAt: DateTime.parse(_readString(json, 'updated_at')),
|
||||
userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '',
|
||||
labels: labelsNode
|
||||
.map((label) => _readString(label as Map<String, dynamic>, 'name'))
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw const FormatException(
|
||||
'Missing repository_url in GitHub search result item.',
|
||||
);
|
||||
}
|
||||
return GitHubIssueSummary.fromRepositoryJson(
|
||||
_parseRepositorySlugFromRepositoryUrl(repositoryUrl),
|
||||
json,
|
||||
);
|
||||
}
|
||||
|
||||
static RepositorySlug _parseRepositorySlugFromRepositoryUrl(String value) {
|
||||
return parseRepositorySlugFromRepositoryUrl(value);
|
||||
}
|
||||
|
||||
factory GitHubIssueSummary.fromJson(Map<String, dynamic> 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': trackerProject,
|
||||
'number': number,
|
||||
'title': title,
|
||||
'body': body,
|
||||
'state': state,
|
||||
'url': url,
|
||||
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||
'user_login': userLogin,
|
||||
'labels': labels,
|
||||
};
|
||||
}
|
||||
|
||||
class GitHubIssueComment {
|
||||
GitHubIssueComment({
|
||||
required this.id,
|
||||
required this.body,
|
||||
required this.userLogin,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String body;
|
||||
final String userLogin;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String url;
|
||||
|
||||
factory GitHubIssueComment.fromJson(Map<String, dynamic> json) {
|
||||
return GitHubIssueComment(
|
||||
id: _readInt(json, 'id'),
|
||||
body: json['body'] as String? ?? '',
|
||||
userLogin: _readLogin(json['user']) ?? _readLogin(json['poster']) ?? '',
|
||||
createdAt: DateTime.parse(_readString(json, 'created_at')),
|
||||
updatedAt: DateTime.parse(_readString(json, 'updated_at')),
|
||||
url: _readUrl(json),
|
||||
);
|
||||
}
|
||||
|
||||
factory GitHubIssueComment.fromOpenProjectJson(
|
||||
Map<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,
|
||||
'user_login': userLogin,
|
||||
'created_at': createdAt.toUtc().toIso8601String(),
|
||||
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||
'url': url,
|
||||
};
|
||||
}
|
||||
|
||||
class IssueThread {
|
||||
IssueThread({required this.issue, required this.comments});
|
||||
|
||||
final GitHubIssueSummary issue;
|
||||
final List<GitHubIssueComment> comments;
|
||||
|
||||
bool get isOpen => issue.state == 'open';
|
||||
|
||||
GitHubIssueComment? get latestComment =>
|
||||
comments.isEmpty ? null : comments.last;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'issue': issue.toJson(),
|
||||
'comments': comments.map((comment) => comment.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
class PostedComment {
|
||||
PostedComment({required this.id, required this.url, required this.body});
|
||||
|
||||
final int id;
|
||||
final String url;
|
||||
final String body;
|
||||
}
|
||||
|
||||
int _readInt(Map<String, dynamic> json, String key, {String? fallbackKey}) {
|
||||
final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]);
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is String) {
|
||||
return int.parse(value);
|
||||
}
|
||||
throw FormatException(
|
||||
'Missing integer field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.',
|
||||
);
|
||||
}
|
||||
|
||||
String _readString(
|
||||
Map<String, dynamic> json,
|
||||
String key, {
|
||||
String? fallbackKey,
|
||||
}) {
|
||||
final value = json[key] ?? (fallbackKey == null ? null : json[fallbackKey]);
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw FormatException(
|
||||
'Missing string field "$key"${fallbackKey == null ? '' : ' or "$fallbackKey"'} in tracker payload.',
|
||||
);
|
||||
}
|
||||
|
||||
String _readUrl(Map<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']) ??
|
||||
'';
|
||||
}
|
||||
|
||||
String? _readLogin(Object? userNode) {
|
||||
if (userNode is Map<String, dynamic>) {
|
||||
final login = userNode['login']?.toString().trim();
|
||||
if (login != null && login.isNotEmpty) {
|
||||
return login;
|
||||
}
|
||||
final userName = userNode['user_name']?.toString().trim();
|
||||
if (userName != null && userName.isNotEmpty) {
|
||||
return userName;
|
||||
}
|
||||
final username = userNode['username']?.toString().trim();
|
||||
if (username != null && username.isNotEmpty) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _readLinkTitle(Object? linkNode) {
|
||||
if (linkNode is Map<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;
|
||||
}
|
||||
export 'issue_tracker/client.dart';
|
||||
export 'issue_tracker/models.dart';
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ abstract interface class ScmClient {
|
|||
|
||||
class GitHubScmClient extends _GitWorktreeScmClient {}
|
||||
|
||||
class GitLabScmClient extends _GitWorktreeScmClient {}
|
||||
|
||||
class GiteaScmClient extends _GitWorktreeScmClient {}
|
||||
|
||||
abstract class _GitWorktreeScmClient implements ScmClient {
|
||||
|
|
@ -117,5 +119,6 @@ abstract class _GitWorktreeScmClient implements ScmClient {
|
|||
|
||||
Map<ScmProvider, ScmClient> defaultScmClients() => {
|
||||
ScmProvider.github: GitHubScmClient(),
|
||||
ScmProvider.gitlab: GitLabScmClient(),
|
||||
ScmProvider.gitea: GiteaScmClient(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -441,6 +441,41 @@ projects:
|
|||
);
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load an explicit GitLab issue tracker provider
|
||||
/// Given a config whose project sets issueTracker.provider to gitlab
|
||||
/// When loading the app config from disk
|
||||
/// Then the project keeps the configured GitLab provider
|
||||
/// And the project uses GitLab polling and GitLab SCM by default
|
||||
/// ```
|
||||
test('Load an explicit GitLab issue tracker provider', () async {
|
||||
// Given a config whose project sets issueTracker.provider to gitlab.
|
||||
final tempDir = await Directory.systemTemp.createTemp('cws-gitlab-');
|
||||
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
projects:
|
||||
sample:
|
||||
issueTracker:
|
||||
provider: gitlab
|
||||
repo: group/subgroup/sample
|
||||
path: ${tempDir.path}
|
||||
''');
|
||||
|
||||
// When loading the app config from disk.
|
||||
final config = await AppConfig.load(configFile.path);
|
||||
|
||||
// Then the project keeps the configured GitLab provider.
|
||||
expect(config.projects['sample']!.provider, IssueTrackerProvider.gitlab);
|
||||
|
||||
// And the project uses GitLab polling and GitLab SCM by default.
|
||||
expect(config.projects['sample']!.scm.provider, ScmProvider.gitlab);
|
||||
expect(
|
||||
config.projects['sample']!.issueAssistant.eventSource.type,
|
||||
IssueAssistantEventSourceKind.glabPolling,
|
||||
);
|
||||
expect(config.projects['sample']!.repo, 'group/subgroup/sample');
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Load an explicit OpenProject issue tracker provider
|
||||
/// Given a config whose project sets issueTracker.provider to openproject
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ void main() {
|
|||
final stdoutText = result.stdout as String;
|
||||
expect(stdoutText, contains('Available commands:'));
|
||||
expect(stdoutText, contains('init-config'));
|
||||
expect(stdoutText, contains('--glab-command'));
|
||||
expect(stdoutText, contains('--gosmee-command'));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:code_work_spawner/code_work_spawner.dart';
|
||||
import 'package:github/github.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'test_support.dart';
|
||||
|
||||
void main() {
|
||||
/// ```gherkin
|
||||
/// Feature: Issue tracker payload mapping
|
||||
|
|
@ -48,5 +53,135 @@ void main() {
|
|||
expect(restored.requiredRepoSlug.owner, 'owner');
|
||||
expect(restored.requiredRepoSlug.name, 'sample');
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Preserve GitLab project paths without forcing owner/repo slugs
|
||||
/// Given a GitLab issue summary created from a namespaced GitLab project payload
|
||||
/// When the summary is serialized and read back through fromJson
|
||||
/// Then the original GitLab project path remains available on the deserialized summary
|
||||
/// And the deserialized summary does not invent a two-segment repository slug
|
||||
/// ```
|
||||
test('Preserve GitLab project paths without forcing owner/repo slugs', () {
|
||||
// Given a GitLab issue summary created from a namespaced GitLab project payload.
|
||||
final original = GitHubIssueSummary.fromGitLabJson(
|
||||
'group/subgroup/sample',
|
||||
<String, dynamic>{
|
||||
'iid': 12,
|
||||
'title': 'Need GitLab support',
|
||||
'description': 'Please add glab support.',
|
||||
'state': 'opened',
|
||||
'web_url':
|
||||
'https://gitlab.example.test/group/subgroup/sample/issues/12',
|
||||
'updated_at': '2026-04-04T12:00:00Z',
|
||||
'author': <String, dynamic>{'username': 'reporter'},
|
||||
'labels': <String>['enhancement'],
|
||||
},
|
||||
);
|
||||
|
||||
// When the summary is serialized and read back through fromJson.
|
||||
final restored = GitHubIssueSummary.fromJson(
|
||||
original.toJson().cast<String, dynamic>(),
|
||||
);
|
||||
|
||||
// Then the original GitLab project path remains available on the deserialized summary.
|
||||
expect(restored.trackerProject, 'group/subgroup/sample');
|
||||
|
||||
// And the deserialized summary does not invent a two-segment repository slug.
|
||||
expect(restored.repoSlug, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Feature: GitLab tracker client integration
|
||||
///
|
||||
/// As a maintainer adding GitLab issue tracking
|
||||
/// I want the tracker client to talk to glab and normalize GitLab payloads
|
||||
/// So that polling, thread loading, and comment posting work like the existing providers
|
||||
/// ```
|
||||
group('IssueTrackerClient GitLab', () {
|
||||
/// ```gherkin
|
||||
/// Scenario: Fetch GitLab issues, comments, and login through glab
|
||||
/// Given a fake glab executable with one GitLab issue and one note
|
||||
/// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment
|
||||
/// Then the GitLab issue payload is normalized into the shared issue summary model
|
||||
/// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL
|
||||
/// ```
|
||||
test('Fetch GitLab issues, comments, and login through glab', () async {
|
||||
// Given a fake glab executable with one GitLab issue and one note.
|
||||
final sandbox = await Directory.systemTemp.createTemp('cws-glab-');
|
||||
final glabScript = File(p.join(sandbox.path, 'glab'));
|
||||
final postedBodyFile = File(p.join(sandbox.path, 'posted-body.txt'));
|
||||
await writeFakeGlabScript(
|
||||
glabScript: glabScript,
|
||||
issueListResponsesByRepo: <String, List<Map<String, Object?>>>{
|
||||
'group/subgroup/sample': <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'iid': 12,
|
||||
'title': 'Need GitLab support',
|
||||
'description': 'Please add glab support.',
|
||||
'state': 'opened',
|
||||
'web_url':
|
||||
'https://gitlab.example.test/group/subgroup/sample/issues/12',
|
||||
'updated_at': '2026-04-04T12:00:00Z',
|
||||
'author': <String, Object?>{'username': 'reporter'},
|
||||
'labels': <String>['enhancement'],
|
||||
},
|
||||
],
|
||||
},
|
||||
commentResponsesByRepo: <String, Map<int, List<Map<String, Object?>>>>{
|
||||
'group/subgroup/sample': <int, List<Map<String, Object?>>>{
|
||||
12: <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'id': 501,
|
||||
'body': 'Working on it.',
|
||||
'created_at': '2026-04-04T12:05:00Z',
|
||||
'updated_at': '2026-04-04T12:06:00Z',
|
||||
'author': <String, Object?>{'username': 'maintainer'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postedBodyFile: postedBodyFile,
|
||||
viewerLogin: 'glab-user',
|
||||
);
|
||||
final client = IssueTrackerClient(glabCommand: glabScript.path);
|
||||
|
||||
// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment.
|
||||
final issues = await client.fetchUpdatedIssuesForRepos(
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
trackerProjects: const <String>['group/subgroup/sample'],
|
||||
since: null,
|
||||
);
|
||||
final thread = await client.fetchThread(
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
trackerProject: 'group/subgroup/sample',
|
||||
issue: issues.single,
|
||||
);
|
||||
final login = await client.getAuthenticatedLogin(
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
);
|
||||
final posted = await client.createIssueComment(
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
trackerProject: 'group/subgroup/sample',
|
||||
issueNumber: 12,
|
||||
body: 'Reply from glab',
|
||||
);
|
||||
|
||||
// Then the GitLab issue payload is normalized into the shared issue summary model.
|
||||
expect(client.trackerCliName(IssueTrackerProvider.gitlab), 'glab');
|
||||
expect(login, 'glab-user');
|
||||
expect(issues.single.trackerProject, 'group/subgroup/sample');
|
||||
expect(issues.single.repoSlug, isNull);
|
||||
expect(issues.single.number, 12);
|
||||
expect(issues.single.state, 'opened');
|
||||
expect(issues.single.labels, ['enhancement']);
|
||||
|
||||
// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL.
|
||||
expect(thread.comments.single.url, endsWith('/issues/12#note_501'));
|
||||
expect(await postedBodyFile.readAsString(), 'Reply from glab');
|
||||
expect(posted.id, 901);
|
||||
expect(posted.url, endsWith('/issues/12#note_901'));
|
||||
expect(posted.body, 'posted');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,6 +430,128 @@ Future<void> writeFakeTeaScript({
|
|||
await makeScriptExecutable(teaScript);
|
||||
}
|
||||
|
||||
Future<void> writeFakeGlabScript({
|
||||
required File glabScript,
|
||||
required Map<String, List<Map<String, Object?>>> issueListResponsesByRepo,
|
||||
required Map<String, Map<int, List<Map<String, Object?>>>>
|
||||
commentResponsesByRepo,
|
||||
File? glabLog,
|
||||
Map<String, Set<int>>? postCommentIssueNumbersByRepo,
|
||||
File? postedBodyFile,
|
||||
String? viewerLogin,
|
||||
bool failViewerLookup = false,
|
||||
}) async {
|
||||
final normalizedPostCommentIssueNumbersByRepo =
|
||||
postCommentIssueNumbersByRepo ??
|
||||
<String, Set<int>>{
|
||||
for (final entry in issueListResponsesByRepo.entries)
|
||||
entry.key: entry.value
|
||||
.map((issue) => issue['iid'])
|
||||
.whereType<int>()
|
||||
.toSet(),
|
||||
};
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('#!/usr/bin/env bash')
|
||||
..writeln('set -euo pipefail');
|
||||
|
||||
if (glabLog != null) {
|
||||
buffer.writeln(
|
||||
r'''printf '%s\n' "$*" >> '''
|
||||
'"${bashScriptPath(glabLog.path)}"',
|
||||
);
|
||||
}
|
||||
|
||||
buffer.writeln(r'''if [[ "$1" == "api" && "$2" == "user" ]]; then''');
|
||||
if (failViewerLookup) {
|
||||
buffer
|
||||
..writeln(r''' echo "viewer lookup failed" >&2''')
|
||||
..writeln(' exit 1')
|
||||
..writeln('fi');
|
||||
} else {
|
||||
buffer
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode({'username': viewerLogin ?? 'glab-user'}))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
|
||||
for (final entry in issueListResponsesByRepo.entries) {
|
||||
final repo = Uri.encodeComponent(entry.key);
|
||||
buffer
|
||||
..writeln(
|
||||
r'''if [[ "$1" == "api" && "$4" == "projects/'''
|
||||
'$repo'
|
||||
r'''/issues?state=opened"* ]]; then''',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(entry.value))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
|
||||
for (final repoEntry in commentResponsesByRepo.entries) {
|
||||
final repo = Uri.encodeComponent(repoEntry.key);
|
||||
for (final entry in repoEntry.value.entries) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"projects/$repo/issues/${entry.key}/notes?per_page=100" ]]; then',
|
||||
)
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(jsonEncode(entry.value))
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
}
|
||||
|
||||
for (final repoEntry in normalizedPostCommentIssueNumbersByRepo.entries) {
|
||||
final repo = Uri.encodeComponent(repoEntry.key);
|
||||
for (final issueNumber in repoEntry.value) {
|
||||
buffer
|
||||
..writeln(
|
||||
'if [[ "\$1" == "api" && "\$4" == '
|
||||
'"projects/$repo/issues/$issueNumber/notes" ]]; then',
|
||||
)
|
||||
..writeln(r''' for arg in "$@"; do''')
|
||||
..writeln(r''' :''');
|
||||
if (postedBodyFile != null) {
|
||||
buffer
|
||||
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
|
||||
..writeln(
|
||||
r''' printf '%s' "${arg#body=}" > '''
|
||||
'"${bashScriptPath(postedBodyFile.path)}"',
|
||||
)
|
||||
..writeln(r''' fi''');
|
||||
}
|
||||
buffer
|
||||
..writeln(r''' done''')
|
||||
..writeln(" cat <<'JSON'")
|
||||
..writeln(
|
||||
jsonEncode({
|
||||
'id': 901,
|
||||
'body': 'posted',
|
||||
'noteable_url':
|
||||
'https://gitlab.example.test/group/subgroup/sample/issues/$issueNumber',
|
||||
}),
|
||||
)
|
||||
..writeln('JSON')
|
||||
..writeln(' exit 0')
|
||||
..writeln('fi');
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(r'''echo "unexpected glab args: $*" >&2''')
|
||||
..writeln('exit 1');
|
||||
|
||||
await glabScript.writeAsString(buffer.toString());
|
||||
await makeScriptExecutable(glabScript);
|
||||
}
|
||||
|
||||
Future<void> writeResponderScript(
|
||||
File responderScript,
|
||||
Map<String, Object?> response, {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,59 @@ void main() {
|
|||
expect(client.createdWorkspaceRoot, '/tmp/worktrees');
|
||||
expect(client.disposedWorkspacePath, '/tmp/workspaces/sample');
|
||||
});
|
||||
|
||||
/// ```gherkin
|
||||
/// Scenario: Route project workspaces through the GitLab SCM client
|
||||
/// Given a workspace manager with a fake GitLab SCM client
|
||||
/// And a project config that selects GitLab as its SCM provider
|
||||
/// When creating and disposing an ephemeral workspace for that project
|
||||
/// Then the workspace manager delegates both operations to the GitLab SCM client
|
||||
/// ```
|
||||
test('Route project workspaces through the GitLab SCM client', () async {
|
||||
// Given a workspace manager with a fake GitLab SCM client.
|
||||
final client = _RecordingScmClient();
|
||||
final manager = WorkspaceManager(
|
||||
worktreeRoot: '/tmp/worktrees',
|
||||
clients: {ScmProvider.gitlab: client},
|
||||
);
|
||||
|
||||
// And a project config that selects GitLab as its SCM provider.
|
||||
final project = ProjectConfig(
|
||||
key: 'sample',
|
||||
provider: IssueTrackerProvider.gitlab,
|
||||
scm: const ScmConfig(provider: ScmProvider.gitlab),
|
||||
repo: 'group/subgroup/sample',
|
||||
repoSlug: null,
|
||||
path: '/tmp/source-repo',
|
||||
defaultBranch: 'main',
|
||||
sessionPrefix: 'sam',
|
||||
issueAssistant: IssueAssistantConfig(
|
||||
enabled: true,
|
||||
eventSource: const PollingIssueEventSourceConfig(
|
||||
type: IssueAssistantEventSourceKind.glabPolling,
|
||||
pollInterval: Duration(seconds: 30),
|
||||
),
|
||||
maxConcurrentIssues: 3,
|
||||
mentionTriggers: const ['@helper'],
|
||||
prompt: defaultIssueAssistantPrompt,
|
||||
responders: const <CliResponderConfig>[],
|
||||
capabilities: {AssistantCapability.planning},
|
||||
notifications: const <NotificationConfig>[],
|
||||
commentTag: 'code-work-spawner',
|
||||
commentPrefixTemplate: defaultCommentPrefixTemplate,
|
||||
commentFooterTemplate: defaultCommentFooterTemplate,
|
||||
),
|
||||
);
|
||||
|
||||
// When creating and disposing an ephemeral workspace for that project.
|
||||
final workspacePath = await manager.createEphemeralWorkspace(project);
|
||||
await manager.disposeEphemeralWorkspace(project, workspacePath);
|
||||
|
||||
// Then the workspace manager delegates both operations to the GitLab SCM client.
|
||||
expect(client.createdProjectPath, '/tmp/source-repo');
|
||||
expect(client.createdWorkspaceRoot, '/tmp/worktrees');
|
||||
expect(client.disposedWorkspacePath, '/tmp/workspaces/sample');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue