feat: add GitLab tracker and SCM support (#35)

Add glab-backed GitLab issue polling, login lookup, and comment posting so GitLab projects can be handled alongside GitHub, Gitea, and OpenProject. Also wire GitLab through SCM selection, config defaults, docs, and examples so issue #35 can be configured end to end.
This commit is contained in:
insleker 2026-04-10 01:59:34 +08:00
parent df54c8ec5f
commit d18bbaed88
17 changed files with 1569 additions and 978 deletions

View File

@ -2,7 +2,7 @@
Repository issue thread assistant with an Agent Orchestrator style YAML config. Repository issue thread assistant with an Agent Orchestrator style YAML config.
It consumes issue tracker events for configured GitHub, 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 projects, reads issue threads, and uses CLI responders such as `codex`, `opencode`, or
`copilot-cli` to decide when to reply. It supports `copilot-cli` to decide when to reply. It supports
multi-repo config, responder fallback chains, and optional Discord webhook and multi-repo config, responder fallback chains, and optional Discord webhook and
@ -12,7 +12,7 @@ independently.
## Highlights ## 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 - Trigger replies from mentions or meaningful thread updates
- Run planning and bug-verification flows in isolated worktrees - Run planning and bug-verification flows in isolated worktrees
- Send optional Discord or desktop notifications for assistant events - Send optional Discord or desktop notifications for assistant events
@ -20,7 +20,7 @@ independently.
## Requirements ## Requirements
- Dart SDK - Dart SDK
- `gh`, `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 - Local checkouts for configured repositories
- Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli` - Optional local responder CLIs such as `codex`, `opencode`, or `copilot-cli`
@ -28,7 +28,9 @@ independently.
Create a local `agent-orchestrator.yaml` and use Create a local `agent-orchestrator.yaml` and use
[`examples/README.md`](examples/README.md) plus [`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 Environment placeholders such as `${CWS_DISCORD_WEBHOOK}` are resolved from the
process environment and from a `.env` file placed next to the selected config 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 \ --config /path/to/agent-orchestrator.yaml \
--db /path/to/state.sqlite3 \ --db /path/to/state.sqlite3 \
--gh-command /usr/bin/gh \ --gh-command /usr/bin/gh \
--glab-command /usr/bin/glab \
--tea-command /usr/bin/tea --tea-command /usr/bin/tea
``` ```

View File

@ -43,6 +43,11 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
help: 'Path to the gh executable.', help: 'Path to the gh executable.',
defaultsTo: 'gh', defaultsTo: 'gh',
) )
..addOption(
'glab-command',
help: 'Path to the glab executable.',
defaultsTo: 'glab',
)
..addOption( ..addOption(
'tea-command', 'tea-command',
help: 'Path to the tea executable.', help: 'Path to the tea executable.',
@ -99,6 +104,7 @@ class _CodeWorkSpawnerCommandRunner extends CommandRunner<void> {
config: config, config: config,
databasePath: topLevelResults['db'] as String, databasePath: topLevelResults['db'] as String,
ghCommand: topLevelResults['gh-command'] as String, ghCommand: topLevelResults['gh-command'] as String,
glabCommand: topLevelResults['glab-command'] as String,
gosmeeCommand: topLevelResults['gosmee-command'] as String, gosmeeCommand: topLevelResults['gosmee-command'] as String,
teaCommand: topLevelResults['tea-command'] as String, teaCommand: topLevelResults['tea-command'] as String,
); );

View File

@ -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 the issue tracker integration, while `scm.provider` selects how temporary repo
workspaces are prepared for responders. Legacy top-level project `provider` is workspaces are prepared for responders. Legacy top-level project `provider` is
still accepted during load for backward compatibility. When `scm` is omitted 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 GitHub projects default to `gh/polling`, GitLab projects default to
`tea/polling`. OpenProject projects default to `op/polling` and currently only `glab/polling`, and Gitea projects default to `tea/polling`. OpenProject
support polling. Because OpenProject issue tracking is not tied to a repository projects default to `op/polling` and currently only support polling. GitLab and
host, OpenProject projects must set `scm.provider` explicitly. GitHub projects OpenProject currently support polling only. Because OpenProject issue tracking
can switch their long-running runtime to is not tied to a repository host, OpenProject projects must set
`scm.provider` explicitly. GitHub projects can switch their long-running runtime to
`gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to `gh/gosmee` or `gh/webhook-forward`, and Gitea projects can switch to
`tea/gosmee`. `tea/gosmee`.
For OpenProject, `repo` may be either a numeric project id or an exact project For OpenProject, `repo` may be either a numeric project id or an exact project
@ -68,6 +69,7 @@ Currently supported fields are:
- `avatarUrl` - `avatarUrl`
Start from [`examples/simple-github.yaml`](simple-github.yaml) for GitHub, 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-gitea.yaml`](simple-gitea.yaml) for Gitea, or
[`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject. [`examples/simple-openproject.yaml`](simple-openproject.yaml) for OpenProject.
This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses This repo uses `dataDir`, `worktreeDir`, and `projects`, and uses

View File

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

View File

@ -206,6 +206,9 @@ class ProjectConfig {
document.issueTracker?.provider ?? document.issueTracker?.provider ??
document.provider ?? document.provider ??
IssueTrackerProvider.github; IssueTrackerProvider.github;
if (provider == IssueTrackerProvider.gitlab) {
_validateGitLabProjectPath(repo, field: 'projects.$key.repo');
}
final fallbackScmProvider = _defaultScmProviderForTracker(provider); final fallbackScmProvider = _defaultScmProviderForTracker(provider);
if (document.scm?.provider == null && fallbackScmProvider == null) { if (document.scm?.provider == null && fallbackScmProvider == null) {
throw FormatException( throw FormatException(
@ -227,6 +230,8 @@ class ProjectConfig {
type: switch (provider) { type: switch (provider) {
IssueTrackerProvider.github => IssueTrackerProvider.github =>
IssueAssistantEventSourceKind.ghPolling, IssueAssistantEventSourceKind.ghPolling,
IssueTrackerProvider.gitlab =>
IssueAssistantEventSourceKind.glabPolling,
IssueTrackerProvider.gitea => IssueTrackerProvider.gitea =>
IssueAssistantEventSourceKind.teaPolling, IssueAssistantEventSourceKind.teaPolling,
IssueTrackerProvider.openproject => IssueTrackerProvider.openproject =>
@ -246,6 +251,13 @@ class ProjectConfig {
'requires issueTracker.provider: github.', '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: case IssueAssistantEventSourceKind.teaPolling:
if (provider != IssueTrackerProvider.gitea) { if (provider != IssueTrackerProvider.gitea) {
throw FormatException( throw FormatException(
@ -284,6 +296,7 @@ class ProjectConfig {
repoSlug: switch (provider) { repoSlug: switch (provider) {
IssueTrackerProvider.github || IssueTrackerProvider.gitea => IssueTrackerProvider.github || IssueTrackerProvider.gitea =>
_parseRepositorySlug(repo, field: 'projects.$key.repo'), _parseRepositorySlug(repo, field: 'projects.$key.repo'),
IssueTrackerProvider.gitlab => null,
IssueTrackerProvider.openproject => null, IssueTrackerProvider.openproject => null,
}, },
path: _expandHome(path), path: _expandHome(path),
@ -315,6 +328,19 @@ class ProjectConfig {
} }
return RepositorySlug(parts[0], parts[1]); 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 { class ScmConfig {
@ -631,6 +657,7 @@ class _IssueAssistantConfigResolver {
String _eventSourceConfigValue(IssueAssistantEventSourceKind value) { String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => 'gh/polling', IssueAssistantEventSourceKind.ghPolling => 'gh/polling',
IssueAssistantEventSourceKind.glabPolling => 'glab/polling',
IssueAssistantEventSourceKind.teaPolling => 'tea/polling', IssueAssistantEventSourceKind.teaPolling => 'tea/polling',
IssueAssistantEventSourceKind.opPolling => 'op/polling', IssueAssistantEventSourceKind.opPolling => 'op/polling',
IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee', IssueAssistantEventSourceKind.ghGosmee => 'gh/gosmee',
@ -642,6 +669,7 @@ String _eventSourceConfigValue(IssueAssistantEventSourceKind value) {
bool _isPollingEventSource(IssueAssistantEventSourceKind value) { bool _isPollingEventSource(IssueAssistantEventSourceKind value) {
return switch (value) { return switch (value) {
IssueAssistantEventSourceKind.ghPolling => true, IssueAssistantEventSourceKind.ghPolling => true,
IssueAssistantEventSourceKind.glabPolling => true,
IssueAssistantEventSourceKind.teaPolling => true, IssueAssistantEventSourceKind.teaPolling => true,
IssueAssistantEventSourceKind.opPolling => true, IssueAssistantEventSourceKind.opPolling => true,
IssueAssistantEventSourceKind.ghGosmee => false, IssueAssistantEventSourceKind.ghGosmee => false,
@ -885,6 +913,7 @@ void _assertCamelCaseConfigKeys(Map<String, dynamic> root) {
ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) { ScmProvider? _defaultScmProviderForTracker(IssueTrackerProvider provider) {
return switch (provider) { return switch (provider) {
IssueTrackerProvider.github => ScmProvider.github, IssueTrackerProvider.github => ScmProvider.github,
IssueTrackerProvider.gitlab => ScmProvider.gitlab,
IssueTrackerProvider.gitea => ScmProvider.gitea, IssueTrackerProvider.gitea => ScmProvider.gitea,
IssueTrackerProvider.openproject => null, IssueTrackerProvider.openproject => null,
}; };
@ -979,12 +1008,13 @@ void _normalizeLegacyEventSourceConfig(Map<String, dynamic> root) {
} }
normalizeIssueAssistant( normalizeIssueAssistant(
issueAssistant, issueAssistant,
defaultType: defaultType: switch ((project['issueTracker']
((project['issueTracker'] as Map<String, dynamic>?)?['provider'] as Map<String, dynamic>?)?['provider']
as String?) == as String?) {
'gitea' 'gitlab' => IssueAssistantEventSourceKind.glabPolling,
? IssueAssistantEventSourceKind.teaPolling 'gitea' => IssueAssistantEventSourceKind.teaPolling,
: IssueAssistantEventSourceKind.ghPolling, _ => IssueAssistantEventSourceKind.ghPolling,
},
); );
} }
} }

View File

@ -250,6 +250,8 @@ enum AssistantCapability { planning, bugVerification }
enum IssueAssistantEventSourceKind { enum IssueAssistantEventSourceKind {
@JsonValue('gh/polling') @JsonValue('gh/polling')
ghPolling, ghPolling,
@JsonValue('glab/polling')
glabPolling,
@JsonValue('tea/polling') @JsonValue('tea/polling')
teaPolling, teaPolling,
@JsonValue('op/polling') @JsonValue('op/polling')
@ -270,7 +272,7 @@ enum IssueAssistantReconciliationKind {
} }
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)
enum IssueTrackerProvider { github, gitea, openproject } enum IssueTrackerProvider { github, gitlab, gitea, openproject }
@JsonEnum(fieldRename: FieldRename.snake) @JsonEnum(fieldRename: FieldRename.snake)
enum ScmProvider { github, gitea, gitlab } enum ScmProvider { github, gitea, gitlab }

View File

@ -42,12 +42,14 @@ class IssueAssistantApp {
required AppConfig config, required AppConfig config,
required String databasePath, required String databasePath,
String ghCommand = 'gh', String ghCommand = 'gh',
String glabCommand = 'glab',
String gosmeeCommand = 'gosmee', String gosmeeCommand = 'gosmee',
String teaCommand = 'tea', String teaCommand = 'tea',
}) async { }) async {
final database = AppDatabase.open(databasePath); final database = AppDatabase.open(databasePath);
final issueTrackerClient = IssueTrackerClient( final issueTrackerClient = IssueTrackerClient(
ghCommand: ghCommand, ghCommand: ghCommand,
glabCommand: glabCommand,
teaCommand: teaCommand, teaCommand: teaCommand,
); );
final pollingIssueEventSource = PollingIssueEventSource( final pollingIssueEventSource = PollingIssueEventSource(
@ -62,6 +64,7 @@ class IssueAssistantApp {
issueEventSource: RoutedIssueEventSource( issueEventSource: RoutedIssueEventSource(
sources: { sources: {
IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.ghPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.glabPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.teaPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource, IssueAssistantEventSourceKind.opPolling: pollingIssueEventSource,
IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource( IssueAssistantEventSourceKind.ghGosmee: GitHubGosmeeIssueEventSource(

View File

@ -109,6 +109,8 @@ class RoutedIssueEventSource implements IssueEventSource {
switch (project.issueAssistant.eventSource.type) { switch (project.issueAssistant.eventSource.type) {
case IssueAssistantEventSourceKind.ghPolling: case IssueAssistantEventSourceKind.ghPolling:
yield IssueAssistantEventSourceKind.ghPolling; yield IssueAssistantEventSourceKind.ghPolling;
case IssueAssistantEventSourceKind.glabPolling:
yield IssueAssistantEventSourceKind.glabPolling;
case IssueAssistantEventSourceKind.teaPolling: case IssueAssistantEventSourceKind.teaPolling:
yield IssueAssistantEventSourceKind.teaPolling; yield IssueAssistantEventSourceKind.teaPolling;
case IssueAssistantEventSourceKind.opPolling: case IssueAssistantEventSourceKind.opPolling:

View File

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

View File

@ -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]);
}

View File

@ -1,961 +1,2 @@
import 'dart:convert'; export 'issue_tracker/client.dart';
import 'dart:io'; export 'issue_tracker/models.dart';
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;
}

View File

@ -17,6 +17,8 @@ abstract interface class ScmClient {
class GitHubScmClient extends _GitWorktreeScmClient {} class GitHubScmClient extends _GitWorktreeScmClient {}
class GitLabScmClient extends _GitWorktreeScmClient {}
class GiteaScmClient extends _GitWorktreeScmClient {} class GiteaScmClient extends _GitWorktreeScmClient {}
abstract class _GitWorktreeScmClient implements ScmClient { abstract class _GitWorktreeScmClient implements ScmClient {
@ -117,5 +119,6 @@ abstract class _GitWorktreeScmClient implements ScmClient {
Map<ScmProvider, ScmClient> defaultScmClients() => { Map<ScmProvider, ScmClient> defaultScmClients() => {
ScmProvider.github: GitHubScmClient(), ScmProvider.github: GitHubScmClient(),
ScmProvider.gitlab: GitLabScmClient(),
ScmProvider.gitea: GiteaScmClient(), ScmProvider.gitea: GiteaScmClient(),
}; };

View File

@ -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 /// ```gherkin
/// Scenario: Load an explicit OpenProject issue tracker provider /// Scenario: Load an explicit OpenProject issue tracker provider
/// Given a config whose project sets issueTracker.provider to openproject /// Given a config whose project sets issueTracker.provider to openproject

View File

@ -34,6 +34,7 @@ void main() {
final stdoutText = result.stdout as String; final stdoutText = result.stdout as String;
expect(stdoutText, contains('Available commands:')); expect(stdoutText, contains('Available commands:'));
expect(stdoutText, contains('init-config')); expect(stdoutText, contains('init-config'));
expect(stdoutText, contains('--glab-command'));
expect(stdoutText, contains('--gosmee-command')); expect(stdoutText, contains('--gosmee-command'));
}); });

View File

@ -1,7 +1,12 @@
import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart'; import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:github/github.dart'; import 'package:github/github.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'test_support.dart';
void main() { void main() {
/// ```gherkin /// ```gherkin
/// Feature: Issue tracker payload mapping /// Feature: Issue tracker payload mapping
@ -48,5 +53,135 @@ void main() {
expect(restored.requiredRepoSlug.owner, 'owner'); expect(restored.requiredRepoSlug.owner, 'owner');
expect(restored.requiredRepoSlug.name, 'sample'); 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');
});
}); });
} }

View File

@ -430,6 +430,128 @@ Future<void> writeFakeTeaScript({
await makeScriptExecutable(teaScript); 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( Future<void> writeResponderScript(
File responderScript, File responderScript,
Map<String, Object?> response, { Map<String, Object?> response, {

View File

@ -102,6 +102,59 @@ void main() {
expect(client.createdWorkspaceRoot, '/tmp/worktrees'); expect(client.createdWorkspaceRoot, '/tmp/worktrees');
expect(client.disposedWorkspacePath, '/tmp/workspaces/sample'); 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');
});
}); });
} }