Merge pull request #36 from existedinnettw/feat/35

feat: add GitLab tracker and SCM support
This commit is contained in:
existedinnettw 2026-04-10 02:32:03 +08:00 committed by GitHub
commit f1eea7abd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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.
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
```

View File

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

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

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.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,
},
);
}
}

View File

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

View File

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

View File

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

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';
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';

View File

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

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

View File

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

View File

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

View File

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

View File

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